"""SP28: claim_acks persistence + batch envelope index (D10).

Five methods on top of the new ``ClaimAck`` ORM table:

* ``add_claim_ack`` — insert one link row (manual or auto) and
  publish ``claim_ack_written`` on the bus.
* ``list_acks_for_claim`` — every link row for one claim (per-claim
  only; TA1 batch-level rows are filtered out by the API layer).
* ``list_claims_for_ack`` — every link row for one ack.
* ``find_ack_orphans`` — acks with no resolvable claim (Inbox
  ack-orphans lane).
* ``remove_claim_ack`` — unlink. Publishes ``claim_ack_dropped``.
* ``batch_envelope_index`` — D10 in-memory map of
  ``Batch.envelope.control_number → batch.id`` (cheap to rebuild;
  re-built once per ingest).

Each mutating method opens its own ``db.SessionLocal()()`` session
so callers don't have to manage session lifecycles. Publishes are
best-effort (wrapped in :func:`_safe_publish`) so a failing bus
subscriber cannot roll back the persisted row.
"""

from __future__ import annotations

import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING

from cyclone import db
from cyclone.db import (
    Ack,
    Batch,
    Claim,
    ClaimAck,
    Ta1Ack,
    Two77caAck,
)

from . import utcnow
from .ui import to_ui_claim_ack
from .write import _sync_publish

if TYPE_CHECKING:
    from cyclone.pubsub import EventBus


log = logging.getLogger(__name__)


def _safe_publish(event_bus: "EventBus | None", kind: str, payload: dict) -> None:
    """Best-effort publish wrapped in try/except.

    Mirrors ``cyclone.store.acks._safe_publish`` — never raises,
    never rolls back the persisted row.
    """
    if event_bus is None:
        return
    try:
        _sync_publish(event_bus, kind, payload)
    except Exception:  # noqa: BLE001
        log.exception("claim_acks store: %s publish failed", kind)


# ---------------------------------------------------------------------------
# D10 batch envelope index
# ---------------------------------------------------------------------------


def batch_envelope_index() -> dict[str, str]:
    """Build a ``{envelope.control_number: batch.id}`` map.

    D10 primary join key (spec §D10). Built once per ingest from
    every Batch row whose ``raw_result_json`` carries an envelope —
    cost is O(N batches), currently ~16, so trivial. Kept as a
    plain dict so callers can ``index.get(scn)`` to resolve.
    """
    out: dict[str, str] = {}
    with db.SessionLocal()() as s:
        rows = (
            s.query(Batch.id, Batch.raw_result_json)
            .filter(Batch.kind == "837p")
            .all()
        )
        for bid, raw in rows:
            env = (raw or {}).get("envelope") or {}
            ctrl = env.get("control_number")
            if isinstance(ctrl, str) and ctrl:
                out[ctrl] = bid
    return out


# ---------------------------------------------------------------------------
# Mutators
# ---------------------------------------------------------------------------


