"""Side-by-side diff between two parsed batches.

Powers ``GET /api/batch-diff?a=<batch_id>&b=<batch_id>``. Given two
``BatchRecord`` objects (kind=837P or 835), project each contained
claim/remittance to a small ``BatchClaimSummary`` and split the union
into three buckets:

  - ``added``   — in B, not in A (by claim_id / payer_claim_control_number)
  - ``removed`` — in A, not in B
  - ``changed`` — in both, with at least one differing field

The diff is purely value-based (no segment-level deep diff). The four
fields we surface as "changed" are:

  - status       (837P: validation-derived label, 835: CLP02 code)
  - total_charge (Decimal → number; tolerance 1e-2 to ignore float jitter)
  - service_date (ISO date; missing vs present counts as a change)
  - cpt_codes    (sorted list equality)

The module is a pure function on Pydantic models — no DB I/O — so the
API endpoint and tests can both call it without session management.
"""

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Iterable

from cyclone.parsers.models import ClaimOutput, ValidationReport
from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
from cyclone.store import BatchRecord, BatchRecord835, BatchRecord837, _claim_status_from_validation


# ---------------------------------------------------------------------------
# Projected types
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class BatchClaimSummary:
    """Small per-claim shape returned in the diff payload.

    Kept tiny on purpose — the diff view shouldn't carry the full EDI
    payload. Raw segments and diagnoses live on the per-batch detail
    endpoint; this view is what the operator uses to spot the deltas
    between A and B at a glance.

    ``service_date`` and ``cpt_codes`` are optional because 835
    remittance rows don't carry the patient name; we surface empty
    strings / empty lists rather than synthesizing values so the UI can
    distinguish "no data" from "blank string".
    """

    claim_id: str
    patient_name: str
    total_charge: float
    service_date: str | None
    status: str
    cpt_codes: list[str]

    def to_dict(self) -> dict[str, Any]:
        return {
            "claim_id": self.claim_id,
            "patient_name": self.patient_name,
            "total_charge": self.total_charge,
            "service_date": self.service_date,
            "status": self.status,
            "cpt_codes": list(self.cpt_codes),
        }


@dataclass(frozen=True)
class FieldDiff:
    """One changed field between two ``BatchClaimSummary`` rows.

    ``a`` and ``b`` are the field values from each side. We don't
    attempt to coerce types — the frontend renders them verbatim — but
    the comparator picks the field-specific projection (e.g. charge as
    a number) so equality is stable.
    """

    field: str
    a: Any
    b: Any

    def to_dict(self) -> dict[str, Any]:
        return {"field": self.field, "a": self.a, "b": self.b}


@dataclass(frozen=True)
class ChangedRow:
    """One claim present in both batches with at least one field delta.

    Always carries both summaries (so the renderer can fall back to
    a single side if a field is missing) plus the diff list (possibly
    multiple fields changed for the same claim).
    """

    a: BatchClaimSummary
    b: BatchClaimSummary
    diff: list[FieldDiff]

    def to_dict(self) -> dict[str, Any]:
        return {
            "a": self.a.to_dict(),
            "b": self.b.to_dict(),
            "diff": [d.to_dict() for d in self.diff],
        }


@dataclass(frozen=True)
class BatchDiffResult:
    """Full diff between two batches.

    Mirrors the API contract documented in the task spec. Counts are
    precomputed so the frontend doesn't have to derive them.
    """

    a_meta: dict[str, Any]
    b_meta: dict[str, Any]
    added: list[BatchClaimSummary]
    removed: list[BatchClaimSummary]
    changed: list[ChangedRow]

    @property
    def summary(self) -> dict[str, int]:
        added_count = len(self.added)
        removed_count = len(self.removed)
        changed_count = len(self.changed)
        # ``unchanged`` are the claims present in BOTH batches that have
        # no field differences. Derivation from A's total:
        #   A's total = unchanged + changed + removed
        # so unchanged = A_total - changed - removed. Clamp at zero so
        # a malformed batch pair can never surface a negative count.
        a_total = len(self.a_meta.get("claim_ids", []) or [])
        unchanged_count = max(0, a_total - changed_count - removed_count)
        return {
            "addedCount": added_count,
            "removedCount": removed_count,
            "changedCount": changed_count,
            "unchangedCount": unchanged_count,
        }

    def to_dict(self) -> dict[str, Any]:
        return {
            "a": self.a_meta,
            "b": self.b_meta,
            "added": [s.to_dict() for s in self.added],
            "removed": [s.to_dict() for s in self.removed],
            "changed": [c.to_dict() for c in self.changed],
            "summary": self.summary,
        }


