"""services/money/invoices_plus — PlusROI invoice generation + the sheet
status lifecycle (phase-3 slice 2b, docs/design/plusroi-forms.md §4;
ADR #023).

SEPARATE from ``services/money/invoices.py`` on purpose. That module is
the Bowden Works TIME-invoice service: statuses open|sent|paid coerced by
``clean_status``, invoice_lines apply/detach, margin math. PlusROI
invoices are RECEIVABLES — no lines, no apply, and the sheet lifecycle
``pending_approval → ready → sent → paid`` (+ cancelled/delayed). Both
write the SAME ``invoices`` table; rows never collide because every read
and write here is tenant-scoped, and the two lifecycles never share a
status vocabulary entry point (BW's ``open`` is not a lifecycle state
here; a transition from it is refused).

Every mutation applies the shared PlusROI write-gate
(``services.plusroi_gates``: owner ``can_transfer`` → writable books →
tenant) and writes an audit row with complete before/after images.

Generation identity (recurring): an app-generated recurring invoice is
named ``"{project.name} — {YYYY-MM}"`` (suffix ``(2)``… on a collision
with an unrelated invoice) and stamped ``relevant_period = "YYYY-MM"`` +
``billing_type = "recurring"``. The (project, month) already-generated
check matches ``to_party == the project's client AND relevant_period ==
month AND name starts with the deterministic prefix`` — the best
queryable identity available without an invoice→project FK (invoices
deliberately carry no project_id in R2). Renaming a project between
generations therefore re-opens the month — documented in ADR #023.
Milestone identity is exact: ``project_milestones.generated_invoice_id``
(one invoice per milestone, ever).

``settlement_month`` is stamped at generation = the billed month
(first-of-month), matching the sheet's "BW Invoice" tag. The FROZEN
settlement engine groups by that tag with no status filter, so a
generated month reads as that month's projected split immediately; a
transition to ``cancelled`` CLEARS the tag so a dead invoice can never
pollute the split (the engine itself stays untouched).

USD realized FX (the sheet's 08 §2 practice): a generated USD invoice
carries ``currency='USD'``, ``manual_total`` = the USD face, and
``original_amount/original_currency`` = that same face. At **paid** the
CAD actually collected is REQUIRED for a non-CAD invoice: it lands in
``collected_amount/collected_currency`` AND becomes the CAD book value
(``manual_total`` = collected, ``currency`` = CAD) — exactly the shape
the sheet importer produces for USD receipts, which is what keeps the
frozen settlement engine (Σ ``manual_total`` as CAD) correct.
"""

from __future__ import annotations

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

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.models import (
    Invoice,
    Party,
    Project,
    ProjectBilling,
    ProjectMilestone,
    Tenant,
)
from app.services.audit import write_audit
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

#: The sheet lifecycle (the widened alembic-0007 status CHECK). BW's
#: ``open`` is deliberately absent — it is not a PlusROI state.
PLUS_STATUSES = (
    "pending_approval",
    "ready",
    "sent",
    "paid",
    "cancelled",
    "delayed",
)

#: Guarded transitions. Forward one step at a time; ``cancelled`` /
#: ``delayed`` are reachable from any live state; ``delayed`` resumes to
#: any live state (paid still requires passing through sent — the QB #);
#: ``ready`` may be sent BACK for re-approval. ``paid``/``cancelled`` are
#: terminal. Anything else — including BW's ``open`` — is a 409.
TRANSITIONS: dict[str, frozenset[str]] = {
    "pending_approval": frozenset({"ready", "cancelled", "delayed"}),
    "ready": frozenset({"pending_approval", "sent", "cancelled", "delayed"}),
    "sent": frozenset({"paid", "cancelled", "delayed"}),
    "delayed": frozenset({"pending_approval", "ready", "sent", "cancelled"}),
    "paid": frozenset(),
    "cancelled": frozenset(),
}

