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

import numpy as np

from sklearn.cluster import KMeans
import scipy.optimize as soptimize

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

_logger = logger

__all__ = ["CE_SMOTE"]


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

            @INPROCEEDINGS{ce_smote,
                                author={Chen, S. and Guo, G. and Chen, L.},
                                booktitle={2010 IEEE 24th International
                                            Conference on Advanced Information
                                            Networking and Applications
                                            Workshops},
                                title={A New Over-Sampling Method Based on
                                        Cluster Ensembles},
                                year={2010},
                                volume={},
                                number={},
                                pages={599-604},
                                keywords={data mining;Internet;pattern
                                            classification;pattern clustering;
                                            over sampling method;cluster
                                            ensembles;classification method;
                                            imbalanced data handling;CE-SMOTE;
                                            clustering consistency index;
                                            cluster boundary minority samples;
                                            imbalanced public data set;
                                            Mathematics;Computer science;
                                            Electronic mail;Accuracy;Nearest
                                            neighbor searches;Application
                                            software;Data mining;Conferences;
                                            Web sites;Information retrieval;
                                            classification;imbalanced data
                                            sets;cluster ensembles;
                                            over-sampling},
                                doi={10.1109/WAINA.2010.40},
                                ISSN={},
                                month={April}}
    """

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

    def __init__(
        self,
        proportion=1.0,
        *,
        h=10,
        k=5,
        nn_params=None,
        ss_params=None,
        alpha=0.5,
        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
            h (int): size of ensemble
            k (int): number of clusters/neighbors
            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
            alpha (float): [0,1] threshold to select boundary 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,
        }
        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(h, "h", 1)
        self.check_greater_or_equal(k, "k", 1)
        self.check_in_range(alpha, "alpha", [0, 1])
        self.check_n_jobs(n_jobs, "n_jobs")

        self.proportion = proportion
        self.h = h  # pylint: disable=invalid-name
        self.k = k  # pylint: disable=invalid-name
        self.nn_params = coalesce(nn_params, {})
        self.alpha = alpha
        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],
            "h": [5, 10, 15],
            "k": [3, 5, 7],
            "alpha": [0.2, 0.5, 0.8],
        }
        return cls.generate_parameter_combinations(parameter_combinations, raw)

    def do_the_clustering(self, X):
        """
        Do the clustering.

        Args:
            X (np.array): all features

        Returns:
            np.array: the labels
        """
        n_dim = X.shape[1]
        labels = []
        for _ in range(self.h):
            n_features = self.random_state.randint(
                np.max([int(n_dim / 2), 1]), n_dim + 1
            )
            features = self.random_state.choice(n_dim, n_features, replace=False)
            n_clusters = min([len(X), self.k])
            kmeans = KMeans(n_clusters=n_clusters, n_init='auto', random_state=self._random_state_init)
            with warnings.catch_warnings():
                warnings.simplefilter("ignore")
                kmeans.fit(X[:, features])
            labels.append(kmeans.labels_)

        return labels

    def cluster_matching(self, labels):
        """
        Do the cluster matching.

        Args:
            labels (list): labelings

        Returns:
            np.array: the cluster matched labeling
        """
        base_label = 0
        for idx, labeling in enumerate(labels):
            if not idx == base_label:
                cost_matrix = np.zeros(shape=(self.k, self.k))

                for jdx in range(self.k):
                    mask_j = labels[base_label] == jdx
                    for kdx in range(self.k):
                        mask_k = labeling == kdx
                        mask_jk = np.logical_and(mask_j, mask_k)
                        cost_matrix[jdx, kdx] = np.sum(mask_jk)

                # solving the assignment problem
                row_ind, _ = soptimize.linear_sum_assignment(-cost_matrix)

                # doing the relabeling
                relabeling = labeling.copy()

                for jdx in range(len(row_ind)):
                    relabeling[labeling == kdx] = jdx

                labels[idx] = relabeling

        return np.vstack(labels)

    def cluster_consistency_index(self, labels):
        """
        Computing the cluster consistency index

        Args:
            np.array: labeling

        Returns:
            np.array: the cluster consistency indices
        """
        cci = np.apply_along_axis(
            lambda x: max(set(x.tolist()), key=x.tolist().count), 0, labels
        )
        cci = np.sum(labels == cci, axis=0)
        cci = cci / self.h

        return cci

    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:
            self.return_copies(X, y, "Sampling is not needed")

        # do the clustering and labelling
        labels = self.do_the_clustering(X)

        # do the cluster matching, clustering 0 will be considered the one to
        # match the others to the problem of finding cluster matching is
        # basically the "assignment problem"
        labels = self.cluster_matching(labels)

        # compute clustering consistency index
        cci = self.cluster_consistency_index(labels)

        # determining minority boundary samples
        P_boundary = X[  # pylint: disable=invalid-name
            (y == self.min_label) & (cci <= self.alpha)
        ]

        # there might be no boundary samples
        if len(P_boundary) <= self.n_dim - 1:
            return self.return_copies(X, y, "empty boundary")

        # finding nearest neighbors of boundary samples
        n_neighbors = min([len(P_boundary), self.k])

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

        nnmt = NearestNeighborsWithMetricTensor(
            n_neighbors=n_neighbors, n_jobs=self.n_jobs, **(nn_params)
        )
        nnmt.fit(P_boundary)
        ind = nnmt.kneighbors(P_boundary, return_distance=False)

        samples = self.sample_simplex(
            X=P_boundary, indices=ind, n_to_sample=n_to_sample
        )

        # do the sampling
        # samples = []
        # for _ in range(n_to_sample):
        #    idx = self.random_state.randint(len(ind))
        #    point_a = P_boundary[idx]
        #    point_b = P_boundary[self.random_state.choice(ind[idx][1:])]
        #    samples.append(self.sample_between_points(point_a, point_b))

        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,
            "h": self.h,
            "k": self.k,
            "nn_params": self.nn_params,
            "alpha": self.alpha,
            "n_jobs": self.n_jobs,
            **OverSamplingSimplex.get_params(self),
        }
