# Copyright (c) 2015-2016, The Authors and Contributors
# <see AUTHORS file>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
# following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the
# following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
# following disclaimer in the documentation and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
# products derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
from typing import Generator

import numpy as np
import logging
import pyximport

from supplychainpy._helpers._decorators import log_this

log = logging.getLogger(__name__)
log.addHandler(logging.NullHandler())

#pyximport.install()
from supplychainpy.demand._squared_error import sum_squared_errors_i1, sum_squared_errors_i2, sum_squared_errors_i0


class Forecast:
    """

    """
    __simple_exponential_smoothing_forecast = {}

    # make keyword args
    def __init__(self, orders: list = None, average_orders: float = None, **kwargs):
        self.__weighted_moving_average = None
        self.__orders = orders
        self.__average_orders = sum([int(demand) for demand in self.__orders]) / len(self.__orders)
        self.__moving_average = []
        self.__total_orders = sum([int(demand) for demand in self.__orders])

    @property
    def total_orders(self):
        return self.__total_orders

    @property
    def moving_average(self) -> list:
        return self.__moving_average

    @moving_average.setter
    def moving_average(self, forecast: list):
        self.__moving_average = forecast

    @property
    def weighted_moving_average(self) -> list:
        return self.__weighted_moving_average

    # specify a start position and the forecast will build from this position
    def moving_average_forecast(self, average_period: int = 3, forecast_length: int = 2,
                                base_forecast: bool = False, start_position: int = 0) -> list:
        """ Generates a forecast from moving averages.

        Generate a forecast from moving averages for as many periods as specified.

        Args:
            average_period (int):       Number of periods to average.
            forecast_length (int):      Number of periods to forecast for.
            base_forecast (bool):       Start a moving average forecast from anywhere
                                        in the list. For use when evaluating a forecast.
            start_position (int):       Where to start the forecast in the list when

        Returns:
            list:       Returns a list of orders including the original and the forecast appended to the end.
                        The appended forecast is as long as specified by the forecast_length. For example

                        orders = [1, 3, 5, 67, 4, 65, 242, 50, 48, 24, 34, 20]
                        d = forecast_demand.Forecast(orders)
                        d.calculate_moving_average_forecast(forecast_length=3)
                        print(d.moving_average_forecast)

                            output:

                                [1, 3, 5, 67, 4, 65, 242, 50, 48, 24, 34, 20, 26, 27, 24]

                        orders = [1, 3, 5, 67, 4, 65, 242, 50, 48, 24, 34, 20]
                        d = forecast_demand.Forecast(orders)
                        d.calculate_moving_average_forecast(forecast_length=3, base_forecast=True, start_position=3)
                        print(d.moving_average_forecast):

                            output:

                                [3, 67, 65, 45, 60, 80]

        Raises:
            ValueError: Incorrect number of orders supplied. Please make sure you have enough orders to
                        calculate an average. The average_period is {}, while the
                        number of orders supplied is {}. The number of orders supplied should be equal
                        or greater than the average_period. Either decrease the average_period or
                        increase the start_position in the list.n
        """

        if base_forecast:
            start_period = start_position + average_period
            end_period = len(self.__orders)
            total_orders = 0
            moving_average = []
            if len(self.__orders[0:start_position]) < average_period:
                raise ValueError("Incorrect number of orders supplied. Please make sure you have enough orders to "
                                 "calculate an average. The average_period is {}, while the \n"
                                 "number of orders supplied is {}. The number of orders supplied should  be equal "
                                 "or greater than the average_period.\n Either decrease the average_period or "
                                 "increase the start_position in the list.".format(average_period, start_position))
            else:
                for i in self.__orders[0:start_position]:
                    moving_average.append(self.__orders[i])

            count = 0
            average_orders = 0.0
            while count < forecast_length:
                for items in moving_average[0:end_period]:
                    total_orders += items
                count += 1
                average_orders = total_orders / float(average_period)
                moving_average.append(round(average_orders))
                average_orders = 0.0
                total_orders = 0.0
                if count < 1:
                    start_period += len(moving_average) - average_period
                else:
                    start_period += 1

                end_period = len(moving_average)
            self.__moving_average = moving_average

        else:
            start_period = len(self.__orders) - average_period
            end_period = len(self.__orders)
            total_orders = 0
            moving_average = self.__orders
            count = 0
            average_orders = 0.0
            while count < forecast_length:
                for items in moving_average[start_period:end_period]:
                    total_orders += items
                count += 1
                average_orders = total_orders / float(average_period)
                moving_average.append(round(average_orders))
                average_orders = 0.0
                total_orders = 0.0
                if count < 1:
                    start_period += len(moving_average) - average_period
                else:
                    start_period += 1

                end_period = len(moving_average)
                self.__moving_average = moving_average
            return moving_average

    def weighted_moving_average_forecast(self, weights: list, average_period: int = 3,
                                         forecast_length: int = 3, base_forecast: bool = False,
                                         start_position=0) -> list:
        """ Generates a forecast from moving averages using user supplied weights.

        Generate a forecast from moving averages for as many periods as specified and adjusts the forecast based
        on the supplied weights.

        Args:
            average_period (int):       Number of periods to average.
            forecast_length (int):      Number of periods to forecast for.
            weights (list):             A list of weights that sum up to one.
            base_forecast(bool):        Start a moving average forecast from anywhere
                                        in the list. For use when evaluating a forecast.
            start_position(int):        Start position

        Returns:
            list:       Returns a list of orders including the original and the forecast appended to the end.
                        The appended forecast is as long as specified by the forecast_length. For example

                        orders = [1, 3, 5, 67, 4, 65, 242, 50, 48, 24, 34, 20]
                        d = forecast_demand.Forecast(orders)
                        d.calculate_moving_average_forecast(forecast_length=3)
                        print(d.moving_average_forecast)

                            output:

                                [1, 3, 5, 67, 4, 65, 242, 50, 48, 24, 34, 20, 13, 11, 7]

        Raises:
            ValueError: The weights should equal 1 and be as long as the average period (default 3).'
                        The supplied weights total {} and is {} members long. Please check the supplied
                        weights.'.format(sum(weights), len(weights)))
        """

        if sum(weights) != 1 or len(weights) != average_period:
            raise ValueError(
                'The weights should equal 1 and be as long as the average period (default 3).'
                ' The supplied weights total {} and is {} members long. Please check the supplied weights.'.format(
                    sum(weights), len(weights)))
        else:
            start_period = len(self.__orders) - average_period

        if base_forecast:
            start_period = start_position + average_period
            end_period = len(self.__orders)
            total_orders = 0
            weighted_moving_average = []
            if start_position + 1 < average_period:
                raise ValueError("Incorrect number of orders supplied. Please make sure you have enough orders to "
                                 "calculate an average. The average_period is {}, while the \n"
                                 "number of orders supplied is {}. The number of orders supplied should  be equal "
                                 "or greater than the average_period.\n Either decrease the average_period or "
                                 "increase the start_position in the list.".format(average_period, start_position))
            else:
                for i in self.__orders[0:start_position]:
                    weighted_moving_average.append(self.__orders[i])

            count = 0
            weight_count = 0
            while count < forecast_length:
                for items in weighted_moving_average[0:end_period]:
                    total_orders += items

                count += 1
                average_orders = (total_orders / float(average_period)) * weights[weight_count]
                weighted_moving_average.append(round(average_orders))
                total_orders = 0.0
                if count < 1:
                    start_period += len(weighted_moving_average) - average_period
                else:
                    start_period += 1
            self.__weighted_moving_average = weighted_moving_average
        else:
            end_period = len(self.__orders)
            total_orders = 0
            weighted_moving_average = self.__orders
            count = 0
            average_orders = 0.0
            while count < forecast_length:
                weight_count = 0
                for items in weighted_moving_average[start_period:end_period]:
                    total_orders += items
                average_orders = (total_orders / float(average_period)) * weights[weight_count]
                count += 1
                weighted_moving_average.append(round(average_orders))
                average_orders = 0.0
                total_orders = 0.0
                if count < 1:
                    start_period += len(weighted_moving_average) - average_period
                else:
                    start_period += 1

                end_period = len(weighted_moving_average)

        self.__weighted_moving_average = weighted_moving_average
        return weighted_moving_average

    # also use to calculate the MAD for all forecasting methods given a spcific length of order

    # TODO-feature fix base_forecast for correct period to period MAD calculation
    def mean_absolute_deviation(self, forecasts: list, base_forecast: bool = False, start_period: int = 3) -> np.array:

        """ calculates the mean absolute deviation (MAD) for a given forecast.

        Calculates the mean absolute deviation for a forecast. The forecast and

        Args:
            forecasts (list):       A previously calculated forecast.
            base_forecast (bool):   Start a moving average forecast from anywhere
                                    in the list.
            start_period (int):     The start period of the forecast.

        Returns:
            np.array

        Raises:
            ValueError:
        """

        if base_forecast:
            end_period = len(forecasts) - start_period
            forecast_array = np.array(forecasts[:end_period])
            orders_array = np.array(self.__orders[start_period: len(forecasts)])
            std_array = orders_array - forecast_array
            std_array = sum(abs(std_array)) / len(std_array)
        else:
            forecast_array = np.array(forecasts)
            orders_array = np.array(self.__orders)
            std_array = orders_array - forecast_array
            std_array = sum(abs(std_array)) / len(std_array)

        return std_array

    @log_this(logging.DEBUG, "Called to compute simple exponential smoothing.")
    def simple_exponential_smoothing(self, *alpha)->Generator[dict, None, None]:
        """ Generates forecast using simple exponential smoothing (SES).

        Args:
            alpha(args):    A list of smoothing level constants (alpha values)

        Returns:
            dict:           Forecast containing
                            {'demand': 165, 'level_estimates': 164.49748124123246, 'alpha': 0.7487406206162335,
                            'one_step_forecast': 163.0, 'forecast_error': 2.0, 'squared_error': 4.0, 't': 1}
         Examples:
                            forecast_demand = Forecast(orders)
                            alpha_values = [0.2, 0.3, 0.4, 0.5, 0.6]
                            ses_forecast = [forecast for forecast in forecast_demand.simple_exponential_smoothing(*alpha_values)]

       """

        for arg in alpha:
            forecast = {}
            current_level_estimate = self.__average_orders
            forecast.update({'alpha': arg,
                             't': 0,
                             'demand': 0,
                             'level_estimates': current_level_estimate,
                             'one_step_forecast': 0,
                             'forecast_error': 0,
                             'squared_error': 0})
            previous_level_estimate = current_level_estimate
            for index, demand in enumerate(tuple(self.__orders), 1):
                current_level_estimate = self._level_estimate(previous_level_estimate, arg, demand)
                yield {'alpha': arg,
                       't': index,
                       'demand': demand,
                       'level_estimates': current_level_estimate,
                       'one_step_forecast': previous_level_estimate,
                       'forecast_error': self._forecast_error(demand, previous_level_estimate),
                       'squared_error': self._forecast_error(demand, previous_level_estimate) ** 2
                       }
                previous_level_estimate = current_level_estimate


    @log_this(logging.DEBUG, "Called HTEC exponential smoothing.")
    def holts_trend_corrected_exponential_smoothing(self, alpha: float, gamma: float, intercept: float, slope: float):
        forecast = {}
        # log.debug('holts ')
        current_level_estimate = intercept
        forecast.update({'alpha': alpha,
                         'gamma': gamma,
                         't': 0,
                         'demand': 0,
                         'level_estimates': current_level_estimate,
                         'trend': slope,
                         'one_step_forecast': 0,
                         'forecast_error': 0,
                         'squared_error': 0

                         })
        previous_trend = slope
        previous_level_estimate = current_level_estimate
        for index, demand in enumerate(tuple(self.__orders), 1):
            log.debug('demand: {}'.format(demand))
            one_step = previous_level_estimate + previous_trend
            log.debug('one_step: {}'.format(one_step))
            forecast_error = self._forecast_error(demand, one_step)
            log.debug('forecast_error: {}'.format(forecast_error))
            current_trend = self._holts_trend(previous_trend, gamma, alpha, forecast_error)
            log.debug('trend: {}'.format(current_trend))
            current_level_estimate = self._level_estimate_holts_trend_corrected(previous_level_estimate,
                                                                                alpha,
                                                                                previous_trend,
                                                                                forecast_error)
            log.debug('current_level: {}'.format(current_level_estimate))
            squared_error = forecast_error ** 2
            yield {'alpha': alpha,
                   'gamma': gamma,
                   't': index,
                   'demand': demand,
                   'trend': current_trend,
                   'level_estimates': current_level_estimate,
                   'one_step_forecast': one_step,
                   'forecast_error': forecast_error,
                   'squared_error': squared_error
                   }
            log.debug('squared_error: {}'.format(squared_error))
            previous_level_estimate = current_level_estimate
            previous_trend = current_trend

    @staticmethod
    def holts_trend_corrected_forecast(forecast: list, forecast_length: int):
        """Creates a forecast for as many periods.

        Args:
            forecast:
            forecast_length:

        Returns:

        """
        end_of_forecast = len(forecast) - 1
        # print(forecast[end_of_forecast])

        new_forecast = []

        for i in range(forecast_length):
            demand_forecast = forecast[end_of_forecast]['level_estimates'] + i * forecast[end_of_forecast]['trend']
            new_forecast.append(demand_forecast)
        return new_forecast

    @staticmethod
    def simple_exponential_smoothing_forecast(forecast: list, forecast_length: int):
        end_of_forecast = len(forecast) - 1
        new_forecast = []

        for i in range(forecast_length):
            demand_forecast = forecast[end_of_forecast]['level_estimates']
            new_forecast.append(demand_forecast)
        return new_forecast

    @staticmethod
    def _holts_trend(previous_trend: float, gamma: float, alpha: float, current_forecast_error: float):
        return previous_trend + alpha * gamma * current_forecast_error

    @staticmethod
    def _level_estimate(lvl: float, smoothing_parameter: float, demand: int):
        return lvl + smoothing_parameter * (demand - lvl)

    @staticmethod
    def _level_estimate_holts_trend_corrected(previous_level: float, smoothing_parameter: float, previous_trend: float,
                                              forecast_error: float):
        return previous_level + previous_trend + smoothing_parameter * forecast_error

    @staticmethod
    def _forecast_error(demand: int, one_step_forecast: float):
        return float(demand) - one_step_forecast

    @staticmethod
    def sum_squared_errors(squared_error: list, smoothing_parameter: float) -> dict:
        return sum_squared_errors_i0(squared_error, smoothing_parameter)

    @staticmethod
    def sum_squared_errors_indi(squared_error: list, smoothing_parameter: float) -> dict:
        return sum_squared_errors_i2(squared_error, smoothing_parameter)

    @staticmethod
    def sum_squared_errors_indi_htces(squared_error: list, alpha: float, gamma: float) -> dict:
        return sum_squared_errors_i1(squared_error, alpha, gamma)

    @staticmethod
    def standard_error(sse: dict, orders_count, smoothing_parameter, df: int = 1) -> float:
        return (sse[smoothing_parameter] / (orders_count - df)) ** 0.5

    def mean_forecast_error(self):

        """

        Args:
            forecasts (int):        Number of periods to average.
            base_forecast (bool):   A list of weights that sum up to one.


        Returns:
            np.array

        Raises:
            ValueError:
        """

        pass

    def mean_aboslute_percentage_error_opt(self, forecast: list) -> int:
        """ Calculates the mean absolute percentage error for the optimised forecasts

        Args:
            forecast (list): forecast generated

        Returns:

        """
        sum_ape = sum([abs((i['demand'] - i['level_estimates']) / i['demand']) for i in forecast])
        mape = (sum_ape / len(forecast)) * 100
        return mape

    def optimise(self):

        """

        Args:
            forecasts (int):        Number of periods to average.
            base_forecast (bool):   A list of weights that sum up to one.


        Returns:
            np.array

        Raises:
            ValueError:
        """
        pass

    def linear_regression(self):

        """

        Args:
            line (int):        Number of periods to average.
            base_forecast (bool):   A list of weights that sum up to one.


        Returns:
            np.array

        Raises:
            ValueError:
        """
        pass

    def autoregressive(self):
        """

        Args:
            forecasts (int):        Number of periods to average.
            base_forecast (bool):   A list of weights that sum up to one.


        Returns:
            np.array

        Raises:
            ValueError:
        """
        pass


if __name__ == '__main__':
    orders = [165, 171, 147, 143, 164, 160, 152, 150, 159, 169, 173, 203, 169, 166, 162, 147, 188, 161, 162, 169, 185,
              188, 200, 229, 189, 218, 185, 199, 210, 193, 211, 208, 216, 218, 264, 304]

    total_orders = 0
    avg_orders = 0
    for order in orders[:12]:
        total_orders += order

    avg_orders = total_orders / 12
    f = Forecast(orders, avg_orders)
    alpha = [0.2, 0.3, 0.4, 0.5, 0.6]
    s = [i for i in f.simple_exponential_smoothing(*alpha)]

    sum_squared_error = f.sum_squared_errors(s, 0.5)
    # print(sum_squared_error)

    standard_error = f.standard_error(sum_squared_error, len(orders), smoothing_parameter=0.5)
    # print(standard_error)