#: While an invoice sits here, its amount is still a draft — editable
#: without touching the project default (Rob's $1,500 → $2,000).
AMOUNT_EDITABLE_STATUSES = frozenset({"pending_approval", "ready"})

BILLING_TIMINGS = ("month_end", "mid_month")

_STATUS_LABELS = {
    "pending_approval": "Pending approval",
    "ready": "Ready to invoice",
    "sent": "Invoiced",
    "paid": "Paid",
    "cancelled": "Cancelled",
    "delayed": "Delayed",
    "open": "Open (Bowden Works)",
}


def _label(status: str) -> str:
    return _STATUS_LABELS.get(status, status)


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


def parse_month(raw) -> date:
    """'YYYY-MM' → the first-of-month date the ledgers are tagged with.
    Same contract (and error code) as the frozen engine's parse_month,
    kept local so this module never reaches into settlement.py."""
    try:
        year_s, month_s = str(raw or "").strip().split("-", 1)
        return date(int(year_s), int(month_s), 1)
    except (ValueError, AttributeError) as exc:
        raise ServiceError(
            422,
            "BAD_MONTH",
            "month must look like YYYY-MM",
            f"Got {raw!r}.",
        ) from exc


def _month_key(month: date) -> str:
    return f"{month.year:04d}-{month.month:02d}"


def _parse_amount(raw, label: str) -> Decimal:
    """A required, positive 2dp money value (the draft-amount edit and
    the CAD-collected entry)."""
    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_timing(raw) -> str:
    value = (raw or "").strip().lower()
    if value not in BILLING_TIMINGS:
        raise ServiceError(
            422,
            "BAD_TIMING",
            "timing must be month_end or mid_month.",
            f"Got {raw!r}.",
        )
    return value


def _escape_like(term: str) -> str:
    """Escape LIKE metacharacters (books.py idiom) — escape ``\\`` FIRST."""
    return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")


# --------------------------------------------------------------- helpers


def _tenant_party_id(session: Session, tenant_id: uuid.UUID) -> uuid.UUID | None:
    return session.scalar(select(Tenant.party_id).where(Tenant.id == tenant_id))


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 _get_project(
    session: Session, tenant_id: uuid.UUID, project_id: uuid.UUID
) -> tuple[Project, ProjectBilling]:
    project = session.scalar(
        select(Project).where(
            Project.id == project_id, Project.tenant_id == tenant_id
        )
    )
    if project is None:
        raise ServiceError(404, "PROJECT_NOT_FOUND", "Project not found.")
    billing = session.get(ProjectBilling, project_id)
    if billing is None:
        raise ServiceError(
            409,
            "NO_BILLING_PROFILE",
            "This project has no billing profile.",
            "Open the project and set it recurring or give it a milestone"
            " schedule first.",
        )
    return project, billing


def _get_invoice(
    session: Session, tenant_id: uuid.UUID, invoice_id: uuid.UUID
) -> Invoice:
    invoice = session.scalar(
        select(Invoice).where(
            Invoice.id == invoice_id, Invoice.tenant_id == tenant_id
        )
    )
    if invoice is None:
        raise ServiceError(404, "INVOICE_NOT_FOUND", "Invoice not found.")
    return invoice


def _name_taken(session: Session, tenant_id: uuid.UUID, name: str) -> bool:
    return (
        session.scalar(
            select(Invoice.id).where(
                Invoice.tenant_id == tenant_id,
                func.lower(Invoice.name) == name.lower(),
            )
        )
        is not None
    )


def _unique_name(session: Session, tenant_id: uuid.UUID, base: str) -> str:
    """Per-tenant case-insensitive uniqueness with a `` (2)``… suffix on
    collision (the sheet importer's own convention)."""
    name = base
    n = 1
    while _name_taken(session, tenant_id, name):
        n += 1
        name = f"{base} ({n})"
    return name


