import datetime
import decimal
import logging

from django.db import models
from django.db.models import F, Q
from django.db.models.loading import get_model
from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver
from django.contrib.contenttypes import generic
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _

from orchestra.core import accounts, services
from orchestra.models import queryset
from orchestra.utils.python import import_class

from . import helpers, settings


logger = logging.getLogger(__name__)


class OrderQuerySet(models.QuerySet):
    group_by = queryset.group_by
    
    def bill(self, **options):
        bills = []
        bill_backend = Order.get_bill_backend()
        qs = self.select_related('account', 'service')
        commit = options.get('commit', True)
        for account, services in qs.group_by('account', 'service').items():
            bill_lines = []
            for service, orders in services.items():
                for order in orders:
                    # Saved for undoing support
                    order.old_billed_on = order.billed_on
                    order.old_billed_until = order.billed_until
                lines = service.handler.generate_bill_lines(orders, account, **options)
                bill_lines.extend(lines)
            # TODO make this consistent always returning the same fucking types
            if commit:
                bills += bill_backend.create_bills(account, bill_lines, **options)
            else:
                bills += [(account, bill_lines)]
        return bills
    
    def givers(self, ini, end):
        return self.cancelled_and_billed().filter(billed_until__gt=ini, registered_on__lt=end)
    
    def cancelled_and_billed(self, exclude=False):
        qs = dict(cancelled_on__isnull=False, billed_until__isnull=False,
                  cancelled_on__lte=F('billed_until'))
        if exclude:
            return self.exclude(**qs)
        return self.filter(**qs)
    
    def get_related(self, **options):
        """ returns related orders that could have a pricing effect """
        Service = get_model(settings.ORDERS_SERVICE_MODEL)
        conflictive = self.filter(service__metric='')
        conflictive = conflictive.exclude(service__billing_period=Service.NEVER)
        conflictive = conflictive.select_related('service').group_by('account_id', 'service')
        qs = Q()
        for account_id, services in conflictive.items():
            for service, orders in services.items():
                if not service.rates.exists():
                    continue
                ini = datetime.date.max
                end = datetime.date.min
                bp = None
                for order in orders:
                    bp = service.handler.get_billing_point(order, **options)
                    end = max(end, bp)
                    ini = min(ini, order.billed_until or order.registered_on)
                qs |= Q(
                    Q(service=service, account=account_id, registered_on__lt=end) & Q(
                        Q(billed_until__isnull=True) | Q(billed_until__lt=end)
                    ) & Q(
                        Q(cancelled_on__isnull=True) | Q(cancelled_on__gt=ini)
                    )
                )
        if not  qs:
            return self.model.objects.none()
        ids = self.values_list('id', flat=True)
        return self.model.objects.filter(qs).exclude(id__in=ids)
    
    def pricing_orders(self, ini, end):
        return self.filter(billed_until__isnull=False, billed_until__gt=ini,
            registered_on__lt=end)
    
    def by_object(self, obj, **kwargs):
        ct = ContentType.objects.get_for_model(obj)
        return self.filter(object_id=obj.pk, content_type=ct, **kwargs)
    
    def active(self, **kwargs):
        """ return active orders """
        return self.filter(
            Q(cancelled_on__isnull=True) | Q(cancelled_on__gt=timezone.now())
        ).filter(**kwargs)
    
    def inactive(self, **kwargs):
        """ return inactive orders """
        return self.filter(cancelled_on__lte=timezone.now(), **kwargs)