# ---------------------------------------------------------------------------
# Projection helpers
# ---------------------------------------------------------------------------


def _summary_from_claim_837(claim: ClaimOutput) -> BatchClaimSummary:
    """Project a 837P ``ClaimOutput`` to the diff summary shape."""
    # Patient name: same composition as to_ui_claim.
    sub = claim.subscriber
    patient_name = f"{sub.first_name} {sub.last_name}".strip()
    # Total charge: coerce Decimal to float; 0.0 when None.
    total_charge = float(claim.claim.total_charge or Decimal("0"))
    # Earliest service date across the SV1 service lines (matches
    # store._service_dates_from_claim convention).
    service_dates: list = [
        sl.service_date for sl in claim.service_lines if sl.service_date is not None
    ]
    service_date: str | None = None
    if service_dates:
        earliest = min(service_dates)
        # The pydantic model serializes dates to ISO strings via the
        # @model_serializer; pull isoformat defensively for both
        # date and datetime inputs (Pydantic coerces in either case).
        service_date = earliest.isoformat() if hasattr(earliest, "isoformat") else str(earliest)
    # CPT codes: collect the procedure.code across all SV1s, in line order.
    cpt_codes = [
        sl.procedure.code for sl in claim.service_lines if sl.procedure.code
    ]
    return BatchClaimSummary(
        claim_id=claim.claim_id,
        patient_name=patient_name,
        total_charge=total_charge,
        service_date=service_date,
        status=_claim_status_from_validation(claim),
        cpt_codes=cpt_codes,
    )


def _summary_from_remit_835(cp: ClaimPayment) -> BatchClaimSummary:
    """Project an 835 ``ClaimPayment`` to the diff summary shape.

    Patient name isn't carried on an ERA — we leave it blank rather
    than synthesize. Total charge / paid amount aren't on the
    ``BatchClaimSummary`` contract; we expose the 835's ``total_charge``
    on the BPR total so it lines up with the 837P's CLM01 amount.
    """
    total_charge = float(cp.total_charge or Decimal("0"))
    service_date: str | None = None
    cpt_codes: list[str] = []
    if cp.service_payments:
        sp = cp.service_payments[0]
        if sp.service_date is not None:
            service_date = (
                sp.service_date.isoformat()
                if hasattr(sp.service_date, "isoformat")
                else str(sp.service_date)
            )
        # CPT codes from every SVC segment in the CLP loop.
        for svc in cp.service_payments:
            if svc.procedure_code:
                cpt_codes.append(svc.procedure_code)
    # CLP02 status code as the primary status label; fall back to the
    # human-readable label when one is bundled in the parser.
    status = cp.status_label or cp.status_code or ""
    return BatchClaimSummary(
        # ``payer_claim_control_number`` is the ERA's natural id and the
        # match key against another batch's 835 (matches on CLP01).
        claim_id=cp.payer_claim_control_number,
        patient_name="",
        total_charge=total_charge,
        service_date=service_date,
        status=status,
        cpt_codes=cpt_codes,
    )


def _summarize_record(rec: BatchRecord) -> list[BatchClaimSummary]:
    """Return the list of summaries for a batch — 837P or 835.

    Dispatched by ``isinstance`` so callers don't need to branch on
    ``kind``. Returns an empty list when the record's ``result`` is
    missing the expected claim array (defensive against partially-loaded
    rows in tests).
    """
    if isinstance(rec, BatchRecord837):
        return [_summary_from_claim_837(c) for c in (rec.result.claims or [])]
    if isinstance(rec, BatchRecord835):
        return [_summary_from_remit_835(c) for c in (rec.result.claims or [])]
    # Unknown record type — return empty so the diff still computes.
    return []


# ---------------------------------------------------------------------------
# Field comparison
# ---------------------------------------------------------------------------


def _eq_charge(a: float, b: float) -> bool:
    """Two floats are equal if they differ by less than a cent.

    Decimal → float coercion can introduce sub-cent noise when the
    source had more than 2 decimal places; tolerate that here so we
    don't surface spurious charge deltas.
    """
    return abs(a - b) < 0.01


