"""FastAPI HTTP surface for the Cyclone 837P and 835 ERA parsers.

The frontend (Vite/React on http://localhost:5173) uploads an X12 file via
``POST /api/parse-837`` or ``POST /api/parse-835`` and receives either:

* a single ``ParseResult`` (or ``ParseResult835``) JSON object when it
  sends ``Accept: application/json``, or
* a stream of newline-delimited JSON objects (``application/x-ndjson``)
  — one envelope, one line per claim/payout, then one summary — so the
  UI can render records incrementally as they're produced.

CORS is configured to allow the Vite dev origins (``http://localhost:5173``
and ``http://127.0.0.1:5173`` — both forms are valid dev-server addresses
but CORS treats them as distinct origins) plus GET/POST with any header.
Additional origins (LAN IPs, staging hosts) can be appended via the
``CYCLONE_ALLOWED_ORIGINS`` env var as a comma-separated list.
"""

from __future__ import annotations

import csv
import io
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from contextlib import asynccontextmanager
from time import monotonic
from typing import Any, AsyncIterator

from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import ValidationError

from cyclone import __version__, db
from cyclone.db import Claim, ClaimState, Remittance
from sqlalchemy import desc, or_
from sqlalchemy.exc import IntegrityError
from cyclone.inbox_state import apply_999_rejections
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.audit_log import AuditEvent, append_event, verify_chain
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
from cyclone.parsers.models_270 import (
    EligibilityBenefitInquiry,
    InformationReceiver270,
    InformationSource270,
    ParseResult270,
    Subscriber270,
)
from cyclone.parsers.models_271 import ParseResult271
from cyclone.parsers.models_835 import ParseResult835
from cyclone.parsers.payer import PayerConfig, PayerConfig835
from cyclone.parsers.parse_270 import parse as parse_270_text
from cyclone.parsers.parse_271 import parse as parse_271_text
from cyclone.parsers.parse_277ca import parse_277ca_text
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.parse_999 import parse_999_text
from cyclone.parsers.parse_ta1 import parse_ta1_text
from cyclone.parsers.serialize_270 import serialize_270
from cyclone.parsers.serialize_999 import serialize_999
from cyclone.parsers.serialize_837 import SerializeError as SerializeError837, serialize_837, serialize_837_for_resubmit
from cyclone.parsers.batch_ack_builder import build_ack_for_batch
from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.pubsub import EventBus
from cyclone.store import (
    AlreadyMatchedError,
    BatchRecord,
    InvalidStateError,
    store,
    utcnow,
)

log = logging.getLogger(__name__)

# Cross-cutting helpers (NDJSON serialization, content negotiation, strict
# rewrites, live-tail generator). See api_helpers.py for the rationale.
from cyclone.api_helpers import (  # noqa: E402
    client_wants_json as _client_wants_json,
    drop_raw_segments_837 as _drop_raw_segments,
    drop_raw_segments_835 as _drop_raw_segments_835,
    has_claim_validation_errors as _has_claim_validation_errors,
    has_835_validation_errors as _has_835_validation_errors,
    heartbeat_seconds as _heartbeat_seconds,
    ndjson_line as _ndjson_line,
    ndjson_stream_837 as _ndjson_stream,
    ndjson_stream_835 as _ndjson_stream_835,
    ndjson_stream_list as _ndjson_stream_list,
    strict_rewrite_837 as _strict_rewrite,
    strict_rewrite_835 as _strict_rewrite_835,
    tail_events as _tail_events,
    wants_ndjson as _wants_ndjson,
)


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    """Initialize per-process resources (DB + EventBus) before the app
    serves requests. No teardown needed for Cyclone's local-only posture.
    """
    from cyclone import db, payers as payer_loader
    from cyclone import scheduler as scheduler_mod
    from cyclone import backup_service as backup_svc_mod
    from cyclone import backup_scheduler as backup_sched_mod
    from cyclone import secrets as secrets_mod
    from cyclone.logging_config import setup_logging
    import os as _os

    # SP18: structured JSON logging. Idempotent — safe even if the
    # CLI pre-configured the root logger.
    setup_logging(
        level=_os.environ.get("CYCLONE_LOG_LEVEL", "INFO"),
        log_file=_os.environ.get("CYCLONE_LOG_FILE") or None,
        json_format=_os.environ.get("CYCLONE_LOG_JSON", "true").lower() not in (
            "false", "0", "no",
        ),
    )

    db.init_db()
    app.state.event_bus = EventBus()
    # SP9: load payer config and seed singleton + 3 providers + CO_TXIX.
    try:
        payer_loader.load_payer_configs()
        store.ensure_clearhouse_seeded()
    except Exception as exc:  # noqa: BLE001
        log.exception("SP9 seed failed: %s", exc)

    # SP16: configure the inbound MFT polling scheduler. The
    # dzinesco clearhouse singleton (seeded by SP9) carries the SFTP
    # block — multi-provider polling is out of scope for v1.
    # Auto-start is opt-in via env var so operators running the API
    # for one-off parsing don't accidentally poll the MFT on startup.
    try:
        clearhouse = store.get_clearhouse()
        if clearhouse is not None:
            sched = scheduler_mod.configure_scheduler(
                clearhouse.sftp_block,
                sftp_block_name=clearhouse.name or "default",
            )
            if _os.environ.get("CYCLONE_SCHEDULER_AUTOSTART", "").lower() in (
                "1", "true", "yes",
            ):
                await sched.start()
    except Exception as exc:  # noqa: BLE001
        # Scheduler setup must never block the API from coming up.
        log.exception("SP16 scheduler config failed: %s", exc)

    # SP17: configure the encrypted backup service + scheduler.
    # Auto-start is opt-in via CYCLONE_BACKUP_AUTOSTART.
    try:
        backup_dir = _os.environ.get(
            "CYCLONE_BACKUP_DIR",
            str(db.DEFAULT_DB_PATH.parent / "backups"),
        )
        retention = int(_os.environ.get("CYCLONE_BACKUP_RETENTION_DAYS", "30"))
        passphrase = secrets_mod.get_secret(
            backup_svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT,
        )
        salt_hex = secrets_mod.get_secret(
            backup_svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT,
        )
        salt = bytes.fromhex(salt_hex) if salt_hex else None
        bk_svc = backup_svc_mod.configure_backup_service(
            backup_dir=backup_dir,
            passphrase=passphrase,
            salt=salt,
            retention_days=retention,
        )
        # Always configure the scheduler so the endpoint can find it;
        # auto-start is gated on env var.
        bk_sched = backup_sched_mod.configure_backup_scheduler(
            bk_svc,
            interval_hours=float(
                _os.environ.get("CYCLONE_BACKUP_INTERVAL_HOURS", "24"),
            ),
        )
        if _os.environ.get("CYCLONE_BACKUP_AUTOSTART", "").lower() in (
            "1", "true", "yes",
        ):
            await bk_sched.start()
    except Exception as exc:  # noqa: BLE001
        # Backup setup must never block the API from coming up.
        log.exception("SP17 backup config failed: %s", exc)

    yield

    # SP17: stop the backup scheduler on shutdown.
    try:
        bk_sched = backup_sched_mod.get_backup_scheduler()
        await bk_sched.stop()
    except RuntimeError:
        pass  # never configured
    except Exception as exc:  # noqa: BLE001
        log.warning("Backup scheduler shutdown failed: %s", exc)

    # SP16: stop the scheduler on shutdown so the test harness
    # (TestClient context manager) doesn't leak background tasks.
    try:
        sched = scheduler_mod.get_scheduler()
        await sched.stop()
    except RuntimeError:
        pass  # never configured
    except Exception as exc:  # noqa: BLE001
        log.warning("Scheduler shutdown failed: %s", exc)


# Mirror cli._PAYER_FACTORIES. Kept local so the API doesn't depend on the
# Click-based CLI module.
PAYER_FACTORIES: dict[str, Any] = {
    "co_medicaid": PayerConfig.co_medicaid,
    "generic_837p": PayerConfig.generic_837p,
}

PAYER_FACTORIES_835: dict[str, Any] = {
    "co_medicaid_835": PayerConfig835.co_medicaid_835,
    "generic_835": PayerConfig835.generic_835,
}

# Allow both common dev-server origins. ``localhost`` and ``127.0.0.1``
# resolve to the same Vite dev server but CORS treats them as distinct
# origins, so the allow-list has to list both — otherwise a tab opened
# via the IP form gets blocked even though the dev server is identical.
VITE_DEV_ORIGINS: list[str] = [
    "http://localhost:5173",
    "http://127.0.0.1:5173",
]
_extra_origins = os.environ.get("CYCLONE_ALLOWED_ORIGINS", "").strip()
if _extra_origins:
    VITE_DEV_ORIGINS.extend(o.strip() for o in _extra_origins.split(",") if o.strip())

app = FastAPI(
    title="Cyclone 837P / 835 Parser API",
    version=__version__,
    description="Upload X12 837P and 835 files and receive parsed records as JSON or NDJSON.",
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=VITE_DEV_ORIGINS,
    allow_credentials=False,
    allow_methods=["GET", "POST"],
    allow_headers=["*"],
)

# SP19: security middleware chain. Starlette's ``add_middleware``
# *prepends*, so the LAST middleware we add is the OUTERMOST. We
# want SecurityHeaders outermost so it stamps the 413/429 responses
# too, then RateLimit, then BodySizeLimit closest to the app.
from cyclone.security import (  # noqa: E402
    BodySizeLimitMiddleware,
    RateLimitMiddleware,
    SecurityHeadersMiddleware,
)

