import datetime
from pandas import DataFrame
from typing import Dict, Any, Tuple, List, Optional
from .fields import Field
from .tags import Tag
from collections import defaultdict
import six
import re
from .utils import dromedary_to_underline, underline_to_dromedary


name = "pinform"


class MeasurementNameComponent(object):

    # noinspection PyProtectedMember
    def __get__(self, instance, owner)-> str:
        if instance is None:
            raise Exception('Cannot access measurement name component without instance')
        return instance._data[self.name]

    # noinspection PyProtectedMember
    def __set__(self, instance, value):
        if instance is None:
            raise Exception('Cannot access tag without instance')
        assert value is not None, "Null value for not measurement name component : " + str(self.name)
        instance._data[self.name] = value

    def __init__(self, name: Optional[str] = None):
        super(MeasurementNameComponent, self).__init__()
        self.name = name

    def __add__(self, other):
        return str(self) + str(other)


class MeasurementMeta(type):
    # noinspection PyInitNewSignature,PyUnresolvedReferences,PyTypeChecker,SpellCheckingInspection,PyMethodParameters
    def __new__(cls, cls_name, bases, attrs: Dict[str, Any]):
        m_module = attrs.pop('__module__')
        new_attrs = {'__module__': m_module}
        class_cell = attrs.pop('__classcell__', None)
        if class_cell is not None:
            new_attrs['__classcell__'] = class_cell

        new_class = super(MeasurementMeta, cls).__new__(cls, cls_name, bases, new_attrs)

        attr_meta = attrs.pop('Meta', None)
        if not attr_meta:
            meta = getattr(new_class, 'Meta', None)
        else:
            meta = attr_meta

        measurement_name = getattr(meta, 'measurement_name', None)
        if measurement_name is None:
            measurement_name = dromedary_to_underline(cls_name)
        setattr(new_class, 'measurement_name', measurement_name)

        for field_name, field in attrs.items():
            if isinstance(field, Field):
                if field.name is None:
                    field.name = field_name
                setattr(new_class, field.name, field)
            elif isinstance(field, Tag):
                if field.name is None:
                    field.name = field_name
                setattr(new_class, field.name, field)
            elif isinstance(field, MeasurementNameComponent):
                if field.name is None:
                    field.name = field_name
                setattr(new_class, field.name, field)
            else:
                setattr(new_class, field_name, field)

        # noinspection PyUnusedLocal
        def my_custom_init(instance_self, time_point: datetime.datetime, *init_args, **init_kwargs):
            _setattr = setattr
            instance_self._data = {}  # dict.fromkeys(attrs.keys())
            instance_self.time_point = time_point
            tmp_class_dict = instance_self.__class__.__dict__
            model_field_names = [k for k in tmp_class_dict.keys() if isinstance(tmp_class_dict.get(k), Field)]
            model_tag_names = [k for k in tmp_class_dict.keys() if isinstance(tmp_class_dict.get(k), Tag)]
            model_name_components = [k for k in tmp_class_dict.keys() if isinstance(tmp_class_dict.get(k), MeasurementNameComponent)]

            model_element_names = model_field_names + model_tag_names + model_name_components

            for field_key in model_element_names:
                if field_key not in init_kwargs.keys():
                    _setattr(instance_self, field_key, None)

            for key, value in init_kwargs.items():
                if key in model_field_names:
                    _setattr(instance_self, key, value)
                elif key in model_tag_names:
                    _setattr(instance_self, key, value)
                elif key == "time_point":
                    if isinstance(value, datetime.datetime):
                        _setattr(instance_self, key, value)
                    else:
                        raise Exception("time_point given is not instance of datetime.datetime  given value:" + str(
                            value) + " type(value):" + str(type(value)))
                else:
                    raise Exception("value given in instance initialization but was not defined in model as Tag or Field. key:" + str(key) +
                                    " val:" + str(value) + " type(value):" + str(type(value)))
        new_class.__init__ = my_custom_init
        return new_class


