#  Copyright (c) 2020
#  <NAME>
from timeit import default_timer as timer

import numpy as np

from MLPApproximator.MlpFunctionGenerator import TestingSet
from MLPApproximator.MlpMetrics import MLPMetrics
from MLPApproximator.MlpPerceptron import Perceptron


class MlpApproximator:
    """
    Multi Layer Perceptron
    Approximate function shape
    Input must be in range [0,1]
    """

    def __init__(self, input_number, output_number, hidden_layer_number,
                 activation_function_hidden_layer, activation_function_output_layer,
                 use_biases=True, debug_level_1_on=False, debug_level_2_on=False,
                 hidden_layer_weights=None, output_layer_weights=None):
        """

        :param input_number:
        :param output_number:
        :param hidden_layer_number:
        :param debug_file:
        """
        self.__debug__level_1_on = debug_level_1_on
        self.__debug__level_2_on = debug_level_2_on

        self.__bias_number = 1 if use_biases else 0
        self.__input_number = input_number
        self.__output_number = output_number

        self.__debug('\tMLP Function Approximator')
        self.__debug(' input number={}'.format(input_number))
        self.__debug('output number={}'.format(output_number))
        self.__debug('hidden number={}'.format(hidden_layer_number))

        self.__p1 = Perceptron(
            input_number + self.__bias_number,
            hidden_layer_number + self.__bias_number,
            activation_function=activation_function_hidden_layer,
            debug_on=debug_level_2_on,
            weight=hidden_layer_weights,
            name="P_hide")
        self.__p2 = Perceptron(
            hidden_layer_number + self.__bias_number,
            output_number,
            activation_function=activation_function_output_layer,
            debug_on=debug_level_2_on,
            weight=output_layer_weights,
            name="P_out")
        self.__mean_output_error = None
        self.__output = None

    def train(self, train_data_set: TestingSet, epoch_number=1):
        """

        :param train_data_set:
        :param epoch_number:
        :return:
        """
        if epoch_number <= 0:
            raise RuntimeError('Epoch must be at least one')

        if np.any(np.abs(train_data_set.X) > 1.):
            raise RuntimeError('Expected dataset Input must be in range [-1; 1]')

        if np.any(np.abs(train_data_set.Y) > 1.):
            raise RuntimeError('Expected dataset Output must be in range [-1; 1]')

        inputs_with_bias = self.__create_input_with_biases(train_data_set)

        self.__debug('Train on {} samples'.format(len(train_data_set.X)))
        metrics = MLPMetrics()
        training_time_start = timer()
        for epoch in range(epoch_number):
            epoch_time_start = timer()

            self.__debug('Epoch:{:>5}/{:<5}'.format(epoch + 1, epoch_number))
            self.__output = self.__propagateForward(inputs_with_bias)
            loss = self.__calculate_rmse(train_data_set.Output)
            correction = self.__propagateErrorBackward(train_data_set.Output)

            epoch_time_stop = timer()

            metrics.addCorrection(correction)
            metrics.addMeanSquaredError(loss)
            self.__debug('\tEpoch Time={:2.3}s GlobalTime={:2.3}s Loss={:2.3}%\n'.format(
                epoch_time_stop - epoch_time_start,
                epoch_time_stop - training_time_start,
                np.mean(loss * 100)))

        self.__debug('\tTraining Time={:2.3}s\n'.format(timer() - training_time_start))
        if self.__debug__level_2_on:
            self.__debug('\tCurrent output=\n{}\n'.format(self.__p2.output()))

        self.__output = self.__p2.output()
        return self.__output, metrics

    def test(self, test_data_set: TestingSet):
        """

        :param test_data_set:
        :return:
        """
        self.__debug('Testing: ')
        inputs_with_bias = self.__create_input_with_biases(test_data_set)

        self.__output = self.__propagateForward(inputs_with_bias)
        loss = self.__calculate_rmse(test_data_set.Output)
        self.__debug('\tLoss={:2.3}%'.format(np.mean(loss) * 100))
        if self.__debug__level_2_on:
            self.__debug('\tCurrent denormalized output=\n{}'.format(self.__p2.output()))
        return self.__output, loss

    def __create_input_with_biases(self, train_data_set):
        inputs_with_bias = train_data_set.Input
        if self.__bias_number > 0:
            inputs_with_bias = np.append(
                train_data_set.Input,
                np.ones(shape=(train_data_set.Input.shape[0], self.__bias_number)),
                axis=1)
        return inputs_with_bias

    def __calculate_rmse(self, expected_out):
        loss = np.sqrt(np.mean(0.5 * np.square(expected_out - self.__p2.output()), axis=0, keepdims=True)).T
        if self.__debug__level_2_on:
            self.__debug('Loss=\n{}'.format(loss))
        return loss

    def __propagateForward(self, input_data):
        p1_out = self.__p1.forwardPropagation(input_data)
        return self.__p2.forwardPropagation(p1_out)

    def __propagateErrorBackward(self, expected_output_data: np.array):
        correction, weight = self.__p2.propagateBackward(expected_output_data)
        self.__p1.propagateHiddenBackward(correction, weight)

        return correction

    def __debug(self, msg, *args):
        if self.__debug__level_1_on:
            print('Approximator: ', msg, *args)

#  Copyright (c) 2020
#  <NAME>
from timeit import default_timer as timer

import numpy as np

