"""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
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


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="auto",
            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


@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 persisted
    (this is called from store.add() AFTER commit). 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, 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)

    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(Match(
            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
        # Migration 0014: composite back-reference to remittances — both
        # sides needed for get_claim_detail to JOIN back to remittances.
        m.claim.matched_remittance_batch_id = m.remittance.batch_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=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},
        ))
        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
