"""Integration tests: full API CRUD, ViewSet filtering, derived endpoints
that the unit + endpoint tests don't cover, and the seed_accounting command
end-to-end against a generated workbook.
"""

import datetime
from decimal import Decimal
from io import BytesIO

import pytest
from django.contrib.auth import get_user_model
from django.core.management import call_command
from rest_framework.test import APIClient

from apps.accounting.models import (
    Account, Fund, Transaction, JournalEntry, JournalLine,
)

User = get_user_model()


@pytest.fixture
def client(db):
    admin = User.objects.create_superuser(email="acc-int@test.local", password="x")
    c = APIClient()
    c.force_authenticate(admin)
    c.organization = admin.organizations.first()
    return c


def _rows(resp):
    body = resp.json()
    return body["results"] if isinstance(body, dict) and "results" in body else body


def _same_id(a, b):
    """Compare ids across uuid7-str (no dashes) and DB UUID (dashed) forms."""
    return str(a).replace("-", "") == str(b).replace("-", "")


# --------------------------------------------------------------------------
# CRUD round-trips
# --------------------------------------------------------------------------

@pytest.mark.django_db
class TestAccountCRUD:
    def test_full_lifecycle(self, client):
        # Create
        r = client.post("/api/accounting/accounts/", {
            "code": " 1000 ", "name": "Cash", "type": "Asset", "normal_balance": "Debit",
        }, format="json")
        assert r.status_code == 201, r.content
        acc_id = r.json()["id"]
        assert r.json()["code"] == "1000"  # validate_code strips

        # Read list. Note: uuid7 str() has no dashes on the just-created
        # object, while the DB-round-tripped list renders canonical dashes —
        # so match on the business key, not the raw id string.
        assert any(a["code"] == "1000" for a in _rows(client.get("/api/accounting/accounts/")))

        # Update
        r = client.patch(f"/api/accounting/accounts/{acc_id}/", {"name": "Cash and Bank"}, format="json")
        assert r.status_code == 200
        assert r.json()["name"] == "Cash and Bank"

        # Delete
        assert client.delete(f"/api/accounting/accounts/{acc_id}/").status_code == 204
        assert not Account.objects.filter(id=acc_id).exists()

    def test_type_filter(self, client):
        Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset", normal_balance="Debit")
        Account.objects.create(organization=client.organization, code="5000", name="Salary", type="Expense", normal_balance="Debit")
        rows = _rows(client.get("/api/accounting/accounts/?type=Expense"))
        assert {a["code"] for a in rows} == {"5000"}


@pytest.mark.django_db
class TestFundEndpoint:
    def test_spent_annotation_from_posted_expenses(self, client):
        fund = Fund.objects.create(organization=client.organization, fund_id="FND-1", name="Health", fund_type="Restricted",
                                   received_amount=Decimal("1000000"))
        exp = Account.objects.create(organization=client.organization, code="5000", name="Expense", type="Expense", normal_balance="Debit")
        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset", normal_balance="Debit")
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-1", date=datetime.date(2026, 1, 1),
                                        fund=fund, status="Posted")
        JournalLine.objects.create(journal=e, account=exp, debit_idr=400000, credit_idr=0)
        JournalLine.objects.create(journal=e, account=cash, debit_idr=0, credit_idr=400000)

        row = next(f for f in _rows(client.get("/api/accounting/funds/")) if f["fund_id"] == "FND-1")
        assert Decimal(str(row["spent_amount"])) == Decimal("400000")
        assert Decimal(str(row["available_amount"])) == Decimal("600000")

    def test_draft_expense_not_counted(self, client):
        fund = Fund.objects.create(organization=client.organization, fund_id="FND-2", name="Edu", received_amount=Decimal("1000"))
        exp = Account.objects.create(organization=client.organization, code="5000", name="Expense", type="Expense", normal_balance="Debit")
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-D", date=datetime.date(2026, 1, 1),
                                        fund=fund, status="Draft")
        JournalLine.objects.create(journal=e, account=exp, debit_idr=500, credit_idr=0)
        row = next(f for f in _rows(client.get("/api/accounting/funds/")) if f["fund_id"] == "FND-2")
        assert Decimal(str(row["spent_amount"])) == Decimal("0")


