import math
from itertools import groupby
from decimal import Decimal, ROUND_HALF_DOWN
from django.utils import timezone
from .rules import is_creator, not_submitted, leads_excursion
from members.rules import is_leader, statement_not_submitted

from django.db import models
from django.db.models import Sum
from django.utils.translation import gettext_lazy as _
from members.models import Member, Freizeit, OEFFENTLICHE_ANREISE, MUSKELKRAFT_ANREISE
from django.conf import settings
import rules
from contrib.models import CommonModel
from contrib.rules import has_global_perm
from utils import cvt_to_decimal, RestrictedFileField

from schwifty import IBAN
import re

# Create your models here.

class Ledger(models.Model):
    name = models.CharField(verbose_name=_('Name'), max_length=30)

    def __str__(self):
        return self.name

    class Meta:
        verbose_name = _('Ledger')
        verbose_name_plural = _('Ledgers')


class TransactionIssue:
    def __init__(self, member, current, target):
        self.member, self.current, self.target = member, current, target

    @property
    def difference(self):
        return self.target - self.current


class StatementManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(submitted=False, confirmed=False)


class Statement(CommonModel):
    MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, VALID = 0, 1, 2

    short_description = models.CharField(verbose_name=_('Short description'),
                                         max_length=30,
                                         blank=True)
    explanation = models.TextField(verbose_name=_('Explanation'), blank=True)

    excursion = models.OneToOneField(Freizeit, verbose_name=_('Associated excursion'),
                                     blank=True,
                                     null=True,
                                     on_delete=models.SET_NULL)

    night_cost = models.DecimalField(verbose_name=_('Price per night'), default=0, decimal_places=2, max_digits=5)

    submitted = models.BooleanField(verbose_name=_('Submitted'), default=False)
    submitted_date = models.DateTimeField(verbose_name=_('Submitted on'), default=None, null=True)
    confirmed = models.BooleanField(verbose_name=_('Confirmed'), default=False)
    confirmed_date = models.DateTimeField(verbose_name=_('Paid on'), default=None, null=True)

    created_by = models.ForeignKey(Member, verbose_name=_('Created by'),
                                   blank=True,
                                   null=True,
                                   on_delete=models.SET_NULL,
                                   related_name='created_statements')
    submitted_by = models.ForeignKey(Member, verbose_name=_('Submitted by'),
                                     blank=True,
                                     null=True,
                                     on_delete=models.SET_NULL,
                                     related_name='submitted_statements')
    confirmed_by = models.ForeignKey(Member, verbose_name=_('Authorized by'),
                                     blank=True,
                                     null=True,
                                     on_delete=models.SET_NULL,
                                     related_name='confirmed_statements')

    class Meta(CommonModel.Meta):
        verbose_name = _('Statement')
        verbose_name_plural = _('Statements')
        permissions = [
            ('may_edit_submitted_statements', 'Is allowed to edit submitted statements')
        ]
        rules_permissions = {
            # this is suboptimal, but Statement is only ever used as an inline on Freizeit
            # so we check for excursion permissions
            'add_obj': is_leader,
            'view_obj': is_leader | has_global_perm('members.view_global_freizeit'),
            'change_obj': is_leader & statement_not_submitted,
            'delete_obj': is_leader & statement_not_submitted,
        }

    def __str__(self):
        if self.excursion is not None:
            return _('Statement: %(excursion)s') % {'excursion': str(self.excursion)}
        else:
            return self.short_description

    def submit(self, submitter=None):
        self.submitted = True
        self.submitted_date = timezone.now()
        self.submitted_by = submitter
        self.save()

    @property
    def transaction_issues(self):
        needed_paiments = [(b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered and b.paid_by]

        if self.excursion is not None:
            needed_paiments.extend([(yl, self.real_per_yl) for yl in self.excursion.jugendleiter.all()])

        needed_paiments = sorted(needed_paiments, key=lambda p: p[0].pk)
        target = dict(map(lambda p: (p[0], sum([x[1] for x in p[1]])), groupby(needed_paiments, lambda p: p[0])))

        transactions = sorted(self.transaction_set.all(), key=lambda trans: trans.member.pk)
        current = dict(map(lambda p: (p[0], sum([t.amount for t in p[1]])), groupby(transactions, lambda trans: trans.member)))

        issues = []
        for member, amount in target.items():
            if amount == 0 and member not in current:
                continue
            elif member not in current:
                issue = TransactionIssue(member=member, current=0, target=amount)
                issues.append(issue)
            elif current[member] != amount:
                issue = TransactionIssue(member=member, current=current[member], target=amount)
                issues.append(issue)

        for member, amount in current.items():
            if amount != 0 and member not in target:
                issue = TransactionIssue(member=member, current=amount, target=0)
                issues.append(issue)

        return issues

    @property
    def ledgers_configured(self):
        return all([trans.ledger is not None for trans in self.transaction_set.all()])

    @property
    def transactions_match_expenses(self):
        return len(self.transaction_issues) == 0

    def is_valid(self):
        return self.ledgers_configured and self.transactions_match_expenses
    is_valid.boolean = True
    is_valid.short_description = _('Ready to confirm')

    @property
    def validity(self):
        if not self.transactions_match_expenses:
            return Statement.NON_MATCHING_TRANSACTIONS
        if not self.ledgers_configured:
            return Statement.MISSING_LEDGER
        else:
            return Statement.VALID

    def confirm(self, confirmer=None):
        if not self.submitted:
            return False

        if not self.validity == Statement.VALID:
            return False

        self.confirmed = True
        self.confirmed_date = timezone.now()
        self.confirmed_by = confirmer
        for trans in self.transaction_set.all():
            trans.confirmed = True
            trans.confirmed_date = timezone.now()
            trans.confirmed_by = confirmer
            trans.save()
        self.save()
        return True

    def generate_transactions(self):
        # bills
        for bill in self.bill_set.all():
            if not bill.costs_covered:
                continue
            if not bill.paid_by:
                return False
            ref = "{}: {}".format(str(self), bill.short_description)
            Transaction(statement=self, member=bill.paid_by, amount=bill.amount, confirmed=False, reference=ref).save()

        # excursion specific
        if self.excursion is None:
            return True

        for yl in self.excursion.jugendleiter.all():
            ref = _("Compensation for %(excu)s") % {'excu': self.excursion.name}
            Transaction(statement=self, member=yl, amount=self.real_per_yl, confirmed=False, reference=ref).save()
        return True

    def reduce_transactions(self):
        # to minimize the number of needed bank transactions, we bundle transactions from same ledger to
        # same member
        transactions = self.transaction_set.all()
        if any((t.ledger is None for t in transactions)):
            return

        sort_key = lambda trans: (trans.member.pk, trans.ledger.pk)
        group_key = lambda trans: (trans.member, trans.ledger)
        transactions = sorted(transactions, key=sort_key)
        for pair, transaction_group in groupby(transactions, group_key):
            member, ledger = pair
            grp = list(transaction_group)
            if len(grp) == 1:
                continue

            new_amount = sum((trans.amount for trans in grp))
            new_ref = "\n".join((trans.reference for trans in grp))
            Transaction(statement=self, member=member, amount=new_amount, confirmed=False, reference=new_ref,
                        ledger=ledger).save()
            for trans in grp:
                trans.delete()

    @property
    def total_bills(self):
        return sum([bill.amount for bill in self.bill_set.all() if bill.costs_covered])

    @property
    def total_bills_theoretic(self):
        return sum([bill.amount for bill in self.bill_set.all()])

    @property
    def euro_per_km(self):
        if self.excursion is None:
            return 0

        if self.excursion.tour_approach == MUSKELKRAFT_ANREISE \
                or self.excursion.tour_approach == OEFFENTLICHE_ANREISE:
            return 0.15
        else:
            return 0.1

    @property
    def transportation_per_yl(self):
        if self.excursion is None:
            return 0

        return cvt_to_decimal(self.excursion.kilometers_traveled * self.euro_per_km)

    @property
    def allowance_per_yl(self):
        if self.excursion is None:
            return 0

        return cvt_to_decimal(self.excursion.duration * settings.ALLOWANCE_PER_DAY)

    @property
    def total_allowance(self):
        return self.allowance_per_yl * self.real_staff_count

    @property
    def total_transportation(self):
        return self.transportation_per_yl * self.real_staff_count

    @property
    def real_night_cost(self):
        return min(self.night_cost, settings.MAX_NIGHT_COST)

    @property
    def nights_per_yl(self):
        if self.excursion is None:
            return 0

        return self.excursion.night_count * self.real_night_cost

    @property
    def total_nights(self):
        return self.nights_per_yl * self.real_staff_count

    @property
    def total_per_yl(self):
        return self.transportation_per_yl \
                + self.allowance_per_yl \
                + self.nights_per_yl

    @property
    def real_per_yl(self):
        if self.excursion is None:
            return 0

        return cvt_to_decimal(self.total_staff / self.excursion.staff_count)

    @property
    def total_staff(self):
        return self.total_per_yl * self.real_staff_count

    @property
    def real_staff_count(self):
        if self.excursion is None:
            return 0

        return min(self.excursion.staff_count, self.admissible_staff_count)

    @property
    def admissible_staff_count(self):
        """An excursion can have as many youth leaders as the max bound on integers allows. Not all youth leaders
        are refinanced though."""
        if self.excursion is None:
            return 0

        #raw_staff_count = self.excursion.jugendleiter.count()
        participant_count = self.excursion.participant_count
        if participant_count < 4:
            return 0
        elif 4 <= participant_count <= 7:
            return 2
        else:
            return 2 + math.ceil((participant_count - 7) / 7)

    @property
    def total(self):
        return self.total_bills + self.total_staff

    @property
    def total_theoretic(self):
        return self.total_bills_theoretic + self.total_staff

    def total_pretty(self):
        return "{}€".format(self.total)
    total_pretty.short_description = _('Total')

    def template_context(self):
        context = {
            'total_bills': self.total_bills,
            'total_bills_theoretic': self.total_bills_theoretic,
            'total': self.total,
        }
        if self.excursion:
            excursion_context = {
                'nights': self.excursion.night_count,
                'price_per_night': self.real_night_cost,
                'duration': self.excursion.duration,
                'staff_count': self.real_staff_count,
                'kilometers_traveled': self.excursion.kilometers_traveled,
                'means_of_transport': self.excursion.get_tour_approach(),
                'euro_per_km': self.euro_per_km,
                'allowance_per_day': settings.ALLOWANCE_PER_DAY,
                'nights_per_yl': self.nights_per_yl,
                'allowance_per_yl': self.allowance_per_yl,
                'transportation_per_yl': self.transportation_per_yl,
                'total_per_yl': self.total_per_yl,
                'total_staff': self.total_staff,
            }
            return dict(context, **excursion_context)
        else:
            return context

    def grouped_bills(self):
        return self.bill_set.values('short_description')\
                            .order_by('short_description')\
                            .annotate(amount=Sum('amount'))


class StatementUnSubmittedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(submitted=False, confirmed=False)


class StatementUnSubmitted(Statement):
    objects = StatementUnSubmittedManager()

    class Meta(CommonModel.Meta):
        proxy = True
        verbose_name = _('Statement in preparation')
        verbose_name_plural = _('Statements in preparation')
        rules_permissions = {
            'add_obj': rules.is_staff,
            'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_statementunsubmitted'),
            'change_obj': is_creator | leads_excursion,
            'delete_obj': is_creator | leads_excursion,
        }


class StatementSubmittedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(submitted=True, confirmed=False)


class StatementSubmitted(Statement):
    objects = StatementSubmittedManager()

    class Meta(CommonModel.Meta):
        proxy = True
        verbose_name = _('Submitted statement')
        verbose_name_plural = _('Submitted statements')
        permissions = [
            ('process_statementsubmitted', 'Can manage submitted statements.'),
        ]


class StatementConfirmedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(confirmed=True)


class StatementConfirmed(Statement):
    objects = StatementConfirmedManager()

    class Meta(CommonModel.Meta):
        proxy = True
        verbose_name = _('Paid statement')
        verbose_name_plural = _('Paid statements')
        permissions = [
            ('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'),
        ]


class Bill(CommonModel):
    statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE)
    short_description = models.CharField(verbose_name=_('Short description'), max_length=30)
    explanation = models.TextField(verbose_name=_('Explanation'), blank=True)

    amount = models.DecimalField(verbose_name=_('Amount'), max_digits=6, decimal_places=2, default=0)
    paid_by = models.ForeignKey(Member, verbose_name=_('Paid by'), null=True,
                                on_delete=models.SET_NULL)
    costs_covered = models.BooleanField(verbose_name=_('Covered'), default=False)
    refunded = models.BooleanField(verbose_name=_('Refunded'), default=False)

    proof = RestrictedFileField(verbose_name=_('Proof'),
                                upload_to='bill_images',
                                blank=True,
                                max_upload_size=5,
                                content_types=['application/pdf',
                                                    'image/jpeg',
                                                    'image/png',
                                                    'image/gif'])

    def __str__(self):
        return "{} ({}€)".format(self.short_description, self.amount)

    def pretty_amount(self):
        return "{}€".format(self.amount)
    pretty_amount.admin_order_field = 'amount'
    pretty_amount.short_description = _('Amount')

    class Meta(CommonModel.Meta):
        verbose_name = _('Bill')
        verbose_name_plural = _('Bills')


