from collections import defaultdict
from contextlib import suppress
from datetime import timedelta
from decimal import Decimal
from typing import TYPE_CHECKING

from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import DateRangeField, RangeOperators
from django.db import models, transaction
from django.dispatch import receiver
from psycopg.types.range import DateRange
from slugify import slugify
from wbcore.contrib.authentication.models import User
from wbcore.contrib.currency.models import CurrencyFXRates
from wbcore.contrib.directory.models import Entry
from wbcore.models import WBModel
from wbcore.signals import pre_merge

if TYPE_CHECKING:
    from wbaccounting.models import Invoice


class BookingEntryDefaultQuerySet(models.QuerySet):
    def filter_for_user(self, user: User) -> models.QuerySet:
        """
        Filters booking entries based on if the current user can see the invoice.

        Args:
            user (User): The user for whom booking entries need to be filtered.

        Returns:
            QuerySet: A filtered queryset.
        """

        # Superuser and users with the admin permission can see all booking entries
        if user.is_superuser or user.has_perm("wbaccounting.administrate_invoice"):
            return self

        # If the user doesn't have the view permission, nothing can be seen
        if not user.has_perm("wbaccounting.view_bookingentry"):
            return self.none()

        # The user can see all booking entries where the counterparty is not private
        # or where the user is part of the exempt users list
        return self.filter(
            models.Q(counterparty__entry_accounting_information__counterparty_is_private=False)
            | models.Q(counterparty__entry_accounting_information__exempt_users=user)
        )

    def filter_unassigned(self) -> models.QuerySet:
        return self.filter(invoice__isnull=True, payment_date__isnull=True)


