# Copyright 2014 Budapest University of Technology and Economics (BME IK)
#
# This file is part of CIRCLE Cloud.
#
# CIRCLE is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# CIRCLE is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along
# with CIRCLE.  If not, see <http://www.gnu.org/licenses/>.


from collections import deque
from contextlib import contextmanager
from functools import update_wrapper
from hashlib import sha224

try:
    from itertools import imap
except ImportError:
    # Python 3...
    imap=map

from itertools import chain
from logging import getLogger
from time import time
from warnings import warn

from django.contrib import messages
from django.contrib.auth.models import User
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
    CharField, DateTimeField, ForeignKey, BooleanField
)
from django.db import models
from django.template import defaultfilters
from django.utils import timezone
from django.utils.encoding import force_text
from django.utils.functional import Promise
from django.utils.translation import ugettext_lazy as _, ugettext_noop
from jsonfield import JSONField

from manager.mancelery import celery
from model_utils.models import TimeStampedModel

logger = getLogger(__name__)


class WorkerNotFound(Exception):
    pass


def get_error_msg(exception):
    try:
        return str(exception)
    except UnicodeDecodeError:
        return str(str(exception), encoding='utf-8', errors='replace')


def activitycontextimpl(act, on_abort=None, on_commit=None):
    result = None
    try:
        try:
            yield act
        except HumanReadableException as e:
            result = e
            raise
        except BaseException as e:
            # BaseException is the common parent of Exception and
            # system-exiting exceptions, e.g. KeyboardInterrupt
            result = create_readable(
                ugettext_noop("Failure."),
                ugettext_noop("Unhandled exception: %(e)s: %(error)s"),
                e=str(e.__class__.__name__),
                error=get_error_msg(e))
            raise
    except BaseException as e:
        logger.exception("Failed activity %s" % str(act))
        handler = None if on_abort is None else lambda a: on_abort(a, e)
        act.finish(succeeded=False, result=result, event_handler=handler)
        raise
    else:
        act.finish(succeeded=True, event_handler=on_commit)


activity_context = contextmanager(activitycontextimpl)


activity_code_separator = '.'


def has_prefix(activity_code, *prefixes):
    """Determine whether the activity code has the specified prefix.

    >>> assert has_prefix('foo.bar.buz', 'foo.bar')
    >>> assert has_prefix('foo.bar.buz', 'foo', 'bar')
    >>> assert has_prefix('foo.bar.buz', 'foo.bar', 'buz')
    >>> assert has_prefix('foo.bar.buz', 'foo', 'bar', 'buz')
    >>> assert not has_prefix('foo.bar.buz', 'foo', 'buz')
    """
    def equal(a, b): return a == b
    act_code_parts = split_activity_code(activity_code)
    prefixes = chain(*map(split_activity_code, prefixes))
    return all(map(equal, act_code_parts, prefixes))


def has_suffix(activity_code, *suffixes):
    """Determine whether the activity code has the specified suffix.

    >>> assert has_suffix('foo.bar.buz', 'bar.buz')
    >>> assert has_suffix('foo.bar.buz', 'bar', 'buz')
    >>> assert has_suffix('foo.bar.buz', 'foo.bar', 'buz')
    >>> assert has_suffix('foo.bar.buz', 'foo', 'bar', 'buz')
    >>> assert not has_suffix('foo.bar.buz', 'foo', 'buz')
    """
    def equal(a, b): return a == b
    act_code_parts = split_activity_code(activity_code)
    suffixes = list(chain(*map(split_activity_code, suffixes)))
    return all(map(equal, reversed(act_code_parts), reversed(suffixes)))


def join_activity_code(*args):
    """Join the specified parts into an activity code.

    :returns: Activity code string.
    """
    return activity_code_separator.join(args)


def split_activity_code(activity_code):
    """Split the specified activity code into its parts.

    :returns: A list of activity code parts.
    """
    return activity_code.split(activity_code_separator)


class Encoder(DjangoJSONEncoder):
    def default(self, obj):
        if isinstance(obj, Promise):
            obj = force_text(obj)
        try:
            return super(Encoder, self).default(obj)
        except TypeError:
            return str(obj)