def _recurring_exists(
    session: Session,
    tenant_id: uuid.UUID,
    project: Project,
    month_key: str,
) -> bool:
    """Has this (project, month) already generated? See the module
    docstring for why identity is (client, relevant_period, name-prefix)."""
    prefix = _escape_like(f"{project.name} — {month_key}".lower())
    return (
        session.scalar(
            select(Invoice.id).where(
                Invoice.tenant_id == tenant_id,
                Invoice.to_party_id == project.client_party_id,
                Invoice.relevant_period == month_key,
                func.lower(Invoice.name).like(f"{prefix}%", escape="\\"),
            )
        )
        is not None
    )


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


def _commission_amount(
    amount: Decimal, pct: Decimal | None
) -> Decimal | None:
    if pct is None:
        return None
    return quantize2(amount * pct / Decimal(100))


def _create_invoice(
    session: Session,
    actor,
    tenant_id: uuid.UUID,
    project: Project,
    billing: ProjectBilling,
    *,
    base_name: str,
    amount: Decimal,
    currency: str,
    month: date,
    billing_kind: str,
) -> Invoice:
    """One generated PlusROI invoice (flushed, not committed). The amount
    is COPIED from the project/milestone — a later per-invoice edit never
    writes back (the project default is the template, not the document)."""
    now = datetime.now(UTC)
    pct = _decimal(billing.default_commission_pct)
    invoice = Invoice(
        id=uuid.uuid4(),
        tenant_id=tenant_id,
        name=_unique_name(session, tenant_id, base_name),
        status="pending_approval",
        currency=currency,
        invoice_date=now.date(),
        from_party_id=_tenant_party_id(session, tenant_id),
        to_party_id=project.client_party_id,
        manual_total=amount,
        created_by=actor.real_user_id,
        # the sheet's billing tag: the month being billed (module docstring)
        settlement_month=month,
        relevant_period=_month_key(month),
        invoice_timing=billing.billing_timing or billing.invoice_timing,
        billing_type=billing_kind,
        commission_pct=pct,
        commission_amount=_commission_amount(amount, pct),
        commission_party_id=billing.commission_party_id,
        # the non-CAD face survives realized-FX conversion at paid (08 §2)
        original_amount=amount if currency != "CAD" else None,
        original_currency=currency if currency != "CAD" else None,
    )
    session.add(invoice)
    session.flush()
    return invoice


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


def _scalar_image(invoice: Invoice) -> dict:
    out: dict = {}
    for column in Invoice.__table__.columns:
        value = getattr(invoice, column.name)
        if isinstance(value, uuid.UUID):
            value = str(value)
        elif isinstance(value, datetime | date):
            value = value.isoformat()
        elif isinstance(value, Decimal):
            value = str(value)
        out[column.name] = value
    return out


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


def _ledger_row(session: Session, invoice: Invoice) -> dict:
    """The SAME row shape ``/api/books/invoices`` emits (plus the
    collected pair) — so the frontend renders generation/lifecycle
    results with one type."""
    client = (
        session.scalar(select(Party.name).where(Party.id == invoice.to_party_id))
        if invoice.to_party_id is not None
        else None
    )
    return {
        "id": str(invoice.id),
        "external_id": invoice.external_id,
        "name": invoice.name,
        "client": client,
        "status": invoice.status,
        "invoice_date": invoice.invoice_date.isoformat()
        if invoice.invoice_date
        else None,
        "due_date": invoice.due_date.isoformat() if invoice.due_date else None,
        "settlement_month": _month_key(invoice.settlement_month)
        if invoice.settlement_month
        else None,
        "currency": invoice.currency,
        "original_amount": _money_str(invoice.original_amount),
        "original_currency": invoice.original_currency,
        "amount": _money_str(invoice.manual_total),
        "fee": _money_str(invoice.fee),
        "commission_amount": _money_str(invoice.commission_amount),
        "billing_type": invoice.billing_type,
        "relevant_period": invoice.relevant_period,
        "notes": invoice.notes,
        "collected_amount": _money_str(invoice.collected_amount),
        "collected_currency": invoice.collected_currency,
    }


