import math
from collections import defaultdict

from django.apps import apps
from django.conf import settings

if settings.HAS_REDIS:
    import django_redis
    redis = django_redis.get_redis_connection("redis")

REDIS_KEY = "pretix_metrics"
_INF = float("inf")
_MINUS_INF = float("-inf")


def _float_to_go_string(d):
    # inspired by https://github.com/prometheus/client_python/blob/master/prometheus_client/core.py
    if d == _INF:
        return '+Inf'
    elif d == _MINUS_INF:
        return '-Inf'
    elif math.isnan(d):
        return 'NaN'
    else:
        return repr(float(d))


class Metric(object):
    """
    Base Metrics Object
    """

    def __init__(self, name, helpstring, labelnames=None):
        self.name = name
        self.helpstring = helpstring
        self.labelnames = labelnames or []

    def __repr__(self):
        return self.name + "{" + ",".join(self.labelnames) + "}"

    def _check_label_consistency(self, labels):
        """
        Checks if the given labels provides exactly the labels that are required.
        """

        # test if every required label is provided
        for labelname in self.labelnames:
            if labelname not in labels:
                raise ValueError("Label {0} not specified.".format(labelname))

        # now test if no further labels are required
        if len(labels) != len(self.labelnames):
            raise ValueError("Unknown labels used: {}".format(", ".join(set(labels) - set(self.labelnames))))

    def _construct_metric_identifier(self, metricname, labels=None, labelnames=None):
        """
        Constructs the scrapable metricname usable in the output format.
        """
        if not labels:
            return metricname
        else:
            named_labels = []
            for labelname in (labelnames or self.labelnames):
                named_labels.append('{}="{}"'.format(labelname, labels[labelname]))

            return metricname + "{" + ",".join(named_labels) + "}"

    def _inc_in_redis(self, key, amount, pipeline=None):
        """
        Increments given key in Redis.
        """
        if settings.HAS_REDIS:
            if not pipeline:
                pipeline = redis
            pipeline.hincrbyfloat(REDIS_KEY, key, amount)

    def _set_in_redis(self, key, value, pipeline=None):
        """
        Sets given key in Redis.
        """
        if settings.HAS_REDIS:
            if not pipeline:
                pipeline = redis
            pipeline.hset(REDIS_KEY, key, value)

    def _get_redis_pipeline(self):
        if settings.HAS_REDIS:
            return redis.pipeline()

    def _execute_redis_pipeline(self, pipeline):
        if settings.HAS_REDIS:
            return pipeline.execute()


class Counter(Metric):
    """
    Counter Metric Object
    Counters can only be increased, they can neither be set to a specific value
    nor decreased.
    """

    def inc(self, amount=1, **kwargs):
        """
        Increments Counter by given amount for the labels specified in kwargs.
        """
        if amount < 0:
            raise ValueError("Counter cannot be increased by negative values.")

        self._check_label_consistency(kwargs)

        fullmetric = self._construct_metric_identifier(self.name, kwargs)
        self._inc_in_redis(fullmetric, amount)


class Gauge(Metric):
    """
    Gauge Metric Object
    Gauges can be set to a specific value, increased and decreased.
    """

    def set(self, value=1, **kwargs):
        """
        Sets Gauge to a specific value for the labels specified in kwargs.
        """
        self._check_label_consistency(kwargs)

        fullmetric = self._construct_metric_identifier(self.name, kwargs)
        self._set_in_redis(fullmetric, value)

    def inc(self, amount=1, **kwargs):
        """
        Increments Gauge by given amount for the labels specified in kwargs.
        """
        if amount < 0:
            raise ValueError("Amount must be greater than zero. Otherwise use dec().")

        self._check_label_consistency(kwargs)

        fullmetric = self._construct_metric_identifier(self.name, kwargs)
        self._inc_in_redis(fullmetric, amount)

    def dec(self, amount=1, **kwargs):
        """
        Decrements Gauge by given amount for the labels specified in kwargs.
        """
        if amount < 0:
            raise ValueError("Amount must be greater than zero. Otherwise use inc().")

        self._check_label_consistency(kwargs)

        fullmetric = self._construct_metric_identifier(self.name, kwargs)
        self._inc_in_redis(fullmetric, amount * -1)


class Histogram(Metric):
    """
    Histogram Metric Object
    """

    def __init__(self, name, helpstring, labelnames=None,
                 buckets=(.005, .01, .025, .05, .075, .1, .25, .5, .75, 1.0, 2.5, 5.0, 7.5, 10.0, 30.0, _INF)):
        if list(buckets) != sorted(buckets):
            # This is probably an error on the part of the user,
            # so raise rather than sorting for them.
            raise ValueError('Buckets not in sorted order')

        if buckets and buckets[-1] != _INF:
            buckets.append(_INF)

        if len(buckets) < 2:
            raise ValueError('Must have at least two buckets')

        self.buckets = buckets
        super().__init__(name, helpstring, labelnames)

    def observe(self, amount, **kwargs):
        """
        Stores a value in the histogram for the labels specified in kwargs.
        """
        if amount < 0:
            raise ValueError("Amount must be greater than zero. Otherwise use inc().")

        self._check_label_consistency(kwargs)

        pipe = self._get_redis_pipeline()

        countmetric = self._construct_metric_identifier(self.name + '_count', kwargs)
        self._inc_in_redis(countmetric, 1, pipeline=pipe)

        summetric = self._construct_metric_identifier(self.name + '_sum', kwargs)
        self._inc_in_redis(summetric, amount, pipeline=pipe)

        kwargs_le = dict(kwargs.items())
        for i, bound in enumerate(self.buckets):
            if amount <= bound:
                kwargs_le['le'] = _float_to_go_string(bound)
                bmetric = self._construct_metric_identifier(self.name + '_bucket', kwargs_le,
                                                            labelnames=self.labelnames + ["le"])
                self._inc_in_redis(bmetric, 1, pipeline=pipe)

        self._execute_redis_pipeline(pipe)


def metric_values():
    """
    Produces the the values to be presented to the monitoring system
    """
    metrics = defaultdict(dict)

    # Metrics from redis
    if settings.HAS_REDIS:
        for key, value in redis.hscan_iter(REDIS_KEY):
            dkey = key.decode("utf-8")
            splitted = dkey.split("{", 2)
            value = float(value.decode("utf-8"))
            metrics[splitted[0]]["{" + splitted[1]] = value

    # Aliases
    aliases = {
        'pretix_view_requests_total': 'pretix_view_duration_seconds_count'
    }
    for a, atarget in aliases.items():
        metrics[a] = metrics[atarget]

    # Throwaway metrics
    for m in apps.get_models():  # Count all models
        metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.objects.count()

    return metrics


"""
Provided metrics
"""
pretix_view_duration_seconds = Histogram("pretix_view_duration_seconds", "Return time of views.",
                                         ["status_code", "method", "url_name"])
pretix_task_runs_total = Counter("pretix_task_runs_total", "Total calls to a celery task",
                                 ["task_name", "status"])
pretix_task_duration_seconds = Histogram("pretix_task_duration_seconds", "Call time of a celery task",
                                         ["task_name"])