@pytest.mark.django_db
class TestTransactionRecompute:
    def test_net_expense_recomputed_on_create(self, client):
        r = client.post("/api/accounting/transactions/", {
            "transaction_id": "TRX-1", "date": "2026-01-01", "transaction_type": "Expense",
            "debit_idr": "750", "credit_idr": "250",
        }, format="json")
        assert r.status_code == 201, r.content
        t = Transaction.objects.get(transaction_id="TRX-1")
        assert t.net_expense_idr == Decimal("500")  # 750 - 250

    def test_net_expense_recomputed_on_update(self, client):
        t = Transaction.objects.create(organization=client.organization, transaction_id="TRX-U", date=datetime.date(2026, 1, 1),
                                       transaction_type="Expense", debit_idr=100, credit_idr=0)
        r = client.patch(f"/api/accounting/transactions/{t.id}/",
                         {"debit_idr": "900", "credit_idr": "100"}, format="json")
        assert r.status_code == 200, r.content
        t.refresh_from_db()
        assert t.net_expense_idr == Decimal("800")

    def test_filters_type_and_status(self, client):
        Transaction.objects.create(organization=client.organization, transaction_id="A", date=datetime.date(2026, 1, 1),
                                   transaction_type="Expense", status="Posted")
        Transaction.objects.create(organization=client.organization, transaction_id="B", date=datetime.date(2026, 1, 1),
                                   transaction_type="Allocation", status="Draft")
        rows = _rows(client.get("/api/accounting/transactions/?status=Posted"))
        assert {t["transaction_id"] for t in rows} == {"A"}
        rows = _rows(client.get("/api/accounting/transactions/?transaction_type=Allocation"))
        assert {t["transaction_id"] for t in rows} == {"B"}


@pytest.mark.django_db
class TestJournalListFilter:
    def test_status_filter(self, client):
        JournalEntry.objects.create(organization=client.organization, journal_no="JV-P", date=datetime.date(2026, 1, 1), status="Posted")
        JournalEntry.objects.create(organization=client.organization, journal_no="JV-D", date=datetime.date(2026, 1, 1), status="Draft")
        rows = _rows(client.get("/api/accounting/journal-entries/?status=Posted"))
        assert {e["journal_no"] for e in rows} == {"JV-P"}


@pytest.mark.django_db
class TestIncomeRecognition:
    """Income-account journals exercise the income branches of the ISAK 335
    statements (position + activities)."""

    def test_recognized_income_flows_to_statements(self, client):
        fund = Fund.objects.create(organization=client.organization, fund_id="FND-1", name="Health", fund_type="Unrestricted")
        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset",
                                      normal_balance="Debit", is_cash=True)
        income = Account.objects.create(organization=client.organization, code="4000", name="Grant Income", type="Income",
                                        normal_balance="Credit")
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-INC", date=datetime.date(2026, 1, 1),
                                        fund=fund, status="Posted")
        JournalLine.objects.create(journal=e, account=cash, debit_idr=500, credit_idr=0)
        JournalLine.objects.create(journal=e, account=income, debit_idr=0, credit_idr=500)

        act = client.get("/api/accounting/isak335/activities/").json()
        assert Decimal(str(act["tanpa_pembatasan"]["total_income"])) == Decimal("500")
        assert Decimal(str(act["total_surplus"])) == Decimal("500")

        pos = client.get("/api/accounting/isak335/financial-position/").json()
        # Income raised net assets (without restriction) by 500; assets = 500.
        assert Decimal(str(pos["total_assets"])) == Decimal("500")
        assert Decimal(str(pos["net_assets"]["tanpa_pembatasan"])) == Decimal("500")
        assert pos["balanced"] is True