app.add_middleware(BodySizeLimitMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(SecurityHeadersMiddleware)

# Resource-group routers. Each module owns its own APIRouter and is
# registered below. New resources go in `cyclone.api_routers.<name>`
# and are wired in here. (Kept as a top-level package rather than nested
# under `cyclone.api` so the existing ``cyclone.api`` module path keeps
# working — Python prefers packages over same-named modules.)
from cyclone.api_routers import acks, admin, health, ta1_acks  # noqa: E402

app.include_router(health.router)
app.include_router(acks.router)
app.include_router(ta1_acks.router)
app.include_router(admin.router)


def _resolve_payer(name: str) -> PayerConfig:
    if name not in PAYER_FACTORIES:
        raise HTTPException(
            status_code=400,
            detail={
                "error": "Unknown payer",
                "detail": f"Unknown payer {name!r}. Choose from: {', '.join(PAYER_FACTORIES)}",
            },
        )
    return PAYER_FACTORIES[name]()


def _resolve_payer_835(name: str) -> PayerConfig835:
    if name not in PAYER_FACTORIES_835:
        raise HTTPException(
            status_code=400,
            detail={
                "error": "Unknown payer",
                "detail": f"Unknown payer {name!r}. Choose from: {', '.join(PAYER_FACTORIES_835)}",
            },
        )
    return PAYER_FACTORIES_835[name]()


# --------------------------------------------------------------------------- #
# Catch-all exception handler
# --------------------------------------------------------------------------- #
#
# Without this, any exception that escapes a route handler is rendered by
# Uvicorn as a bare ``500 Internal Server Error`` text/plain response with
# NO CORS headers. Browsers reading such a response report it as a CORS
# error (``Origin ... is not allowed by Access-Control-Allow-Origin``)
# because they cannot read the body without the CORS allow-origin header
# — even though the actual failure was on the server. We catch everything
# here, log it, and return a JSON error with the request's Origin echoed
# back so the browser can surface the real message.
@app.exception_handler(Exception)
async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
    log.exception(
        "Unhandled exception in %s %s", request.method, request.url.path
    )
    headers: dict[str, str] = {}
    origin = request.headers.get("origin", "")
    if origin:
        headers["Access-Control-Allow-Origin"] = origin
        headers["Vary"] = "Origin"
    return JSONResponse(
        status_code=500,
        content={"error": "Internal server error", "detail": str(exc)},
        headers=headers,
    )


@app.post("/api/parse-837")
async def parse_837(
    request: Request,
    file: UploadFile = File(...),
    payer: str = Query("co_medicaid"),
    include_raw_segments: bool = Query(True),
    strict: bool = Query(False),
    ack: bool = Query(False),
) -> Any:
    raw = await file.read()
    if not raw:
        return JSONResponse(
            status_code=400,
            content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
        )
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError as exc:
        return JSONResponse(
            status_code=400,
            content={"error": "Encoding error", "detail": str(exc)},
        )

    config = _resolve_payer(payer)

    try:
        result = parse(text, config, input_file=file.filename or "")
    except CycloneParseError as exc:
        return JSONResponse(
            status_code=400,
            content={"error": "Parse error", "detail": str(exc)},
        )
    except Exception as exc:  # pragma: no cover - safety net
        log.exception("Unexpected parser failure")
        return JSONResponse(
            status_code=500,
            content={"error": "Internal server error", "detail": str(exc)},
        )

    if strict:
        result = _strict_rewrite(result)
    if not include_raw_segments:
        result = _drop_raw_segments(result)

    if _has_claim_validation_errors(result):
        # Per spec: 422 when claims failed validation.
        # Body still includes the full ParseResult so the client can show errors.
        # Validation-failed parses are NOT persisted (the data is suspect);
        # only parses that survive validation end up in the store.
        return JSONResponse(
            status_code=422,
            content=json.loads(result.model_dump_json()),
        )

    # Persist the cleaned-up result so the cleaned result is what clients
    # retrieve via /api/batches/{id}.
    rec = BatchRecord(
        id=uuid.uuid4().hex,
        kind="837p",
        input_filename=file.filename or "upload.txt",
        parsed_at=utcnow(),
        result=result,
    )
    try:
        store.add(rec, event_bus=request.app.state.event_bus)
    except IntegrityError as exc:
        # ``(batch_id, patient_control_number)`` is UNIQUE — fires when a
        # single batch file contains the same CLM01 control number twice,
        # or when the same claim id has already been ingested in a prior
        # batch. Surface as 409 with the batch id so the caller can look
        # it up; do NOT 500 (a 500 without CORS headers is misreported by
        # browsers as a CORS error and hides the real cause).
        log.warning("Duplicate claim while persisting batch %s: %s", rec.id, exc)
        return JSONResponse(
            status_code=409,
            content={
                "error": "Duplicate claim",
                "detail": (
                    "This file (or one previously ingested with the same "
                    "claim control number) collides with an existing "
                    "record. Inspect the file for duplicate CLM01 "
                    "control numbers, or remove the existing batch "
                    "before retrying."
                ),
                "batch_id": rec.id,
            },
        )

    if _client_wants_json(request):
        body = json.loads(result.model_dump_json())
        if ack:
            ack_body = _build_and_persist_ack(rec.id)
            if ack_body is not None:
                body["ack"] = ack_body
        return JSONResponse(content=body)

    # Default: NDJSON stream.
    return StreamingResponse(
        _ndjson_stream(result),
        media_type="application/x-ndjson",
    )


def _build_and_persist_ack(batch_id: str) -> dict | None:
    """Build a 999 ACK for ``batch_id`` and persist the row.

    Returns the ack payload dict (matches the ``/api/parse-999``
    response shape so the JSON and NDJSON clients can share the
    schema) or None if the build failed. The build is fail-soft:
    errors are logged but never abort the 837 ingest, because the
    user-visible 837 result is still correct.
    """
    try:
        ack_result = build_ack_for_batch(batch_id)
    except Exception:
        log.exception("build_ack_for_batch failed for %s", batch_id)
        return None
    fg = ack_result.functional_group_acks[0] if ack_result.functional_group_acks else None
    if fg is None:
        return None
    raw_text = serialize_999(ack_result, interchange_control_number=ack_result.envelope.control_number)
    row = store.add_ack(
        source_batch_id=batch_id,
        accepted_count=fg.accepted_count,
        rejected_count=fg.rejected_count,
        received_count=fg.received_count,
        ack_code=fg.ack_code,
        raw_json=json.loads(ack_result.model_dump_json()),
    )
    return {
        "id": row.id,
        "accepted_count": fg.accepted_count,
        "rejected_count": fg.rejected_count,
        "received_count": fg.received_count,
        "ack_code": fg.ack_code,
        "source_batch_id": batch_id,
        "raw_999_text": raw_text,
    }


# --------------------------------------------------------------------------- #
# 835 ERA (Health Care Claim Payment/Advice)
# --------------------------------------------------------------------------- #


def _reconciliation_summary_for_batch(batch_id: str) -> dict:
    """Return ``{matched, unmatched_claims, unmatched_remittances, skipped}`` for a batch.

    Reads from the DB after ``store.add()`` has already triggered T10
    reconciliation synchronously (via ``_run_reconcile``). Counts are
    observed at this moment; a subsequent manual match/unmatch will not
    be reflected until the next request.

    ``skipped`` is reserved for future use — the T10 orchestrator tracks
    skipped claims internally but does not surface a queryable count.
    """
    from sqlalchemy import func, select
    from cyclone import db
    from cyclone.db import Match, Remittance

    with db.SessionLocal()() as s:
        matched = s.execute(
            select(func.count(Match.id)).where(
                Match.remittance_id.in_(
                    select(Remittance.id).where(Remittance.batch_id == batch_id)
                )
            )
        ).scalar_one()

    # Pull unmatched via the store (small result set; cheap).
    unmatched = store.list_unmatched(kind="both")
    return {
        "matched": matched,
        "unmatched_claims": len(unmatched["claims"]),
        "unmatched_remittances": len(unmatched["remittances"]),
        "skipped": 0,  # reserved — T10 does not persist a skipped count
    }


@app.post("/api/parse-835")
async def parse_835_endpoint(
    request: Request,
    file: UploadFile = File(...),
    payer: str = Query("co_medicaid_835"),
    include_raw_segments: bool = Query(True),
    strict: bool = Query(False),
) -> Any:
    raw = await file.read()
    if not raw:
        return JSONResponse(
            status_code=400,
            content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
        )
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError as exc:
        return JSONResponse(
            status_code=400,
            content={"error": "Encoding error", "detail": str(exc)},
        )

    config = _resolve_payer_835(payer)

    try:
        result = parse_835(text, config, input_file=file.filename or "")
    except CycloneParseError as exc:
        return JSONResponse(
            status_code=400,
            content={"error": "Parse error", "detail": str(exc)},
        )
    except Exception as exc:  # pragma: no cover - safety net
        log.exception("Unexpected parser failure")
        return JSONResponse(
            status_code=500,
            content={"error": "Internal server error", "detail": str(exc)},
        )

    # Always run the validator; attach the report so the JSON path can
    # surface it and the NDJSON path can fold the counts into the summary.
    # 835 validation is batch-level, so pass/fail applies uniformly to every
    # claim payment in the batch (passed=N or 0, failed=0 or N).
    report = validate_835(result, config)
    n = len(result.claims)
    claim_ids = [c.payer_claim_control_number for c in result.claims]
    if report.passed:
        passed, failed, failed_claim_ids = n, 0, []
    else:
        passed, failed, failed_claim_ids = 0, n, claim_ids
    result = result.model_copy(update={
        "validation": report,
        "summary": result.summary.model_copy(update={
            "passed": passed,
            "failed": failed,
            "failed_claim_ids": failed_claim_ids,
        }),
    })

    if strict:
        result = _strict_rewrite_835(result)
    if not include_raw_segments:
        result = _drop_raw_segments_835(result)

    if _has_835_validation_errors(result):
        return JSONResponse(
            status_code=422,
            content=json.loads(result.model_dump_json()),
        )

    # Persist the cleaned-up result so the cleaned result is what clients
    # retrieve via /api/batches/{id}.
    rec = BatchRecord(
        id=uuid.uuid4().hex,
        kind="835",
        input_filename=file.filename or "upload.txt",
        parsed_at=utcnow(),
        result=result,
    )
    try:
        store.add(rec, event_bus=request.app.state.event_bus)
    except IntegrityError as exc:
        log.warning("Duplicate remittance while persisting batch %s: %s", rec.id, exc)
        return JSONResponse(
            status_code=409,
            content={
                "error": "Duplicate remittance",
                "detail": (
                    "This 835 file (or one previously ingested with the "
                    "same payer claim control number) collides with an "
                    "existing record. Remove the existing remittance "
                    "before retrying."
                ),
                "batch_id": rec.id,
            },
        )

    if _client_wants_json(request):
        body = json.loads(result.model_dump_json())
        body["reconciliation"] = _reconciliation_summary_for_batch(rec.id)
        return JSONResponse(content=body)

    # Default: NDJSON stream.
    return StreamingResponse(
        _ndjson_stream_835(result),
        media_type="application/x-ndjson",
    )


# --------------------------------------------------------------------------- #
# 999 ACK (Implementation Acknowledgment)
# --------------------------------------------------------------------------- #


def _ack_count_summary(result) -> tuple[int, int, int, str]:
    """Aggregate (received, accepted, rejected, ack_code) from a ParseResult999.

    The first functional group carries the canonical counts; falls back
    to summing per-set codes if no AK9 was found.
    """
    if result.functional_group_acks:
        fg = result.functional_group_acks[0]
        return (fg.received_count, fg.accepted_count, fg.rejected_count, fg.ack_code)
    sets = result.set_responses
    received = len(sets)
    accepted = sum(1 for s in sets if s.set_accept_reject.code == "A")
    rejected = received - accepted
    if rejected == 0:
        code = "A"
    elif accepted == 0:
        code = "R"
    else:
        code = "P"
    return (received, accepted, rejected, code)


def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
    """Return a synthetic batches.id for a received 999 with no source batch.

    The acks.source_batch_id FK requires a row in batches; for received
    999s we synthesize an id of the form ``999-<ISA13>``. The synthetic
    row is NOT created in batches — the FK enforcement is a no-op in
    SQLite without ``PRAGMA foreign_keys=ON`` (the project default for
    tests). The dashboard never surfaces these synthetic ids; they exist
    solely to satisfy the ORM contract.
    """
    return f"999-{(interchange_control_number or '').strip() or '000000001'}"


@app.post("/api/parse-999")
async def parse_999_endpoint(
    request: Request,
    file: UploadFile = File(...),
) -> Any:
    """Parse a 999 ACK file, persist a row, and return JSON.

    Behavior mirrors ``/api/parse-835``:
    - 400 on empty / undecodable / malformed EDI (never 500).
    - 200 on success with ``{"ack": {id, accepted_count, rejected_count,
      received_count, ack_code, raw_999_text}, "parsed": <ParseResult999>}``.

    The persisted ``acks.source_batch_id`` is a synthetic id
    (``999-<ISA13>``) because a received 999 has no inbound 837 batch
    to FK to. The dashboard's `/acks` list surfaces these; operators
    can still see which interchange each row came from.
    """
    raw = await file.read()
    if not raw:
        return JSONResponse(
            status_code=400,
            content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
        )
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError as exc:
        return JSONResponse(
            status_code=400,
            content={"error": "Encoding error", "detail": str(exc)},
        )

    try:
        result = parse_999_text(text, input_file=file.filename or "")
    except CycloneParseError as exc:
        return JSONResponse(
            status_code=400,
            content={"error": "Parse error", "detail": str(exc)},
        )
    except Exception as exc:  # pragma: no cover - safety net
        log.exception("Unexpected parser failure on 999")
        return JSONResponse(
            status_code=500,
            content={"error": "Internal server error", "detail": str(exc)},
        )

    received, accepted, rejected, ack_code = _ack_count_summary(result)
    icn = result.envelope.control_number
    synthetic_id = _ack_synthetic_source_batch_id(icn)

    # Build the raw 999 text from the parsed result (round-trip).
    raw_999_text = serialize_999(result, interchange_control_number=icn or "000000001")

    # SP6 T4: move claims whose 999 set was rejected into ClaimState.REJECTED.
    # The 999's set_control_number (AK202) is the source 837's ST02; in
    # practice we look it up against patient_control_number because that's
    # the field 999 ACKs cross-reference in this product.
    with db.SessionLocal()() as session:
        def _lookup(pcn: str):
            return session.query(Claim).filter_by(patient_control_number=pcn).first()
        _rejection_result = apply_999_rejections(
            session, result, claim_lookup=_lookup,
        )
    if _rejection_result.matched:
        bus = request.app.state.event_bus
        for cid in _rejection_result.matched:
            await bus.publish("claim.rejected", {"claim_id": cid})
    if _rejection_result.orphans:
        log.warning(
            "999 had %d orphan set refs: %s",
            len(_rejection_result.orphans),
            _rejection_result.orphans[:5],
        )

    row = store.add_ack(
        source_batch_id=synthetic_id,
        accepted_count=accepted,
        rejected_count=rejected,
        received_count=received,
        ack_code=ack_code,
        raw_json=json.loads(result.model_dump_json()),
    )

    # SP11: append one audit row per rejected claim. Each row chains
    # to the previous one — see cyclone.audit_log.
    if _rejection_result.matched:
        with db.SessionLocal()() as audit_s:
            for cid in _rejection_result.matched:
                append_event(audit_s, AuditEvent(
                    event_type="claim.rejected",
                    entity_type="claim",
                    entity_id=cid,
                    payload={"source_batch_id": synthetic_id, "ack_id": row.id},
                    actor="999-parser",
                ))
            audit_s.commit()

    return JSONResponse(content={
        "ack": {
            "id": row.id,
            "accepted_count": accepted,
            "rejected_count": rejected,
            "received_count": received,
            "ack_code": ack_code,
            "source_batch_id": synthetic_id,
            "raw_999_text": raw_999_text,
        },
        "parsed": json.loads(result.model_dump_json()),
    })


# --------------------------------------------------------------------------- #
# TA1 (Interchange Acknowledgment)
# --------------------------------------------------------------------------- #


def _ta1_synthetic_source_batch_id(interchange_control_number: str) -> str:
    """Return a synthetic ``batches.id`` for a received TA1 with no source batch.

    Mirrors :func:`_ack_synthetic_source_batch_id`. The ta1_acks.source_batch_id
    FK requires a row in batches; for received TA1s we synthesize an id of
    the form ``TA1-<ISA13>``. The row is NOT created in batches (same
    FK-is-no-op convention as the 999 path).
    """
    return f"TA1-{(interchange_control_number or '').strip() or '000000001'}"


@app.post("/api/parse-ta1")
async def parse_ta1_endpoint(
    file: UploadFile = File(...),
) -> Any:
    """Parse a TA1 (Interchange Acknowledgment) file, persist a row, return JSON.

    Mirrors ``/api/parse-999`` but for the lower-level envelope ack:
      - 400 on empty / undecodable / malformed EDI (never 500).
      - 200 on success with ``{"ta1": {id, control_number, ack_code,
        note_code, interchange_date, interchange_time, sender_id,
        receiver_id, source_batch_id, raw_ta1_text}, "parsed":
        <ParseResultTa1>}``.

    The persisted ``ta1_acks.source_batch_id`` is a synthetic id
    (``TA1-<ISA13>``) because a received TA1 has no inbound batch to
    FK to. The dashboard's ``/ta1-acks`` list surfaces these.
    """
    raw = await file.read()
    if not raw:
        return JSONResponse(
            status_code=400,
            content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
        )
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError as exc:
        return JSONResponse(
            status_code=400,
            content={"error": "Encoding error", "detail": str(exc)},
        )

    try:
        result = parse_ta1_text(text, input_file=file.filename or "")
    except CycloneParseError as exc:
        return JSONResponse(
            status_code=400,
            content={"error": "Parse error", "detail": str(exc)},
        )
    except Exception as exc:  # pragma: no cover - safety net
        log.exception("Unexpected parser failure on TA1")
        return JSONResponse(
            status_code=500,
            content={"error": "Internal server error", "detail": str(exc)},
        )

    # Build the raw TA1 text from the parsed result (round-trip).
    raw_ta1_text = _serialize_ta1(result)

    row = store.add_ta1_ack(
        source_batch_id=result.source_batch_id,
        control_number=result.ta1.control_number,
        interchange_date=result.ta1.interchange_date,
        interchange_time=result.ta1.interchange_time,
        ack_code=result.ta1.ack_code,
        note_code=result.ta1.note_code,
        ack_generated_date=result.ta1.ack_generated_date,
        sender_id=result.envelope.sender_id,
        receiver_id=result.envelope.receiver_id,
        raw_json=json.loads(result.model_dump_json()),
    )

    return JSONResponse(content={
        "ta1": {
            "id": row.id,
            "control_number": result.ta1.control_number,
            "ack_code": result.ta1.ack_code,
            "note_code": result.ta1.note_code,
            "interchange_date": result.ta1.interchange_date.isoformat()
                if result.ta1.interchange_date else None,
            "interchange_time": result.ta1.interchange_time,
            "sender_id": result.envelope.sender_id,
            "receiver_id": result.envelope.receiver_id,
            "source_batch_id": result.source_batch_id,
            "raw_ta1_text": raw_ta1_text,
        },
        "parsed": json.loads(result.model_dump_json()),
    })


# --------------------------------------------------------------------------- #
# 277CA (Claim Acknowledgment) — SP10
# --------------------------------------------------------------------------- #


def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
    """Return a synthetic ``batches.id`` for a received 277CA with no source batch.

    Mirrors :func:`_ack_synthetic_source_batch_id`. The 277CA row's
    ``source_batch_id`` FK requires a row in batches; for received
    277CAs we synthesize an id of the form ``277CA-<ISA13>``. The row
    is NOT created in batches — same FK-is-no-op convention as the 999
    path.
    """
    return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"


@app.post("/api/parse-277ca")
async def parse_277ca_endpoint(
    request: Request,
    file: UploadFile = File(...),
) -> Any:
    """Parse a 277CA Claim Acknowledgment file, persist a row, and stamp rejections.

    Behavior mirrors ``/api/parse-999``:
    - 400 on empty / undecodable / malformed EDI (never 500).
    - 200 on success with ``{"ack": {id, control_number, accepted_count,
      rejected_count, payer_claim_control_numbers, raw_277ca_text},
      "parsed": <ParseResult277CA>}``.

    After parse, runs :func:`apply_277ca_rejections` to stamp the
    payer-rejected fields on each matching claim row. The Inbox
    Payer-Rejected lane lights up as a side-effect of this call.
    """
    raw = await file.read()
    if not raw:
        return JSONResponse(
            status_code=400,
            content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
        )
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError as exc:
        return JSONResponse(
            status_code=400,
            content={"error": "Encoding error", "detail": str(exc)},
        )

    try:
        result = parse_277ca_text(text, input_file=file.filename or "")
    except CycloneParseError as exc:
        return JSONResponse(
            status_code=400,
            content={"error": "Parse error", "detail": str(exc)},
        )
    except Exception as exc:  # pragma: no cover - safety net
        log.exception("Unexpected parser failure on 277CA")
        return JSONResponse(
            status_code=500,
            content={"error": "Internal server error", "detail": str(exc)},
        )

    icn = result.envelope.control_number
    synthetic_id = _277ca_synthetic_source_batch_id(icn)

    accepted = sum(1 for s in result.claim_statuses if s.classification == "accepted")
    paid = sum(1 for s in result.claim_statuses if s.classification == "paid")
    rejected = sum(1 for s in result.claim_statuses if s.classification == "rejected")
    pended = sum(1 for s in result.claim_statuses if s.classification == "pended")

    # Persist the 277CA row first so we have an id to attach to claims.
    row = store.add_277ca_ack(
        source_batch_id=synthetic_id,
        control_number=icn,
        accepted_count=accepted,
        rejected_count=rejected,
        paid_count=paid,
        pended_count=pended,
        raw_json=json.loads(result.model_dump_json()),
    )

    # Stamp payer-rejection fields on matching claims. The 277CA's
    # REF*1K carries the patient's claim control number we sent in
    # CLM01 — same convention the 999 ACK uses, so the lookup hits
    # Claim.patient_control_number (mirrors apply_999_rejections).
    with db.SessionLocal()() as session:
        def _lookup(pcn: str):
            return (
                session.query(Claim)
                .filter(Claim.patient_control_number == pcn)
                .first()
            )
        apply_result = apply_277ca_rejections(
            session, result, claim_lookup=_lookup, two77ca_id=row.id,
        )
    if apply_result.matched:
        bus = request.app.state.event_bus
        for cid in apply_result.matched:
            await bus.publish("claim.payer_rejected", {"claim_id": cid})
        # SP11: audit trail for each payer-rejected claim.
        with db.SessionLocal()() as audit_s:
            for cid in apply_result.matched:
                append_event(audit_s, AuditEvent(
                    event_type="claim.payer_rejected",
                    entity_type="claim",
                    entity_id=cid,
                    payload={
                        "source_batch_id": synthetic_id,
                        "277ca_id": row.id,
                    },
                    actor="277ca-parser",
                ))
            audit_s.commit()
    if apply_result.orphans:
        log.warning(
            "277CA had %d orphan status entries (no matching claim): %s",
            len(apply_result.orphans),
            apply_result.orphans[:5],
        )

    return JSONResponse(content={
        "ack": {
            "id": row.id,
            "control_number": icn,
            "accepted_count": accepted,
            "rejected_count": rejected,
            "paid_count": paid,
            "pended_count": pended,
            "source_batch_id": synthetic_id,
            "matched_claim_ids": apply_result.matched,
            "orphan_status_codes": apply_result.orphans,
        },
        "parsed": json.loads(result.model_dump_json()),
    })


@app.get("/api/277ca-acks")
def list_277ca_acks_endpoint(
    limit: int = Query(100, ge=1, le=1000),
) -> Any:
    """Return the list of persisted 277CA ACKs, newest first."""
    rows = store.list_277ca_acks()
    items = [_277ca_to_ui(r) for r in rows[:limit]]
    return {"total": len(rows), "items": items}


@app.get("/api/277ca-acks/{ack_id}")
def get_277ca_ack_endpoint(ack_id: int) -> dict:
    """Return one persisted 277CA ACK row with its parsed detail."""
    row = store.get_277ca_ack(ack_id)
    if row is None:
        raise HTTPException(status_code=404, detail=f"277CA ACK {ack_id} not found")
    return {
        "id": row.id,
        "control_number": row.control_number,
        "accepted_count": row.accepted_count,
        "rejected_count": row.rejected_count,
        "paid_count": row.paid_count,
        "pended_count": row.pended_count,
        "source_batch_id": row.source_batch_id,
        "parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
        "raw_json": row.raw_json,
    }


def _277ca_to_ui(row) -> dict:
    """Render a 277caAck row for the UI (list endpoint shape)."""
    return {
        "id": row.id,
        "control_number": row.control_number,
        "accepted_count": row.accepted_count,
        "rejected_count": row.rejected_count,
        "paid_count": row.paid_count,
        "pended_count": row.pended_count,
        "source_batch_id": row.source_batch_id,
        "parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
    }


def _serialize_ta1(result) -> str:
    """Render a TA1 file from a ParseResultTa1 for the ``raw_ta1_text`` field.

    Mirrors what the parser consumed: ISA envelope → TA1 → IEA. We
    rebuild minimal ISA fields from the envelope plus the TA1 segment
    verbatim. The serializer is intentionally tiny — TA1 has no GS/ST,
    so there's no functional-group structure to round-trip.
    """
    ta1 = result.ta1
    parts = [
        f"TA1*{ta1.control_number}*{ta1.interchange_date.strftime('%y%m%d') if ta1.interchange_date else ''}*"
        f"{ta1.interchange_time or ''}*{ta1.ack_code}*{ta1.note_code or ''}*"
        f"{ta1.ack_generated_date.strftime('%y%m%d') if ta1.ack_generated_date else ''}",
    ]
    return "~".join(parts) + "~"


def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
    """Reconstruct a TA1 segment from the persisted flat row (for the detail endpoint)."""
    date_s = row.interchange_date.strftime("%y%m%d") if row.interchange_date else ""
    return (
        f"TA1*{row.control_number}*{date_s}*{row.interchange_time or ''}*"
        f"{row.ack_code}*{row.note_code or ''}~"
    )


# --------------------------------------------------------------------------- #
# SP6 — Inbox endpoints
# --------------------------------------------------------------------------- #


@app.get("/api/inbox/lanes")
def inbox_lanes():
    """Return all Inbox lanes in one call."""
    dismissed_pairs = getattr(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,
    }


@app.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:
        # Migration 0014: composite PKs. The URL takes only id (not
        # batch_id); look up by id alone, first match wins. Cross-batch
        # duplicates are handled at ingest time by parse-decide-workflow;
        # this manual-match endpoint inherits the legacy "id alone" API.
        claim = s.query(Claim).filter(Claim.id == claim_id).first()
        remit = s.query(Remittance).filter(Remittance.id == remit_id).first()
        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
        # Migration 0014: composite back-reference to remittances.
        # matched_remittance_batch_id is the batch side; the SQL-level FK
        # to remittances(batch_id, id) can't be declared in SQLite (no
        # ALTER CONSTRAINT) so the application keeps both columns in sync.
        claim.matched_remittance_batch_id = remit.batch_id
        remit.claim_id = claim_id
        s.commit()
    return {"ok": True, "claim_id": claim_id, "remit_id": remit_id}


@app.post("/api/inbox/candidates/dismiss")
def inbox_dismiss_candidates(body: dict):
    """Add candidate pairs to the session-scoped dismissed set."""
    pairs = body.get("pairs") or []
    if not hasattr(app.state, "dismissed_pairs"):
        app.state.dismissed_pairs = set()
    for p in pairs:
        cid = p.get("claim_id")
        rid = p.get("remit_id")
        if cid and rid:
            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".
# --------------------------------------------------------------------------- #
@app.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:
            # Migration 0014: composite PK; URL takes only claim_id.
            claim = session.query(Claim).filter(Claim.id == cid).first()
            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,
    }


