from datetime import date
from decimal import Decimal
import pytest

from app.models.finance import JournalEntry, Account
from app.models.accounting import PaymentAllocation, InventoryLot
from app.models.inventory import InventoryTransaction


def _mk_item(client, headers, name="Soil", category="substrate", quantity=0, cost_price=0.0, **kw):
    payload = {
        "name": name, "category": category, "unit": "kg",
        "quantity": quantity, "cost_price": cost_price,
    }
    payload.update(kw)
    r = client.post("/api/inventory", json=payload, headers=headers)
    assert r.status_code == 201, r.text
    return r.json()


def _mk_customer(client, headers, name="Alice"):
    r = client.post("/api/customers", json={"name": name, "customer_type": "Retail"}, headers=headers)
    assert r.status_code == 201, r.text
    return r.json()


def _mk_supplier(client, headers, name="GreenFields"):
    r = client.post("/api/suppliers", json={"name": name}, headers=headers)
    assert r.status_code == 201, r.text
    return r.json()


def _mk_sale(client, headers, customer_id, items, paid=0.0, sale_type="retail", **kw):
    payload = {
        "customer_id": customer_id,
        "sale_type": sale_type,
        "paid": paid,
        "items": [
            {"inventory_id": it["inventory_id"], "quantity": it["quantity"], "unit_price": it["unit_price"]}
            for it in items
        ],
    }
    payload.update(kw)
    r = client.post("/api/sales", json=payload, headers=headers)
    assert r.status_code == 201, r.text
    return r.json()


class TestFifoInventory:
    def test_opening_stock_not_double_counted(self, client, auth_headers, db):
        item = _mk_item(client, auth_headers, quantity=100, cost_price=5.0)
        inv = client.get(f"/api/inventory/{item['id']}", headers=auth_headers).json()
        assert inv["quantity"] == 100
        vals = client.get("/api/inventory/valuation", headers=auth_headers).json()
        row = next(r for r in vals["items"] if r["item_id"] == item["id"])
        assert row["quantity"] == 100
        assert row["value"] == pytest.approx(500.0)

    def test_fifo_consumes_oldest_lot_first(self, client, auth_headers, db):
        item = _mk_item(client, auth_headers, quantity=50, cost_price=5.0)
        r = client.post("/api/inventory/transactions", json={
            "item_id": item["id"], "type": "in", "quantity": 50, "unit_cost": 8.0,
            "reference_type": "purchase", "reference_id": "PO-X",
        }, headers=auth_headers)
        assert r.status_code == 201, r.text

        lots = client.get(f"/api/inventory/lots?item_id={item['id']}", headers=auth_headers).json()
        assert len(lots) == 2
        lots_sorted = sorted(lots, key=lambda l: l["unit_cost"])
        assert lots_sorted[0]["unit_cost"] == 5.0
        assert lots_sorted[1]["unit_cost"] == 8.0

        r = client.post("/api/inventory/transactions", json={
            "item_id": item["id"], "type": "out", "quantity": 70, "notes": "used",
        }, headers=auth_headers)
        assert r.status_code == 201, r.text
        assert r.json()["type"] == "out"

        vals = client.get("/api/inventory/valuation", headers=auth_headers).json()
        row = next(x for x in vals["items"] if x["item_id"] == item["id"])
        assert row["quantity"] == 30
        assert row["value"] == pytest.approx(240.0)

        txs = client.get(f"/api/inventory/transactions?item_id={item['id']}", headers=auth_headers).json()
        outs = [t for t in txs if t["type"] == "out"]
        assert len(outs) == 2
        assert {t["unit_cost"] for t in outs} == {5.0, 8.0}

    def test_insufficient_stock_rejected(self, client, auth_headers):
        item = _mk_item(client, auth_headers, quantity=3, cost_price=5.0)
        r = client.post("/api/inventory/transactions", json={
            "item_id": item["id"], "type": "out", "quantity": 10,
        }, headers=auth_headers)
        assert r.status_code == 400


