"""
This module implements the IPADE_ID method.
"""
from dataclasses import dataclass

import numpy as np

from sklearn.metrics import roc_auc_score, accuracy_score
from sklearn.preprocessing import MinMaxScaler

from ..base import OverSampling
from ..base import mode, instantiate_obj

from .._logger import logger

_logger = logger

__all__ = ["IPADE_ID"]


@dataclass
class TrainingSet:
    """
    Represents a trining set
    """

    X: np.array
    y: np.array


class IPADE_ID(OverSampling):
    """
    References:

        * BibTex::

            @article{ipade_id,
                    title = "Addressing imbalanced classification with
                                instance generation techniques: IPADE-ID",
                    journal = "Neurocomputing",
                    volume = "126",
                    pages = "15 - 28",
                    year = "2014",
                    note = "Recent trends in Intelligent Data Analysis Online
                                Data Processing",
                    issn = "0925-2312",
                    doi = "https://doi.org/10.1016/j.neucom.2013.01.050",
                    author = "Victoria López and Isaac Triguero and Cristóbal
                                J. Carmona and Salvador García and
                                Francisco Herrera",
                    keywords = "Differential evolution, Instance generation,
                                Nearest neighbor, Decision tree, Imbalanced
                                datasets"
                    }

    Notes:

        * According to the algorithm, if the addition of a majority sample
            doesn't improve the AUC during the DE optimization process,
            the addition of no further majority points is tried.
        * In the differential evolution the multiplication by a random number
            seems have a deteriorating effect, new scaling parameter added to
            fix this.
        * It is not specified how to do the evaluation.
        * When the dt classifier is grown to full depth, each leaf contains
            only one element, and there are no independent elements left
            for validation.
    """

    categories = [
        OverSampling.cat_changes_majority,
        OverSampling.cat_memetic,
        OverSampling.cat_uses_classifier,
    ]

    def __init__(
        self,
        *,
        F=0.1,
        G=0.1,
        OT=20,
        max_it=40,
        dt_classifier=("sklearn.tree", "DecisionTreeClassifier", {"random_state": 2}),
        base_classifier=("sklearn.tree", "DecisionTreeClassifier", {"random_state": 2}),
        n_jobs=1,
        random_state=None,
        **_kwargs
    ):
        """
        Constructor of the sampling object

        Args:
            F (float): control parameter of differential evolution
            G (float): control parameter of the evolution
            OT (int): number of optimizations
            max_it (int): maximum number of iterations for DE_optimization
            dt_classifier (obj): decision tree classifier object
            base_classifier (obj): classifier object
            n_jobs (int): number of parallel jobs
            random_state (int/RandomState/None): initializer of random_state,
                                                    like in sklearn
        """
        super().__init__(random_state=random_state, checks={"min_n_min": 4})
        self.check_greater(F, "F", 0)
        self.check_greater(G, "G", 0)
        self.check_greater(OT, "OT", 0)
        self.check_greater(max_it, "max_it", 0)
        self.check_n_jobs(n_jobs, "n_jobs")

        self.de_params = {
            "F": F,
            "G": G,
            "OT": OT,
            "max_it": max_it,
            "dt_classifier": dt_classifier,
            "base_classifier": base_classifier,
        }

        self.n_jobs = n_jobs

        self.dt_classifier_obj = instantiate_obj(dt_classifier)
        self.base_classifier_obj = instantiate_obj(base_classifier)

    @classmethod
    def parameter_combinations(cls, raw=False):
        """
        Generates reasonable parameter combinations.

        Returns:
            list(dict): a list of meaningful parameter combinations
        """
        # as the OT and max_it parameters control the discovery of the feature
        # space it is enough to try sufficiently large numbers
        dt_classifiers = [
            ("sklearn.tree", "DecisionTreeClassifier", {"random_state": 2})
        ]
        base_classifiers = [
            ("sklearn.tree", "DecisionTreeClassifier", {"random_state": 2})
        ]
        parameter_combinations = {
            "F": [0.1, 0.2],
            "G": [0.1, 0.2],
            "OT": [30],
            "max_it": [40],
            "dt_classifier": dt_classifiers,
            "base_classifier": base_classifiers,
        }
        return cls.generate_parameter_combinations(parameter_combinations, raw)

    def update_element(self, *, GS_y, idx, GS_x, indices, TR_X):
        """
        Updates the GS_idx vector

        Args:
            GS_y (np.array): the y labels in the optimization set
            idx (np.array): index of the vector to update
            GS_x (np.array): the vector to update
            indices (dict): indices of the various classes
            TR_X (np.array): the original vectors

        Returns:
            np.array: the updated GS_x vector
        """
        rand_idx = self.random_state.choice(indices[GS_y[idx]], 3, replace=False)

        random = self.random_state.random_sample()
        value = (
            GS_x
            + self.de_params["G"] * random * (TR_X[rand_idx[0]] - GS_x)
            + self.de_params["F"] * (TR_X[rand_idx[1]] - TR_X[rand_idx[2]])
        )

        return value

    def DE_optimization(  # pylint: disable=invalid-name
        self,
        *,
        GS,  # pylint: disable=invalid-name
        TR,
        indices,
        for_validation
    ):
        """
        Implements the DE_optimization method of the paper.
        Roughly, for each point of GS takes 3 random samples from the same
        class and moves the actual point towards them, then
        evaluates the new configuration.

        Args:
            GS (TrainingSet): the optimized training set
            TR (TrainingSet): the original training set
            indices (dict): indices of the various class labels
            for_validation (np.array): array of indices for X used for
                                        validation

        Returns:
            TrainingSet: optimized training set
        """

        # evaluate training set
        AUC_GS = self.evaluate_ID(  # pylint: disable=invalid-name
            GS=GS,
            TR=TrainingSet(TR.X[for_validation], TR.y[for_validation]),
        )

        # optimizing the training set
        for _ in range(self.de_params["max_it"]):
            GS_hat = []  # pylint: disable=invalid-name
            # doing the differential evolution
            for idx, GS_x in enumerate(GS.X):  # pylint: disable=invalid-name
                value = self.update_element(
                    GS_y=GS.y, idx=idx, GS_x=GS_x, indices=indices, TR_X=TR.X
                )

                GS_hat.append(np.clip(value, 0.0, 1.0))

            GS_hat = np.vstack(GS_hat)  # pylint: disable=invalid-name

            # evaluating the current setting
            AUC_GS_hat = self.evaluate_ID(
                GS=TrainingSet(GS_hat, GS.y),  # pylint: disable=invalid-name
                TR=TrainingSet(TR.X[for_validation], TR.y[for_validation]),
            )

            if AUC_GS_hat > AUC_GS:
                GS = TrainingSet(GS_hat, GS.y)
                AUC_GS = AUC_GS_hat  # pylint: disable=invalid-name

        return GS

    def evaluate_ID(self, *, GS, TR):  # pylint: disable=invalid-name
        """
        Implements the evaluate_ID function of the paper.

        Args:
            GS (TrainingSet): the optimized training set
            TR (TrainingSet): the original training set

        Returns:
            float: ROC AUC score
        """

        base_classifier = self.new_base_classifier()

        base_classifier.fit(GS.X, GS.y)
        min_index = np.where(base_classifier.classes_ == self.min_label)[0][0]
        pred = base_classifier.predict_proba(TR.X)[:, min_index]

        return roc_auc_score(TR.y, pred)

    def evaluate_class(self, *, GS, TR):
        """
        Implements the classification evaluation function of the paper.

        Args:
            GS (TrainingSet): the optimized training set
            TR (TrainingSet): the original training set

        Returns:
            float: accuracy score
        """
        base_classifier = self.new_base_classifier()

        base_classifier.fit(GS.X, GS.y)
        pred = base_classifier.predict(TR.X)

        return accuracy_score(TR.y, pred)

    def new_base_classifier(self):
        """
        Instantiates a new instance of the base classifier

        Returns:
            obj: a new instance of the base classifier
        """
        class_ = self.base_classifier_obj.__class__
        return class_(**(self.base_classifier_obj.get_params()))

    def configuration_checks(
        self, y, for_validation, GS_y  # pylint: disable=invalid-name
    ):
        """
        Checks if the configuration is valid.

        Args:
            y (np.array): target labels
            for_validation (np.array): the indices of validation samples
            GS_y (np.array): the updated target labels
        """
        if len(np.unique(y[for_validation])) == 1:
            _logger.info(
                "%s: No minority samples in validation set", self.__class__.__name__
            )
            raise ValueError("No minority samples in validation set")

        if len(np.unique(GS_y)) == 1:
            _logger.info(
                "%s: No minority samples in reduced dataset", self.__class__.__name__
            )
            raise ValueError("No minority samples in reduced dataset")

    def phase_1(self, TR, indices):  # pylint: disable=invalid-name
        """
        Initialization of the method.

        Args:
            TR (TrainingSet): the original training set
            indices (dict): the indices of the various classes

        Returns:
            float, np.array, TrainingSet: the AUC score, the validation
                                        indices and the initial optimized
                                        training set
        """
        _logger.info("%s: Initialization", self.__class__.__name__)

        self.dt_classifier_obj.fit(TR.X, TR.y)
        leafs = self.dt_classifier_obj.apply(TR.X)
        unique_leafs = np.unique(leafs)

        used_in_GS = np.repeat(False, len(TR.X))  # pylint: disable=invalid-name
        for_validation = np.where(np.logical_not(used_in_GS))[0]

        # extracting mean elements of the leafs
        GS_X = []  # pylint: disable=invalid-name
        GS_y = []  # pylint: disable=invalid-name
        for leaf in unique_leafs:
            leaf_indices = np.where(leafs == leaf)[0]
            GS_X.append(np.mean(TR.X[leaf_indices], axis=0))
            GS_y.append(mode(TR.y[leaf_indices]))
            if len(leaf_indices) == 1:
                used_in_GS[leaf_indices[0]] = True

        GS = TrainingSet(  # pylint: disable=invalid-name
            np.vstack(GS_X), np.array(GS_y)
        )

        # updating the indices of the validation set excluding those used in GS
        for_validation = np.where(np.logical_not(used_in_GS))[0]

        _logger.info(
            "%s: Size of validation set %d",
            self.__class__.__name__,
            len(for_validation),
        )
        self.configuration_checks(TR.y, for_validation, GS.y)

        # DE optimization takes place
        _logger.info("%s: DE optimization", self.__class__.__name__)

        GS = self.DE_optimization(  # pylint: disable=invalid-name
            GS=GS,
            TR=TR,
            indices=indices,
            for_validation=for_validation,
        )

        # evaluate results
        AUC = self.evaluate_ID(  # pylint: disable=invalid-name
            GS=GS,
            TR=TrainingSet(TR.X[for_validation], TR.y[for_validation]),
        )

        return AUC, for_validation, GS

    def determine_target_class(self, *, TR, GS, for_validation, register_class):
        """
        Determine the target class according to the description in the paper

        Args:
            TR (TrainingSet): the original training set
            GS (TrainingSet): the optimized training set
            for_validation (np.array): labels used for validation
            register_class (dict): the status of the class

        Returns:
            int: the target class label
        """
        accuracy_class = {self.min_label: 0, self.maj_label: 0}

        less_accuracy = np.inf

        # loop in line 8
        for idx in [self.min_label, self.maj_label]:
            # condition in line 9
            if register_class[idx] == "optimizable":
                y_mask = TR.y[for_validation] == idx
                class_for_validation = for_validation[y_mask]
                accuracy_class[idx] = self.evaluate_class(
                    GS=GS,
                    TR=TrainingSet(
                        TR.X[class_for_validation], TR.y[class_for_validation]
                    ),
                )
                if accuracy_class[idx] < less_accuracy:
                    less_accuracy = accuracy_class[idx]
                    target_class = idx

        return target_class

    def generate_trial(self, *, indices, target_class, TR, GS, for_validation):
        """
        Generates a trial training set

        Args:
            indices (dict): the indices of the various classes
            target_class (int): the target class label
            TR (TrainingSet): the original training set
            GS (TrainingSet): the optimized training set
            for_validation (np.array): the indices used for validation

        Returns:
            TrainingSet, np.array: the trial training set and the updated
                                    validation indices
        """
        idx = self.random_state.choice(indices[target_class])

        GS_trial_X = np.vstack([GS.X, TR.X[idx]])  # pylint: disable=invalid-name
        GS_trial_y = np.hstack([GS.y, TR.y[idx]])  # pylint: disable=invalid-name

        # removing idx from the validation set in order to keep
        # the validation fair
        for_validation_trial = for_validation[for_validation != idx]

        return TrainingSet(GS_trial_X, GS_trial_y), for_validation_trial

    def phase_2(self, *, TR, indices, for_validation, GS, AUC):
        """
        Phase 2 of the algorithm

        Args:
            TR (TrainingSet): original training set
            indices (dict): indices of the various classes
            for_validation (np.array): the mask of the validation samples
            GS (TrainingSet): the optimized training set
            AUC (float): the AUC score

        Returns:
            TrainingSet: the optimized training set
        """
        register_class = {self.min_label: "optimizable", self.maj_label: "optimizable"}
        number_of_optimizations = {self.min_label: 0, self.maj_label: 0}

        _logger.info("%s: Starting optimization", self.__class__.__name__)

        GS_trial = None  # pylint: disable=invalid-name

        while AUC < 1.0 and (
            register_class[self.min_label] == "optimizable"
            or register_class[self.maj_label] == "optimizable"
        ):
            target_class = self.determine_target_class(
                TR=TR,
                GS=GS,
                for_validation=for_validation,
                register_class=register_class,
            )

            # conditional in line 17
            if (
                target_class == self.min_label
                and number_of_optimizations[target_class] > 0
            ):
                # this is a tricky part, because GS_trial is defined later only
                GS_trial = self.DE_optimization(  # pylint: disable=invalid-name
                    GS=GS_trial,
                    TR=TR,
                    indices=indices,
                    for_validation=for_validation,
                )
            else:
                (
                    GS_trial,  # pylint: disable=invalid-name
                    for_validation_trial,
                ) = self.generate_trial(
                    indices=indices,
                    target_class=target_class,
                    TR=TR,
                    GS=GS,
                    for_validation=for_validation,
                )

                # doing optimization
                GS_trial = self.DE_optimization(  # pylint: disable=invalid-name
                    GS=GS,
                    TR=TR,
                    indices=indices,
                    for_validation=for_validation_trial,
                )

            # line 23
            AUC_trial = self.evaluate_ID(  # pylint: disable=invalid-name
                GS=GS_trial,
                TR=TrainingSet(TR.X[for_validation_trial], TR.y[for_validation_trial]),
            )
            # conditional in line 24
            if AUC_trial > AUC:
                AUC = AUC_trial
                GS = GS_trial
                for_validation = for_validation_trial

                _logger.info(
                    "%s: Size of validation set %d",
                    self.__class__.__name__,
                    len(for_validation),
                )
                self.configuration_checks(TR.y, for_validation, GS.y)

                number_of_optimizations[target_class] = 0
            else:
                # conditional in line 29
                if (
                    target_class == self.min_label
                    and number_of_optimizations[target_class] < self.de_params["OT"]
                ):
                    number_of_optimizations[target_class] += 1
                else:
                    register_class[target_class] = "non-optimizable"

        return GS

    def sampling_algorithm(self, X, y):
        """
        Does the sample generation according to the class parameters.

        Args:
            X (np.ndarray): training set
            y (np.array): target labels

        Returns:
            (np.ndarray, np.array): the extended training set and target labels
        """
        mms = MinMaxScaler()
        X = mms.fit_transform(X)

        min_indices = np.where(y == self.min_label)[0]
        maj_indices = np.where(y == self.maj_label)[0]

        indices = {self.min_label: min_indices, self.maj_label: maj_indices}

        try:
            # Phase 1: Initialization
            AUC, for_validation, GS = self.phase_1(  # pylint: disable=invalid-name
                TrainingSet(X, y), indices
            )

            # Phase 2: Addition of new instances
            GS = self.phase_2(  # pylint: disable=invalid-name
                TR=TrainingSet(X, y),
                indices=indices,
                for_validation=for_validation,
                GS=GS,
                AUC=AUC,
            )
        except ValueError as value:
            return self.return_copies(X, y, value.args[0])

        return mms.inverse_transform(GS.X), GS.y

    def get_params(self, deep=False):
        """
        Returns:
            dict: the parameters of the current sampling object
        """
        return {
            "F": self.de_params["F"],
            "G": self.de_params["G"],
            "OT": self.de_params["OT"],
            "max_it": self.de_params["max_it"],
            "n_jobs": self.n_jobs,
            "dt_classifier": self.de_params["dt_classifier"],
            "base_classifier": self.de_params["base_classifier"],
            **OverSampling.get_params(self),
        }
