from collections import defaultdict
import string

import numpy as np
from sklearn.preprocessing import LabelEncoder

from mondobrain.core.series import MondoSeries
from mondobrain.utils.data import is_discrete


class DDTransformer:
    """
    -------------------------------------------------
    # A convenience class to access the default dict for
    #  transforming the values to the encoded values and back
    #Example Usage:
    edf, dd = encode(df)
    dd.original_to_encoded("column1", "valueA")
    #  >>  1
    dd.encoded_to_original("column1", 1)
    #  >> "valueA"
    __________________________________________________
    """

    def __init__(self, dataframe):
        """
        -------------------------------------------------
        :param output_default_dict: the default dictionary generated by this encoder
        __________________________________________________
        """
        def_dict = defaultdict(LabelEncoder)
        clean_dataframe = dataframe.apply(DDTransformer.clean_nulls)
        clean_dataframe.apply(lambda x: def_dict[x.name].fit_transform(x))
        def_dict["__columns__"].fit_transform(clean_dataframe.columns)
        self.set_column_encoders(clean_dataframe.columns)
        self._def_dict = def_dict

    def __repr__(self):
        return "DDTranslator for values of: " + str(list(self._def_dict.keys()))

    @staticmethod
    def _enumerate_letters(count):
        """
        Generates a list of unique capital letter strings accordingly
        eg. ['A', 'B', ... , 'AA', 'BB', 'CC', ...]

        :param df: number of unique letter strings to return
        :returns: list <str>: list of capital letter strings
        """

        letters_in_alphabet = 26

        class _unique_list(list):
            """
            _unique_list extends the standard list for this application
            and will only allow items not already in the list to be appended
            NOTE: this is *only* for this application, as this implementation
            will break if items are removed

            Adds a method, 'contains' which will check the internal set of already
            seen items and prevent appending to the list in the case it is already there
            """

            def __init__(self):
                self._seen = set()

            def append(self, item):
                if item not in self._seen:
                    self._seen.add(item)
                    super().append(item)  # append the item to the list

            def contains(self, item):
                return item in self._seen

        results = _unique_list()

        for i in range(count):
            column_title = string.ascii_uppercase[i % letters_in_alphabet]
            # must loop here in the instance that the column_title is in the set already
            while True:
                if results.contains(column_title):
                    column_title += string.ascii_uppercase[i % letters_in_alphabet]
                else:
                    break
            results.append(column_title)
        return results

    def set_column_encoders(self, columns):
        codes = self._enumerate_letters(columns.size)
        self._column_encoder = dict(enumerate(codes))
        self._column_decoder = {code: idx for idx, code in enumerate(codes)}

    def original_to_encoded_column(self, column_label):
        """
        -------------------------------------------------
        :param column_label: the column name for the lookup
        :return: returns the encoded column_label
        :raises: dd.original_to_encoded("wrong_column_name", 1) =>
             sklearn.exceptions.NotFittedError
        __________________________________________________
        """
        encoded_col_label = self._def_dict["__columns__"].transform([column_label])[0]
        encoded_col_label = self._column_encoder[encoded_col_label]

        return encoded_col_label

    def original_to_encoded(self, column_label, orig_value):
        """
        -------------------------------------------------
        :param column_label: the column name for the lookup
        :param orig_value: the original values
        :return: returns the encoded value mapped to that original value,
        for this column_label
        :raises: dd.original_to_encoded("wrong_column_name", 1) =>
             sklearn.exceptions.NotFittedError
        :raises: dd.original_to_encoded("petallengthincm", "999") =>
             ValueError if no 999 in original data set
        __________________________________________________
        """
        clean_orig_value = DDTransformer.clean_nulls(orig_value)
        encoded = self._def_dict[column_label].transform(clean_orig_value)

        if is_discrete(orig_value.dtype):
            encoded = encoded.astype(str)

        encoded_col_label = self.original_to_encoded_column(column_label)

        return encoded, encoded_col_label

    def encoded_to_original_column(self, column_label):
        """
        -------------------------------------------------
        :param column_label: the column name for the lookup
        :return: returns the original column_label
        :raises: dd.encoded_to_original("wrong_column_name", 1) =>
            sklearn.exceptions.NotFittedError
        __________________________________________________
        """
        decoded_col_label = self._def_dict["__columns__"].inverse_transform(
            [column_label]
        )[0]

        return decoded_col_label

    def encoded_to_original(self, column_label, encoded_value):
        """
        -------------------------------------------------
        :param column_label: the column name for the lookup
        :param encoded_value: the encoded values to get originals
        :return: returns the original value mapped to that encoded value,
        for this column_label
        :raises: dd.encoded_to_original("wrong_column_name", 1) =>
            sklearn.exceptions.NotFittedError
        :raises: dd.encoded_to_original("petallengthincm", "999") =>
            ValueError if no 999 in encoded data set
        __________________________________________________
        """
        decoded_col_label = self.encoded_to_original_column(column_label)

        decoded = self._def_dict[decoded_col_label].inverse_transform(
            encoded_value.astype(np.int)
        )

        return MondoSeries(decoded, name=decoded_col_label)

    def column_has_null_values(self, column):
        """
        -------------------------------------------------
        :param column_label: the column name for the lookup
        :return: returns True if variable is has null values, and False if it does not
        :raises: dd.variable_is_continuous("alskdjalskjdaldj") =>
            KeyError if no variable named 'alskdjalskjdaldj' in data set
        __________________________________________________
        """
        return column.isnull().any()

    def variable_is_continuous(self, column_label):
        """
        -------------------------------------------------
        :param column_label: the column name for the lookup
        :return: returns True if variable is continuous, and False if it is not
        :raises: dd.variable_is_continuous("alskdjalskjdaldj") =>
            KeyError if no variable named 'alskdjalskjdaldj' in data set
        __________________________________________________
        """
        return self._column_label_is_continuous[column_label]

    @staticmethod
    def clean_nulls(dataseries):
        """
        Function operates on a column and transforms nulls to a base type reference.
        This is required because null or NaN types are generally float distinct
        float values and will be encoded distinctly otherwise
        :param dataseries:
        :return:
        """
        clean_me = dataseries.copy()

        # these values will be used as replacement values
        # to ensure that they float to the top
        min_int = np.iinfo(np.int64).min
        min_float = np.finfo(np.float64).min
        min_str = "      "

        # define a dict with objects to replace nulls
        the_cleaner = (
            (np.object_, min_str),
            (np.int64, min_int),
            (np.float64, min_float),
        )

        # run pd.fillna replacements with each of the columns
        # sets and fill according to type
        for obj, base in the_cleaner:
            if np.issubdtype(clean_me.dtype, obj):
                clean_me.fillna(base, inplace=True)

        return clean_me