# -------------------------------------------------------------- generate


def generate_for_project(
    session: Session, actor, project_id: uuid.UUID, payload: dict
) -> dict:
    """POST /api/plus-projects/{id}/generate-invoice {month}.

    Recurring → ONE ``pending_approval`` invoice for that month (409
    ``ALREADY_GENERATED`` on a repeat — the batch path skips instead).
    Milestone → EVERY un-generated milestone at once, each stamped
    ``generated_invoice_id`` so it can never generate twice; a repeat
    call returns ``created: []``. Owner-gated, writable-books,
    tenant-scoped, audited."""
    require_owner(actor, "Only an owner can generate invoices.")
    require_writable_books(session, actor)
    tenant_id = require_tenant(actor)

    month = parse_month(payload.get("month"))
    month_key = _month_key(month)
    project, billing = _get_project(session, tenant_id, project_id)

    created: list[Invoice] = []
    skipped_generated = 0

    if billing.is_recurring:
        amount = _decimal(billing.amount)
        if amount is None:
            raise ServiceError(
                422,
                "NO_DEFAULT_AMOUNT",
                "This recurring project has no default amount.",
                "Set the project's default amount first.",
            )
        if _recurring_exists(session, tenant_id, project, month_key):
            raise ServiceError(
                409,
                "ALREADY_GENERATED",
                f'"{project.name}" already has a {month_key} invoice.',
                "Edit that invoice on the Books → Invoices page instead of"
                " generating a duplicate.",
            )
        currency = (billing.currency or _tenant_currency(session, tenant_id)).upper()
        created.append(
            _create_invoice(
                session,
                actor,
                tenant_id,
                project,
                billing,
                base_name=f"{project.name} — {month_key}",
                amount=amount,
                currency=currency,
                month=month,
                billing_kind="recurring",
            )
        )
    else:
        milestones = list(
            session.scalars(
                select(ProjectMilestone)
                .where(ProjectMilestone.project_id == project.id)
                .order_by(ProjectMilestone.sort_order, ProjectMilestone.label)
            ).all()
        )
        if not milestones:
            raise ServiceError(
                409,
                "NO_MILESTONES",
                "This project has no milestone schedule.",
                "Add milestones (deposit / milestone / final) on the project"
                " first, or make it recurring.",
            )
        fallback = billing.currency or _tenant_currency(session, tenant_id)
        for milestone in milestones:
            if milestone.generated_invoice_id is not None:
                skipped_generated += 1
                continue
            currency = (milestone.currency or fallback).upper()
            invoice = _create_invoice(
                session,
                actor,
                tenant_id,
                project,
                billing,
                base_name=f"{project.name} — {milestone.label}",
                amount=quantize2(Decimal(str(milestone.amount))),
                currency=currency,
                month=month,
                billing_kind="milestone",
            )
            milestone.generated_invoice_id = invoice.id
            created.append(invoice)

    write_audit(
        session,
        actor,
        action="invoices_plus.generate",
        entity_type="project",
        entity_id=project.id,
        after={
            "project": project.name,
            "month": month_key,
            "created": [_scalar_image(inv) for inv in created],
            "skipped_already_generated": skipped_generated,
        },
    )
    session.commit()
    return {
        "ok": True,
        "project_id": str(project.id),
        "month": month_key,
        "created": [_ledger_row(session, inv) for inv in created],
        "skipped_already_generated": skipped_generated,
    }


def _months_between(anchor: date, month: date) -> int:
    return (month.year - anchor.year) * 12 + (month.month - anchor.month)


