"""UI serialization helpers — convert ORM rows to API-shaped dicts.

These are the boundary between the persistence layer and the wire format
consumed by the React frontend. Keep them stable: any rename or
shape change ripples to every API consumer.

Also hosts ``utcnow()`` (the tz-aware UTC now) because it's a small
free function that several modules want and there's no better home.

SP25: ``to_ui_ack`` / ``to_ui_ta1_ack`` / ``to_ui_two77ca_ack`` were
moved here from ``api_routers/acks.py`` / ``api_routers/ta1_acks.py``
/ ``api.py`` so the live-tail event payload can match the list endpoint
shape byte-for-byte. The seam between persistence and streaming
depends on the two halves staying in sync.
"""

from __future__ import annotations

from datetime import datetime, timezone
from decimal import Decimal

from cyclone import db
from cyclone.db import Claim, Remittance
from cyclone.parsers.models import ClaimOutput
from cyclone.parsers.models_835 import ClaimPayment
from cyclone.parsers.payer import PayerConfig835

from .orm_builders import _claim_status_from_validation


def to_ui_claim(
    claim: ClaimOutput,
    *,
    batch_id: str,
    parsed_at: datetime,
) -> dict:
    """Map a 837P ClaimOutput to the UI's `Claim` shape (preserved)."""
    parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
    return {
        "id": claim.claim_id,
        "patientName": f"{claim.subscriber.first_name} {claim.subscriber.last_name}".strip(),
        "providerNpi": claim.billing_provider.npi,
        "payerName": claim.payer.name,
        "cptCode": (
            claim.service_lines[0].procedure.code
            if claim.service_lines
            else ""
        ),
        "billedAmount": float(claim.claim.total_charge or 0.0),
        "receivedAmount": 0.0,
        "status": _claim_status_from_validation(claim),
        "denialReason": None,
        "submissionDate": parsed_iso,
        "batchId": batch_id,
        "parsedAt": parsed_iso,
    }


def to_ui_remittance(
    cp: ClaimPayment,
    *,
    batch_id: str,
    parsed_at: datetime,
    payer_config: PayerConfig835 | None = None,
    payer_name: str = "",
) -> dict:
    """Map an 835 ClaimPayment to the UI's `Remittance` shape (preserved)."""
    code = cp.status_code
    if code in {"21", "22"}:
        status = "reconciled"
    else:
        status = "received"

    denial_reason: str | None = None
    if code == "4" and cp.service_payments:
        sp = cp.service_payments[0]
        if sp.adjustments:
            adj = sp.adjustments[0]
            denial_reason = (
                f"{adj.group_code}-{adj.reason_code}: ${float(adj.amount):.2f}"
            )

    cfg = payer_config if payer_config is not None else PayerConfig835.generic_835()
    validation_warnings: list[str] = []
    if code not in cfg.allowed_status_codes:
        validation_warnings.append(
            f"CLP02 code {code} not in payer allowlist"
        )

    # Aggregate adjustmentAmount across ALL service-line CAS rows, not just
    # the first line. Mirrors the SUM the reconcile aggregator (T10)
    # computes against persisted CasAdjustment rows; this inline version
    # is the write-path equivalent (used when streaming 835 NDJSON
    # responses before persistence finishes).
    adjustment_total = Decimal("0")
    for sp in cp.service_payments:
        for adj in sp.adjustments:
            adjustment_total += adj.amount

    parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
    return {
        "id": cp.payer_claim_control_number,
        "claimId": cp.original_claim_id or "",
        "payerName": payer_name,
        "paidAmount": float(cp.total_paid or 0.0),
        "adjustmentAmount": float(adjustment_total),
        "status": status,
        "denialReason": denial_reason,
        "validationWarnings": validation_warnings,
        "receivedDate": parsed_iso,
        "batchId": batch_id,
        "parsedAt": parsed_iso,
    }