def add_claim_ack(
    *,
    claim_id: str | None,
    batch_id: str | None,
    ack_id: int,
    ack_kind: str,
    ak2_index: int | None = None,
    set_control_number: str | None = None,
    set_accept_reject_code: str | None = None,
    linked_by: str = "auto",
    event_bus: "EventBus | None" = None,
    now: datetime | None = None,
) -> ClaimAck:
    """Persist one link row and return it.

    The DB-level unique index
    ``ux_claim_acks_dedup(claim_id, ack_kind, ack_id, ak2_index)
    WHERE claim_id IS NOT NULL AND ak2_index IS NOT NULL`` enforces
    idempotency for the per-AK2 case. The 277CA (ak2_index NULL)
    and TA1 (claim_id NULL) paths are deduplicated by application
    code — see :func:`cyclone.claim_acks._link_exists`.

    When ``event_bus`` is provided, publishes one ``claim_ack_written``
    event (with the full :func:`cyclone.store.ui.to_ui_claim_ack`
    payload) so the live-tail subscribers on both
    ``/api/claims/{id}/acks/stream`` and
    ``/api/acks/{kind}/{id}/claims/stream`` see new rows the moment
    they land.
    """
    if ack_kind not in ("999", "277ca", "ta1"):
        raise ValueError(f"add_claim_ack: unknown ack_kind={ack_kind!r}")
    if linked_by not in ("auto", "manual"):
        raise ValueError(f"add_claim_ack: linked_by={linked_by!r}")
    if claim_id is None and batch_id is None:
        raise ValueError(
            "add_claim_ack: at least one of claim_id/batch_id must be set"
        )
    ts = now or utcnow()
    with db.SessionLocal()() as s:
        row = ClaimAck(
            claim_id=claim_id,
            batch_id=batch_id,
            ack_id=ack_id,
            ack_kind=ack_kind,
            ak2_index=ak2_index,
            set_control_number=set_control_number,
            set_accept_reject_code=set_accept_reject_code,
            linked_at=ts,
            linked_by=linked_by,
        )
        s.add(row)
        s.commit()
        s.refresh(row)
        row_id = row.id

    if event_bus is not None:
        with db.SessionLocal()() as s:
            payload = to_ui_claim_ack(s.get(ClaimAck, row_id))
        _safe_publish(event_bus, "claim_ack_written", payload)

    return row


def remove_claim_ack(
    link_id: int,
    *,
    event_bus: "EventBus | None" = None,
) -> bool:
    """Unlink. Returns ``True`` when a row was deleted.

    Publishes ``claim_ack_dropped`` with ``{"id", "claim_id"}`` so
    the live-tail subscribers can remove the link from their local
    store. Does NOT touch ``Claim.state`` — the unlink is purely
    about the link row, per spec §D6.
    """
    with db.SessionLocal()() as s:
        row = s.get(ClaimAck, link_id)
        if row is None:
            return False
        payload = {
            "id": row.id,
            "claim_id": row.claim_id,
            "ack_id": row.ack_id,
            "ack_kind": row.ack_kind,
        }
        s.delete(row)
        s.commit()

    if event_bus is not None:
        _safe_publish(event_bus, "claim_ack_dropped", payload)
    return True


# ---------------------------------------------------------------------------
# Readers
# ---------------------------------------------------------------------------


def list_acks_for_claim(claim_id: str) -> list[ClaimAck]:
    """Return every ClaimAck row for one claim (newest first)."""
    with db.SessionLocal()() as s:
        return (
            s.query(ClaimAck)
            .filter(ClaimAck.claim_id == claim_id)
            .order_by(ClaimAck.id.desc())
            .all()
        )


def list_claims_for_ack(kind: str, ack_id: int) -> list[ClaimAck]:
    """Return every ClaimAck row for one ack (any kind).

    For 999 / 277CA: returns 0..N rows (one per AK2 / ClaimStatus).
    For TA1: returns 0..1 row (envelope-level, populated batch_id).
    """
    if kind not in ("999", "277ca", "ta1"):
        raise ValueError(f"list_claims_for_ack: unknown kind={kind!r}")
    with db.SessionLocal()() as s:
        return (
            s.query(ClaimAck)
            .filter(
                ClaimAck.ack_kind == kind,
                ClaimAck.ack_id == ack_id,
            )
            .order_by(ClaimAck.id.asc())
            .all()
        )


# ---------------------------------------------------------------------------
# Orphan detection (Inbox "Ack orphans" lane — spec §D7)
# ---------------------------------------------------------------------------