class TestSalePosting:
    def test_cash_sale_auto_posts_with_cogs(self, client, auth_headers, db):
        soil = _mk_item(client, auth_headers, quantity=20, cost_price=5.0)
        fert = _mk_item(client, auth_headers, name="Fertilizer", category="input",
                        quantity=30, cost_price=3.0)
        cust = _mk_customer(client, auth_headers)
        sale = _mk_sale(client, auth_headers, cust["id"], [
            {"inventory_id": soil["id"], "quantity": 5, "unit_price": 8.0},
            {"inventory_id": fert["id"], "quantity": 10, "unit_price": 4.0},
        ], paid=80.0)
        assert sale["total"] == 80.0
        assert sale["due"] == 0.0

        posting = client.get(f"/api/sales/{sale['id']}/posting", headers=auth_headers).json()
        assert posting["posted"] is True
        assert posting["status"] == "posted"

        entry = db.query(JournalEntry).filter(
            JournalEntry.source == "sale",
            JournalEntry.reference_id == sale["id"],
            JournalEntry.is_reversal == False,  # noqa: E712
        ).first()
        assert entry is not None
        codes = {a.id: a.code for a in db.query(Account).all()}
        lines = {(codes[ln.account_id], "debit" if Decimal(str(ln.debit)) > 0 else "credit")
                 for ln in entry.lines}
        assert ("1010-BR-001", "debit") in lines
        assert ("4010", "credit") in lines
        assert ("1045", "credit") in lines

        vals = client.get("/api/inventory/valuation", headers=auth_headers).json()
        soil_row = next(r for r in vals["items"] if r["item_id"] == soil["id"])
        assert soil_row["quantity"] == 15
        fert_row = next(r for r in vals["items"] if r["item_id"] == fert["id"])
        assert fert_row["quantity"] == 20

        txs = db.query(InventoryTransaction).filter(
            InventoryTransaction.reference_type == "sale",
            InventoryTransaction.reference_id == sale["id"],
        ).all()
        assert sum(t.quantity for t in txs) == 15

    def test_credit_sale_posts_on_demand_and_locks_edits(self, client, auth_headers, db):
        soil = _mk_item(client, auth_headers, quantity=20, cost_price=5.0)
        cust = _mk_customer(client, auth_headers)
        sale = _mk_sale(client, auth_headers, cust["id"], [
            {"inventory_id": soil["id"], "quantity": 6, "unit_price": 8.0},
        ], paid=0.0)
        assert sale["due"] == 48.0
        posting = client.get(f"/api/sales/{sale['id']}/posting", headers=auth_headers).json()
        assert posting["posted"] is False

        r = client.put(f"/api/sales/{sale['id']}",
                       json={"notes": "pre-post edit ok"}, headers=auth_headers)
        assert r.status_code == 200

        r = client.post(f"/api/sales/{sale['id']}/post", headers=auth_headers)
        assert r.status_code == 200, r.text
        posting = client.get(f"/api/sales/{sale['id']}/posting", headers=auth_headers).json()
        assert posting["posted"] is True

        r = client.put(f"/api/sales/{sale['id']}",
                       json={"notes": "should fail"}, headers=auth_headers)
        assert r.status_code == 400
        r = client.delete(f"/api/sales/{sale['id']}", headers=auth_headers)
        assert r.status_code == 400

    def test_sale_return_reverses_and_restocks(self, client, auth_headers, db):
        soil = _mk_item(client, auth_headers, quantity=20, cost_price=5.0)
        cust = _mk_customer(client, auth_headers)
        sale = _mk_sale(client, auth_headers, cust["id"], [
            {"inventory_id": soil["id"], "quantity": 5, "unit_price": 8.0},
        ], paid=40.0)
        assert client.get(f"/api/sales/{sale['id']}/posting", headers=auth_headers).json()["posted"]

        r = client.post(f"/api/sales/{sale['id']}/return", params={"reason": "defect"},
                        headers=auth_headers)
        assert r.status_code == 200, r.text

        refreshed = client.get(f"/api/sales/{sale['id']}", headers=auth_headers).json()
        assert refreshed["status"] == "returned"

        original = db.query(JournalEntry).filter(
            JournalEntry.source == "sale",
            JournalEntry.reference_id == sale["id"],
            JournalEntry.is_reversal == False,  # noqa: E712
        ).first()
        assert original is not None
        assert original.status == "reversed"
        reversal = db.query(JournalEntry).filter(
            JournalEntry.reversal_of_id == original.id
        ).first()
        assert reversal is not None

        vals = client.get("/api/inventory/valuation", headers=auth_headers).json()
        row = next(r for r in vals["items"] if r["item_id"] == soil["id"])
        assert row["quantity"] == 20

    def test_unposted_sale_cannot_return(self, client, auth_headers):
        soil = _mk_item(client, auth_headers, quantity=20, cost_price=5.0)
        cust = _mk_customer(client, auth_headers)
        sale = _mk_sale(client, auth_headers, cust["id"], [
            {"inventory_id": soil["id"], "quantity": 5, "unit_price": 8.0},
        ], paid=0.0)
        r = client.post(f"/api/sales/{sale['id']}/return", headers=auth_headers)
        assert r.status_code == 400


