# -*- coding: utf-8 -*-
"""reV scheduled losses module.

"""
import logging
import warnings
import json

import numpy as np

from reV.losses.utils import (convert_to_full_month_names,
                              filter_unknown_month_names,
                              hourly_indices_for_months)
from reV.utilities.exceptions import reVLossesValueError, reVLossesWarning


logger = logging.getLogger(__name__)


class Outage:
    """A specific type of outage.

    This class stores and validates information about a single type of
    outage. In particular, the number of outages, duration, percentage
    of farm down, and the allowed months for scheduling the outage
    must all be provided. These inputs are then validated so that they
    can be used in instances of scheduling objects.

    """

    REQUIRED_KEYS = {'count',
                     'duration',
                     'percentage_of_capacity_lost',
                     'allowed_months'}
    """Required keys in the input specification dictionary."""

    def __init__(self, specs):
        """

        Parameters
        ----------
        specs : dict
            A dictionary containing specifications for this outage. This
            dictionary must contain the following keys:
                - `count`
                    An integer value representing the total number of
                    times this outage should be scheduled. This number
                    should be larger than 0.
                - `duration`
                    An integer value representing the total number of
                    consecutive hours that this outage should take. This
                    value must be larger than 0 and less than the number
                    of hours in the allowed months.
                - `percentage_of_capacity_lost`
                    An integer or float value representing the total
                    percentage of the total capacity that will be lost
                    for the duration of the outage. This value must be
                    in the range (0, 100].
                - `allowed_months`
                    A list of month names corresponding to the allowed
                    months for the scheduled outages. Month names can be
                    unformatted and can be specified using 3-letter
                    month abbreviations.
            The input dictionary can also provide the following optional
            keys:
                - `allow_outage_overlap` - by default, ``True``
                    A bool flag indicating whether or not this outage is
                    allowed to overlap with other outages, including
                    itself. It is recommended to set this value to
                    ``True`` whenever possible, as it allows for more
                    flexible scheduling.
                - `name` - by default, string containing init parameters
                    A unique name for the outage, used for more
                    descriptive error messages.

        """
        self._specs = specs
        self._full_month_names = None
        self._total_available_hours = None
        self._name = None
        self._validate()

    def _validate(self):
        """Validate the input specs."""
        self._validate_required_keys_exist()
        self._validate_count()
        self._validate_and_convert_to_full_name_months()
        self._validate_duration()
        self._validate_percentage()

    def _validate_required_keys_exist(self):
        """Raise an error if any required keys are missing."""
        missing_keys = [n for n in self.REQUIRED_KEYS if n not in self._specs]
        if any(missing_keys):
            msg = ("The following required keys are missing from the Outage "
                   "specification: {}".format(sorted(missing_keys)))
            logger.error(msg)
            raise reVLossesValueError(msg)

    def _validate_count(self):
        """Validate that the total number of outages is an integer. """
        if not isinstance(self.count, int):
            msg = ("Number of outages must be an integer, but got {} for {}"
                   .format(self.count, self.name))
            logger.error(msg)
            raise reVLossesValueError(msg)

        if self.count < 1:
            msg = ("Number of outages must be greater than 0, but got "
                   "{} for {}".format(self.count, self.name))
            logger.error(msg)
            raise reVLossesValueError(msg)

    def _validate_and_convert_to_full_name_months(self):
        """Validate month input and convert to full month names. """
        months = convert_to_full_month_names(self._specs['allowed_months'])
        known_months, unknown_months = filter_unknown_month_names(months)

        if unknown_months:
            msg = ("The following month names were not understood: {}. Please "
                   "use either the full month name or the standard 3-letter "
                   "month abbreviation. For more info, see the month name "
                   "documentation for the python standard package `calendar`."
                   .format(unknown_months))
            logger.warning(msg)
            warnings.warn(msg, reVLossesWarning)

        if not known_months:
            msg = ("No known month names were provided! Please use either the "
                   "full month name or the standard 3-letter month "
                   "abbreviation. For more info, see the month name "
                   "documentation for the python standard package `calendar`. "
                   "Received input: {!r}"
                   .format(self._specs['allowed_months']))
            logger.error(msg)
            raise reVLossesValueError(msg)

        self._full_month_names = list(set(known_months))

    def _validate_duration(self):
        """Validate that the duration is between 0 and the max total. """
        if not isinstance(self.duration, int):
            msg = ("Duration must be an integer number of hours, "
                   "but got {} for {}".format(self.duration, self.name))
            logger.error(msg)
            raise reVLossesValueError(msg)

        if not 1 <= self.duration <= self.total_available_hours:
            msg = ("Duration of outage must be between 1 and the total "
                   "available hours based on allowed month input ({} for "
                   "a total hour count of {}), but got {} for {}"
                   .format(self.allowed_months, self.total_available_hours,
                           self.percentage_of_capacity_lost, self.name))
            logger.error(msg)
            raise reVLossesValueError(msg)

    def _validate_percentage(self):
        """Validate that the percentage is in the range (0, 100]. """
        if not 0 < self.percentage_of_capacity_lost <= 100:
            msg = ("Percentage of farm down during outage must be in the "
                   "range (0, 100], but got {} for {}"
                   .format(self.percentage_of_capacity_lost, self.name))
            logger.error(msg)
            raise reVLossesValueError(msg)

    def __repr__(self):
        return "Outage({!r})".format(self._specs)

    def __str__(self):
        if self._name is None:
            self._name = self._specs.get('name') or self._default_name()
        return self._name

    def _default_name(self):
        """Generate a default name for the outage."""
        specs = self._specs.copy()
        specs.update({'allowed_months': self.allowed_months,
                      'allow_outage_overlap': self.allow_outage_overlap})
        specs_as_str = ", ".join(["{}={}".format(k, v)
                                  for k, v in specs.items()])
        return "Outage({})".format(specs_as_str)

    @property
    def count(self):
        """int: Total number of times outage should be scheduled."""
        return self._specs['count']

    @property
    def duration(self):
        """int: Total number of consecutive hours per outage."""
        return self._specs['duration']

    @property
    def percentage_of_capacity_lost(self):
        """int | float: Percent of capacity taken down per outage."""
        return self._specs['percentage_of_capacity_lost']

    @property
    def allowed_months(self):
        """list: Months during which outage can be scheduled."""
        return self._full_month_names

    @property
    def allow_outage_overlap(self):
        """bool: Indicator for overlap with other outages."""
        return self._specs.get('allow_outage_overlap', True)

    @property
    def name(self):
        """str: Name of the outage."""
        return self._specs.get('name', str(self))

    @property
    def total_available_hours(self):
        """int: Total number of hours avialbale based on allowed months."""
        if self._total_available_hours is None:
            self._total_available_hours = len(
                hourly_indices_for_months(self.allowed_months))
        return self._total_available_hours