class BillOnExcursionProxy(Bill):
    class Meta(CommonModel.Meta):
        proxy = True
        verbose_name = _('Bill')
        verbose_name_plural = _('Bills')
        rules_permissions = {
            'add_obj': leads_excursion & not_submitted,
            'view_obj': leads_excursion | has_global_perm('finance.view_global_billonexcursionproxy'),
            'change_obj': (leads_excursion | has_global_perm('finance.change_global_billonexcursionproxy')) & not_submitted,
            'delete_obj': (leads_excursion | has_global_perm('finance.delete_global_billonexcursionproxy')) & not_submitted,
        }


class BillOnStatementProxy(Bill):
    class Meta(CommonModel.Meta):
        proxy = True
        verbose_name = _('Bill')
        verbose_name_plural = _('Bills')
        rules_permissions = {
            'add_obj': (is_creator | leads_excursion) & not_submitted,
            'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_billonstatementproxy'),
            'change_obj': (is_creator | leads_excursion | has_global_perm('finance.change_global_billonstatementproxy'))
                     & (not_submitted | has_global_perm('finance.process_statementsubmitted')),
            'delete_obj': (is_creator | leads_excursion | has_global_perm('finance.delete_global_billonstatementproxy'))
                     & not_submitted,
        }


class Transaction(models.Model):
    reference = models.TextField(verbose_name=_('Reference'))
    amount = models.DecimalField(max_digits=6, decimal_places=2, verbose_name=_('Amount'))
    member = models.ForeignKey(Member, verbose_name=_('Recipient'),
                               on_delete=models.CASCADE)
    ledger = models.ForeignKey(Ledger, blank=False, null=True, default=None, verbose_name=_('Ledger'),
                               on_delete=models.SET_NULL)

    statement = models.ForeignKey(Statement, verbose_name=_('Statement'),
                                  on_delete=models.CASCADE)

    confirmed = models.BooleanField(verbose_name=_('Paid'), default=False)
    confirmed_date = models.DateTimeField(verbose_name=_('Paid on'), default=None, null=True)
    confirmed_by = models.ForeignKey(Member, verbose_name=_('Authorized by'),
                                     blank=True,
                                     null=True,
                                     on_delete=models.SET_NULL,
                                     related_name='confirmed_transactions')

    def __str__(self):
        return "T#{}".format(self.pk)

    @staticmethod
    def escape_reference(reference):
        umlaut_map = {
            'ä': 'ae', 'ö': 'oe', 'ü': 'ue',
            'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue',
            'ß': 'ss'
        }
        pattern = re.compile('|'.join(umlaut_map.keys()))
        int_reference = pattern.sub(lambda x: umlaut_map[x.group()], reference)
        allowed_chars = r"[^a-z0-9 /?: .,'+-]"
        clean_reference = re.sub(allowed_chars, '', int_reference, flags=re.IGNORECASE)
        return clean_reference

    def code(self):

        if self.amount == 0:
            return ""

        iban = IBAN(self.member.iban, allow_invalid=True)
        if not iban.is_valid:
            return ""
        bic = iban.bic

        reference = self.escape_reference(self.reference)
        
        # also escaping receiver as umlaute are also not allowed here
        receiver = self.escape_reference(f"{self.member.prename} {self.member.lastname}")
        return f"""BCD
001
1
SCT
{bic}
{receiver}
{iban}
EUR{self.amount}


{reference}"""

    class Meta:
        verbose_name = _('Transaction')
        verbose_name_plural = _('Transactions')


class Receipt(models.Model):
    short_description = models.CharField(verbose_name=_('Short description'), max_length=30)
    ledger = models.ForeignKey(Ledger, blank=False, null=False, verbose_name=_('Ledger'),
                               on_delete=models.CASCADE)
    amount = models.DecimalField(max_digits=6, decimal_places=2)
    comments = models.TextField()
