r"""
ETS models for time series analysis.

The ETS models are a family of time series models. They can be seen as a
generalization of simple exponential smoothing to time series that contain
trends and seasonalities. Additionally, they have an underlying state space
model.

An ETS model is specified by an error type (E; additive or multiplicative), a
trend type (T; additive or multiplicative, both damped or undamped, or none),
and a seasonality type (S; additive or multiplicative or none).
The following gives a very short summary, a more thorough introduction can be
found in [1]_.

Denote with :math:`\circ_b` the trend operation (addition or
multiplication), with :math:`\circ_d` the operation linking trend and dampening
factor :math:`\phi` (multiplication if trend is additive, power if trend is
multiplicative), and with :math:`\circ_s` the seasonality operation (addition
or multiplication).
Furthermore, let :math:`\ominus` be the respective inverse operation
(subtraction or division).

With this, it is possible to formulate the ETS models as a forecast equation
and 3 smoothing equations. The former is used to forecast observations, the
latter are used to update the internal state.

.. math::

    \hat{y}_{t|t-1} &= (l_{t-1} \circ_b (b_{t-1}\circ_d \phi))\circ_s s_{t-m}\\
    l_{t} &= \alpha (y_{t} \ominus_s s_{t-m})
             + (1 - \alpha) (l_{t-1} \circ_b (b_{t-1} \circ_d \phi))\\
    b_{t} &= \beta/\alpha (l_{t} \ominus_b l_{t-1})
             + (1 - \beta/\alpha) b_{t-1}\\
    s_{t} &= \gamma (y_t \ominus_s (l_{t-1} \circ_b (b_{t-1}\circ_d\phi))
             + (1 - \gamma) s_{t-m}

The notation here follows [1]_; :math:`l_t` denotes the level at time
:math:`t`, `b_t` the trend, and `s_t` the seasonal component. :math:`m` is the
number of seasonal periods, and :math:`\phi` a trend damping factor.
The parameters :math:`\alpha, \beta, \gamma` are the smoothing parameters,
which are called ``smoothing_level``, ``smoothing_trend``, and
``smoothing_seasonal``, respectively.

Note that the formulation above as forecast and smoothing equation does not
distinguish different error models -- it is the same for additive and
multiplicative errors. But the different error models lead to different
likelihood models, and therefore will lead to different fit results.

The error models specify how the true values :math:`y_t` are updated. In the
additive error model,

.. math::

    y_t = \hat{y}_{t|t-1} + e_t,

in the multiplicative error model,

.. math::

    y_t = \hat{y}_{t|t-1}\cdot (1 + e_t).

Using these error models, it is possible to formulate state space equations for
the ETS models:

.. math::

   y_t &= Y_t + \eta \cdot e_t\\
   l_t &= L_t + \alpha \cdot (M_e \cdot L_t + \kappa_l) \cdot e_t\\
   b_t &= B_t + \beta \cdot (M_e \cdot B_t + \kappa_b) \cdot e_t\\
   s_t &= S_t + \gamma \cdot (M_e \cdot S_t+\kappa_s)\cdot e_t\\

with

.. math::

   B_t &= b_{t-1} \circ_d \phi\\
   L_t &= l_{t-1} \circ_b B_t\\
   S_t &= s_{t-m}\\
   Y_t &= L_t \circ_s S_t,

and

.. math::

   \eta &= \begin{cases}
               Y_t\quad\text{if error is multiplicative}\\
               1\quad\text{else}
           \end{cases}\\
   M_e &= \begin{cases}
               1\quad\text{if error is multiplicative}\\
               0\quad\text{else}
           \end{cases}\\

and, when using the additive error model,

.. math::

   \kappa_l &= \begin{cases}
               \frac{1}{S_t}\quad
               \text{if seasonality is multiplicative}\\
               1\quad\text{else}
           \end{cases}\\
   \kappa_b &= \begin{cases}
               \frac{\kappa_l}{l_{t-1}}\quad
               \text{if trend is multiplicative}\\
               \kappa_l\quad\text{else}
           \end{cases}\\
   \kappa_s &= \begin{cases}
               \frac{1}{L_t}\quad\text{if seasonality is multiplicative}\\
               1\quad\text{else}
           \end{cases}

When using the multiplicative error model

.. math::

   \kappa_l &= \begin{cases}
               0\quad
               \text{if seasonality is multiplicative}\\
               S_t\quad\text{else}
           \end{cases}\\
   \kappa_b &= \begin{cases}
               \frac{\kappa_l}{l_{t-1}}\quad
               \text{if trend is multiplicative}\\
               \kappa_l + l_{t-1}\quad\text{else}
           \end{cases}\\
   \kappa_s &= \begin{cases}
               0\quad\text{if seasonality is multiplicative}\\
               L_t\quad\text{else}
           \end{cases}

When fitting an ETS model, the parameters :math:`\alpha, \beta`, \gamma,
\phi` and the initial states `l_{-1}, b_{-1}, s_{-1}, \ldots, s_{-m}` are
selected as maximizers of log likelihood.

References
----------
.. [1] Hyndman, R.J., & Athanasopoulos, G. (2019) *Forecasting:
   principles and practice*, 3rd edition, OTexts: Melbourne,
   Australia. OTexts.com/fpp3. Accessed on April 19th 2020.
"""

from collections import OrderedDict
import contextlib
import datetime as dt

import numpy as np
import pandas as pd
from scipy.stats import _distn_infrastructure, norm, rv_continuous, rv_discrete

from statsmodels.base.covtype import descriptions
import statsmodels.base.wrapper as wrap
from statsmodels.iolib.summary import forg
from statsmodels.iolib.table import SimpleTable
from statsmodels.iolib.tableformatting import fmt_params
from statsmodels.tools.decorators import cache_readonly
from statsmodels.tools.tools import Bunch
from statsmodels.tools.validation import (
    array_like,
    bool_like,
    int_like,
    string_like,
)
import statsmodels.tsa.base.tsa_model as tsbase
from statsmodels.tsa.exponential_smoothing import base
import statsmodels.tsa.exponential_smoothing._ets_smooth as smooth
from statsmodels.tsa.exponential_smoothing.initialization import (
    _initialization_heuristic,
)
from statsmodels.tsa.tsatools import freq_to_period

# Implementation details:

# * The smoothing equations are implemented only for models having all
#   components (trend, dampening, seasonality). When using other models, the
#   respective parameters (smoothing and initial parameters) are set to values
#   that lead to the reduced model (often zero).
#   The internal model is needed for smoothing (called from fit and loglike),
#   forecasts, and simulations.
# * Somewhat related to above: There are 2 sets of parameters: model/external
#   params, and internal params.
#   - model params are all parameters necessary for a model, and are for
#     example passed as argument to the likelihood function or as start_params
#     to fit
#   - internal params are what is used internally in the smoothing equations
# * Regarding fitting, bounds, fixing parameters, and internal parameters, the
#   overall workflow is the following:
#   - get start parameters in the form of external parameters (includes fixed
#     parameters)
#   - transform external parameters to internal parameters, bounding all that
#     are missing -> now we have some missing parameters, but potentially also
#     some user-specified bounds
#   - set bounds for fixed parameters
#   - make sure that starting parameters are within bounds
#   - set up the constraint bounds and function
# * Since the traditional bounds are nonlinear for beta and gamma, if no bounds
#   are given, we internally use beta_star and gamma_star for fitting
# * When estimating initial level and initial seasonal values, one of them has
#   to be removed in order to have a well posed problem. I am solving this by
#   fixing the last initial seasonal value to 0 (for additive seasonality) or 1
#   (for multiplicative seasonality).
#   For the additive models, this means I have to subtract the last initial
#   seasonal value from all initial seasonal values and add it to the initial
#   level; for the multiplicative models I do the same with division and
#   multiplication