class BookingEntryManager(models.Manager):
    def get_queryset(self) -> BookingEntryDefaultQuerySet:
        return BookingEntryDefaultQuerySet(self.model)

    def filter_for_user(self, user: User):
        return self.get_queryset().filter_for_user(user)

    def filter_unassigned(self):
        return self.get_queryset().filter_unassigned()

    @transaction.atomic
    def bulk_create(self, counterparty: Entry, objs: list["BookingEntry"], *args, **kwargs) -> list["BookingEntry"]:
        if not objs:
            return super().bulk_create(objs, *args, **kwargs)

        created_entries = []
        squashable_entries = defaultdict(list)
        default_period = objs[0].period
        existing_invoiced_booking_entries = BookingEntry.objects.filter(
            invoice__isnull=False, period=default_period, counterparty=counterparty
        )
        for booking_entry in objs:
            conditions = (
                models.Q(counterparty=counterparty)
                & models.Q(product=booking_entry.product)
                & models.Q(type=booking_entry.type)
                & (models.Q(account=booking_entry.account) | models.Q(account__isnull=True))
                & models.Q(period__overlap=booking_entry.period)
            )
            # delete existing draft booking entry to replace them with newest values, this is safe as we exclitictly ensure these are not linked to invoice or paid yet
            BookingEntry.objects.filter(conditions).filter(invoice__isnull=True).delete()
            # We have an unpaid booking; check if there is an already-paid, invoiced one.
            try:
                invoiced_booking_entry = BookingEntry.objects.filter(conditions).get(
                    invoice__isnull=False,
                )
                booking_entry.initial_booking_entry = invoiced_booking_entry
                # if the invoiced booking entry and the newest booking entry share the same account, we can safely merge them together (by taking the gross value difference)
                if invoiced_booking_entry.account == booking_entry.account:
                    if invoiced_booking_entry.gross_value == booking_entry.gross_value:
                        continue
                    else:
                        # we store the difference from the already invoiced gross value

                        booking_entry.gross_value -= invoiced_booking_entry.gross_value
                        if booking_entry.gross_value:
                            created_entries.append(booking_entry)
                # otherwise, we have to unset the account and handle these squashable booking entries in a later state, once we have processed all entries
                else:
                    booking_entry.account = None
                    squashable_entries[invoiced_booking_entry].append(booking_entry)
                # we update fields that might have changed
                if invoiced_booking_entry.parameters != booking_entry.parameters:
                    invoiced_booking_entry.parameters = booking_entry.parameters
                    invoiced_booking_entry.quantity = booking_entry.quantity
                    invoiced_booking_entry.save()
                existing_invoiced_booking_entries = existing_invoiced_booking_entries.exclude(
                    id=invoiced_booking_entry.id
                )
            except BookingEntry.DoesNotExist:
                created_entries.append(booking_entry)

        # for each squashable booking entries (entries from an invoice whose account are not already set), we have no other choice than merging all booking entry for each account and match the sum gross value agasint the already invoiced booking entry to get the difference to be paid
        for invoiced_booking_entry, duplicated_entries in squashable_entries.items():
            total_gross_value = sum(map(lambda o: o.gross_value, duplicated_entries))
            booking_entry = duplicated_entries[
                0
            ]  # this is arbitrary but we expect all other fields to be the same for all duplicated booking entries (e.g. vat and fx rate should be equivalent among similar product, account, counterparty and type)
            booking_entry.gross_value = total_gross_value - invoiced_booking_entry.gross_value
            created_entries.append(booking_entry)

        for obselete_booking_entry in existing_invoiced_booking_entries:
            # we predelete existing draft booking entry without account as we will replace it with a new value
            BookingEntry.objects.filter(
                key=obselete_booking_entry.key,
                period=obselete_booking_entry.period,
                account__isnull=True,
                invoice__isnull=True,
                counterparty=counterparty,
            ).delete()
            created_entries.append(
                self.model(
                    key=obselete_booking_entry.key,
                    period=obselete_booking_entry.period,
                    counterparty=counterparty,
                    product=obselete_booking_entry.product,
                    type=obselete_booking_entry.type,
                    booking_date=obselete_booking_entry.booking_date,
                    vat=obselete_booking_entry.vat,
                    gross_value=obselete_booking_entry.gross_value
                    * Decimal(
                        "-1"
                    ),  # we create the opposite booking entry to represent that we overpaid a position that should have been sold
                    currency=obselete_booking_entry.currency,
                    initial_booking_entry=obselete_booking_entry,
                )
            )
        created_entries = list(
            filter(lambda o: abs(o.gross_value) > BookingEntry.MINIMUM_VALID_GROSS_VALUE, created_entries)
        )
        # Delegate: bulk insert
        return super().bulk_create(created_entries, *args, **kwargs)


class BookingEntryType(WBModel):
    key = models.CharField(max_length=255, verbose_name="Key", unique=True, db_index=True)
    title = models.CharField(max_length=255, verbose_name="Title")

    class Meta:
        verbose_name = "Booking Type"
        verbose_name_plural = "Bookings Type"

    def __str__(self) -> str:
        return self.title

    def save(self, *args, **kwargs):
        if not self.key:
            self.key = slugify(self.title)
        super().save(*args, **kwargs)

    @classmethod
    def get_endpoint_basename(cls) -> str:
        return "wbaccounting:bookingentrytype"

    @classmethod
    def get_representation_value_key(cls) -> str:
        return "id"

    @classmethod
    def get_representation_label_key(cls) -> str:
        return "{{title}}"

    @classmethod
    def get_representation_endpoint(cls) -> str:
        return "wbaccounting:bookingentrytyperepresentation-list"


