"""``/api/batches*`` — read views + ZIP export over the parsed-batch population.

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

- ``POST /api/batches/{batch_id}/export-837`` — download a ZIP of
  regenerated X12 837 files for the requested claim ids. Per-claim
  payer config + clearhouse identity drive the submitter/receiver
  blocks; per-claim millisecond offset on the filename keeps every
  file in the bundle unique (HCPF requires this). Read-only — does
  NOT mutate Claim state (compare with
  ``/api/inbox/rejected/resubmit?download=true`` which DOES flip
  ``REJECTED → SUBMITTED``).
- ``GET  /api/batches``                       — summary list,
  newest-first, capped at ``limit``. Each row includes ``claimIds``
  (837P only, so the Upload page can render a one-click Re-export
  button per row without a round-trip to ``/api/batches/{id}``).
  SP30: also returns billing-outcome fields
  (``acceptedCount`` / ``rejectedCount`` / ``pendingCount`` /
  ``billedTotal`` / ``topRejectionReason`` / ``hasProblem``) so the
  Dashboard "Recent batches" widget can render one row per batch
  without an N+1 fetch.
- ``GET  /api/batches/{batch_id}``            — full batch record
  (parsed envelope + claims). 404 when unknown.

Three single-router helpers stay in this file (per spec D4):

- :func:`_batch_summary_claim_count`       — claim count (837P or 835).
- :func:`_batch_summary_claim_ids`         — per-claim ids (837P only).
- :func:`_batch_summary_billing_outcomes`  — per-batch GROUP BY state
  aggregate plus the most-recent rejection reason.

Inline imports inside handlers (preserved verbatim per spec D5):
``zipfile``, ``datetime``, ``ZoneInfo``, ``build_outbound_filename``,
``PayerConfigORM``, ``func`` (sqlalchemy).

SP36 Task 14: this block moved here from ``api.py:1280`` (the 3
``/api/batches*`` routes + 3 single-router helpers + the SP30
state-bucket tuples and the ``# 277CA STC category`` note).
"""
from __future__ import annotations

import io
import json
from typing import Any

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

from cyclone import db
from cyclone.api_helpers import (
    ndjson_stream_list as _ndjson_stream_list,
    wants_ndjson as _wants_ndjson,
)
from cyclone.auth.deps import matrix_gate
from cyclone.db import Batch, Claim, ClaimState
from cyclone.parsers.models import ClaimOutput
from cyclone.parsers.serialize_837 import SerializeError as SerializeError837
from cyclone.parsers.serialize_837 import serialize_837_for_resubmit
from cyclone.store import BatchRecord, store

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


