import functools
from abc import ABC
from typing import Collection, Optional, Union

import numpy as np


def _format_simulator(_simulate):
    """Formats input and output of the _simulator method"""

    @functools.wraps(_simulate)
    def decorator(self, size: int, reps: Optional[int] = None):
        if reps is not None:
            if not isinstance(reps, int) or reps <= 0:
                raise ValueError('`reps` must be an integer greater than 0')

        return np.asarray(_simulate(self, size, reps))

    return decorator


class DistributionMixin(ABC):
    def __init__(self):
        self.custom_dist: Optional[np.ndarray] = None

    def derive_dist_size(self, size):
        """Checks that the size required for the simulation is within the generated custom dist bounds"""
        assert self.custom_dist is not None, 'derive_dist_size should not be called when custom_dist is not specified'

        if len(self.custom_dist) < size:
            times = round(np.ceil(size / len(self.custom_dist) * 100) / 100, 2)
            raise ValueError(f'rvs array too short. Increase the number of uniform rvs generated by {times}x')

    @property
    def custom_dist(self) -> Optional[np.ndarray]:
        """
        Optional density with fitted object from which to simulate. This "distribution" "replaces"
        the standard distribution (errors) generated from the GARCH instance during forecast.
        In essence, the error distributions could be generated from an external process, and then
        using inverse CDF of the underlying distribution (i.e. Skew-T, Normal, etc.), recover the
        GARCH forecasts during the simulation stage.

        It is understood that what is supplied are the standardized (0,1) innovations and not the
        unstandardized residuals. The usefulness of this becomes apparent when one is considering
        the copula-GARCH approach or the bootstrap method.

        Returns
        -------
        ndarray
            Array of density values or None
        """
        return self._custom_dist

    @custom_dist.setter
    def custom_dist(self, values: Optional[Union[np.ndarray, float]]):
        if values is None:
            self._custom_dist = None
        else:
            assert isinstance(values, Collection), "custom_dist should be an array"
            self._custom_dist = np.asarray(values)
