"""
This module implements the SMOTE_FRST_2T method.
"""

import numpy as np

from sklearn.preprocessing import MinMaxScaler
from sklearn.metrics import pairwise_distances

from ..base import coalesce, coalesce_dict
from ..base import OverSampling

from ._smote import SMOTE

from .._logger import logger

_logger = logger

__all__ = ["SMOTE_FRST_2T"]


class SMOTE_FRST_2T(OverSampling):
    """
    References:
        * BibTex::

            @article{smote_frst_2t,
                        title = "Fuzzy-rough imbalanced learning for the
                                    diagnosis of High Voltage Circuit
                                    Breaker maintenance: The SMOTE-FRST-2T
                                    algorithm",
                        journal = "Engineering Applications of Artificial
                        Intelligence",
                        volume = "48",
                        pages = "134 - 139",
                        year = "2016",
                        issn = "0952-1976",
                        doi = "https://doi.org/10.1016/j.engappai.2015.10.009",
                        author = "Ramentol, E. and Gondres, I. and Lajes, S.
                                    and Bello, R. and Caballero,Y. and
                                    Cornelis, C. and Herrera, F.",
                        keywords = "High Voltage Circuit Breaker (HVCB),
                                    Imbalanced learning, Fuzzy rough set
                                    theory, Resampling methods"
                        }

    Notes:
        * Unlucky setting of parameters might result 0 points added, we have
            fixed this by increasing the gamma_S threshold if the number of
            samples accepted is low.
        * Similarly, unlucky setting of parameters might result all majority
            samples turned into minority.
        * In my opinion, in the algorithm presented in the paper the
            relations are incorrect. The authors talk about accepting samples
            having POS score below a threshold, and in the algorithm in
            both places POS >= gamma is used.
    """

    categories = [
        OverSampling.cat_changes_majority,
        OverSampling.cat_noise_removal,
        OverSampling.cat_sample_ordinary,
        OverSampling.cat_application,
        OverSampling.cat_metric_learning,
    ]

    def __init__(
        self,
        n_neighbors=5,
        *,
        nn_params=None,
        ss_params=None,
        gamma_S=0.7,  # pylint: disable=invalid-name
        gamma_M=0.03,  # pylint: disable=invalid-name
        n_jobs=1,
        random_state=None,
        **_kwargs
    ):
        """
        Constructor of the sampling object

        Args:
            n_neighbors (int): number of neighbors in the SMOTE sampling
            nn_params (dict): additional parameters for nearest neighbor calculations, any
                                parameter NearestNeighbors accepts, and additionally use
                                {'metric': 'precomputed', 'metric_learning': '<method>', ...}
                                with <method> in 'ITML', 'LSML' to enable the learning of
                                the metric to be used for neighborhood calculations
            ss_params (dict): simplex sampling parameters
            gamma_S (float): threshold of synthesized samples
            gamma_M (float): threshold of majority samples
            n_jobs (int): number of parallel jobs
            random_state (int/RandomState/None): initializer of random_state,
                                                    like in sklearn
        """
        ss_params_default = {
            "n_dim": 2,
            "simplex_sampling": "random",
            "within_simplex_sampling": "random",
            "gaussian_component": None,
        }

        super().__init__(random_state=random_state)
        self.check_greater_or_equal(n_neighbors, "n_neighbors", 1)
        self.check_greater_or_equal(gamma_S, "gamma_S", 0)
        self.check_greater_or_equal(gamma_M, "gamma_M", 0)
        self.check_n_jobs(n_jobs, "n_jobs")

        self.gamma_S = gamma_S  # pylint: disable=invalid-name
        self.gamma_M = gamma_M  # pylint: disable=invalid-name
        self.n_neighbors = n_neighbors
        self.nn_params = coalesce(nn_params, {})
        self.ss_params = coalesce_dict(ss_params, ss_params_default)
        self.n_jobs = n_jobs

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

        Returns:
            list(dict): a list of meaningful parameter combinations
        """
        parameter_combinations = {
            "n_neighbors": [3, 5, 7],
            "gamma_S": [0.8, 1.0],
            "gamma_M": [0.03, 0.05, 0.1],
        }
        return cls.generate_parameter_combinations(parameter_combinations, raw)

    def pos_cache(
        self, X_0, X_1  # pylint: disable=invalid-name  # pylint: disable=invalid-name
    ):
        """
        Calculate the matrix used to derive the positive membership values

        Args:
            X_0 (np.array): one array of vectors
            X_1 (np.array): another array of vectors

        Returns:
            np.array: the matrix that can be used to calculate the
                        membership values of X_1
        """
        # because of the minmax scaling, the clipping in eq. 1 is not needed
        dist = pairwise_distances(X_0, X_1, metric="l1")
        R_xy = X_0.shape[1] - dist  # pylint: disable=invalid-name
        # taking the Lukasiewitcz T-norm
        R_xy = R_xy - X_0.shape[1] + 1.0  # pylint: disable=invalid-name
        R_xy[R_xy < 0.0] = 0.0

        pos = 1.0 - R_xy

        return pos

    def iteration(self, X, y, X_min, smote):
        """
        The function implementing the main iteration of the algorithm

        Args:
            X (np.array): the training vectors
            y (np.array): the target labels
            X_min (np.array): the minority samples
            smote (obj): the smote object used for sampling

        Returns:
            np.array, np.array: the updated majority and new minority samples
        """
        X_maj = X[y == self.maj_label]

        pos_cache = self.pos_cache(X_min, X_maj)

        # initializing some lists containing the results
        result_synth = np.zeros(shape=(0, X.shape[1]))
        result_maj = np.zeros(shape=(0, X.shape[1]))
        iteration = 0

        gammas = (self.gamma_S, self.gamma_M)

        while (len(X_min) + len(result_synth) + len(result_maj)) < len(
            X_maj
        ) and iteration < 100:
            _logger.info("%s: iteration: %d", self.__class__.__name__, iteration)

            # checking if the parameters aren't too conservative
            if len(result_synth) < iteration:
                gammas = (gammas[0] * 1.1, gammas[1])
                _logger.info(
                    "%s: gamma_S increased to %f", self.__class__.__name__, gammas[0]
                )

            X_samp = smote.sample(X, y)[0][X.shape[0] :]

            # computing POS membership values for the new samples
            pos_synth = self.pos_cache(X_min, X_samp)

            # adding samples with POS membership smaller than gamma_S to the
            # minority set
            to_add = np.where(np.min(pos_synth, axis=0) < gammas[0])[0]
            result_synth = np.vstack([result_synth, X_samp[to_add]])

            # checking the minimum POS values of the majority samples
            to_remove = np.where(np.min(pos_cache, axis=0) < gammas[1])[0]

            # if the number of majority samples with POS membership smaller
            # than gamma_M is not extreme, then changing labels, otherwise
            # decreasing gamma_M
            if len(to_remove) > (len(X_maj) - len(X_min)) / 2:
                to_remove = np.array([])
                gammas = (gammas[0], gammas[1] * 0.9)
                _logger.info(
                    "%s: gamma_M decreased to %f", self.__class__.__name__, gammas[1]
                )
            elif len(to_remove) > 0:
                result_maj = np.vstack([result_maj, X_maj[to_remove]])
                X_maj = np.delete(X_maj, to_remove, axis=0)
                pos_cache = np.delete(pos_cache, to_remove, axis=1)
                pos_cache = np.vstack(
                    [pos_cache, self.pos_cache(result_maj[-len(to_remove) :], X_maj)]
                )

            # updating pos cache
            if len(to_add) > 0:
                pos_cache = np.vstack(
                    [pos_cache, self.pos_cache(X_samp[to_add], X_maj)]
                )

            _logger.info(
                "%s: minority added: %d, majority removed: %d",
                self.__class__.__name__,
                len(to_add),
                len(to_remove),
            )
            iteration = iteration + 1

        return X_maj, np.vstack([result_synth, result_maj])

    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
        """
        if np.sum(y == self.maj_label) <= np.sum(y == self.min_label):
            return self.return_copies(X, y, "Sampling is not needed")

        # Turning the ranges to 1 speeds up the positive membership
        # calculations
        mmscaler = MinMaxScaler()
        X = mmscaler.fit_transform(X)

        X_min = X[y == self.min_label]

        # after MinMax scaling, the POS value can be calculated as follows
        # determine proportion
        diff = np.sum(y == self.maj_label) - np.sum(y == self.min_label)
        prop = np.max([1.1 / diff, 0.2])

        # executing SMOTE to generate some minority samples
        smote = SMOTE(
            proportion=prop,
            n_neighbors=self.n_neighbors,
            nn_params=self.nn_params,
            ss_params=self.ss_params,
            n_jobs=self.n_jobs,
            random_state=self._random_state_init,
        )

        # iterating until the dataset becomes balanced
        X_maj, result_min = self.iteration(X, y, X_min, smote)

        # packing the results
        X_res = np.vstack([X_maj, X_min, result_min])  # pylint: disable=invalid-name

        y_res_maj = np.repeat(self.maj_label, len(X_maj))
        y_res_min = np.repeat(self.min_label, len(X_res) - len(X_maj))
        y_res = np.hstack([y_res_maj, y_res_min])

        return mmscaler.inverse_transform(X_res), y_res

    def get_params(self, deep=False):
        """
        Returns:
            dict: the parameters of the current sampling object
        """
        return {
            "n_neighbors": self.n_neighbors,
            "nn_params": self.nn_params,
            "ss_params": self.ss_params,
            "gamma_S": self.gamma_S,
            "gamma_M": self.gamma_M,
            "n_jobs": self.n_jobs,
            **OverSampling.get_params(self),
        }