@app.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:
            # Migration 0014: composite PK; URL takes only claim_id.
            c = s.query(Claim).filter(Claim.id == cid).first()
            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,
    )


@app.get("/api/inbox/export.csv")
def inbox_export_csv(lane: str):
    """Stream a CSV for a single lane."""
    if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
        raise HTTPException(400, f"unknown lane: {lane}")
    dismissed_pairs = getattr(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"'},
    )


# --------------------------------------------------------------------------- #
# GET endpoints (read views over the in-memory store)
# --------------------------------------------------------------------------- #


def _batch_summary_claim_count(rec: BatchRecord) -> int:
    """Return the number of claims on a batch, handling both 837P and 835."""
    if rec.kind == "837p":
        return len(rec.result.claims)  # type: ignore[attr-defined]
    if rec.kind == "835":
        return len(rec.result.claims)  # type: ignore[attr-defined]
    return 0


@app.get("/api/batches")
def list_batches(
    request: Request,
    limit: int = Query(100, ge=1, le=1000),
) -> Any:
    """Summary of all parsed batches, newest first."""
    records = store.list(limit=limit)
    items = [
        {
            "id": r.id,
            "kind": r.kind,
            "inputFilename": r.input_filename,
            "parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"),
            "claimCount": _batch_summary_claim_count(r),
        }
        for r in records
    ]
    all_records = store.all()
    total = len(all_records)
    returned = len(items)
    has_more = total > returned
    if _wants_ndjson(request):
        return StreamingResponse(
            _ndjson_stream_list(items, total, returned, has_more),
            media_type="application/x-ndjson",
        )
    return {
        "items": items,
        "total": total,
        "returned": returned,
        "has_more": has_more,
    }