class OutageScheduler:
    """A scheduler for multiple input outages.

    Given a list of information about different types of desired
    outages, this class leverages the stochastic scheduling routines of
    :class:`SingleOutageScheduler` to calculate the total losses due to
    the input outages on an hourly basis.

    Attributes
    ----------
    outages : :obj:`list` of :obj:`Outages <Outage>`
        The user-provided list of :obj:`Outages <Outage>` containing
        info about all types of outages to be scheduled.
    seed : :obj:`int`
        The seed value used to seed the random generator in order
        to produce random but reproducible losses. This is useful
        for ensuring that stochastically scheduled losses vary
        between different sites (i.e. that randomly scheduled
        outages in two different location do not match perfectly on
        an hourly basis).
    total_losses : :obj:`np.array`
        An array (of length 8760) containing the per-hour total loss
        percentage resulting from the stochastically scheduled outages.
        This array contains only zero values before the
        :meth:`~OutageScheduler.calculate` method is run.
    can_schedule_more : :obj:`np.array`
        A boolean array (of length 8760) indicating wether or not more
        losses can be scheduled for a given hour. This array keeps track
        of all the scheduling conflicts between input outages.

    Warnings
    --------
    It is possible that not all outages input by the user will be
    scheduled. This can happen when there is not enough time allowed
    for all of the input outages. To avoid this issue, always be sure to
    allow a large enough month range for long outages that take up a big
    portion of the farm and try to allow outage overlap whenever
    possible.

    See Also
    --------
    :class:`SingleOutageScheduler` : Single outage scheduler.
    :class:`Outage` : Specifications for a single outage.
    """

    def __init__(self, outages, seed=0):
        """
        Parameters
        ----------
        outages : list of :obj:`Outages <Outage>`
            A list of :obj:`Outages <Outage>`, where each :obj:`Outage`
            contains info about a single type of outage. See the
            documentation of :class:`Outage` for a description of the
            required keys of each outage dictionary.
        seed : int, optional
            An integer value used to seed the random generator in order
            to produce random but reproducible losses. This is useful
            for ensuring that stochastically scheduled losses vary
            between different sites (i.e. that randomly scheduled
            outages in two different location do not match perfectly on
            an hourly basis). By default, the seed is set to 0.
        """
        self.outages = outages
        self.seed = seed
        self.total_losses = np.zeros(8760)
        self.can_schedule_more = np.full(8760, True)

    def calculate(self):
        """Calculate total losses from stochastically scheduled outages.

        This function calls :meth:`SingleOutageScheduler.calculate`
        on every outage input (sorted by largest duration and then
        largest number of outages) and returns the aggregate the losses
        from the result.

        Returns
        -------
        :obj:`np.array`
            An array (of length 8760) containing the per-hour total loss
            percentage resulting from the stochastically scheduled
            outages.
        """
        sorted_outages = sorted(self.outages,
                                key=lambda o: (o.duration,
                                               o.count,
                                               o.percentage_of_capacity_lost,
                                               sum(sum(map(ord, name))
                                                   for name
                                                   in o.allowed_months),
                                               o.allow_outage_overlap))
        for outage in sorted_outages[::-1]:
            self.seed += 1
            SingleOutageScheduler(outage, self).calculate()
        return self.total_losses


