"""Pure reconciliation functions.

These functions take lists of objects (ORM or stand-in dataclasses) and
return intent objects describing what should change. They do not own a
database session; the caller (cyclone.store) persists the intents.

Match algorithm:
- Group claims by patient_control_number.
- For each remit, find a candidate claim with the same PCN.
- Prefer the claim whose service_date_from is closest to the remit's
  service_date, within +/- window_days.
- If the remit has no service_date, fall back to the first claim for
  that PCN (in input order).
- Skip claims that already have matched_remittance_id set.
- is_reversal flag is propagated to the Match.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Iterable, Optional, Protocol

from sqlalchemy import func

from cyclone.db import ClaimState


WINDOW_DAYS_DEFAULT = 7


class _ClaimLike(Protocol):
    id: str
    patient_control_number: Optional[str]  # truthy-filtered in match()
    service_date_from: Optional[date]
    matched_remittance_id: Optional[str]


class _RemitLike(Protocol):
    id: str
    payer_claim_control_number: str
    service_date: Optional[date]
    is_reversal: bool


@dataclass
class Match:
    claim: _ClaimLike
    remittance: _RemitLike
    strategy: str
    is_reversal: bool
    # SP31: which content-keys the 2-of-3 (or 3-of-3) rule agreed on.
    # Populated for ``score-auto`` matches; empty for ``pcn-exact`` /
    # ``manual`` (PCN-exact only uses the PCN key by definition; manual
    # is operator-driven). Floats into the ``auto_matched_835``
    # ActivityEvent payload so the audit trail records *why* the link
    # fired. Transient — the ORM Match row does not persist these
    # (spec keeps the audit trail in ActivityEvent.payload_json only).
    keys_matched: set[str] = field(default_factory=set)
    # SP31: how many candidates were in the ±30-day window pool when
    # the score-auto match fired. Useful when an operator is reviewing
    # a borderline auto-link — 1-of-1 is unambiguous; 1-of-5 was lucky.
    candidate_count: int = 0


def match(
    claims: Iterable[_ClaimLike],
    remittances: Iterable[_RemitLike],
    *,
    window_days: int = WINDOW_DAYS_DEFAULT,
) -> list[Match]:
    """Pair each remit with the closest eligible claim. Returns Match intents."""
    claims_list = list(claims)
    remits_list = list(remittances)

    # Eligible = no current match, has a PCN.
    eligible = [c for c in claims_list if c.patient_control_number and not c.matched_remittance_id]
    by_pcn: dict[str, list[_ClaimLike]] = {}
    for c in eligible:
        by_pcn.setdefault(c.patient_control_number, []).append(c)

    matches: list[Match] = []
    used_claim_ids: set[str] = set()

    for r in remits_list:
        candidates = by_pcn.get(r.payer_claim_control_number, [])
        candidates = [c for c in candidates if c.id not in used_claim_ids]
        if not candidates:
            continue

        chosen = _pick_claim(candidates, r.service_date, window_days)
        if chosen is None:
            continue

        matches.append(Match(
            claim=chosen,
            remittance=r,
            strategy="pcn-exact",
            is_reversal=r.is_reversal,
        ))
        used_claim_ids.add(chosen.id)

    return matches


def _pick_claim(
    candidates: list[_ClaimLike],
    remit_service_date: Optional[date],
    window_days: int,
) -> Optional[_ClaimLike]:
    """Choose the claim with the closest service_date_from within the window."""
    if remit_service_date is None:
        return candidates[0]  # no date info → first claim for that PCN

    best: tuple[int, _ClaimLike] | None = None
    for c in candidates:
        if c.service_date_from is None:
            continue
        delta = abs((c.service_date_from - remit_service_date).days)
        if delta > window_days:
            continue
        if best is None or delta < best[0]:
            best = (delta, c)

    if best is not None:
        return best[1]
    # Remit has a date but no candidate was within the window → no match.
    return None


# --- SP31: content-keys fallback matcher -----------------------------------

CHARGE_TOLERANCE = Decimal("0.01")
KEYS_REQUIRED = 2


def _content_keys_match(remit, claim) -> set[str]:
    """Return the set of content-keys that agree between ``remit`` and ``claim``.

    Compares {``pcn``, ``charge``, ``npi``} between the two sides and returns
    the subset whose values match. Caller checks ``len(returned) >= KEYS_REQUIRED``
    to decide whether to auto-link. Returning the matched keys (rather than a
    bool) lets the ActivityEvent payload record exactly which 2-of-3 (or 3-of-3)
    produced the auto-link — the audit trail for the SP31 content-keys rule.

    Charge compared with ``CHARGE_TOLERANCE`` tolerance (rounding drift).
    NPI counts as "not matched" when the remit's NPI is empty.

    Uses duck-typing via ``getattr`` so the helper works against both:
      - shim / dataclass test objects (planned names: total_charge_amount,
        total_charge, rendering_provider_npi), and
      - real SQLAlchemy ORM instances (Claim.charge_amount, Remittance.total_charge,
        Claim.provider_npi, Claim.rendering_provider_npi,
        Remittance.rendering_provider_npi).

    Production note (SP32): the typed ``rendering_provider_npi`` column is
    the primary read for both sides (Claim = NM1*82, Remit = NM1*1P). Legacy
    rows pre-0019 still carry the value in ``raw_json``; the read order is:
      - NPI:  typed column → raw_json (``service_provider_npi`` for remit,
        ``rendering_provider_npi`` for claim) → claim ``provider_npi``
        (billing fallback for legacy 837p rows without NM1*82 extraction).
      - Charge: planned attribute name → real ORM column → raw_json.
    The ``raw_json`` fallback is what makes this helper safe against a
    raw ``Remittance`` ORM instance from ``reconcile.run()``.
    """
    matched: set[str] = set()

    # PCN: payer_claim_control_number ↔ patient_control_number (stripped).
    if (getattr(remit, "payer_claim_control_number", "") or "").strip() == \
       (getattr(claim, "patient_control_number", "") or "").strip():
        matched.add("pcn")

    # Charge: prefer the planned attribute names, fall back to real ORM columns,
    # then to raw_json (where the 835 parser stores CLP03). Explicit ``is None``
    # checks avoid the ``Decimal("0")`` truthiness footgun — ``Decimal("0")``
    # is truthy in a boolean context but ``or`` would still let it through;
    # the more important property is that we don't accidentally replace a real
    # value with a default.
    _remit_charge = getattr(remit, "total_charge_amount", None)
    if _remit_charge is None:
        _remit_charge = getattr(remit, "total_charge", None)
    if _remit_charge is None:
        _remit_charge = (getattr(remit, "raw_json", None) or {}).get("total_charge_amount")

    _claim_charge = getattr(claim, "total_charge", None)
    if _claim_charge is None:
        _claim_charge = getattr(claim, "charge_amount", None)
    if _claim_charge is None:
        _claim_charge = (getattr(claim, "raw_json", None) or {}).get("total_charge_amount")

    if _remit_charge is not None and _claim_charge is not None and \
       abs(_remit_charge - _claim_charge) < CHARGE_TOLERANCE:
        matched.add("charge")

    # NPI: typed-column primary path (SP32), raw_json fallback (legacy rows).
    # Remit reads rendering_provider_npi (single value, D4); claim reads
    # rendering_provider_npi first, then falls back to provider_npi (billing)
    # for legacy rows where the 837p parser hadn't yet extracted NM1*82.
    remit_npi = (
        (getattr(remit, "rendering_provider_npi", "") or "")
        or ((getattr(remit, "raw_json", None) or {}).get("service_provider_npi", "") or "")
        or ((getattr(remit, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
    ).strip()
    claim_npi = (
        (getattr(claim, "rendering_provider_npi", "") or "")
        or (getattr(claim, "provider_npi", "") or "")
        or ((getattr(claim, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
    ).strip()
    if remit_npi and remit_npi == claim_npi:
        matched.add("npi")

    return matched


# --- SP31: DB-side fallback candidate matcher -------------------------------

SCORE_FALLBACK_WINDOW_DAYS = 30


def _score_fallback_candidates(session, remit) -> Optional[tuple[str, set[str], int]]:
    """Return (claim_id, keys_matched, candidate_count) for a unique 2-of-3 match.

    Queries a ±SCORE_FALLBACK_WINDOW_DAYS candidate pool filtered by:
    - claim.matched_remittance_id IS NULL (not yet linked)
    - claim.state NOT IN terminal set (PAID, DENIED, REJECTED,
      REVERSED, RECONCILED)
    - claim.service_date_from within window of remit.service_date

    Returns ``(claim_id, keys_matched, candidate_count)`` when exactly one
    candidate passes the 2-of-3 rule (via :func:`_content_keys_match`),
    otherwise ``None``. ``keys_matched`` is the set returned by the helper
    (``{"pcn", "charge"}`` or any 2-of-3 / 3-of-3 combination) — the
    ActivityEvent payload records it as the audit trail for *why* the
    auto-link fired. ``candidate_count`` is the size of the window pool
    (independent of how many matched) so the operator can see whether
    the match was 1-of-N or 1-of-1.

    Ambiguous matches (2+ candidates) also return ``None`` — they land
    in the Inbox Unlinked lane for manual review.

    Note on payer_id: ``Remittance`` has no ``payer_id`` column
    (``Claim.payer_id`` does, and is the source of truth on the claim
    side; the 835 parser stores payer_id in ``Remittance.raw_json``).
    Filtering by payer would require either a raw_json read or a model
    column add — out of scope for SP31 Task 3. The PCN itself is
    expected to be unique within a payer's submission, so cross-payer
    collisions are unlikely. Revisit if production shows otherwise.
    """
    from sqlalchemy import select, and_
    from datetime import timedelta
    from cyclone.db import Claim, ClaimState

    TERMINAL = {
        ClaimState.PAID, ClaimState.DENIED, ClaimState.REJECTED,
        ClaimState.REVERSED, ClaimState.RECONCILED,
    }

    if remit.service_date is None:
        return None  # need a date for the window query

    window_lo = remit.service_date - timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)
    window_hi = remit.service_date + timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)

    candidates = list(session.execute(
        select(Claim).where(
            and_(
                Claim.matched_remittance_id.is_(None),
                Claim.service_date_from >= window_lo,
                Claim.service_date_from <= window_hi,
                Claim.state.notin_([s.value for s in TERMINAL]),
            )
        )
    ).scalars().all())

    matched_pairs = [
        (c.id, _content_keys_match(remit, c))
        for c in candidates
    ]
    matched_pairs = [
        (cid, ks) for cid, ks in matched_pairs if len(ks) >= KEYS_REQUIRED
    ]
    if len(matched_pairs) == 1:
        cid, keys_matched = matched_pairs[0]
        return (cid, keys_matched, len(candidates))
    return None  # 0 matches OR 2+ (ambiguous)


@dataclass
class ApplyIntent:
    """Result of applying a match. The caller persists this.

    A noop is produced when the claim is in a terminal state or the operation
    is not applicable. The caller should record an Activity row and skip any
    Claim mutation when `skipped is True`.
    """
    new_state: Optional[ClaimState] = None
    activity_kind: str = "reconcile"
    skipped: bool = False
    prior_claim_state: Optional[ClaimState] = None
    reason: str = ""

    @classmethod
    def noop(cls, *, activity_kind: str = "invalid_state", reason: str = "") -> "ApplyIntent":
        return cls(
            new_state=None,
            activity_kind=activity_kind,
            skipped=True,
            prior_claim_state=None,
            reason=reason,
        )


def apply_payment(
    claim: _ClaimLike,
    remit: _RemitLike,
    *,
    charge: Decimal,
    paid: Decimal,
    status_code: str = "",
) -> ApplyIntent:
    """Decide the Claim's new state given a non-reversal match.

    Rules:
    - If the claim is already in a terminal state (paid/partial/denied/reconciled),
      return a noop (the caller should record an invalid_state activity).
    - status_code == "4" → DENIED.
    - paid <= 0 → RECEIVED (claim acknowledged but no money yet).
    - paid >= charge → PAID in full.
    - 0 < paid < charge → PARTIAL.
    """
    if claim.state in (ClaimState.PAID.value, ClaimState.PARTIAL.value,
                       ClaimState.DENIED.value, ClaimState.RECONCILED.value):
        return ApplyIntent.noop(
            reason=f"claim already in terminal state {claim.state}",
        )

    if status_code == "4":
        new_state = ClaimState.DENIED
    elif paid is None or paid <= 0:
        new_state = ClaimState.RECEIVED
    elif paid >= charge:
        new_state = ClaimState.PAID
    else:
        new_state = ClaimState.PARTIAL

    return ApplyIntent(new_state=new_state, activity_kind="reconcile")


def apply_reversal(claim: _ClaimLike, remit: _RemitLike) -> ApplyIntent:
    """Apply a reversal: flip paid/partial → REVERSED, preserve prior state.

    Reversals on claims not in a paid/partial state are noops with
    activity_kind "reversal_skipped" so the caller can still log them.
    """
    if claim.state in (ClaimState.PAID.value, ClaimState.PARTIAL.value):
        return ApplyIntent(
            new_state=ClaimState.REVERSED,
            activity_kind="reversal",
            prior_claim_state=ClaimState(claim.state),
        )
    return ApplyIntent.noop(
        activity_kind="reversal_skipped",
        reason=f"reversal on state {claim.state}",
    )


def split_unmatched(
    claims: list[_ClaimLike],
    remits: list[_RemitLike],
    matches: list[Match],
) -> tuple[list[_ClaimLike], list[_RemitLike]]:
    """Return (unmatched_claims, unmatched_remits) after applying matches."""
    matched_claim_ids = {m.claim.id for m in matches}
    matched_remit_ids = {m.remittance.id for m in matches}
    return (
        [c for c in claims if c.id not in matched_claim_ids],
        [r for r in remits if r.id not in matched_remit_ids],
    )


# --- reconcile.run() orchestrator ---------------------------------------------


@dataclass
class ReconcileResult:
    matched: int = 0
    unmatched_claims: int = 0
    unmatched_remittances: int = 0
    skipped: int = 0


def run(session, batch_id: str) -> ReconcileResult:
    """Orchestrate reconciliation for an 835 batch.

    Expects Batch + Remittance + CasAdjustment rows already added to
    ``session`` (not yet committed). SP27 Task 10 calls this from
    inside ``CycloneStore.add``'s ingest session, BEFORE
    ``s.commit()`` — so the whole 835 ingest (rows + reconcile) is
    a single transaction. If anything here raises, the ingest rolls
    back; no half-reconciled batch ever lands.

    Loads the new remittances + all currently-unmatched Claims, calls
    match(), then applies each match's intent inside the caller's session.

    Returns counts. Does NOT commit; caller controls transaction.
    Raises on failure (caller catches and writes activity event).
    """
    from sqlalchemy import select
    from cyclone.db import (
        Batch, Claim, Remittance, CasAdjustment, Match as MatchORM, ActivityEvent,
        ClaimState,
    )

    new_remits = list(
        session.execute(
            select(Remittance).where(Remittance.batch_id == batch_id)
        ).scalars().all()
    )

    unmatched_claims = list(
        session.execute(
            select(Claim).where(Claim.matched_remittance_id.is_(None))
        ).scalars().all()
    )

    matches = match(unmatched_claims, new_remits)

    # SP31: content-keys fallback for remits that PCN-exact couldn't pair.
    # Operates on the still-unmatched remits so we don't double-match.
    matched_remit_ids = {m.remittance.id for m in matches}
    used_claim_ids = {m.claim.id for m in matches}
    for remit in new_remits:
        if remit.id in matched_remit_ids:
            continue
        if getattr(remit, "claim_id", None) is not None:
            continue  # already linked
        fallback_result = _score_fallback_candidates(session, remit)
        if fallback_result is None:
            continue
        matched_claim_id, keys_matched, candidate_count = fallback_result
        # Find the claim object in the unmatched_claims list (it was loaded).
        target_claim = next(
            (c for c in unmatched_claims if c.id == matched_claim_id),
            None,
        )
        if target_claim is None or target_claim.id in used_claim_ids:
            continue
        matches.append(Match(
            claim=target_claim, remittance=remit,
            strategy="score-auto", is_reversal=remit.is_reversal,
            keys_matched=keys_matched,
            candidate_count=candidate_count,
        ))
        used_claim_ids.add(target_claim.id)

    applied = 0
    skipped = 0
    for m in matches:
        if m.is_reversal:
            intent = apply_reversal(m.claim, m.remittance)
        else:
            intent = apply_payment(
                m.claim, m.remittance,
                charge=m.claim.charge_amount, paid=m.remittance.total_paid,
                status_code=m.remittance.status_code,
            )

        if intent.skipped:
            session.add(ActivityEvent(
                ts=datetime.now(timezone.utc), kind=intent.activity_kind,
                batch_id=batch_id, claim_id=m.claim.id,
                remittance_id=m.remittance.id,
                payload_json={"reason": intent.reason,
                              "current_state": getattr(m.claim.state, "value", m.claim.state)},
            ))
            skipped += 1
            continue

        session.add(MatchORM(
            claim_id=m.claim.id, remittance_id=m.remittance.id,
            strategy=m.strategy,
            matched_at=datetime.now(timezone.utc),
            prior_claim_state=intent.prior_claim_state,
            is_reversal=m.is_reversal,
        ))
        if not m.is_reversal:
            m.claim.state = intent.new_state
        else:
            m.claim.state = ClaimState.REVERSED
        m.claim.matched_remittance_id = m.remittance.id
        # Symmetric FK: also set Remittance.claim_id so list_unmatched (which
        # filters on Remittance.claim_id IS NULL) correctly drops auto-matched
        # remits from the orphan bucket. Manual matches do this too.
        m.remittance.claim_id = m.claim.id

        session.add(ActivityEvent(
            ts=datetime.now(timezone.utc),
            kind=("auto_matched_835" if m.strategy == "score-auto" else intent.activity_kind),
            batch_id=batch_id, claim_id=m.claim.id,
            remittance_id=m.remittance.id,
            payload_json=(
                {
                    "new_state": m.claim.state.value, "strategy": m.strategy,
                    # SP31 spec D8: the auto_matched_835 payload records
                    # which content-keys the 2-of-3 (or 3-of-3) rule
                    # agreed on, plus how many candidates were in the
                    # window pool. ``sorted()`` converts the set to a
                    # JSON-serializable list and pins a deterministic
                    # order for tests + audit reads.
                    "keys_matched": sorted(m.keys_matched),
                    "candidate_count": m.candidate_count,
                }
                if m.strategy == "score-auto"
                else {"new_state": m.claim.state.value}
            ),
        ))
        applied += 1

    # SP7: line-level reconciliation + claim-level CAS aggregate. For each
    # matched (claim, remit) pair, pair each 837 service line with the first
    # 835 SVC composite that matches on (procedure_code, modifiers,
    # service_date, units) and persist a LineReconciliation row per line on
    # either side (matched or explicitly unmatched). Also recompute the
    # remittance's CLP-level and total CAS aggregates.
    #
    # Architecture note: 837 service lines live in ``Claim.raw_json``, not a
    # separate ORM table. The ``LineReconciliation.claim_service_line_number``
    # column stores the 1-based line index; the SVC side uses
    # ``ServiceLinePayment`` (real ORM rows) so we get an FK to it.
    for m in matches:
        if m.is_reversal:
            continue  # reversals are handled by a separate path (see §7.3)
        _reconcile_pair(session, m.claim, m.remittance)

    # Partition.
    unmatched_claims_after, unmatched_remits_after = split_unmatched(
        unmatched_claims, new_remits,
        [m for m in matches if m.claim.id in {c.id for c in unmatched_claims}],
    )

    return ReconcileResult(
        matched=applied,
        unmatched_claims=len(unmatched_claims_after),
        unmatched_remittances=len(unmatched_remits_after),
        skipped=skipped,
    )


# ---------------------------------------------------------------------------
# SP7 §4 — per-service-line matching
# ---------------------------------------------------------------------------


@dataclass
class LineMatch:
    """One row of a line-level reconciliation result.

    At least one of ``claim_line`` / ``service_payment`` is non-None.
    Spec §4.1.
    """
    claim_line: Optional["_ClaimLineLike"] = None
    service_payment: Optional["_ServicePaymentLike"] = None
    status: str = ""  # matched | unmatched_835_only | unmatched_837_only


class _ClaimLineLike(Protocol):
    line_number: int
    procedure_code: str
    modifiers: list
    service_date: Optional[date]
    units: Optional[Decimal]


class _ServicePaymentLike(Protocol):
    line_number: int
    procedure_code: str
    modifiers: list
    service_date: Optional[date]
    units: Optional[Decimal]


def _norm_code(code: str) -> str:
    return (code or "").strip().upper()


def _norm_mods(mods) -> frozenset:
    return frozenset((m or "").strip().upper() for m in (mods or []) if m)


def _svc_matches_claim(svc, claim) -> bool:
    """Strict match per spec §4.2."""
    return (
        _norm_code(svc.procedure_code) == _norm_code(claim.procedure_code)
        and _norm_mods(svc.modifiers) == _norm_mods(claim.modifiers)
        and svc.service_date == claim.service_date
        and svc.units == claim.units
    )


def match_service_lines(
    claim_lines,
    service_payments,
) -> list:
    """Pair each 837 SV1 line with the first 835 SVC composite that matches.

    Spec §4. Unmatched lines on either side produce their own LineMatch
    rows so every line is accounted for in the persistence step.
    """
    matches: list = []
    used_svc_ids: set = set()

    for claim in sorted(claim_lines, key=lambda c: c.line_number):
        match_idx: Optional[int] = None
        for i, svc in enumerate(service_payments):
            if i in used_svc_ids:
                continue
            if _svc_matches_claim(svc, claim):
                match_idx = i
                break
        if match_idx is not None:
            used_svc_ids.add(match_idx)
            matches.append(LineMatch(
                claim_line=claim,
                service_payment=service_payments[match_idx],
                status="matched",
            ))
        else:
            matches.append(LineMatch(
                claim_line=claim,
                service_payment=None,
                status="unmatched_837_only",
            ))

    for i, svc in enumerate(service_payments):
        if i in used_svc_ids:
            continue
        matches.append(LineMatch(
            claim_line=None,
            service_payment=svc,
            status="unmatched_835_only",
        ))

    return matches


def _reconcile_pair(session, claim, remittance) -> None:
    """SP7: line-level reconciliation + claim-level CAS aggregate for one pair.

    Idempotent: clears existing ``LineReconciliation`` rows for the
    claim before inserting fresh ones, and recomputes the aggregate
    fields on the remittance regardless of prior values. Safe to call
    across manual_unmatch + manual_rematch cycles.

    Used by both ``reconcile.run()`` (after auto-match) and
    ``store.manual_match()`` (after operator override) so manual matches
    produce the same per-line audit data as auto matches. See spec §4.4.

    Steps:
      1. Delete any existing ``LineReconciliation`` rows for the claim.
      2. Read 837 service lines from ``Claim.raw_json``.
      3. Read ``ServiceLinePayment`` rows for the remittance.
      4. Run ``match_service_lines`` (pure) to pair SV1 ↔ SVC.
      5. Persist ``LineReconciliation`` rows.
      6. Recompute ``Remittance.claim_level_adjustment_amount`` from CAS
         rows with ``service_line_payment_id IS NULL`` (CLP-level).
      7. Recompute ``Remittance.adjustment_amount`` from all CAS rows.

    Does not commit; caller's session controls the transaction.
    """
    from sqlalchemy import select
    from cyclone.db import (
        CasAdjustment, LineReconciliation, ServiceLinePayment,
    )

    # 1: clear stale rows so re-running produces a clean slate. Stale
    # rows can appear after a manual_unmatch + manual_rematch cycle that
    # pairs the claim with a *different* remittance, leaving rows whose
    # ``service_line_payment_id`` references SVC rows no longer paired
    # with this claim.
    stale = list(
        session.execute(
            select(LineReconciliation)
            .where(LineReconciliation.claim_id == claim.id)
        ).scalars().all()
    )
    for lr in stale:
        session.delete(lr)

    # 2: read 837 service lines (JSON blob).
    raw = claim.raw_json or {}
    raw_lines = raw.get("service_lines") or []
    claim_lines = []
    for d in raw_lines:
        proc = d.get("procedure") or {}
        claim_lines.append(_ClaimLineShim(
            line_number=d.get("line_number"),
            procedure_code=proc.get("code", ""),
            modifiers=proc.get("modifiers") or [],
            service_date=_parse_iso_date(d.get("service_date")),
            units=Decimal(str(d["units"])) if d.get("units") is not None else None,
        ))

    # 3: read 835 SVC rows (real ORM).
    svc_payments = list(
        session.execute(
            select(ServiceLinePayment)
            .where(ServiceLinePayment.remittance_id == remittance.id)
            .order_by(ServiceLinePayment.line_number)
        ).scalars().all()
    )
    svc_shims = [_SvcPaymentShim.from_orm(s) for s in svc_payments]

    # 4-5: pure pair + persist.
    line_matches = match_service_lines(claim_lines, svc_shims)
    now = datetime.now(timezone.utc)
    for lm in line_matches:
        session.add(LineReconciliation(
            claim_id=claim.id,
            claim_service_line_number=lm.claim_line.line_number if lm.claim_line else None,
            service_line_payment_id=lm.service_payment.line_id if lm.service_payment else None,
            status=lm.status,
            reconciled_at=now,
        ))

    # 6: claim-level CAS aggregate (CLP-level adjustments, no SLI pointer).
    cl_total = session.execute(
        select(func.sum(CasAdjustment.amount))
        .where(CasAdjustment.remittance_id == remittance.id)
        .where(CasAdjustment.service_line_payment_id.is_(None))
    ).scalar_one() or Decimal("0")
    remittance.claim_level_adjustment_amount = cl_total

    # 7: total CAS aggregate.
    total = session.execute(
        select(func.sum(CasAdjustment.amount))
        .where(CasAdjustment.remittance_id == remittance.id)
    ).scalar_one() or Decimal("0")
    remittance.adjustment_amount = total


@dataclass
class _ClaimLineShim:
    """Adapt an 837 service-line dict to the _ClaimLineLike protocol.

    Used by ``reconcile.run()`` to call ``match_service_lines()`` against
    lines that live inside ``Claim.raw_json`` (no ORM table).
    """
    line_number: int
    procedure_code: str
    modifiers: list
    service_date: Optional[date]
    units: Optional[Decimal]


@dataclass
class _SvcPaymentShim:
    """Adapt an ORM ``ServiceLinePayment`` to the _ServicePaymentLike protocol."""

    line_id: int
    line_number: int
    procedure_code: str
    modifiers: list
    service_date: Optional[date]
    units: Optional[Decimal]

    @classmethod
    def from_orm(cls, svc) -> "_SvcPaymentShim":
        import json as _json
        return cls(
            line_id=svc.id,
            line_number=svc.line_number,
            procedure_code=svc.procedure_code,
            modifiers=_json.loads(svc.modifiers_json or "[]"),
            service_date=svc.service_date,
            units=Decimal(str(svc.units)) if svc.units is not None else None,
        )


def _parse_iso_date(value) -> Optional[date]:
    """Coerce a date / ISO-8601 string / None to ``datetime.date``."""
    if value is None:
        return None
    if isinstance(value, date):
        return value
    if isinstance(value, str):
        return date.fromisoformat(value)
    return None
