"""PlusROI Expenses writes (services/money/expenses.py; phase-3 slice 3,
ADR #024): manual entry, Yulia's multi-line split, PATCH/DELETE, the
form-options read — owner-gated, sheet-owned-blocked, tenant-scoped,
audited, and visible in the SAME ledger ``/api/books/expenses`` reads.

SQLite fixtures per the house style (mirrors tests/invoices_plus)."""

import uuid
from datetime import date
from types import SimpleNamespace

import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool

from app.models import (
    AuditLog,
    Base,
    Expense,
    Party,
    Project,
    Tenant,
    TenantVendor,
)
from app.services import books as books_svc
from app.services.errors import ServiceError
from app.services.money import expenses as svc

JULY = "2026-07"
JULY_1 = date(2026, 7, 1)

APP = {"books_owner": "app", "default_currency": "CAD"}
SHEET = {"books_owner": "sheet"}


def pid(raw: str) -> uuid.UUID:
    return uuid.UUID(raw)


@pytest.fixture()
def s():
    engine = create_engine(
        "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
    )
    Base.metadata.create_all(engine)
    with Session(engine) as session:
        yield session
    engine.dispose()


def _party(s, kind, name, slug):
    party = Party(kind=kind, name=name, slug=slug)
    s.add(party)
    s.flush()
    return party


def _tenant(s, name, slug, settings):
    party = _party(s, "org", name, slug)
    tenant = Tenant(party_id=party.id, settings=settings)
    s.add(tenant)
    s.flush()
    return tenant, party


def owner(tenant_id, person_party_id=None):
    return SimpleNamespace(
        tenant_id=tenant_id,
        user_id=None,
        real_user_id=None,
        person_party_id=person_party_id,
        is_viewing_as=False,
        allowed=lambda cap: True,
        capabilities={"can_see_income": True},
    )


def worker(tenant_id):
    return SimpleNamespace(
        tenant_id=tenant_id,
        user_id=None,
        real_user_id=None,
        person_party_id=None,
        is_viewing_as=False,
        allowed=lambda cap: False,
        capabilities={"can_see_income": False},
    )


@pytest.fixture()
def fx(s):
    plusroi, plusroi_party = _tenant(s, "PlusROI", "plusroi", APP)
    other, _ = _tenant(s, "Other Books", "other", APP)
    sheet, _ = _tenant(s, "SheetCo", "sheetco", SHEET)

    yulia = _party(s, "person", "Yulia", "yulia")
    bw = _party(s, "org", "Bowden Works", "bowden-works")
    client = _party(s, "org", "Copernic", "copernic")

    def project(tenant, name):
        p = Project(
            tenant_id=tenant.id,
            operator_party_id=plusroi_party.id,
            client_party_id=client.id,
            name=name,
            status="active",
        )
        s.add(p)
        s.flush()
        return p

    proj_a = project(plusroi, "Site A")
    proj_b = project(plusroi, "Site B")
    proj_other = project(other, "Foreign Project")
    s.flush()
    return SimpleNamespace(
        plusroi=plusroi,
        plusroi_party=plusroi_party,
        other=other,
        sheet=sheet,
        yulia=yulia,
        bw=bw,
        proj_a=proj_a,
        proj_b=proj_b,
        proj_other=proj_other,
    )


def _payload(fx, **over):
    body = {
        "vendor_party_id": str(fx.yulia.id),
        "project_id": str(fx.proj_a.id),
        "amount": "125.50",
        "currency": "CAD",
        "category": "Labour",
        "paid_by_party_id": str(fx.plusroi_party.id),
        "expense_date": "2026-07-03",
        "settlement_month": JULY,
        "notes": "July hours",
    }
    body.update(over)
    return body


# ------------------------------------------------------------ manual create


def test_manual_expense_roundtrip(s, fx):
    res = svc.create_expense(s, owner(fx.plusroi.id), _payload(fx))
    assert res["ok"] is True
    row = res["row"]
    assert row["vendor"] == "Yulia"
    assert row["project"] == "Site A"
    assert row["amount"] == "125.50"
    assert row["currency"] == "CAD"
    assert row["category"] == "Labour"
    assert row["paid_by"] == "PlusROI"
    assert row["paid_by_is_tenant"] is True
    assert row["expense_date"] == "2026-07-03"
    assert row["settlement_month"] == JULY
    assert row["notes"] == "July hours"

    expense = s.get(Expense, pid(row["id"]))
    assert expense.tenant_id == fx.plusroi.id
    assert expense.source == "manual"
    assert expense.settlement_month == JULY_1

    # …and it shows in the SAME ledger the Books UI reads.
    listed = books_svc.list_expenses(s, owner(fx.plusroi.id))
    assert listed["total_count"] == 1
    assert listed["rows"][0]["id"] == row["id"]
    assert listed["rows"][0]["amount"] == "125.50"


def test_manual_expense_minimal_defaults(s, fx):
    """Optional fields optional; currency defaults to the tenant's."""
    res = svc.create_expense(
        s,
        owner(fx.plusroi.id),
        {
            "vendor_party_id": str(fx.yulia.id),
            "amount": "10",
            "expense_date": "2026-07-01",
        },
    )
    row = res["row"]
    assert row["project"] is None
    assert row["currency"] == "CAD"  # tenant default
    assert row["settlement_month"] is None
    assert row["paid_by"] is None
    assert row["amount"] == "10.00"


@pytest.mark.parametrize(
    "over,code",
    [
        ({"vendor_party_id": None}, "VENDOR_REQUIRED"),
        ({"vendor_party_id": "not-a-uuid"}, "BAD_PARTY"),
        ({"vendor_party_id": str(uuid.uuid4())}, "PARTY_NOT_FOUND"),
        ({"amount": ""}, "BAD_AMOUNT"),
        ({"amount": "zero"}, "BAD_AMOUNT"),
        ({"amount": "-5"}, "BAD_AMOUNT"),
        ({"currency": "DOLLARS"}, "BAD_CURRENCY"),
        ({"expense_date": "not-a-date"}, "BAD_DATE"),
        ({"expense_date": ""}, "BAD_DATE"),
        ({"settlement_month": "July"}, "BAD_MONTH"),
        ({"project_id": "nope"}, "BAD_PROJECT"),
        ({"paid_by_party_id": str(uuid.uuid4())}, "PARTY_NOT_FOUND"),
    ],
)
def test_manual_expense_validation(s, fx, over, code):
    with pytest.raises(ServiceError) as err:
        svc.create_expense(s, owner(fx.plusroi.id), _payload(fx, **over))
    assert err.value.code == code
    assert s.scalar(select(func.count()).select_from(Expense)) == 0


def test_manual_expense_foreign_project_404(s, fx):
    with pytest.raises(ServiceError) as err:
        svc.create_expense(
            s, owner(fx.plusroi.id), _payload(fx, project_id=str(fx.proj_other.id))
        )
    assert err.value.code == "PROJECT_NOT_IN_ORG" and err.value.status == 404


# ------------------------------------------------------------- multi-line


def test_multiline_creates_n_rows_sharing_vendor_and_date(s, fx):
    """Danielle transcribing Yulia: one submit → one row per project."""
    res = svc.create_expense_batch(
        s,
        owner(fx.plusroi.id),
        {
            "vendor_party_id": str(fx.yulia.id),
            "expense_date": "2026-07-05",
            "currency": "CAD",
            "paid_by_party_id": str(fx.bw.id),
            "lines": [
                {"project_id": str(fx.proj_a.id), "amount": "300", "category": "Labour"},
                {"project_id": str(fx.proj_b.id), "amount": "450.25",
                 "category": "Labour", "notes": "extra revisions"},
            ],
        },
    )
    assert res["ok"] is True and res["count"] == 2
    assert [r["project"] for r in res["rows"]] == ["Site A", "Site B"]
    assert [r["amount"] for r in res["rows"]] == ["300.00", "450.25"]
    assert all(r["vendor"] == "Yulia" for r in res["rows"])
    assert all(r["expense_date"] == "2026-07-05" for r in res["rows"])
    assert all(r["paid_by"] == "Bowden Works" for r in res["rows"])
    assert all(r["paid_by_is_tenant"] is False for r in res["rows"])
    assert all(r["settlement_month"] is None for r in res["rows"])

    # ONE audit group for the whole submit.
    audits = s.scalars(
        select(AuditLog).where(AuditLog.action == "expenses.create_batch")
    ).all()
    assert len(audits) == 1
    assert audits[0].after["count"] == 2
    assert len(audits[0].after["expenses"]) == 2


def test_multiline_atomic_on_bad_line(s, fx):
    """A bad line anywhere aborts the WHOLE submit (one transaction)."""
    with pytest.raises(ServiceError) as err:
        svc.create_expense_batch(
            s,
            owner(fx.plusroi.id),
            {
                "vendor_party_id": str(fx.yulia.id),
                "expense_date": "2026-07-05",
                "lines": [
                    {"project_id": str(fx.proj_a.id), "amount": "300"},
                    {"project_id": str(fx.proj_b.id), "amount": "not-money"},
                ],
            },
        )
    assert err.value.code == "BAD_AMOUNT"
    s.rollback()
    assert s.scalar(select(func.count()).select_from(Expense)) == 0


@pytest.mark.parametrize(
    "lines,code",
    [
        ([], "NO_LINES"),
        ([{"amount": "10"}], "LINE_PROJECT_REQUIRED"),
        (
            [{"project_id": "x", "amount": "1"}] * (svc.MAX_BATCH_LINES + 1),
            "TOO_MANY_LINES",
        ),
    ],
)
def test_multiline_validation(s, fx, lines, code):
    with pytest.raises(ServiceError) as err:
        svc.create_expense_batch(
            s,
            owner(fx.plusroi.id),
            {
                "vendor_party_id": str(fx.yulia.id),
                "expense_date": "2026-07-05",
                "lines": lines,
            },
        )
    assert err.value.code == code


# ------------------------------------------------------------ patch/delete


def test_patch_only_touches_provided_keys(s, fx):
    row = svc.create_expense(s, owner(fx.plusroi.id), _payload(fx))["row"]
    res = svc.update_expense(
        s,
        owner(fx.plusroi.id),
        pid(row["id"]),
        {"amount": "200", "settlement_month": ""},
    )
    assert res["changed"] is True
    assert res["row"]["amount"] == "200.00"
    assert res["row"]["settlement_month"] is None  # '' clears the tag
    # untouched keys survive
    assert res["row"]["vendor"] == "Yulia"
    assert res["row"]["category"] == "Labour"
    assert res["row"]["notes"] == "July hours"


def test_patch_project_reassign_and_clear(s, fx):
    row = svc.create_expense(s, owner(fx.plusroi.id), _payload(fx))["row"]
    res = svc.update_expense(
        s, owner(fx.plusroi.id), pid(row["id"]), {"project_id": str(fx.proj_b.id)}
    )
    assert res["row"]["project"] == "Site B"
    res = svc.update_expense(
        s, owner(fx.plusroi.id), pid(row["id"]), {"project_id": None}
    )
    assert res["row"]["project"] is None


def test_delete_expense(s, fx):
    row = svc.create_expense(s, owner(fx.plusroi.id), _payload(fx))["row"]
    res = svc.delete_expense(s, owner(fx.plusroi.id), pid(row["id"]))
    assert res == {"ok": True, "deleted": row["id"]}
    assert s.get(Expense, pid(row["id"])) is None
    audit = s.scalars(
        select(AuditLog).where(AuditLog.action == "expenses.delete")
    ).one()
    assert audit.before["expense"]["id"] == row["id"]


# --------------------------------------------------------- gates + scope


def _mutations(s, fx, actor):
    return [
        lambda: svc.create_expense(s, actor, _payload(fx)),
        lambda: svc.create_expense_batch(
            s,
            actor,
            {
                "vendor_party_id": str(fx.yulia.id),
                "expense_date": "2026-07-05",
                "lines": [{"project_id": str(fx.proj_a.id), "amount": "1"}],
            },
        ),
        lambda: svc.update_expense(s, actor, uuid.uuid4(), {"amount": "1"}),
        lambda: svc.delete_expense(s, actor, uuid.uuid4()),
    ]


def test_owner_gate_403_on_every_mutation_and_options(s, fx):
    w = worker(fx.plusroi.id)
    for call in [*_mutations(s, fx, w), lambda: svc.expense_options(s, w)]:
        with pytest.raises(ServiceError) as err:
            call()
        assert err.value.code == "NOT_ALLOWED" and err.value.status == 403


def test_sheet_owned_409_on_every_mutation(s, fx):
    for call in _mutations(s, fx, owner(fx.sheet.id)):
        with pytest.raises(ServiceError) as err:
            call()
        assert err.value.code == "SHEET_OWNED_READ_ONLY" and err.value.status == 409


def test_tenant_isolation(s, fx):
    row = svc.create_expense(s, owner(fx.plusroi.id), _payload(fx))["row"]
    foreign = owner(fx.other.id)

    with pytest.raises(ServiceError) as err:
        svc.update_expense(s, foreign, pid(row["id"]), {"amount": "999"})
    assert err.value.code == "EXPENSE_NOT_FOUND" and err.value.status == 404

    with pytest.raises(ServiceError) as err:
        svc.delete_expense(s, foreign, pid(row["id"]))
    assert err.value.code == "EXPENSE_NOT_FOUND"

    # …and the other tenant's ledger stays empty.
    assert books_svc.list_expenses(s, foreign)["total_count"] == 0


def test_every_mutation_is_audited(s, fx):
    o = owner(fx.plusroi.id)
    row = svc.create_expense(s, o, _payload(fx))["row"]
    svc.update_expense(s, o, pid(row["id"]), {"amount": "1.23"})
    svc.create_expense_batch(
        s,
        o,
        {
            "vendor_party_id": str(fx.yulia.id),
            "expense_date": "2026-07-06",
            "lines": [{"project_id": str(fx.proj_a.id), "amount": "2"}],
        },
    )
    svc.delete_expense(s, o, pid(row["id"]))
    actions = set(
        s.scalars(
            select(AuditLog.action).where(
                AuditLog.tenant_id == fx.plusroi.id,
                AuditLog.action.like("expenses.%"),
            )
        ).all()
    )
    assert actions == {
        "expenses.create",
        "expenses.update",
        "expenses.create_batch",
        "expenses.delete",
    }
    update_row = s.scalars(
        select(AuditLog).where(AuditLog.action == "expenses.update")
    ).one()
    assert update_row.before["expense"]["amount"] == "125.50"
    assert update_row.after["expense"]["amount"] == "1.23"


# ----------------------------------------------------------------- options


def test_expense_options_shape(s, fx):
    # a marker-listed vendor with defaults + an expense-referenced payer
    s.add(
        TenantVendor(
            tenant_id=fx.plusroi.id,
            vendor_party_id=fx.yulia.id,
            default_category="Labour",
            default_paid_by_party_id=fx.bw.id,
        )
    )
    s.flush()
    svc.create_expense(
        s, owner(fx.plusroi.id), _payload(fx, paid_by_party_id=str(fx.bw.id))
    )

    opts = svc.expense_options(s, owner(fx.plusroi.id))
    vendors = {v["label"]: v for v in opts["vendors"]}
    assert vendors["Yulia"]["default_category"] == "Labour"
    assert vendors["Yulia"]["default_paid_by_party_id"] == str(fx.bw.id)
    labels = [p["label"] for p in opts["projects"]]
    assert labels == ["Copernic : Site A", "Copernic : Site B"]
    paid_by = {p["label"] for p in opts["paid_by"]}
    assert {"PlusROI", "Bowden Works"} <= paid_by  # tenant party + payer
    assert opts["categories"] == ["Labour"]
    assert opts["default_currency"] == "CAD"
    assert "CAD" in opts["currencies"] and "USD" in opts["currencies"]
    assert opts["tenant_party_id"] == str(fx.plusroi_party.id)


def test_options_tenant_isolation(s, fx):
    svc.create_expense(s, owner(fx.plusroi.id), _payload(fx))
    opts = svc.expense_options(s, owner(fx.other.id))
    assert opts["vendors"] == []
    assert [p["label"] for p in opts["projects"]] == ["Copernic : Foreign Project"]