class BookingEntry(WBModel):
    MINIMUM_VALID_GROSS_VALUE: int = 1  # booking entry with gross value bellow 1 are excluded

    class Meta:
        verbose_name = "Booking"
        verbose_name_plural = "Bookings"

        permissions = (
            (
                "can_generate_booking_entries",
                "Can Generate Bookings",
            ),
        )
        constraints = [
            ExclusionConstraint(
                name="exclude_overlapping_booking_entry",
                condition=models.Q(invoice__isnull=True) & models.Q(account__isnull=False),
                expressions=[
                    ("period", RangeOperators.OVERLAPS),
                    ("counterparty", RangeOperators.EQUAL),
                    ("key", RangeOperators.EQUAL),
                    ("account", RangeOperators.EQUAL),
                ],
            ),
            ExclusionConstraint(
                name="exclude_overlapping_booking_entry_no_account",
                condition=models.Q(invoice__isnull=True) & models.Q(account__isnull=True),
                expressions=[
                    ("period", RangeOperators.OVERLAPS),
                    ("counterparty", RangeOperators.EQUAL),
                    ("key", RangeOperators.EQUAL),
                ],
            ),
            ExclusionConstraint(
                name="exclude_overlapping_invoiced_booking_entry",
                condition=models.Q(invoice__isnull=False) & models.Q(account__isnull=False),
                expressions=[
                    ("period", RangeOperators.OVERLAPS),
                    ("counterparty", RangeOperators.EQUAL),
                    ("key", RangeOperators.EQUAL),
                    ("account", RangeOperators.EQUAL),
                    ("invoice", RangeOperators.EQUAL),
                ],
            ),
            ExclusionConstraint(
                name="exclude_overlapping_invoiced_booking_entry_no_account",
                condition=models.Q(invoice__isnull=False) & models.Q(account__isnull=True),
                expressions=[
                    ("period", RangeOperators.OVERLAPS),
                    ("counterparty", RangeOperators.EQUAL),
                    ("key", RangeOperators.EQUAL),
                    ("invoice", RangeOperators.EQUAL),
                ],
            ),
        ]

        indexes = [
            models.Index(fields=["counterparty", "type", "product", "account", "period"]),
            models.Index(fields=["counterparty", "type", "product", "account", "period", "invoice"]),
        ]

    def __str__(self) -> str:
        return f"{self.title} ({self.period})"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Reference date defaults to booking date if not specified
        if self.booking_date:
            if self.period is None:
                self.period = DateRange(self.booking_date, self.booking_date + timedelta(days=1))
            if not self.reference_date:
                self.reference_date = self.booking_date
        if self.product and self.type:
            self.title = self.compute_str()
        self.key = self.get_key()

    objects = BookingEntryManager()
    key = models.CharField(max_length=255, verbose_name="Key")

    # The title of the booking entry which describes what was booked
    period = DateRangeField(verbose_name="Period")
    # The entry who has to pay / receive money
    counterparty = models.ForeignKey(
        "directory.Entry", related_name="booking_entries", on_delete=models.PROTECT, verbose_name="Counterparty"
    )

    # we have to keep these fields nullable because for manually created oboking entry, we usually don't have an uniformized product and type pair
    account = models.ForeignKey(
        "wbcrm.Account",
        related_name="booking_entries",
        on_delete=models.SET_NULL,
        verbose_name="Account",
        blank=True,
        null=True,
    )

    title = models.CharField(max_length=255, verbose_name="Title")
    product = models.ForeignKey(
        "wbcrm.Product",
        related_name="booking_entries",
        on_delete=models.CASCADE,
        verbose_name="Product",
        blank=True,
        null=True,
    )
    type = models.ForeignKey(
        BookingEntryType,
        related_name="booking_entries",
        on_delete=models.CASCADE,
        verbose_name="Type",
        blank=True,
        null=True,
    )

    # The date when this booking entry is booked. Most likely the current date
    booking_date = models.DateField(verbose_name="Booking Date")

    # The date when this booking entry has to be paid / received. If None, this date is not important.
    due_date = models.DateField(null=True, blank=True, verbose_name="Due Date")

    # The date when this booking entry was paid / received. If None, this booking entry is still "open"
    payment_date = models.DateField(null=True, blank=True, verbose_name="Payment Date")

    # The reference date represents a date to which accounting period a booking entry belongs
    reference_date = models.DateField(verbose_name="Reference Date", null=True, blank=True)

    # Either Gross or Net has to be specified. If both are specifying then net is preferred.
    # If only one is specified the other one is determined based on the given VAT. The default
    # VAT is 0%. If the value of net/gross is negative, then money is paid.

    vat = models.DecimalField(max_digits=4, decimal_places=4, default=Decimal(0), verbose_name="VAT")
    gross_value = models.DecimalField(
        max_digits=16, decimal_places=4, default=Decimal("0"), verbose_name="Gross Value"
    )
    net_value = models.GeneratedField(
        expression=models.F("gross_value") / (models.Value(Decimal("1")) + models.F("vat")),
        output_field=models.DecimalField(max_digits=16, decimal_places=4),
        db_persist=True,
    )
    quantity = models.IntegerField(null=True, blank=True, verbose_name="Quantity")

    invoice_fx_rate = models.DecimalField(
        max_digits=20, decimal_places=6, null=True, blank=True, verbose_name="Invoice FX Rate"
    )
    invoice_net_value = models.GeneratedField(
        expression=models.F("invoice_fx_rate")
        * (models.F("gross_value") / (models.Value(Decimal("1")) + models.F("vat"))),
        output_field=models.DecimalField(max_digits=16, decimal_places=4, null=True, blank=True),
        db_persist=True,
    )
    invoice_gross_value = models.GeneratedField(
        expression=models.F("invoice_fx_rate") * models.F("gross_value"),
        output_field=models.DecimalField(max_digits=16, decimal_places=4, null=True, blank=True),
        db_persist=True,
    )

    # The currency of the booking entry
    currency = models.ForeignKey(
        "currency.Currency", related_name="booking_entries", on_delete=models.PROTECT, verbose_name="Currency"
    )

    invoice = models.ForeignKey(
        "Invoice",
        related_name="booking_entries",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        verbose_name="Invoice",
    )
    initial_booking_entry = models.ForeignKey(
        "BookingEntry",
        related_name="related_entries",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        verbose_name="Initial Booking Entry",
        help_text="If set, contains the booking entry already invoiced wich this entry values should override.",
    )
    # The backlinks stores information and the destination where to find the underlying data
    # The backlinks is rendered in lists and instances as a button
    backlinks = models.JSONField(default=dict, blank=True)

    # Extra parameters for rendering on an invoice
    parameters = models.JSONField(default=dict, blank=True)

    # Generator that produced this Booking Entry
    generator = models.CharField(max_length=256, null=True, blank=True)

    def save(self, *args, **kwargs):
        if self.invoice:
            self.assign_invoice(self.invoice)
        super().save(*args, **kwargs)

    def compute_str(self) -> str:
        return f"{self.product.title} {self.type}"

    def get_key(self) -> str:
        if self.product and self.type:
            return f"product-{self.product.id}-type-{self.type.id}"
        else:
            return slugify(self.title)

    def assign_invoice(self, invoice: "Invoice"):
        # We get the fx rate (If it is the same currency, then it will be 1)
        self.invoice = invoice
        with suppress(CurrencyFXRates.DoesNotExist):
            self.invoice_fx_rate = invoice.get_fx_rate(self.booking_date, self.currency)
            if self.invoice.payment_date and not self.payment_date:
                self.payment_date = self.invoice.payment_date

    @classmethod
    def get_endpoint_basename(cls):
        return "wbaccounting:bookingentry"

    @classmethod
    def get_representation_value_key(cls):
        return "id"

    @classmethod
    def get_representation_label_key(cls):
        return "{{title}}"

    @classmethod
    def get_representation_endpoint(cls):
        return "wbaccounting:bookingentryrepresentation-list"


@receiver(pre_merge, sender="directory.Entry")
def pre_merge_entry_bookinentry(sender: models.Model, merged_object, main_object, **kwargs):
    BookingEntry.objects.filter(counterparty=merged_object).update(counterparty=main_object)