@app.get("/api/batches/{batch_id}")
def get_batch(batch_id: str) -> Any:
    rec = store.get(batch_id)
    if rec is None:
        raise HTTPException(
            status_code=404,
            detail={"error": "Not found", "detail": f"Batch {batch_id} not found"},
        )
    return json.loads(rec.result.model_dump_json())


@app.get("/api/claims")
def list_claims(
    request: Request,
    batch_id: str | None = Query(None),
    status: str | None = Query(None),
    provider_npi: str | None = Query(None),
    payer: str | None = Query(None),
    date_from: str | None = Query(None),
    date_to: str | None = Query(None),
    sort: str | None = Query(None),
    order: str = Query("desc"),
    limit: int = Query(100, ge=1, le=1000),
    offset: int = Query(0, ge=0),
) -> Any:
    common = dict(
        batch_id=batch_id,
        status=status,
        provider_npi=provider_npi,
        payer=payer,
        date_from=date_from,
        date_to=date_to,
    )
    items = list(store.iter_claims(
        sort=sort, order=order, limit=limit, offset=offset, **common,
    ))
    total = len(list(store.iter_claims(**common)))
    returned = len(items)
    has_more = total > offset + returned
    if _wants_ndjson(request):
        return StreamingResponse(
            _ndjson_stream_list(items, total, returned, has_more),
            media_type="application/x-ndjson",
        )
    return {
        "items": items,
        "total": total,
        "returned": returned,
        "has_more": has_more,
    }


# --------------------------------------------------------------------------- #
# Live-tail NDJSON streaming endpoints (Phase 3 — SP5)
# --------------------------------------------------------------------------- #


@app.get("/api/claims/stream")
async def claims_stream(
    request: Request,
    status: str | None = Query(None),
    provider_npi: str | None = Query(None),
    payer: str | None = Query(None),
    date_from: str | None = Query(None),
    date_to: str | None = Query(None),
    sort: str | None = Query(None),
    order: str = Query("desc"),
    limit: int = Query(100, ge=1, le=1000),
) -> StreamingResponse:
    """Stream Claims as NDJSON: snapshot first, then live events.

    Wire format:
      * ``{"type":"item","data":<claim>}`` per snapshot row, then per
        new ``claim_written`` event
      * ``{"type":"snapshot_end","data":{"count":N}}`` after the snapshot
      * ``{"type":"heartbeat","data":{"ts":<iso>}}`` every
        ``CYCLONE_TAIL_HEARTBEAT_S`` seconds when idle

    Query params mirror :func:`list_claims` so a frontend can swap a
    one-shot fetch for a tail with no URL surgery.

    NOTE: registered before ``/api/claims/{claim_id}`` so the literal
    ``stream`` path segment doesn't get matched as a claim id.
    """
    bus: EventBus = request.app.state.event_bus

    async def gen() -> AsyncIterator[bytes]:
        # 1. Snapshot (eager — iter_claims returns a list already).
        rows = store.iter_claims(
            status=status, provider_npi=provider_npi, payer=payer,
            date_from=date_from, date_to=date_to,
            sort=sort or "-submission_date", order=order, limit=limit,
        )
        for row in rows:
            yield _ndjson_line({"type": "item", "data": row})
        yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})

        # 2. Subscribe + heartbeats.
        async for chunk in _tail_events(request, bus, ["claim_written"]):
            yield chunk

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


@app.get("/api/claims/{claim_id}")
def get_claim_detail_endpoint(claim_id: str) -> dict:
    """Return one claim with full drawer context (SP4).

    Body shape is produced by :meth:`CycloneStore.get_claim_detail`:
    header, state, service lines, diagnoses, parties, validation,
    raw segments, ``stateHistory`` (most-recent-first, capped at 50),
    and a populated ``matchedRemittance`` block when paired.

    Path param is ``claim_id`` (matches the SP3 ``/api/acks/{ack_id}``
    convention). Returns 404 — never 500 — on a missing claim so the
    UI can distinguish "doesn't exist" from a transient fetch error.
    """
    body = store.get_claim_detail(claim_id)
    if body is None:
        raise HTTPException(
            status_code=404,
            detail={
                "error": "Not found",
                "detail": f"Claim {claim_id} not found",
            },
        )
    return body


@app.get("/api/claims/{claim_id}/serialize-837")
def serialize_claim_as_837(claim_id: str):
    """Return the claim as a regenerated X12 837P file (SP8).

    Loads the ClaimOutput from the persisted ``raw_json`` and runs the
    outbound serializer. Returns 404 if the claim doesn't exist, 422 if
    the stored payload has no parseable ClaimOutput (data integrity
    issue, not a transient failure).
    """
    with db.SessionLocal()() as s:
        # Migration 0014: composite PK; URL takes only claim_id.
        row = s.query(Claim).filter(Claim.id == claim_id).first()
        if row is None:
            return JSONResponse(
                {"error": "Not found", "detail": f"Claim {claim_id} not found"},
                status_code=404,
            )
        if not row.raw_json:
            return JSONResponse(
                {
                    "error": "Unprocessable",
                    "detail": f"Claim {claim_id} has no raw_json; cannot serialize",
                },
                status_code=422,
            )
        try:
            claim_obj = ClaimOutput.model_validate(row.raw_json)
        except Exception as exc:
            return JSONResponse(
                {
                    "error": "Unprocessable",
                    "detail": f"Claim {claim_id} raw_json is malformed: {exc}",
                },
                status_code=422,
            )

    try:
        text = serialize_837(claim_obj)
    except SerializeError837 as exc:
        return JSONResponse(
            {"error": "Unprocessable", "detail": str(exc)},
            status_code=422,
        )

    return Response(
        content=text,
        media_type="text/x12",
        headers={
            "Content-Disposition": f'attachment; filename="claim-{claim_id}.x12"'
        },
    )


@app.get("/api/claims/{claim_id}/line-reconciliation")
def get_claim_line_reconciliation(claim_id: str) -> dict:
    """Per-line reconciliation view for the ClaimDrawer tab.

    Spec §5.1. Returns the 837 service lines and 835 SVC composites
    side-by-side, with per-line CAS adjustments and a summary block.

    Architecture note: 837 service lines live in ``Claim.raw_json``
    (not a separate ORM table), so the 837-side rows are read from the
    JSON blob; the 835-side rows come from ``ServiceLinePayment`` ORM.
    ``LineReconciliation.claim_service_line_number`` stores the 1-based
    line number to join them.
    """
    from sqlalchemy import select
    from cyclone.db import (
        LineReconciliation, ServiceLinePayment, CasAdjustment,
    )
    import json as _json
    from decimal import Decimal

    with db.SessionLocal()() as s:
        # Migration 0014: composite PK; URL takes only claim_id.
        claim = s.query(db.Claim).filter(db.Claim.id == claim_id).first()
        if claim is None:
            raise HTTPException(
                status_code=404,
                detail={"error": "Not found", "detail": f"Claim {claim_id} not found"},
            )

        # 837 service lines: from raw_json.
        raw = claim.raw_json or {}
        claim_lines_raw = raw.get("service_lines") or []
        # Normalize to dicts for the response.
        claim_lines = [_claim_line_dict(d) for d in claim_lines_raw]

        # 835 service payments: ORM rows from the matched remit.
        remits = list(
            s.execute(
                select(db.Remittance).where(db.Remittance.claim_id == claim_id)
            ).scalars().all()
        )
        svc_payments: list[dict] = []
        svc_ids: list[int] = []
        if remits:
            svc_rows = list(
                s.execute(
                    select(ServiceLinePayment).where(
                        ServiceLinePayment.remittance_id.in_([r.id for r in remits])
                    ).order_by(ServiceLinePayment.line_number)
                ).scalars().all()
            )
            for svc in svc_rows:
                d = _svc_to_dict(svc)
                svc_payments.append(d)
                svc_ids.append(svc.id)

        # LineReconciliation rows.
        lrs = list(
            s.execute(
                select(LineReconciliation).where(LineReconciliation.claim_id == claim_id)
            ).scalars().all()
        )
        # Index by claim_service_line_number and service_line_payment_id.
        lr_by_claim_num: dict[int, LineReconciliation] = {
            lr.claim_service_line_number: lr for lr in lrs if lr.claim_service_line_number is not None
        }
        lr_by_svc: dict[int, LineReconciliation] = {
            lr.service_line_payment_id: lr for lr in lrs if lr.service_line_payment_id is not None
        }

        # CAS rows grouped by svc id.
        cas_by_svc: dict[int, list[CasAdjustment]] = {}
        if svc_ids:
            cas_rows = list(
                s.execute(
                    select(CasAdjustment).where(CasAdjustment.service_line_payment_id.in_(svc_ids))
                ).scalars().all()
            )
            for c in cas_rows:
                cas_by_svc.setdefault(c.service_line_payment_id, []).append(c)

        # Build output lines array, preserving 837 order then 835-only.
        svc_by_id: dict[int, dict] = {d["id"]: d for d in svc_payments}
        lines_out: list[dict] = []
        billed_total = Decimal("0")
        paid_total = Decimal("0")
        adjustment_total = Decimal("0")
        matched_count = 0
        used_svc_ids: set[int] = set()

        for cl in claim_lines:
            billed_total += Decimal(str(cl["charge"]))
            lr = lr_by_claim_num.get(cl["line_number"])
            if lr is None:
                lines_out.append({
                    "claim_service_line": cl,
                    "service_line_payment": None,
                    "status": "unmatched_837_only",
                    "adjustments": [],
                })
                continue
            svc_id = lr.service_line_payment_id
            svc = svc_by_id.get(svc_id) if svc_id else None
            if svc_id is not None:
                used_svc_ids.add(svc_id)
            cas_list = cas_by_svc.get(svc_id, []) if svc_id is not None else []
            cas_total = sum((Decimal(str(c.amount)) for c in cas_list), Decimal("0"))
            if svc:
                paid_total += Decimal(str(svc["payment"]))
            adjustment_total += cas_total
            if lr.status == "matched":
                matched_count += 1
            lines_out.append({
                "claim_service_line": cl,
                "service_line_payment": svc,
                "status": lr.status,
                "adjustments": [
                    {"group_code": c.group_code, "reason_code": c.reason_code,
                     "amount": str(Decimal(str(c.amount)))}
                    for c in cas_list
                ],
            })

        # 835-only lines (no claim match).
        for lr in lrs:
            if lr.claim_service_line_number is not None:
                continue
            svc_id = lr.service_line_payment_id
            if svc_id is None:
                continue
            if svc_id in used_svc_ids:
                continue
            svc = svc_by_id.get(svc_id)
            cas_list = cas_by_svc.get(svc_id, [])
            cas_total = sum((Decimal(str(c.amount)) for c in cas_list), Decimal("0"))
            if svc:
                paid_total += Decimal(str(svc["payment"]))
            adjustment_total += cas_total
            lines_out.append({
                "claim_service_line": None,
                "service_line_payment": svc,
                "status": lr.status,
                "adjustments": [
                    {"group_code": c.group_code, "reason_code": c.reason_code,
                     "amount": str(Decimal(str(c.amount)))}
                    for c in cas_list
                ],
            })

        return {
            "claim_id": claim_id,
            "summary": {
                "billed_total": str(billed_total),
                "paid_total": str(paid_total),
                "adjustment_total": str(adjustment_total),
                "matched_lines": matched_count,
                "total_lines": len(claim_lines),
            },
            "lines": lines_out,
        }