def generate_batch(session: Session, actor, payload: dict) -> dict:
    """POST /api/plus-invoices/generate-batch {month, timing} — Danielle's
    twice-monthly run: every ACTIVE recurring project with the given
    billing timing gets that month's invoice. Idempotent: a (project,
    month) that already generated is skipped, so re-running is safe.
    Cycle guard (money-safety): a multi-month cycle (quarterly/annual)
    generates only on its due months, anchored on the billing
    ``start_date``; with no anchor it is SKIPPED (generate it from the
    project row instead) rather than over-invoiced monthly."""
    require_owner(actor, "Only an owner can generate invoices.")
    require_writable_books(session, actor)
    tenant_id = require_tenant(actor)

    month = parse_month(payload.get("month"))
    month_key = _month_key(month)
    timing = _parse_timing(payload.get("timing"))

    rows = session.execute(
        select(Project, ProjectBilling)
        .join(ProjectBilling, ProjectBilling.project_id == Project.id)
        .where(
            Project.tenant_id == tenant_id,
            Project.status == "active",
            ProjectBilling.is_recurring.is_(True),
            ProjectBilling.billing_timing == timing,
        )
        .order_by(func.lower(Project.name))
    ).all()

    results: list[dict] = []
    created_images: list[dict] = []
    for project, billing in rows:
        entry = {"project_id": str(project.id), "name": project.name}
        amount = _decimal(billing.amount)
        cycle = billing.cycle_months or 1
        if amount is None:
            entry["result"] = "skipped_no_amount"
        elif cycle > 1 and billing.start_date is None:
            entry["result"] = "skipped_no_anchor"
        elif cycle > 1 and _months_between(billing.start_date, month) % cycle != 0:
            entry["result"] = "skipped_off_cycle"
        elif _recurring_exists(session, tenant_id, project, month_key):
            entry["result"] = "skipped_exists"
        else:
            currency = (
                billing.currency or _tenant_currency(session, tenant_id)
            ).upper()
            invoice = _create_invoice(
                session,
                actor,
                tenant_id,
                project,
                billing,
                base_name=f"{project.name} — {month_key}",
                amount=amount,
                currency=currency,
                month=month,
                billing_kind="recurring",
            )
            entry["result"] = "created"
            entry["invoice"] = _ledger_row(session, invoice)
            created_images.append(_scalar_image(invoice))
        results.append(entry)

    created_count = sum(1 for r in results if r["result"] == "created")
    write_audit(
        session,
        actor,
        action="invoices_plus.generate_batch",
        entity_type="tenant",
        entity_id=tenant_id,
        after={
            "month": month_key,
            "timing": timing,
            "results": [
                {k: v for k, v in r.items() if k != "invoice"} for r in results
            ],
            "created": created_images,
        },
    )
    session.commit()
    return {
        "ok": True,
        "month": month_key,
        "timing": timing,
        "results": results,
        "created_count": created_count,
        "skipped_count": len(results) - created_count,
    }


# -------------------------------------------------------------- lifecycle


def _stamp(invoice: Invoice, new_status: str, now: datetime) -> None:
    """First-crossing sent_at/paid_at (the BW convention, kept identical:
    stamps are never cleared or overwritten)."""
    if new_status in ("sent", "paid") and invoice.sent_at is None:
        invoice.sent_at = now
    if new_status == "paid" and invoice.paid_at is None:
        invoice.paid_at = now
    invoice.status = new_status


