import logging
from contextlib import suppress

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Sum
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.formats import localize
from django.utils.functional import cached_property

from store.models import Checklist
from tg_bot.messages import TGBonusMessage

logger = logging.getLogger(__name__)


class BonusType:
    # Другое начисление
    OTHER_DEPOSIT = 0

    # Клиент передал номер ТГ-боту
    SIGNUP = 1

    # Клиент сделал заказ
    DEFAULT_PURCHASE = 2

    # Приглашенный клиент сделал свою первую покупку, бонус реферреру
    FOR_INVITER = 3

    # Клиент сделал заказ и получил бонус за первую покупку от реферрера
    INVITED_FIRST_PURCHASE = 4

    # Другое списание
    OTHER_WITHDRAWAL = 10

    # Клиент потратил баллы на заказ
    SPENT_PURCHASE = 11

    CHOICES = (
        (OTHER_DEPOSIT, 'Другое начисление'),
        (SIGNUP, 'Бонус за регистрацию'),
        (DEFAULT_PURCHASE, 'Бонус за покупку'),
        (FOR_INVITER, 'Бонус за первую покупку приглашенного'),
        (INVITED_FIRST_PURCHASE, 'Бонус за первую покупку'),

        (OTHER_WITHDRAWAL, 'Другое списание'),
        (SPENT_PURCHASE, 'Списание бонусов за заказ'),
    )

    LOG_NAMES = {
        OTHER_DEPOSIT: 'OTHER_DEPOSIT',
        SIGNUP: 'SIGNUP',
        DEFAULT_PURCHASE: 'DEFAULT_PURCHASE',
        FOR_INVITER: 'FOR_INVITER',
        INVITED_FIRST_PURCHASE: 'INVITED_FIRST_PURCHASE',

        OTHER_WITHDRAWAL: 'OTHER_WITHDRAWAL',
        SPENT_PURCHASE: 'SPENT_PURCHASE',
    }

    ONE_TIME_TYPES = {SIGNUP}
    ORDER_TYPES = {DEFAULT_PURCHASE, FOR_INVITER, INVITED_FIRST_PURCHASE, SPENT_PURCHASE}


class BonusProgramTransactionQuerySet(models.QuerySet):
    # TODO: optimize queries
    def with_base_related(self):
        return self.select_related('order')

    def cancel(self):
        for t in self:
            t.cancel()


class BonusProgramTransaction(models.Model):
    """ Represents the history of all bonus program transactions """

    type = models.PositiveSmallIntegerField('Тип транзакции', choices=BonusType.CHOICES)
    user = models.ForeignKey('User', verbose_name='Пользователь транзакции', on_delete=models.CASCADE, related_name='bonus_transactions')
    date = models.DateTimeField('Дата транзакции', auto_now_add=True)
    amount = models.SmallIntegerField('Количество, руб')
    comment = models.CharField('Комментарий', max_length=200, null=True, blank=True)
    was_cancelled = models.BooleanField('Была отменена', editable=False, default=False)

    # Bound objects
    order = models.ForeignKey('store.Checklist', verbose_name="Связанный заказ", null=True, blank=True, on_delete=models.SET_NULL)

    objects = BonusProgramTransactionQuerySet.as_manager()

    class Meta:
        ordering = ['-date']
        verbose_name = "История баланса"
        verbose_name_plural = "История баланса"

    def _notify_user_about_new_transaction(self):
        msg = None

        match self.type:
            case BonusType.SIGNUP:
                msg = TGBonusMessage.SIGNUP.format(amount=self.amount)

            case BonusType.DEFAULT_PURCHASE:
                msg = TGBonusMessage.PURCHASE_ADDED.format(amount=self.amount, order_id=self.order.id)

            case BonusType.FOR_INVITER:
                msg = TGBonusMessage.FOR_INVITER.format(amount=self.amount)

            case BonusType.INVITED_FIRST_PURCHASE:
                msg = TGBonusMessage.INVITED_FIRST_PURCHASE.format(amount=self.amount, order_id=self.order.id)

            case BonusType.OTHER_DEPOSIT:
                comment = self.comment or "—"
                msg = TGBonusMessage.OTHER_DEPOSIT.format(amount=self.amount, comment=comment)

            case BonusType.OTHER_WITHDRAWAL:
                comment = self.comment or "—"
                msg = TGBonusMessage.OTHER_WITHDRAWAL.format(amount=abs(self.amount), comment=comment)

            case BonusType.SPENT_PURCHASE:
                msg = TGBonusMessage.PURCHASE_SPENT.format(amount=abs(self.amount), order_id=self.order_id)

            case _:
                pass

        if msg is not None:
            self.user.notify_tg_bot(msg)

    def cancel(self):
        # Skip transactions that refers to cancelled ones
        if self.was_cancelled:
            return

        date_formatted = localize(timezone.localtime(self.date))

        if self.amount > 0:
            comment = f"Отмена начисления #{self.id} от {date_formatted}"
            bonus_type = BonusType.OTHER_WITHDRAWAL
        elif self.amount < 0:
            comment = f"Отмена списания #{self.id} от {date_formatted}"
            bonus_type = BonusType.OTHER_DEPOSIT
        else:
            return

        # Create reverse transaction, user's balance will be recalculated in post_save signal
        transaction = BonusProgramTransaction(
            user_id=self.user_id,
            type=bonus_type,
            amount=self.amount * -1,
            comment=comment,
            order=self.order,
            was_cancelled=True
        )
        transaction.save()

        self.was_cancelled = True
        self.save()

    def delete(self, *args, **kwargs):
        # Don't delete transaction, cancel it instead
        self.cancel()

    def clean(self):
        # No underflow or dummy transactions allowed. Fail loudly
        if self.amount == 0 or (self.user.balance + self.amount) < 0:
            raise ValidationError("No underflow or dummy transactions allowed")

        # Check for uniqueness for given user
        qs = self.user.bonus_history.filter(type=self.type)
        if self.id:
            qs = qs.exclude(id=self.id)

        uniqueness_err = None
        bonus_name = BonusType.LOG_NAMES.get(self.type, self.type)

        match self.type:
            case t if t in BonusType.ONE_TIME_TYPES:
                if qs.exists():
                    uniqueness_err = f"User {self.user_id} already got {bonus_name} one-time bonus"

            case t if t in BonusType.ORDER_TYPES:
                # Check that order is defined
                if self.order_id is None:
                    raise ValidationError("Order is required for that type")

                # Check for duplicates for the same order
                already_exists = qs.filter(order_id=self.order_id).exists()
                if already_exists:
                    uniqueness_err = f"User {self.user_id} already got {bonus_name} bonus for order #{self.order_id}"

        if uniqueness_err:
            logger.info(uniqueness_err)
            raise ValidationError(uniqueness_err)

    def save(self, *args, **kwargs):
        self.full_clean()

        if self.id is None:
            self._notify_user_about_new_transaction()

        return super().save(*args, **kwargs)


