"""``/api/claim-acks`` (and per-claim/per-ack surfaces) — SP28.

Seven endpoints that surface the ``claim_acks`` join table to the
frontend + manual-match fallback for orphans. Mounted by
``cyclone.api`` alongside the existing ``/api/acks`` and
``/api/ta1-acks`` routers.

The live-tail endpoints (``/api/claims/{id}/acks/stream`` and
``/api/acks/{kind}/{id}/claims/stream``) subscribe to the
``claim_ack_written`` bus event so the ClaimDrawer Acknowledgments
panel and the per-ack claims list refresh in real time.

Manual match is any-logged-in user (D5) — this endpoint mutates
metadata only (``claim_acks`` row + live-tail event), no
``Claim.state`` mutation, no payment data. Idempotent: re-calling
with the same ``claim_id`` returns 200 with the existing row.
Rejects (409) when the claim is in a terminal state (``REVERSED``).
"""
from __future__ import annotations

import logging
from typing import Any, AsyncIterator, Literal

from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field

from cyclone import db
from cyclone.api_helpers import ndjson_line, tail_events
from cyclone.auth.deps import matrix_gate
from cyclone.pubsub import EventBus
from cyclone.store import store, to_ui_claim_ack

router = APIRouter(dependencies=[Depends(matrix_gate)])

log = logging.getLogger(__name__)


AckKind = Literal["999", "277ca", "ta1"]


class MatchClaimBody(BaseModel):
    """Body for ``POST /api/acks/{kind}/{ack_id}/match-claim``."""

    claim_id: str = Field(..., min_length=1)
    set_control_number: str | None = None
    set_accept_reject_code: str | None = None
    ak2_index: int | None = None


# ---------------------------------------------------------------------------
# Per-claim surface
# ---------------------------------------------------------------------------


@router.get("/api/claims/{claim_id}/acks/stream")
async def claim_acks_stream(
    request: Request,
    claim_id: str,
) -> StreamingResponse:
    """Stream ClaimAck rows for one claim as NDJSON.

    Subscribes to ``claim_ack_written`` and filters for rows where
    ``claim_id`` matches the path param. Each matching event is
    emitted as ``{"type": "item", "data": to_ui_claim_ack(...)}``;
    the client-side ``useMergedTail`` hook dedupes by id.

    Registered BEFORE ``/api/claims/{claim_id}/acks`` so the literal
    ``stream`` path segment doesn't get matched as a suffix.
    """
    bus: EventBus = request.app.state.event_bus

    async def gen() -> AsyncIterator[bytes]:
        rows = [
            r for r in store.list_acks_for_claim(claim_id)
            if r.claim_id is not None  # filter out TA1 batch-level rows
        ]
        for row in rows:
            yield ndjson_line({"type": "item", "data": to_ui_claim_ack(row)})
        yield ndjson_line({
            "type": "snapshot_end",
            "data": {"count": len(rows)},
        })

        async for chunk in tail_events(request, bus, ["claim_ack_written"]):
            # tail_events yields full NDJSON lines; the client filter
            # picks claim_id matches. We forward every event so the
            # wire format mirrors /api/claims/stream.
            yield chunk

    return StreamingResponse(gen(), media_type="application/x-ndjson")


@router.get("/api/claims/{claim_id}/acks")
def list_acks_for_claim_endpoint(claim_id: str) -> Any:
    """Return every ClaimAck row for one claim (per-claim only).

    TA1 batch-level rows (where ``claim_id IS NULL``) are filtered
    out — those don't belong to a specific claim, they're a
    envelope-level acknowledgement that hangs off the originating
    837 batch. The ClaimDrawer Acknowledgments panel is
    per-claim only.
    """
    rows = store.list_acks_for_claim(claim_id)
    items = [to_ui_claim_ack(r) for r in rows if r.claim_id is not None]
    return {"total": len(items), "items": items}


# ---------------------------------------------------------------------------
# Per-ack surface
# ---------------------------------------------------------------------------


@router.get("/api/acks/{kind}/{ack_id}/claims/stream")
async def ack_claims_stream(
    request: Request,
    kind: AckKind,
    ack_id: int,
) -> StreamingResponse:
    """Stream ClaimAck rows for one ack as NDJSON.

    Subscribes to ``claim_ack_written`` and forwards events the
    store knows about. Clients filter by ack_id + ack_kind on the
    client side.
    """
    bus: EventBus = request.app.state.event_bus

    async def gen() -> AsyncIterator[bytes]:
        rows = store.list_claims_for_ack(kind, ack_id)
        for row in rows:
            yield ndjson_line({"type": "item", "data": to_ui_claim_ack(row)})
        yield ndjson_line({
            "type": "snapshot_end",
            "data": {"count": len(rows)},
        })
        async for chunk in tail_events(request, bus, ["claim_ack_written"]):
            yield chunk

    return StreamingResponse(gen(), media_type="application/x-ndjson")