class ActivityModel(TimeStampedModel):
    activity_code = CharField(max_length=100, verbose_name=_('activity code'))
    readable_name_data = JSONField(blank=True, null=True,
                                   dump_kwargs={"cls": Encoder},
                                   verbose_name=_('human readable name'),
                                   help_text=_('Human readable name of '
                                               'activity.'))
    parent = ForeignKey('self', blank=True, null=True, related_name='children', on_delete=models.CASCADE)
    task_uuid = CharField(blank=True, max_length=50, null=True, unique=True,
                          help_text=_('Celery task unique identifier.'),
                          verbose_name=_('task_uuid'))
    user = ForeignKey(User, blank=True, null=True, verbose_name=_('user'),
                  help_text=_('The person who started this activity.'), on_delete=models.CASCADE)
    started = DateTimeField(verbose_name=_('started at'),
                            blank=True, null=True,
                            help_text=_('Time of activity initiation.'))
    finished = DateTimeField(verbose_name=_('finished at'),
                             blank=True, null=True,
                             help_text=_('Time of activity finalization.'))
    succeeded = BooleanField(blank=True, null=True,
                                 help_text=_('True, if the activity has '
                                             'finished successfully.'))
    result_data = JSONField(verbose_name=_('result'), blank=True, null=True,
                            dump_kwargs={"cls": Encoder},
                            help_text=_('Human readable result of activity.'))

    def __str__(self):
        if self.parent:
            return self.parent.activity_code + "->" + self.activity_code
        else:
            return self.activity_code

    class Meta:
        abstract = True

    def finish(self, succeeded, result=None, event_handler=None):
        if not self.finished:
            self.finished = timezone.now()
            self.succeeded = succeeded
            if result is not None:
                self.result = result
            if event_handler is not None:
                event_handler(self)
            self.save()

    @property
    def has_succeeded(self):
        return self.finished and self.succeeded

    @property
    def has_failed(self):
        return self.finished and not self.succeeded

    @property
    def readable_name(self):
        return HumanReadableObject.from_dict(self.readable_name_data)

    @readable_name.setter
    def readable_name(self, value):
        self.readable_name_data = None if value is None else value.to_dict()

    @property
    def result(self):
        return HumanReadableObject.from_dict(self.result_data)

    @result.setter
    def result(self, value):
        if isinstance(value, str):
            warn("Using string as result value is deprecated. Use "
                 "HumanReadableObject instead.",
                 DeprecationWarning, stacklevel=2)
            value = create_readable(user_text_template="",
                                    admin_text_template=value)
        elif not hasattr(value, "to_dict"):
            warn("Use HumanReadableObject.", DeprecationWarning, stacklevel=2)
            value = create_readable(user_text_template="",
                                    admin_text_template=str(value))

        self.result_data = None if value is None else value.to_dict()

    @classmethod
    def construct_activity_code(cls, code_suffix, sub_suffix=None):
        code = join_activity_code(cls.ACTIVITY_CODE_BASE, code_suffix)
        if sub_suffix:
            return join_activity_code(code, sub_suffix)
        else:
            return code

    def get_status_id(self):
        if self.succeeded is None:
            return 'wait'
        elif self.succeeded:
            return 'success'
        else:
            return 'failed'


@celery.task()
def compute_cached(method, instance, memcached_seconds,
                   key, start, *args, **kwargs):
    """Compute and store actual value of cached method."""
    if isinstance(method, str):
        model, id = instance
        instance = model.objects.get(id=id)
        try:
            method = getattr(model, method)
            while hasattr(method, '_original') or hasattr(method, 'fget'):
                try:
                    method = method._original
                except AttributeError:
                    method = method.fget
        except AttributeError:
            logger.exception("Couldnt get original method of %s",
                             str(method))
            raise

    #  call the actual method
    result = method(instance, *args, **kwargs)
    # save to memcache
    cache.set(key, result, memcached_seconds)
    now = time()
    elapsed = now - start
    cache.set("%s.cached" % key, 2, max(memcached_seconds * 0.5,
                                        memcached_seconds - elapsed))
    setattr(instance, key, {'time': start, 'value': result})
    logger.debug('Value of %s<%s>.%s(%s)=<%s> saved to cache (%s elapsed, key: %s).',
                 str(method.__module__), str(instance), method.__name__, str(args),
                 str(result), elapsed, key)
    return result


