#===============================================================================
# Copyright 2014 Intel Corporation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#===============================================================================

# daal4py GBT scikit-learn-compatible estimator class

import numpy as np
import numbers
from sklearn.base import BaseEstimator, ClassifierMixin, RegressorMixin
from sklearn.utils.validation import check_X_y, check_array, check_is_fitted
from sklearn import preprocessing
from sklearn.utils.multiclass import check_classification_targets
from sklearn.utils import check_random_state
import daal4py as d4p
from .._utils import getFPType


class GBTDAALBase(BaseEstimator, d4p.mb.GBTDAALBaseModel):
    def __init__(self,
                 split_method='inexact',
                 max_iterations=50,
                 max_tree_depth=6,
                 shrinkage=0.3,
                 min_split_loss=0,
                 reg_lambda=1,
                 observations_per_tree_fraction=1,
                 features_per_node=0,
                 min_observations_in_leaf_node=5,
                 memory_saving_mode=False,
                 max_bins=256,
                 min_bin_size=5,
                 random_state=None):
        self.split_method = split_method
        self.max_iterations = max_iterations
        self.max_tree_depth = max_tree_depth
        self.shrinkage = shrinkage
        self.min_split_loss = min_split_loss
        self.reg_lambda = reg_lambda
        self.observations_per_tree_fraction = observations_per_tree_fraction
        self.features_per_node = features_per_node
        self.min_observations_in_leaf_node = min_observations_in_leaf_node
        self.memory_saving_mode = memory_saving_mode
        self.max_bins = max_bins
        self.min_bin_size = min_bin_size
        self.random_state = random_state

    def _check_params(self):
        if self.split_method not in ('inexact', 'exact'):
            raise ValueError('Parameter "split_method" must be '
                             '"inexact" or "exact".')
        if not isinstance(self.max_iterations, numbers.Integral) or \
                self.max_iterations <= 0:
            raise ValueError('Parameter "max_iterations" must be '
                             'non-zero positive integer value.')
        if not isinstance(self.max_tree_depth, numbers.Integral) or \
                self.max_tree_depth < 0:
            raise ValueError('Parameter "max_tree_depth" must be '
                             'positive integer value or zero.')
        if self.shrinkage < 0 or self.shrinkage >= 1:
            raise ValueError('Parameter "shrinkage" must be '
                             'more or equal to 0 and less than 1.')
        if self.min_split_loss < 0:
            raise ValueError('Parameter "min_split_loss" must be '
                             'more or equal to zero.')
        if self.reg_lambda < 0:
            raise ValueError('Parameter "reg_lambda" must be '
                             'more or equal to zero.')
        if self.observations_per_tree_fraction <= 0 or \
                self.observations_per_tree_fraction > 1:
            raise ValueError('Parameter "observations_per_tree_fraction" must be '
                             'more than 0 and less or equal to 1.')
        if not isinstance(self.features_per_node, numbers.Integral) or \
                self.features_per_node < 0:
            raise ValueError('Parameter "features_per_node" must be '
                             'positive integer value or zero.')
        if not isinstance(self.min_observations_in_leaf_node, numbers.Integral) or \
                self.min_observations_in_leaf_node <= 0:
            raise ValueError('Parameter "min_observations_in_leaf_node" must be '
                             'non-zero positive integer value.')
        if not (isinstance(self.memory_saving_mode, bool)):
            raise ValueError('Parameter "memory_saving_mode" must be '
                             'boolean value.')
        if not isinstance(self.max_bins, numbers.Integral) or \
                self.max_bins <= 0:
            raise ValueError('Parameter "max_bins" must be '
                             'non-zero positive integer value.')
        if not isinstance(self.min_bin_size, numbers.Integral) or \
                self.min_bin_size <= 0:
            raise ValueError('Parameter "min_bin_size" must be '
                             'non-zero positive integer value.')

    allow_nan_ = False

    def _more_tags(self):
        return {"allow_nan": self.allow_nan_}