@pytest.mark.django_db
class TestJournalUpdateViaAPI:
    def test_patch_replaces_lines(self, client):
        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset", normal_balance="Debit")
        exp = Account.objects.create(organization=client.organization, code="5000", name="Exp", type="Expense", normal_balance="Debit")
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-U", date=datetime.date(2026, 1, 1))
        JournalLine.objects.create(journal=e, account=cash, debit_idr=999, credit_idr=0)
        r = client.patch(f"/api/accounting/journal-entries/{e.id}/", {
            "lines": [
                {"account": str(exp.id), "debit_idr": "60", "credit_idr": "0"},
                {"account": str(cash.id), "debit_idr": "0", "credit_idr": "60"},
            ],
        }, format="json")
        assert r.status_code == 200, r.content
        e.refresh_from_db()
        assert e.lines.count() == 2
        assert e.totals() == (Decimal("60"), Decimal("60"))


# --------------------------------------------------------------------------
# Derived endpoints not covered elsewhere
# --------------------------------------------------------------------------

@pytest.mark.django_db
class TestBudgetVsActual:
    def test_budget_minus_actual(self, client):
        fund = Fund.objects.create(organization=client.organization, fund_id="FND-1", name="Health", fund_type="Restricted")
        exp = Account.objects.create(organization=client.organization, code="5000", name="Exp", type="Expense", normal_balance="Debit")
        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset", normal_balance="Debit")
        # Budget: an Allocation transaction.
        Transaction.objects.create(organization=client.organization, transaction_id="ALLOC", date=datetime.date(2026, 1, 1),
                                   transaction_type="Allocation", fund=fund,
                                   budget_line="Personnel", original_amount=Decimal("100000"))
        # Actual: a posted expense journal.
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-1", date=datetime.date(2026, 2, 1),
                                        fund=fund, budget_line="Personnel", status="Posted")
        JournalLine.objects.create(journal=e, account=exp, debit_idr=30000, credit_idr=0)
        JournalLine.objects.create(journal=e, account=cash, debit_idr=0, credit_idr=30000)

        rows = client.get("/api/accounting/budget-vs-actual/").json()
        row = next(r for r in rows if r["budget_line"] == "Personnel")
        assert Decimal(str(row["original_budget"])) == Decimal("100000")
        assert Decimal(str(row["actual_expense"])) == Decimal("30000")
        assert Decimal(str(row["available_budget"])) == Decimal("70000")
        assert row["status"] == "Available"

    def test_over_budget_flag(self, client):
        fund = Fund.objects.create(organization=client.organization, fund_id="FND-9", name="Tiny", fund_type="Restricted")
        exp = Account.objects.create(organization=client.organization, code="5000", name="Exp", type="Expense", normal_balance="Debit")
        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset", normal_balance="Debit")
        Transaction.objects.create(organization=client.organization, transaction_id="ALLOC", date=datetime.date(2026, 1, 1),
                                   transaction_type="Allocation", fund=fund,
                                   budget_line="Travel", original_amount=Decimal("100"))
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-2", date=datetime.date(2026, 2, 1),
                                        fund=fund, budget_line="Travel", status="Posted")
        JournalLine.objects.create(journal=e, account=exp, debit_idr=250, credit_idr=0)
        JournalLine.objects.create(journal=e, account=cash, debit_idr=0, credit_idr=250)
        rows = client.get("/api/accounting/budget-vs-actual/").json()
        row = next(r for r in rows if r["budget_line"] == "Travel")
        assert row["status"] == "Over Budget"
        assert row["utilization"] > 1


@pytest.mark.django_db
class TestGeneralLedgerFilters:
    def test_filter_by_account_code(self, client):
        fund = Fund.objects.create(organization=client.organization, fund_id="FND-1", name="Health")
        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset", normal_balance="Debit")
        inc = Account.objects.create(organization=client.organization, code="2100", name="Deferred", type="Liability", normal_balance="Credit")
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-1", date=datetime.date(2026, 1, 1),
                                        fund=fund, status="Posted")
        JournalLine.objects.create(journal=e, account=cash, debit_idr=700, credit_idr=0)
        JournalLine.objects.create(journal=e, account=inc, debit_idr=0, credit_idr=700)

        body = client.get("/api/accounting/general-ledger/?account=1000").json()
        assert body["count"] == 1
        assert body["results"][0]["account_code"] == "1000"

    def test_excludes_draft(self, client):
        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset", normal_balance="Debit")
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-D", date=datetime.date(2026, 1, 1), status="Draft")
        JournalLine.objects.create(journal=e, account=cash, debit_idr=100, credit_idr=0)
        assert client.get("/api/accounting/general-ledger/").json()["results"] == []