@router.post("/api/batches/{batch_id}/export-837")
def export_batch_837(request: Request, batch_id: str, body: dict):
    """Download a ZIP of regenerated X12 837 files for the requested claim_ids.

    Body shape: ``{"claim_ids": [str, ...]}``.

    Each successfully serialized claim becomes an entry in the ZIP named
    per the HCPF X12 File Naming Standards:
    ``tp{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (with a per-claim
    millisecond offset so every file in the bundle has a unique name).
    The ``serialize_837_for_resubmit`` serializer is used so every file
    gets a unique interchange / group control number — back-to-back
    exports of the same set must produce different envelopes (required
    by X12).

    The submitter block (Loop 1000A — NM1*41 + PER) is populated from
    the clearhouse singleton (dzinesco's identity in the seeded config)
    and the receiver block (NM1*40) is populated from the per-payer
    config. Without this wiring, the serializer falls back to
    ``CYCLONE`` / ``RECEIVER`` placeholders and HCPF rejects the file.

    No DB state is mutated by this endpoint — it is read-only. Compare
    with ``/api/inbox/rejected/resubmit?download=true`` which ALSO flips
    ``ClaimState.REJECTED → SUBMITTED``; the two endpoints are
    intentionally separate.

    Responses:
      200 — ``application/zip`` with the .x12 entries. Per-claim failures
            are surfaced via the ``X-Cyclone-Serialize-Errors`` header
            (JSON-encoded array of ``{claim_id, reason}``).
      400 — ``claim_ids`` missing or empty.
      404 — ``batch_id`` unknown.
      422 — every claim failed to serialize; body is JSON listing all
            failures (``{"detail": {"serialize_errors": [...]}}``).
    """
    import zipfile
    from datetime import datetime
    from zoneinfo import ZoneInfo

    from cyclone.edi.filenames import build_outbound_filename

    ids = body.get("claim_ids") or []
    if not ids:
        raise HTTPException(400, "claim_ids required")

    with db.SessionLocal()() as s:
        batch = s.get(Batch, batch_id)
        if batch is None:
            raise HTTPException(404, f"unknown batch: {batch_id}")

        serialize_errors: list[dict] = []
        ordered_rows: list[tuple[str, "Claim"]] = []
        for cid in ids:
            c = s.get(Claim, cid)
            if c is None:
                serialize_errors.append({"claim_id": cid, "reason": "unknown claim_id"})
                continue
            ordered_rows.append((cid, c))

    # Pull clearhouse identity (submitter). If unseeded, the serializer
    # falls back to placeholder defaults — degraded but not a hard error.
    ch = store.get_clearhouse()
    submitter_kwargs: dict = {}
    if ch is not None:
        submitter_kwargs = {
            "sender_id": ch.tpid,
            "submitter_name": ch.submitter_name,
            "submitter_contact_name": ch.submitter_contact_name,
            "submitter_contact_email": ch.submitter_contact_email,
        }
        # Submitter phone is not in the clearhouse config today, but if
        # it ever is, wire it here. Email is the canonical contact
        # channel for HCPF submissions per the SP9 spec.
        if getattr(ch, "submitter_contact_phone", None):
            submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone

    # Resolve per-claim payer config so each file's receiver (NM1*40)
    # and SBR09 are correct. Cache so we don't re-query the same payer.
    from cyclone.db import PayerConfigORM as _PayerConfigORM
    _payer_cache: dict[str, dict | None] = {}

    def _resolve_payer_cfg(claim_obj: ClaimOutput) -> dict | None:
        pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
        pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
        cache_key = pid or pname
        if cache_key in _payer_cache:
            return _payer_cache[cache_key]
        cfg: dict | None = None
        with db.SessionLocal()() as ss:
            # 1. Exact match on (payer_id, "837P")
            if pid:
                row = ss.get(_PayerConfigORM, (pid, "837P"))
                if row is not None:
                    cfg = dict(row.config_json)
            # 2. Fallback: any row whose payer_id matches the parsed payer.name
            #    (HCPF files emit "SKCO0" in NM109 but the canonical
            #    payer_id in the DB is "CO_TXIX" — name-matching is the
            #    pragmatic lookup for that case).
            if cfg is None and pname:
                row = (
                    ss.query(_PayerConfigORM)
                    .filter(_PayerConfigORM.transaction_type == "837P")
                    .all()
                )
                for r in row:
                    cj = dict(r.config_json)
                    if cj.get("submitter_name") and pname.lower() in str(cj).lower():
                        cfg = cj
                        break
                    if (r.payer_id or "").upper() == pname.upper():
                        cfg = cj
                        break
            # 3. Last resort: first 837P row in the table.
            if cfg is None:
                row = (
                    ss.query(_PayerConfigORM)
                    .filter(_PayerConfigORM.transaction_type == "837P")
                    .first()
                )
                if row is not None:
                    cfg = dict(row.config_json)
        _payer_cache[cache_key] = cfg
        return cfg

    # Build per-claim kwargs (receiver + SBR09) lazily. Receiver
    # defaults to the parsed payer name/ID if no config row matches.
    def _serialize_kwargs(claim_obj: ClaimOutput) -> dict:
        payer_cfg = _resolve_payer_cfg(claim_obj) or {}
        receiver_id = (
            payer_cfg.get("receiver_id")
            or (claim_obj.payer.id if claim_obj.payer else None)
            or "RECEIVER"
        )
        receiver_name = (
            payer_cfg.get("receiver_name")
            or (claim_obj.payer.name if claim_obj.payer else None)
            or receiver_id
        )
        sbr09 = payer_cfg.get("sbr09_default") or "MC"
        return {
            "receiver_id": receiver_id,
            "receiver_name": receiver_name,
            "claim_filing_indicator_code": sbr09,
        }

    # Base MT timestamp for HCPF filenames. We add a per-claim
    # millisecond offset so each file in the ZIP has a unique 17-digit
    # ts (HCPF requires that; the spec also enforces "1of1" for the
    # sequence element).
    base_ts = datetime.now(ZoneInfo("America/Denver"))

    def _per_claim_filename(idx: int, cid: str) -> str:
        if ch is None:
            # No clearhouse — fall back to a per-claim friendly name.
            return f"claim-{cid}.x12"
        # Millisecond offset, with second/minute rollover.
        offset_ms = (idx - 1) * 1  # 1 ms per claim is enough within an export
        ts_mt = base_ts.fromtimestamp(
            base_ts.timestamp() + offset_ms / 1000.0, tz=ZoneInfo("America/Denver")
        )
        return build_outbound_filename(ch.tpid, "837P", now_mt=ts_mt)

    buf = io.BytesIO()
    with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
        for idx, (cid, c) in enumerate(ordered_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:
                kwargs = {**submitter_kwargs, **_serialize_kwargs(claim_obj)}
                text = serialize_837_for_resubmit(
                    claim_obj, interchange_index=idx, **kwargs
                )
            except SerializeError837 as exc:
                serialize_errors.append({"claim_id": cid, "reason": str(exc)})
                continue
            zf.writestr(_per_claim_filename(idx, cid), text)

    success_count = len(ids) - len(serialize_errors)
    if serialize_errors and success_count == 0:
        # Every claim failed — surface the failure list in the body so the
        # UI can render a useful error toast (the response is not a ZIP).
        raise HTTPException(
            422,
            detail={"serialize_errors": serialize_errors},
        )

    buf.seek(0)
    headers = {
        "Content-Disposition": (
            f'attachment; filename="batch-{batch_id}-{success_count}-claims.zip"'
        ),
    }
    if serialize_errors:
        headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors)
    return Response(
        content=buf.getvalue(),
        media_type="application/zip",
        headers=headers,
    )


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