def to_ui_claim_from_orm(
    row: Claim,
    *,
    batch_id: str,
    parsed_at: datetime,
    received_total: float = 0.0,
) -> dict:
    """Map an ORM ``Claim`` row to the UI's claim shape.

    ``to_ui_claim`` takes a Pydantic ``ClaimOutput`` (used on the write path
    during 837 ingest). For read paths — list_unmatched, manual_match return
    values — we already have the ORM row and the serialized fields it
    carries in ``raw_json``. Reading from ``raw_json`` keeps the UI shape
    in sync with the original 837 parse without re-deserializing to a
    Pydantic model.

    Adds two fields ``to_ui_claim`` doesn't emit: ``state`` (the
    reconciliation state machine value) and ``matchedRemittanceId`` (the
    FK to the paired remittance, or None). Both are required by the UI.
    """
    raw = row.raw_json or {}
    bp = raw.get("billing_provider", {})
    payer_obj = raw.get("payer", {})
    sub = raw.get("subscriber", {})
    service_lines = raw.get("service_lines", [])
    parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
    cpt = (
        service_lines[0].get("procedure", {}).get("code", "")
        if service_lines
        else ""
    )
    state_value = (
        row.state.value if hasattr(row.state, "value") else str(row.state)
    )
    return {
        "id": row.id,
        "state": state_value,
        "billedAmount": float(row.charge_amount or 0),
        "patientName": (
            f"{sub.get('first_name', '')} {sub.get('last_name', '')}".strip()
        ),
        "providerNpi": bp.get("npi") or row.provider_npi or "",
        "payerName": payer_obj.get("name") or "",
        "cptCode": cpt,
        "submissionDate": parsed_iso,
        "parsedAt": parsed_iso,
        "status": state_value,
        "matchedRemittanceId": row.matched_remittance_id,
        "batchId": batch_id,
        # Parity with ``to_ui_claim``'s shape — the UI tolerates extra keys
        # but expects these on freshly-loaded rows from /api/claims too.
        # ``received_total`` comes from the matched Remittance row when one
        # exists; callers that don't pre-compute it (write path, unmatched
        # list) get the default of 0.0 — which matches the unmapped state.
        "receivedAmount": float(received_total),
        "denialReason": None,
    }


# Max number of ActivityEvent rows surfaced in the detail drawer's
# state history. The spec caps it at 50; a higher claim volume (manual
# match/unmatch thrash) just shows the 50 most recent. Exposed as a
# module constant so the endpoint layer can pass it through as a
# default if it ever supports a `?limit=N` query param.
CLAIM_DETAIL_HISTORY_LIMIT = 50


def _iso_z(value: datetime | None) -> str:
    """Format a tz-aware-or-naive UTC datetime as ISO-8601 with trailing Z.

    The DB columns are declared ``DateTime(timezone=True)`` and rows are
    stored UTC at write time, but SQLite drops the tzinfo on read
    (returning a naive ``datetime``). Re-attach UTC for naive values
    so the spec contract holds: every ISO datetime field ends in Z.
    """
    if value is None:
        return ""
    if value.tzinfo is None:
        value = value.replace(tzinfo=timezone.utc)
    return value.isoformat().replace("+00:00", "Z")


def to_ui_ack(row: db.Ack) -> dict:
    """Map an ``Ack`` ORM row to the UI shape used by ``/api/acks``.

    Field names match the rest of the Cyclone API (snake_case). The
    frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack``
    interface in ``src/types/index.ts``.

    Adds ``patient_control_number`` pulled from ``raw_json`` so the
    operator can correlate each 999 back to the original claim batch.

    SP25: this was previously ``api_routers/acks._ack_to_ui``. Moved
    here so the live-tail event payload (``ack_received``) matches the
    list-endpoint shape — the seam between persistence and streaming
    depends on byte-for-byte equality.
    """
    body = {
        "id": row.id,
        "source_batch_id": row.source_batch_id,
        "accepted_count": row.accepted_count,
        "rejected_count": row.rejected_count,
        "received_count": row.received_count,
        "ack_code": row.ack_code,
        "parsed_at": (
            row.parsed_at.isoformat().replace("+00:00", "Z")
            if row.parsed_at is not None
            else ""
        ),
        "patient_control_number": None,
    }
    raw = row.raw_json or {}
    try:
        set_responses = raw.get("set_responses") or []
        if set_responses:
            body["patient_control_number"] = set_responses[0].get("set_control_number")
    except (AttributeError, TypeError):
        pass
    return body