@pytest.mark.django_db
class TestCashFlowClassification:
    def test_proportional_split_across_categories(self, client):
        # One journal: cash out 100, split across an operating expense (60)
        # and an investing asset (40). Cash delta should split 60/40.
        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset",
                                      normal_balance="Debit", is_cash=True,
                                      cash_flow_category="operating")
        op = Account.objects.create(organization=client.organization, code="5000", name="Salary", type="Expense",
                                    normal_balance="Debit", cash_flow_category="operating")
        inv = Account.objects.create(organization=client.organization, code="1500", name="Equipment", type="Asset",
                                     normal_balance="Debit", cash_flow_category="investing")
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-1", date=datetime.date(2026, 1, 1), status="Posted")
        JournalLine.objects.create(journal=e, account=op, debit_idr=60, credit_idr=0)
        JournalLine.objects.create(journal=e, account=inv, debit_idr=40, credit_idr=0)
        JournalLine.objects.create(journal=e, account=cash, debit_idr=0, credit_idr=100)

        body = client.get("/api/accounting/isak335/cash-flows/").json()
        assert Decimal(str(body["operating"])) == Decimal("-60")
        assert Decimal(str(body["investing"])) == Decimal("-40")
        assert Decimal(str(body["net_change"])) == Decimal("-100")
        assert Decimal(str(body["ending_cash"])) == Decimal("-100")


# --------------------------------------------------------------------------
# Seed command end-to-end
# --------------------------------------------------------------------------

def _build_workbook():
    """Build a minimal but structurally-faithful accounting workbook in memory."""
    import openpyxl
    wb = openpyxl.Workbook()
    wb.remove(wb.active)

    coa = wb.create_sheet("Chart of Accounts")
    coa.append(["NGO Chart of Accounts"])
    coa.append([])
    coa.append(["Account Code", "Account Name", "Type", "Normal Balance", "Notes"])
    coa.append(["1000", "Cash and Bank", "Asset", "Debit", "Bank"])
    coa.append(["2100", "Deferred Grant Income", "Liability", "Credit", "Unrecognized"])
    coa.append(["5000", "Personnel Expense", "Expense", "Debit", "Salaries"])

    funds = wb.create_sheet("Funds & Grants")
    funds.append(["Funds and Grant Register"])
    funds.append([])
    funds.append(["Fund ID", "Donor", "Grant / Fund Name", "Fund Type", "Project",
                  "Start Date", "End Date", "Award Amount (IDR)", "Received (IDR)",
                  "Spent (IDR)", "Available (IDR)"])
    funds.append(["FND-001", "Global Health", "Community Health", "Restricted", "PRJ-HEALTH",
                  datetime.datetime(2026, 1, 1), datetime.datetime(2026, 12, 31),
                  1000000, 700000, 0, 700000])

    tx = wb.create_sheet("Transactions")
    tx.append(["NGO Transaction Register"])
    tx.append([])
    tx.append(["note"])
    tx.append([])
    tx.append(["Transaction ID", "Date", "Description", "Transaction Type", "Fund ID",
               "Donor", "Project", "Activity", "Budget Line", "Account Code", "Counterparty",
               "Currency", "Original Amount", "Exchange Rate", "Debit (IDR)", "Credit (IDR)",
               "Net Expense (IDR)", "Status"])
    tx.append(["TRX-001", datetime.datetime(2026, 1, 10), "Grant", "Grant Receipt", "FND-001",
               "Global Health", "PRJ-HEALTH", "Mgmt", "Other", "1000", "Global Health",
               "IDR", 700000, 1, 700000, 0, 0, "Posted"])

    je = wb.create_sheet("Journal Entries")
    je.append(["Double-Entry Journal Entries"])
    je.append([])
    je.append(["note"])
    je.append([])
    je.append(["Journal No.", "Date", "Reference", "Description", "Account Code",
               "Account Name", "Debit (IDR)", "Credit (IDR)", "Fund ID", "Donor",
               "Project", "Activity", "Budget Line", "Status"])
    je.append(["JV-001", datetime.datetime(2026, 1, 10), "TRX-001", "Grant", "1000",
               "Cash and Bank", 700000, 0, "FND-001", "Global Health", "PRJ-HEALTH",
               "Mgmt", "Other", "Posted"])
    je.append(["JV-001", datetime.datetime(2026, 1, 10), "TRX-001", "Grant", "2100",
               "Deferred Grant Income", 0, 700000, "FND-001", "Global Health", "PRJ-HEALTH",
               "Mgmt", "Other", "Posted"])

    buf = BytesIO()
    wb.save(buf)
    buf.seek(0)
    return buf


