"""
This module implements the V_SYNTH method.
"""

import numpy as np

from sklearn.decomposition import PCA
from sklearn.metrics import pairwise_distances
import scipy.spatial as sspatial

from ..base import OverSampling
from .._logger import logger

_logger = logger

__all__ = ["V_SYNTH"]


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

            @article{v_synth,
                     author = {Young,Ii, William A. and Nykl, Scott L. and
                                Weckman, Gary R. and Chelberg, David M.},
                     title = {Using Voronoi Diagrams to Improve
                                Classification Performances when Modeling
                                Imbalanced Datasets},
                     journal = {Neural Comput. Appl.},
                     issue_date = {July      2015},
                     volume = {26},
                     number = {5},
                     month = jul,
                     year = {2015},
                     issn = {0941-0643},
                     pages = {1041--1054},
                     numpages = {14},
                     url = {http://dx.doi.org/10.1007/s00521-014-1780-0},
                     doi = {10.1007/s00521-014-1780-0},
                     acmid = {2790665},
                     publisher = {Springer-Verlag},
                     address = {London, UK, UK},
                     keywords = {Data engineering, Data mining, Imbalanced
                                    datasets, Knowledge extraction,
                                    Numerical algorithms, Synthetic
                                    over-sampling},
                    }

    Notes:
        * The proposed encompassing bounding box generation is incorrect.
        * Voronoi diagram generation in high dimensional spaces is instable
    """

    categories = [OverSampling.cat_extensive, OverSampling.cat_sample_ordinary]

    def __init__(
        self, proportion=1.0, n_components=3, *, 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
            n_components (int): number of components for PCA
            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_dim": 2})
        self.check_greater_or_equal(proportion, "proportion", 0)
        self.check_greater_or_equal(n_components, "n_component", 1)
        self.check_n_jobs(n_jobs, "n_jobs")

        self.proportion = proportion
        self.n_components = n_components
        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],
            "n_components": [3],
        }
        return cls.generate_parameter_combinations(parameter_combinations, raw)

    def add_bounding_box(self, X):
        """
        Add bounding box

        Args:
            X (np.array): all training vectors

        Returns:
            np.array, np.array: the extended set of vectors and the
                                bounding box
        """
        # creating the bounding box
        mins = np.min(X, axis=0)
        maxs = np.max(X, axis=0)
        mins = mins - 0.1 * np.abs(mins)
        maxs = maxs + 0.1 * np.abs(maxs)

        dim = len(X[0])

        def random_min_maxs():
            return np.where(self.random_state.randint(0, 1, size=dim) == 0, mins, maxs)

        n_bounding_box = min([100, len(X[0])])
        bounding_box = [random_min_maxs() for _ in range(n_bounding_box)]
        X_bb = np.vstack([X, bounding_box])  # pylint: disable=invalid-name

        return X_bb, bounding_box

    def filter_vectors_too_close(self, X_pca, y_pca):  # pylint: disable=invalid-name
        """
        Determine the Voronoi-tessellation

        Args:
            X_pca (np.array): the vectors after PCA
            y_pca (np.array): the extended target labels

        Returns:
            np.array, np.array: the updated training vectors
                                and target labels
        """
        distm = pairwise_distances(X_pca)

        to_remove = []
        for idx in range(len(distm)):
            for jdx in range(idx + 1, len(distm)):
                if distm[idx, jdx] < 0.001:
                    to_remove.append(idx)

        X_pca = np.delete(X_pca, to_remove, axis=0)
        y_pca = np.delete(y_pca, to_remove)

        return X_pca, y_pca

    def generate_samples(
        self, *, voronoi, y_pca, candidate_face_generators, n_to_sample
    ):
        """
        Generate samples.

        Args:
            voronoi (obj): a Voronoi tessellation
            y_pca (np.array): the updated target labels
            candidate_face_generators (np.array): indices of the
                                                    face generators
            n_to_sample (int): the number of samples to generate

        Returns:
            np.array: the generated samples
        """
        samples = []
        for _ in range(n_to_sample):
            # randomly choosing a pair from the ridge point pairs of different
            # labels
            random_face = self.random_state.choice(candidate_face_generators)

            # extracting the vertices of the face between the points
            ridge_vertices = voronoi.ridge_vertices[random_face]
            face_vertices = voronoi.vertices[ridge_vertices]

            # creating a random vector for sampling the face (supposed to be
            # convex)
            weights = self.random_state.random_sample(size=len(face_vertices))
            weights = weights / np.sum(weights)

            # initiating a sample point on the face
            sample_point_on_face = np.sum((face_vertices.T * weights).T, axis=0)

            # finding the ridge point with the minority label
            if y_pca[voronoi.ridge_points[random_face][0]] == self.min_label:
                h_vec = voronoi.points[voronoi.ridge_points[random_face][0]]
            else:
                h_vec = voronoi.points[voronoi.ridge_points[random_face][1]]

            # generating a point between the minority ridge point and the
            # random point on the face
            samples.append(self.sample_between_points(sample_point_on_face, h_vec))
        return np.vstack(samples)

    def dimensionality_reduction(
        self, X_bb, y, bounding_box  # pylint: disable=invalid-name
    ):
        """
        Do the dimensionality reduction.

        Args:
            X_bb (np.array): the extended set of vectors
            y (np.array): the target labels
            bounding_box (np.array): the bounding box vectors

        Returns:
            np.array, np.array, int, obj: the PCA-d training vectors, the
                                    corresponding target labels, the number
                                    of components and the fitted PCA object
        """
        # applying PCA to reduce the dimensionality of the data
        n_components = np.min([X_bb.shape[1], self.n_components])
        component_mask = np.array([True] * n_components)

        while np.sum(component_mask) > 1:
            pca = PCA(n_components=n_components)
            X_pca = pca.fit_transform(X_bb)  # pylint: disable=invalid-name
            y_pca = np.hstack([y, np.repeat(-1, len(bounding_box))])

            component_mask = pca.explained_variance_ratio_ > 1e-2

            if np.sum(component_mask) == n_components:
                break

            n_components = np.sum(component_mask)

        return X_pca, y_pca, n_components, pca

    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_bb, bounding_box = self.add_bounding_box(X)  # pylint: disable=invalid-name

        X_pca, y_pca, n_components, pca = self.dimensionality_reduction(  # pylint: disable=invalid-name
            X_bb, y, bounding_box
        )

        if n_components == 1:
            return self.return_copies(
                X, y, "Voronoi tessellation in 1D is not feasible"
            )

        X_pca, y_pca = self.filter_vectors_too_close(  # pylint: disable=invalid-name
            X_pca, y_pca
        )

        # doing the Voronoi tessellation
        voronoi = sspatial.Voronoi(X_pca)

        # extracting those ridge point pairs which are candidates for
        # generating an edge between two cells of different class labels
        candidate_face_generators = []
        for idx, ridge in enumerate(voronoi.ridge_points):
            if ridge[0] < len(y) and ridge[1] < len(y) and y[ridge[0]] != y[ridge[1]]:
                candidate_face_generators.append(idx)

        # seems like this never happens
        if len(candidate_face_generators) == 0:
            return self.return_copies(X, y, "No candidate face generators found")

        # generating samples
        samples = self.generate_samples(
            voronoi=voronoi,
            y_pca=y_pca,
            candidate_face_generators=candidate_face_generators,
            n_to_sample=n_to_sample,
        )

        return (
            np.vstack([X, pca.inverse_transform(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,
            "n_components": self.n_components,
            "n_jobs": self.n_jobs,
            **OverSampling.get_params(self),
        }