@router.get("/api/acks/{kind}/{ack_id}/claims")
def list_claims_for_ack_endpoint(kind: AckKind, ack_id: int) -> Any:
    """Return every ClaimAck row for one ack (any kind).

    For 999 / 277CA: returns 0..N rows (one per AK2 / ClaimStatus).
    For TA1: returns 0..1 row (envelope-level, populated batch_id).
    """
    rows = store.list_claims_for_ack(kind, ack_id)
    items = [to_ui_claim_ack(r) for r in rows]
    return {"total": len(items), "items": items}


# ---------------------------------------------------------------------------
# Manual match / unmatch (D5, D9)
# ---------------------------------------------------------------------------


@router.post("/api/acks/{kind}/{ack_id}/match-claim")
def manual_match_claim_endpoint(
    request: Request,
    kind: AckKind,
    ack_id: int,
    body: MatchClaimBody,
) -> Any:
    """Manual link fallback (D5/D9). Any-logged-in-user posture.

    Inserts a ``claim_acks`` row with ``linked_by="manual"`` and
    publishes ``claim_ack_written`` so the drawers refresh. The
    endpoint is idempotent: if a row already exists for this dedup
    key, the existing row is returned (200). 409 when the claim is
    in a terminal state (``REVERSED``). 404 when the claim doesn't
    exist or the ack doesn't exist.
    """
    # Verify the claim exists and is in a non-terminal state.
    from cyclone.db import ClaimState as _CS
    with db.SessionLocal()() as s:
        claim = s.get(db.Claim, body.claim_id)
        if claim is None:
            raise HTTPException(
                status_code=404,
                detail={
                    "error": "Not found",
                    "detail": f"Claim {body.claim_id} not found",
                },
            )
        if claim.state == _CS.REVERSED:
            return JSONResponse(
                status_code=409,
                content={
                    "error": "Conflict",
                    "detail": (
                        f"Claim {body.claim_id} is in terminal state "
                        f"{claim.state.value} and cannot be linked."
                    ),
                },
            )
        # Verify the ack exists (any kind).
        ack_table = {
            "999": db.Ack,
            "277ca": db.Two77caAck,
            "ta1": db.Ta1Ack,
        }[kind]
        if s.get(ack_table, ack_id) is None:
            raise HTTPException(
                status_code=404,
                detail={
                    "error": "Not found",
                    "detail": f"{kind} ACK {ack_id} not found",
                },
            )
        # Idempotency: if a manual or auto link already exists, return it.
        existing = (
            s.query(db.ClaimAck)
            .filter(
                db.ClaimAck.ack_kind == kind,
                db.ClaimAck.ack_id == ack_id,
                db.ClaimAck.claim_id == body.claim_id,
            )
            .first()
        )
        if existing is not None:
            return {
                "link": to_ui_claim_ack(existing),
                "created": False,
            }

    # Insert via the store so the publish-from-store contract fires.
    row = store.add_claim_ack(
        claim_id=body.claim_id,
        batch_id=None,
        ack_id=ack_id,
        ack_kind=kind,
        ak2_index=body.ak2_index,
        set_control_number=body.set_control_number,
        set_accept_reject_code=body.set_accept_reject_code,
        linked_by="manual",
        event_bus=request.app.state.event_bus,
    )
    return {"link": to_ui_claim_ack(row), "created": True}


@router.delete("/api/acks/{kind}/{ack_id}/match-claim/{claim_id}")
def manual_unmatch_claim_endpoint(
    request: Request,
    kind: AckKind,
    ack_id: int,
    claim_id: str,
) -> Any:
    """Unlink (preserves ``Claim.state`` mutation; only removes the row).

    404 when no link row exists for the dedup key. Publishes
    ``claim_ack_dropped`` so live-tail subscribers remove the link.
    """
    from cyclone import db as _db
    with _db.SessionLocal()() as s:
        link = (
            s.query(_db.ClaimAck)
            .filter(
                _db.ClaimAck.ack_kind == kind,
                _db.ClaimAck.ack_id == ack_id,
                _db.ClaimAck.claim_id == claim_id,
            )
            .first()
        )
        if link is None:
            raise HTTPException(
                status_code=404,
                detail={
                    "error": "Not found",
                    "detail": (
                        f"No link for {kind} ack {ack_id} → "
                        f"claim {claim_id}"
                    ),
                },
            )
        link_id = link.id

    removed = store.remove_claim_ack(link_id, event_bus=request.app.state.event_bus)
    return {"removed": removed, "link_id": link_id}


# ---------------------------------------------------------------------------
# Inbox ack-orphans lane (spec §D7)
# ---------------------------------------------------------------------------


@router.get("/api/inbox/ack-orphans")
def list_ack_orphans_endpoint(
    kind: AckKind | None = Query(None, description="Filter by ack kind"),
) -> Any:
    """List acks with no resolvable Claim row of their own kind.

    Used by the Inbox "Ack orphans" lane for the operator's manual
    reconciliation flow. Filters by kind: ``999``, ``277ca``, ``ta1``.
    """
    if kind is not None:
        items = store.find_ack_orphans(kind)
        return {"total": len(items), "items": items}
    items_999 = store.find_ack_orphans("999")
    items_277ca = store.find_ack_orphans("277ca")
    items_ta1 = store.find_ack_orphans("ta1")
    all_items = items_999 + items_277ca + items_ta1
    return {"total": len(all_items), "items": all_items}


__all__ = ["router"]