"""
This module implements the MWMOTE method.
"""
import warnings

import numpy as np

from sklearn.cluster import KMeans
from scipy.linalg import circulant

from ..base import coalesce, coalesce_dict
from ..base import NearestNeighborsWithMetricTensor
from ..base import OverSamplingSimplex
from .._logger import logger

_logger = logger

__all__ = ["MWMOTE"]


class MWMOTE(OverSamplingSimplex):
    """
    References:
        * BibTex::

            @ARTICLE{mwmote,
                        author={Barua, S. and Islam, M. M. and Yao, X. and
                                Murase, K.},
                        journal={IEEE Transactions on Knowledge and Data
                                Engineering},
                        title={MWMOTE--Majority Weighted Minority Oversampling
                                Technique for Imbalanced Data Set Learning},
                        year={2014},
                        volume={26},
                        number={2},
                        pages={405-425},
                        keywords={learning (artificial intelligence);pattern
                                    clustering;sampling methods;AUC;area under
                                    curve;ROC;receiver operating curve;G-mean;
                                    geometric mean;minority class cluster;
                                    clustering approach;weighted informative
                                    minority class samples;Euclidean distance;
                                    hard-to-learn informative minority class
                                    samples;majority class;synthetic minority
                                    class samples;synthetic oversampling
                                    methods;imbalanced learning problems;
                                    imbalanced data set learning;
                                    MWMOTE-majority weighted minority
                                    oversampling technique;Sampling methods;
                                    Noise measurement;Boosting;Simulation;
                                    Complexity theory;Interpolation;Abstracts;
                                    Imbalanced learning;undersampling;
                                    oversampling;synthetic sample generation;
                                    clustering},
                        doi={10.1109/TKDE.2012.232},
                        ISSN={1041-4347},
                        month={Feb}}

    Notes:
        * The original method was not prepared for the case of having clusters
            of 1 elements.
        * It is not clear if within the cluster of the informative minority item
            the informative minority item should be choosen again
    """

    categories = [
        OverSamplingSimplex.cat_extensive,
        OverSamplingSimplex.cat_uses_clustering,
        OverSamplingSimplex.cat_borderline,
        OverSamplingSimplex.cat_metric_learning,
    ]

    def __init__(
        self,
        proportion=1.0,
        *,
        k1=5,
        k2=5,
        k3=5,
        M=10,
        cf_th=5.0,
        cmax=10.0,
        nn_params=None,
        ss_params=None,
        n_jobs=1,
        random_state=None,
        **_kwargs
    ):
        """
        Constructor of the sampling object

        Args:
            proportion (float): proportion of the difference of n_maj and n_min
                                to sample e.g. 1.0 means that after sampling
                                the number of minority samples will be equal to
                                the number of majority samples
            k1 (int): parameter of the NearestNeighbors component
            k2 (int): parameter of the NearestNeighbors component
            k3 (int): parameter of the NearestNeighbors component
            M (int): number of clusters
            cf_th (float): cutoff threshold
            cmax (float): maximum closeness value
            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
            n_jobs (int): number of parallel jobs
            random_state (int/RandomState/None): initializer of random_state,
                                                    like in sklearn
        """
        nn_params = coalesce(nn_params, {})

        ss_params_default = {
            "n_dim": 2,
            "simplex_sampling": "random",
            "within_simplex_sampling": "random",
            "gaussian_component": None,
        }
        ss_params = coalesce_dict(ss_params, ss_params_default)

        super().__init__(**ss_params, random_state=random_state)
        self.check_greater_or_equal(proportion, "proportion", 0)
        self.check_greater_or_equal(k1, "k1", 1)
        self.check_greater_or_equal(k2, "k2", 1)
        self.check_greater_or_equal(k3, "k3", 1)
        self.check_greater_or_equal(M, "M", 1)
        self.check_greater_or_equal(cf_th, "cf_th", 0)
        self.check_greater_or_equal(cmax, "cmax", 0)
        self.check_n_jobs(n_jobs, "n_jobs")

        self.proportion = proportion
        self.params = {
            "k1": k1,
            "k2": k2,
            "k3": k3,
            "M": M,
            "cf_th": cf_th,
            "cmax": cmax,
        }
        self.nn_params = nn_params
        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 = {
            "proportion": [0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0],
            "k1": [5, 9],
            "k2": [5, 9],
            "k3": [5, 9],
            "M": [4, 10],
            "cf_th": [5.0],
            "cmax": [10.0],
        }
        return cls.generate_parameter_combinations(parameter_combinations, raw)

    def filter_minorities(self, X, y, nn_params, minority):
        """
        Filter minority samples.

        Args:
            X (np.array): all training vectors
            y (np.array): all target labels
            nn_params (dict): the nearest neighbors parameters
            minority (np.array): the minority indices

        Returns:
            np.array: the filtered minority indices
        """
        # Step 1
        n_neighbors = min([len(X), self.params["k1"] + 1])
        nnmt = NearestNeighborsWithMetricTensor(
            n_neighbors=n_neighbors, n_jobs=self.n_jobs, **(nn_params)
        )
        nnmt.fit(X)
        ind1 = nnmt.kneighbors(X, return_distance=False)

        # Step 2
        arr = [i for i in minority if np.sum(y[ind1[i][1:]] == self.min_label)]
        filtered_minority = np.array(arr)

        return filtered_minority

    def determine_border_majority(self, X, X_maj, filtered_minority, nn_params):
        """
        Determine border majority samples.

        Args:
            X (np.array): all training samples
            X_maj (np.array): all majority samples
            filtered_minority (np.array): filtered minority samples
            nn_params (dict): nearest neighbors parameters

        Returns:
            np.array: the bordering majority samples
        """
        # Step 3 - ind2 needs to be indexed by indices of the lengh of X_maj
        n_neighbors = min([len(X_maj), self.params["k2"] + 1])
        nn_maj = NearestNeighborsWithMetricTensor(
            n_neighbors=n_neighbors, n_jobs=self.n_jobs, **(nn_params)
        )
        nn_maj.fit(X_maj)
        ind2 = nn_maj.kneighbors(X[filtered_minority], return_distance=False)

        # Step 4
        border_majority = np.unique(ind2.flatten())

        return border_majority

    def determine_informative_minority(self, X_min, X_maj, border_majority, nn_params):
        """
        Determine the informative minority samples.

        Args:
            X_min (np.array): minority samples
            X_maj (np.array): majority samples
            border_majority (np.array): border majority samples
            nn_params (dict): the nearest neighbors parameters

        Returns:
            np.array: indices of informative minority samples
        """

        # Step 5 - ind3 needs to be indexed by indices of the length of X_min
        n_neighbors = min([self.params["k3"], len(X_min)])
        nn_min = NearestNeighborsWithMetricTensor(
            n_neighbors=n_neighbors, n_jobs=self.n_jobs, **(nn_params)
        )
        nn_min.fit(X_min)
        ind3 = nn_min.kneighbors(X_maj[border_majority], return_distance=False)

        # Step 6 - informative minority indexes X_min
        informative_minority = np.unique(ind3.flatten())

        return informative_minority

    def closeness_factor(self, y_vec, x_vec):
        """
        Closeness factor according to the Eq (6)

        Args:
            y_vec (np.array): training instance (border_majority)
            x_vec (np.array): training instance (informative_minority)
            cf_th (float): cutoff threshold
            cmax (float): maximum values

        Returns:
            float: closeness factor
        """
        dist = np.linalg.norm(y_vec - x_vec) / y_vec.shape[0]

        # if dist == 0:
        #    dist = 0.1

        dist = np.max([0.1, dist])

        f_val = np.min([1.0 / dist, self.params["cf_th"]])

        return f_val / self.params["cf_th"] * self.params["cmax"]

    def determine_information_weights(
        self, X_maj, X_min, border_majority, informative_minority
    ):
        """
        Determine information weights.

        Args:
            X_maj (np.array): majority samples
            X_min (np.array): minority samples
            border_majority (np.array): border majority indices
            informative minority (np.array): informative minority indices

        Returns:
            np.array: information weights
        """
        closeness_factors = np.zeros(
            shape=(border_majority.shape[0], informative_minority.shape[0])
        )

        for idx, bm_i in enumerate(border_majority):
            for jdx, im_j in enumerate(informative_minority):
                closeness_factors[idx, jdx] = self.closeness_factor(
                    X_maj[bm_i], X_min[im_j]
                )

        _logger.info("%s: computing information weights", self.__class__.__name__)
        information_weights = np.zeros(
            shape=(border_majority.shape[0], informative_minority.shape[0])
        )
        for idx in range(border_majority.shape[0]):
            norm_factor = np.sum(closeness_factors[idx, :])
            for jdx in range(informative_minority.shape[0]):
                cf_ij = closeness_factors[idx, jdx]
                information_weights[idx, jdx] = cf_ij**2 / norm_factor

        return information_weights

    def determine_selection_probabilities(
        self, *, X, y, X_min, filtered_minority, nn_params
    ):
        """
        Determine selection probabilities.

        Args:
            X (np.array): all training vectors
            y (np.array): all target labels
            X_min (np.array): minority samples
            filtered_minority (np.array): indices of the filtered
                                            minority samples
            minority (np.array): the minority indices
            nn_params (dict): the nearest neighbors parameters

        Returns:
            np.array, np.array: indices of informative minority and
                                selection probabilities
        """
        X_maj = X[y == self.maj_label]

        border_majority = self.determine_border_majority(
            X, X_maj, filtered_minority, nn_params
        )

        informative_minority = self.determine_informative_minority(
            X_min, X_maj, border_majority, nn_params
        )

        # Steps 7 - 9
        _logger.info("%s: computing closeness factors", self.__class__.__name__)

        information_weights = self.determine_information_weights(
            X_maj, X_min, border_majority, informative_minority
        )

        selection_weights = np.sum(information_weights, axis=0)
        selection_probabilities = selection_weights / np.sum(selection_weights)

        return informative_minority, selection_probabilities

    def determine_cluster_probs(
        self, informative_minority, selection_probabilities, cluster_labels
    ):
        """
        Determine cluster probabilities.

        Args:
            informative_minority (np.array): indices of informative minority
                                                sample indices
            selection_probabilities (np.array): selection probabilities
            cluster_labels (np.array): cluster labels

        Returns:
            np.array: the cluster probabilities
        """
        cluster_weights = np.zeros(shape=(np.max(cluster_labels) + 1,))
        for idx, p_idx in enumerate(informative_minority):
            cluster_weights[cluster_labels[p_idx]] += selection_probabilities[idx]

        return cluster_weights / np.sum(cluster_weights)

    def determine_within_prob(
        self, cluster, informative_minority, selection_probabilities
    ):
        """
        Determine the within cluster probabilities.

        Args:
            cluster (np.array): the cluster indices
            informative_minority (np.array): the informative minority
                                                indices
            selection_probabilities (np.array): the informative minority
                                                probabilities

        Returns:
            np.array: the within cluster probabilities
        """
        within_prob = np.zeros(shape=(len(cluster),))

        for idx, c_idx in enumerate(cluster):
            where = np.where(informative_minority == c_idx)[0]
            if len(where) > 0:
                within_prob[idx] += selection_probabilities[where[0]]

        within_prob = within_prob / np.sum(within_prob)

        return within_prob

    def sample_clusters(
        self, informative_minority, selection_probabilities, cluster_labels, n_to_sample
    ):
        """
        Sample the clusters.

        Args:
            informative_minority (np.array): indices of the informative
                                                minority
            selection_probabilities (np.array): the selection probabilities
            cluster_labels (np.array): the cluster labels
            n_to_sample (int): the number of samples to generate

        Returns:
            np.array, np.array: the selected cluster labels and counts
        """
        cluster_probs = self.determine_cluster_probs(
            informative_minority, selection_probabilities, cluster_labels
        )

        clusters_selected = self.random_state.choice(
            len(cluster_probs), n_to_sample, p=cluster_probs
        )

        cluster_unique, cluster_count = np.unique(clusters_selected, return_counts=True)

        return cluster_unique, cluster_count

    def generate_samples_in_clusters(
        self,
        *,
        X_min,
        clusters,
        cluster_labels,
        informative_minority,
        selection_probabilities,
        n_to_sample
    ):
        """
        Generate samples in clusters.

        Args:
            X_min (np.array): the minority vectors
            clusters (np.array): the clusters
            cluster_labels (np.array): the cluster labeling
            informative_minority (np.array): indices of the informative
                                                minority
            selection_probabilities (np.array): the selection probabilities
            n_to_sample (int): the number of samples to generate

        Returns:
            np.array: the generated samples
        """
        # Step 11-12
        cluster_unique, cluster_count = self.sample_clusters(
            informative_minority, selection_probabilities, cluster_labels, n_to_sample
        )

        # n_dim_original = self.n_dim
        samples = []
        for idx, cluster in enumerate(cluster_unique):
            cluster_vectors = X_min[clusters[cluster]]
            within_prob = self.determine_within_prob(
                clusters[cluster], informative_minority, selection_probabilities
            )

            # self.n_dim = np.min([self.n_dim, cluster_vectors.shape[0]])
            samples.append(
                self.sample_simplex(
                    X=cluster_vectors,
                    indices=circulant(np.arange(cluster_vectors.shape[0])),
                    n_to_sample=cluster_count[idx],
                    base_weights=within_prob,
                )
            )
            # self.n_dim = n_dim_original
        # """

        # for cluster in clusters:
        #    if len(cluster) == 1:
        #        print(X_min[cluster])
        #
        # samples=[]
        ## Step 12
        #
        # for _ in range(n_to_sample):
        #    random_index = self.random_state.choice(informative_minority,
        #                                            p=selection_probabilities)
        #    cluster_label = cluster_labels[random_index]
        #    cluster = clusters[cluster_label]
        #    random_index_in_cluster = self.random_state.choice(cluster)
        #    print(cluster_label, random_index, random_index_in_cluster)
        #    X_random = X_min[random_index]
        #    X_random_cluster = X_min[random_index_in_cluster]
        #    #samples.append(self.sample_between_points(X_random,
        #    #                                          X_random_cluster))
        #    samples.append(X_random + (X_random_cluster - X_random)\
        # *self.random_state.random_sample())

        return np.vstack(samples)

    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
        """
        n_to_sample = self.det_n_to_sample(self.proportion)
        if n_to_sample == 0:
            return self.return_copies(X, y, "Sampling is not needed")

        X_min = X[y == self.min_label]

        nn_params = {**self.nn_params}
        nn_params["metric_tensor"] = self.metric_tensor_from_nn_params(nn_params, X, y)

        filtered_minority = self.filter_minorities(
            X, y, nn_params, np.where(y == self.min_label)[0]
        )

        if len(filtered_minority) == 0:
            return self.return_copies(
                X, y, "No minority samples remaining after filtering"
            )

        (
            informative_minority,
            selection_probabilities,
        ) = self.determine_selection_probabilities(
            X=X,
            y=y,
            X_min=X_min,
            filtered_minority=filtered_minority,
            nn_params=nn_params,
        )

        # Step 10
        _logger.info("%s: do clustering", self.__class__.__name__)
        kmeans = KMeans(
            n_clusters=np.min([len(np.unique(X_min, axis=1)), self.params["M"]]),
            n_init='auto',
            random_state=self._random_state_init,
        )
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            kmeans.fit(X_min)
        # imin_labels = kmeans.labels_[informative_minority]

        clusters = [
            np.where(kmeans.labels_ == i)[0] for i in range(np.max(kmeans.labels_) + 1)
        ]

        samples = self.generate_samples_in_clusters(
            X_min=X_min,
            clusters=clusters,
            cluster_labels=kmeans.labels_,
            informative_minority=informative_minority,
            selection_probabilities=selection_probabilities,
            n_to_sample=n_to_sample,
        )

        return (
            np.vstack([X, samples]),
            np.hstack([y, np.repeat(self.min_label, len(samples))]),
        )

    def get_params(self, deep=False):
        """
        Returns:
            dict: the parameters of the current sampling object
        """
        return {
            "proportion": self.proportion,
            "k1": self.params["k1"],
            "k2": self.params["k2"],
            "k3": self.params["k3"],
            "M": self.params["M"],
            "cf_th": self.params["cf_th"],
            "cmax": self.params["cmax"],
            "nn_params": self.nn_params,
            "n_jobs": self.n_jobs,
            **OverSamplingSimplex.get_params(self),
        }