@pytest.mark.django_db
class TestSourceDocumentLinks:
    """JournalEntry/Transaction/Fund can link to source documents in finance,
    procurements, and projects, and the serializer surfaces a source_label."""

    def test_journal_links_to_receivable_invoice(self, client):
        from apps.companies.models import Company
        from apps.finance.models import Invoice
        donor = Company.objects.create(organization=client.organization, name="Global Health Foundation")
        inv = Invoice.objects.create(organization=client.organization, invoice_number="INV-001", donor=donor, total=Decimal("700000"))

        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset", normal_balance="Debit")
        inc = Account.objects.create(organization=client.organization, code="4000", name="Grant Income", type="Income", normal_balance="Credit")
        r = client.post("/api/accounting/journal-entries/", {
            "journal_no": "JV-INV", "date": "2026-01-01",
            "source_receivable_invoice": str(inv.id),
            "lines": [
                {"account": str(cash.id), "debit_idr": "700000", "credit_idr": "0"},
                {"account": str(inc.id), "debit_idr": "0", "credit_idr": "700000"},
            ],
        }, format="json")
        assert r.status_code == 201, r.content
        body = r.json()
        assert body["source_receivable_invoice"] is not None
        assert body["source_label"] == str(inv)
        # FK actually persisted.
        je = JournalEntry.objects.get(journal_no="JV-INV")
        assert _same_id(je.source_receivable_invoice_id, inv.id)
        # Reverse relation works for traceability.
        assert inv.accounting_journal_entries.filter(pk=je.pk).exists()

    def test_transaction_links_to_payment_and_budget_item(self, client):
        from apps.asset_management.models import Vendor
        from apps.procurements.models import Invoice as PInvoice, Payment
        from apps.finance.models import BudgetCategory, BudgetItem
        vendor = Vendor.objects.create(organization=client.organization, name="Acme Supplies")
        pinv = PInvoice.objects.create(organization=client.organization, invoice_number="PINV-1", vendor=vendor,
                                       amount=Decimal("100"), total=Decimal("100"),
                                       due_date=datetime.date(2026, 1, 31))
        pay = Payment.objects.create(organization=client.organization, invoice=pinv, amount=Decimal("100"),
                                     method="transfer", reference="PAY-1")
        cat = BudgetCategory.objects.create(organization=client.organization, code="C1", name="Personnel")
        item = BudgetItem.objects.create(category=cat, code="C1.1", name="Salaries")

        r = client.post("/api/accounting/transactions/", {
            "transaction_id": "TRX-PAY", "date": "2026-01-01", "transaction_type": "Expense",
            "source_payment": str(pay.id), "budget_item": str(item.id),
            "debit_idr": "100", "credit_idr": "0",
        }, format="json")
        assert r.status_code == 201, r.content
        body = r.json()
        # Payment has no custom __str__; just confirm a label is surfaced.
        assert body["source_label"] and "Payment" in body["source_label"]
        assert body["budget_item_name"] == "Salaries"

        t = Transaction.objects.get(transaction_id="TRX-PAY")
        assert _same_id(t.source_payment_id, pay.id)
        assert _same_id(t.budget_item_id, item.id)

    def test_transaction_filter_by_budget_item(self, client):
        from apps.finance.models import BudgetCategory, BudgetItem
        cat = BudgetCategory.objects.create(organization=client.organization, code="C1", name="Travel")
        item = BudgetItem.objects.create(category=cat, code="C1.1", name="Flights")
        Transaction.objects.create(organization=client.organization, transaction_id="A", date=datetime.date(2026, 1, 1),
                                   transaction_type="Expense", budget_item=item)
        Transaction.objects.create(organization=client.organization, transaction_id="B", date=datetime.date(2026, 1, 1),
                                   transaction_type="Expense")
        rows = _rows(client.get(f"/api/accounting/transactions/?budget_item={item.id}"))
        assert {t["transaction_id"] for t in rows} == {"A"}

    def test_fund_links_to_project_fund(self, client):
        from apps.projects.models import ProjectFund
        pf = ProjectFund.objects.create(organization=client.organization, source="Global Health", amount=Decimal("1000000"))
        r = client.post("/api/accounting/funds/", {
            "fund_id": "FND-LINK", "name": "Linked Fund", "fund_type": "Restricted",
            "project_fund": str(pf.id),
        }, format="json")
        assert r.status_code == 201, r.content
        body = r.json()
        assert body["project_fund"] is not None
        assert body["project_fund_label"] == str(pf)
        f = Fund.objects.get(fund_id="FND-LINK")
        assert _same_id(f.project_fund_id, pf.id)