class ETSModel(base.StateSpaceMLEModel):
    r"""
    ETS models.

    Parameters
    ----------
    endog : array_like
        The observed time-series process :math:`y`
    error : str, optional
        The error model. "add" (default) or "mul".
    trend : str or None, optional
        The trend component model. "add", "mul", or None (default).
    damped_trend : bool, optional
        Whether or not an included trend component is damped. Default is
        False.
    seasonal : str, optional
        The seasonality model. "add", "mul", or None (default).
    seasonal_periods : int, optional
        The number of periods in a complete seasonal cycle for seasonal
        (Holt-Winters) models. For example, 4 for quarterly data with an
        annual cycle or 7 for daily data with a weekly cycle. Required if
        `seasonal` is not None.
    initialization_method : str, optional
        Method for initialization of the state space model. One of:

        * 'estimated' (default)
        * 'heuristic'
        * 'known'

        If 'known' initialization is used, then `initial_level` must be
        passed, as well as `initial_trend` and `initial_seasonal` if
        applicable.
        'heuristic' uses a heuristic based on the data to estimate initial
        level, trend, and seasonal state. 'estimated' uses the same heuristic
        as initial guesses, but then estimates the initial states as part of
        the fitting process.  Default is 'estimated'.
    initial_level : float, optional
        The initial level component. Only used if initialization is 'known'.
    initial_trend : float, optional
        The initial trend component. Only used if initialization is 'known'.
    initial_seasonal : array_like, optional
        The initial seasonal component. An array of length `seasonal_periods`.
        Only used if initialization is 'known'.
    bounds : dict or None, optional
        A dictionary with parameter names as keys and the respective bounds
        intervals as values (lists/tuples/arrays).
        The available parameter names are, depending on the model and
        initialization method:

        * "smoothing_level"
        * "smoothing_trend"
        * "smoothing_seasonal"
        * "damping_trend"
        * "initial_level"
        * "initial_trend"
        * "initial_seasonal.0", ..., "initial_seasonal.<m-1>"

        The default option is ``None``, in which case the traditional
        (nonlinear) bounds as described in [1]_ are used.

    Notes
    -----
    The ETS models are a family of time series models. They can be seen as a
    generalization of simple exponential smoothing to time series that contain
    trends and seasonalities. Additionally, they have an underlying state
    space model.

    An ETS model is specified by an error type (E; additive or multiplicative),
    a trend type (T; additive or multiplicative, both damped or undamped, or
    none), and a seasonality type (S; additive or multiplicative or none).
    The following gives a very short summary, a more thorough introduction can
    be found in [1]_.

    Denote with :math:`\circ_b` the trend operation (addition or
    multiplication), with :math:`\circ_d` the operation linking trend and
    dampening factor :math:`\phi` (multiplication if trend is additive, power
    if trend is multiplicative), and with :math:`\circ_s` the seasonality
    operation (addition or multiplication). Furthermore, let :math:`\ominus`
    be the respective inverse operation (subtraction or division).

    With this, it is possible to formulate the ETS models as a forecast
    equation and 3 smoothing equations. The former is used to forecast
    observations, the latter are used to update the internal state.

    .. math::

        \hat{y}_{t|t-1} &= (l_{t-1} \circ_b (b_{t-1}\circ_d \phi))
                           \circ_s s_{t-m}\\
        l_{t} &= \alpha (y_{t} \ominus_s s_{t-m})
                 + (1 - \alpha) (l_{t-1} \circ_b (b_{t-1} \circ_d \phi))\\
        b_{t} &= \beta/\alpha (l_{t} \ominus_b l_{t-1})
                 + (1 - \beta/\alpha) b_{t-1}\\
        s_{t} &= \gamma (y_t \ominus_s (l_{t-1} \circ_b (b_{t-1}\circ_d\phi))
                 + (1 - \gamma) s_{t-m}

    The notation here follows [1]_; :math:`l_t` denotes the level at time
    :math:`t`, `b_t` the trend, and `s_t` the seasonal component. :math:`m`
    is the number of seasonal periods, and :math:`\phi` a trend damping
    factor. The parameters :math:`\alpha, \beta, \gamma` are the smoothing
    parameters, which are called ``smoothing_level``, ``smoothing_trend``, and
    ``smoothing_seasonal``, respectively.

    Note that the formulation above as forecast and smoothing equation does
    not distinguish different error models -- it is the same for additive and
    multiplicative errors. But the different error models lead to different
    likelihood models, and therefore will lead to different fit results.

    The error models specify how the true values :math:`y_t` are
    updated. In the additive error model,

    .. math::

        y_t = \hat{y}_{t|t-1} + e_t,

    in the multiplicative error model,

    .. math::

        y_t = \hat{y}_{t|t-1}\cdot (1 + e_t).

    Using these error models, it is possible to formulate state space
    equations for the ETS models:

    .. math::

       y_t &= Y_t + \eta \cdot e_t\\
       l_t &= L_t + \alpha \cdot (M_e \cdot L_t + \kappa_l) \cdot e_t\\
       b_t &= B_t + \beta \cdot (M_e \cdot B_t + \kappa_b) \cdot e_t\\
       s_t &= S_t + \gamma \cdot (M_e \cdot S_t+\kappa_s)\cdot e_t\\

    with

    .. math::

       B_t &= b_{t-1} \circ_d \phi\\
       L_t &= l_{t-1} \circ_b B_t\\
       S_t &= s_{t-m}\\
       Y_t &= L_t \circ_s S_t,

    and

    .. math::

       \eta &= \begin{cases}
                   Y_t\quad\text{if error is multiplicative}\\
                   1\quad\text{else}
               \end{cases}\\
       M_e &= \begin{cases}
                   1\quad\text{if error is multiplicative}\\
                   0\quad\text{else}
               \end{cases}\\

    and, when using the additive error model,

    .. math::

       \kappa_l &= \begin{cases}
                   \frac{1}{S_t}\quad
                   \text{if seasonality is multiplicative}\\
                   1\quad\text{else}
               \end{cases}\\
       \kappa_b &= \begin{cases}
                   \frac{\kappa_l}{l_{t-1}}\quad
                   \text{if trend is multiplicative}\\
                   \kappa_l\quad\text{else}
               \end{cases}\\
       \kappa_s &= \begin{cases}
                   \frac{1}{L_t}\quad\text{if seasonality is multiplicative}\\
                   1\quad\text{else}
               \end{cases}

    When using the multiplicative error model

    .. math::

       \kappa_l &= \begin{cases}
                   0\quad
                   \text{if seasonality is multiplicative}\\
                   S_t\quad\text{else}
               \end{cases}\\
       \kappa_b &= \begin{cases}
                   \frac{\kappa_l}{l_{t-1}}\quad
                   \text{if trend is multiplicative}\\
                   \kappa_l + l_{t-1}\quad\text{else}
               \end{cases}\\
       \kappa_s &= \begin{cases}
                   0\quad\text{if seasonality is multiplicative}\\
                   L_t\quad\text{else}
               \end{cases}

    When fitting an ETS model, the parameters :math:`\alpha, \beta`, \gamma,
    \phi` and the initial states `l_{-1}, b_{-1}, s_{-1}, \ldots, s_{-m}` are
    selected as maximizers of log likelihood.

    References
    ----------
    .. [1] Hyndman, R.J., & Athanasopoulos, G. (2019) *Forecasting:
       principles and practice*, 3rd edition, OTexts: Melbourne,
       Australia. OTexts.com/fpp3. Accessed on April 19th 2020.
    """

    def __init__(
        self,
        endog,
        error="add",
        trend=None,
        damped_trend=False,
        seasonal=None,
        seasonal_periods=None,
        initialization_method="estimated",
        initial_level=None,
        initial_trend=None,
        initial_seasonal=None,
        bounds=None,
        dates=None,
        freq=None,
        missing="none",
    ):

        super().__init__(
            endog, exog=None, dates=dates, freq=freq, missing=missing
        )

        # MODEL DEFINITION
        # ================
        options = ("add", "mul", "additive", "multiplicative")
        # take first three letters of option -> either "add" or "mul"
        self.error = string_like(error, "error", options=options)[:3]
        self.trend = string_like(
            trend, "trend", options=options, optional=True
        )
        if self.trend is not None:
            self.trend = self.trend[:3]
        self.damped_trend = bool_like(damped_trend, "damped_trend")
        self.seasonal = string_like(
            seasonal, "seasonal", options=options, optional=True
        )
        if self.seasonal is not None:
            self.seasonal = self.seasonal[:3]

        self.has_trend = self.trend is not None
        self.has_seasonal = self.seasonal is not None

        if self.has_seasonal:
            self.seasonal_periods = int_like(
                seasonal_periods, "seasonal_periods", optional=True
            )
            if seasonal_periods is None:
                self.seasonal_periods = freq_to_period(self._index_freq)
            if self.seasonal_periods <= 1:
                raise ValueError("seasonal_periods must be larger than 1.")
        else:
            # in case the model has no seasonal component, we internally handle
            # this as if it had an additive seasonal component with
            # seasonal_periods=1, but restrict the smoothing parameter to 0 and
            # set the initial seasonal to 0.
            self.seasonal_periods = 1

        # reject invalid models
        if np.any(self.endog <= 0) and (
            self.error == "mul"
            or self.trend == "mul"
            or self.seasonal == "mul"
        ):
            raise ValueError(
                "endog must be strictly positive when using"
                "multiplicative error, trend or seasonal components."
            )
        if self.damped_trend and not self.has_trend:
            raise ValueError("Can only dampen the trend component")

        # INITIALIZATION METHOD
        # =====================
        self.set_initialization_method(
            initialization_method,
            initial_level,
            initial_trend,
            initial_seasonal,
        )

        # BOUNDS
        # ======
        self.set_bounds(bounds)

        # SMOOTHER
        # ========
        if self.trend == "add" or self.trend is None:
            if self.seasonal == "add" or self.seasonal is None:
                self._smoothing_func = smooth._ets_smooth_add_add
            else:
                self._smoothing_func = smooth._ets_smooth_add_mul
        else:
            if self.seasonal == "add" or self.seasonal is None:
                self._smoothing_func = smooth._ets_smooth_mul_add
            else:
                self._smoothing_func = smooth._ets_smooth_mul_mul

    def set_initialization_method(
        self,
        initialization_method,
        initial_level=None,
        initial_trend=None,
        initial_seasonal=None,
    ):
        """
        Sets a new initialization method for the state space model.

        Parameters
        ----------
        initialization_method : str, optional
            Method for initialization of the state space model. One of:

            * 'estimated' (default)
            * 'heuristic'
            * 'known'

            If 'known' initialization is used, then `initial_level` must be
            passed, as well as `initial_trend` and `initial_seasonal` if
            applicable.
            'heuristic' uses a heuristic based on the data to estimate initial
            level, trend, and seasonal state. 'estimated' uses the same
            heuristic as initial guesses, but then estimates the initial states
            as part of the fitting process. Default is 'estimated'.
        initial_level : float, optional
            The initial level component. Only used if initialization is
            'known'.
        initial_trend : float, optional
            The initial trend component. Only used if initialization is
            'known'.
        initial_seasonal : array_like, optional
            The initial seasonal component. An array of length
            `seasonal_periods`. Only used if initialization is 'known'.
        """
        self.initialization_method = string_like(
            initialization_method,
            "initialization_method",
            options=("estimated", "known", "heuristic"),
        )
        if self.initialization_method == "known":
            if initial_level is None:
                raise ValueError(
                    "`initial_level` argument must be provided"
                    ' when initialization method is set to "known".'
                )
            if self.has_trend and initial_trend is None:
                raise ValueError(
                    "`initial_trend` argument must be provided"
                    " for models with a trend component when"
                    ' initialization method is set to "known".'
                )
            if self.has_seasonal and initial_seasonal is None:
                raise ValueError(
                    "`initial_seasonal` argument must be provided"
                    " for models with a seasonal component when"
                    ' initialization method is set to "known".'
                )
        elif (
            self.initialization_method == "heuristic"
            or self.initialization_method == "estimated"
        ):
            (
                initial_level,
                initial_trend,
                initial_seasonal,
            ) = _initialization_heuristic(
                self.endog,
                trend=self.trend,
                seasonal=self.seasonal,
                seasonal_periods=self.seasonal_periods,
            )
        if not self.has_trend:
            initial_trend = 0
        if not self.has_seasonal:
            initial_seasonal = 0
        self.initial_level = initial_level
        self.initial_trend = initial_trend
        self.initial_seasonal = initial_seasonal

        # we also have to reset the params index dictionaries
        self._internal_params_index = OrderedDict(
            zip(self._internal_param_names, np.arange(self._k_params_internal))
        )
        self._params_index = OrderedDict(
            zip(self.param_names, np.arange(self.k_params))
        )

    def set_bounds(self, bounds):
        """
        Set bounds for parameter estimation.

        Parameters
        ----------
        bounds : dict or None, optional
            A dictionary with parameter names as keys and the respective bounds
            intervals as values (lists/tuples/arrays).
            The available parameter names are in ``self.param_names``.
            The default option is ``None``, in which case the traditional
            (nonlinear) bounds as described in [1]_ are used.

        References
        ----------
        .. [1] Hyndman, R.J., & Athanasopoulos, G. (2019) *Forecasting:
           principles and practice*, 3rd edition, OTexts: Melbourne,
           Australia. OTexts.com/fpp3. Accessed on April 19th 2020.
        """
        if bounds is None:
            self.bounds = {}
        else:
            if not isinstance(bounds, (dict, OrderedDict)):
                raise ValueError("bounds must be a dictionary")
            for key in bounds:
                if key not in self.param_names:
                    raise ValueError(
                        f"Invalid key: {key} in bounds dictionary"
                    )
                bounds[key] = array_like(
                    bounds[key], f"bounds[{key}]", shape=(2,)
                )
            self.bounds = bounds

    @staticmethod
    def prepare_data(data):
        """
        Prepare data for use in the state space representation
        """
        endog = np.array(data.orig_endog, order="C")
        if endog.ndim != 1:
            raise ValueError("endog must be 1-dimensional")
        return endog, None

    @property
    def nobs_effective(self):
        return self.nobs

    @property
    def k_endog(self):
        return 1

    @property
    def short_name(self):
        name = "".join(
            [
                str(s)[0].upper()
                for s in [self.error, self.trend, self.seasonal]
            ]
        )
        if self.damped_trend:
            name = name[0:2] + "d" + name[2]
        return name

    @property
    def _param_names(self):
        param_names = ["smoothing_level"]
        if self.has_trend:
            param_names += ["smoothing_trend"]
        if self.has_seasonal:
            param_names += ["smoothing_seasonal"]
        if self.damped_trend:
            param_names += ["damping_trend"]

        # Initialization
        if self.initialization_method == "estimated":
            param_names += ["initial_level"]
            if self.has_trend:
                param_names += ["initial_trend"]
            if self.has_seasonal:
                param_names += [
                    f"initial_seasonal.{i}"
                    for i in range(self.seasonal_periods)
                ]
        return param_names

    @property
    def state_names(self):
        names = ["level"]
        if self.has_trend:
            names += ["trend"]
        if self.has_seasonal:
            names += ["seasonal"]
        return names

    @property
    def initial_state_names(self):
        names = ["initial_level"]
        if self.has_trend:
            names += ["initial_trend"]
        if self.has_seasonal:
            names += [
                f"initial_seasonal.{i}" for i in range(self.seasonal_periods)
            ]
        return names

    @property
    def _smoothing_param_names(self):
        return [
            "smoothing_level",
            "smoothing_trend",
            "smoothing_seasonal",
            "damping_trend",
        ]

    @property
    def _internal_initial_state_names(self):
        param_names = [
            "initial_level",
            "initial_trend",
        ]
        param_names += [
            f"initial_seasonal.{i}" for i in range(self.seasonal_periods)
        ]
        return param_names

    @property
    def _internal_param_names(self):
        return self._smoothing_param_names + self._internal_initial_state_names

    @property
    def _k_states(self):
        return 1 + int(self.has_trend) + int(self.has_seasonal)  # level

    @property
    def _k_states_internal(self):
        return 2 + self.seasonal_periods

    @property
    def _k_smoothing_params(self):
        return self._k_states + int(self.damped_trend)

    @property
    def _k_initial_states(self):
        return (
            1
            + int(self.has_trend)
            + +int(self.has_seasonal) * self.seasonal_periods
        )

    @property
    def k_params(self):
        k = self._k_smoothing_params
        if self.initialization_method == "estimated":
            k += self._k_initial_states
        return k

    @property
    def _k_params_internal(self):
        return 4 + 2 + self.seasonal_periods

    def _internal_params(self, params):
        """
        Converts a parameter array passed from outside to the internally used
        full parameter array.
        """
        # internal params that are not needed are all set to zero, except phi,
        # which is one
        internal = np.zeros(self._k_params_internal, dtype=params.dtype)
        for i, name in enumerate(self.param_names):
            internal_idx = self._internal_params_index[name]
            internal[internal_idx] = params[i]
        if not self.damped_trend:
            internal[3] = 1  # phi is 4th parameter
        if self.initialization_method != "estimated":
            internal[4] = self.initial_level
            internal[5] = self.initial_trend
            internal[6:] = self.initial_seasonal
        return internal

    def _model_params(self, internal):
        """
        Converts internal parameters to model parameters
        """
        params = np.empty(self.k_params)
        for i, name in enumerate(self.param_names):
            internal_idx = self._internal_params_index[name]
            params[i] = internal[internal_idx]
        return params

    @property
    def _seasonal_index(self):
        return 1 + int(self.has_trend)

    def _get_states(self, xhat):
        states = np.empty((self.nobs, self._k_states))
        all_names = ["level", "trend", "seasonal"]
        for i, name in enumerate(self.state_names):
            idx = all_names.index(name)
            states[:, i] = xhat[:, idx]
        return states

    def _get_internal_states(self, states, params):
        """
        Converts a state matrix/dataframe to the (nobs, 2+m) matrix used
        internally
        """
        internal_params = self._internal_params(params)
        if isinstance(states, (pd.Series, pd.DataFrame)):
            states = states.values
        internal_states = np.zeros((self.nobs, 2 + self.seasonal_periods))
        internal_states[:, 0] = states[:, 0]
        if self.has_trend:
            internal_states[:, 1] = states[:, 1]
        if self.has_seasonal:
            for j in range(self.seasonal_periods):
                internal_states[j:, 2 + j] = states[
                    0 : self.nobs - j, self._seasonal_index
                ]
                internal_states[0:j, 2 + j] = internal_params[6 : 6 + j][::-1]
        return internal_states

    @property
    def _default_start_params(self):
        return {
            "smoothing_level": 0.1,
            "smoothing_trend": 0.01,
            "smoothing_seasonal": 0.01,
            "damping_trend": 0.98,
        }

    @property
    def _start_params(self):
        """
        Default start params in the format of external parameters.
        This should not be called directly, but by calling
        ``self.start_params``.
        """
        params = []
        for p in self._smoothing_param_names:
            if p in self.param_names:
                params.append(self._default_start_params[p])

        if self.initialization_method == "estimated":
            lvl_idx = len(params)
            params += [self.initial_level]
            if self.has_trend:
                params += [self.initial_trend]
            if self.has_seasonal:
                # we have to adapt the seasonal values a bit to make sure the
                # problem is well posed (see implementation notes above)
                initial_seasonal = self.initial_seasonal
                if self.seasonal == "mul":
                    params[lvl_idx] *= initial_seasonal[-1]
                    initial_seasonal /= initial_seasonal[-1]
                else:
                    params[lvl_idx] += initial_seasonal[-1]
                    initial_seasonal -= initial_seasonal[-1]
                params += initial_seasonal.tolist()

        return np.array(params)

    def _convert_and_bound_start_params(self, params):
        """
        This converts start params to internal params, sets internal-only
        parameters as bounded, sets bounds for fixed parameters, and then makes
        sure that all start parameters are within the specified bounds.
        """
        internal_params = self._internal_params(params)
        # set bounds for missing and fixed
        for p in self._internal_param_names:
            idx = self._internal_params_index[p]
            if p not in self.param_names:
                # any missing parameters are set to the value they got from the
                # call to _internal_params
                self.bounds[p] = [internal_params[idx]] * 2
            elif self._has_fixed_params and p in self._fixed_params:
                self.bounds[p] = [self._fixed_params[p]] * 2
            # make sure everything is within bounds
            if p in self.bounds:
                internal_params[idx] = np.clip(
                    internal_params[idx]
                    + 1e-3,  # try not to start on boundary
                    *self.bounds[p],
                )
        return internal_params

    def _setup_bounds(self):
        # By default, we are using the traditional constraints for the
        # smoothing parameters if nothing else is specified
        #
        #    0 <     alpha     < 1
        #    0 <   beta/alpha  < 1
        #    0 < gamma + alpha < 1
        #  0.8 <      phi      < 0.98
        #
        # For initial states, no bounds are the default setting.
        #
        # Since the bounds for beta and gamma are not in the simple form of a
        # constant interval, we will use the parameters beta_star=beta/alpha
        # and gamma_star=gamma+alpha during fitting.

        lb = np.zeros(self._k_params_internal) + 1e-4
        ub = np.ones(self._k_params_internal) - 1e-4

        # other bounds for phi and initial states
        lb[3], ub[3] = 0.8, 0.98
        if self.initialization_method == "estimated":
            lb[4:-1] = -np.inf
            ub[4:-1] = np.inf
            # fix the last initial_seasonal to 0 or 1, otherwise the equation
            # is underdetermined
            if self.seasonal == "mul":
                lb[-1], ub[-1] = 1, 1
            else:
                lb[-1], ub[-1] = 0, 0

        # set lb and ub for parameters with bounds
        for p in self._internal_param_names:
            idx = self._internal_params_index[p]
            if p in self.bounds:
                lb[idx], ub[idx] = self.bounds[p]

        return [(lb[i], ub[i]) for i in range(self._k_params_internal)]

    def fit(
        self,
        start_params=None,
        maxiter=1000,
        full_output=True,
        disp=True,
        callback=None,
        return_params=False,
        **kwargs,
    ):
        r"""
        Fit an ETS model by maximizing log-likelihood.

        Log-likelihood is a function of the model parameters :math:`\alpha,
        \beta, \gamma, \phi` (depending on the chosen model), and, if
        `initialization_method` was set to `'estimated'` in the constructor,
        also the initial states :math:`l_{-1}, b_{-1}, s_{-1}, \ldots, s_{-m}`.

        The fit is performed using the L-BFGS algorithm.

        Parameters
        ----------
        start_params : array_like, optional
            Initial values for parameters that will be optimized. If this is
            ``None``, default values will be used.
            The length of this depends on the chosen model. This should contain
            the parameters in the following order, skipping parameters that do
            not exist in the chosen model.

            * `smoothing_level` (:math:`\alpha`)
            * `smoothing_trend` (:math:`\beta`)
            * `smoothing_seasonal` (:math:`\gamma`)
            * `damping_trend` (:math:`\phi`)

            If ``initialization_method`` was set to ``'estimated'`` (the
            default), additionally, the parameters

            * `initial_level` (:math:`l_{-1}`)
            * `initial_trend` (:math:`l_{-1}`)
            * `initial_seasonal.0` (:math:`s_{-1}`)
            * ...
            * `initial_seasonal.<m-1>` (:math:`s_{-m}`)

            also have to be specified.
        maxiter : int, optional
            The maximum number of iterations to perform.
        full_output : bool, optional
            Set to True to have all available output in the Results object's
            mle_retvals attribute. The output is dependent on the solver.
            See LikelihoodModelResults notes section for more information.
        disp : bool, optional
            Set to True to print convergence messages.
        callback : callable callback(xk), optional
            Called after each iteration, as callback(xk), where xk is the
            current parameter vector.
        return_params : bool, optional
            Whether or not to return only the array of maximizing parameters.
            Default is False.
        **kwargs
            Additional keyword arguments to pass to the optimizer.

        Returns
        -------
        results : ETSResults
        """

        if start_params is None:
            start_params = self.start_params
        else:
            start_params = np.asarray(start_params)

        if self._has_fixed_params and len(self._free_params_index) == 0:
            final_params = np.asarray(list(self._fixed_params.values()))
            mlefit = Bunch(
                params=start_params, mle_retvals=None, mle_settings=None
            )
        else:
            internal_start_params = self._convert_and_bound_start_params(
                start_params
            )
            bounds = self._setup_bounds()

            # check if we need to use the starred parameters
            use_beta_star = "smoothing_trend" not in self.bounds
            if use_beta_star:
                internal_start_params[1] /= internal_start_params[0]
            use_gamma_star = "smoothing_seasonal" not in self.bounds
            if use_gamma_star:
                internal_start_params[2] /= 1 - internal_start_params[0]

            # check if we have fixed parameters and remove them from the
            # parameter vector
            is_fixed = np.zeros(self._k_params_internal, dtype=int)
            fixed_values = np.empty_like(internal_start_params)
            params_without_fixed = []
            kwargs["bounds"] = []
            for i in range(self._k_params_internal):
                if bounds[i][0] == bounds[i][1]:
                    is_fixed[i] = True
                    fixed_values[i] = bounds[i][0]
                else:
                    params_without_fixed.append(internal_start_params[i])
                    kwargs["bounds"].append(bounds[i])
            params_without_fixed = np.asarray(params_without_fixed)

            # pre-allocate memory for smoothing results
            yhat = np.zeros(self.nobs)
            xhat = np.zeros((self.nobs, self._k_states_internal))

            kwargs["approx_grad"] = True
            with self.use_internal_loglike():
                mlefit = super().fit(
                    params_without_fixed,
                    fargs=(
                        yhat,
                        xhat,
                        is_fixed,
                        fixed_values,
                        use_beta_star,
                        use_gamma_star,
                    ),
                    method="lbfgs",
                    maxiter=maxiter,
                    full_output=full_output,
                    disp=disp,
                    callback=callback,
                    skip_hessian=True,
                    **kwargs,
                )
            # convert params back
            # first, insert fixed params
            fitted_params = np.empty_like(internal_start_params)
            idx_without_fixed = 0
            for i in range(self._k_params_internal):
                if is_fixed[i]:
                    fitted_params[i] = fixed_values[i]
                else:
                    fitted_params[i] = mlefit.params[idx_without_fixed]
                    idx_without_fixed += 1

            if use_beta_star:
                fitted_params[1] *= fitted_params[0]
            if use_gamma_star:
                fitted_params[2] *= 1 - fitted_params[0]
            final_params = self._model_params(fitted_params)

        if return_params:
            return final_params
        else:
            result = self.smooth(final_params)
            result.mlefit = mlefit
            result.mle_retvals = mlefit.mle_retvals
            result.mle_settings = mlefit.mle_settings
            return result

    def _loglike_internal(
        self,
        params,
        yhat,
        xhat,
        is_fixed=None,
        fixed_values=None,
        use_beta_star=False,
        use_gamma_star=False,
    ):
        """
        Log-likelihood function to be called from fit to avoid reallocation of
        memory.

        Parameters
        ----------
        params : np.ndarray of np.float
            Model parameters: (alpha, beta, gamma, phi, l[-1],
            b[-1], s[-1], ..., s[-m]). If there are no fixed values this must
            be in the format of internal parameters. Otherwise the fixed values
            are skipped.
        yhat : np.ndarray
            Array of size (n,) where fitted values will be written to.
        xhat : np.ndarray
            Array of size (n, _k_states_internal) where fitted states will be
            written to.
        is_fixed : np.ndarray or None
            Boolean array indicating values which are fixed during fitting.
            This must have the full length of internal parameters.
        fixed_values : np.ndarray or None
            Array of fixed values (arbitrary values for non-fixed parameters)
            This must have the full length of internal parameters.
        use_beta_star : boolean
            Whether to internally use beta_star as parameter
        use_gamma_star : boolean
            Whether to internally use gamma_star as parameter
        """
        if np.iscomplexobj(params):
            data = np.asarray(self.endog, dtype=complex)
        else:
            data = self.endog

        if is_fixed is None:
            is_fixed = np.zeros(self._k_params_internal, dtype=int)
            fixed_values = np.empty(
                self._k_params_internal, dtype=params.dtype
            )

        self._smoothing_func(
            params,
            data,
            yhat,
            xhat,
            is_fixed,
            fixed_values,
            use_beta_star,
            use_gamma_star,
        )
        res = self._residuals(yhat, data=data)
        logL = -self.nobs / 2 * (np.log(2 * np.pi * np.mean(res ** 2)) + 1)
        if self.error == "mul":
            logL -= np.sum(np.log(yhat))
            if np.isnan(logL):
                logL = np.inf
        return logL

    @contextlib.contextmanager
    def use_internal_loglike(self):
        external_loglike = self.loglike
        self.loglike = self._loglike_internal
        try:
            yield
        finally:
            self.loglike = external_loglike

    def loglike(self, params, **kwargs):
        r"""
        Log-likelihood of model.

        Parameters
        ----------
        params : np.ndarray of np.float
            Model parameters: (alpha, beta, gamma, phi, l[-1],
            b[-1], s[-1], ..., s[-m])

        Notes
        -----
        The log-likelihood of a exponential smoothing model is [1]_:

        .. math::

           l(\theta, x_0|y) = - \frac{n}{2}(\log(2\pi s^2) + 1)
                              - \sum\limits_{t=1}^n \log(k_t)

        with

        .. math::

           s^2 = \frac{1}{n}\sum\limits_{t=1}^n \frac{\hat{y}_t - y_t}{k_t}

        where :math:`k_t = 1` for the additive error model and :math:`k_t =
        y_t` for the multiplicative error model.

        References
        ----------
        .. [1] J. K. Ord, A. B. Koehler R. D. and Snyder (1997). Estimation and
           Prediction for a Class of Dynamic Nonlinear Statistical Models.
           *Journal of the American Statistical Association*, 92(440),
           1621-1629
        """
        params = self._internal_params(np.asarray(params))
        yhat = np.zeros(self.nobs, dtype=params.dtype)
        xhat = np.zeros(
            (self.nobs, self._k_states_internal), dtype=params.dtype
        )
        return self._loglike_internal(np.asarray(params), yhat, xhat)

    def _residuals(self, yhat, data=None):
        """Calculates residuals of a prediction"""
        if data is None:
            data = self.endog
        if self.error == "mul":
            return (data - yhat) / yhat
        else:
            return data - yhat

    def _smooth(self, params):
        """
        Exponential smoothing with given parameters

        Parameters
        ----------
        params : array_like
            Model parameters

        Returns
        -------
        yhat : pd.Series or np.ndarray
            Predicted values from exponential smoothing. If original data was a
            ``pd.Series``, returns a ``pd.Series``, else a ``np.ndarray``.
        xhat : pd.DataFrame or np.ndarray
            Internal states of exponential smoothing. If original data was a
            ``pd.Series``, returns a ``pd.DataFrame``, else a ``np.ndarray``.
        """
        internal_params = self._internal_params(params)
        yhat = np.zeros(self.nobs)
        xhat = np.zeros((self.nobs, self._k_states_internal))
        is_fixed = np.zeros(self._k_params_internal, dtype=int)
        fixed_values = np.empty(self._k_params_internal, dtype=params.dtype)
        self._smoothing_func(
            internal_params, self.endog, yhat, xhat, is_fixed, fixed_values
        )

        # remove states that are only internal
        states = self._get_states(xhat)

        if self.use_pandas:
            _, _, _, index = self._get_prediction_index(0, self.nobs - 1)
            yhat = pd.Series(yhat, index=index)
            statenames = ["level"]
            if self.has_trend:
                statenames += ["trend"]
            if self.has_seasonal:
                statenames += ["seasonal"]
            states = pd.DataFrame(states, index=index, columns=statenames)
        return yhat, states

    def smooth(self, params, return_raw=False):
        """
        Exponential smoothing with given parameters

        Parameters
        ----------
        params : array_like
            Model parameters
        return_raw : bool, optional
            Whether to return only the state space results or the full results
            object. Default is ``False``.

        Returns
        -------
        result : ETSResultsWrapper or tuple
            If ``return_raw=False``, returns a ETSResultsWrapper
            object. Otherwise a tuple of arrays or pandas objects, depending on
            the format of the endog data.
        """
        params = np.asarray(params)
        results = self._smooth(params)
        return self._wrap_results(params, results, return_raw)

    @property
    def _res_classes(self):
        return {"fit": (ETSResults, ETSResultsWrapper)}

    def hessian(
        self, params, approx_centered=False, approx_complex_step=True, **kwargs
    ):
        r"""
        Hessian matrix of the likelihood function, evaluated at the given
        parameters

        Parameters
        ----------
        params : array_like
            Array of parameters at which to evaluate the hessian.
        approx_centered : bool
            Whether to use a centered scheme for finite difference
            approximation
        approx_complex_step : bool
            Whether to use complex step differentiation for approximation

        Returns
        -------
        hessian : ndarray
            Hessian matrix evaluated at `params`

        Notes
        -----
        This is a numerical approximation.
        """
        method = kwargs.get("method", "approx")

        if method == "approx":
            if approx_complex_step:
                hessian = self._hessian_complex_step(params, **kwargs)
            else:
                hessian = self._hessian_finite_difference(
                    params, approx_centered=approx_centered, **kwargs
                )
        else:
            raise NotImplementedError("Invalid Hessian calculation method.")

        return hessian

    def score(
        self, params, approx_centered=False, approx_complex_step=True, **kwargs
    ):
        method = kwargs.get("method", "approx")

        if method == "approx":
            if approx_complex_step:
                score = self._score_complex_step(params, **kwargs)
            else:
                score = self._score_finite_difference(
                    params, approx_centered=approx_centered, **kwargs
                )
        else:
            raise NotImplementedError("Invalid score method.")

        return score

    def update(params, *args, **kwargs):
        # Dummy method to make methods copied from statespace.MLEModel work
        ...