class Order(models.Model):
    account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
        related_name='orders')
    content_type = models.ForeignKey(ContentType)
    object_id = models.PositiveIntegerField(null=True)
    service = models.ForeignKey(settings.ORDERS_SERVICE_MODEL, verbose_name=_("service"),
        related_name='orders')
    registered_on = models.DateField(_("registered"), default=timezone.now)
    cancelled_on = models.DateField(_("cancelled"), null=True, blank=True)
    billed_on = models.DateField(_("billed"), null=True, blank=True)
    billed_until = models.DateField(_("billed until"), null=True, blank=True)
    ignore = models.BooleanField(_("ignore"), default=False)
    description = models.TextField(_("description"), blank=True)
    
    content_object = generic.GenericForeignKey()
    objects = OrderQuerySet.as_manager()
    
    class Meta:
        get_latest_by = 'id'
    
    def __str__(self):
        return str(self.service)
    
    @classmethod
    def update_orders(cls, instance, service=None, commit=True):
        updates = []
        if service is None:
            Service = get_model(settings.ORDERS_SERVICE_MODEL)
            services = Service.get_services(instance)
        else:
            services = [service]
        for service in services:
            orders = Order.objects.by_object(instance, service=service).select_related('service').active()
            if service.handler.matches(instance):
                if not orders:
                    account_id = getattr(instance, 'account_id', instance.pk)
                    if account_id is None:
                        # New account workaround -> user.account_id == None
                        continue
                    ignore = service.handler.get_ignore(instance)
                    order = cls(content_object=instance, service=service,
                            account_id=account_id, ignore=ignore)
                    if commit:
                        order.save()
                    updates.append((order, 'created'))
                    logger.info("CREATED new order id: {id}".format(id=order.id))
                else:
                    if len(orders) > 1:
                        raise ValueError("A single active order was expected.")
                    order = orders[0]
                    updates.append((order, 'updated'))
                if commit:
                    order.update()
            elif orders:
                if len(orders) > 1:
                    raise ValueError("A single active order was expected.")
                order = orders[0]
                order.cancel(commit=commit)
                logger.info("CANCELLED order id: {id}".format(id=order.id))
                updates.append((order, 'cancelled'))
        return updates
    
    @classmethod
    def get_bill_backend(cls):
        return import_class(settings.ORDERS_BILLING_BACKEND)()
    
    def update(self):
        instance = self.content_object
        handler = self.service.handler
        metric = ''
        if handler.metric:
            metric = handler.get_metric(instance)
            if metric is not None:
                MetricStorage.store(self, metric)
            metric = ', metric:{}'.format(metric)
        description = handler.get_order_description(instance)
        logger.info("UPDATED order id:{id}, description:{description}{metric}".format(
            id=self.id, description=description, metric=metric).encode('ascii', 'replace')
        )
        if self.description != description:
            self.description = description
            self.save(update_fields=['description'])
    
    def cancel(self, commit=True):
        self.cancelled_on = timezone.now()
        self.ignore = self.service.handler.get_order_ignore(self)
        if commit:
            self.save(update_fields=['cancelled_on', 'ignore'])
            logger.info("CANCELLED order id: {id}".format(id=self.id))
    
    def mark_as_ignored(self):
        self.ignore = True
        self.save(update_fields=['ignore'])
    
    def mark_as_not_ignored(self):
        self.ignore = False
        self.save(update_fields=['ignore'])
    
    def get_metric(self, *args, **kwargs):
        if kwargs.pop('changes', False):
            ini, end = args
            result = []
            prev = None
            for metric in self.metrics.filter(created_on__lt=end).order_by('id'):
                created = metric.created_on
                if created > ini:
                    cini = prev.created_on
                    if not result:
                        cini = ini
                    result.append((cini, created, prev.value))
                prev = metric
            if created < end:
                result.append((created, end, metric.value))
            return result
        if kwargs:
            raise AttributeError
        if len(args) == 2:
            ini, end = args
            metrics = self.metrics.filter(updated_on__lt=end, updated_on__gte=ini)
        elif len(args) == 1:
            date = args[0]
            date = datetime.date(year=date.year, month=date.month, day=date.day)
            date += datetime.timedelta(days=1)
            metrics = self.metrics.filter(updated_on__lt=date)
        elif not args:
            return self.metrics.latest('updated_on').value
        else:
            raise AttributeError
        try:
            return metrics.latest('updated_on').value
        except MetricStorage.DoesNotExist:
            return decimal.Decimal(0)


class MetricStorage(models.Model):
    """ Stores metric state for future billing """
    order = models.ForeignKey(Order, verbose_name=_("order"), related_name='metrics')
    value = models.DecimalField(_("value"), max_digits=16, decimal_places=2)
    created_on = models.DateField(_("created"), auto_now_add=True)
#    default=lambda: timezone.now())
    # TODO time field?
    updated_on = models.DateTimeField(_("updated"))
    
    class Meta:
        get_latest_by = 'id'
    
    def __str__(self):
        return str(self.order)
    
    @classmethod
    def store(cls, order, value):
        now = timezone.now()
        try:
            last = cls.objects.filter(order=order).latest()
        except cls.DoesNotExist:
            cls.objects.create(order=order, value=value, updated_on=now)
        else:
            error = decimal.Decimal(str(settings.ORDERS_METRIC_ERROR))
            if value > last.value+error or value < last.value-error:
                cls.objects.create(order=order, value=value, updated_on=now)
            else:
                last.updated_on = now
                last.save(update_fields=['updated_on'])


accounts.register(Order)


# TODO perhas use cache = caches.get_request_cache() to cache an account delete and don't processes get_related_objects() if the case
# FIXME https://code.djangoproject.com/ticket/24576
# TODO build a cache hash table {model: related, model: None}
@receiver(post_delete, dispatch_uid="orders.cancel_orders")
def cancel_orders(sender, **kwargs):
    if sender._meta.app_label not in settings.ORDERS_EXCLUDED_APPS:
        instance = kwargs['instance']
        # Account delete will delete all related orders, no need to maintain order consistency
        if isinstance(instance, Order.account.field.rel.to):
            return
        if type(instance) in services:
            for order in Order.objects.by_object(instance).active():
                order.cancel()
        elif not hasattr(instance, 'account'):
            # FIXME Indeterminate behaviour
            related = helpers.get_related_object(instance)
            if related and related != instance:
                type(related).objects.get(pk=related.pk)


@receiver(post_save, dispatch_uid="orders.update_orders")
def update_orders(sender, **kwargs):
    if sender._meta.app_label not in settings.ORDERS_EXCLUDED_APPS:
        instance = kwargs['instance']
        if type(instance) in services:
            Order.update_orders(instance)
        elif not hasattr(instance, 'account'):
            related = helpers.get_related_object(instance)
            if related and related != instance:
                Order.update_orders(related)