def to_ui_ta1_ack(row: db.Ta1Ack) -> dict:
    """Map a ``Ta1Ack`` ORM row to the UI shape used by ``/api/ta1-acks``.

    SP25: this was previously ``api_routers/ta1_acks._ta1_to_ui``.
    Moved here so the live-tail event payload (``ta1_ack_received``)
    matches the list-endpoint shape byte-for-byte.

    Note: ``parsed_at`` uses naive ``isoformat()`` (no ``Z`` suffix) —
    preserved from the original implementation. SQLite strips tzinfo
    on read, so the field comes back as a naive datetime; we deliberately
    keep the original (non-``Z``) rendering rather than re-attaching
    UTC, because changing the wire shape now would drift both halves
    of the live-tail contract.
    """
    return {
        "id": row.id,
        "control_number": row.control_number,
        "ack_code": row.ack_code,
        "note_code": row.note_code,
        "interchange_date": row.interchange_date.isoformat()
            if row.interchange_date else None,
        "interchange_time": row.interchange_time,
        "sender_id": row.sender_id,
        "receiver_id": row.receiver_id,
        "source_batch_id": row.source_batch_id,
        "parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
    }


def to_ui_two77ca_ack(row: db.Two77caAck) -> dict:
    """Map a ``Two77caAck`` ORM row to the UI shape used by ``/api/277ca-acks``.

    SP25: this was previously ``api._277ca_to_ui`` inlined alongside
    the 277CA list endpoint. Moved here so the live-tail event
    payload (``two77ca_ack_received``) matches the list-endpoint
    shape byte-for-byte.

    Note: ``parsed_at`` uses naive ``isoformat()`` (no ``Z`` suffix) —
    same SQLite-tzinfo caveat as :func:`to_ui_ta1_ack`.
    """
    return {
        "id": row.id,
        "control_number": row.control_number,
        "accepted_count": row.accepted_count,
        "rejected_count": row.rejected_count,
        "paid_count": row.paid_count,
        "pended_count": row.pended_count,
        "source_batch_id": row.source_batch_id,
        "parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
    }


def _address_to_ui(addr: dict | None) -> dict:
    """Render a raw ``Address`` dict in the spec's parties address shape.

    Returns an empty dict when the source is missing so the UI can
    branch on the field's presence rather than the value. The spec
    shape is ``{line1, line2|null, city, state, zip}``.
    """
    if not addr:
        return {}
    return {
        "line1": addr.get("line1") or "",
        "line2": addr.get("line2"),
        "city": addr.get("city") or "",
        "state": addr.get("state") or "",
        "zip": addr.get("zip") or "",
    }


def _validation_issues_to_ui(issues: list[dict] | None) -> list[dict]:
    """Project ValidationIssue dicts onto the spec's per-issue shape.

    Source includes ``segment_index`` (a parser debug aid) which the
    spec doesn't surface; we drop it. The endpoint contract is
    ``{rule, severity, message}`` per issue.
    """
    if not issues:
        return []
    return [
        {
            "rule": issue.get("rule", ""),
            "severity": issue.get("severity", "error"),
            "message": issue.get("message", ""),
        }
        for issue in issues
    ]


