"""/api/entries — reads (M3: list + totals + options) AND writes (M5:
field-level PATCH, manual create, re-resolve, delete, the bulk engine,
undo). Thin HTTP layer — every rule lives in services/entries_write,
services/bulk, services/audit (04-architecture)."""

from __future__ import annotations

import uuid

from fastapi import APIRouter, Query
from pydantic import BaseModel, Field

from app.routers.deps import (
    ActorDep,
    ApiError,
    FiltersDep,
    SessionDep,
    build_entry_filters,
    contain_view_as,
    tenant_currency,
)
from app.services import audit as audit_svc
from app.services import bulk as bulk_svc
from app.services import entries_write as write_svc
from app.services.errors import ServiceError
from app.services.money import invoices as invoice_svc
from app.services.reporting import entries as entries_svc

router = APIRouter()


def _run(fn, *args, **kwargs):
    """Translate the service-layer ServiceError into the app-wide
    structured HTTP error triple."""
    try:
        return fn(*args, **kwargs)
    except ServiceError as exc:
        raise ApiError(exc.status, exc.code, exc.summary, exc.detail) from exc

SORT_KEYS = frozenset(
    {
        "date",
        "user",
        "operator",
        "client",
        "project",
        "description",
        "source_hrs",
        "cost",
        "billout_amount",
        "status",
    }
)


@router.get("/api/entries")
def list_entries(
    session: SessionDep,
    actor: ActorDep,
    f: FiltersDep,
    sort: str = Query("date"),
    dir: str = Query("desc"),
    page: int = Query(1, ge=1),
) -> dict:
    f = contain_view_as(actor, f)
    result = entries_svc.list_entries(
        session,
        actor,
        f,
        sort=sort if sort in SORT_KEYS else "date",
        direction="asc" if dir == "asc" else "desc",
        page=page,
        zero_currency=tenant_currency(actor),
    )
    return {
        "rows": result.rows,
        "total_count": result.total_count,
        "page": result.page,
        "page_size": result.page_size,
        "total_pages": result.total_pages,
    }


@router.get("/api/entries/totals")
def entry_totals(session: SessionDep, actor: ActorDep, f: FiltersDep) -> dict:
    f = contain_view_as(actor, f)
    return entries_svc.entry_totals(
        session, actor, f, zero_currency=tenant_currency(actor)
    )


@router.get("/api/entries/options")
def entry_options(session: SessionDep, actor: ActorDep, f: FiltersDep) -> dict:
    f = contain_view_as(actor, f)
    return entries_svc.entry_options(session, actor, f)


@router.get("/api/entries/edit-options")
def edit_options(session: SessionDep, actor: ActorDep) -> dict:
    """Picker data for cell editing / manual entry: active workers +
    the entity catalog (operators, client edges, projects)."""
    return _run(write_svc.edit_options, session, actor)


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


class EntryPatch(BaseModel):
    """Field-level PATCH — only the fields the client SET are applied
    (cell edits touch exactly one thing). Entity chain: project_id
    (existing project / null = explicit unlink) XOR the full
    operator+client+project trio (resolve-or-create)."""

    description: str | None = None
    billable: bool | None = None
    start_at: str | None = None
    end_at: str | None = None
    duration: str | None = None  # "H:MM[:SS]" or decimal hours
    duration_seconds: int | None = None
    worker_party_id: str | None = None
    project_id: str | None = None
    operator: str | None = None
    client: str | None = None
    project: str | None = None


class EntryCreate(BaseModel):
    date: str = ""
    start_time: str | None = None
    duration: str | None = None
    duration_seconds: int | None = None
    description: str | None = None
    worker_party_id: str | None = None
    project_id: str | None = None
    operator: str | None = None
    client: str | None = None
    project: str | None = None


def _row_or_gone(session, actor, entry_id) -> dict:
    row = entries_svc.fetch_entry_row(
        session, actor, entry_id, zero_currency=tenant_currency(actor)
    )
    if row is None:  # pragma: no cover — scope was checked by the write
        raise ApiError(404, "ENTRY_NOT_FOUND", "Entry not found.")
    return row


@router.post("/api/entries")
def create_entry(session: SessionDep, actor: ActorDep, body: EntryCreate) -> dict:
    entry = _run(write_svc.create_entry, session, actor, body.model_dump())
    return {"ok": True, "row": _row_or_gone(session, actor, entry.id)}


# NOTE: the bulk + undo routes are registered BEFORE the /{entry_id}
# routes — FastAPI matches in registration order, and "bulk"/"undo"
# must never be parsed as an entry id.

# ------------------------------------------------------------- bulk


class BulkBody(BaseModel):
    """The bulk-selection contract (judgment #11): explicit `ids` OR
    `all_matching` + the SAME filter params the display queries used;
    `expected_count` is REQUIRED — the server aborts on mismatch."""

    expected_count: int
    all_matching: bool = False
    ids: list[uuid.UUID] = Field(default_factory=list)
    filters: dict[str, str] = Field(default_factory=dict)

    # bulk edit targets
    worker_party_id: str | None = None
    project_id: str | None = None
    new_operator: str | None = None
    new_client: str | None = None
    new_project: str | None = None


