import datetime
import numpy
import shapely.geometry
import geojson


from poobrains import app
import poobrains.helpers
import poobrains.form
import poobrains.formapp
import poobrains.auth
import poobrains.svg


def pretty_si(number):

    """
    Return a SI-postfixed string representation of a number (int or float).
    """

    postfixes = ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']

    value = number
    for postfix in postfixes:
        if value / 1000.0 < 1:
            break

        value /= 1000.0

    return "%.3f%s" % (value, postfix)

def pretty_datetime64(dt64):
    dt = dt64.item()
    return f"{dt.day}. {dt.month}. {dt.year} – {dt.hour}:{dt.minute}:{dt.second}",

__geo_lookup__ = {
    geojson.Point: shapely.geometry.Point,
    geojson.MultiPoint: shapely.geometry.MultiPoint,
    geojson.LineString: shapely.geometry.LineString,
    geojson.MultiLineString: shapely.geometry.MultiLineString,
    geojson.Polygon: shapely.geometry.Polygon,
    geojson.MultiPolygon: shapely.geometry.MultiPolygon,
    geojson.GeometryCollection: shapely.geometry.GeometryCollection,
}

def geo_from_string(value):

    data = geojson.loads(value)
    return geojson_to_shapely(data)

def geojson_to_shapely(data):

    geojson_type = type(data)

    if geojson_type == type(None):
        return data

    if geojson_type == geojson.Feature:
        return geojson_to_shapely(data.geometry) # Feature container unceremoniously dropped

    elif geojson_type == geojson.FeatureCollection:
        return geojson_to_shapely(geojson.GeometryCollection(data.features))

    else:
        shapely_type = __geo_lookup__[geojson_type]
        if geojson_type == geojson.Polygon:
            return shapely_type(data.coordinates[0], data.coordinates[1:])
        if geojson_type == geojson.MultiPolygon:
            return shapely_type([shapely.geometry.Polygon(x[0], x[1:]) for x in data.coordinates])
        if geojson_type == geojson.GeometryCollection:
            geometries = []
            for geojson_geometry in data['geometries']:
                geometries.append(geojson_to_shapely(geojson_geometry))
            return shapely_type(geometries)
        return shapely_type(data['coordinates']) # Point, MultiPoint, LineString, MultiLineString 

def geo_to_string(value):
    return geojson.dumps(value)

def cast_string_datetime(x):

    replacements = [
        ('Jan', '01'),
        ('Feb', '02'),
        ('Mar', '03'),
        ('Apr', '04'),
        ('May', '05'),
        ('Jun', '06'),
        ('Jul', '07'),
        ('Aug', '08'),
        ('Sep', '09'),
        ('Oct', '10'),
        ('Nov', '11'),
        ('Dec', '12')
    ]

    for r in replacements:
        x = x.replace(r[0], r[1])

    return numpy.datetime64(datetime.datetime.fromisoformat(x))

def data_to_dict(data):

    """
    Prepare a [Ephemeral]Dataset.data dictionary for serialization
    """

    prepro_data = {}
    for column_name, column in data.items():

        column_dtype = __dtypes__[column['dtype']]

        prepro_column = {}
        for key in column:
            if key == 'color' and not column[key] is None:
                prepro_column[key] = column[key].tuple_rgb()
            elif key != 'observations':
                prepro_column[key] = column[key]

        prepro_observations = {}
        for index, observation in column['observations'].items():

            prepro_observations[index] = column_dtype.numpy_string(observation)

        prepro_column['observations'] = prepro_observations
        prepro_data[column_name] = prepro_column

    return prepro_data

