from collections import UserDict, namedtuple

import NvRules


# TODO: switch to enum.Enum once this is available in static interpreter
class Importance:
    OPTIONAL = 1
    REQUIRED = 2


_MetricRequest = namedtuple(
    "MetricRequest",
    ["name", "alias", "importance", "default_value", "warn_when_missing"],
    defaults=(None, None, Importance.REQUIRED, 0.0, True),
)


class MetricRequest(_MetricRequest):
    """A metric request containing name, and optional alias, importance and default value.

    Args:
        name (str): The name of the metric.
        alias (str, optional): An alias for the metric name. Defaults to None.
        importance (:obj:`Importance`, optional): Whether the metric is required or
            optional. Defaults to Importance.REQUIRED.
        default_value (int, float, string, optional): A default value for OPTIONAL
            metrics. Defaults to float(0).
        warn_when_missing (bool, optional): Whether to issue a warning when an OPTIONAL
            metric is missing. Defaults to True.
    """

    pass


class MetricNotFoundError(Exception):
    """Exception raised when metric cannot be found.

    Args:
        name (str): The name of the metric that was not found.
        importance (:obj:`Importance`): Whether the metric was marked
            required or optional.
        message (str): A message explaining the error
    """

    def __init__(self, name, importance, message=None):
        importance_str = "Required" if importance == Importance.REQUIRED else "Optional"
        default_message = "{} metric {} could not be found.".format(importance_str, name)

        self.name = name
        self.importance = importance
        self.message = message or default_message
        super().__init__(self.message)


class RequestedMetric:
    """Wrapper class for available (loaded) metrics.

    Args:
        name (str): The name of the metric.
        metric (:obj:`IMetric`): An IMetric object representing the metric.
        importance (:obj:`Importance`, optional): Whether the metric was marked
            required or optional. Defaults to Importance.REQUIRED.
        alias (str, optional): An alias for the metric name. Defaults to None.
    """

    _DEFAULT_IMPORTANCE = Importance.REQUIRED

    def __init__(self, name, metric, importance=None, alias=None):
        self._name = name
        self._metric = metric
        self._importance = importance or RequestedMetric._DEFAULT_IMPORTANCE
        self._alias = alias

    @property
    def name(self):
        return self._name

    @property
    def metric(self):
        return self._metric

    @property
    def importance(self):
        return self._importance

    @property
    def alias(self):
        return self._alias


class RequestedMetricBuilder:
    """Builds a RequestedMetric from an IMetric contained in an IAction.

    Args:
        metrics (:obj:`IAction`): An IAction object (potentially) containing the IMetric
            associated with `name`.
        name (str): The name of the metric.
        importance (:obj:`Importance`, optional): Whether the metric was marked
            required or optional. Defaults to Importance.REQUIRED.
        alias (str, optional): An alias for the metric name. Defaults to None.

    Raises:
        MetricNotFoundError: If no IMetric object associated with `name` is available.
    """

    def __init__(self, metrics, name, importance=Importance.REQUIRED, alias=None):
        self._metric = None
        try:
            metric = metrics[name]
            self._metric = RequestedMetric(name, metric, importance, alias)
        except KeyError:
            raise MetricNotFoundError(name, importance)

    """Returns the RequestedMetric object built."""

    def build(self):
        return self._metric


class RequestedMetricDict(UserDict):
    """Custom dict for IMetric lookup using either the metric's name or alias.

    Allows insertion of RequestedMetric values using a key that matches either
    the metric's name or its alias. Lookup will return the IMetric object directly.
    Lookup can be done using name or alias, independently of how the RequestedMetric
    was inserted.
    """

    def __init__(self):
        self.aliasToName = {}
        super().__init__()

    def __getitem__(self, key):
        try:
            return super().__getitem__(key).metric
        except KeyError:
            pass
        try:
            return super().__getitem__(self.aliasToName[key]).metric
        except KeyError:
            pass
        raise KeyError(key)

    def __setitem__(self, key, item):
        name = item.name
        alias = item.alias

        if (key != name) and (key != alias):
            raise KeyError("Key must match either the metric's name or alias.")

        if alias is not None:
            # check whether alias is already used by another metric
            if (alias in self.aliasToName) and (self.aliasToName[alias] != name):
                raise KeyError("Alias {} is already used by metric {}".format(alias, key))
            # save the alias for later lookup
            self.aliasToName[alias] = name

        return super().__setitem__(name, item)

    def __contains__(self, key):
        is_alias = key in self.aliasToName
        return super().__contains__(key) or is_alias