_FILTER_KEYS = frozenset(
    {
        "start", "end", "status", "batch", "q", "project", "user",
        "source_email", "client", "operator", "imported_by", "invoice",
    }
)


def _selection(actor, body: BulkBody) -> bulk_svc.BulkSelection:
    if body.all_matching:
        raw = {k: v for k, v in body.filters.items() if k in _FILTER_KEYS}
        missing_info = body.filters.get("missing_info") in ("1", "true", "True")
        f = build_entry_filters(**raw, missing_info=missing_info)
        f = contain_view_as(actor, f)
        return bulk_svc.BulkSelection(expected_count=body.expected_count, filters=f)
    if not body.ids:
        raise ApiError(422, "NO_SELECTION", "No entries selected.")
    return bulk_svc.BulkSelection(expected_count=body.expected_count, ids=list(body.ids))


@router.post("/api/entries/bulk/edit")
def bulk_edit(session: SessionDep, actor: ActorDep, body: BulkBody) -> dict:
    return _run(
        bulk_svc.bulk_edit_fields,
        session,
        actor,
        _selection(actor, body),
        worker_party_id=uuid.UUID(body.worker_party_id) if body.worker_party_id else None,
        project_id=uuid.UUID(body.project_id) if body.project_id else None,
        new_operator=body.new_operator,
        new_client=body.new_client,
        new_project=body.new_project,
    )


@router.post("/api/entries/bulk/recalculate")
def bulk_recalculate(session: SessionDep, actor: ActorDep, body: BulkBody) -> dict:
    return _run(bulk_svc.bulk_recalculate, session, actor, _selection(actor, body))


@router.post("/api/entries/bulk/delete")
def bulk_delete(session: SessionDep, actor: ActorDep, body: BulkBody) -> dict:
    return _run(bulk_svc.bulk_delete, session, actor, _selection(actor, body))


class BulkApplyBody(BulkBody):
    """The bulk contract + the target invoice (M6). Same selection
    machinery, same expected_count safety stop."""

    invoice_id: uuid.UUID


@router.post("/api/entries/bulk/apply-to-invoice")
def bulk_apply_to_invoice(
    session: SessionDep, actor: ActorDep, body: BulkApplyBody
) -> dict:
    """Attach the selection's pending rows to the invoice — locks them
    (invoices.md B23–B33 on the M5 engine; owner-only)."""
    return _run(
        invoice_svc.apply_entries,
        session,
        actor,
        body.invoice_id,
        _selection(actor, body),
    )


class BulkDetachBody(BaseModel):
    """Detach is EXPLICIT-selection only (invoices.md B62/T-INV-015):
    there is deliberately no all_matching path. `invoice_id` scopes the
    detach to one invoice (the ?invoice= view); omitted = any locked
    selected row."""

    expected_count: int
    ids: list[uuid.UUID] = Field(default_factory=list)
    invoice_id: uuid.UUID | None = None
    all_matching: bool = False


@router.post("/api/entries/bulk/detach")
def bulk_detach(session: SessionDep, actor: ActorDep, body: BulkDetachBody) -> dict:
    if body.all_matching:
        raise ApiError(
            422,
            "DETACH_EXPLICIT_ONLY",
            "Detach only works on an explicit selection.",
            'Uncheck "select all matching" and pick rows.',
        )
    if not body.ids:
        raise ApiError(422, "NO_SELECTION", "No entries selected.")
    return _run(
        invoice_svc.detach_entries,
        session,
        actor,
        ids=list(body.ids),
        expected_count=body.expected_count,
        invoice_id=body.invoice_id,
    )


@router.post("/api/entries/undo/{audit_id}")
def undo(session: SessionDep, actor: ActorDep, audit_id: uuid.UUID) -> dict:
    """Replay the before-images of one undoable audit row (judgment
    #11: undo = replay-from-audit-row; skips since-locked rows and
    reports exact counts)."""
    return _run(audit_svc.undo_entries_action, session, actor, audit_id)


# --------------------------------------------------- single-row writes


@router.patch("/api/entries/{entry_id}")
def update_entry(
    session: SessionDep, actor: ActorDep, entry_id: uuid.UUID, body: EntryPatch
) -> dict:
    patch = {k: getattr(body, k) for k in body.model_fields_set}
    entry = _run(write_svc.update_entry, session, actor, entry_id, patch)
    return {"ok": True, "row": _row_or_gone(session, actor, entry.id)}


@router.post("/api/entries/{entry_id}/reresolve")
def reresolve_entry(session: SessionDep, actor: ActorDep, entry_id: uuid.UUID) -> dict:
    entry, info = _run(write_svc.reresolve_entry, session, actor, entry_id)
    return {"ok": True, "info": info, "row": _row_or_gone(session, actor, entry.id)}


@router.delete("/api/entries/{entry_id}")
def delete_entry(session: SessionDep, actor: ActorDep, entry_id: uuid.UUID) -> dict:
    undo_id = _run(write_svc.delete_entry, session, actor, entry_id)
    return {"ok": True, "undo_id": str(undo_id)}