class TestGrnPosting:
    def test_grn_auto_posts_and_raises_supplier_balance(self, client, auth_headers, db):
        soil = _mk_item(client, auth_headers, quantity=0)
        fert = _mk_item(client, auth_headers, name="Fertilizer", category="input", quantity=0)
        sup = _mk_supplier(client, auth_headers)

        r = client.post("/api/purchases/orders", json={
            "supplier_id": sup["id"],
            "items": [
                {"inventory_id": soil["id"], "description": "soil", "quantity": 10, "unit_price": 6.0},
                {"inventory_id": fert["id"], "description": "fert", "quantity": 20, "unit_price": 2.0},
            ],
        }, headers=auth_headers)
        assert r.status_code == 201, r.text
        po = r.json()

        r = client.post("/api/purchases/grn", json={"purchase_order_id": po["id"]}, headers=auth_headers)
        assert r.status_code == 201, r.text
        grn = r.json()
        assert grn["total"] == 100.0

        posting = client.get(f"/api/purchases/grn/{grn['id']}/posting", headers=auth_headers).json()
        assert posting["posted"] is True

        sup_after = client.get(f"/api/suppliers/{sup['id']}", headers=auth_headers).json()
        assert sup_after["balance"] == pytest.approx(100.0)

        entry = db.query(JournalEntry).filter(
            JournalEntry.source == "purchase",
            JournalEntry.reference_id == grn["id"],
            JournalEntry.is_reversal == False,  # noqa: E712
        ).first()
        assert entry is not None
        codes = {a.id: a.code for a in db.query(Account).all()}
        debit_inv = Decimal("0")
        credit_ap = Decimal("0")
        for ln in entry.lines:
            if codes[ln.account_id] == "1045":
                debit_inv += Decimal(str(ln.debit))
            if codes[ln.account_id] == "2010":
                credit_ap += Decimal(str(ln.credit))
        assert debit_inv == Decimal("100.00")
        assert credit_ap == Decimal("100.00")

        lots = db.query(InventoryLot).filter(InventoryLot.item_id == soil["id"]).all()
        assert len(lots) == 1
        assert Decimal(str(lots[0].remaining_qty)) == Decimal("10")
        assert Decimal(str(lots[0].unit_cost)) == Decimal("6.00")

    def test_supplier_payment_allocation_and_aging(self, client, auth_headers, db):
        soil = _mk_item(client, auth_headers, quantity=0)
        sup = _mk_supplier(client, auth_headers)
        r = client.post("/api/purchases/orders", json={
            "supplier_id": sup["id"],
            "items": [{"inventory_id": soil["id"], "description": "soil", "quantity": 10, "unit_price": 6.0}],
        }, headers=auth_headers)
        po = r.json()
        grn = client.post("/api/purchases/grn", json={"purchase_order_id": po["id"]},
                          headers=auth_headers).json()

        r = client.post("/api/finance/payments", json={
            "type": "paid", "party_type": "supplier", "party_id": sup["id"],
            "amount": 60.0, "date": str(date.today()), "method": "cash",
        }, headers=auth_headers)
        assert r.status_code == 201, r.text
        payment = r.json()

        allocs = db.query(PaymentAllocation).filter(PaymentAllocation.payment_id == payment["id"]).all()
        assert len(allocs) == 1
        assert allocs[0].document_type == "grn"
        assert Decimal(str(allocs[0].amount)) == Decimal("60.00")

        sup_after = client.get(f"/api/suppliers/{sup['id']}", headers=auth_headers).json()
        assert sup_after["balance"] == pytest.approx(0.0)

        aging = client.get("/api/accounting/payments/aging?party_type=supplier",
                           headers=auth_headers).json()
        assert aging["total_balance"] == 0.0

        r = client.post(f"/api/finance/payments/{payment['id']}/reverse",
                        params={"reason": "wrong"}, headers=auth_headers)
        assert r.status_code == 200, r.text
        sup_after = client.get(f"/api/suppliers/{sup['id']}", headers=auth_headers).json()
        assert sup_after["balance"] == pytest.approx(60.0)
        aging = client.get("/api/accounting/payments/aging?party_type=supplier",
                           headers=auth_headers).json()
        assert aging["total_balance"] == pytest.approx(60.0)