def dict_to_data(rough_data):

    """
        Preprocess a deserialized dict into the format of [Ephemeral]Dataset.data.
        Reverse operation of data_to_dict.
    """

    data = {}
    for column_name, rough_column in rough_data.items():

        column_dtype = __dtypes__[rough_column['dtype']]

        if rough_column['color'] is None:
            color = None
        else:
            color = poobrains.svg.color.Color.from_tuplergb(rough_column['color'])

        data[column_name] = {
            'title': rough_column['title'],
            'description': rough_column['description'],
            'dtype': rough_column['dtype'],
            'color': color,
            'observations': {}
        }

        if isinstance(data[column_name]['color'], tuple):
            data[column_name]['color'] = poobrains.svg.Color.from_tuplergb(data[column_name]['color'])

        for str_idx, rough_observation in rough_column['observations'].items():

            idx = int(str_idx)

            observation = column_dtype.string_numpy(rough_observation)
            data[column_name]['observations'][idx] = observation

    return data


class DTypeOwnedMultistateFieldset(poobrains.formapp.MultistateFieldset):

    def __init__(self, owner, parent, **kwargs):

        # owner is implicitly passed because this is ClassOrInstanceBound
        super().__init__(parent, **kwargs)
        self.dtype = owner


class DTypeJoinForm(DTypeOwnedMultistateFieldset):

    def build(self):

        self['join_operation'] = poobrains.form.fields.Select(
            label="Join operation",
            choices = self.dtype.join_operations,
        )

        self['join_column_other'] = poobrains.form.fields.Select(
            label="On column (other)",
            choices=self.parent.other.column_choices_of_dtype(self.dtype.join_compatible_dtypes()),
            help_text="Column *of the other dataset* that is used to identify matching rows by."
        )

        self['join_tolerance'] = poobrains.form.fields.Number(
            label='Tolerance',
            default=0,
            help_text='± this value will count as match when comparing the *on* columns'
        )

        self.join_finalize = poobrains.form.Button('submit', label='Add join')

    def process(self, submit):

        joindef = {
            'column_self': self.parent.session['join_column_self'],
            'op': self['join_operation'].value,
            'column_other': self['join_column_other'].value,
            'tolerance': self['join_tolerance'].value
        }

        joindef['description'] = f"self.{joindef['column_self']} {joindef['op']} other.{joindef['column_other']} ± {joindef['tolerance']}"

        self.parent.session['joins'].append(joindef)

        del(self.parent.session['join_column_self'])
        self.parent.state = 'merge'


class DTypeFilterForm(DTypeOwnedMultistateFieldset):

    def build(self):

        self['op'] = poobrains.form.fields.Select(label='Operation', choices=self.dtype.filter_operations)

        if issubclass(self.dtype.form_field.func, poobrains.formapp.Component): # dtype.form_field is a partial with .func being the actual class object 
            self['value'] = self.dtype.form_field(self)
        else:
            self['value'] = self.dtype.form_field(type=self.dtype.form_type)
        self['value_column'] = poobrains.form.fields.Select(label='Other column', choices=self.formapp.dataset.column_choices_of_dtype(self.dtype.dtype))
        self.filter_op = poobrains.form.Button('submit', label='Add filter')# submit button to trigger this forms .process

    def process(self, submit):

        if not self.is_submitting_fieldset(submit):
            return self.process_submitting_fieldset(submit)

        else:

            filterdef = {
                'column': self.parent['filter_params']['column'].value,
                'op': self['op'].value,
                'value': self['value'].value,
                'value_column': self['value_column'].value,
            }

            if filterdef['value_column']:
                filter_value = filterdef['value_column']
            else:
                filter_value = filterdef['value']

            filterdef['description'] =  f"{filterdef['column']} {filterdef['op']} {filter_value}" # ± {filterdef['tolerance']}"

            self.parent.session['filters'].append(filterdef)

            self.parent.state = 'start'


__dtypes__ = {}