class SingleOutageScheduler:
    """A scheduler for a single outage.

    Given information about a single type of outage, this class
    facilitates the (randomized) scheduling of all requested instances
    of the outage. See :meth:`SingleOutageScheduler.calculate` for
    specific details about the scheduling process.

    Attributes
    ----------
    outage : :obj:`Outage`
        The user-provided :obj:`Outage` containing info about the outage
        to be scheduled.
    scheduler : :obj:`OutageScheduler`
        A scheduler object that keeps track of the total hourly losses
        from the input outage as well as any other outages it has
        already scheduled.
    can_schedule_more : :obj:`np.array`
        A boolean array (of length 8760) indicating wether or not more
        losses can be scheduled for a given hour. This is specific
        to the input outage only.

    Warnings
    --------
    It is possible that not all outages input by the user can be
    scheduled. This can happen when there is not enough time allowed
    for all of the input outages. To avoid this issue, always be sure to
    allow a large enough month range for long outages that take up a big
    portion of the farm and try to allow outage overlap whenever
    possible.

    See Also
    --------
    :class:`OutageScheduler` : Scheduler for multiple outages.
    :class:`Outage` : Specifications for a single outage.
    """

    MAX_ITER = 10_000
    """Max number of extra attempts to schedule outages."""

    def __init__(self, outage, scheduler):
        """

        Parameters
        ----------
        outage : Outage
            An outage object contianing info about the outage to be
            scheduled.
        scheduler : OutageScheduler
            A scheduler object that keeps track of the total hourly
            losses from the input outage as well as any other outages
            it has already scheduled.
        """
        self.outage = outage
        self.scheduler = scheduler
        self.can_schedule_more = np.full(8760, False)
        self._scheduled_outage_inds = []

    def calculate(self):
        """Calculate losses from stochastically scheduled outages.

        This function attempts to schedule outages according to the
        specification provided in the :obj:`Outage` input. Specifically,
        it checks the available hours based on the main
        :obj:`Scheduler <OutageScheduler>` (which may have other outages
        already scheduled) and attempts to randomly add new outages with
        the specified duration and percent of losses. The function
        terminates when the desired number of outages (specified by
        :attr:`Outage.count`) have been successfully scheduled, or when
        the number of attempts exceeds
        :attr:`~SingleOutageScheduler.MAX_ITER` + :attr:`Outage.count`.

        Warns
        -----
        reVLossesWarning
            If the number of requested outages could not be scheduled.
        """
        self.update_when_can_schedule_from_months()

        for iter_ind in range(self.outage.count + self.MAX_ITER):
            self.update_when_can_schedule()
            if not self.can_schedule_more.any():
                break
            seed = self.scheduler.seed + iter_ind
            outage_slice = self.find_random_outage_slice(seed=seed)
            if self.can_schedule_more[outage_slice].all():
                self.schedule_losses(outage_slice)
            if len(self._scheduled_outage_inds) == self.outage.count:
                break

        if len(self._scheduled_outage_inds) < self.outage.count:
            if len(self._scheduled_outage_inds) == 0:
                msg_start = "Could not schedule any requested outages"
            else:
                msg_start = ("Could only schedule {} out of {} requested "
                             "outages"
                             .format(len(self._scheduled_outage_inds),
                                     self.outage.count))
            msg = ("{} after a max of {:,} iterations. You are likely "
                   "attempting to schedule a lot of long outages or a lot "
                   "of short outages with a large percentage of the farm at "
                   "a time. Please adjust the outage specifications and try "
                   "again"
                   .format(msg_start, self.outage.count + self.MAX_ITER))
            logger.warning(msg)
            warnings.warn(msg, reVLossesWarning)

    def update_when_can_schedule_from_months(self):
        """
        Update :attr:`can_schedule_more` using :attr:`Outage.allowed_months`.

        This function sets the :attr:`can_schedule_more` bool array to
        `True` for all of the months in :attr:`Outage.allowed_months`.
        """
        inds = hourly_indices_for_months(self.outage.allowed_months)
        self.can_schedule_more[inds] = True

    def update_when_can_schedule(self):
        """Update :attr:`can_schedule_more` using :obj:`OutageScheduler`.

        This function sets the :attr:`can_schedule_more` bool array to
        `True` wherever :attr:`OutageScheduler.can_schedule_more` is
        also `True` and wherever the losses from this outage would not
        cause the :attr:`OutageScheduler.total_losses` to exceed 100%.
        """
        self.can_schedule_more &= self.scheduler.can_schedule_more
        if self.outage.allow_outage_overlap:
            total_new_losses = (self.scheduler.total_losses
                                + self.outage.percentage_of_capacity_lost)
            losses_will_not_exceed_100 = total_new_losses <= 100
            self.can_schedule_more &= losses_will_not_exceed_100
        else:
            self.can_schedule_more &= self.scheduler.total_losses == 0

    def find_random_outage_slice(self, seed=None):
        """Find a random slot of time for this type of outage.

        This function randomly selects a starting time for this outage
        given the allowed times in :attr:`can_schedule_more`. It does
        **not** verify that the outage can be scheduled for the entire
        requested duration.

        Parameters
        ----------
        seed : int, optional
            Integer used to seed the :func:`np.random.choice` call.
            If :obj:`None`, seed is not used.

        Returns
        -------
        :obj:`slice`
            A slice corresponding to the random slot of time for this
            type of outage.
        """
        if seed is not None:
            np.random.seed(seed)
        outage_ind = np.random.choice(np.where(self.can_schedule_more)[0])
        return slice(outage_ind, outage_ind + self.outage.duration)

    def schedule_losses(self, outage_slice):
        """Schedule the input outage during the given slice of time.

        Given a slice in the hourly loss array, add the losses from this
        outage (which is equivalent to scheduling them).

        Parameters
        ----------
        outage_slice : slice
            A slice corresponding to the slot of time to schedule this
            outage.
        """
        self._scheduled_outage_inds.append(outage_slice.start)
        self.scheduler.total_losses[outage_slice] += (
            self.outage.percentage_of_capacity_lost)
        if not self.outage.allow_outage_overlap:
            self.scheduler.can_schedule_more[outage_slice] = False