def generate_referral_code():
    """ Generate unique numeric referral code for User """
    from account.models import User

    while True:
        allowed_chars = "0123456789"
        code = get_random_string(settings.REFERRAL_CODE_LENGTH, allowed_chars)

        # Hacky code for migrations
        if "referral_code" not in User._meta.fields:
            return code

        if not User.objects.filter(referral_code=code).exists():
            return code


class BonusProgramMixin(models.Model):
    balance = models.PositiveSmallIntegerField('Баланс, руб', default=0, editable=False)
    referral_code = models.CharField(max_length=settings.REFERRAL_CODE_LENGTH, default=generate_referral_code,
                                     editable=False)

    class Meta:
        abstract = True

    @cached_property
    def bonus_history(self):
        return BonusProgramTransaction.objects.filter(user_id=self.id)

    def update_balance(self, amount, bonus_type, comment=None, order=None):
        # No underflow or dummy transactions allowed, fail silently
        if amount == 0 or (self.balance + amount) < 0:
            return

        # Create bonus transaction, user's balance will be recalculated in post_save signal
        transaction = BonusProgramTransaction(user_id=self.id,
                                              amount=amount, type=bonus_type,
                                              comment=comment, order=order)
        transaction.save()
        self.refresh_from_db(fields=['balance'])

    def recalculate_balance(self):
        total_balance = self.bonus_history \
                            .aggregate(total_balance=Sum('amount'))['total_balance'] or 0

        self.balance = max(0, total_balance)
        self.save(update_fields=['balance'])

    def spend_bonuses(self, order: Checklist):
        if order is None or order.customer_id is None:
            return

        # Always use fresh balance
        self.recalculate_balance()

        # Spend full_price bonuses or nothing
        to_spend = min(self.balance, order.full_price)
        order.customer.update_balance(-to_spend, BonusType.SPENT_PURCHASE, order=order)

    def add_signup_bonus(self):
        bonus_type = BonusType.SIGNUP
        amount = self._get_bonus_amount("signup")

        self.update_balance(amount, bonus_type)

    def add_order_bonus(self, order):
        from store.models import Checklist

        bonus_type = BonusType.DEFAULT_PURCHASE
        amount = self._get_bonus_amount("default_purchase")

        if order.status not in Checklist.Status.BONUS_ORDER_STATUSES:
            return

        self.update_balance(amount, bonus_type, order=order)

    def add_referral_bonus(self, order: Checklist, for_inviter: bool):
        amount = self._get_bonus_amount("referral")

        # Check if data is sufficient
        if order.customer_id is None or order.customer.inviter is None:
            return

        # Check if eligible
        # Bonus is only for first purchase and only for orders that reached the CHINA_RUSSIA status
        if order.status != Checklist.Status.CHINA_RUSSIA or order.customer.customer_orders.count() != 1:
            return

        user = order.customer.inviter if for_inviter else order.customer
        bonus_type = BonusType.FOR_INVITER if for_inviter else BonusType.INVITED_FIRST_PURCHASE

        # Add bonuses
        user.update_balance(amount, bonus_type, order=order)

    @staticmethod
    def _get_bonus_amount(config_key) -> int:
        amount = 0
        with suppress(KeyError):
            amount = settings.BONUS_PROGRAM_CONFIG["amounts"][config_key]

        return amount

    # TODO: move to custom logger
    def _log(self, level, message: str):
        message = f"[BonusProgram #{self.id}] {message}"
        logger.log(level, message)