def method_cache(memcached_seconds=60, instance_seconds=30):  # noqa
    """Cache return value of decorated method to memcached and memory.

    :param memcached_seconds: Invalidate memcached results after this time.
    :param instance_seconds: Invalidate results cached to static memory after
    this time.

    If a result is cached on instance, return that first.  If that fails, check
    memcached. If all else fails, run the method and cache on instance and in
    memcached.

    Do not use for methods with side effects.
    Instances are hashed by their id attribute, args by their unicode
    representation.

    ** NOTE: Methods that return None are always "recached".
    Based on https://djangosnippets.org/snippets/2477/
    """

    def inner_cache(method):

        method_name = method.__name__

        def get_key(instance, *args, **kwargs):
            concat_str = str(method.__module__) + '<' + str(instance) + '>' + '.' + method_name + str(args) + str(kwargs)
            key = sha224(concat_str.encode('utf-8')).hexdigest()
            logger.debug("concat_str: %s key: %s", concat_str, key)
            return key

        def x(instance, *args, **kwargs):
            invalidate = kwargs.pop('invalidate_cache', False)
            now = time()
            key = get_key(instance, *args, **kwargs)

            result = None
            try:
                vals = getattr(instance, key)
            except AttributeError:
                pass
            else:
                if vals['time'] + instance_seconds > now:
                    # has valid on class cache, return that
                    result = vals['value']
#                    setattr(instance, key, {'time': now, 'value': result})

            if result is None:
                result = cache.get(key)

            if invalidate or (result is None):
                logger.debug("all caches failed, compute now (key,inv,res,sec: %s,%s,%s,%s)" % (key, invalidate, result, memcached_seconds))
                result = compute_cached(method, instance, memcached_seconds,
                                        key, time(), *args, **kwargs)
#                setattr(instance, key, {'time': now, 'value': result})
                logger.debug("new key,result: %s, %s", key, result)
            elif not cache.get("%s.cached" % key):
                logger.debug("%s.caches expiring, compute async (%s sec)", key, memcached_seconds)
                cache.set("%s.cached" % key, 1, memcached_seconds * 0.5)
                logger.debug("%s.caches set %s", key, cache.get("%s.cached" % key))
                try:
                    compute_cached.apply_async(
                        queue='localhost.man', kwargs=kwargs, args=[
                            method_name, (instance.__class__, instance.id),
                            memcached_seconds, key, time()] + list(args))
                except:
                    logger.exception("Couldnt compute async %s key %s", method_name, key)

            return result

        update_wrapper(x, method)
        x._original = method
        return x

    return inner_cache


class HumanSortField(CharField):
    """
    A CharField that monitors another field on the same model and sets itself
    to a normalized value, which can be used for sensible lexicographycal
    sorting for fields containing numerals. (Avoiding technically correct
    orderings like [a1, a10, a2], which can be annoying for file or host
    names.)

    Apart from CharField's default arguments, an argument is requered:
        - monitor   sets the base field, whose value is normalized.
        - maximum_number_length    can also be provided, and defaults to 4. If
        you have to sort values containing numbers greater than 9999, you
        should increase it.

    Code is based on carljm's django-model-utils.
    """
    def __init__(self, *args, **kwargs):
        kwargs.setdefault('default', "")
        self.maximum_number_length = kwargs.pop('maximum_number_length', 4)
        monitor = kwargs.pop('monitor', None)
        if not monitor:
            raise TypeError(
                '%s requires a "monitor" argument' % self.__class__.__name__)
        self.monitor = monitor
        kwargs['blank'] = True
        super(HumanSortField, self).__init__(*args, **kwargs)

    def get_monitored_value(self, instance):
        return getattr(instance, self.monitor)

    def deconstruct(self):
        name, path, args, kwargs = super(HumanSortField, self).deconstruct()
        if self.monitor is not None:
            kwargs['monitor'] = self.monitor
        return name, path, args, kwargs

    @staticmethod
    def _partition(s, pred):
        """Partition a deque of chars to a tuple of a
           - string of the longest prefix matching pred,
           - string of the longest prefix after the former one not matching,
           - deque of the remaining characters.

           >>> HumanSortField._partition(deque("1234abc567"),
           ...                           lambda s: s.isdigit())
           ('1234', 'abc', deque(['5', '6', '7']))
           >>> HumanSortField._partition(deque("12ab"), lambda s: s.isalpha())
           ('', '12', deque(['a', 'b']))
        """
        match, notmatch = deque(), deque()
        while s and pred(s[0]):
            match.append(s.popleft())
        while s and not pred(s[0]):
            notmatch.append(s.popleft())
        return (''.join(match), ''.join(notmatch), s)

    def get_normalized_value(self, val):
        logger.debug('Normalizing value: %s', val)
        norm = ""
        val = deque(val)
        while val:
            numbers, letters, val = self._partition(val,
                                                    lambda s: s[0].isdigit())
            if numbers:
                norm += numbers.rjust(self.maximum_number_length, '0')
            norm += letters
        logger.debug('Normalized value: %s', norm)
        return norm

    def pre_save(self, model_instance, add):
        logger.debug('Pre-saving %s.%s. %s',
                     model_instance, self.attname, add)
        value = self.get_normalized_value(
            self.get_monitored_value(model_instance))
        setattr(model_instance, self.attname, value[:self.max_length])
        return super(HumanSortField, self).pre_save(model_instance, add)


