"""services/projects_admin — the owner-only project *financials* write
surface: income + the two display-only billout adjustments
(`billout_adjustment_pct` / `billout_adjustment_amount`).

This is the R1 rebuild of the legacy `updateProjectIncome` server action
(01-current-system/features/projects-and-entities.md B21–B23;
adjustments-and-rates.md B31–B36). It carries the parity behaviors:

  * **owner-only** — gated on `can_transfer` (the money-visibility gate;
    parity with the legacy `canTransfer`). Error: "Only an owner can edit
    project income." A non-owner is refused BEFORE any read.
  * **all fields written every save** — income, pct, and amount are each
    optional; a blank field CLEARS (writes NULL). One save fully replaces
    the project's financials (B23/B34).
  * **display-only, never stamped** — this writes ONLY `projects` columns;
    it re-stamps NOTHING (ADR #040). Reporting applies the adjustment at
    read time (`reporting/projects.project_summary` / `summary`), so the
    written values flow straight into the /projects rollup and margin on
    the next fetch. Not a `services/money/` change (no stamp is touched),
    so it is outside the single-writer money lock.
  * **audited with before-images** — every save writes an audit row with
    the complete prior financials, same convention as team_admin.

Two deliberate deviations from the literal orchestrator brief, decided by
the parity bar (ADR #010):

  1. **pct and amount are NOT mutually exclusive.** The old billout
     adjustment is `raw × (1 + pct/100) + amount` — pct AND amount COMBINE
     (adjustments-and-rates.md B32; test T-ADJ-018 sets both; the migrated
     "Site Rebuild" project carries pct 10 AND amount 250). There is no
     XOR CHECK on those columns in either the legacy or R1 schema — the
     only XOR in the system is `rate_overrides` (rate XOR pct), a separate
     feature already built in team_admin. Enforcing XOR here would break
     T-ADJ-018 and contradict the migrated data, so it is NOT enforced.
  2. **The constraint we DO enforce is income⇔currency pairing.** The R1
     `projects` table's real CHECK is `ck_projects_income_currency_pair`
     (`(income IS NULL) = (income_currency IS NULL)`). We always set
     `income_currency` to the tenant currency when income is present and
     NULL when it is cleared, so the pair can never violate the CHECK.

The legacy "Cannot set income on a project group with missing operator,
client, or project. Fix the entries first." NULL-tuple guard becomes
STRUCTURAL in R1: financials attach to a project *entity id*, so an
unassigned/"(missing)" rollup group simply has no id to target — the UI
never offers the editor on such a row, and the endpoint requires a real
project id (a bad/unknown id is a clean PROJECT_NOT_FOUND).
"""

from __future__ import annotations

import re
import uuid
from decimal import Decimal, InvalidOperation

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models import Project, Tenant
from app.services.audit import write_audit
from app.services.errors import ServiceError

#: characters stripped from money/percent inputs before parsing (parity
#: with the legacy `strips [$,%]` cleaning).
_STRIP = re.compile(r"[$,%\s]")


# --------------------------------------------------------------- gates


def _require_owner(actor) -> None:
    """The income/adjustment gate — owner-only (can_transfer), parity with
    the legacy `canTransfer` on `updateProjectIncome`."""
    if not actor.allowed("can_transfer"):
        raise ServiceError(
            403, "NOT_ALLOWED", "Only an owner can edit project income."
        )


def _require_tenant(actor) -> uuid.UUID:
    if actor.tenant_id is None:
        raise ServiceError(400, "NO_TENANT", "No tenant context resolved.")
    return actor.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"  # 07 #21 all-CAD default


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


def _clean(raw) -> str:
    return _STRIP.sub("", str(raw)) if raw is not None else ""


def parse_income(raw) -> Decimal | None:
    """Income: blank/None -> None (clear). Otherwise a finite, NON-negative
    number rounded to cents. Parity message (B21/B35)."""
    if raw is None or str(raw).strip() == "":
        return None
    try:
        value = Decimal(_clean(raw))
    except (InvalidOperation, ValueError) as exc:
        raise ServiceError(
            422,
            "BAD_INCOME",
            "Income must be a non-negative number, or empty to clear.",
        ) from exc
    if not value.is_finite() or value < 0:
        raise ServiceError(
            422,
            "BAD_INCOME",
            "Income must be a non-negative number, or empty to clear.",
        )
    return value.quantize(Decimal("0.01"))


def parse_adjustment(raw, label: str) -> Decimal | None:
    """A billout adjustment field (pct or fixed amount): blank/None -> None
    (clear). Otherwise a finite number of ANY sign, rounded to 2 decimals
    (parity precision, B35 — the migrated 4-dp pct still round-trips fine
    through the numeric(7,4) column). No -100 floor: the display adjustment
    is deliberately unbounded (a rate override is the floored one)."""
    if raw is None or str(raw).strip() == "":
        return None
    try:
        value = Decimal(_clean(raw))
    except (InvalidOperation, ValueError) as exc:
        raise ServiceError(
            422, "BAD_ADJUSTMENT", f"{label} must be a number, or empty to clear."
        ) from exc
    if not value.is_finite():
        raise ServiceError(
            422, "BAD_ADJUSTMENT", f"{label} must be a number, or empty to clear."
        )
    return value.quantize(Decimal("0.01"))


# --------------------------------------------------------------- snapshot


def _financials(project: Project) -> dict:
    """JSON-serializable image of a project's four financial fields — the
    audit before/after payload."""

    def num(value) -> str | None:
        return str(value) if value is not None else None

    return {
        "income": num(project.income),
        "income_currency": project.income_currency,
        "billout_adjustment_pct": num(project.billout_adjustment_pct),
        "billout_adjustment_amount": num(project.billout_adjustment_amount),
    }


def _money(amount: Decimal | None, currency: str) -> dict | None:
    if amount is None:
        return None
    return {"amount": str(amount), "currency": currency}


# --------------------------------------------------------------- write


def update_project_financials(
    session: Session, actor, project_id: uuid.UUID, data: dict
) -> dict:
    """Set a project's income + display-only billout adjustments in one
    owner-only, audited write. Re-stamps NOTHING (ADR #040); reporting
    recomputes billout_adjusted + margin at read time on the next fetch."""
    _require_owner(actor)
    tenant_id = _require_tenant(actor)

    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.",
            "Income and adjustments attach to a resolved project — fix the"
            " entries onto a project first.",
        )

    income = parse_income(data.get("income"))
    pct = parse_adjustment(data.get("billout_adjustment_pct"), "Adjustment %")
    amount = parse_adjustment(data.get("billout_adjustment_amount"), "Adjustment $")
    currency = (
        (data.get("currency") or "").strip().upper()
        or _tenant_currency(session, tenant_id)
    )

    before = _financials(project)

    # income⇔currency pairing (ck_projects_income_currency_pair): a present
    # income always carries the tenant currency; a cleared income clears it.
    project.income = income
    project.income_currency = currency if income is not None else None
    project.billout_adjustment_pct = pct
    project.billout_adjustment_amount = amount

    after = _financials(project)
    changed = before != after

    write_audit(
        session,
        actor,
        action="projects.update_financials",
        entity_type="project",
        entity_id=project.id,
        before=before,
        after=after,
    )
    session.commit()

    return {
        "ok": True,
        "changed": changed,
        "info": f'Saved financials for "{project.name}".',
        "project_id": str(project.id),
        "income": _money(income, currency),
        "billout_adjustment_pct": str(pct) if pct is not None else None,
        "billout_adjustment_amount": _money(amount, currency),
    }