def _batch_summary_claim_ids(rec: BatchRecord) -> list[str]:
    """Return per-claim ids for an 837P batch, or ``[]`` otherwise.

    The Upload page's History tab renders a one-click Re-export ZIP
    button per row; that button calls
    ``POST /api/batches/{id}/export-837`` with the row's claim ids.
    Carrying them in the list response avoids an extra round-trip
    to ``/api/batches/{id}`` for every row. 835 has no re-export
    endpoint, so the list is empty for those — the UI uses the
    empty list as the signal to hide the button.
    """
    if rec.kind != "837p":
        return []
    return [
        c.claim_id
        for c in rec.result.claims  # type: ignore[attr-defined]
        if getattr(c, "claim_id", None)
    ]


# SP30: state buckets the Dashboard widget (and any future "how the
# last batch billed" surface) reads at a glance. Keep these in sync
# with ClaimState — adding a new state here is a deliberate decision
# the operator needs to see, not a coincidence.
_BATCH_SUMMARY_ACCEPTED_STATES: tuple[ClaimState, ...] = (
    ClaimState.PAID,
    ClaimState.RECEIVED,
    ClaimState.RECONCILED,
    ClaimState.PARTIAL,
)
_BATCH_SUMMARY_REJECTED_STATES: tuple[ClaimState, ...] = (
    ClaimState.REJECTED,
    ClaimState.DENIED,
    ClaimState.REVERSED,
)
_BATCH_SUMMARY_PENDING_STATES: tuple[ClaimState, ...] = (
    ClaimState.SUBMITTED,
)
# 277CA STC category A4/A6/A7 — payer-side rejections that may not
# yet have flipped Claim.state (the operator hasn't acknowledged).
# The Dashboard widget treats these as problems too, mirroring the
# Inbox `rejected + payer_rejected` aggregation.
_BATCH_SUMMARY_PAYER_REJECT_CODES: tuple[str, ...] = ("A4", "A6", "A7")