def _claim_line_dict(d: dict) -> dict:
    """Project an 837 service-line dict from ``Claim.raw_json`` to wire shape."""
    from decimal import Decimal
    proc = d.get("procedure") or {}
    charge = d.get("charge")
    units = d.get("units")
    return {
        "line_number": d.get("line_number"),
        "procedure_qualifier": proc.get("qualifier", "HC"),
        "procedure_code": proc.get("code", ""),
        "modifiers": proc.get("modifiers") or [],
        "charge": str(Decimal(str(charge))) if charge is not None else "0",
        "units": str(Decimal(str(units))) if units is not None else None,
        "unit_type": d.get("unit_type"),
        "service_date": d.get("service_date"),
    }


def _svc_to_dict(svc) -> dict:
    """Project an ORM ``ServiceLinePayment`` to wire shape."""
    import json as _json
    from decimal import Decimal
    return {
        "id": svc.id,
        "line_number": svc.line_number,
        "procedure_qualifier": svc.procedure_qualifier,
        "procedure_code": svc.procedure_code,
        "modifiers": _json.loads(svc.modifiers_json or "[]"),
        "charge": str(Decimal(str(svc.charge))),
        "payment": str(Decimal(str(svc.payment))),
        "units": str(Decimal(str(svc.units))) if svc.units is not None else None,
        "unit_type": svc.unit_type,
        "service_date": svc.service_date.isoformat() if svc.service_date else None,
    }


@app.get("/api/reconciliation/unmatched")
def get_reconciliation_unmatched() -> dict:
    """Return unmatched Claims (left) and unmatched Remittances (right).

    Powers the reconciliation review surface: every Claim with no
    paired Remittance appears on the left, every Remittance with no
    paired Claim appears on the right. The two lists are always present
    (empty list, never absent) so the UI can index unconditionally.
    """
    return store.list_unmatched(kind="both")


# --------------------------------------------------------------------------- #
# Side-by-side diff between two batches (SP3 P4 / T18)
# --------------------------------------------------------------------------- #


@app.get("/api/batch-diff")
def get_batch_diff(
    a: str | None = Query(None),
    b: str | None = Query(None),
) -> dict:
    """Return a side-by-side diff of two batches identified by id.

    Query params: ``a=<batch_id>``, ``b=<batch_id>`` (both required).

    Response body (snake_case keys, see :mod:`cyclone.batch_diff` for the
    projector shapes):
      - ``a`` / ``b`` — small metadata blocks (id, kind, parsedAt,
        inputFilename, claimCount)
      - ``added`` — claims present in B but not A
      - ``removed`` — claims present in A but not B
      - ``changed`` — claims present in both, with field deltas
      - ``summary`` — precomputed counts

    Errors:
      - 400 — missing ``a`` or ``b``
      - 404 — either batch id is unknown

    Pure read endpoint — never mutates the store. Both 837P and 835
    batches are accepted (mixed-kind diffs are valid: comparing the
    submitted claims against the matching remittances).
    """
    if not a or not b:
        raise HTTPException(
            status_code=400,
            detail={"error": "Missing param", "detail": "Both ?a=<batch_id> and ?b=<batch_id> are required."},
        )
    try:
        a_rec, b_rec = store.load_two_for_diff(a, b)
    except LookupError as exc:
        raise HTTPException(
            status_code=404,
            detail={"error": "Not found", "detail": str(exc)},
        )

    # Lazy import — keeps the module's import surface small until the
    # endpoint is actually hit. Mirrors the same pattern used by other
    # endpoint-local helpers (e.g. reconciler).
    from cyclone.batch_diff import diff_batches_to_wire

    return diff_batches_to_wire(a_rec, b_rec)


@app.post("/api/reconciliation/match")
def post_reconciliation_match(body: dict) -> dict:
    """Manually pair a Claim with a Remittance (operator override).

    Body: ``{"claim_id": ..., "remit_id": ...}``. Returns
    ``{"claim": <ui>, "match": <ui>}`` on success. Errors:
      - 400: missing ``claim_id`` or ``remit_id``
      - 404: claim or remittance not found
      - 409: claim already matched, or apply_* returned a noop
        (claim in terminal state) — detail echoes ``current_state``
        and ``activity_kind`` so the UI can render a precise message.
    """
    claim_id = body.get("claim_id")
    remit_id = body.get("remit_id")
    if not claim_id or not remit_id:
        raise HTTPException(
            status_code=400,
            detail="claim_id and remit_id required",
        )
    try:
        return store.manual_match(claim_id, remit_id)
    except AlreadyMatchedError as e:
        raise HTTPException(
            status_code=409,
            detail={"error": "already_matched", "message": str(e)},
        )
    except InvalidStateError as e:
        raise HTTPException(
            status_code=409,
            detail={
                "error": "invalid_state",
                "current_state": e.current_state,
                "activity_kind": e.activity_kind,
            },
        )
    except LookupError:
        # manual_match raises LookupError when the claim or remittance
        # row is missing (we catch the parent class so any future
        # KeyError subclasses in the store get the same treatment).
        raise HTTPException(
            status_code=404,
            detail="claim_or_remit_not_found",
        )


@app.post("/api/reconciliation/unmatch")
def post_reconciliation_unmatch(body: dict) -> dict:
    """Remove the current match for a Claim; reset Claim to submitted.

    Body: ``{"claim_id": ...}``. Returns
    ``{"claim": <ui>, "deletedMatches": <count>}``. Errors:
      - 400: missing ``claim_id``
      - 404: claim not found
      - 409: claim has no current match (NotMatchedError is mapped
        by the store; we surface 409 to match the manual_match contract)
    """
    from cyclone.store import NotMatchedError
    claim_id = body.get("claim_id")
    if not claim_id:
        raise HTTPException(
            status_code=400,
            detail="claim_id required",
        )
    try:
        return store.manual_unmatch(claim_id)
    except NotMatchedError as e:
        raise HTTPException(
            status_code=409,
            detail={"error": "not_matched", "message": str(e)},
        )
    except LookupError:
        raise HTTPException(
            status_code=404,
            detail="claim_not_found",
        )


@app.get("/api/remittances")
def list_remittances(
    request: Request,
    batch_id: str | None = Query(None),
    payer: str | None = Query(None),
    claim_id: str | None = Query(None),
    date_from: str | None = Query(None),
    date_to: str | None = Query(None),
    sort: str | None = Query(None),
    order: str = Query("desc"),
    limit: int = Query(100, ge=1, le=1000),
    offset: int = Query(0, ge=0),
) -> Any:
    common = dict(
        batch_id=batch_id,
        payer=payer,
        claim_id=claim_id,
        date_from=date_from,
        date_to=date_to,
    )
    items = list(store.iter_remittances(
        sort=sort, order=order, limit=limit, offset=offset, **common,
    ))
    total = len(list(store.iter_remittances(**common)))
    returned = len(items)
    has_more = total > offset + returned
    if _wants_ndjson(request):
        return StreamingResponse(
            _ndjson_stream_list(items, total, returned, has_more),
            media_type="application/x-ndjson",
        )
    return {
        "items": items,
        "total": total,
        "returned": returned,
        "has_more": has_more,
    }


@app.get("/api/remittances/stream")
async def remittances_stream(
    request: Request,
    payer: str | None = Query(None),
    claim_id: str | None = Query(None),
    date_from: str | None = Query(None),
    date_to: str | None = Query(None),
    sort: str | None = Query(None),
    order: str = Query("desc"),
    limit: int = Query(100, ge=1, le=1000),
) -> StreamingResponse:
    """Stream Remittances as NDJSON: snapshot first, then live events.

    Subscribes to ``remittance_written``. Default sort is
    ``-received_date`` (newest-first), matching the list endpoint's
    most common sort.

    NOTE: registered before ``/api/remittances/{remittance_id}`` so
    the literal ``stream`` path segment doesn't get matched as a
    remittance id.
    """
    bus: EventBus = request.app.state.event_bus

    async def gen() -> AsyncIterator[bytes]:
        rows = store.iter_remittances(
            payer=payer, claim_id=claim_id,
            date_from=date_from, date_to=date_to,
            sort=sort or "-received_date", order=order, limit=limit,
        )
        for row in rows:
            yield _ndjson_line({"type": "item", "data": row})
        yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})

        async for chunk in _tail_events(request, bus, ["remittance_written"]):
            yield chunk

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


@app.get("/api/remittances/{remittance_id}")
def get_remittance(remittance_id: str) -> dict:
    """Return one remittance with its labeled CAS ``adjustments`` array.

    Path param is ``remittance_id`` (not ``id``) to avoid shadowing
    FastAPI's internal ``id`` name and to keep OpenAPI docs self-
    describing. Returns 404 when the remittance is missing — never 500.
    """
    body = store.get_remittance(remittance_id)
    if body is None:
        raise HTTPException(
            status_code=404,
            detail={"error": "Not found", "detail": f"Remittance {remittance_id} not found"},
        )
    return body


@app.get("/api/providers")
def list_providers(
    request: Request,
    npi: str | None = Query(None),
    state: str | None = Query(None),
    limit: int = Query(100, ge=1, le=1000),
    offset: int = Query(0, ge=0),
) -> Any:
    items = store.distinct_providers()
    if npi is not None:
        items = [p for p in items if p["npi"] == npi]
    if state is not None:
        items = [p for p in items if p.get("state") == state]
    paged = items[offset:offset + limit]
    total = len(items)
    returned = len(paged)
    has_more = total > offset + returned
    if _wants_ndjson(request):
        return StreamingResponse(
            _ndjson_stream_list(paged, total, returned, has_more),
            media_type="application/x-ndjson",
        )
    return {
        "items": paged,
        "total": total,
        "returned": returned,
        "has_more": has_more,
    }


@app.get("/api/activity")
def list_activity(
    request: Request,
    kind: str | None = Query(None),
    since: str | None = Query(None),
    limit: int = Query(200, ge=1, le=500),
) -> Any:
    events = store.recent_activity(limit=limit)
    if kind is not None:
        events = [e for e in events if e["kind"] == kind]
    if since is not None:
        events = [e for e in events if e["timestamp"] >= since]
    total = len(events)
    has_more = False
    if _wants_ndjson(request):
        return StreamingResponse(
            _ndjson_stream_list(events, total, total, has_more),
            media_type="application/x-ndjson",
        )
    return {
        "items": events,
        "total": total,
        "returned": total,
        "has_more": has_more,
    }


@app.get("/api/activity/stream")
async def activity_stream(
    request: Request,
    kind: str | None = Query(None),
    since: str | None = Query(None),
    limit: int = Query(50, ge=1, le=500),
) -> StreamingResponse:
    """Stream Activity events as NDJSON: snapshot first, then live events.

    Subscribes to ``activity_recorded``. Default ``limit`` is 50
    (smaller than the list endpoint's 200) because activity is
    high-volume — callers usually want the most recent handful, not a
    full replay.
    """
    bus: EventBus = request.app.state.event_bus

    async def gen() -> AsyncIterator[bytes]:
        # Snapshot reuses the same in-memory filter as ``list_activity``
        # so the two endpoints are interchangeable for the snapshot
        # half.
        events = store.recent_activity(limit=limit)
        if kind is not None:
            events = [e for e in events if e["kind"] == kind]
        if since is not None:
            events = [e for e in events if e["timestamp"] >= since]
        for ev in events:
            yield _ndjson_line({"type": "item", "data": ev})
        yield _ndjson_line({
            "type": "snapshot_end", "data": {"count": len(events)},
        })

        async for chunk in _tail_events(request, bus, ["activity_recorded"]):
            yield chunk

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


# --------------------------------------------------------------------------- #
# 999 ACKs (read views)
# --------------------------------------------------------------------------- #


# --------------------------------------------------------------------------- #
# 270 / 271 eligibility (SP3 P4 T23–T24) — API-only, no DB persistence
# --------------------------------------------------------------------------- #