def to_ui_claim_detail(
    row: Claim,
    *,
    batch_id: str,
    parsed_at: datetime,
) -> dict:
    """Map an ORM ``Claim`` row to the SP4 detail-drawer UI shape.

    A superset of :func:`to_ui_claim_from_orm`: same top-level identity
    fields, plus the full parties / validation / service-lines /
    diagnoses / raw-segments / service-date / state-label payload that
    the drawer needs. ``matchedRemittance`` and ``stateHistory`` are
    *not* filled in here — they require extra queries and are stitched
    in by :meth:`CycloneStore.get_claim_detail`.

    The mapper is deliberately a pure function (no DB I/O) so the
    endpoint layer can call it from a worker thread or swap the
    history/remittance sources for tests without re-implementing the
    body.
    """
    raw = row.raw_json or {}
    bp = raw.get("billing_provider", {}) or {}
    payer_obj = raw.get("payer", {}) or {}
    sub = raw.get("subscriber", {}) or {}
    service_lines = raw.get("service_lines", []) or []
    diagnoses = raw.get("diagnoses", []) or []
    validation = raw.get("validation", {}) or {}
    raw_segments = raw.get("raw_segments", []) or []

    parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
    state_value = (
        row.state.value if hasattr(row.state, "value") else str(row.state)
    )

    # Service dates come from the dedicated ORM columns (denormalized at
    # ingest in _claim_837_row so the list views can sort/filter on
    # them without a JSON parse). ``isoformat()`` on a ``date`` gives
    # ``YYYY-MM-DD`` — the spec shape.
    service_date_from_iso = (
        row.service_date_from.isoformat() if row.service_date_from else None
    )
    service_date_to_iso = (
        row.service_date_to.isoformat() if row.service_date_to else None
    )

    return {
        # -- identity + state -----------------------------------------
        "id": row.id,
        "batchId": batch_id,
        "state": state_value,
        "stateLabel": state_value.capitalize(),
        # -- money + dates --------------------------------------------
        "billedAmount": float(row.charge_amount or 0),
        "serviceDateFrom": service_date_from_iso,
        "serviceDateTo": service_date_to_iso,
        "submissionDate": parsed_iso,
        "parsedAt": parsed_iso,
        # -- patient / provider / payer -------------------------------
        "patientName": (
            f"{sub.get('first_name', '')} {sub.get('last_name', '')}".strip()
        ),
        "providerNpi": bp.get("npi") or row.provider_npi or "",
        "providerName": bp.get("name") or "",
        "payerName": payer_obj.get("name") or "",
        "payerId": payer_obj.get("id") or row.payer_id or "",
        # -- diagnoses ------------------------------------------------
        "diagnoses": [
            {
                "code": d.get("code", ""),
                "qualifier": d.get("qualifier"),
            }
            for d in diagnoses
        ],
        # -- service lines --------------------------------------------
        # ``service_lines[i].procedure`` is a nested dict in the
        # serialized raw_json; the spec flattens it into the line.
        # ``charge`` and ``units`` are stored as Decimal via Pydantic
        # and serialized to string — coerce defensively. ``modifiers``
        # defaults to [] so the UI doesn't have to handle null.
        "serviceLines": [
            {
                "lineNumber": sl.get("line_number"),
                "procedureQualifier": (
                    sl.get("procedure", {}).get("qualifier", "") or ""
                ),
                "procedureCode": (
                    sl.get("procedure", {}).get("code", "") or ""
                ),
                "modifiers": list(
                    sl.get("procedure", {}).get("modifiers") or []
                ),
                "charge": float(sl.get("charge") or 0),
                "units": (
                    float(sl["units"])
                    if sl.get("units") is not None
                    else None
                ),
                "unitType": sl.get("unit_type"),
                "serviceDate": sl.get("service_date"),
            }
            for sl in service_lines
        ],
        # -- parties --------------------------------------------------
        "parties": {
            "billingProvider": {
                "name": bp.get("name") or "",
                "npi": bp.get("npi") or "",
                "taxId": bp.get("tax_id") or "",
                "address": _address_to_ui(bp.get("address")),
            },
            "subscriber": {
                "firstName": sub.get("first_name") or "",
                "lastName": sub.get("last_name") or "",
                "memberId": sub.get("member_id") or "",
                "dob": sub.get("dob"),
                "gender": sub.get("gender"),
            },
            "payer": {
                "name": payer_obj.get("name") or "",
                "id": payer_obj.get("id") or "",
            },
        },
        # -- validation ----------------------------------------------
        "validation": {
            "passed": bool(validation.get("passed", True)),
            "errors": _validation_issues_to_ui(validation.get("errors")),
            "warnings": _validation_issues_to_ui(validation.get("warnings")),
        },
        # -- raw segments (debug aid) --------------------------------
        "rawSegments": raw_segments,
        # -- matched remittance (filled by get_claim_detail) ---------
        "matchedRemittance": None,
        # -- state history (filled by get_claim_detail) --------------
        "stateHistory": [],
    }


def to_ui_remittance_from_orm(
    row: Remittance,
    *,
    batch_id: str,
    parsed_at: datetime,
) -> dict:
    """Map an ORM ``Remittance`` row to the UI's remittance shape.

    Same idea as ``to_ui_claim_from_orm``: read the PayerName from the
    parent batch's ``raw_result_json`` (the ParseResult835 stashed at
    insert time) since ``Remittance`` itself doesn't carry it.
    """
    parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
    payer_name = ""
    if row.batch is not None and row.batch.raw_result_json:
        payer_obj = row.batch.raw_result_json.get("payer", {}) or {}
        payer_name = payer_obj.get("name") or ""
    status = (
        "reconciled" if row.status_code in ("21", "22") else "received"
    )
    return {
        "id": row.id,
        "payerClaimControlNumber": row.payer_claim_control_number,
        "claimId": row.claim_id or "",
        "payerName": payer_name,
        "paidAmount": float(row.total_paid or 0),
        "adjustmentAmount": float(row.adjustment_amount or 0),
        "status": status,
        "denialReason": None,
        "validationWarnings": [],
        "receivedDate": row.received_at.isoformat().replace("+00:00", "Z"),
        "batchId": batch_id,
        "parsedAt": parsed_iso,
    }