def _batch_summary_billing_outcomes(
    records: list[BatchRecord],
) -> dict[str, dict]:
    """Compute per-batch billing outcome for the Dashboard widget.

    Returns ``{batch_id: {accepted, rejected, pending, billed,
    top_rejection_reason, has_problem}}`` for every batch in
    ``records``. Empty input → empty dict.

    Two SQL queries, both bounded by the supplied batch ids:

    1. One GROUP BY ``(batch_id, state)`` aggregate that produces
       the accepted/rejected/pending counts and the sum of
       ``charge_amount`` (the billed total). Single pass — no N+1.
    2. One ordered scan over the rejected + payer-rejected subset
       to pick the most recent rejection reason (truncated to 60
       chars). Skipped when the first query found no rejections
       and no payer-rejects, so the happy path stays at one query.

    835 batches have no Claim rows — the GROUP BY returns no
    rows for them, so the dict entry for an 835 batch is
    ``{accepted:0, rejected:0, pending:0, billed:0.0,
    top_rejection_reason:None, has_problem:False}`` (filled by
    the caller's ``.get(id, defaults)`` pattern).
    """
    if not records:
        return {}
    from sqlalchemy import func  # local import to keep top-of-file light

    batch_ids = [r.id for r in records]
    outcome: dict[str, dict] = {
        bid: {
            "accepted": 0,
            "rejected": 0,
            "pending": 0,
            "billed": 0.0,
            "top_rejection_reason": None,
            "has_problem": False,
        }
        for bid in batch_ids
    }

    with db.SessionLocal()() as s:
        # ---- 1. GROUP BY (batch_id, state) for counts + billed total ----
        rows = (
            s.query(
                Claim.batch_id,
                Claim.state,
                func.count(Claim.id),
                func.coalesce(func.sum(Claim.charge_amount), 0),
            )
            .filter(Claim.batch_id.in_(batch_ids))
            .group_by(Claim.batch_id, Claim.state)
            .all()
        )
        any_rejection_or_payer = False
        for batch_id, state, count, billed in rows:
            slot = outcome.get(batch_id)
            if slot is None:
                continue  # batch has no row in our pre-allocated dict
            count = int(count or 0)
            billed_f = float(billed or 0)
            slot["billed"] += billed_f
            if state in _BATCH_SUMMARY_ACCEPTED_STATES:
                slot["accepted"] += count
            elif state in _BATCH_SUMMARY_REJECTED_STATES:
                slot["rejected"] += count
                any_rejection_or_payer = True
            elif state in _BATCH_SUMMARY_PENDING_STATES:
                slot["pending"] += count
            # everything else (DRAFT, etc.) is excluded from the widget.

        # ---- 2. Most-recent rejection reason + payer-reject probe ----
        # Only run when we know there IS at least one rejection OR a
        # payer-reject claim somewhere in the batch set; otherwise
        # the first query alone is enough.
        if any_rejection_or_payer:
            rej_rows = (
                s.query(
                    Claim.batch_id,
                    Claim.rejection_reason,
                    Claim.payer_rejected_status_code,
                )
                .filter(
                    Claim.batch_id.in_(batch_ids),
                    Claim.state.in_(_BATCH_SUMMARY_REJECTED_STATES)
                    | Claim.payer_rejected_status_code.in_(
                        _BATCH_SUMMARY_PAYER_REJECT_CODES
                    ),
                )
                .order_by(Claim.rejected_at.desc().nullslast())
                .all()
            )
            seen_reason: set[str] = set()
            for batch_id, reason, payer_code in rej_rows:
                slot = outcome.get(batch_id)
                if slot is None:
                    continue
                if payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES:
                    slot["has_problem"] = True
                # Capture the first non-null reason for this batch
                # (rej_rows is ordered newest-first, so the first
                # non-null wins). Truncate to 60 chars + ellipsis.
                if (
                    slot["top_rejection_reason"] is None
                    and reason
                    and batch_id not in seen_reason
                ):
                    r = reason.strip()
                    if len(r) > 60:
                        r = r[:60] + "…"
                    slot["top_rejection_reason"] = r
                    seen_reason.add(batch_id)
                if (
                    slot["rejected"] > 0
                    or payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES
                ):
                    slot["has_problem"] = True

    return outcome


@router.get("/api/batches")
def list_batches(
    request: Request,
    limit: int = Query(100, ge=1, le=1000),
) -> Any:
    """Summary of all parsed batches, newest first.

    Each item includes ``claimIds`` (837P only) so the History tab
    on the Upload page can render a one-click re-export button per
    row without an extra round-trip to ``/api/batches/{id}``. The
    list is still capped at ``limit`` claims; see the full result
    via the by-id endpoint when more is needed.

    SP30: also returns billing-outcome fields
    (``acceptedCount`` / ``rejectedCount`` / ``pendingCount`` /
    ``billedTotal`` / ``topRejectionReason`` / ``hasProblem``) so
    the Dashboard "Recent batches" widget can render one row per
    batch without an N+1 fetch. See
    :func:`_batch_summary_billing_outcomes`.
    """
    records = store.list(limit=limit)
    outcomes = _batch_summary_billing_outcomes(records)
    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),
            "claimIds": _batch_summary_claim_ids(r),
            "acceptedCount": outcomes.get(r.id, {}).get("accepted", 0),
            "rejectedCount": outcomes.get(r.id, {}).get("rejected", 0),
            "pendingCount": outcomes.get(r.id, {}).get("pending", 0),
            "billedTotal": round(outcomes.get(r.id, {}).get("billed", 0.0), 2),
            "topRejectionReason": outcomes.get(r.id, {}).get(
                "top_rejection_reason"
            ),
            "hasProblem": outcomes.get(r.id, {}).get("has_problem", False),
        }
        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,
    }


@router.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())