class TestPaymentsAndAging:
    def _make_posted_credit_sales(self, client, auth_headers):
        soil = _mk_item(client, auth_headers, quantity=50, cost_price=5.0)
        cust = _mk_customer(client, auth_headers)
        sale_a = _mk_sale(client, auth_headers, cust["id"], [
            {"inventory_id": soil["id"], "quantity": 4, "unit_price": 8.0},
        ], paid=0.0, sale_type="retail")
        sale_b = _mk_sale(client, auth_headers, cust["id"], [
            {"inventory_id": soil["id"], "quantity": 2, "unit_price": 8.0},
        ], paid=0.0, sale_type="retail")
        assert client.post(f"/api/sales/{sale_a['id']}/post", headers=auth_headers).status_code == 200
        assert client.post(f"/api/sales/{sale_b['id']}/post", headers=auth_headers).status_code == 200
        return cust, soil, sale_a, sale_b

    def test_customer_payment_allocates_fifo_and_aging(self, client, auth_headers, db):
        cust, soil, sale_a, sale_b = self._make_posted_credit_sales(client, auth_headers)
        assert sale_a["due"] == 32.0
        assert sale_b["due"] == 16.0

        r = client.post("/api/finance/payments", json={
            "type": "received", "party_type": "customer", "party_id": cust["id"],
            "amount": 40.0, "date": str(date.today()), "method": "cash",
        }, headers=auth_headers)
        assert r.status_code == 201, r.text
        payment = r.json()

        allocs = {a.document_id: a for a in db.query(PaymentAllocation)
                  .filter(PaymentAllocation.payment_id == payment["id"]).all()}
        assert Decimal(str(allocs[sale_a["id"]].amount)) == Decimal("32.00")
        assert Decimal(str(allocs[sale_b["id"]].amount)) == Decimal("8.00")

        aging = client.get("/api/accounting/payments/aging?party_type=customer",
                           headers=auth_headers).json()
        assert aging["total_balance"] == pytest.approx(8.0)
        row = next(r for r in aging["rows"] if r["party_id"] == cust["id"])
        assert row["balance"] == pytest.approx(8.0)
        assert row["bucket_30"] == pytest.approx(8.0)

        cust_after = client.get(f"/api/customers/{cust['id']}", headers=auth_headers).json()
        assert cust_after["outstanding"] == pytest.approx(8.0)

    def test_party_ledger_running_balance(self, client, auth_headers):
        cust, soil, sale_a, sale_b = self._make_posted_credit_sales(client, auth_headers)
        client.post("/api/finance/payments", json={
            "type": "received", "party_type": "customer", "party_id": cust["id"],
            "amount": 48.0, "date": str(date.today()), "method": "cash",
        }, headers=auth_headers)
        ledger = client.get(f"/api/accounting/parties/customer/{cust['id']}/ledger",
                            headers=auth_headers).json()
        events = ledger["events"]
        assert [e["document_type"] for e in events] == ["sale", "sale", "payment"]
        assert events[-1]["running_balance"] == pytest.approx(0.0)


class TestPostingBatch:
    def test_sales_posting_batch(self, client, auth_headers):
        soil = _mk_item(client, auth_headers, quantity=20, cost_price=5.0)
        cust = _mk_customer(client, auth_headers)
        posted = _mk_sale(client, auth_headers, cust["id"], [
            {"inventory_id": soil["id"], "quantity": 5, "unit_price": 8.0},
        ], paid=40.0)
        unposted = _mk_sale(client, auth_headers, cust["id"], [
            {"inventory_id": soil["id"], "quantity": 3, "unit_price": 8.0},
        ], paid=0.0)
        assert client.get(f"/api/sales/{posted['id']}/posting", headers=auth_headers).json()["posted"]

        r = client.get(f"/api/sales/posting-batch?ids={posted['id']},{unposted['id']}",
                       headers=auth_headers)
        assert r.status_code == 200, r.text
        body = r.json()
        assert body[posted["id"]]["posted"] is True
        assert body[posted["id"]]["status"] == "posted"
        assert body[posted["id"]]["entry_number"]
        assert body[unposted["id"]]["posted"] is False
        assert body[unposted["id"]]["status"] is None

        r = client.get("/api/sales/posting-batch?ids=", headers=auth_headers)
        assert r.status_code == 200
        assert r.json() == {}

    def test_grn_posting_batch(self, client, auth_headers):
        soil = _mk_item(client, auth_headers, quantity=0)
        sup = _mk_supplier(client, auth_headers)
        r = client.post("/api/purchases/orders", json={
            "supplier_id": sup["id"],
            "items": [{"inventory_id": soil["id"], "description": "soil",
                       "quantity": 10, "unit_price": 6.0}],
        }, headers=auth_headers)
        assert r.status_code == 201, r.text
        grn = client.post("/api/purchases/grn",
                          json={"purchase_order_id": r.json()["id"]},
                          headers=auth_headers).json()
        missing = "00000000-0000-0000-0000-000000000000"

        r = client.get(f"/api/purchases/grn/posting-batch?ids={grn['id']},{missing}",
                       headers=auth_headers)
        assert r.status_code == 200, r.text
        body = r.json()
        assert body[grn["id"]]["posted"] is True
        assert body[grn["id"]]["status"] == "posted"
        assert body[missing]["posted"] is False