class Measurement(six.with_metaclass(MeasurementMeta)):
    measurement_name = "Measurement"

    # noinspection PyUnusedLocal
    def __init__(self, time_point: datetime.datetime, *args, **kwargs):
        super(Measurement, self).__init__()
        field_names = []
        tag_names = []
        _setattr = setattr

        # Note: maybe ? save all timestamps as utc in database. Convert them to appropriate timezones when needed in python.
        #       By default, InfluxDB stores and returns timestamps in UTC.
        #
        # influx has timezones:
        # https://docs.influxdata.com/influxdb/v1.7/query_language/data_exploration/#the-time-zone-clause
        # I tested and it supports timezones. But there is something wrong with python client.
        # >>> If I give a datetime with timezone to python client, it still stores it in db with utc.
        #     the time is not wrong but it when u query that data point again,
        #     it does not give it with the timezone we gave it in first place.
        #
        #
        # I think it is better to save times in utc and then convert it on client side (python).
        # because when I insert a point from python influx client with timezone offset (+03:30 for example),
        # it gets saved in the database as a utc. see:
        # https://stackoverflow.com/questions/39736238/how-to-set-time-zone-in-influxdb-using-python-client
        #
        self.time_point = time_point

        fields_iter = Measurement.get_fields(type(self))
        for f_name, field in fields_iter.items():
            if not field.null and kwargs.get(f_name, None) is None:
                raise ValueError("Null value passed for non-nullable field " + str(f_name))
            field_names.append(f_name)

        tags_iter = Measurement.get_tags(type(self))
        for t_name, tag in tags_iter.items():
            if not tag.null and kwargs.get(t_name, None) is None:
                raise ValueError("Null value passed for non-nullable tag " + str(t_name))
            tag_names.append(t_name)

        if kwargs:
            for prop in tuple(kwargs):
                if prop in field_names:
                    _setattr(self, prop, kwargs[prop])
                    del kwargs[prop]
                if prop in tag_names:
                    _setattr(self, prop, kwargs[prop])
                    del kwargs[prop]

        if kwargs:
            raise TypeError("'%s' is an invalid keyword argument for this function" % list(kwargs)[0])

    @staticmethod
    def get_field_names(cls) -> List[str]:
        field_names_list = []
        type_dicts = cls.__dict__  # type(self).__dict__
        for field_name, field in type_dicts.items():
            if isinstance(field, Field):
                field_names_list.append(field.name)
        return field_names_list

    @staticmethod
    def get_tag_names(cls) -> List[str]:
        tag_names_list = []
        type_dicts = cls.__dict__
        for field_name, field in type_dicts.items():
            if isinstance(field, Tag):
                tag_names_list.append(field.name)
        return tag_names_list

    @staticmethod
    def get_fields(cls) -> Dict[str, Field]:
        fields_dict = {}
        type_dicts = cls.__dict__  # type(self).__dict__
        for field_name, field in type_dicts.items():
            if isinstance(field, Field):
                field_name = field.name
                fields_dict[field_name] = field
        return fields_dict

    def get_field_values_as_dict(self) -> Dict[str, Any]:
        fields_dict = {}
        type_dicts = type(self).__dict__
        for field_name, field in type_dicts.items():
            if isinstance(field, Field):
                field_name = field.name
                field_value = self.__getattribute__(field_name)
                fields_dict[field_name] = field_value
        return fields_dict

    def get_fields_and_field_values_as_dict(self) -> Dict[str, Tuple[Field, Any]]:
        fields_dict = {}
        type_dicts = type(self).__dict__
        for field_name, field in type_dicts.items():
            if isinstance(field, Field):
                field_name = field.name
                field_value = self.__getattribute__(field_name)
                fields_dict[field_name] = (field, field_value)
        return fields_dict

    @staticmethod
    def get_tags(cls) -> Dict[str, Tag]:
        tags_dict = {}
        type_dicts = cls.__dict__  # type(self).__dict__
        for tag_name, tag in type_dicts.items():
            if isinstance(tag, Tag):
                tag_name = tag.name if tag.name is not None else name
                tags_dict[tag_name] = tag
        return tags_dict

    def get_tag_values_as_dict(self) -> Dict[str, Any]:
        tags_dict = {}
        type_dicts = type(self).__dict__
        for tag_name, tag in type_dicts.items():
            if isinstance(tag, Tag):
                tag_name = tag.name  # tag.name if tag.name is not None else item[0]
                tag_value = self.__getattribute__(tag_name)
                tags_dict[tag_name] = tag_value
        return tags_dict

    def get_tags_and_tag_values_as_dict(self) -> Dict[str, Tuple[Tag, Optional[str]]]:
        tags_dict = {}
        type_dicts = type(self).__dict__
        for tag_name, tag in type_dicts.items():
            if isinstance(tag, Tag):
                tag_name = tag.name  # tag.name if tag.name is not None else item[0]
                tag_value = self.__getattribute__(tag_name)
                tags_dict[tag_name] = (tag, tag_value)
        return tags_dict

    def get_name_component_values_as_dict(self) -> Dict[str, Any]:
        components_dict = {}
        type_dicts = type(self).__dict__
        for component_name, component in type_dicts.items():
            if isinstance(component, MeasurementNameComponent):
                component_name = component.name  # tag.name if tag.name is not None else item[0]
                tag_value = self.__getattribute__(component_name)
                components_dict[component_name] = tag_value
        return components_dict

    @staticmethod
    def get_name(cls, name_components: Dict[str, str] = None) -> str:
        measurement_name = cls.measurement_name
        name_tags = re.findall('\((.*?)\)', measurement_name)
        for name_tag in name_tags:
            if name_components is None:
                raise Exception('Measurement name resolution needs a component named ' + name_tag + ' but null name components is provided')
            if name_tag in name_components.keys():
                measurement_name.replace('(' + name_tag + ')', name_components[name_tag])
            else:
                raise Exception('Tag with name ' + name_tag + ' not provided in name resolution tags for resolving dynamic measurement name')
        return measurement_name

    def get_measurement_name(self) -> str:
        return Measurement.get_name(type(self), name_components=self.get_name_component_values_as_dict())

    def get_cli_format(self) -> Dict[str, Any]:

        fields_iter = self.get_fields_and_field_values_as_dict()
        for f_name, (field, field_value) in fields_iter.items():
            if not field.null and field_value is None:
                raise ValueError("Null value passed for non-nullable field " + field.name)

        tags_iter = self.get_tags_and_tag_values_as_dict()
        for f_name, (tag, tag_value) in tags_iter.items():
            if not tag.null and tag_value is None:
                raise ValueError("Null value passed for non-nullable tag " + tag.name)

        components_dict = self.get_name_component_values_as_dict()
        measurement_name = Measurement.get_name(type(self), name_components=components_dict)
        return {
            "measurement": measurement_name,
            "tags": self.get_tag_values_as_dict(),
            "time": str(self.time_point),
            "fields": self.get_field_values_as_dict()
        }