def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]:
    """Build a :class:`ParseResult270` from a request body dict.

    The body shape is the minimum surface needed to build a valid 270
    inquiry (per spec section 3.4 — operator-driven, ephemeral):

    ::

        {
            "subscriber": {first_name, last_name, member_id, dob},
            "provider":   {npi, name},
            "payer":      {id, name},
            "service_type_code": "1"
        }

    Returns ``(ParseResult270, service_type_code)``. Raises
    :class:`HTTPException` (400) when the body is missing required
    fields.
    """
    subscriber_in = body.get("subscriber") or {}
    provider_in = body.get("provider") or {}
    payer_in = body.get("payer") or {}
    service_type_code = (body.get("service_type_code") or "").strip()

    # Required-field checks. We surface a single 400 with the first
    # missing field name to match the rest of the API's error contract.
    if not service_type_code:
        raise HTTPException(
            status_code=400,
            detail={"error": "Bad request", "detail": "service_type_code is required"},
        )
    if not subscriber_in.get("member_id"):
        raise HTTPException(
            status_code=400,
            detail={"error": "Bad request", "detail": "subscriber.member_id is required"},
        )
    if not provider_in.get("npi"):
        raise HTTPException(
            status_code=400,
            detail={"error": "Bad request", "detail": "provider.npi is required"},
        )
    if not payer_in.get("name"):
        raise HTTPException(
            status_code=400,
            detail={"error": "Bad request", "detail": "payer.name is required"},
        )

    # Build the Pydantic models. The serializer handles all envelope
    # generation (sender_id/receiver_id/control_number/transaction_date
    # are filled in by the serializer with sensible defaults).
    from datetime import date as _date

    subscriber_dob_raw = subscriber_in.get("dob")
    subscriber_dob: _date | None = None
    if subscriber_dob_raw:
        try:
            subscriber_dob = _date.fromisoformat(subscriber_dob_raw)
        except (TypeError, ValueError) as exc:
            raise HTTPException(
                status_code=400,
                detail={
                    "error": "Bad request",
                    "detail": f"subscriber.dob must be YYYY-MM-DD: {exc}",
                },
            ) from exc

    result = ParseResult270(
        envelope=Envelope(
            sender_id="SUBMITTERID",
            receiver_id=str(payer_in.get("id") or "RECEIVERID"),
            control_number="000000001",
            transaction_date=_date.today(),
            implementation_guide="005010X279A1",
        ),
        information_source=InformationSource270(
            name=str(payer_in["name"]),
            id=str(payer_in.get("id") or "") or None,
        ),
        information_receiver=InformationReceiver270(
            name=str(provider_in.get("name") or ""),
            npi=str(provider_in["npi"]),
        ),
        subscriber=Subscriber270(
            member_id=str(subscriber_in["member_id"]),
            first_name=str(subscriber_in.get("first_name") or "") or None,
            last_name=str(subscriber_in.get("last_name") or "") or None,
            dob=subscriber_dob,
        ),
        inquiries=[EligibilityBenefitInquiry(service_type_code=service_type_code)],
        summary=BatchSummary(
            input_file="eligibility_request",
            control_number="000000001",
            transaction_date=_date.today(),
            total_claims=1,
            passed=1,
            failed=0,
        ),
    )
    return result, service_type_code


@app.post("/api/eligibility/request")
def post_eligibility_request(body: dict) -> Any:
    """Build a 270 eligibility inquiry from a small JSON body.

    Returns ``{"raw_270_text": <X12>, "parsed": <ParseResult270>}``
    so the operator can either download the raw text (paste into a
    payer portal) or render the parsed fields directly. Per spec
    section 3.4, nothing is persisted to the DB.
    """
    try:
        result, _ = _validate_eligibility_request(body)
    except HTTPException:
        raise
    except (KeyError, TypeError, ValueError) as exc:
        raise HTTPException(
            status_code=400,
            detail={"error": "Bad request", "detail": f"Malformed body: {exc}"},
        ) from exc

    raw_270_text = serialize_270(result)
    return {
        "raw_270_text": raw_270_text,
        "parsed": json.loads(result.model_dump_json()),
    }


@app.post("/api/eligibility/parse-271")
async def post_eligibility_parse_271(
    file: UploadFile = File(...),
) -> Any:
    """Parse a 271 eligibility response and return the structured summary.

    Accepts the raw 271 text as a file upload (multipart/form-data),
    mirrors the ``/api/parse-999`` contract. Per spec section 3.4 the
    result is NOT persisted — the operator re-pastes the 271 each
    time they need a fresh read.

    The response body is a JSON object with three top-level keys:
    ``coverage_benefits``, ``subscriber``, and ``summary``. 400 is
    returned on empty / undecodable / malformed EDI; 200 on success.
    """
    raw = await file.read()
    if not raw:
        return JSONResponse(
            status_code=400,
            content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
        )
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError as exc:
        return JSONResponse(
            status_code=400,
            content={"error": "Encoding error", "detail": str(exc)},
        )

    try:
        result = parse_271_text(text, input_file=file.filename or "")
    except CycloneParseError as exc:
        return JSONResponse(
            status_code=400,
            content={"error": "Parse error", "detail": str(exc)},
        )
    except Exception as exc:  # pragma: no cover - safety net
        log.exception("Unexpected parser failure on 271")
        return JSONResponse(
            status_code=500,
            content={"error": "Internal server error", "detail": str(exc)},
        )

    return {
        "coverage_benefits": [
            json.loads(cb.model_dump_json()) for cb in result.coverage_benefits
        ],
        "subscriber": json.loads(result.subscriber.model_dump_json()),
        "summary": json.loads(result.summary.model_dump_json()),
        "envelope": json.loads(result.envelope.model_dump_json()),
        "information_source": json.loads(result.information_source.model_dump_json()),
        "information_receiver": json.loads(result.information_receiver.model_dump_json()),
    }


# ---------------------------------------------------------------------------
# SP9: providers / payers / clearhouse endpoints
# ---------------------------------------------------------------------------


@app.get("/api/clearhouse")
def get_clearhouse():
    """Return the singleton clearhouse config (dzinesco's identity, SFTP block, filename block)."""
    ch = store.get_clearhouse()
    if ch is None:
        raise HTTPException(status_code=404, detail="clearhouse not seeded")
    return json.loads(ch.model_dump_json())


@app.post("/api/clearhouse/submit")
def submit_to_clearhouse(body: dict):
    """Submit a batch of claims to the clearhouse (SFTP). SP9: stub.

    Body: ``{"claim_ids": [...], "payer_id": "CO_TXIX"}``

    Stub behavior: serializes each claim via the SP7 serializer, builds
    an HCPF-compliant outbound filename, and copies the result to
    ``{staging_dir}/{outbound_path}/{filename}`` instead of opening a
    real SFTP connection. Returns a receipt per claim.
    """
    from cyclone.clearhouse import make_client
    from cyclone.edi.filenames import build_outbound_filename

    claim_ids = body.get("claim_ids", [])
    payer_id = body.get("payer_id")
    if not claim_ids:
        raise HTTPException(status_code=400, detail="claim_ids required")
    if not payer_id:
        raise HTTPException(status_code=400, detail="payer_id required")

    ch = store.get_clearhouse()
    if ch is None:
        raise HTTPException(status_code=500, detail="clearhouse not seeded")

    client = make_client(ch.sftp_block)
    results = []
    for cid in claim_ids:
        try:
            x12_text = _serialize_claim_for_submit(cid)
        except Exception as exc:  # noqa: BLE001
            results.append({"claim_id": cid, "ok": False, "error": str(exc)})
            continue
        filename = build_outbound_filename(ch.tpid, "837P")
        remote = f"{ch.sftp_block.paths['outbound']}/{filename}"
        staging_path = client.write_file(remote, x12_text.encode("utf-8"))
        results.append({
            "claim_id": cid,
            "ok": True,
            "filename": filename,
            "staging_path": str(staging_path),
            "remote_path": remote,
        })
        # SP11: audit trail for each successful clearhouse submission.
        with db.SessionLocal()() as audit_s:
            append_event(audit_s, AuditEvent(
                event_type="clearhouse.submitted",
                entity_type="claim",
                entity_id=cid,
                payload={
                    "filename": filename,
                    "remote_path": remote,
                    "tpid": ch.tpid,
                    "stub": ch.sftp_block.stub,
                },
                actor="clearhouse-submit",
            ))
            audit_s.commit()
    return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}


def _serialize_claim_for_submit(claim_id: str) -> str:
    """Serialize a claim to X12 for SFTP submission. Lazy import of the
    serializer to avoid pulling FastAPI machinery at module import time."""
    from cyclone.parsers.serialize_837 import serialize_837
    from cyclone import db
    with db.SessionLocal()() as s:
        # Migration 0014: composite PK; URL takes only claim_id.
        row = s.query(db.Claim).filter(db.Claim.id == claim_id).first()
        if row is None:
            raise ValueError(f"claim {claim_id!r} not found")
        # Re-parse the stored raw_json to get a ClaimOutput
        from cyclone.parsers.models import ClaimOutput, Envelope, Subscriber, Payer, BillingProvider
        from cyclone.parsers.parse_837 import parse
        raw = row.raw_json or {}
        # Reconstruct minimal ClaimOutput from raw_json; this is best-effort.
        return _serialize_claim_from_raw(row, raw)


def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
    """Best-effort serializer that uses the stored raw_json to emit a fresh 837.

    For SP9 this delegates to the existing serialize_837 helper if the
    claim has a complete raw_segments array. Otherwise it returns a
    minimal placeholder.
    """
    from cyclone.parsers.serialize_837 import serialize_837
    from cyclone.parsers.parse_837 import parse

    # Re-parse the original batch text (need to re-derive from store).
    # SP9 stub: if the claim has a `raw_json` with `x12_text`, use that.
    if isinstance(raw, dict) and raw.get("x12_text"):
        result = parse(raw["x12_text"])
        if result.claims:
            return serialize_837(result.claims[0])
    # Fallback: raise so the caller sees an error.
    raise RuntimeError(
        f"claim {claim_row.id!r} cannot be re-serialized: no stored x12_text"
    )


@app.get("/api/config/providers")
def list_configured_providers(is_active: bool | None = Query(default=True)):
    """List the configured provider rows (3 NPIs for SP9)."""
    return [json.loads(p.model_dump_json()) for p in store.list_providers(is_active=is_active)]


# --------------------------------------------------------------------------- #
# SP11: tamper-evident audit log (admin)
# --------------------------------------------------------------------------- #


@app.get("/api/admin/audit-log")
def list_audit_log_endpoint(
    entity_type: str | None = Query(default=None),
    entity_id: str | None = Query(default=None),
    event_type: str | None = Query(default=None),
    limit: int = Query(default=100, ge=1, le=1000),
) -> Any:
    """List audit-log rows, newest first, with optional filters.

    Filters match the (entity_type, entity_id) pair (typical use:
    "show me everything that happened to claim C-123") or a single
    event_type (typical use: "show me all clearhouse.submitted
    events today").
    """
    with db.SessionLocal()() as s:
        q = s.query(db.AuditLog)
        if entity_type:
            q = q.filter(db.AuditLog.entity_type == entity_type)
        if entity_id:
            q = q.filter(db.AuditLog.entity_id == entity_id)
        if event_type:
            q = q.filter(db.AuditLog.event_type == event_type)
        rows = q.order_by(db.AuditLog.id.desc()).limit(limit).all()
        return {
            "total": len(rows),
            "items": [
                {
                    "id": r.id,
                    "event_type": r.event_type,
                    "entity_type": r.entity_type,
                    "entity_id": r.entity_id,
                    "actor": r.actor,
                    "payload": json.loads(r.payload_json) if r.payload_json else None,
                    "created_at": r.created_at.isoformat() if r.created_at else None,
                    "prev_hash": r.prev_hash,
                    "hash": r.hash,
                }
                for r in rows
            ],
        }


@app.get("/api/admin/audit-log/verify")
def verify_audit_log_endpoint() -> Any:
    """Walk the audit-log chain and verify every row's hash.

    Returns ``{"ok": true, "checked": N}`` for a clean chain, or
    ``{"ok": false, "checked": K, "first_bad_id": X, "reason": "..."}``
    for a broken chain. This is the operator's "did anyone tamper?"
    endpoint; run it on demand or via a nightly cron job.
    """
    with db.SessionLocal()() as s:
        result = verify_chain(s)
    return {
        "ok": result.ok,
        "checked": result.checked,
        "first_bad_id": result.first_bad_id,
        "reason": result.reason,
    }


# ---------------------------------------------------------------------------
# SP15: SQLCipher key rotation
#
# Re-encrypts the DB in place with a fresh key, then updates the
# Keychain so subsequent connections open with the new key. This is
# a 1-time operation per rotation; for routine read/write the rest
# of the API is unchanged.
#
# Concurrency: the rotation holds a module-level lock so two
# concurrent requests can't race and end up with mismatched Keychain
# + DB. The lock is a simple threading.Lock; a process restart
# resets it (intentional — the operator's next start-up opens with
# whatever key is in the Keychain).
# ---------------------------------------------------------------------------
import threading as _threading
from cyclone import db_crypto as _db_crypto
from cyclone import secrets as _secrets

_db_rotate_lock = _threading.Lock()