class DType(object):

    """
        Collection of information about handling numpy dtypes.
        Holds info on form construction and validation as well
        as data transformation functions.

        Never instantiated, these classes just act as container
        to group dtype-specific actions.
    """

    @classmethod
    def join_compatible_dtypes(cls):
        types = {cls} # make sure the dtype we're coming from comes first

        for dtype_name, dtype_def in __dtypes__.items():
            if cls.match_func is dtype_def.match_func:
                types.add(dtype_name)

        return types

    def __init_subclass__(cls):

        required_properties = {
            'dtype', # string, canonical name of this dtype
            'dtype_class', # class object, the actual numpy dtype or corresponding class, used for isinstance checked.
            'form_type', # click type for form validation
            'form_field', # form field class for form building
            'join_form',
            'filter_form',
            'numpy_string', # serialization
            'string_numpy', # deserialization
            'numpy_vis', # cast to visualizable size (ex: datetime to float for plotting time series). this is a one-way operation
            'numpy_pretty', # prettification for legend etc in visualizations
            'join_operations',
            #'join_types', dtypes this dtype can be joined on
            'match_func', # function called to test whether two values match enough to satisfy a join condition
            'filter_operations',
            'filter_func',
        }
        
        actual_properties = set(dir(cls))

        if not required_properties.issubset(actual_properties):
            # we want to fall flat on our faces with this during startup, not surprisingly at runtime
            raise NotImplementedError(f"Missing required properties for DType definition {cls.__name__}: {required_properties - actual_properties}")

        __dtypes__[cls.dtype] = cls


class boolean(DType):

    dtype = 'bool'
    dtype_class = bool 

    join_form = DTypeJoinForm
    filter_form = DTypeFilterForm

    form_type = poobrains.form.types.BOOL
    form_field = poobrains.form.fields.Checkbox
    join_operations = (('==', '== (equals)'),)
    match_func = lambda a, b, joindef: a == b
    filter_operations = (('==', '== (equals)'),)
    filter_func = lambda a, b, filterdef: a == b

    def numpy_string(x):
        if x:
            return 't'
        return 'f'

    def string_numpy(x):
        if x == 't':
            return True
        return False

    def numpy_pretty(x):
        if x:
            return 'Yes'
        return 'No'
    numpy_vis = float


class int32(DType):

    dtype = 'int32'
    dtype_class = numpy.int32

    join_form = DTypeJoinForm
    filter_form = DTypeFilterForm

    form_type = poobrains.form.types.INT
    form_field = poobrains.form.fields.Number
    numpy_string = str
    string_numpy = numpy.int32
    numpy_pretty = pretty_si
    numpy_vis = float
    join_operations = (('==', '== (equals)'),)

    def match_func(a, b, joindef):

        if joindef['tolerance'] == 0:
            if a == b:
                return True
        else:
            if abs(a - b) <= joindef['tolerance']:
                return True

        return False

    filter_operations = (
        ('<', '< (smaller than)'),
        ('<=', '<= (smaller than or equal to)'),
        ('==', '== (equals)'),
        ('>=', '>= (greater than or equal to)'),
        ('>', '> (greater than)')
    )

    def filter_func(a, b, filterdef):
        if filterdef['op'] == '<':
            if a < b:
                return True
        elif filterdef['op'] == '<=':
            if a <= b:
                return True
        elif filterdef['op'] == '==':
            if a == b:
                return True
        elif filterdef['op'] == '>=':
            if a >= b:
                return True
        elif filterdef['op'] == '>':
            if a > b:
                return True

        return False


class int64(int32):

    dtype = 'int64'
    dtype_class = numpy.int64
    string_numpy = numpy.int64


class uint32(int32):

    dtype = 'uint32'
    dtype_class = numpy.uint32
    string_numpy = numpy.uint32


class uint64(int32):

    dtype = 'uint64'
    dtype_class = numpy.uint64
    string_numpy = numpy.uint64


class float32(int32):

    dtype = 'float32'
    dtype_class = numpy.float32
    form_type = poobrains.form.types.FLOAT
    string_numpy = numpy.float32


class float64(float32):

    dtype = 'float64'
    dtype_class = numpy.float64
    string_numpy = numpy.float64