@pytest.mark.django_db
class TestSeedCommand:
    def test_seed_imports_all_source_tabs(self, tmp_path):
        from apps.companies.models import Organization
        Organization.objects.create(name="Test Org")
        path = tmp_path / "wb.xlsx"
        path.write_bytes(_build_workbook().read())

        call_command("seed_accounting", file=str(path), clear=True)

        assert Account.objects.count() == 3
        assert Fund.objects.count() == 1
        assert Transaction.objects.count() == 1
        assert JournalEntry.objects.count() == 1
        assert JournalLine.objects.count() == 2

        # Cash account flagged is_cash by name heuristic.
        assert Account.objects.get(code="1000").is_cash is True
        # The single journal is balanced.
        jv = JournalEntry.objects.get(journal_no="JV-001")
        assert jv.totals() == (Decimal("700000"), Decimal("700000"))

    def test_seed_is_idempotent(self, tmp_path):
        from apps.companies.models import Organization
        Organization.objects.create(name="Test Org")
        path = tmp_path / "wb.xlsx"
        path.write_bytes(_build_workbook().read())
        call_command("seed_accounting", file=str(path))
        call_command("seed_accounting", file=str(path))  # second run, no --clear
        # update_or_create keyed on business ids -> no duplicates.
        assert Account.objects.count() == 3
        assert JournalLine.objects.count() == 2

    def test_seed_missing_file_is_graceful(self, tmp_path):
        from apps.companies.models import Organization
        Organization.objects.create(name="Test Org")
        # Should not raise; command writes an error and returns.
        call_command("seed_accounting", file=str(tmp_path / "nope.xlsx"))
        assert Account.objects.count() == 0


# --------------------------------------------------------------------------
# Ledger-integrity fixes: status can't bypass post_entry, lines can't be
# edited under a Posted journal, empty/degenerate journals can't post, line
# validation, Transaction can't post without a linked Posted journal, Fund
# delete is blocked while it has ledger history.
# --------------------------------------------------------------------------