from MLPApproximator.MlpFunctionGenerator import TestingSet
from MLPApproximator.MlpMetrics import MLPMetrics
from MLPApproximator.MlpPerceptron import Perceptron


class MlpApproximator:
    """
    Multi Layer Perceptron
    Approximate function shape
    Input must be in range [0,1]
    """

    def __init__(self, input_number, output_number, hidden_layer_number,
                 activation_function_hidden_layer, activation_function_output_layer,
                 use_biases=True, debug_level_1_on=False, debug_level_2_on=False,
                 hidden_layer_weights=None, output_layer_weights=None):
        """

        :param input_number:
        :param output_number:
        :param hidden_layer_number:
        :param debug_file:
        """
        self.__debug__level_1_on = debug_level_1_on
        self.__debug__level_2_on = debug_level_2_on

        self.__bias_number = 1 if use_biases else 0
        self.__input_number = input_number
        self.__output_number = output_number

        self.__debug('\tMLP Function Approximator')
        self.__debug(' input number={}'.format(input_number))
        self.__debug('output number={}'.format(output_number))
        self.__debug('hidden number={}'.format(hidden_layer_number))

        self.__p1 = Perceptron(
            input_number + self.__bias_number,
            hidden_layer_number + self.__bias_number,
            activation_function=activation_function_hidden_layer,
            debug_on=debug_level_2_on,
            weight=hidden_layer_weights,
            name="P_hide")
        self.__p2 = Perceptron(
            hidden_layer_number + self.__bias_number,
            output_number,
            activation_function=activation_function_output_layer,
            debug_on=debug_level_2_on,
            weight=output_layer_weights,
            name="P_out")
        self.__mean_output_error = None
        self.__output = None

    def train(self, train_data_set: TestingSet, epoch_number=1):
        """

        :param train_data_set:
        :param epoch_number:
        :return:
        """
        if epoch_number <= 0:
            raise RuntimeError('Epoch must be at least one')

        if np.any(np.abs(train_data_set.X) > 1.):
            raise RuntimeError('Expected dataset Input must be in range [-1; 1]')

        if np.any(np.abs(train_data_set.Y) > 1.):
            raise RuntimeError('Expected dataset Output must be in range [-1; 1]')

        inputs_with_bias = self.__create_input_with_biases(train_data_set)

        self.__debug('Train on {} samples'.format(len(train_data_set.X)))
        metrics = MLPMetrics()
        training_time_start = timer()
        for epoch in range(epoch_number):
            epoch_time_start = timer()

            self.__debug('Epoch:{:>5}/{:<5}'.format(epoch + 1, epoch_number))
            self.__output = self.__propagateForward(inputs_with_bias)
            loss = self.__calculate_rmse(train_data_set.Output)
            correction = self.__propagateErrorBackward(train_data_set.Output)

            epoch_time_stop = timer()

            metrics.addCorrection(correction)
            metrics.addMeanSquaredError(loss)
            self.__debug('\tEpoch Time={:2.3}s GlobalTime={:2.3}s Loss={:2.3}%\n'.format(
                epoch_time_stop - epoch_time_start,
                epoch_time_stop - training_time_start,
                np.mean(loss * 100)))

        self.__debug('\tTraining Time={:2.3}s\n'.format(timer() - training_time_start))
        if self.__debug__level_2_on:
            self.__debug('\tCurrent output=\n{}\n'.format(self.__p2.output()))

        self.__output = self.__p2.output()
        return self.__output, metrics

    def test(self, test_data_set: TestingSet):
        """

        :param test_data_set:
        :return:
        """
        self.__debug('Testing: ')
        inputs_with_bias = self.__create_input_with_biases(test_data_set)

        self.__output = self.__propagateForward(inputs_with_bias)
        loss = self.__calculate_rmse(test_data_set.Output)
        self.__debug('\tLoss={:2.3}%'.format(np.mean(loss) * 100))
        if self.__debug__level_2_on:
            self.__debug('\tCurrent denormalized output=\n{}'.format(self.__p2.output()))
        return self.__output, loss

    def __create_input_with_biases(self, train_data_set):
        inputs_with_bias = train_data_set.Input
        if self.__bias_number > 0:
            inputs_with_bias = np.append(
                train_data_set.Input,
                np.ones(shape=(train_data_set.Input.shape[0], self.__bias_number)),
                axis=1)
        return inputs_with_bias

    def __calculate_rmse(self, expected_out):
        loss = np.sqrt(np.mean(0.5 * np.square(expected_out - self.__p2.output()), axis=0, keepdims=True)).T
        if self.__debug__level_2_on:
            self.__debug('Loss=\n{}'.format(loss))
        return loss

    def __propagateForward(self, input_data):
        p1_out = self.__p1.forwardPropagation(input_data)
        return self.__p2.forwardPropagation(p1_out)

    def __propagateErrorBackward(self, expected_output_data: np.array):
        correction, weight = self.__p2.propagateBackward(expected_output_data)
        self.__p1.propagateHiddenBackward(correction, weight)

        return correction

    def __debug(self, msg, *args):
        if self.__debug__level_1_on:
            print('Approximator: ', msg, *args)

#  Copyright (c) 2020 #  <NAME> Multi Layer Perceptron     Approximate function shape     Input must be in range [0,1] :param input_number:         :param output_number:         :param hidden_layer_number:         :param debug_file: :param train_data_set:         :param epoch_number:         :return: :param test_data_set:         :return: