"""services/money/expenses — the PlusROI Expenses write surface (phase-3
slice 3, docs/design/plusroi-forms.md §5; ADR #024).

The payables journal (``expenses``) finally gets in-app writes:

  * **Manual entry** — one ``source='manual'`` row (Danielle keying a
    vendor bill): vendor party + optional project + amount/currency +
    category + paid-by + date + optional settlement tag + notes.
  * **Multi-line entry** — Yulia's per-project invoice: ONE submit → N
    rows sharing vendor + date + currency (+ shared paid-by/settlement
    tag), split by {project, amount}. One transaction, ONE audit row
    carrying every created image (the audit group).
  * **PATCH / DELETE** — per-row corrections. PATCH-style: only the
    provided keys change. The tenant-level sheet-owned write-block is
    the protection for sheet-owned tenants; rows in an APP-owned tenant
    are the owner's books regardless of their historical ``source``.

Row DTOs mirror ``services/books.list_expenses`` exactly (2dp decimal
strings, 'YYYY-MM' settlement tags, ``paid_by_is_tenant``) so the
frontend renders create/update results with the one ledger type.

Every mutation applies the shared PlusROI write-gate
(``services.plusroi_gates``: owner ``can_transfer`` → writable books →
tenant) and writes an audit row with complete images. The FROZEN
settlement engine (``services/money/settlement.py``) is untouched — a
created/tagged expense simply shows up in its Σ by ``settlement_month``.
"""

from __future__ import annotations

import re
import uuid
from datetime import date
from decimal import Decimal, InvalidOperation

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models import Expense, Party, Project, Tenant, TenantVendor
from app.services.audit import write_audit
from app.services.entities.resolve import load_entity_graph
from app.services.errors import ServiceError
from app.services.plusroi_gates import (
    require_owner,
    require_tenant,
    require_writable_books,
)
from app.services.reporting.money import quantize2

#: Bound on one multi-line submit (Yulia's split invoices run a handful
#: of lines; 100 is generous while keeping the transaction bounded).
MAX_BATCH_LINES = 100

CURRENCY_RE = re.compile(r"^[A-Za-z]{3}$")


# --------------------------------------------------------------- parsing


def _clean_text(raw) -> str | None:
    if raw is None:
        return None
    trimmed = str(raw).strip()
    return trimmed or None


def _parse_amount(raw, label: str) -> Decimal:
    """A required, positive 2dp money value (invoices_plus idiom)."""
    text = re.sub(r"[$,\s]", "", str(raw if raw is not None else "").strip())
    if not text:
        raise ServiceError(422, "BAD_AMOUNT", f"{label} is required.")
    try:
        value = Decimal(text)
    except InvalidOperation as exc:
        raise ServiceError(422, "BAD_AMOUNT", f"{label} must be a number.") from exc
    if not value.is_finite() or value <= 0:
        raise ServiceError(422, "BAD_AMOUNT", f"{label} must be a positive number.")
    return quantize2(value)


def _parse_date(raw, label: str) -> date:
    """A required real YYYY-MM-DD date."""
    text = str(raw or "").strip()
    try:
        return date.fromisoformat(text)
    except ValueError as exc:
        raise ServiceError(
            422,
            "BAD_DATE",
            f"{label} must be a YYYY-MM-DD date.",
            f"Got {raw!r}.",
        ) from exc


def _parse_month_tag(raw) -> date | None:
    """Optional 'YYYY-MM' → the first-of-month tag; ''/None = untagged."""
    text = str(raw or "").strip()
    if not text:
        return None
    try:
        year_s, month_s = text.split("-", 1)
        return date(int(year_s), int(month_s), 1)
    except ValueError as exc:
        raise ServiceError(
            422,
            "BAD_MONTH",
            "settlement_month must look like YYYY-MM",
            f"Got {raw!r}.",
        ) from exc


def _parse_currency(raw, default: str) -> str:
    text = str(raw or "").strip()
    if not text:
        return default
    if not CURRENCY_RE.match(text):
        raise ServiceError(
            422,
            "BAD_CURRENCY",
            "currency must be a 3-letter code.",
            f"Got {raw!r}.",
        )
    return text.upper()


# ------------------------------------------------------------ validation


def _tenant_currency(session: Session, tenant_id: uuid.UUID) -> str:
    settings = session.scalar(select(Tenant.settings).where(Tenant.id == tenant_id))
    if isinstance(settings, dict):
        code = settings.get("default_currency")
        if isinstance(code, str) and code.strip():
            return code.strip().upper()
    return "CAD"


def _validate_party(session: Session, raw, label: str) -> uuid.UUID:
    try:
        party_id = uuid.UUID(str(raw))
    except (ValueError, TypeError) as exc:
        raise ServiceError(422, "BAD_PARTY", f"{label} must be a UUID.") from exc
    if session.scalar(select(Party.id).where(Party.id == party_id)) is None:
        raise ServiceError(404, "PARTY_NOT_FOUND", f"Selected {label} party not found.")
    return party_id


def _validate_optional_party(session: Session, raw, label: str) -> uuid.UUID | None:
    if raw in (None, ""):
        return None
    return _validate_party(session, raw, label)


def _validate_project(session: Session, tenant_id: uuid.UUID, raw) -> uuid.UUID | None:
    """Optional project — must belong to THIS tenant (cc-service idiom)."""
    if raw in (None, ""):
        return None
    try:
        project_id = uuid.UUID(str(raw))
    except (ValueError, TypeError) as exc:
        raise ServiceError(422, "BAD_PROJECT", "project_id must be a UUID.") from exc
    ok = session.scalar(
        select(Project.id).where(
            Project.id == project_id, Project.tenant_id == tenant_id
        )
    )
    if ok is None:
        raise ServiceError(
            404, "PROJECT_NOT_IN_ORG", "Selected project not found in this org."
        )
    return project_id


def _get_expense(session: Session, tenant_id: uuid.UUID, expense_id: uuid.UUID) -> Expense:
    expense = session.scalar(
        select(Expense).where(
            Expense.id == expense_id, Expense.tenant_id == tenant_id
        )
    )
    if expense is None:
        raise ServiceError(404, "EXPENSE_NOT_FOUND", "Expense not found.")
    return expense


# ------------------------------------------------------------ serializing


def _money_str(value) -> str | None:
    return None if value is None else str(quantize2(Decimal(str(value))))


def _month_key(value: date | None) -> str | None:
    return None if value is None else f"{value.year:04d}-{value.month:02d}"


def _scalar_image(expense: Expense) -> dict:
    out: dict = {}
    for column in Expense.__table__.columns:
        value = getattr(expense, column.name)
        if isinstance(value, uuid.UUID):
            value = str(value)
        elif isinstance(value, date):
            value = value.isoformat()
        elif isinstance(value, Decimal) or (
            value is not None and column.name == "created_at"
        ):
            value = str(value)
        out[column.name] = value
    return out


def ledger_row(session: Session, expense: Expense) -> dict:
    """The SAME row shape ``/api/books/expenses`` emits — one frontend
    type for list + write results."""
    tenant_party_id = session.scalar(
        select(Tenant.party_id).where(Tenant.id == expense.tenant_id)
    )
    vendor = (
        session.scalar(select(Party.name).where(Party.id == expense.vendor_party_id))
        if expense.vendor_party_id is not None
        else None
    )
    payer = (
        session.scalar(select(Party.name).where(Party.id == expense.paid_by_party_id))
        if expense.paid_by_party_id is not None
        else None
    )
    project = (
        session.scalar(select(Project.name).where(Project.id == expense.project_id))
        if expense.project_id is not None
        else None
    )
    return {
        "id": str(expense.id),
        "expense_date": expense.expense_date.isoformat()
        if expense.expense_date
        else None,
        "vendor": vendor,
        "project": project,
        "category": expense.category,
        "type": expense.type,
        "amount": _money_str(expense.amount),
        "currency": expense.currency,
        "paid_by": payer,
        "paid_by_is_tenant": (
            expense.paid_by_party_id is not None
            and tenant_party_id is not None
            and expense.paid_by_party_id == tenant_party_id
        ),
        "settlement_month": _month_key(expense.settlement_month),
        "external_invoice_id": expense.external_invoice_id,
        "notes": expense.notes,
    }


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


def expense_options(session: Session, actor) -> dict:
    """One round trip of everything the expense form needs (the cc
    batch_detail idiom): vendor picker options (with the tenant_vendors
    defaults so picking a vendor pre-fills category/paid-by), project
    picker options ("{client} : {project}" — the single EntityPicker
    vocabulary), paid-by party options, the category datalist, and the
    tenant default currency. Owner-only read; never audited."""
    require_owner(actor, "Only an owner can enter expenses.")
    tenant_id = require_tenant(actor)

    # vendors = the tenant's markers ∪ parties its expenses already pay
    marker_rows = session.execute(
        select(TenantVendor, Party)
        .join(Party, Party.id == TenantVendor.vendor_party_id)
        .where(TenantVendor.tenant_id == tenant_id)
    ).all()
    vendors: dict[uuid.UUID, dict] = {
        party.id: {
            "id": str(party.id),
            "label": party.name,
            "default_category": tv.default_category,
            "default_paid_by_party_id": (
                str(tv.default_paid_by_party_id)
                if tv.default_paid_by_party_id is not None
                else None
            ),
        }
        for tv, party in marker_rows
    }
    for party_id, name in session.execute(
        select(Party.id, Party.name)
        .join(Expense, Expense.vendor_party_id == Party.id)
        .where(Expense.tenant_id == tenant_id)
        .distinct()
    ).all():
        vendors.setdefault(
            party_id,
            {
                "id": str(party_id),
                "label": name,
                "default_category": None,
                "default_paid_by_party_id": None,
            },
        )

    graph = load_entity_graph(session, tenant_id)
    projects = [
        {
            "id": str(project_id),
            "label": f"{graph.client_names.get(cl_id, '')} : {name}",
        }
        for project_id, (name, _op_id, cl_id) in graph.project_chain.items()
    ]
    projects.sort(key=lambda o: o["label"].lower())

    # paid-by: the tenant party itself + every party that has fronted an
    # expense before (Bowden Works, partners) + the actor's own person.
    paid_by: dict[uuid.UUID, str] = {}
    tenant_party_id = session.scalar(
        select(Tenant.party_id).where(Tenant.id == tenant_id)
    )
    ids = [tenant_party_id, getattr(actor, "person_party_id", None)]
    for pid, name in session.execute(
        select(Party.id, Party.name)
        .join(Expense, Expense.paid_by_party_id == Party.id)
        .where(Expense.tenant_id == tenant_id)
        .distinct()
    ).all():
        paid_by[pid] = name
    for pid in ids:
        if pid is not None and pid not in paid_by:
            name = session.scalar(select(Party.name).where(Party.id == pid))
            if name:
                paid_by[pid] = name

    categories = [
        c
        for (c,) in session.execute(
            select(Expense.category)
            .where(Expense.tenant_id == tenant_id, Expense.category.is_not(None))
            .distinct()
            .order_by(Expense.category)
        ).all()
    ]

    default_currency = _tenant_currency(session, tenant_id)
    currencies = sorted(
        {default_currency, "CAD", "USD"}
        | {
            c
            for (c,) in session.execute(
                select(Expense.currency)
                .where(Expense.tenant_id == tenant_id)
                .distinct()
            ).all()
            if c
        }
    )
    return {
        "vendors": sorted(vendors.values(), key=lambda v: v["label"].lower()),
        "projects": projects,
        "paid_by": sorted(
            ({"id": str(pid), "label": name} for pid, name in paid_by.items()),
            key=lambda p: p["label"].lower(),
        ),
        "categories": categories,
        "currencies": currencies,
        "default_currency": default_currency,
        "tenant_party_id": str(tenant_party_id) if tenant_party_id else None,
    }


# --------------------------------------------------------------- writes


def _build_expense(
    session: Session,
    actor,
    tenant_id: uuid.UUID,
    *,
    vendor_party_id: uuid.UUID,
    project_id: uuid.UUID | None,
    amount: Decimal,
    currency: str,
    expense_date: date,
    category: str | None,
    paid_by_party_id: uuid.UUID | None,
    settlement_month: date | None,
    notes: str | None,
) -> Expense:
    expense = Expense(
        id=uuid.uuid4(),
        tenant_id=tenant_id,
        vendor_party_id=vendor_party_id,
        project_id=project_id,
        amount=amount,
        currency=currency,
        expense_date=expense_date,
        category=category,
        paid_by_party_id=paid_by_party_id,
        settlement_month=settlement_month,
        notes=notes,
        source="manual",
        created_by=actor.real_user_id,
    )
    session.add(expense)
    return expense


def create_expense(session: Session, actor, payload: dict) -> dict:
    """POST /api/expenses — one manual payables row. Owner-gated,
    writable-books, tenant-scoped, audited."""
    require_owner(actor, "Only an owner can enter expenses.")
    require_writable_books(session, actor)
    tenant_id = require_tenant(actor)

    if payload.get("vendor_party_id") in (None, ""):
        raise ServiceError(422, "VENDOR_REQUIRED", "Pick a vendor.")
    expense = _build_expense(
        session,
        actor,
        tenant_id,
        vendor_party_id=_validate_party(session, payload.get("vendor_party_id"), "vendor"),
        project_id=_validate_project(session, tenant_id, payload.get("project_id")),
        amount=_parse_amount(payload.get("amount"), "Amount"),
        currency=_parse_currency(
            payload.get("currency"), _tenant_currency(session, tenant_id)
        ),
        expense_date=_parse_date(payload.get("expense_date"), "Expense date"),
        category=_clean_text(payload.get("category")),
        paid_by_party_id=_validate_optional_party(
            session, payload.get("paid_by_party_id"), "paid-by"
        ),
        settlement_month=_parse_month_tag(payload.get("settlement_month")),
        notes=_clean_text(payload.get("notes")),
    )
    session.flush()
    write_audit(
        session,
        actor,
        action="expenses.create",
        entity_type="expense",
        entity_id=expense.id,
        after={"expense": _scalar_image(expense)},
    )
    session.commit()
    return {"ok": True, "row": ledger_row(session, expense)}


def create_expense_batch(session: Session, actor, payload: dict) -> dict:
    """POST /api/expenses/batch — Yulia's split invoice: N rows sharing
    vendor + date + currency (+ optional shared paid-by / settlement
    tag), one per {project, amount} line. ONE transaction, ONE audit row
    carrying every created image."""
    require_owner(actor, "Only an owner can enter expenses.")
    require_writable_books(session, actor)
    tenant_id = require_tenant(actor)

    if payload.get("vendor_party_id") in (None, ""):
        raise ServiceError(422, "VENDOR_REQUIRED", "Pick a vendor.")
    vendor_id = _validate_party(session, payload.get("vendor_party_id"), "vendor")
    expense_date = _parse_date(payload.get("expense_date"), "Expense date")
    currency = _parse_currency(
        payload.get("currency"), _tenant_currency(session, tenant_id)
    )
    paid_by = _validate_optional_party(
        session, payload.get("paid_by_party_id"), "paid-by"
    )
    settlement_month = _parse_month_tag(payload.get("settlement_month"))

    lines = payload.get("lines") or []
    if not isinstance(lines, list) or not lines:
        raise ServiceError(422, "NO_LINES", "Add at least one line.")
    if len(lines) > MAX_BATCH_LINES:
        raise ServiceError(
            422,
            "TOO_MANY_LINES",
            f"At most {MAX_BATCH_LINES} lines per submit.",
        )

    created: list[Expense] = []
    for i, line in enumerate(lines, start=1):
        if not isinstance(line, dict) or line.get("project_id") in (None, ""):
            raise ServiceError(
                422,
                "LINE_PROJECT_REQUIRED",
                f"Line {i}: pick a project.",
                "Multi-line entry splits one vendor invoice by project.",
            )
        created.append(
            _build_expense(
                session,
                actor,
                tenant_id,
                vendor_party_id=vendor_id,
                project_id=_validate_project(session, tenant_id, line.get("project_id")),
                amount=_parse_amount(line.get("amount"), f"Line {i} amount"),
                currency=currency,
                expense_date=expense_date,
                category=_clean_text(line.get("category")),
                paid_by_party_id=paid_by,
                settlement_month=settlement_month,
                notes=_clean_text(line.get("notes")),
            )
        )
    session.flush()
    write_audit(
        session,
        actor,
        action="expenses.create_batch",
        entity_type="expense",
        entity_id=created[0].id,
        after={
            "count": len(created),
            "expenses": [_scalar_image(e) for e in created],
        },
    )
    session.commit()
    return {
        "ok": True,
        "count": len(created),
        "rows": [ledger_row(session, e) for e in created],
    }


def update_expense(session: Session, actor, expense_id: uuid.UUID, patch: dict) -> dict:
    """PATCH /api/expenses/{id} — only the provided keys change (the
    router builds the dict from the request's set fields)."""
    require_owner(actor, "Only an owner can edit expenses.")
    require_writable_books(session, actor)
    tenant_id = require_tenant(actor)
    expense = _get_expense(session, tenant_id, expense_id)
    before = _scalar_image(expense)

    if "vendor_party_id" in patch:
        if patch.get("vendor_party_id") in (None, ""):
            raise ServiceError(422, "VENDOR_REQUIRED", "Pick a vendor.")
        expense.vendor_party_id = _validate_party(
            session, patch.get("vendor_party_id"), "vendor"
        )
    if "project_id" in patch:
        expense.project_id = _validate_project(
            session, tenant_id, patch.get("project_id")
        )
    if "amount" in patch:
        expense.amount = _parse_amount(patch.get("amount"), "Amount")
    if "currency" in patch:
        expense.currency = _parse_currency(
            patch.get("currency"), _tenant_currency(session, tenant_id)
        )
    if "expense_date" in patch:
        expense.expense_date = _parse_date(patch.get("expense_date"), "Expense date")
    if "category" in patch:
        expense.category = _clean_text(patch.get("category"))
    if "paid_by_party_id" in patch:
        expense.paid_by_party_id = _validate_optional_party(
            session, patch.get("paid_by_party_id"), "paid-by"
        )
    if "settlement_month" in patch:
        expense.settlement_month = _parse_month_tag(patch.get("settlement_month"))
    if "notes" in patch:
        expense.notes = _clean_text(patch.get("notes"))

    after = _scalar_image(expense)
    write_audit(
        session,
        actor,
        action="expenses.update",
        entity_type="expense",
        entity_id=expense.id,
        before={"expense": before},
        after={"expense": after},
    )
    session.commit()
    return {
        "ok": True,
        "changed": before != after,
        "row": ledger_row(session, expense),
    }


def delete_expense(session: Session, actor, expense_id: uuid.UUID) -> dict:
    """DELETE /api/expenses/{id}. The complete before-image rides the
    audit row (forensics; deleting a CC-ported row frees its port marker
    so the line can be ported again — ADR #024)."""
    require_owner(actor, "Only an owner can delete expenses.")
    require_writable_books(session, actor)
    tenant_id = require_tenant(actor)
    expense = _get_expense(session, tenant_id, expense_id)
    image = _scalar_image(expense)
    session.delete(expense)
    write_audit(
        session,
        actor,
        action="expenses.delete",
        entity_type="expense",
        entity_id=expense_id,
        before={"expense": image},
    )
    session.commit()
    return {"ok": True, "deleted": str(expense_id)}


__all__ = [
    "create_expense",
    "create_expense_batch",
    "update_expense",
    "delete_expense",
    "expense_options",
    "ledger_row",
    "MAX_BATCH_LINES",
]