class ETSResults(base.StateSpaceMLEResults):
    """
    Results from an error, trend, seasonal (ETS) exponential smoothing model
    """
    def __init__(self, model, params, results):
        yhat, xhat = results
        self._llf = model.loglike(params)
        self._residuals = model._residuals(yhat)
        self._fittedvalues = yhat
        # scale is concentrated in this model formulation and corresponds to
        # mean squared residuals, see docstring of model.loglike
        scale = np.mean(self._residuals ** 2)
        super().__init__(model, params, scale=scale)

        # get model definition
        model_definition_attrs = [
            "short_name",
            "error",
            "trend",
            "seasonal",
            "damped_trend",
            "has_trend",
            "has_seasonal",
            "seasonal_periods",
            "initialization_method",
        ]
        for attr in model_definition_attrs:
            setattr(self, attr, getattr(model, attr))
        self.param_names = [
            "%s (fixed)" % name if name in self.fixed_params else name
            for name in (self.model.param_names or [])
        ]

        # get fitted states and parameters
        internal_params = self.model._internal_params(params)
        self.states = xhat
        if self.model.use_pandas:
            states = self.states.iloc
        else:
            states = self.states
        self.initial_state = np.zeros(model._k_initial_states)

        self.level = states[:, 0]
        self.initial_level = internal_params[4]
        self.initial_state[0] = self.initial_level
        self.alpha = self.params[0]
        self.smoothing_level = self.alpha
        if self.has_trend:
            self.slope = states[:, 1]
            self.initial_trend = internal_params[5]
            self.initial_state[1] = self.initial_trend
            self.beta = self.params[1]
            self.smoothing_trend = self.beta
        if self.has_seasonal:
            self.season = states[:, self.model._seasonal_index]
            self.initial_seasonal = internal_params[6:]
            self.initial_state[
                self.model._seasonal_index :
            ] = self.initial_seasonal
            self.gamma = self.params[self.model._seasonal_index]
            self.smoothing_seasonal = self.gamma
        if self.damped_trend:
            self.phi = internal_params[3]
            self.damping_trend = self.phi

        # degrees of freedom of model
        k_free_params = self.k_params - len(self.fixed_params)
        self.df_model = k_free_params + 1

        # standardized forecasting error
        self.mean_resid = np.mean(self.resid)
        self.scale_resid = np.std(self.resid, ddof=1)
        self.standardized_forecasts_error = (
            self.resid - self.mean_resid
        ) / self.scale_resid

        # Setup covariance matrix notes dictionary
        # For now, only support "approx"
        if not hasattr(self, "cov_kwds"):
            self.cov_kwds = {}
        self.cov_type = "approx"

        # Setup the cache
        self._cache = {}

        # Handle covariance matrix calculation
        self._cov_approx_complex_step = True
        self._cov_approx_centered = False
        approx_type_str = "complex-step"
        try:
            self._rank = None
            if self.k_params == 0:
                self.cov_params_default = np.zeros((0, 0))
                self._rank = 0
                self.cov_kwds["description"] = "No parameters estimated."
            else:
                self.cov_params_default = self.cov_params_approx
                self.cov_kwds["description"] = descriptions["approx"].format(
                    approx_type=approx_type_str
                )
        except np.linalg.LinAlgError:
            self._rank = 0
            k_params = len(self.params)
            self.cov_params_default = np.zeros((k_params, k_params)) * np.nan
            self.cov_kwds["cov_type"] = (
                "Covariance matrix could not be calculated: singular."
                " information matrix."
            )

    @cache_readonly
    def nobs_effective(self):
        return self.nobs

    @cache_readonly
    def fittedvalues(self):
        return self._fittedvalues

    @cache_readonly
    def resid(self):
        return self._residuals

    @cache_readonly
    def llf(self):
        """
        log-likelihood function evaluated at the fitted params
        """
        return self._llf

    def _get_prediction_params(self, start_idx):
        """
        Returns internal parameter representation of smoothing parameters and
        "initial" states for prediction/simulation, that is the states just
        before the first prediction/simulation step.
        """
        internal_params = self.model._internal_params(self.params)
        if start_idx == 0:
            return internal_params
        else:
            internal_states = self.model._get_internal_states(
                self.states, self.params
            )
            start_state = np.empty(6 + self.seasonal_periods)
            start_state[0:4] = internal_params[0:4]
            start_state[4:] = internal_states[start_idx - 1, :]
            return start_state

    def _relative_forecast_variance(self, steps):
        """
        References
        ----------
        .. [1] Hyndman, R.J., & Athanasopoulos, G. (2019) *Forecasting:
           principles and practice*, 3rd edition, OTexts: Melbourne,
           Australia. OTexts.com/fpp3. Accessed on April 19th 2020.
        """
        h = steps
        alpha = self.smoothing_level
        if self.has_trend:
            beta = self.smoothing_trend
        if self.has_seasonal:
            gamma = self.smoothing_seasonal
            m = self.seasonal_periods
            k = np.asarray((h - 1) / m, dtype=int)
        if self.damped_trend:
            phi = self.damping_trend
        model = self.model.short_name
        if model == "ANN":
            return 1 + alpha ** 2 * (h - 1)
        elif model == "AAN":
            return 1 + (h - 1) * (
                alpha ** 2 + alpha * beta * h + beta ** 2 * h / 6 * (2 * h - 1)
            )
        elif model == "AAdN":
            return (
                1
                + alpha ** 2 * (h - 1)
                + (
                    (beta * phi * h)
                    / ((1 - phi) ** 2)
                    * (2 * alpha * (1 - phi) + beta * phi)
                )
                - (
                    (beta * phi * (1 - phi ** h))
                    / ((1 - phi) ** 2 * (1 - phi ** 2))
                    * (
                        2 * alpha * (1 - phi ** 2)
                        + beta * phi * (1 + 2 * phi - phi ** h)
                    )
                )
            )
        elif model == "ANA":
            return 1 + alpha ** 2 * (h - 1) + gamma * k * (2 * alpha + gamma)
        elif model == "AAA":
            return (
                1
                + (h - 1)
                * (
                    alpha ** 2
                    + alpha * beta * h
                    + (beta ** 2) / 6 * h * (2 * h - 1)
                )
                + gamma * k * (2 * alpha + gamma + beta * m * (k + 1))
            )
        elif model == "AAdA":
            return (
                1
                + alpha ** 2 * (h - 1)
                + gamma * k * (2 * alpha + gamma)
                + (beta * phi * h)
                / ((1 - phi) ** 2)
                * (2 * alpha * (1 - phi) + beta * phi)
                - (
                    (beta * phi * (1 - phi ** h))
                    / ((1 - phi) ** 2 * (1 - phi ** 2))
                    * (
                        2 * alpha * (1 - phi ** 2)
                        + beta * phi * (1 + 2 * phi - phi ** h)
                    )
                )
                + (
                    (2 * beta * gamma * phi)
                    / ((1 - phi) * (1 - phi ** m))
                    * (k * (1 - phi ** m) - phi ** m * (1 - phi ** (m * k)))
                )
            )
        else:
            raise NotImplementedError

    def simulate(
        self,
        nsimulations,
        anchor=None,
        repetitions=1,
        random_errors=None,
        random_state=None,
    ):
        r"""
        Random simulations using the state space formulation.

        Parameters
        ----------
        nsimulations : int
            The number of simulation steps.
        anchor : int, str, or datetime, optional
            First period for simulation. The simulation will be conditional on
            all existing datapoints prior to the `anchor`.  Type depends on the
            index of the given `endog` in the model. Two special cases are the
            strings 'start' and 'end'. `start` refers to beginning the
            simulation at the first period of the sample (i.e. using the
            initial values as simulation anchor), and `end` refers to
            beginning the simulation at the first period after the sample.
            Integer values can run from 0 to `nobs`, or can be negative to
            apply negative indexing. Finally, if a date/time index was provided
            to the model, then this argument can be a date string to parse or a
            datetime type. Default is 'start'.
            Note: `anchor` corresponds to the observation right before the
            `start` observation in the `predict` method.
        repetitions : int, optional
            Number of simulated paths to generate. Default is 1 simulated path.
        random_errors : optional
            Specifies how the random errors should be obtained. Can be one of
            the following:

            * ``None``: Random normally distributed values with variance
              estimated from the fit errors drawn from numpy's standard
              RNG (can be seeded with the `random_state` argument). This is the
              default option.
            * A distribution function from ``scipy.stats``, e.g.
              ``scipy.stats.norm``: Fits the distribution function to the fit
              errors and draws from the fitted distribution.
              Note the difference between ``scipy.stats.norm`` and
              ``scipy.stats.norm()``, the latter one is a frozen distribution
              function.
            * A frozen distribution function from ``scipy.stats``, e.g.
              ``scipy.stats.norm(scale=2)``: Draws from the frozen distribution
              function.
            * A ``np.ndarray`` with shape (`nsimulations`, `repetitions`): Uses
              the given values as random errors.
            * ``"bootstrap"``: Samples the random errors from the fit errors.

        random_state : int or np.random.RandomState, optional
            A seed for the random number generator or a
            ``np.random.RandomState`` object. Only used if `random_errors` is
            ``None``. Default is ``None``.

        Returns
        -------
        sim : pd.Series, pd.DataFrame or np.ndarray
            An ``np.ndarray``, ``pd.Series``, or ``pd.DataFrame`` of simulated
            values.
            If the original data was a ``pd.Series`` or ``pd.DataFrame``, `sim`
            will be a ``pd.Series`` if `repetitions` is 1, and a
            ``pd.DataFrame`` of shape (`nsimulations`, `repetitions`) else.
            Otherwise, if `repetitions` is 1, a ``np.ndarray`` of shape
            (`nsimulations`,) is returned, and if `repetitions` is not 1 a
            ``np.ndarray`` of shape (`nsimulations`, `repetitions`) is
            returned.
        """

        r"""
        Implementation notes
        --------------------
        The simulation is based on the state space model of the Holt-Winter's
        methods. The state space model assumes that the true value at time
        :math:`t` is randomly distributed around the prediction value.
        If using the additive error model, this means:

        .. math::

            y_t &= \hat{y}_{t|t-1} + e_t\\
            e_t &\sim \mathcal{N}(0, \sigma^2)

        Using the multiplicative error model:

        .. math::

            y_t &= \hat{y}_{t|t-1} \cdot (1 + e_t)\\
            e_t &\sim \mathcal{N}(0, \sigma^2)

        Inserting these equations into the smoothing equation formulation leads
        to the state space equations. The notation used here follows
        [1]_.

        Additionally,

        .. math::

           B_t = b_{t-1} \circ_d \phi\\
           L_t = l_{t-1} \circ_b B_t\\
           S_t = s_{t-m}\\
           Y_t = L_t \circ_s S_t,

        where :math:`\circ_d` is the operation linking trend and damping
        parameter (multiplication if the trend is additive, power if the trend
        is multiplicative), :math:`\circ_b` is the operation linking level and
        trend (addition if the trend is additive, multiplication if the trend
        is multiplicative), and :math:'\circ_s` is the operation linking
        seasonality to the rest.

        The state space equations can then be formulated as

        .. math::

           y_t = Y_t + \eta \cdot e_t\\
           l_t = L_t + \alpha \cdot (M_e \cdot L_t + \kappa_l) \cdot e_t\\
           b_t = B_t + \beta \cdot (M_e \cdot B_t+\kappa_b) \cdot e_t\\
           s_t = S_t + \gamma \cdot (M_e \cdot S_t + \kappa_s) \cdot e_t\\

        with

        .. math::

           \eta &= \begin{cases}
                       Y_t\quad\text{if error is multiplicative}\\
                       1\quad\text{else}
                   \end{cases}\\
           M_e &= \begin{cases}
                       1\quad\text{if error is multiplicative}\\
                       0\quad\text{else}
                   \end{cases}\\

        and, when using the additive error model,

        .. math::

           \kappa_l &= \begin{cases}
                       \frac{1}{S_t}\quad
                       \text{if seasonality is multiplicative}\\
                       1\quad\text{else}
                   \end{cases}\\
           \kappa_b &= \begin{cases}
                       \frac{\kappa_l}{l_{t-1}}\quad
                       \text{if trend is multiplicative}\\
                       \kappa_l\quad\text{else}
                   \end{cases}\\
           \kappa_s &= \begin{cases}
                       \frac{1}{L_t}\quad
                       \text{if seasonality is multiplicative}\\
                       1\quad\text{else}
                   \end{cases}

        When using the multiplicative error model

        .. math::

           \kappa_l &= \begin{cases}
                       0\quad
                       \text{if seasonality is multiplicative}\\
                       S_t\quad\text{else}
                   \end{cases}\\
           \kappa_b &= \begin{cases}
                       \frac{\kappa_l}{l_{t-1}}\quad
                       \text{if trend is multiplicative}\\
                       \kappa_l + l_{t-1}\quad\text{else}
                   \end{cases}\\
           \kappa_s &= \begin{cases}
                       0\quad\text{if seasonality is multiplicative}\\
                       L_t\quad\text{else}
                   \end{cases}

        References
        ----------
        .. [1] Hyndman, R.J., & Athanasopoulos, G. (2018) *Forecasting:
           principles and practice*, 2nd edition, OTexts: Melbourne,
           Australia. OTexts.com/fpp2. Accessed on February 28th 2020.
        """

        # Get the starting location
        start_idx = self._get_prediction_start_index(anchor)

        # set initial values and obtain parameters
        start_params = self._get_prediction_params(start_idx)
        x = np.zeros((nsimulations, self.model._k_states_internal))
        # is fixed and fixed values are dummy arguments
        is_fixed = np.zeros(len(start_params), dtype=int)
        fixed_values = np.zeros_like(start_params)
        (
            alpha,
            beta_star,
            gamma_star,
            phi,
            m,
            _,
        ) = smooth._initialize_ets_smooth(
            start_params, x, is_fixed, fixed_values
        )
        beta = alpha * beta_star
        gamma = (1 - alpha) * gamma_star
        # make x a 3 dimensional matrix: first dimension is nsimulations
        # (number of steps), next is number of states, innermost is repetitions
        nstates = x.shape[1]
        x = np.tile(np.reshape(x, (nsimulations, nstates, 1)), repetitions)
        y = np.empty((nsimulations, repetitions))

        # get random error eps
        sigma = np.sqrt(self.scale)
        if isinstance(random_errors, np.ndarray):
            if random_errors.shape != (nsimulations, repetitions):
                raise ValueError(
                    "If random is an ndarray, it must have shape "
                    "(nsimulations, repetitions)!"
                )
            eps = random_errors
        elif random_errors == "bootstrap":
            eps = np.random.choice(
                self.resid, size=(nsimulations, repetitions), replace=True
            )
        elif random_errors is None:
            if random_state is None:
                eps = np.random.randn(nsimulations, repetitions) * sigma
            elif isinstance(random_state, int):
                rng = np.random.RandomState(random_state)
                eps = rng.randn(nsimulations, repetitions) * sigma
            elif isinstance(random_state, np.random.RandomState):
                eps = random_state.randn(nsimulations, repetitions) * sigma
            else:
                raise ValueError(
                    "Argument random_state must be None, an integer, "
                    "or an instance of np.random.RandomState"
                )
        elif isinstance(random_errors, (rv_continuous, rv_discrete)):
            params = random_errors.fit(self.resid)
            eps = random_errors.rvs(*params, size=(nsimulations, repetitions))
        elif isinstance(random_errors, _distn_infrastructure.rv_frozen):
            eps = random_errors.rvs(size=(nsimulations, repetitions))
        else:
            raise ValueError("Argument random_errors has unexpected value!")

        # get model settings
        mul_seasonal = self.seasonal == "mul"
        mul_trend = self.trend == "mul"
        mul_error = self.error == "mul"

        # define trend, damping and seasonality operations
        if mul_trend:
            op_b = np.multiply
            op_d = np.power
        else:
            op_b = np.add
            op_d = np.multiply
        if mul_seasonal:
            op_s = np.multiply
        else:
            op_s = np.add

        # x translation:
        # - x[t, 0, :] is level[t]
        # - x[t, 1, :] is trend[t]
        # - x[t, 2, :] is seasonal[t]
        # - x[t, 3, :] is seasonal[t-1]
        # - x[t, 2+j, :] is seasonal[t-j]
        # - similarly: x[t-1, 2+m-1, :] is seasonal[t-m]
        for t in range(nsimulations):
            B = op_d(x[t - 1, 1, :], phi)
            L = op_b(x[t - 1, 0, :], B)
            S = x[t - 1, 2 + m - 1, :]
            Y = op_s(L, S)
            if self.error == "add":
                eta = 1
                kappa_l = 1 / S if mul_seasonal else 1
                kappa_b = kappa_l / x[t - 1, 0, :] if mul_trend else kappa_l
                kappa_s = 1 / L if mul_seasonal else 1
            else:
                eta = Y
                kappa_l = 0 if mul_seasonal else S
                kappa_b = (
                    kappa_l / x[t - 1, 0, :]
                    if mul_trend
                    else kappa_l + x[t - 1, 0, :]
                )
                kappa_s = 0 if mul_seasonal else L

            y[t, :] = Y + eta * eps[t, :]
            x[t, 0, :] = L + alpha * (mul_error * L + kappa_l) * eps[t, :]
            x[t, 1, :] = B + beta * (mul_error * B + kappa_b) * eps[t, :]
            x[t, 2, :] = S + gamma * (mul_error * S + kappa_s) * eps[t, :]
            # update seasonals by shifting previous seasonal right
            x[t, 3:, :] = x[t - 1, 2:-1, :]

        # Wrap data / squeeze where appropriate
        if repetitions > 1:
            names = ["simulation.%d" % num for num in range(repetitions)]
        else:
            names = "simulation"
        return self.model._wrap_data(
            y, start_idx, start_idx + nsimulations - 1, names=names
        )

    def forecast(self, steps=1):
        """
        Out-of-sample forecasts

        Parameters
        ----------
        steps : int, str, or datetime, optional
            If an integer, the number of steps to forecast from the end of the
            sample. Can also be a date string to parse or a datetime type.
            However, if the dates index does not have a fixed frequency, steps
            must be an integer. Default

        Returns
        -------
        forecast : ndarray
            Array of out of sample forecasts. A (steps x k_endog) array.
        """
        return self._forecast(steps, "end")

    def _forecast(self, steps, anchor):
        """
        Dynamic prediction/forecasting
        """
        # forecast is the same as simulation without errors
        return self.simulate(
            steps, anchor=anchor, random_errors=np.zeros((steps, 1))
        )

    def _handle_prediction_index(self, start, dynamic, end, index):
        if start is None:
            start = 0

        # Handle start, end, dynamic
        start, end, out_of_sample, _ = self.model._get_prediction_index(
            start, end, index
        )
        # if end was outside of the sample, it is now the last point in the
        # sample
        if start > end + out_of_sample + 1:
            raise ValueError(
                "Prediction start cannot lie outside of the sample."
            )

        # Handle `dynamic`
        if isinstance(dynamic, (str, dt.datetime, pd.Timestamp)):
            dynamic, _, _ = self.model._get_index_loc(dynamic)
            # Convert to offset relative to start
            dynamic = dynamic - start
        elif isinstance(dynamic, bool):
            if dynamic:
                dynamic = 0
            else:
                dynamic = end + 1 - start

        # start : index of first predicted value
        # dynamic : offset to first dynamically predicted value
        #     -> if dynamic == 0, only dynamic simulations
        if dynamic == 0:
            start_smooth = None
            end_smooth = None
            nsmooth = 0
            start_dynamic = start
        else:
            # dynamic simulations from start + dynamic
            start_smooth = start
            end_smooth = min(start + dynamic - 1, end)
            nsmooth = max(end_smooth - start_smooth + 1, 0)
            start_dynamic = start + dynamic
        # anchor for simulations is one before start_dynamic
        if start_dynamic == 0:
            anchor_dynamic = "start"
        else:
            anchor_dynamic = start_dynamic - 1
        # end is last point in sample, out_of_sample gives number of
        # simulations out of sample
        end_dynamic = end + out_of_sample
        ndynamic = end_dynamic - start_dynamic + 1
        return (
            start,
            end,
            start_smooth,
            end_smooth,
            anchor_dynamic,
            start_dynamic,
            end_dynamic,
            nsmooth,
            ndynamic,
            index,
        )

    def predict(self, start=None, end=None, dynamic=False, index=None):
        """
        In-sample prediction and out-of-sample forecasting

        Parameters
        ----------
        start : int, str, or datetime, optional
            Zero-indexed observation number at which to start forecasting,
            i.e., the first forecast is start. Can also be a date string to
            parse or a datetime type. Default is the the zeroth observation.
        end : int, str, or datetime, optional
            Zero-indexed observation number at which to end forecasting, i.e.,
            the last forecast is end. Can also be a date string to
            parse or a datetime type. However, if the dates index does not
            have a fixed frequency, end must be an integer index if you
            want out of sample prediction. Default is the last observation in
            the sample.
        dynamic : bool, int, str, or datetime, optional
            Integer offset relative to `start` at which to begin dynamic
            prediction. Can also be an absolute date string to parse or a
            datetime type (these are not interpreted as offsets).
            Prior to this observation, true endogenous values will be used for
            prediction; starting with this observation and continuing through
            the end of prediction, forecasted endogenous values will be used
            instead.
        index : pd.Index, optional
            Optionally an index to associate the predicted results to. If None,
            an attempt is made to create an index for the predicted results
            from the model's index or model's row labels.

        Returns
        -------
        forecast : array_like or pd.Series.
            Array of out of in-sample predictions and / or out-of-sample
            forecasts. An (npredict,) array. If original data was a pd.Series
            or DataFrame, a pd.Series is returned.
        """

        (
            start,
            end,
            start_smooth,
            end_smooth,
            anchor_dynamic,
            _,
            end_dynamic,
            nsmooth,
            ndynamic,
            index,
        ) = self._handle_prediction_index(start, dynamic, end, index)

        y = np.empty(nsmooth + ndynamic)

        # In sample nondynamic prediction: smoothing
        if nsmooth > 0:
            y[0:nsmooth] = self.fittedvalues[start_smooth : end_smooth + 1]

        # Out of sample/dynamic prediction: forecast
        if ndynamic > 0:
            y[nsmooth:] = self._forecast(ndynamic, anchor_dynamic)

        # when we are doing out of sample only prediction, start > end + 1, and
        # we only want to output beginning at start
        if start > end + 1:
            ndiscard = start - (end + 1)
            y = y[ndiscard:]

        # Wrap data / squeeze where appropriate
        return self.model._wrap_data(y, start, end_dynamic)

    def get_prediction(
        self,
        start=None,
        end=None,
        dynamic=False,
        index=None,
        method=None,
        simulate_repetitions=1000,
        **simulate_kwargs,
    ):
        """
        Calculates mean prediction and prediction intervals.

        Parameters
        ----------
        start : int, str, or datetime, optional
            Zero-indexed observation number at which to start forecasting,
            i.e., the first forecast is start. Can also be a date string to
            parse or a datetime type. Default is the the zeroth observation.
        end : int, str, or datetime, optional
            Zero-indexed observation number at which to end forecasting, i.e.,
            the last forecast is end. Can also be a date string to
            parse or a datetime type. However, if the dates index does not
            have a fixed frequency, end must be an integer index if you
            want out of sample prediction. Default is the last observation in
            the sample.
        dynamic : bool, int, str, or datetime, optional
            Integer offset relative to `start` at which to begin dynamic
            prediction. Can also be an absolute date string to parse or a
            datetime type (these are not interpreted as offsets).
            Prior to this observation, true endogenous values will be used for
            prediction; starting with this observation and continuing through
            the end of prediction, forecasted endogenous values will be used
            instead.
        index : pd.Index, optional
            Optionally an index to associate the predicted results to. If None,
            an attempt is made to create an index for the predicted results
            from the model's index or model's row labels.
        method : str or None, optional
            Method to use for calculating prediction intervals. 'exact'
            (default, if available) or 'simulated'.
        simulate_repetitions : int, optional
            Number of simulation repetitions for calculating prediction
            intervals when ``method='simulated'``. Default is 1000.
        **simulate_kwargs :
            Additional arguments passed to the ``simulate`` method.

        Returns
        -------
        PredictionResults
            Predicted mean values and prediction intervals
        """
        return PredictionResultsWrapper(
            PredictionResults(
                self,
                start,
                end,
                dynamic,
                index,
                method,
                simulate_repetitions,
                **simulate_kwargs,
            )
        )

    def summary(self, alpha=0.05, start=None):
        """
        Summarize the fitted model

        Parameters
        ----------
        alpha : float, optional
            Significance level for the confidence intervals. Default is 0.05.
        start : int, optional
            Integer of the start observation. Default is 0.

        Returns
        -------
        summary : Summary instance
            This holds the summary table and text, which can be printed or
            converted to various output formats.

        See Also
        --------
        statsmodels.iolib.summary.Summary
        """
        model_name = f"ETS({self.short_name})"

        summary = super().summary(
            alpha=alpha,
            start=start,
            title="ETS Results",
            model_name=model_name,
        )

        if self.model.initialization_method != "estimated":
            params = np.array(self.initial_state)
            if params.ndim > 1:
                params = params[0]
            names = self.model.initial_state_names
            param_header = [
                "initialization method: %s" % self.model.initialization_method
            ]
            params_stubs = names
            params_data = [
                [forg(params[i], prec=4)] for i in range(len(params))
            ]

            initial_state_table = SimpleTable(
                params_data, param_header, params_stubs, txt_fmt=fmt_params
            )
            summary.tables.insert(-1, initial_state_table)

        return summary