def _diff_pair(
    a: BatchClaimSummary,
    b: BatchClaimSummary,
    *,
    compare_status: bool,
) -> list[FieldDiff]:
    """Return the list of changed fields between two same-id summaries.

    Compares the four fields documented in the module docstring; an
    unchanged field is simply not in the returned list.

    ``compare_status`` controls whether ``status`` enters the diff:
      - True when both batches are the same kind (837P vs 837P, or
        835 vs 835). 837P's validation-derived status (``submitted`` /
        ``pending`` / ``denied`` / ``draft``) and 835's CLP02 code
        (``1`` / ``4`` / ``21`` / …) live in different namespaces, so
        comparing across kinds surfaces spurious deltas the operator
        can't act on. We skip the comparison when kinds differ.
    """
    out: list[FieldDiff] = []
    if compare_status and a.status != b.status:
        out.append(FieldDiff("status", a.status, b.status))
    if not _eq_charge(a.total_charge, b.total_charge):
        out.append(FieldDiff("total_charge", a.total_charge, b.total_charge))
    if a.service_date != b.service_date:
        out.append(FieldDiff("service_date", a.service_date, b.service_date))
    # CPT codes are compared as sorted, deduplicated lists so reordering
    # between SV1 segments doesn't register as a diff.
    a_codes = sorted(set(a.cpt_codes))
    b_codes = sorted(set(b.cpt_codes))
    if a_codes != b_codes:
        out.append(FieldDiff("cpt_codes", a_codes, b_codes))
    return out


def _meta(rec: BatchRecord, *, claim_ids: Iterable[str]) -> dict[str, Any]:
    """Build the ``a``/``b`` metadata block for the diff envelope.

    The ``claim_ids`` list is passed in by the caller (the same source
    used for the summaries) so the meta block is consistent with what
    the diff actually saw.
    """
    parsed_at = rec.parsed_at
    parsed_iso = parsed_at.isoformat().replace("+00:00", "Z") if parsed_at else ""
    return {
        "id": rec.id,
        "kind": rec.kind,
        "parsedAt": parsed_iso,
        "inputFilename": rec.input_filename,
        "claimCount": len(list(claim_ids)),
        # Internal-only — stripped from the wire response below. The
        # API layer recomputes counts via to_dict, so this key is
        # used solely for the ``summary.unchangedCount`` derivation.
        "claim_ids": list(claim_ids),
    }


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def diff_batches(
    a: BatchRecord,
    b: BatchRecord,
) -> BatchDiffResult:
    """Compute the side-by-side diff between ``a`` and ``b``.

    Both records can be 837P, both 835, or one of each — we project
    each to ``BatchClaimSummary`` independently and match by the
    summary's ``claim_id`` field (837P ``CLM01``, 835 ``CLP01``). When
    the kinds differ the result is still well-defined; it just means
    you're comparing e.g. an 837P claim set against the matching
    remittances — useful when the operator wants to see which claims
    were paid vs. submitted.

    Order preservation:
      - ``added`` follows B's input order (matches the upload narrative).
      - ``removed`` follows A's input order.
      - ``changed`` follows B's input order (the "current state" side).

    This mirrors git-diff conventions where the right-hand side drives
    the row order on the page.
    """
    a_summaries = _summarize_record(a)
    b_summaries = _summarize_record(b)
    a_by_id = {s.claim_id: s for s in a_summaries}
    b_by_id = {s.claim_id: s for s in b_summaries}

    # Status only carries comparable meaning when both batches share a
    # kind (837P/837P or 835/835). Across kinds the namespaces differ
    # (validation-derived vs CLP02) so we skip that field.
    compare_status = a.kind == b.kind

    added: list[BatchClaimSummary] = []
    removed: list[BatchClaimSummary] = []
    changed: list[ChangedRow] = []

    # Walk B's order for added/changed (B drives row order).
    for s in b_summaries:
        if s.claim_id not in a_by_id:
            added.append(s)
            continue
        other = a_by_id[s.claim_id]
        diff = _diff_pair(other, s, compare_status=compare_status)
        if diff:
            changed.append(ChangedRow(a=other, b=s, diff=diff))

    # Walk A's order for removed (anything in A but not in B).
    for s in a_summaries:
        if s.claim_id not in b_by_id:
            removed.append(s)

    a_meta = _meta(a, claim_ids=[s.claim_id for s in a_summaries])
    b_meta = _meta(b, claim_ids=[s.claim_id for s in b_summaries])
    return BatchDiffResult(
        a_meta=a_meta,
        b_meta=b_meta,
        added=added,
        removed=removed,
        changed=changed,
    )


def diff_batches_to_wire(
    a: BatchRecord,
    b: BatchRecord,
) -> dict[str, Any]:
    """Convenience wrapper that returns the JSON-ready dict.

    Strips the internal ``claim_ids`` key from the metadata blocks
    before returning so the wire contract stays clean — the spec only
    promises ``id, kind, parsedAt, inputFilename, claimCount`` per side.
    """
    result = diff_batches(a, b)
    wire = result.to_dict()
    wire["a"].pop("claim_ids", None)
    wire["b"].pop("claim_ids", None)
    return wire