@pytest.mark.django_db
class TestJournalStatusIsProtected:
    def test_patch_status_directly_is_ignored(self, client):
        """status is read-only on the serializer — only post_entry can post."""
        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset", normal_balance="Debit")
        exp = Account.objects.create(organization=client.organization, code="5000", name="Exp", type="Expense", normal_balance="Debit")
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-ST1", date=datetime.date(2026, 1, 1))
        JournalLine.objects.create(journal=e, account=exp, debit_idr=100, credit_idr=0)
        # Deliberately unbalanced (no offsetting credit) — if status were
        # writable this would post an unbalanced journal.
        r = client.patch(f"/api/accounting/journal-entries/{e.id}/", {"status": "Posted"}, format="json")
        assert r.status_code == 200, r.content
        e.refresh_from_db()
        assert e.status == "Draft"

    def test_cannot_edit_lines_once_posted(self, client):
        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset", normal_balance="Debit")
        exp = Account.objects.create(organization=client.organization, code="5000", name="Exp", type="Expense", normal_balance="Debit")
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-ST2", date=datetime.date(2026, 1, 1))
        JournalLine.objects.create(journal=e, account=exp, debit_idr=100, credit_idr=0)
        JournalLine.objects.create(journal=e, account=cash, debit_idr=0, credit_idr=100)
        post = client.post(f"/api/accounting/journal-entries/{e.id}/post_entry/")
        assert post.status_code == 200, post.content

        r = client.patch(f"/api/accounting/journal-entries/{e.id}/", {
            "lines": [{"account": str(exp.id), "debit_idr": "1", "credit_idr": "0"}],
        }, format="json")
        assert r.status_code == 400, r.content
        e.refresh_from_db()
        assert e.lines.count() == 2  # unchanged


@pytest.mark.django_db
class TestJournalLineValidation:
    def _accounts(self, client):
        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset", normal_balance="Debit")
        exp = Account.objects.create(organization=client.organization, code="5000", name="Exp", type="Expense", normal_balance="Debit")
        return cash, exp

    def test_rejects_fewer_than_two_lines(self, client):
        exp = Account.objects.create(organization=client.organization, code="5000", name="Exp", type="Expense", normal_balance="Debit")
        r = client.post("/api/accounting/journal-entries/", {
            "journal_no": "JV-L1", "date": "2026-01-01",
            "lines": [{"account": str(exp.id), "debit_idr": "10", "credit_idr": "0"}],
        }, format="json")
        assert r.status_code == 400, r.content

    def test_rejects_line_with_both_debit_and_credit(self, client):
        cash, exp = self._accounts(client)
        r = client.post("/api/accounting/journal-entries/", {
            "journal_no": "JV-L2", "date": "2026-01-01",
            "lines": [
                {"account": str(exp.id), "debit_idr": "10", "credit_idr": "10"},
                {"account": str(cash.id), "debit_idr": "0", "credit_idr": "10"},
            ],
        }, format="json")
        assert r.status_code == 400, r.content

    def test_rejects_negative_amounts(self, client):
        cash, exp = self._accounts(client)
        r = client.post("/api/accounting/journal-entries/", {
            "journal_no": "JV-L3", "date": "2026-01-01",
            "lines": [
                {"account": str(exp.id), "debit_idr": "-10", "credit_idr": "0"},
                {"account": str(cash.id), "debit_idr": "0", "credit_idr": "10"},
            ],
        }, format="json")
        assert r.status_code == 400, r.content

    def test_rejects_empty_line(self, client):
        cash, exp = self._accounts(client)
        r = client.post("/api/accounting/journal-entries/", {
            "journal_no": "JV-L4", "date": "2026-01-01",
            "lines": [
                {"account": str(exp.id), "debit_idr": "0", "credit_idr": "0"},
                {"account": str(cash.id), "debit_idr": "0", "credit_idr": "10"},
            ],
        }, format="json")
        assert r.status_code == 400, r.content


@pytest.mark.django_db
class TestEmptyJournalCannotPost:
    def test_post_entry_rejects_zero_lines(self, client):
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-E1", date=datetime.date(2026, 1, 1))
        r = client.post(f"/api/accounting/journal-entries/{e.id}/post_entry/")
        assert r.status_code == 400, r.content
        e.refresh_from_db()
        assert e.status == "Draft"