def update_plus_invoice(
    session: Session, actor, invoice_id: uuid.UUID, patch: dict
) -> dict:
    """PATCH /api/plus-invoices/{id} — the guarded lifecycle + draft
    edits. Only the SET keys act:

    - ``amount`` — editable while ``pending_approval``/``ready`` only
      (409 ``AMOUNT_LOCKED`` after); keeps the non-CAD face and the
      stamped commission in sync; NEVER writes back to the project.
    - ``external_id`` — the QuickBooks #; REQUIRED to cross into
      ``sent`` (may arrive in the same patch).
    - ``status`` — one guarded step (TRANSITIONS); illegal jumps are a
      409 naming both states. ``cancelled`` clears ``settlement_month``
      (the frozen engine must never count a dead invoice).
    - ``collected_amount``/``collected_currency`` — at ``paid``,
      REQUIRED for a non-CAD invoice; realizes the FX: the collected CAD
      becomes the book value (manual_total/currency), the face stays in
      original_amount/original_currency."""
    require_owner(actor, "Only an owner can edit invoices.")
    require_writable_books(session, actor)
    tenant_id = require_tenant(actor)

    invoice = _get_invoice(session, tenant_id, invoice_id)
    before = _scalar_image(invoice)

    # --- draft amount edit (checked against the PRE-transition status) ---
    if "amount" in patch:
        if invoice.status not in AMOUNT_EDITABLE_STATUSES:
            raise ServiceError(
                409,
                "AMOUNT_LOCKED",
                f'The amount is locked once an invoice leaves approval — this'
                f' one is {_label(invoice.status)}.',
                "Amounts are editable while Pending approval or Ready only.",
            )
        amount = _parse_amount(patch.get("amount"), "Amount")
        invoice.manual_total = amount
        if invoice.currency != "CAD":
            invoice.original_amount = amount  # the face follows the draft
        pct = _decimal(invoice.commission_pct)
        if pct is not None:
            invoice.commission_amount = _commission_amount(amount, pct)

    if "external_id" in patch:
        invoice.external_id = (patch.get("external_id") or "").strip() or None

    # --- guarded transition ----------------------------------------------
    if "status" in patch:
        target = (patch.get("status") or "").strip().lower()
        if target not in PLUS_STATUSES:
            raise ServiceError(
                422,
                "BAD_STATUS",
                f"status must be one of {', '.join(PLUS_STATUSES)}.",
                f"Got {patch.get('status')!r}.",
            )
        current = invoice.status
        if target != current:
            allowed = TRANSITIONS.get(current)
            if allowed is None or target not in allowed:
                raise ServiceError(
                    409,
                    "ILLEGAL_TRANSITION",
                    f'An invoice can\'t go from "{_label(current)}" to'
                    f' "{_label(target)}".',
                    "Lifecycle: Pending approval → Ready → Invoiced → Paid,"
                    " with Cancelled/Delayed available along the way.",
                )
            if target == "sent" and not invoice.external_id:
                raise ServiceError(
                    422,
                    "EXTERNAL_ID_REQUIRED",
                    "Record the QuickBooks invoice # to mark it invoiced.",
                    "Pass external_id with the sent transition.",
                )
            if target == "paid":
                _record_collection(invoice, patch)
            if target == "cancelled":
                invoice.settlement_month = None
            _stamp(invoice, target, datetime.now(UTC))

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


def _record_collection(invoice: Invoice, patch: dict) -> None:
    """The paid-transition money rules (see update_plus_invoice)."""
    raw = patch.get("collected_amount")
    provided = raw is not None and str(raw).strip() != ""
    if invoice.currency != "CAD" and not provided:
        raise ServiceError(
            422,
            "COLLECTED_AMOUNT_REQUIRED",
            f"This is a {invoice.currency} invoice — record the CAD actually"
            " collected to mark it paid.",
            "Pass collected_amount (CAD) with the paid transition; that"
            " becomes the CAD book value the settlement sums.",
        )
    if not provided:
        return
    collected = _parse_amount(raw, "Collected amount")
    collected_currency = (
        (patch.get("collected_currency") or "CAD").strip().upper() or "CAD"
    )
    invoice.collected_amount = collected
    invoice.collected_currency = collected_currency
    if invoice.currency != collected_currency:
        # Realized FX: preserve the face, then re-book in the collected
        # currency — the exact shape the sheet importer gives USD receipts.
        if invoice.original_amount is None:
            invoice.original_amount = invoice.manual_total
            invoice.original_currency = invoice.currency
        invoice.manual_total = collected
        invoice.currency = collected_currency
