"""``/api/inbox*`` — operator-facing Inbox surface (SP6 + SP14).

Six endpoints, all gated by ``matrix_gate``:

- ``GET  /api/inbox/lanes``                       — all lanes in one
  call (``compute_lanes`` from :mod:`cyclone.inbox_lanes`).
- ``POST /api/inbox/candidates/{remit_id}/match`` — manually link a
  remit to a claim; surfaces 409 with the current state when the
  claim is already matched.
- ``POST /api/inbox/candidates/dismiss``          — add candidate
  pairs to the session-scoped dismissed set (mutates
  ``request.app.state.dismissed_pairs``).
- ``POST /api/inbox/payer-rejected/acknowledge``  — SP14: mark
  Payer-Rejected claims as acknowledged. Idempotent; returns the
  count actually transitioned vs. already-acked / not-found /
  not-rejected.
- ``POST /api/inbox/rejected/resubmit``           — bulk move
  REJECTED claims back to SUBMITTED. With
  ``?download=true`` returns a ZIP of regenerated 837 files
  (``serialize_837_for_resubmit`` with per-claim ``interchange_index``
  for unique control numbers). Conflicts are omitted from the ZIP
  and surfaced via the ``X-Cyclone-Serialize-Errors`` header.
- ``GET  /api/inbox/export.csv``                  — stream a CSV for
  a single lane (rejected / candidates / unmatched / done_today).

All endpoints use ``request.app.state`` rather than the module-level
``app`` global so they're robust against ``importlib.reload`` of
the api module — the reload rebinds ``app`` to a new instance, but
``request.app`` always points at the instance actually serving the
current request.

SP36 Task 13: this block moved here from ``api.py:1280`` (the 6
``/api/inbox*`` routes, with the SP14 comment block preserved
verbatim).
"""
from __future__ import annotations

import csv
import io
import json
from datetime import datetime, timezone

from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import StreamingResponse

from cyclone import db
from cyclone.auth.deps import matrix_gate
from cyclone.db import Claim, ClaimState, Remittance
from cyclone.parsers.models import ClaimOutput
from cyclone.parsers.serialize_837 import SerializeError as SerializeError837
from cyclone.parsers.serialize_837 import serialize_837_for_resubmit

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


@router.get("/api/inbox/lanes")
def inbox_lanes(request: Request):
    """Return all Inbox lanes in one call.

    Uses ``request.app.state`` rather than the module-level ``app``
    global so the endpoint is robust against ``importlib.reload`` of
    this module (some tests do this to mutate the CORS allow-list).
    After a reload, the module-level ``app`` rebinds to a new
    FastAPI instance; ``request.app`` always points at the instance
    that is actually serving the current request, so per-request
    state stays consistent with the test's TestClient target.
    """
    dismissed_pairs = getattr(request.app.state, "dismissed_pairs", set())
    with db.SessionLocal()() as session:
        from cyclone.inbox_lanes import compute_lanes
        lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
    return {
        "rejected": lanes.rejected,
        # SP10: payer-rejected lane (277CA STC A4/A6/A7). Distinct from
        # the 999 envelope rejection in ``rejected`` above.
        "payer_rejected": lanes.payer_rejected,
        "candidates": lanes.candidates,
        "unmatched": lanes.unmatched,
        "done_today": lanes.done_today,
    }


@router.post("/api/inbox/candidates/{remit_id}/match")
def inbox_match_candidate(remit_id: str, body: dict):
    """Manually link a remit to a claim."""
    claim_id = body.get("claim_id")
    if not claim_id:
        raise HTTPException(400, "claim_id required")
    with db.SessionLocal()() as s:
        claim = s.get(Claim, claim_id)
        remit = s.get(Remittance, remit_id)
        if claim is None or remit is None:
            raise HTTPException(404, "claim or remit not found")
        if claim.matched_remittance_id and claim.matched_remittance_id != remit_id:
            raise HTTPException(
                409,
                detail={
                    "error": "claim_already_matched",
                    "current_state": (
                        claim.state.value if hasattr(claim.state, "value")
                        else str(claim.state)
                    ),
                    "matched_remittance_id": claim.matched_remittance_id,
                },
            )
        claim.matched_remittance_id = remit_id
        remit.claim_id = claim_id
        s.commit()
    return {"ok": True, "claim_id": claim_id, "remit_id": remit_id}