class MeasurementUtils:

    @staticmethod
    def field_to_dataframe_column_name(field_name: str) -> str:
        return underline_to_dromedary(field_name)

    @staticmethod
    def dataframe_column_to_field_name(column_name: str) -> str:
        return dromedary_to_underline(column_name)

    @staticmethod
    def to_dataframe(items: List[Measurement]) -> DataFrame:
        if len(items) == 0:
            return DataFrame()
        item0 = items[0]
        type_error = "Items passed to create dataframe must be of measurement type"
        assert isinstance(item0, Measurement), type_error
        item_type = type(items[0])
        for item in items:
            if not isinstance(item, Measurement):
                raise Exception(type_error)
            if type(item) != item_type:
                raise Exception("Items passed to create dataframe must have same type")
        m_fields = Measurement.get_fields(type(item0))
        m_tags = Measurement.get_tags(type(item0))

        field_names = []
        tag_names = []
        column_names = []
        for f_name, field in m_fields.items():
            column_names.append(MeasurementUtils.field_to_dataframe_column_name(field.name))
            field_names.append(field.name)
        for t_name, tag in m_tags.items():
            column_names.append(MeasurementUtils.field_to_dataframe_column_name(tag.name))
            tag_names.append(tag.name)

        data_points = defaultdict(list)
        for item in items:
            item_fields = item.get_field_values_as_dict()
            item_tags = item.get_tag_values_as_dict()

            data_points["time_point"].append(item.time_point)
            for f_name in field_names:
                data_points[f_name].append(item_fields.get(f_name))
            for t_name in tag_names:
                data_points[t_name].append(item_tags.get(t_name))

        df_result = DataFrame.from_dict(data=data_points, orient='columns')
        df_result.set_index("time_point", drop=True, inplace=True)
        df_result.columns = [MeasurementUtils.field_to_dataframe_column_name(col) for col in df_result.columns]
        return df_result

    @staticmethod
    def from_dataframe(df: DataFrame, cls: type) -> List[Measurement]:
        assert df is not None, "Null DataFrame passed to create list of measurements"
        measurements = []
        m_fields = Measurement.get_fields(cls=cls)
        m_tags = Measurement.get_tags(cls=cls)

        field_names = []
        tag_names = []

        for field_name, f in m_fields.items():
            field_names.append(field_name)
        for tag_name, t in m_tags.items():
            tag_names.append(tag_name)

        for i in df.index:
            data_points = {}
            for f_name in field_names:
                data_points[f_name] = df.at[i, MeasurementUtils.field_to_dataframe_column_name(f_name)]
            for t_name in tag_names:
                data_points[t_name] = df.at[i, MeasurementUtils.field_to_dataframe_column_name(t_name)]
            data_points['time_point'] = i
            measurements.append(cls(**data_points))

        return measurements
