from datetime import date
from decimal import Decimal
from unittest.mock import patch

import pytest
from psycopg.types.range import DateRange
from pytest_factoryboy import LazyFixture
from wbcore.contrib.authentication.models import User

from wbaccounting.models import BookingEntry, EntryAccountingInformation, Invoice


@pytest.mark.django_db
class TestBookingEntry:
    def test_str(self, booking_entry: BookingEntry):
        assert str(booking_entry) == f"{booking_entry.title} ({booking_entry.period})"

    @pytest.mark.parametrize(
        "method,return_value",
        [
            ("get_endpoint_basename", "wbaccounting:bookingentry"),
            ("get_representation_value_key", "id"),
            ("get_representation_label_key", "{{title}}"),
            ("get_representation_endpoint", "wbaccounting:bookingentryrepresentation-list"),
        ],
    )
    def test_wbmodel_methods(self, method: str, return_value: str):
        assert getattr(BookingEntry, method)() == return_value

    @pytest.mark.parametrize("user__is_superuser", [False])
    def test_filter_for_user_no_superuser(self, booking_entry: BookingEntry, user: User):
        booking_entries = BookingEntry.objects.filter_for_user(user)  # type: ignore
        assert booking_entry not in booking_entries

    @pytest.mark.parametrize("user__is_superuser", [True])
    def test_filter_for_user_superuser(self, booking_entry: BookingEntry, user: User):
        booking_entries = BookingEntry.objects.filter_for_user(user)  # type: ignore
        assert booking_entry in booking_entries

    def test_filter_unassigned(self, invoice, booking_entry_factory: BookingEntry):
        b1 = booking_entry_factory.create(invoice=None, payment_date=date(2025, 1, 1))  # noqa
        b2 = booking_entry_factory.create(invoice=invoice, payment_date=None)  # noqa
        b3 = booking_entry_factory.create(invoice=None, payment_date=None)

        assert set(BookingEntry.objects.filter_unassigned()) == {b3}

    @patch.object(Invoice, "get_fx_rate")
    def test_assign_invoice(self, mock_get_fx_rate, invoice, booking_entry_factory):
        mock_get_fx_rate.return_value = Decimal("1.5")
        b = booking_entry_factory.create(invoice=invoice, payment_date=None)
        assert b.invoice_fx_rate == Decimal("1.5")
        assert b.invoice == invoice
        assert b.payment_date is None

        # ensure that reassignin an invoice with a payment date set the booking entry payment date accordingly
        invoice.payment_date = date.today()
        b.assign_invoice(invoice)

        assert b.payment_date == invoice.payment_date

    @pytest.mark.parametrize("user__is_superuser", [False])
    @pytest.mark.parametrize("user__user_permissions", [(["wbaccounting.view_bookingentry"])])
    @pytest.mark.parametrize("booking_entry__counterparty", [LazyFixture("entry")])
    @pytest.mark.parametrize("entry_accounting_information__entry", [LazyFixture("entry")])
    @pytest.mark.parametrize("entry_accounting_information__counterparty_is_private", [False])
    def test_filter_for_user_public_counterparty(
        self, booking_entry: BookingEntry, user: User, entry_accounting_information: EntryAccountingInformation
    ):
        booking_entries = BookingEntry.objects.filter_for_user(user)  # type: ignore
        assert booking_entry in booking_entries

    @pytest.mark.parametrize("user__is_superuser", [False])
    @pytest.mark.parametrize("user__user_permissions", [(["wbaccounting.view_bookingentry"])])
    @pytest.mark.parametrize("booking_entry__counterparty", [LazyFixture("entry")])
    @pytest.mark.parametrize("entry_accounting_information__entry", [LazyFixture("entry")])
    @pytest.mark.parametrize("entry_accounting_information__counterparty_is_private", [True])
    def test_filter_for_user_private_counterparty(
        self, booking_entry: BookingEntry, user: User, entry_accounting_information: EntryAccountingInformation
    ):
        booking_entries = BookingEntry.objects.filter_for_user(user)  # type: ignore
        assert booking_entry not in booking_entries

    @pytest.mark.parametrize("user__is_superuser", [False])
    @pytest.mark.parametrize("user__user_permissions", [(["wbaccounting.view_bookingentry"])])
    @pytest.mark.parametrize("booking_entry__counterparty", [LazyFixture("entry")])
    @pytest.mark.parametrize("entry_accounting_information__entry", [LazyFixture("entry")])
    @pytest.mark.parametrize("entry_accounting_information__counterparty_is_private", [True])
    def test_filter_for_user_private_counterparty_with_exempt(
        self, booking_entry: BookingEntry, user: User, entry_accounting_information: EntryAccountingInformation
    ):
        entry_accounting_information.exempt_users.add(user)
        booking_entries = BookingEntry.objects.filter_for_user(user)  # type: ignore
        assert booking_entry in booking_entries

    @pytest.mark.parametrize("booking_entry__net_value", [None])
    @pytest.mark.parametrize("booking_entry__gross_value", [Decimal(99)])
    @pytest.mark.parametrize("booking_entry__vat", [Decimal(0.1)])
    def test_gross_without_net(self, booking_entry: BookingEntry):
        assert pytest.approx(booking_entry.net_value) == Decimal(90)

    def test_invoice_value_same_currency(self, booking_entry: BookingEntry):
        booking_entry.refresh_from_db()
        assert booking_entry.net_value == booking_entry.invoice_net_value
        assert booking_entry.gross_value == booking_entry.invoice_gross_value

    def test_invoice_value_different_currency(
        self, booking_entry: BookingEntry, invoice: Invoice, currency_factory, mocker
    ):
        # Change the invoice currency
        invoice.invoice_currency = currency_factory.create()
        invoice.save()

        # Patch the currency convert method to no rely on the database
        mocker.patch("wbcore.contrib.currency.models.Currency.convert", return_value=Decimal(1.1))
        # Run the save method to trigger fx computation
        booking_entry.save()
        assert booking_entry.invoice_net_value == pytest.approx(
            booking_entry.net_value * booking_entry.invoice_fx_rate
        )  # type: ignore

    # This old test that checked if save was called if booking entry was save, this was inefficent an
    # def test_invoice_save_on_change(self, booking_entry: BookingEntry, mocker):
    #     mocked_save = mocker.patch("wbaccounting.models.invoice.Invoice.save", autospec=True)
    #     booking_entry.save()
    #     mocked_save.assert_called_once()
    #
    # def test_invoice_delete_on_change(self, booking_entry: BookingEntry, mocker):
    #     mocked_save = mocker.patch("wbaccounting.models.invoice.Invoice.save", autospec=True)
    #     booking_entry.delete()
    #     mocked_save.assert_called_once()