def to_ui_remittance_with_adjustments(
    row: Remittance,
    *,
    batch_id: str,
    parsed_at: datetime,
    cas_rows: list["db.CasAdjustment"] | None = None,
) -> dict:
    """Same shape as :func:`to_ui_remittance_from_orm` plus an ``adjustments`` array.

    Each persisted ``CasAdjustment`` row is rendered as
    ``{"group", "reason", "label", "amount", "quantity"}``. The ``label``
    is resolved through :func:`cyclone.parsers.cas_codes.reason_label`
    so the UI does not have to ship its own CARC dictionary.

    ``cas_rows`` is optional so callers that don't have the rows handy
    can still get the base dict; in that case ``adjustments`` is ``[]``.
    Pass ``cas_rows`` to avoid an extra round-trip; the endpoint at
    ``GET /api/remittances/{id}`` passes them in to keep this mapper a
    pure function.
    """
    base = to_ui_remittance_from_orm(
        row, batch_id=batch_id, parsed_at=parsed_at,
    )
    if not cas_rows:
        base["adjustments"] = []
        return base

    # Lazy import to avoid the circular store ↔ parsers import that
    # happens on cold start; mirrors the same pattern used elsewhere
    # in this module.
    from cyclone.parsers.cas_codes import reason_label

    base["adjustments"] = [
        {
            "group": c.group_code,
            "reason": c.reason_code,
            "label": reason_label(c.group_code, c.reason_code),
            "amount": float(c.amount or 0),
            "quantity": (
                float(c.quantity) if c.quantity is not None else None
            ),
        }
        for c in cas_rows
    ]
    return base


def _svc_to_wire_dict(svc) -> dict:
    """Project an ORM ``ServiceLinePayment`` to the wire format used by
    the remit drawer's ``serviceLinePayments`` array.

    Mirrors the shape produced by the line-reconciliation endpoint so
    the UI can render the same components from either source.
    """
    import json as _json
    return {
        "id": svc.id,
        "line_number": svc.line_number,
        "procedure_qualifier": svc.procedure_qualifier,
        "procedure_code": svc.procedure_code,
        "modifiers": _json.loads(svc.modifiers_json or "[]"),
        "charge": str(Decimal(str(svc.charge))),
        "payment": str(Decimal(str(svc.payment))),
        "units": str(Decimal(str(svc.units))) if svc.units is not None else None,
        "unit_type": svc.unit_type,
        "service_date": svc.service_date.isoformat() if svc.service_date else None,
    }


def to_ui_provider(
    *,
    npi: str,
    name: str,
    tax_id: str | None = None,
    address: str | None = None,
    city: str | None = None,
    state: str | None = None,
    zip: str | None = None,
    phone: str | None = None,
    claim_count: int = 0,
    outstanding_ar: float = 0.0,
) -> dict:
    return {
        "npi": npi,
        "name": name,
        "taxId": tax_id or "",
        "address": address or "",
        "city": city or "",
        "state": state or "",
        "zip": zip or "",
        "phone": phone or "",
        "claimCount": claim_count,
        "outstandingAr": float(outstanding_ar),
    }


def to_activity_event(
    *,
    id: str,
    kind: str,
    message: str,
    timestamp: datetime,
    npi: str | None = None,
    amount: float | None = None,
) -> dict:
    return {
        "id": id,
        "kind": kind,
        "message": message,
        "timestamp": timestamp.isoformat().replace("+00:00", "Z"),
        "npi": npi,
        "amount": amount,
    }


def _date_in_bounds(
    item: dict,
    field: str,
    date_from: str | None,
    date_to: str | None,
) -> bool:
    """True if ``item[field]`` falls within ``[date_from, date_to]``."""
    val = item.get(field)
    if val is None:
        return date_from is None and date_to is None
    date_part = val[:10]
    if date_from is not None and date_part < date_from:
        return False
    if date_to is not None and date_part > date_to:
        return False
    return True