class ScheduledLossesMixin:
    """Mixin class for :class:`reV.SAM.generation.AbstractSamGeneration`.

    Warning
    -------
    Using this class for anything excpet as a mixin for
    :class:`~reV.SAM.generation.AbstractSamGeneration` may result in
    unexpected results and/or errors.
    """

    OUTAGE_CONFIG_KEY = 'reV_outages'
    """Specify outage information in the config file using this key."""
    OUTAGE_SEED_CONFIG_KEY = 'reV_outages_seed'
    """Specify a randomizer seed in the config file using this key."""

    def add_scheduled_losses(self, resource):
        """Add stochastically scheduled losses to SAM config file.

        This function reads the information in the ``reV_outages`` key
        of the ``sam_sys_inputs`` dictionary and computes stochastically
        scheduled losses from that input. If the value for
        ``reV_outages`` is a string, it must have been generated by
        calling :func:`json.dumps` on the list of dictionaries
        containing outage specifications. Otherwise, the outage
        information is expected to be a list of dictionaries containing
        outage specifications. See :class:`Outage` for a description of
        the specifications allowed for each outage. The scheduled losses
        are passed to SAM via the ``hourly`` key to signify which hourly
        capacity factors should be adjusted with outage losses. If no
        outage info is specified in ``sam_sys_inputs``, no scheduled
        losses are added.

        Parameters
        ----------
        resource : pd.DataFrame
            Timeseries resource data for a single location with a pandas
            DatetimeIndex. The ``year`` value of the index will be used
            to seed the stochastically scheduled losses.

        See Also
        --------
        :class:`Outage` : Single outage specification.

        Notes
        -----
        The scheduled losses are passed to SAM via the ``hourly`` key to
        signify which hourly capacity factors should be adjusted with
        outage losses. If the user specifies other hourly adjustment
        factors via the ``hourly`` key, the effect is combined. For
        example, if the user inputs a 33% hourly adjustment factor and
        reV schedules an outage for 70% of the farm down for the same
        hour, then the resulting adjustment factor is::
            1 - [(1 - 70/100) * (1 - 33/100)] = 0.799
        This means the generation will be reduced by ~80%, because the
        user requested 33% losses for the 30% the farm that remained
        operational during the scheduled outage (i.e. 20% remaining of
        the original generation).
        """
        outages = self._user_outage_input(resource)
        if not outages:
            return

        logger.debug("Adding the following stochastically scheduled outages: "
                     "{}".format(outages))
        logger.debug("Scheduled outages seed: {}".format(self.outage_seed))

        scheduler = OutageScheduler(outages, seed=self.outage_seed)
        hourly_outages = scheduler.calculate()
        self._add_outages_to_sam_inputs(hourly_outages)

        logger.debug("Hourly adjustment factors after scheduled outages: {}"
                     .format(list(self.sam_sys_inputs['hourly'])))

    def _user_outage_input(self, resource):
        """Get outage and seed info from config. """
        outage_specs = self.sam_sys_inputs.pop(self.OUTAGE_CONFIG_KEY, None)
        if outage_specs is None:
            return

        # site-specific info is input as str
        if isinstance(outage_specs, str):
            outage_specs = json.loads(outage_specs)

        outages = [Outage(spec) for spec in outage_specs]
        self.__year_seed = int(resource.index.year.values[0])
        self.__user_input_seed = self.sam_sys_inputs.pop(
            self.OUTAGE_SEED_CONFIG_KEY, 0)
        return outages

    def _add_outages_to_sam_inputs(self, outages):
        """Add the hourly adjustment factors to config, checking user input."""

        hourly_mult = 1 - outages / 100

        user_hourly_input = self.sam_sys_inputs.pop('hourly', [0] * 8760)
        user_hourly_mult = 1 - np.array(user_hourly_input) / 100

        final_hourly_mult = hourly_mult * user_hourly_mult
        self.sam_sys_inputs['hourly'] = (1 - final_hourly_mult) * 100

    @property
    def outage_seed(self):
        """int: A value to use as the seed for the outage losses. """
        base_seed = self.__year_seed + self.__user_input_seed
        try:
            return int(self.meta.name) + base_seed
        except (AttributeError, TypeError, ValueError):
            pass

        try:
            return hash(tuple(self.meta)) + base_seed
        except (AttributeError, TypeError):
            pass

        return base_seed