class HumanReadableObject(object):
    def __init__(self, user_text_template, admin_text_template, params):
        self._set_values(user_text_template, admin_text_template, params)

    def _set_values(self, user_text_template, admin_text_template, params):
        if isinstance(user_text_template, Promise):
            user_text_template = user_text_template._proxy____args[0]
        if isinstance(admin_text_template, Promise):
            admin_text_template = admin_text_template._proxy____args[0]
        self.user_text_template = user_text_template
        self.admin_text_template = admin_text_template
        for k, v in list(params.items()):
            try:
                v = timezone.datetime.strptime(
                    v, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.UTC())
            except (ValueError, TypeError):  # Mock raises TypeError
                pass
            if isinstance(v, timezone.datetime):
                params[k] = defaultfilters.date(v, "DATETIME_FORMAT")
        self.params = params

    @classmethod
    def create(cls, user_text_template, admin_text_template=None, **params):
        return cls(user_text_template=user_text_template,
                   admin_text_template=(admin_text_template or
                                        user_text_template), params=params)

    def set(self, user_text_template, admin_text_template=None, **params):
        self._set_values(user_text_template,
                         admin_text_template or user_text_template, params)

    @classmethod
    def from_dict(cls, d):
        return None if d is None else cls(**d)

    def _get_parsed_text(self, key):
        value = getattr(self, key)
        if value == "":
            return ""
        try:
            return _(value) % self.params
        except KeyError:
            logger.exception("Can't render %s '%s' %% %s",
                             key, value, str(self.params))
            raise

    def get_admin_text(self):
        try:
            return self._get_parsed_text("admin_text_template")
        except KeyError:
            return self.get_user_text()

    def get_user_text(self):
        try:
            return self._get_parsed_text("user_text_template")
        except KeyError:
            return self.user_text_template

    def get_text(self, user):
        if user and user.is_superuser:
            return self.get_admin_text()
        else:
            return self.get_user_text()

    def to_dict(self):
        return {"user_text_template": self.user_text_template,
                "admin_text_template": self.admin_text_template,
                "params": self.params}

    def __str__(self):
        return self.get_user_text()


create_readable = HumanReadableObject.create


class HumanReadableException(HumanReadableObject, Exception):
    """HumanReadableObject that is an Exception so can used in except clause.
    """

    def __init__(self, level=None, *args, **kwargs):
        super(HumanReadableException, self).__init__(*args, **kwargs)
        if level is not None:
            if hasattr(messages, level):
                self.level = level
            else:
                raise ValueError(
                    "Level should be the name of an attribute of django."
                    "contrib.messages (and it should be callable with "
                    "(request, message)). Like 'error', 'warning'.")
        elif not hasattr(self, "level"):
            self.level = "error"

    def send_message(self, request, level=None):
        msg = self.get_text(request.user)
        getattr(messages, level or self.level)(request, msg)


def fetch_human_exception(exception, user=None):
    """Fetch user readable message from exception.

    >>> r = humanize_exception("foo", Exception())
    >>> fetch_human_exception(r, User())
    u'foo'
    >>> fetch_human_exception(r).get_text(User())
    u'foo'
    >>> fetch_human_exception(Exception(), User())
    u'Unknown error'
    >>> fetch_human_exception(PermissionDenied(), User())
    u'Permission Denied'
    """

    if not isinstance(exception, HumanReadableException):
        if isinstance(exception, PermissionDenied):
            exception = create_readable(ugettext_noop("Permission Denied"))
        else:
            exception = create_readable(ugettext_noop("Unknown error"),
                                        ugettext_noop("Unknown error: %(ex)s"),
                                        ex=str(exception))
    return exception.get_text(user) if user else exception


def humanize_exception(message, exception=None, level=None, **params):
    """Return new dynamic-class exception which is based on
    HumanReadableException and the original class with the dict of exception.

    >>> try: raise humanize_exception("Welcome!", TypeError("hello"))
    ... except HumanReadableException as e: print(e.get_admin_text())
    ... 
    Welcome!
    """

    Ex = type("HumanReadable" + type(exception).__name__,
              (HumanReadableException, type(exception)),
              exception.__dict__)
    ex = Ex.create(message, **params)
    if level:
        ex.level = level
    return ex