class RequestedMetricsParser:
    """Convenience class to query IMetric objects from an IAction.

    Args:
        handle: The NvRules ContextHandle.
        action (:obj:`IAction`): IAction object containing the IMetrics to be requested.
    """

    _MISSING_REQUIRED_METRICS_MESSAGE = (
        "Some required metrics are missing; aborted rule execution."
    )

    def __init__(self, handle, action):
        self.handle = handle
        self.frontend = NvRules.get_context(handle).frontend()
        self.metrics = action

    def parse(self, requested_metrics):
        """Parse a list of `MetricRequest`s and return a custom dict of `IMetric` objects.

        Args:
            requested_metrics (List[MetricRequest]): A list of requested metrics.

        Returns:
            A RequestedMetricDict, which returns an IMetric object for any valid metric
                name or alias.

        Raises:
            SystemError: If any REQUIRED metric is not contained in the IAction object.
        """
        parsed_metrics = RequestedMetricDict()
        found_missing_required_metrics = False
        found_missing_optional_metrics = False
        missing_required_metrics = set()

        for metric in requested_metrics:
            try:
                parsed_metrics[metric.name] = RequestedMetricBuilder(
                    name=metric.name,
                    metrics=self.metrics,
                    importance=metric.importance,
                    alias=metric.alias,
                ).build()
            except MetricNotFoundError as error:
                if error.importance == Importance.OPTIONAL:
                    parsed_metrics[metric.name] = RequestedMetric(
                        name=metric.name,
                        metric=self._create_fallback_metric(metric),
                        importance=metric.importance,
                        alias=metric.alias,
                    )
                    # issue a warning for the first missing optional metric (by default)
                    if metric.warn_when_missing and not found_missing_optional_metrics:
                        self.frontend.message(
                            NvRules.MsgType.WARNING,
                            self._get_missing_optional_metric_warning(error),
                        )
                        found_missing_optional_metrics = True
                elif error.importance == Importance.REQUIRED:
                    found_missing_required_metrics = True
                    missing_required_metrics.add(metric.name)
                    self.frontend.message(
                        NvRules.MsgType.ERROR,
                        self._get_missing_required_metric_warning(error),
                    )

        if found_missing_required_metrics:
            NvRules.raise_exception(
                self.handle,
                f'{RequestedMetricsParser._MISSING_REQUIRED_METRICS_MESSAGE}: {", ".join(missing_required_metrics)}'
            )

        return parsed_metrics

    def _create_fallback_metric(self, metric_request):
        if metric_request.default_value is None:
            return None

        value = metric_request.default_value
        fallback_metric = self.metrics.add_metric(metric_request.name)

        if isinstance(value, int) and value >= 0:
            fallback_metric.set_uint64(NvRules.IMetric.ValueKind_UINT64, value)
        elif isinstance(value, float):
            fallback_metric.set_double(NvRules.IMetric.ValueKind_DOUBLE, value)
        elif isinstance(value, str):
            fallback_metric.set_string(NvRules.IMetric.ValueKind_STRING, value)
        else:
            raise ValueError("Can only create fallback metric from uint, float or str.")

        return fallback_metric

    def _get_missing_optional_metric_warning(self, error):
        return str(
            "The optional metric {} could not be found. "
            "Collecting it as an additional metric could enable the rule "
            "to provide more guidance.".format(error.name)
        )

    def _get_missing_required_metric_warning(self, error):
        return error.message
