"""
Classes and functions to treat column data types according to
Deirokay data types.
"""

from decimal import Decimal

from numpy import nan
from pandas import NA, NaT, Series, to_datetime


class Validator:
    """Base validation class for column data type validation.
    """

    def __init__(self, *, unique=False, nullable=True):
        self.unique = unique
        self.nullable = nullable

    def __call__(self, listlike):
        return self.treat(Series(listlike))

    def treat(self, series: Series):
        """Treat a raw Series to match data expectations for parsing
        and formatting.

        Parameters
        ----------
        series : Series
            Raw pandas Series to be treated.

        Raises
        ------
        ValueError
            Column has null values when not_null constraint was
            requested or
            column has duplicate values when unique constraint was
            requested.
        """
        if not self.nullable and any(series.isnull()):
            null_indices = list(series[series.isnull()].index)
            null_indices_limit = null_indices[:min(len(null_indices), 30)]
            raise ValueError(f"The '{series.name}' column has"
                             f" {len(null_indices)} null values,"
                             " but it shouldn't.\n"
                             "Here are the indices of some null values:\n"
                             f"{null_indices_limit}...")

        if self.unique and not series.is_unique:
            duplicated = list(series[series.duplicated(keep='first')])
            duplicated_limit = duplicated[:min(len(duplicated), 10)]

            raise ValueError(f"The '{series.name}' column values"
                             " are not unique, as requested.\n"
                             f"There are {len(duplicated)} non unique values,"
                             " and here are some of them:\n"
                             f"{duplicated_limit}...")

    @staticmethod
    def serialize(series: Series) -> dict:
        """Create a Deirokay-compatible serializable object that can
        be serialized (in JSON or YAML formats) and parsed back by
        Deirokay treaters.
        This method is useful when generating validation documents
        that embed Deirokay-compatible user data.
        The output format is a Python dict containing two keys:
        - `values`: list of values as Python object.
        - `parser`: Deirokay options to parse the data.
        (See more: `deirokay.parser.get_treater_instance`).

        Parameters
        ----------
        series : Series
            Pandas Series to be serialized.

        Returns
        -------
        dict
            A Python dict containing the keys `values` and `parser`.
        """
        raise NotImplementedError('No serializer for this treater.')


class NumericTreater(Validator):
    """Base class for numeric treaters"""

    def __init__(self, thousand_sep=None, **kwargs):
        super().__init__(**kwargs)

        self.thousand_sep = thousand_sep

    # docstr-coverage:inherited
    def treat(self, series):
        super().treat(series)
        series = self._treat_thousand_sep(series)

        return series

    def _treat_thousand_sep(self, series):
        if self.thousand_sep is not None:
            try:
                series = series.str.replace(self.thousand_sep, '', regex=False)
            except AttributeError as e:
                raise AttributeError(
                    'Make sure you are not declaring a `thousand_sep` to'
                    ' read a non-text-like column. This may happen when'
                    ' reading numeric columns from a .parquet file,'
                    ' for instance.'
                ) from e
        return series


class BooleanTreater(Validator):
    """Treater for boolean-like variables"""

    def __init__(self,
                 truthies=['true', 'True'],
                 falsies=['false', 'False'],
                 ignore_case=False,
                 default_value=None,
                 **kwargs):
        super().__init__(**kwargs)

        assert default_value in (True, False, None)

        self.ignore_case = ignore_case
        self.default_value = default_value

        if self.ignore_case:
            self.truthies = set(truthy.lower() for truthy in truthies)
            self.falsies = set(falsy.lower() for falsy in falsies)
        else:
            self.truthies = set(truthies)
            self.falsies = set(falsies)

        if self.truthies & self.falsies:
            raise ValueError('Truthies and Falsies sets should be'
                             ' disjoint sets.')

    def _evaluate(self, value):
        if value is True:
            return True
        if value is False:
            return False
        if value is None or value is nan or value is NA:
            return self.default_value

        _value = value.lower() if self.ignore_case else value
        if _value in self.truthies:
            return True
        if _value in self.falsies:
            return False
        raise ValueError(f'Unexpected boolean value: "{value}"'
                         f' ({value.__class__})\n'
                         f'Expected values: {self.truthies | self.falsies}')

    # docstr-coverage:inherited
    def treat(self, series):
        super().treat(series)
        series = series.apply(self._evaluate).astype('boolean')
        # Validate again
        super().treat(series)

        return series

    # docstr-coverage:inherited
    @staticmethod
    def serialize(series):
        def _convert(item):
            if item is NA:
                return None
            return bool(item)
        return {
            'values': [_convert(item) for item in series],
            'parser': {
                'dtype': 'boolean'
            }
        }