@router.post("/api/inbox/candidates/dismiss")
def inbox_dismiss_candidates(body: dict, request: Request):
    """Add candidate pairs to the session-scoped dismissed set.

    Uses ``request.app.state`` rather than the module-level ``app``
    global so the endpoint is robust against ``importlib.reload`` of
    this module (some tests do this to mutate the CORS allow-list).
    After a reload, the module-level ``app`` rebinds to a new
    FastAPI instance; ``request.app`` always points at the instance
    that is actually serving the current request, so the test's
    TestClient target is the one whose state we mutate.
    """
    pairs = body.get("pairs") or []
    if not hasattr(request.app.state, "dismissed_pairs"):
        request.app.state.dismissed_pairs = set()
    for p in pairs:
        cid = p.get("claim_id")
        rid = p.get("remit_id")
        if cid and rid:
            request.app.state.dismissed_pairs.add(frozenset({cid, rid}))
    return {"ok": True, "dismissed_count": len(pairs)}


# --------------------------------------------------------------------------- #
# SP14: Payer-Rejected acknowledge
#
# Operator hits "Acknowledge" on the Payer-Rejected Inbox lane to clear
# the claim from the working surface. We don't delete the rejection
# (the original payer_rejected_* fields stay for SP11 audit), we just
# set payer_rejected_acknowledged_at so the lane query filters it out.
#
# Idempotent: re-acknowledging an already-acknowledged claim is a noop
# (the timestamp is not bumped). Returns the count actually transitioned
# so the UI can show "3 of 5 were already acknowledged".
# --------------------------------------------------------------------------- #
@router.post("/api/inbox/payer-rejected/acknowledge")
def inbox_acknowledge_payer_rejected(body: dict):
    """Mark Payer-Rejected claims as acknowledged by the operator."""
    claim_ids = body.get("claim_ids") or []
    actor = body.get("actor") or "operator"
    if not isinstance(claim_ids, list) or not claim_ids:
        raise HTTPException(400, "claim_ids must be a non-empty list")
    if not all(isinstance(c, str) for c in claim_ids):
        raise HTTPException(400, "claim_ids must be a list of strings")

    with db.SessionLocal()() as session:
        from cyclone.db import Claim
        now = datetime.now(timezone.utc)
        transitioned = 0
        already_acked = 0
        not_found = 0
        not_rejected = 0
        for cid in claim_ids:
            claim = session.get(Claim, cid)
            if claim is None:
                not_found += 1
                continue
            if claim.payer_rejected_at is None:
                not_rejected += 1
                continue
            if claim.payer_rejected_acknowledged_at is not None:
                already_acked += 1
                continue
            claim.payer_rejected_acknowledged_at = now
            claim.payer_rejected_acknowledged_actor = actor
            transitioned += 1
            # SP11: audit event for the acknowledge action.
            try:
                from cyclone.audit_log import append_event, AuditEvent
                append_event(session, AuditEvent(
                    event_type="claim.payer_rejected_acknowledged",
                    entity_type="claim",
                    entity_id=claim.id,
                    actor=actor,
                    payload={
                        "payer_rejected_status_code": claim.payer_rejected_status_code,
                        "payer_rejected_by_277ca_id": claim.payer_rejected_by_277ca_id,
                    },
                ))
            except Exception:  # noqa: BLE001
                # Audit append is best-effort; don't block the operator's
                # acknowledge action on an audit-log failure.
                pass
        if transitioned:
            session.commit()

    return {
        "ok": True,
        "transitioned": transitioned,
        "already_acked": already_acked,
        "not_found": not_found,
        "not_rejected": not_rejected,
    }


