"""
This module implements the DEAGO method.
"""

import os
import random
import warnings

warnings.simplefilter("ignore", DeprecationWarning)

import numpy as np  # pylint: disable=wrong-import-position
from sklearn.preprocessing import (  # pylint: disable=wrong-import-position
    StandardScaler,
)

from ..base import (  # pylint: disable=wrong-import-position
    OverSampling,
    coalesce,
    coalesce_dict,
)
from ._smote import SMOTE  # pylint: disable=wrong-import-position

from .._logger import logger  # pylint: disable=wrong-import-position

_logger = logger

__all__ = ["DEAGO"]


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

            @INPROCEEDINGS{deago,
                            author={Bellinger, C. and Japkowicz, N. and
                                        Drummond, C.},
                            booktitle={2015 IEEE 14th International
                                        Conference on Machine Learning
                                        and Applications (ICMLA)},
                            title={Synthetic Oversampling for Advanced
                                        Radioactive Threat Detection},
                            year={2015},
                            volume={},
                            number={},
                            pages={948-953},
                            keywords={radioactive waste;advanced radioactive
                                        threat detection;gamma-ray spectral
                                        classification;industrial nuclear
                                        facilities;Health Canadas national
                                        monitoring networks;Vancouver 2010;
                                        Isotopes;Training;Monitoring;
                                        Gamma-rays;Machine learning algorithms;
                                        Security;Neural networks;machine
                                        learning;classification;class
                                        imbalance;synthetic oversampling;
                                        artificial neural networks;
                                        autoencoders;gamma-ray spectra},
                            doi={10.1109/ICMLA.2015.58},
                            ISSN={},
                            month={Dec}}

    Notes:
        * There is no hint on the activation functions and amounts of noise.
    """

    categories = [
        OverSampling.cat_extensive,
        OverSampling.cat_density_estimation,
        OverSampling.cat_application,
        OverSampling.cat_metric_learning,
    ]

    def __init__(
        self,
        proportion=1.0,
        n_neighbors=5,
        *,
        nn_params=None,
        ss_params=None,
        e=100,  # pylint: disable=invalid-name
        h=0.3,  # pylint: disable=invalid-name
        sigma=0.1,
        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_neighbors (int): number of 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
            ss_params (dict): simplex sampling parameters
            e (int): number of epochs
            h (float): fraction of number of hidden units
            sigma (float): training noise
            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(proportion, "proportion", 0.0)
        self.check_greater_or_equal(n_neighbors, "n_neighbors", 1)
        self.check_greater(e, "e", 1)
        self.check_greater(h, "h", 0)
        self.check_greater(sigma, "sigma", 0)
        self.check_n_jobs(n_jobs, "n_jobs")

        self.proportion = proportion
        self.n_neighbors = n_neighbors
        self.nn_params = coalesce(nn_params, {})
        self.ss_params = coalesce_dict(ss_params, ss_params_default)
        self.encoder_params = {"e": e, "h": h, "sigma": sigma}
        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_neighbors": [3, 5, 7],
            "e": [40],
            "h": [0.1, 0.2, 0.3, 0.4, 0.5],
            "sigma": [0.05, 0.1, 0.2],
        }
        return cls.generate_parameter_combinations(parameter_combinations, raw)

    def get_model(self, n_dim, encoding_d):
        """
        Return the model

        Args:
            n_dim (int): the number of dimensions
            encoding_d (int): the number of encoding dimensions

        Returns:
            tensorflow.keras.models.Model, list: the model and the callbacks
        """
        # this import is here to prevent the heavy importing of tensorflow when not needed
        from tensorflow.keras.layers import (  # pylint: disable=wrong-import-position,no-name-in-module,import-outside-toplevel
            Input,
            Dense,
            GaussianNoise,
        )
        from tensorflow.keras.models import (  # pylint: disable=wrong-import-position,no-name-in-module,import-outside-toplevel
            Model,
        )
        from tensorflow.keras.callbacks import (  # pylint: disable=wrong-import-position,no-name-in-module,import-outside-toplevel
            EarlyStopping,
        )

        # constructing the autoencoder
        callbacks = [EarlyStopping(monitor="val_loss", patience=2)]

        input_layer = Input(shape=(n_dim,))
        noise = GaussianNoise(self.encoder_params["sigma"])(input_layer)
        encoded = Dense(encoding_d, activation="relu")(noise)
        decoded = Dense(n_dim, activation="linear")(encoded)

        dae = Model(input_layer, decoded)

        return dae, callbacks

    def execute_autoencoder(
        self,
        X_min_normalized,  # pylint: disable=invalid-name
        X_init_normalized,  # pylint: disable=invalid-name
    ):
        """
        Execute the autoencoder.

        Args:
            X_min_normalized (np.array): the normalized minority samples
            X_init_normalized (np.array): the normalized generated samples

        Returns:
            np.array: the denoised samples (all)
        """

        n_dim = X_min_normalized.shape[1]
        encoding_d = np.max([2, int(np.rint(n_dim * self.encoder_params["h"]))])

        _logger.info(
            "%s: Input dimension: %d, encoding dimension: %d",
            self.__class__.__name__,
            n_dim,
            encoding_d,
        )

        warnings.simplefilter("ignore", DeprecationWarning)

        dae, callbacks = self.get_model(n_dim, encoding_d)

        dae.compile(optimizer="adadelta", loss="mean_squared_error")
        actual_epochs = np.max(
            [self.encoder_params["e"], int(5000.0 / X_min_normalized.shape[0])]
        )

        if X_min_normalized.shape[0] > 10:
            val_num = int(0.2 * X_min_normalized.shape[0])
            X_min_train = X_min_normalized[:-val_num]  # pylint: disable=invalid-name
            X_min_val = X_min_normalized[-val_num:]  # pylint: disable=invalid-name

            dae.fit(
                X_min_train,
                X_min_train,
                epochs=actual_epochs,
                validation_data=(X_min_val, X_min_val),
                callbacks=callbacks,
                verbose=0,
            )
        else:
            dae.fit(X_min_normalized, X_min_normalized, epochs=actual_epochs, verbose=0)

        return dae.predict(X_init_normalized, verbose=0)

    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.")

        warnings.simplefilter("ignore", DeprecationWarning)

        # this import is here to prevent the heavy importing of tensorflow when not needed
        import tensorflow as tf  # pylint: disable=wrong-import-position,import-outside-toplevel

        os.environ["OMP_NUM_THREADS"] = f"{self.n_jobs}"
        os.environ["TF_NUM_INTRAOP_THREADS"] = f"{self.n_jobs}"
        os.environ["TF_NUM_INTEROP_THREADS"] = f"{self.n_jobs}"

        tf.config.threading.set_inter_op_parallelism_threads(self.n_jobs)
        tf.config.threading.set_intra_op_parallelism_threads(self.n_jobs)

        tf.config.set_soft_device_placement(True)

        os.environ["PYTHONHASHSEED"] = f"{self._random_state_init}"

        random.seed(self._random_state_init)
        np.random.seed(self._random_state_init)
        tf.random.set_seed(self._random_state_init)

        # sampling by smote
        X_samp, _ = SMOTE(
            proportion=self.proportion,
            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,
        ).sample(X, y)

        # samples to map to the manifold extracted by the autoencoder
        X_init = X_samp[len(X) :]  # pylint: disable=invalid-name

        # normalizing
        X_min = X[y == self.min_label]
        scaler = StandardScaler()
        X_min_normalized = scaler.fit_transform(X_min)  # pylint: disable=invalid-name
        X_init_normalized = scaler.transform(X_init)  # pylint: disable=invalid-name

        # mapping the initial samples to the manifold
        samples = scaler.inverse_transform(
            self.execute_autoencoder(X_min_normalized, X_init_normalized)
        )

        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,
            "n_neighbors": self.n_neighbors,
            "e": self.encoder_params["e"],
            "h": self.encoder_params["h"],
            "sigma": self.encoder_params["sigma"],
            "ss_params": self.ss_params,
            "n_jobs": self.n_jobs,
            **OverSampling.get_params(self),
        }