class FloatTreater(NumericTreater):
    """Treater for float variables"""

    def __init__(self, decimal_sep=None, **kwargs):
        super().__init__(**kwargs)

        self.decimal_sep = decimal_sep

    # docstr-coverage:inherited
    def treat(self, series):
        series = super().treat(series)
        series = self._treat_decimal_sep(series)

        return series.astype(float).astype('Float64')

    def _treat_decimal_sep(self, series):
        if self.decimal_sep is not None and self.decimal_sep != '.':
            try:
                series = series.str.replace(self.decimal_sep, '.', regex=False)
            except AttributeError as e:
                print(*e.args)
                raise AttributeError(
                    'Make sure you are not declaring a `decimal_sep` to'
                    ' read a non-text-like column. This may happen when'
                    ' reading numeric columns from a .parquet file,'
                    ' for instance.'
                )
        return series

    # docstr-coverage:inherited
    @staticmethod
    def serialize(series):
        def _convert(item):
            if item is NA:
                return None
            return float(item)
        return {
            'values': [_convert(item) for item in series],
            'parser': {
                'dtype': 'float'
            }
        }


class DecimalTreater(FloatTreater):
    """Treater for decimal variables"""

    def __init__(self, decimal_places=None, **kwargs):
        super().__init__(**kwargs)

        self.decimal_places = decimal_places

    # docstr-coverage:inherited
    def treat(self, series):
        series = NumericTreater.treat(self, series)
        series = self._treat_decimal_sep(series)
        series = series.map(lambda x: Decimal(x) if x is not None else None)
        series = self._treat_decimal_places(series)

        return series

    def _treat_decimal_places(self, series):
        if self.decimal_places is not None:
            q = Decimal(10) ** -self.decimal_places
            series = series.apply(
                lambda x: x.quantize(q) if x is not None else None
            )
        return series

    # docstr-coverage:inherited
    @staticmethod
    def serialize(series):
        def _convert(item):
            if item is None or item is NA:
                return None
            return str(item)
        return {
            'values': [_convert(item) for item in series],
            'parser': {
                'dtype': 'decimal'
            }
        }


class IntegerTreater(NumericTreater):
    """Treater for integer variables"""

    # docstr-coverage:inherited
    def treat(self, series):
        return super().treat(series).astype(float).astype('Int64')

    # docstr-coverage:inherited
    @staticmethod
    def serialize(series):
        def _convert(item):
            if item is NA:
                return None
            return int(item)
        return {
            'values': [_convert(item) for item in series],
            'parser': {
                'dtype': 'integer'
            }
        }


class DateTime64Treater(Validator):
    """Treater for datetime variables"""

    def __init__(self, format='%Y-%m-%d %H:%M:%S', **kwargs):
        super().__init__(**kwargs)

        self.format = format

    # docstr-coverage:inherited
    def treat(self, series):
        super().treat(series)

        return to_datetime(series, format=self.format)

    # docstr-coverage:inherited
    @staticmethod
    def serialize(series):
        def _convert(item):
            if item is None or item is NaT:
                return None
            return str(item)
        return {
            'values': [_convert(item) for item in series],
            'parser': {
                'dtype': 'datetime'
            }
        }


class DateTreater(DateTime64Treater):
    """Treater for date-only variables"""

    def __init__(self, format='%Y-%m-%d', **kwargs):
        super().__init__(format, **kwargs)

    # docstr-coverage:inherited
    def treat(self, series):
        return super().treat(series).dt.date

    # docstr-coverage:inherited
    @staticmethod
    def serialize(series):
        def _convert(item):
            if item is None or item is NaT:
                return None
            return str(item)
        return {
            'values': [_convert(item) for item in series],
            'parser': {
                'dtype': 'date'
            }
        }


class TimeTreater(DateTime64Treater):
    """Treater for time-only variables"""

    def __init__(self, format='%H:%M:%S', **kwargs):
        super().__init__(format, **kwargs)

    # docstr-coverage:inherited
    def treat(self, series):
        return super().treat(series).dt.time

    # docstr-coverage:inherited
    @staticmethod
    def serialize(series):
        def _convert(item):
            if item is None or item is NaT:
                return None
            return str(item)
        return {
            'values': [_convert(item) for item in series],
            'parser': {
                'dtype': 'time'
            }
        }


class StringTreater(Validator):
    """Treater for string variables"""

    def __init__(self, treat_null_as=None, **kwargs):
        super().__init__(**kwargs)

        self.treat_null_as = treat_null_as

    # docstr-coverage:inherited
    def treat(self, series):
        super().treat(series)

        if self.treat_null_as is not None:
            series = series.fillna(self.treat_null_as)

        return series

    # docstr-coverage:inherited
    @staticmethod
    def serialize(series):
        def _convert(item):
            if item is None:
                return None
            return str(item)
        return {
            'values': [_convert(item) for item in series],
            'parser': {
                'dtype': 'string'
            }
        }