class ETSResultsWrapper(wrap.ResultsWrapper):
    _attrs = {
        "fittedvalues": "rows",
        "level": "rows",
        "resid": "rows",
        "season": "rows",
        "slope": "rows",
    }
    _wrap_attrs = wrap.union_dicts(
        tsbase.TimeSeriesResultsWrapper._wrap_attrs, _attrs
    )
    _methods = {"predict": "dates", "forecast": "dates"}
    _wrap_methods = wrap.union_dicts(
        tsbase.TimeSeriesResultsWrapper._wrap_methods, _methods
    )


wrap.populate_wrapper(ETSResultsWrapper, ETSResults)


class PredictionResults:
    """
    ETS mean prediction and prediction intervals

    Parameters
    ----------
    results : ETSResults
        Model estimation results.
    start : int, str, or datetime, optional
        Zero-indexed observation number at which to start forecasting,
        i.e., the first forecast is start. Can also be a date string to
        parse or a datetime type. Default is the the zeroth observation.
    end : int, str, or datetime, optional
        Zero-indexed observation number at which to end forecasting, i.e.,
        the last forecast is end. Can also be a date string to
        parse or a datetime type. However, if the dates index does not
        have a fixed frequency, end must be an integer index if you
        want out of sample prediction. Default is the last observation in
        the sample.
    dynamic : bool, int, str, or datetime, optional
        Integer offset relative to `start` at which to begin dynamic
        prediction. Can also be an absolute date string to parse or a
        datetime type (these are not interpreted as offsets).
        Prior to this observation, true endogenous values will be used for
        prediction; starting with this observation and continuing through
        the end of prediction, forecasted endogenous values will be used
        instead.
    index : pd.Index, optional
        Optionally an index to associate the predicted results to. If None,
        an attempt is made to create an index for the predicted results
        from the model's index or model's row labels.
    method : str or None, optional
        Method to use for calculating prediction intervals. 'exact' (default,
        if available) or 'simulated'.
    simulate_repetitions : int, optional
        Number of simulation repetitions for calculating prediction intervals.
        Default is 1000.
    **simulate_kwargs :
        Additional arguments passed to the ``simulate`` method.
    """

    def __init__(
        self,
        results,
        start=None,
        end=None,
        dynamic=False,
        index=None,
        method=None,
        simulate_repetitions=1000,
        **simulate_kwargs,
    ):
        self.use_pandas = results.model.use_pandas

        if method is None:
            exact_available = ["ANN", "AAN", "AAdN", "ANA", "AAA", "AAdA"]
            if results.model.short_name in exact_available:
                method = "exact"
            else:
                method = "simulated"
        self.method = method

        (
            start,
            end,
            start_smooth,
            _,
            anchor_dynamic,
            start_dynamic,
            end_dynamic,
            nsmooth,
            ndynamic,
            index,
        ) = results._handle_prediction_index(start, dynamic, end, index)

        self.predicted_mean = results.predict(
            start=start, end=end_dynamic, dynamic=dynamic, index=index
        )
        self.row_labels = self.predicted_mean.index
        self.endog = np.empty(nsmooth + ndynamic) * np.nan
        if nsmooth > 0:
            self.endog[0: (end - start + 1)] = results.data.endog[
                start: (end + 1)
            ]
        self.model = Bunch(
            data=results.model.data.__class__(
                endog=self.endog, predict_dates=self.row_labels
            )
        )

        if self.method == "simulated":

            sim_results = []
            # first, perform "non-dynamic" simulations, i.e. simulations of
            # only one step, based on the previous step
            if nsmooth > 1:
                if start_smooth == 0:
                    anchor = "start"
                else:
                    anchor = start_smooth - 1
                for i in range(nsmooth):
                    sim_results.append(
                        results.simulate(
                            1,
                            anchor=anchor,
                            repetitions=simulate_repetitions,
                            **simulate_kwargs,
                        )
                    )
                    # anchor
                    anchor = start_smooth + i
            sim_results.append(
                results.simulate(
                    ndynamic,
                    anchor=anchor_dynamic,
                    repetitions=simulate_repetitions,
                    **simulate_kwargs,
                )
            )
            self.simulation_results = np.concatenate(sim_results, axis=0)
            # if self.use_pandas:
            #     self.simulation_results = pd.DataFrame(
            #         self.simulation_results, index=self.row_labels,
            #         columns=sim_results[0].columns
            #     )
        else:  # method == 'exact'
            steps = np.ones(ndynamic + nsmooth)
            if ndynamic > 0:
                steps[
                    (start_dynamic - min(start_dynamic, start)):
                    ] = range(1, ndynamic + 1)
            # when we are doing out of sample only prediction,
            # start > end + 1, and
            # we only want to output beginning at start
            if start > end + 1:
                ndiscard = start - (end + 1)
                steps = steps[ndiscard:]
            self.forecast_variance = (
                results.mse * results._relative_forecast_variance(steps)
            )

    @property
    def var_pred_mean(self):
        """The variance of the predicted mean"""
        return self.forecast_variance

    def pred_int(self, alpha=0.05):
        """
        Calculates prediction intervals by performing multiple simulations.

        Parameters
        ----------
        alpha : float, optional
            The significance level for the prediction interval. Default is
            0.05, that is, a 95% prediction interval.
        """

        if self.method == "simulated":
            simulated_upper_pi = np.quantile(
                self.simulation_results, 1 - alpha / 2, axis=1
            )
            simulated_lower_pi = np.quantile(
                self.simulation_results, alpha / 2, axis=1
            )
            pred_int = np.vstack((simulated_lower_pi, simulated_upper_pi)).T
        else:
            q = norm.ppf(1 - alpha / 2)
            half_interval_size = q * np.sqrt(self.forecast_variance)
            pred_int = np.vstack(
                (
                    self.predicted_mean - half_interval_size,
                    self.predicted_mean + half_interval_size,
                )
            ).T

        if self.use_pandas:
            pred_int = pd.DataFrame(pred_int, index=self.row_labels)
            names = [
                f"lower PI (alpha={alpha:f})",
                f"upper PI (alpha={alpha:f})",
            ]
            pred_int.columns = names
        return pred_int

    def summary_frame(self, endog=0, alpha=0.05):
        pred_int = np.asarray(self.pred_int(alpha=alpha))
        to_include = {}
        to_include["mean"] = self.predicted_mean
        if self.method == "simulated":
            to_include["mean_numerical"] = np.mean(
                self.simulation_results, axis=1
            )
        to_include["pi_lower"] = pred_int[:, 0]
        to_include["pi_upper"] = pred_int[:, 1]

        res = pd.DataFrame(
            to_include, index=self.row_labels, columns=list(to_include.keys())
        )
        return res


class PredictionResultsWrapper(wrap.ResultsWrapper):
    _attrs = {
        "predicted_mean": "dates",
        "simulation_results": "dates",
        "endog": "dates",
    }
    _wrap_attrs = wrap.union_dicts(_attrs)

    _methods = {}
    _wrap_methods = wrap.union_dicts(_methods)


wrap.populate_wrapper(PredictionResultsWrapper, PredictionResults)  # noqa:E305