@app.post("/api/admin/db/rotate-key")
def rotate_db_key_endpoint(body: dict | None = None) -> Any:
    """Generate a fresh DB key, re-encrypt the DB, update the Keychain.

    Request body (optional):
        actor: who initiated the rotation. Defaults to "operator".
        reason: human-readable reason. Written to the audit log.

    Returns:
        ``{ok, old_fingerprint, new_fingerprint, rotated_at, table_count}``
        on success. On failure (DB not encrypted, rekey failed,
        Keychain update failed) returns the same shape with
        ``ok=false`` and a ``reason``. HTTP 503 is returned if the
        rekey fails or encryption is not enabled.

    The Keychain write happens *after* the rekey succeeds. If the
    Keychain write fails, the DB has the new key but the Keychain
    still has the old one — the endpoint returns 503 with a
    "keychain update failed" reason and the operator must restore
    the old key manually (``cyclone db restore-key <old_key>``) to
    avoid being locked out.
    """
    body = body or {}
    actor = body.get("actor") or "operator"
    reason = body.get("reason") or ""

    if not _db_crypto.is_encryption_enabled():
        raise HTTPException(
            status_code=400,
            detail="encryption not enabled (sqlcipher3 missing or no Keychain key)",
        )

    # Acquire the lock; non-blocking so a stuck rotation doesn't
    # silently hold up other requests.
    if not _db_rotate_lock.acquire(blocking=False):
        raise HTTPException(
            status_code=409,
            detail="another key rotation is in progress",
        )
    try:
        url = db._resolve_url()
        old_key = _db_crypto.get_db_key()
        if not old_key:
            raise HTTPException(
                status_code=400,
                detail="no DB key in Keychain; cannot rotate",
            )

        new_key = _db_crypto.generate_db_key()
        result = _db_crypto.rotate_db_key(
            url=url, old_key=old_key, new_key=new_key,
        )
        if not result.ok:
            # Rekey failed. The DB still has the old key. The
            # Keychain is unchanged. Caller should NOT retry with
            # the same new key (it's lost); generate a fresh one.
            log.error("SQLCipher rotate failed: %s", result.reason)
            raise HTTPException(
                status_code=503,
                detail={
                    "ok": False,
                    "old_fingerprint": result.old_fingerprint,
                    "new_fingerprint": result.new_fingerprint,
                    "rotated_at": result.rotated_at,
                    "reason": result.reason,
                },
            )

        # Rekey succeeded. Now update the Keychain. If this fails
        # the DB is locked behind the new key — operator must
        # restore the old key manually.
        if not _secrets.set_secret(_db_crypto.KEYCHAIN_ACCOUNT, new_key):
            log.error("Keychain update failed after successful rekey!")
            raise HTTPException(
                status_code=503,
                detail={
                    "ok": False,
                    "old_fingerprint": result.old_fingerprint,
                    "new_fingerprint": result.new_fingerprint,
                    "rotated_at": result.rotated_at,
                    "reason": (
                        "rekey succeeded but Keychain update failed — "
                        "the DB is now encrypted with the new key but "
                        "the Keychain still has the old one. "
                        "Restore the old key to the Keychain to recover."
                    ),
                },
            )

        # Store the old key in the "previous" account for a grace
        # period so the operator can roll back if they discover the
        # new key is broken (e.g. the Keychain entry got truncated).
        _secrets.set_secret(_db_crypto.KEYCHAIN_ACCOUNT_PREVIOUS, old_key)

        # Rebuild the engine so subsequent connections use the new
        # key. dispose_engine() closes every pooled connection that
        # was using the old key; init_db() opens new ones with the
        # new key from the (now-updated) Keychain.
        db.reinit_engine()

        # Audit log the rotation. We do this after the engine is
        # rebuilt so the audit event is written with the new key —
        # proving that the new key works for new writes.
        try:
            from cyclone.audit_log import append_event, AuditEvent
            with db.SessionLocal()() as s:
                append_event(s, AuditEvent(
                    event_type="db.key_rotated",
                    entity_type="database",
                    entity_id="cyclone.db",
                    actor=actor,
                    payload={
                        "old_fingerprint": result.old_fingerprint,
                        "new_fingerprint": result.new_fingerprint,
                        "table_count": result.table_count,
                        "reason": reason,
                    },
                ))
                s.commit()
        except Exception as exc:  # noqa: BLE001
            # Audit append is best-effort; rotation already succeeded.
            log.warning("could not write audit event for rotation: %s", exc)

        return {
            "ok": True,
            "old_fingerprint": result.old_fingerprint,
            "new_fingerprint": result.new_fingerprint,
            "rotated_at": result.rotated_at,
            "table_count": result.table_count,
        }
    finally:
        _db_rotate_lock.release()


# ---------------------------------------------------------------------------
# SP17: encrypted DB backups (admin)
#
# The actual encryption + lifecycle lives in :mod:`cyclone.backup` and
# :mod:`cyclone.backup_service`. The scheduler (separate from the
# MFT scheduler) lives in :mod:`cyclone.backup_scheduler`. These
# endpoints expose the operator's manual controls plus a tick for
# "take a backup right now."
#
# Restore is intentionally two-step: an idle browser tab can't nuke
# the live DB. The first call returns a ``restore_token`` (a one-shot
# 64-char hex) and a preview of the backup's fingerprint + table
# count plus the live DB's. The second call with the token performs
# the actual swap.
# ---------------------------------------------------------------------------
from cyclone import backup_service as _backup_svc_mod
from cyclone import backup_scheduler as _backup_sched_mod


def _backup_or_503():
    try:
        return _backup_svc_mod.get_backup_service()
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc))


@app.post("/api/admin/backup/create")
def backup_create() -> Any:
    """Take an encrypted backup right now. Returns the new backup metadata."""
    from cyclone import audit_log as _audit
    svc = _backup_or_503()
    try:
        result = svc.create_now()
    except Exception as exc:  # noqa: BLE001
        # Surface a 503 with the reason so the operator sees what
        # went wrong without grepping server logs.
        raise HTTPException(
            status_code=503,
            detail=f"backup failed: {type(exc).__name__}: {exc}",
        )
    # Audit the create. Best-effort; failure here doesn't roll back
    # the backup (already on disk).
    try:
        with db.SessionLocal()() as s:
            _audit.append_event(s, _audit.AuditEvent(
                event_type="db.backup_created",
                entity_type="database",
                entity_id="cyclone.db",
                actor="operator",
                payload={
                    "backup_id": result.backup.id,
                    "db_fingerprint": result.backup.db_fingerprint,
                    "table_count": result.backup.table_count,
                    "triggered_by": "api",
                },
            ))
            s.commit()
    except Exception as exc:  # noqa: BLE001
        log.warning("could not write backup_created audit event: %s", exc)

    return {
        "ok": True,
        "backup": {
            "id": result.backup.id,
            "filename": result.backup.filename,
            "size_bytes": result.backup.size_bytes,
            "db_fingerprint": result.backup.db_fingerprint,
            "table_count": result.backup.table_count,
            "created_at": result.backup.created_at.isoformat(),
            "key_fingerprint": result.backup.key_fingerprint,
        },
        "sidecar": {
            "format_version": result.sidecar.format_version,
            "kdf": result.sidecar.kdf,
            "kdf_iterations": result.sidecar.kdf_iterations,
            "cipher": result.sidecar.cipher,
        },
    }


@app.get("/api/admin/backup/list")
def backup_list(
    limit: int = Query(default=100, ge=1, le=1000),
    status: str | None = Query(default=None),
) -> Any:
    """List ``db_backups`` rows, newest first. Filters by status."""
    svc = _backup_or_503()
    rows = svc.list_backups(limit=limit, status=status)
    return {
        "count": len(rows),
        "files": [
            {
                "id": r.id,
                "filename": r.filename,
                "backup_dir": r.backup_dir,
                "size_bytes": r.size_bytes,
                "db_fingerprint": r.db_fingerprint,
                "table_count": r.table_count,
                "created_at": r.created_at.isoformat() if r.created_at else None,
                "completed_at": r.completed_at.isoformat() if r.completed_at else None,
                "status": r.status,
                "error_message": r.error_message,
                "key_fingerprint": r.key_fingerprint,
            }
            for r in rows
        ],
    }


@app.get("/api/admin/backup/status")
def backup_status() -> Any:
    """Snapshot of the backup subsystem (counts, disk usage, last run)."""
    svc = _backup_or_503()
    snap = svc.status()
    # Also include the BackupScheduler's snapshot if configured.
    try:
        sched = _backup_sched_mod.get_backup_scheduler()
        snap["scheduler"] = sched.status().as_dict()
    except RuntimeError:
        snap["scheduler"] = None
    return snap


@app.post("/api/admin/backup/{backup_id}/verify")
def backup_verify(backup_id: int) -> Any:
    """Decrypt + checksum-verify a backup against its sidecar."""
    svc = _backup_or_503()
    try:
        v = svc.verify(backup_id)
    except _backup_svc_mod.BackupError as exc:
        raise HTTPException(status_code=404, detail=str(exc))
    return {
        "backup_id": v.backup_id,
        "filename": v.filename,
        "ok": v.ok,
        "expected_fingerprint": v.expected_fingerprint,
        "actual_fingerprint": v.actual_fingerprint,
        "table_count": v.table_count,
        "reason": v.reason,
    }


@app.post("/api/admin/backup/{backup_id}/restore/initiate")
def backup_restore_initiate(backup_id: int) -> Any:
    """First step of the two-step restore. Returns a ``restore_token``."""
    svc = _backup_or_503()
    try:
        init = svc.restore_initiate(backup_id)
    except _backup_svc_mod.BackupError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return {
        "backup_id": init.backup_id,
        "filename": init.filename,
        "size_bytes": init.size_bytes,
        "restore_token": init.restore_token,
        "expires_at": init.expires_at.isoformat(),
        "preview": {
            "backup_db_fingerprint": init.db_fingerprint,
            "backup_table_count": init.table_count,
            "current_db_fingerprint": init.current_db_fingerprint,
            "current_table_count": init.current_table_count,
        },
        "warning": (
            "Confirming will dispose the live engine and replace the DB "
            "file with the backup. In-flight requests will error. "
            "Re-issue the call with the restore_token within 5 minutes."
        ),
    }


@app.post("/api/admin/backup/{backup_id}/restore/confirm")
def backup_restore_confirm(
    backup_id: int,
    body: dict | None = None,
) -> Any:
    """Second step of the two-step restore. Performs the swap."""
    body = body or {}
    token = body.get("restore_token")
    if not token or not isinstance(token, str):
        raise HTTPException(
            status_code=400,
            detail="missing or invalid restore_token in request body",
        )
    actor = body.get("actor") or "operator"

    svc = _backup_or_503()
    try:
        result = svc.restore_confirm(backup_id, token, actor=actor)
    except _backup_svc_mod.BackupError as exc:
        raise HTTPException(status_code=400, detail=str(exc))

    # Audit the restore. Best-effort.
    try:
        from cyclone import audit_log as _audit
        with db.SessionLocal()() as s:
            _audit.append_event(s, _audit.AuditEvent(
                event_type="db.backup_restored",
                entity_type="database",
                entity_id="cyclone.db",
                actor=actor,
                payload={
                    "backup_id": result.backup_id,
                    "filename": result.filename,
                    "restored_from_fingerprint": result.restored_from_fingerprint,
                    "new_db_fingerprint": result.new_db_fingerprint,
                    "restored_at": result.restored_at.isoformat(),
                },
            ))
            s.commit()
    except Exception as exc:  # noqa: BLE001
        log.warning("could not write backup_restored audit event: %s", exc)

    return {
        "ok": True,
        "backup_id": result.backup_id,
        "filename": result.filename,
        "restored_from_fingerprint": result.restored_from_fingerprint,
        "restored_at": result.restored_at.isoformat(),
        "new_db_fingerprint": result.new_db_fingerprint,
    }


@app.post("/api/admin/backup/prune")
def backup_prune() -> Any:
    """Apply the retention policy now. Returns the deleted paths."""
    from cyclone import audit_log as _audit
    svc = _backup_or_503()
    deleted = svc.prune()
    actor = "operator"
    if deleted:
        try:
            with db.SessionLocal()() as s:
                _audit.append_event(s, _audit.AuditEvent(
                    event_type="db.backup_pruned",
                    entity_type="database",
                    entity_id="cyclone.db",
                    actor=actor,
                    payload={"deleted_paths": deleted},
                ))
                s.commit()
        except Exception as exc:  # noqa: BLE001
            log.warning("could not write backup_pruned audit event: %s", exc)
    return {"ok": True, "deleted_count": len(deleted), "deleted_paths": deleted}


# ---------------------------------------------------------------------------
# SP17: backup scheduler (admin)
# ---------------------------------------------------------------------------


@app.post("/api/admin/backup/scheduler/start")
async def backup_scheduler_start() -> Any:
    """Begin the backup scheduler loop."""
    try:
        sched = _backup_sched_mod.get_backup_scheduler()
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc))
    await sched.start()
    return {"status": sched.status().as_dict()}