@pytest.mark.django_db
class TestBookingEntryManager:
    def test_bulk_create_filters_out_tiny_gross_values(
        self, currency, entry, account, product_factory, booking_entry_type, booking_entry_factory
    ):
        ok_entry = booking_entry_factory.build(
            currency=currency,
            counterparty=entry,
            product=product_factory.create(),
            type=booking_entry_type,
            account=account,
            invoice=None,
            gross_value=BookingEntry.MINIMUM_VALID_GROSS_VALUE + Decimal("0.01"),
        )
        tiny_entry = booking_entry_factory.build(
            currency=currency,
            counterparty=entry,
            product=product_factory.create(),
            type=booking_entry_type,
            account=account,
            invoice=None,
            gross_value=BookingEntry.MINIMUM_VALID_GROSS_VALUE,
        )

        BookingEntry.objects.bulk_create(entry, [ok_entry, tiny_entry])

        assert BookingEntry.objects.count() == 1
        assert BookingEntry.objects.first().gross_value == ok_entry.gross_value

    def test_bulk_create_creates_basic(
        self, currency, entry, account, product, booking_entry_type, booking_entry_factory
    ):
        draft = booking_entry_factory.build(
            currency=currency,
            period=DateRange(date(2025, 1, 1), date(2025, 4, 1)),
            counterparty=entry,
            account=account,
            product=product,
            type=booking_entry_type,
            invoice=None,
            gross_value=Decimal("100"),
        )

        created = BookingEntry.objects.bulk_create(entry, [draft])

        assert len(created) == 1
        db_entry = BookingEntry.objects.get(pk=created[0].pk)
        assert db_entry.gross_value == Decimal("100")

        # assert that reimporting will update existing and delete the older one
        correction = booking_entry_factory.build(
            currency=currency,
            counterparty=entry,
            product=draft.product,
            type=draft.type,
            account=draft.account,
            period=draft.period,
            gross_value=Decimal("200"),
            invoice=None,
        )

        BookingEntry.objects.bulk_create(entry, [correction])

        # Old draft must be gone, new one present
        with pytest.raises(BookingEntry.DoesNotExist):
            draft.refresh_from_db()

        assert BookingEntry.objects.get(
            counterparty=entry,
            product=draft.product,
            type=draft.type,
            account=draft.account,
            period=draft.period,
        ).gross_value == Decimal("200")

    def test_bulk_create_if_invoiced_same_account(self, entry, booking_entry_factory, invoice_factory):
        invoice = invoice_factory()
        invoiced = booking_entry_factory.create(
            counterparty=entry,
            invoice=invoice,
            gross_value=Decimal("150"),
        )
        correction = booking_entry_factory.build(
            invoice=None,
            currency=invoiced.currency,
            counterparty=entry,
            product=invoiced.product,
            type=invoiced.type,
            account=invoiced.account,
            period=invoiced.period,
            gross_value=Decimal("200"),
        )

        created = BookingEntry.objects.bulk_create(entry, [correction])

        invoiced.refresh_from_db()
        assert created[0].gross_value == Decimal("200") - Decimal("150")
        assert invoiced.gross_value == Decimal("150")  # shouldn't be touched

    def test_bulk_create_squashable_entries_different_account(
        self, entry, invoice, account_factory, booking_entry_factory, invoice_factory
    ):
        invoiced = booking_entry_factory.create(
            counterparty=entry, invoice=invoice, gross_value=Decimal("300"), account=None
        )

        entry_1 = booking_entry_factory.build(
            invoice=None,
            currency=invoiced.currency,
            counterparty=entry,
            product=invoiced.product,
            type=invoiced.type,
            account=account_factory.create(),
            period=invoiced.period,
            gross_value=Decimal("200"),
        )

        entry_2 = booking_entry_factory.build(
            invoice=None,
            currency=invoiced.currency,
            counterparty=entry,
            product=invoiced.product,
            type=invoiced.type,
            account=account_factory.create(),
            period=invoiced.period,
            gross_value=Decimal("150"),
        )

        created = BookingEntry.objects.bulk_create(entry, [entry_1, entry_2])

        # e1/e2 should have been merged and account unset during processing
        assert len(created) == 1
        merged = created[0]
        # difference: (200 + 150) - 300 = 50
        assert merged.gross_value == Decimal("50")
        assert merged.initial_booking_entry == invoiced
        assert merged.account is None

    def test_bulk_create_creates_opposite_for_obsolete_drafts(
        self, entry, booking_entry_factory, invoice, account_factory
    ):
        ok_invoiced = booking_entry_factory.create(
            invoice=invoice, counterparty=entry, gross_value=Decimal("200"), account=None
        )
        wrongly_invoiced = booking_entry_factory.create(
            period=ok_invoiced.period, invoice=invoice, counterparty=entry, gross_value=Decimal("150"), account=None
        )

        correction = booking_entry_factory.build(
            invoice=None,
            currency=ok_invoiced.currency,
            counterparty=entry,
            product=ok_invoiced.product,
            type=ok_invoiced.type,
            account=None,
            period=ok_invoiced.period,
            gross_value=Decimal("200"),
        )

        BookingEntry.objects.bulk_create(entry, [correction])

        ok_invoiced.refresh_from_db()
        wrongly_invoiced.refresh_from_db()
        assert ok_invoiced.gross_value == Decimal("200")
        assert wrongly_invoiced.gross_value == Decimal("150")

        opposite_booking = BookingEntry.objects.get(
            counterparty=entry,
            product=wrongly_invoiced.product,
            type=wrongly_invoiced.type,
            invoice__isnull=True,
            account__isnull=True,
        )
        assert opposite_booking.gross_value == Decimal("-150")