@pytest.mark.django_db
class TestTransactionPostingRequiresLedger:
    def test_cannot_post_without_linked_journal(self, client):
        r = client.post("/api/accounting/transactions/", {
            "transaction_id": "TRX-P1", "date": "2026-01-01", "transaction_type": "Expense",
            "debit_idr": "100", "credit_idr": "0", "status": "Posted",
        }, format="json")
        assert r.status_code == 400, r.content
        assert not Transaction.objects.filter(transaction_id="TRX-P1").exists()

    def test_can_post_once_linked_journal_is_posted(self, client):
        t = Transaction.objects.create(
            organization=client.organization, transaction_id="TRX-P2", date=datetime.date(2026, 1, 1),
            transaction_type="Expense", debit_idr=100, credit_idr=0,
        )
        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset", normal_balance="Debit")
        exp = Account.objects.create(organization=client.organization, code="5000", name="Exp", type="Expense", normal_balance="Debit")
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-P2", date=datetime.date(2026, 1, 1), source_transaction=t)
        JournalLine.objects.create(journal=e, account=exp, debit_idr=100, credit_idr=0)
        JournalLine.objects.create(journal=e, account=cash, debit_idr=0, credit_idr=100)
        client.post(f"/api/accounting/journal-entries/{e.id}/post_entry/")

        r = client.patch(f"/api/accounting/transactions/{t.id}/", {"status": "Posted"}, format="json")
        assert r.status_code == 200, r.content

    def test_rejects_debit_mismatched_with_original_amount(self, client):
        t = Transaction.objects.create(
            organization=client.organization, transaction_id="TRX-P3", date=datetime.date(2026, 1, 1),
            transaction_type="Expense", original_amount=10, exchange_rate=15000,
        )
        cash = Account.objects.create(organization=client.organization, code="1000", name="Cash", type="Asset", normal_balance="Debit")
        exp = Account.objects.create(organization=client.organization, code="5000", name="Exp", type="Expense", normal_balance="Debit")
        e = JournalEntry.objects.create(organization=client.organization, journal_no="JV-P3", date=datetime.date(2026, 1, 1), source_transaction=t)
        JournalLine.objects.create(journal=e, account=exp, debit_idr=150000, credit_idr=0)
        JournalLine.objects.create(journal=e, account=cash, debit_idr=0, credit_idr=150000)
        client.post(f"/api/accounting/journal-entries/{e.id}/post_entry/")

        # original_amount x exchange_rate = 150,000 but debit_idr set to 5,000.
        r = client.patch(f"/api/accounting/transactions/{t.id}/", {
            "status": "Posted", "debit_idr": "5000",
        }, format="json")
        assert r.status_code == 400, r.content


@pytest.mark.django_db
class TestFundDeleteIsProtected:
    def test_cannot_delete_fund_with_transactions(self, client):
        fund = Fund.objects.create(organization=client.organization, fund_id="FND-DEL1", name="Test", award_amount=100, received_amount=100)
        Transaction.objects.create(
            organization=client.organization, transaction_id="TRX-DEL1", date=datetime.date(2026, 1, 1),
            transaction_type="Expense", fund=fund,
        )
        r = client.delete(f"/api/accounting/funds/{fund.id}/")
        assert r.status_code == 400, r.content
        assert Fund.objects.filter(id=fund.id).exists()

    def test_cannot_delete_fund_with_journal_entries(self, client):
        fund = Fund.objects.create(organization=client.organization, fund_id="FND-DEL2", name="Test", award_amount=100, received_amount=100)
        JournalEntry.objects.create(organization=client.organization, journal_no="JV-DEL2", date=datetime.date(2026, 1, 1), fund=fund)
        r = client.delete(f"/api/accounting/funds/{fund.id}/")
        assert r.status_code == 400, r.content
        assert Fund.objects.filter(id=fund.id).exists()

    def test_can_delete_fund_with_no_ledger_history(self, client):
        fund = Fund.objects.create(organization=client.organization, fund_id="FND-DEL3", name="Test", award_amount=100, received_amount=100)
        r = client.delete(f"/api/accounting/funds/{fund.id}/")
        assert r.status_code == 204, r.content
        assert not Fund.objects.filter(id=fund.id).exists()