@app.post("/api/admin/backup/scheduler/stop")
async def backup_scheduler_stop() -> Any:
    """Stop the backup scheduler loop."""
    try:
        sched = _backup_sched_mod.get_backup_scheduler()
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc))
    await sched.stop()
    return {"status": sched.status().as_dict()}


@app.post("/api/admin/backup/scheduler/tick")
async def backup_scheduler_tick() -> Any:
    """Run one backup tick now (create + prune + audit)."""
    try:
        sched = _backup_sched_mod.get_backup_scheduler()
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc))
    result = await sched.tick()
    return {"ok": result.ok, "tick": result.as_dict()}


# ---------------------------------------------------------------------------
# SP16: live MFT polling scheduler (admin)
#
# The scheduler lives in :mod:`cyclone.scheduler` and is configured by
# the lifespan handler. The endpoints below expose start / stop /
# one-shot tick / status / history so an operator (or a cron job)
# can drive the scheduler without touching the DB.
#
# Note: the scheduler is OFF by default. Auto-start is opt-in via
# ``CYCLONE_SCHEDULER_AUTOSTART=true`` at launch. These endpoints
# are the operator's manual controls.
# ---------------------------------------------------------------------------
from cyclone import scheduler as _scheduler_mod


def _scheduler_or_503():
    """Return the configured scheduler or raise 503."""
    try:
        return _scheduler_mod.get_scheduler()
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc))


@app.post("/api/admin/scheduler/start")
async def scheduler_start() -> Any:
    """Begin polling the MFT inbound path every poll_interval_seconds."""
    sched = _scheduler_or_503()
    await sched.start()
    return {"status": sched.status().as_dict()}


@app.post("/api/admin/scheduler/stop")
async def scheduler_stop() -> Any:
    """Stop polling. Waits up to 30s for the current tick to finish."""
    sched = _scheduler_or_503()
    await sched.stop()
    return {"status": sched.status().as_dict()}


@app.post("/api/admin/scheduler/tick")
async def scheduler_tick() -> Any:
    """Run a single poll cycle synchronously and return the result.

    Useful for: forcing a poll without waiting for the next interval;
    verifying SFTP connectivity; running a one-shot import from the
    CLI (``curl -X POST .../api/admin/scheduler/tick``).
    """
    sched = _scheduler_or_503()
    result = await sched.tick()
    return {"ok": True, "tick": result.as_dict()}


@app.get("/api/admin/scheduler/status")
def scheduler_status() -> Any:
    """Return the scheduler's runtime snapshot (running, counters, last tick)."""
    sched = _scheduler_or_503()
    return sched.status().as_dict()


@app.get("/api/admin/scheduler/processed-files")
def scheduler_processed_files(
    limit: int = Query(default=100, ge=1, le=1000),
    status: str | None = Query(default=None),
) -> Any:
    """List rows from ``processed_inbound_files``, newest first.

    The operator's "what did the scheduler do?" view. Filters by
    ``status`` (``ok`` / ``error`` / ``skipped`` / ``pending``).
    Returns ``{"count": N, "files": [...]}`` where ``files[i]``
    matches the ORM row as a JSON dict.
    """
    from cyclone.db import ProcessedInboundFile
    from cyclone.scheduler import STATUS_OK, STATUS_ERROR, STATUS_SKIPPED, STATUS_PENDING

    valid_statuses = {STATUS_OK, STATUS_ERROR, STATUS_SKIPPED, STATUS_PENDING}
    if status is not None and status not in valid_statuses:
        raise HTTPException(
            status_code=400,
            detail=f"status must be one of {sorted(valid_statuses)}",
        )

    with db.SessionLocal()() as s:
        q = s.query(db.ProcessedInboundFile)
        if status is not None:
            q = q.filter(db.ProcessedInboundFile.status == status)
        rows = q.order_by(db.ProcessedInboundFile.id.desc()).limit(limit).all()
        return {
            "count": len(rows),
            "files": [
                {
                    "id": r.id,
                    "sftp_block_name": r.sftp_block_name,
                    "name": r.name,
                    "size": r.size,
                    "modified_at": r.modified_at.isoformat() if r.modified_at else None,
                    "file_type": r.file_type,
                    "processed_at": r.processed_at.isoformat() if r.processed_at else None,
                    "parser_used": r.parser_used,
                    "claim_count": r.claim_count,
                    "status": r.status,
                    "error_message": r.error_message,
                }
                for r in rows
            ],
        }


@app.get("/api/config/providers/{npi}")
def get_configured_provider(npi: str):
    p = store.get_provider(npi)
    if p is None:
        raise HTTPException(status_code=404, detail=f"provider {npi!r} not found")
    provider_dict = json.loads(p.model_dump_json())

    # SP21 Task 1.6: extend the response with two top-N arrays that the
    # drill-down peek panel hangs off. ``recent_claims`` reuses the
    # existing store projection (already returns UI-shaped dicts with
    # ``submissionDate``); ``recent_activity`` is a direct ORM join
    # because ``ActivityEvent`` has no ``provider_npi`` column — the
    # filter has to hop through ``Claim.id``.
    recent_claims = sorted(
        store.iter_claims(provider_npi=npi),
        key=lambda c: c.get("submissionDate") or "",
        reverse=True,
    )[:10]

    # Activity filter has TWO join paths back to a Claim for this
    # provider:
    #   1. ``ActivityEvent.claim_id IN (claim_ids)`` — events that were
    #      recorded with a claim FK already set (claim_submitted,
    #      manual_match, claim_paid, etc.).
    #   2. ``Remittance.claim_id IN (claim_ids)`` — the original
    #      ``remit_received`` event recorded at 835 ingest time
    #      (``store.add`` lines 999-1003) is inserted with
    #      ``claim_id=None`` because the remittance hasn't been matched
    #      to a claim yet. The auto-reconcile pass later sets
    #      ``Remittance.claim_id`` (``reconcile.run`` lines 289-293),
    #      but the *original* ActivityEvent row stays orphaned. The
    #      outer-join-then-OR lets us surface both shapes. Without
    #      path 2, a provider's activity feed looks frozen the moment
    #      an 835 lands — the most common activity, invisible.
    with db.SessionLocal()() as s:
        claim_ids = [
            cid
            for (cid,) in s.query(Claim.id)
            .filter(Claim.provider_npi == npi)
            .all()
        ]
        activity_rows = []
        if claim_ids:
            activity_rows = (
                s.query(db.ActivityEvent)
                .outerjoin(
                    Remittance,
                    db.ActivityEvent.remittance_id == Remittance.id,
                )
                .filter(or_(
                    db.ActivityEvent.claim_id.in_(claim_ids),
                    Remittance.claim_id.in_(claim_ids),
                ))
                .order_by(desc(db.ActivityEvent.ts))
                .limit(10)
                .all()
            )

    def _activity_to_ui(a):
        return {
            "id": a.id,
            "ts": a.ts.isoformat().replace("+00:00", "Z") if a.ts else "",
            "kind": a.kind,
            "batchId": a.batch_id,
            "claimId": a.claim_id,
            "remittanceId": a.remittance_id,
            "payload": a.payload_json or {},
        }

    provider_dict["recent_claims"] = recent_claims
    provider_dict["recent_activity"] = [_activity_to_ui(a) for a in activity_rows]
    return provider_dict


@app.get("/api/config/payers")
def list_configured_payers(is_active: bool | None = Query(default=True)):
    return [json.loads(p.model_dump_json()) for p in store.list_payers(is_active=is_active)]


@app.get("/api/config/payers/{payer_id}/configs")
def list_payer_configs(payer_id: str):
    """List all (transaction_type, config_json) blocks for a payer."""
    from cyclone import payers as payer_loader
    configs = [
        {"transaction_type": tx, "config_json": block, "source": "yaml"}
        for (pid, tx), block in payer_loader.all_configs().items()
        if pid == payer_id
    ]
    # Also check the DB for runtime-overridden configs
    for tx in ("837P", "835", "277CA", "999", "TA1"):
        live = store.get_payer_config(payer_id, tx)
        if live is not None:
            configs.append({"transaction_type": tx, "config_json": live, "source": "db"})
    return configs


# --------------------------------------------------------------------------- #
# SP21 Task 1.5: payer-level summary for the drill-down panel
# --------------------------------------------------------------------------- #

# Per-payer_id memoization for /api/payers/{payer_id}/summary. The drill-
# down UI hammers this on every hover; the underlying query is O(claims)
# per payer so a 60s TTL keeps the panel responsive. Process-local on
# purpose — invalidation is driven by the TTL alone for now (see note
# below).
_SUMMARY_TTL_S = 60.0
_summary_cache: dict[str, tuple[float, dict]] = {}


def _clear_summary_cache() -> None:
    """Test hook: wipe the process-local cache.

    The 60s TTL means tests that want to assert on a recomputed payload
    must clear the cache between requests. Not used by production code.
    """
    _summary_cache.clear()


# Pubsub invalidation is intentionally NOT wired. The ``claim_written``
# and ``remittance_written`` payloads emitted by ``store.add`` are the
# UI-shaped dicts from ``to_ui_claim_from_orm`` / ``to_ui_remittance_from_orm``,
# neither of which carries the X12 ``payer_id`` (NM1*PR*PI qualifier).
# They carry ``payerName`` only, which is the human-readable name and not
# the URL key we cache on. Wiring a subscriber here would either need a
# DB lookup per event (re-deriving payer_id from Claim.id) or a different
# cache key — both are out of scope for this task. The 60s TTL keeps
# staleness bounded; a follow-up can wire targeted invalidation if the
# UI proves TTL-bounded staleness is unacceptable.


@app.get("/api/payers/{payer_id}/summary")
def get_payer_summary(payer_id: str):
    """Payer-level rollup for the drill-down panel.

    Returns billed/received totals, denial rate, and top providers for
    the given ``payer_id`` (the X12 NM1*PR*PI qualifier, e.g. ``SKCO0``).
    Cached in-process for ``_SUMMARY_TTL_S`` seconds.

    404 when no claims AND no remits reference this payer_id — the UI
    uses that to distinguish a typo from a payer with zero traffic
    (the latter would still return a valid 200 with zeroed totals).
    """
    now = monotonic()
    cached = _summary_cache.get(payer_id)
    if cached and (now - cached[0]) < _SUMMARY_TTL_S:
        return cached[1]

    log.debug("payer summary cache miss", extra={"payer_id": payer_id})

    # Query claims + remits scoped to this payer_id. We bypass
    # ``store.iter_claims`` because that helper filters by payer NAME
    # substring, not by the X12 PI qualifier we use as the URL key.
    with db.SessionLocal()() as s:
        claim_rows: list[Claim] = (
            s.query(Claim).filter(Claim.payer_id == payer_id).all()
        )
        claim_ids = [c.id for c in claim_rows]
        remit_rows: list[Remittance] = []
        if claim_ids:
            remit_rows = (
                s.query(Remittance)
                .filter(Remittance.claim_id.in_(claim_ids))
                .all()
            )

        if not claim_rows and not remit_rows:
            raise HTTPException(
                status_code=404,
                detail=f"Payer {payer_id!r} not found",
            )

        billed_total = sum(float(c.charge_amount or 0) for c in claim_rows)
        received_total = sum(float(r.total_paid or 0) for r in remit_rows)
        denied = sum(
            1 for c in claim_rows if c.state == ClaimState.DENIED
        )
        claim_count = len(claim_rows)
        denial_rate = (denied / claim_count) if claim_count else 0.0

        provider_counts: dict[str, int] = {}
        for c in claim_rows:
            npi = c.provider_npi
            if not npi:
                continue
            provider_counts[npi] = provider_counts.get(npi, 0) + 1
        top_providers = [
            {"npi": npi, "count": count}
            for npi, count in sorted(
                provider_counts.items(), key=lambda kv: -kv[1]
            )[:5]
        ]

        # Best-effort payer display name from the first claim's raw
        # payer object (NM1*PR name element, e.g. "COHCPF"). Falls
        # back to the id when no parsed envelope is available.
        payer_name = payer_id
        for c in claim_rows:
            raw = c.raw_json or {}
            p = raw.get("payer") if isinstance(raw, dict) else None
            if isinstance(p, dict) and p.get("name"):
                payer_name = p["name"]
                break

    payload = {
        "payer_id": payer_id,
        "name": payer_name,
        "claim_count": claim_count,
        "billed_total": billed_total,
        "received_total": received_total,
        "denial_rate": denial_rate,
        "top_providers": top_providers,
    }
    _summary_cache[payer_id] = (now, payload)
    return payload


@app.post("/api/admin/reload-config")
def reload_config():
    """Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
    from cyclone import payers as payer_loader
    try:
        configs = payer_loader.load_payer_configs()
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    return {"ok": True, "loaded": len(configs), "errors": []}


__all__ = ["app"]