class float128(float32):

    dtype = 'float128'
    dtype_class = numpy.float128
    string_numpy = numpy.float128


class datetime64(int32):

    dtype = 'datetime64'
    dtype_class = numpy.datetime64
    form_type = poobrains.form.types.DATETIME
    form_field = poobrains.form.fields.DateTime
    numpy_string = lambda x: x.item().isoformat()
    string_numpy = cast_string_datetime
    numpy_pretty = pretty_datetime64
    numpy_vis = lambda x: x.item().timestamp()
    # match_funcs taken from int32
    def match_func(a, b, joindef):
        # (a - b) is a numpy.timedelta64, .item() is its corresponding datetime.timedelta
        return abs((a - b).item().total_seconds()) <= joindef['tolerance']

    # filter function and operations taken from int32 because general
    # comparisons work the same on datetimes as on numbers


class unicode(DType):

    """
    > Inconsistency warning: This class doesn't have the same name as its
    > associated dtype string as that would mean overriding base type `str`.
    """

    dtype = 'str'
    dtype_class = str

    join_form = DTypeJoinForm
    filter_form = DTypeFilterForm

    form_type = poobrains.form.types.STRING
    form_field = poobrains.form.fields.TextArea
    numpy_string = str
    string_numpy = lambda x: x
    numpy_pretty = lambda x: x,
    numpy_vis = lambda x: sum([ord(char) for char in x]),
    join_operations = int32.join_operations

    def match_func(a, b, joindef):

        if joindef['tolerance'] == 0:
            return a == b
        return poobrains.helpers.levenshtein_distance(a, b) <= joindef['tolerance']

    filter_operations = (
        ('==', '== (equals)'),
        ('levenshtein <', 'levenshtein distance < (smaller than)'),
        ('levenshtein <=', 'levenshtein distance <= (smaller than or equal to)'),
        ('levenshtein ==', 'levenshtein distance == (equals)'),
        ('levenshtein >=', 'levenshtein distance >= (greater than or equal to)'),
        ('levenshtein >', 'levenshtein distance > (greater than)'),
        ('len <', 'length < (smaller than)'),
        ('len <=', 'length <= (smaller than or equal to)'),
        ('len ==', 'length == (equals)'),
        ('len >=', 'length >= (greater than or equal to)'),
        ('len >', 'length > (greater than)')
    )

    def filter_func(a, b, filterdef):
        
        if filterdef['op'] == '==':
            return a == b

        elif filterdef['op'] == 'levenshtein <':
            return poobrains.helpers.levenshtein_distance(a, b) < tolerance

        elif filterdef['op'] == 'levenshtein <=':
            return poobrains.helpers.levenshtein_distance(a, b) <= tolerance

        elif filterdef['op'] == 'levenshtein ==':
            return poobrains.helpers.levenshtein_distance(a, b) == tolerance

        elif filterdef['op'] == 'levenshtein >=':
            return poobrains.helpers.levenshtein_distance(a, b) >= tolerance

        elif filterdef['op'] == 'levenshtein >':
            return poobrains.helpers.levenshtein_distance(a, b) > tolerance

        elif filterdef['op'] == 'len <':
            if b != None:
                return len(a) < len(b)
            return len(a) < tolerance

        elif filterdef['op'] == 'len <=':
            if b != None:
                return len(a) <= len(b)
            return len(a) <= tolerance

        elif filterdef['op'] == 'len ==':
            if b != None:
                return len(a) == len(b)
            return len(a) == tolerance

        elif filterdef['op'] == 'len >=':
            if b != None:
                return len(a) >= len(b)
            return len(a) >= tolerance

        elif filterdef['op'] == 'len >':
            if b != None:
                return len(a) > len(b)
            return len(a) > tolerance

        return False