def find_ack_orphans(kind: str) -> list[dict]:
    """Return acks with no resolvable Claim row of their own kind.

    Used by the Inbox ack-orphans lane for the operator's manual
    reconciliation flow. The downstream ``/api/inbox/ack-orphans``
    endpoint calls this and returns the rendered shape.

    "Orphan" means: for 999 / 277CA, ``ack_kind=kind AND ack_id IN
    (acks_with_no_claim_acks_link)``. For TA1, "orphan" means the
    TA1 row exists but no Batch with matching sender/receiver was
    resolved (so no link row was created).

    Output dict shape (one row per orphan ack, rendered by
    :func:`to_ui_claim_ack`-style serialization):

      * ``kind``       — "999" / "277ca" / "ta1"
      * ``ack_id``     — the ack row's id
      * ``control_number`` — for 999/277CA, envelope.control_number;
                             for TA1, ta1.control_number
      * ``set_control_numbers`` — empty list when no claims match
      * ``raw_summary`` — flat copy of the ack's UI shape for the
                          lane-header counts
    """
    if kind not in ("999", "277ca", "ta1"):
        raise ValueError(f"find_ack_orphans: unknown kind={kind!r}")

    out: list[dict] = []
    if kind == "999":
        ack_table = Ack
        ctrl_attr = None
    elif kind == "277ca":
        ack_table = Two77caAck
        ctrl_attr = "control_number"
    else:
        ack_table = Ta1Ack
        ctrl_attr = "control_number"

    with db.SessionLocal()() as s:
        # Every ack row of the given kind, with a LEFT JOIN against
        # any claim_acks link; orphan when NO link was created.
        if kind == "999":
            # For 999, the "ack has no link" means no ClaimAck row
            # was emitted at all (the auto-linker emits one per AK2
            # even when the AK2 is rejected, so 999 with at least
            # one AK2 that resolved to a claim is never an orphan).
            # We treat a 999 as orphan when it has zero ClaimAck
            # rows tied to its id.
            all_acks = s.query(Ack).order_by(Ack.id.desc()).all()
            for ack_row in all_acks:
                count = (
                    s.query(ClaimAck)
                    .filter(ClaimAck.ack_kind == "999",
                            ClaimAck.ack_id == ack_row.id)
                    .count()
                )
                if count == 0:
                    out.append({
                        "kind": "999",
                        "ack_id": ack_row.id,
                        "control_number": _ack_control_number(ack_row, "999"),
                        "parsed_at": (
                            ack_row.parsed_at.isoformat().replace(
                                "+00:00", "Z"
                            ) if ack_row.parsed_at else None
                        ),
                    })
        elif kind == "277ca":
            all_acks = s.query(Two77caAck).order_by(Two77caAck.id.desc()).all()
            for ack_row in all_acks:
                count = (
                    s.query(ClaimAck)
                    .filter(ClaimAck.ack_kind == "277ca",
                            ClaimAck.ack_id == ack_row.id)
                    .count()
                )
                if count == 0:
                    out.append({
                        "kind": "277ca",
                        "ack_id": ack_row.id,
                        "control_number": ack_row.control_number or "",
                        "parsed_at": (
                            ack_row.parsed_at.isoformat().replace(
                                "+00:00", "Z"
                            ) if ack_row.parsed_at else None
                        ),
                    })
        else:
            all_acks = s.query(Ta1Ack).order_by(Ta1Ack.id.desc()).all()
            for ack_row in all_acks:
                count = (
                    s.query(ClaimAck)
                    .filter(ClaimAck.ack_kind == "ta1",
                            ClaimAck.ack_id == ack_row.id)
                    .count()
                )
                if count == 0:
                    out.append({
                        "kind": "ta1",
                        "ack_id": ack_row.id,
                        "control_number": ack_row.control_number or "",
                        "parsed_at": (
                            ack_row.parsed_at.isoformat().replace(
                                "+00:00", "Z"
                            ) if ack_row.parsed_at else None
                        ),
                    })
    return out


def _ack_control_number(ack_row: Ack, kind: str) -> str:
    """Best-effort control-number lookup for a 999 ack row.

    The 999 ORM row doesn't carry the envelope's control_number in
    a dedicated column; we re-derive it from ``raw_json`` (the same
    source :func:`cyclone.store.ui.to_ui_ack` uses for the patient
    control number).
    """
    raw = ack_row.raw_json or {}
    env = raw.get("envelope") or {}
    return env.get("control_number") or ""


__all__ = [
    "add_claim_ack",
    "batch_envelope_index",
    "find_ack_orphans",
    "list_acks_for_claim",
    "list_claims_for_ack",
    "remove_claim_ack",
]