@router.post("/api/inbox/rejected/resubmit")
def inbox_resubmit_rejected(
    request: Request,
    body: dict,
    download: bool = Query(False, description="When true, return a ZIP of regenerated 837 files for the resubmitted claims (instead of JSON)."),
):
    """Bulk move REJECTED claims back to SUBMITTED.

    With ``?download=true``, the response is a ``application/zip`` archive
    containing one ``claim-{id}.x12`` per successfully resubmitted claim
    (regenerated via ``serialize_837_for_resubmit`` so each file gets a
    unique interchange/group control number). Conflicts are omitted from
    the ZIP — they remain visible to the caller via the JSON shape of the
    non-download path. Empty resubmit + download → 200 with an empty zip
    so the UI can still hand the user a downloadable artifact.
    """
    ids = body.get("claim_ids") or []
    if not ids:
        raise HTTPException(400, "claim_ids required")
    accepted: list[str] = []
    conflicts: list[dict] = []
    # Track which claims are about to be resubmitted (and their index in
    # the bundle) so the download path can serialize them with unique
    # control numbers — back-to-back resubmits in the same file would
    # otherwise all share ISA13/GS06 = "000000001".
    accepted_with_rows: list[tuple[str, "Claim"]] = []
    with db.SessionLocal()() as s:
        for cid in ids:
            c = s.get(Claim, cid)
            if c is None:
                continue
            if c.state != ClaimState.REJECTED:
                conflicts.append({
                    "claim_id": cid,
                    "current_state": (
                        c.state.value if hasattr(c.state, "value")
                        else str(c.state)
                    ),
                })
                continue
            c.state = ClaimState.SUBMITTED
            c.state_changed_at = datetime.now(timezone.utc)
            c.rejection_reason = None
            c.rejected_at = None
            c.resubmit_count = (c.resubmit_count or 0) + 1
            accepted.append(cid)
            accepted_with_rows.append((cid, c))
        s.commit()

    if not download:
        return {"ok": True, "resubmitted": accepted, "conflicts": conflicts}

    # Build a ZIP of regenerated 837s for the accepted claims. Conflicts
    # and missing ids are deliberately excluded — the user already saw
    # them in the JSON response on prior actions; the download is the
    # "give me the files I asked for" payload.
    import zipfile
    buf = io.BytesIO()
    serialize_errors: list[dict] = []
    with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
        for idx, (cid, c) in enumerate(accepted_with_rows, start=1):
            if not c.raw_json:
                serialize_errors.append({"claim_id": cid, "reason": "no raw_json"})
                continue
            try:
                claim_obj = ClaimOutput.model_validate(c.raw_json)
            except Exception as exc:
                serialize_errors.append({"claim_id": cid, "reason": f"raw_json invalid: {exc}"})
                continue
            try:
                text = serialize_837_for_resubmit(claim_obj, interchange_index=idx)
            except SerializeError837 as exc:
                serialize_errors.append({"claim_id": cid, "reason": str(exc)})
                continue
            zf.writestr(f"claim-{cid}.x12", text)
    buf.seek(0)
    headers = {
        "Content-Disposition": (
            f'attachment; filename="resubmit-{len(accepted)}-claims.zip"'
        ),
    }
    # Surface per-claim serialization failures as a custom response header
    # so the UI can show "10 resubmitted, 2 couldn't be regenerated" without
    # parsing the binary. The header value is JSON-encoded; the UI is
    # expected to JSON.parse it after a fetch with response.ok.
    if serialize_errors:
        headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors)
    return Response(
        content=buf.getvalue(),
        media_type="application/zip",
        headers=headers,
    )


@router.get("/api/inbox/export.csv")
def inbox_export_csv(lane: str, request: Request):
    """Stream a CSV for a single lane.

    Uses ``request.app.state`` rather than the module-level ``app``
    global so the endpoint is robust against ``importlib.reload`` of
    this module (see ``inbox_dismiss_candidates`` for context).
    """
    if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
        raise HTTPException(400, f"unknown lane: {lane}")
    dismissed_pairs = getattr(request.app.state, "dismissed_pairs", set())
    with db.SessionLocal()() as session:
        from cyclone.inbox_lanes import compute_lanes
        lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
    rows = getattr(lanes, lane)

    buf = io.StringIO()
    writer = csv.writer(buf)
    writer.writerow([
        "id", "kind", "patient_control_number", "charge_amount",
        "payer_id", "provider_npi", "state", "rejection_reason",
        "service_date", "score",
    ])
    for r in rows:
        writer.writerow([
            r.get("id") or r.get("payer_claim_control_number"),
            r.get("kind"),
            r.get("patient_control_number"),
            r.get("charge_amount"),
            r.get("payer_id"),
            r.get("provider_npi") or r.get("rendering_provider_npi"),
            r.get("state"),
            r.get("rejection_reason"),
            r.get("service_date_from") or r.get("service_date"),
            r.get("score"),
        ])
    buf.seek(0)
    return StreamingResponse(
        iter([buf.getvalue()]),
        media_type="text/csv",
        headers={"Content-Disposition": f'attachment; filename="inbox-{lane}.csv"'},
    )
