from decimal import Decimal

from django.db import IntegrityError, transaction
from django.test import TestCase

from .models import LedgerAccount, LedgerEntry, LedgerJournal
from .services import LedgerError, account_balance, post_journal, trial_balance


class PostJournalTests(TestCase):
    """Chart of accounts (1000/2000/2100/2200/4000) is seeded by ledger's
    post_migrate signal, so it already exists in the test database."""

    def test_balanced_journal_posts_matching_entries(self):
        journal = post_journal(
            reference="TEST-1",
            lines=[
                {"account": "1000", "debit": "1000.00"},
                {"account": "2000", "credit": "1000.00"},
            ],
        )

        self.assertEqual(LedgerEntry.objects.filter(journal=journal).count(), 2)
        self.assertEqual(account_balance("1000"), Decimal("1000.00"))
        self.assertEqual(account_balance("2000"), Decimal("-1000.00"))
        self.assertEqual(trial_balance()["imbalance"], Decimal("0"))

    def test_unbalanced_journal_is_rejected_and_not_persisted(self):
        with self.assertRaises(LedgerError):
            post_journal(
                reference="TEST-UNBALANCED",
                lines=[
                    {"account": "1000", "debit": "1000.00"},
                    {"account": "2000", "credit": "900.00"},
                ],
            )

        self.assertFalse(LedgerJournal.objects.filter(reference="TEST-UNBALANCED").exists())

    def test_negative_amount_is_rejected(self):
        with self.assertRaises(LedgerError):
            post_journal(
                reference="TEST-NEGATIVE",
                lines=[
                    {"account": "1000", "debit": "-100.00"},
                    {"account": "2000", "credit": "-100.00"},
                ],
            )

    def test_line_cannot_debit_and_credit_at_once(self):
        with self.assertRaises(LedgerError):
            post_journal(
                reference="TEST-BOTH-SIDES",
                lines=[{"account": "1000", "debit": "100.00", "credit": "100.00"}],
            )

    def test_line_must_move_a_non_zero_amount(self):
        with self.assertRaises(LedgerError):
            post_journal(reference="TEST-NO-MOVEMENT", lines=[{"account": "1000", "debit": "0"}])

    def test_unknown_account_is_rejected(self):
        with self.assertRaises(LedgerError):
            post_journal(
                reference="TEST-UNKNOWN-ACCOUNT",
                lines=[
                    {"account": "9999", "debit": "100.00"},
                    {"account": "1000", "credit": "100.00"},
                ],
            )

    def test_inactive_account_is_rejected(self):
        LedgerAccount.objects.filter(code="1000").update(is_active=False)

        with self.assertRaises(LedgerError):
            post_journal(
                reference="TEST-INACTIVE",
                lines=[
                    {"account": "1000", "debit": "100.00"},
                    {"account": "2000", "credit": "100.00"},
                ],
            )

    def test_replay_by_idempotency_key_does_not_double_post(self):
        first = post_journal(
            reference="TEST-IDEMPOTENT-A",
            idempotency_key="fund:deal-1",
            lines=[
                {"account": "1000", "debit": "500.00"},
                {"account": "2000", "credit": "500.00"},
            ],
        )

        second = post_journal(
            reference="TEST-IDEMPOTENT-B",  # different reference, same key
            idempotency_key="fund:deal-1",
            lines=[
                {"account": "1000", "debit": "500.00"},
                {"account": "2000", "credit": "500.00"},
            ],
        )

        self.assertEqual(first.id, second.id)
        self.assertEqual(LedgerJournal.objects.filter(idempotency_key="fund:deal-1").count(), 1)
        self.assertEqual(account_balance("1000"), Decimal("500.00"))

    def test_replay_by_reference_alone_does_not_double_post(self):
        first = post_journal(
            reference="TEST-SAME-REF",
            lines=[
                {"account": "1000", "debit": "200.00"},
                {"account": "2000", "credit": "200.00"},
            ],
        )
        second = post_journal(
            reference="TEST-SAME-REF",
            lines=[
                {"account": "1000", "debit": "200.00"},
                {"account": "2000", "credit": "200.00"},
            ],
        )

        self.assertEqual(first.id, second.id)
        self.assertEqual(LedgerEntry.objects.filter(journal=first).count(), 2)


class LedgerEntryConstraintTests(TestCase):
    """post_journal() enforces these in Python; the DB CHECK constraints on
    LedgerEntry (ledger/migrations/0002) are a backstop against any writer
    that bypasses the service — assert them directly here."""

    def setUp(self):
        self.account = LedgerAccount.objects.get(code="1000")
        self.journal = LedgerJournal.objects.create(reference="CONSTRAINT-TEST")

    def test_both_zero_is_rejected_at_db_level(self):
        with self.assertRaises(IntegrityError), transaction.atomic():
            LedgerEntry.objects.create(journal=self.journal, account=self.account, debit=0, credit=0)

    def test_both_non_zero_is_rejected_at_db_level(self):
        with self.assertRaises(IntegrityError), transaction.atomic():
            LedgerEntry.objects.create(journal=self.journal, account=self.account, debit=50, credit=50)

    def test_negative_debit_is_rejected_at_db_level(self):
        with self.assertRaises(IntegrityError), transaction.atomic():
            LedgerEntry.objects.create(journal=self.journal, account=self.account, debit=-5, credit=0)

    def test_negative_credit_is_rejected_at_db_level(self):
        with self.assertRaises(IntegrityError), transaction.atomic():
            LedgerEntry.objects.create(journal=self.journal, account=self.account, debit=0, credit=-5)

    def test_single_sided_entry_is_allowed_at_db_level(self):
        LedgerEntry.objects.create(journal=self.journal, account=self.account, debit=10, credit=0)
        self.assertEqual(LedgerEntry.objects.filter(journal=self.journal).count(), 1)


class TrialBalanceTests(TestCase):
    def test_trial_balance_nets_to_zero_across_multiple_journals(self):
        post_journal(
            reference="TB-1",
            lines=[{"account": "1000", "debit": "1000.00"}, {"account": "2000", "credit": "1000.00"}],
        )
        post_journal(
            reference="TB-2",
            lines=[{"account": "2000", "debit": "400.00"}, {"account": "2100", "credit": "400.00"}],
        )

        tb = trial_balance()
        self.assertEqual(tb["total_debit"], tb["total_credit"])
        self.assertEqual(tb["imbalance"], Decimal("0"))

    def test_account_balance_for_unknown_code_is_zero(self):
        self.assertEqual(account_balance("does-not-exist"), Decimal("0"))