class GeometryFilterForm(DTypeFilterForm):

    def build(self):

        column_only_ops = (
            'equals', 
            'almost_equals', 
            'contains', 
            'crosses', 
            'disjoint', 
            'intersects', 
            'overlaps', 
            'touches', 
            'within'
        )

        self['op'] = poobrains.form.fields.Select(label='Operation', choices=self.dtype.filter_operations)

        if self.state == 'start':
            pass # op only
        elif self.state == 'op_params':

            self['op'].value = self.session['op']
            self['op'].readonly = True

            if self.session['op'] in column_only_ops:
                self['value_column'] = poobrains.form.fields.Select(label='Other column', choices=self.formapp.dataset.column_choices_of_dtype(self.dtype.dtype))

            #elif self.session['op']


class GeometryValueFieldset(DTypeOwnedMultistateFieldset):

    def build(self):

        description = '['
        if 'points' in self.session:
            description += ', '.join([f"{x} / {y}" for x, y in self.session['points']])

        description += ']'

        self['description'] = poobrains.form.fields.Message(value=description)

        if self.state == 'start':
            self.point_new = poobrains.form.Button('submit', label='New point')

        elif self.state == 'point_new':
            self['x'] = poobrains.form.fields.Number(label='X / longitude')
            self['y'] = poobrains.form.fields.Number(label='Y / langitude')
            self.point_add = poobrains.form.Button('submit', label='Add point')

    def process(self, submit):

        if submit == 'point_new':
            self.state = 'point_new'
        elif submit == 'point_add':
            if not 'points' in self.session:
                self.session['points'] = []

            self.session['points'].append((self['x'].value, self['y'].value))
            self.state = 'start'

    @property
    def value(self):
        return self.session.get('points', [])


class geometry(DType):

    dtype = 'geometry'
    dtype_class = shapely.geometry.base.BaseGeometry
    join_form = DTypeJoinForm
    filter_form = DTypeFilterForm
    #filter_form = GeometryFilterForm

    form_type = poobrains.form.types.STRING
    #form_field = poobrains.form.fields.TextArea
    form_field = GeometryValueFieldset
    numpy_string = geo_to_string
    string_numpy = geo_from_string
    numpy_pretty = None
    numpy_vis = None
    join_operations = (
        ('equals', 'Equals (shapely object.equals)'),
        ('almost_equals', 'Almost equals (shapely object.almost_equals)'),
    )

    def match_func(a, b, joindef):

        if joindef['op'] == 'equals':
            return a.equals(b)
        elif op == 'almost_equals':
            return a.almost_equals(b, tolerance)

    filter_operations = (
        ('equals', 'Equals (shapely object.equals)'),
        ('almost_equals', 'Almost equals (shapely object.almost_equals)'),
        ('contains', 'Contains (shapely object.contains)'),
        ('crosses', 'Crosses (shapely object.crosses)'),
        ('disjoint', 'Disjoint (shapely object.disjoint)'),
        ('intersects', 'Intersects (shapely object.intersects)'),
        ('overlaps', 'Overlaps (shapely object.overlaps)'),
        ('touches', 'Touches (shapely object.touches)'),
        ('within', 'Within (shapely object.within)')
    )

    def filter_func(a, b, filterdef):

        if not isinstance(b, shapely.geometry.base.BaseGeometry):
            b = shapely.geometry.Polygon(b) # cast polygon built manually through the form

        if filterdef['op'] == 'equals':
            return a.equals(b)
        if filterdef['op'] == 'almost_equals':
            return a.almost_equals(b)
        if filterdef['op'] == 'contains':
            return a.contains(b)
        if filterdef['op'] == 'crosses':
            return a.crosses(b)
        if filterdef['op'] == 'disjoint':
            return a.disjoint(b)
        if filterdef['op'] == 'intersects':
            return a.intersects(b)
        if filterdef['op'] == 'overlaps':
            return a.overlaps(b)
        if filterdef['op'] == 'touches':
            return a.touches(b)
        if filterdef['op'] == 'within':
            return a.within(b)
        return False