class GBTDAALClassifier(GBTDAALBase, ClassifierMixin):
    def fit(self, X, y):
        # Check the algorithm parameters
        self._check_params()

        # Check that X and y have correct shape
        X, y = check_X_y(X, y, y_numeric=False, dtype=[np.single, np.double])

        check_classification_targets(y)

        # Encode labels
        le = preprocessing.LabelEncoder()
        le.fit(y)
        self.classes_ = le.classes_
        y_ = le.transform(y)

        # Convert to 2d array
        y_ = y_.reshape((-1, 1))

        self.n_outputs_ = y_.shape[1]

        self.n_classes_ = len(self.classes_)

        self.n_features_in_ = X.shape[1]

        # Classifier can't train when only one class is present.
        # Trivial case
        if self.n_classes_ == 1:
            return self

        # Get random seed
        rs_ = check_random_state(self.random_state)
        seed_ = rs_.randint(0, np.iinfo('i').max)

        # Define type of data
        fptype = getFPType(X)

        # Fit the model
        train_algo = d4p.gbt_classification_training(
            fptype=fptype,
            nClasses=self.n_classes_,
            splitMethod=self.split_method,
            maxIterations=self.max_iterations,
            maxTreeDepth=self.max_tree_depth,
            shrinkage=self.shrinkage,
            minSplitLoss=self.min_split_loss,
            lambda_=self.reg_lambda,
            observationsPerTreeFraction=self.observations_per_tree_fraction,
            featuresPerNode=self.features_per_node,
            minObservationsInLeafNode=self.min_observations_in_leaf_node,
            memorySavingMode=self.memory_saving_mode,
            maxBins=self.max_bins,
            minBinSize=self.min_bin_size,
            engine=d4p.engines_mcg59(seed=seed_))
        train_result = train_algo.compute(X, y_)

        # Store the model
        self.daal_model_ = train_result.model

        # Return the classifier
        return self

    def _predict(self, X, resultsToEvaluate):
        # Input validation
        if not self.allow_nan_:
            X = check_array(X, dtype=[np.single, np.double])
        else:
            X = check_array(X, dtype=[np.single, np.double], force_all_finite='allow-nan')

        # Check is fit had been called
        check_is_fitted(self, ['n_features_in_', 'n_classes_'])

        # Trivial case
        if self.n_classes_ == 1:
            return np.full(X.shape[0], self.classes_[0])

        fptype = getFPType(X)
        predict_result = self._predict_classification(X, fptype, resultsToEvaluate)

        if resultsToEvaluate == "computeClassLabels":
            # Decode labels
            le = preprocessing.LabelEncoder()
            le.classes_ = self.classes_
            return le.inverse_transform(predict_result)
        return predict_result

    def predict(self, X):
        return self._predict(X, "computeClassLabels")

    def predict_proba(self, X):
        return self._predict(X, "computeClassProbabilities")

    def predict_log_proba(self, X):
        proba = self.predict_proba(X)

        if self.n_outputs_ == 1:
            return np.log(proba)

        for k in range(self.n_outputs_):
            proba[k] = np.log(proba[k])

        return proba

    def convert_model(model):
        gbm = GBTDAALClassifier()
        gbm._convert_model(model)

        gbm.classes_ = model.classes_
        gbm.allow_nan_ = True
        return gbm


class GBTDAALRegressor(GBTDAALBase, RegressorMixin):
    def fit(self, X, y):
        # Check the algorithm parameters
        self._check_params()

        # Check that X and y have correct shape
        X, y = check_X_y(X, y, y_numeric=True, dtype=[np.single, np.double])

        # Convert to 2d array
        y_ = y.reshape((-1, 1))

        self.n_features_in_ = X.shape[1]

        # Get random seed
        rs_ = check_random_state(self.random_state)
        seed_ = rs_.randint(0, np.iinfo('i').max)

        # Define type of data
        fptype = getFPType(X)

        # Fit the model
        train_algo = d4p.gbt_regression_training(
            fptype=fptype,
            splitMethod=self.split_method,
            maxIterations=self.max_iterations,
            maxTreeDepth=self.max_tree_depth,
            shrinkage=self.shrinkage,
            minSplitLoss=self.min_split_loss,
            lambda_=self.reg_lambda,
            observationsPerTreeFraction=self.observations_per_tree_fraction,
            featuresPerNode=self.features_per_node,
            minObservationsInLeafNode=self.min_observations_in_leaf_node,
            memorySavingMode=self.memory_saving_mode,
            maxBins=self.max_bins,
            minBinSize=self.min_bin_size,
            engine=d4p.engines_mcg59(seed=seed_))
        train_result = train_algo.compute(X, y_)

        # Store the model
        self.daal_model_ = train_result.model

        # Return the classifier
        return self

    def predict(self, X):
        # Input validation
        if not self.allow_nan_:
            X = check_array(X, dtype=[np.single, np.double])
        else:
            X = check_array(X, dtype=[np.single, np.double], force_all_finite='allow-nan')

        # Check is fit had been called
        check_is_fitted(self, ['n_features_in_'])

        fptype = getFPType(X)
        return self._predict_regression(X, fptype)

    def convert_model(model):
        gbm = GBTDAALRegressor()
        gbm._convert_model(model)

        gbm.allow_nan_ = True
        return gbm
