"""Write path for parsed batches — insert, reconcile, publish.

The single entry point is ``add_record(record, *, event_bus=None)``,
which replaces the body of the previous ``CycloneStore.add`` method.
It owns its own SQLAlchemy session, runs idempotency checks, persists
the batch + child rows, then (for 835 batches) triggers reconciliation
and (if ``event_bus`` is provided) publishes live-tail events.

Reconciliation runs OUTSIDE the persistence session, fail-soft: errors
are logged but do not roll back the persisted batch.
"""

from __future__ import annotations

import json

from cyclone import db
from cyclone.db import (
    ActivityEvent,
    Batch,
    Claim,
    Remittance,
)
from cyclone.parsers.models import ParseResult
from cyclone.parsers.models_835 import ParseResult835
from .orm_builders import _claim_837_row, _persist_835_remit, _remittance_835_row
from .records import BatchRecord, BatchRecord837, BatchRecord835
from .ui import to_ui_claim_from_orm, to_ui_remittance_from_orm


def add_record(record: BatchRecord, *, event_bus=None) -> None:
    """Persist a parsed batch (837P or 835) to the DB.

    For 837P batches: inserts the Batch row, one Claim row per
    claim, and a ``claim_submitted`` ActivityEvent per claim.

    For 835 batches: inserts the Batch row, one Remittance row per
    ClaimPayment, and a ``remit_received`` ActivityEvent per
    ClaimPayment. Reconciliation (auto-match + per-pair CAS
    aggregate) runs IN THE SAME SESSION before commit (SP27
    Task 10) — a single ``s.commit()`` covers both ingest and
    reconciliation. If reconcile raises, the whole 835 ingest
    rolls back; the batch never appears half-reconciled with
    placeholder ``adjustment_amount`` values.

    Idempotency: ``Claim.id`` and ``Remittance.id`` are PRIMARY KEYS,
    so a re-ingest of the same fixture (e.g. ``/api/parse-837`` called
    twice with the same file) would otherwise raise
    ``IntegrityError``. We do a per-row ``session.get(...)`` check
    before each insert; if the row already exists, we log a warning
    and skip. The batch row itself is still inserted (each parse
    has a fresh ``uuid4`` id from the API). O(n) per row, but
    acceptable for the small fixture sizes — production load is
    one batch at a time via the API, not bulk inserts.

    When ``event_bus`` is provided, publishes one ``claim_written``
    or ``remittance_written`` event per newly-inserted row plus an
    ``activity_recorded`` event per activity row, after commit. The
    publish calls are best-effort — failures are logged but do not
    roll back the persisted batch.
    """
    from cyclone.pubsub import EventBus

    import logging
    log = logging.getLogger(__name__)

    # Track rows we actually inserted so we can publish events for them.
    inserted_claim_ids: list[str] = []
    inserted_remit_ids: list[str] = []

    with db.SessionLocal()() as s:
        batch_row = Batch(
            id=record.id,
            kind=record.kind,
            input_filename=record.input_filename,
            parsed_at=record.parsed_at,
            totals_json=None,
            validation_json=None,
            raw_result_json=json.loads(record.result.model_dump_json()),
        )
        s.add(batch_row)

        if isinstance(record, BatchRecord837):
            result: ParseResult = record.result
            for claim in result.claims:
                if s.get(Claim, claim.claim_id) is not None:
                    log.warning(
                        "add: claim %s already exists; skipping (batch=%s)",
                        claim.claim_id, record.id,
                    )
                    continue
                s.add(_claim_837_row(claim, record.id))
                s.add(ActivityEvent(
                    ts=record.parsed_at,
                    kind="claim_submitted",
                    batch_id=record.id,
                    claim_id=claim.claim_id,
                    payload_json={
                        "message": (
                            f"Claim {claim.claim_id} submitted · "
                            f"{claim.payer.name}"
                        ),
                        "npi": claim.billing_provider.npi,
                        "amount": float(claim.claim.total_charge or 0.0),
                    },
                ))
                inserted_claim_ids.append(claim.claim_id)
        elif isinstance(record, BatchRecord835):
            result835: ParseResult835 = record.result
            payer_name = result835.payer.name
            for cp in result835.claims:
                if s.get(Remittance, cp.payer_claim_control_number) is not None:
                    log.warning(
                        "add: remittance %s already exists; skipping (batch=%s)",
                        cp.payer_claim_control_number, record.id,
                    )
                    continue
                remit_row = _remittance_835_row(cp, record.id)
                s.add(remit_row)
                # Flush so remit_row.id (FK target of cas_adjustments) is
                # populated. SQLAlchemy assigns the PK on flush; without
                # this the CasAdjustment inserts below would reference an
                # unset id and violate the FK.
                s.flush()
                # SP7: persist per-line ServiceLinePayment + linked
                # SVC-level CAS rows + claim-level CAS bucket. Replaces
                # the previous per-SVC CAS insert loop so the
                # service_line_payment_id FK is set correctly.
                _persist_835_remit(s, cp, remit_row.id)
                s.add(ActivityEvent(
                    ts=record.parsed_at,
                    kind="remit_received",
                    batch_id=record.id,
                    remittance_id=cp.payer_claim_control_number,
                    payload_json={
                        "message": (
                            f"Remit {cp.payer_claim_control_number} received"
                        ),
                        "payerName": payer_name,
                        "amount": float(cp.total_paid or 0.0),
                    },
                ))
                inserted_remit_ids.append(cp.payer_claim_control_number)
        else:
            raise TypeError(
                f"Unsupported BatchRecord subclass: {type(record).__name__}"
            )

        # SP27 Task 10: run reconcile INSIDE the same session, before
        # commit. The placeholder ``adjustment_amount`` set by
        # ``_remittance_835_row`` is overwritten by reconcile's
        # ``_reconcile_pair`` SUM-of-CAS-aggregate pass before the
        # rows are ever visible. If reconcile raises, the whole
        # 835 ingest rolls back and the batch never lands.
        if record.kind == "835":
            from cyclone import reconcile as _reconcile
            _reconcile.run(s, record.id)

        s.commit()

    # Publish live-tail events synchronously. EventBus.publish is async
    # but its body is purely synchronous ``put_nowait`` enqueues; we
    # bypass the async wrapper and call the internal enqueue directly
    # so callers (sync FastAPI endpoints, sync test harnesses) don't
    # need to await.
    if event_bus is not None and (inserted_claim_ids or inserted_remit_ids):
        publish_events_sync(
            event_bus, record, inserted_claim_ids, inserted_remit_ids,
        )


def publish_events_sync(
    event_bus,
    record: BatchRecord,
    claim_ids: list[str],
    remit_ids: list[str],
) -> None:
    """Build UI-shaped payloads for newly-inserted rows and publish.

    Runs after commit so subscribers can immediately re-fetch from
    the API and see consistent data. Each ``claim_written`` /
    ``remittance_written`` payload is identical to what the matching
    list endpoint would return for that row.

    This is sync because EventBus's enqueue path is sync; we don't
    need a coroutine for ``put_nowait``.
    """
    from cyclone.pubsub import EventBus

    import logging
    log = logging.getLogger(__name__)

    try:
        with db.SessionLocal()() as s:
            for cid in claim_ids:
                row = s.get(Claim, cid)
                if row is None:
                    continue
                ui = to_ui_claim_from_orm(
                    row, batch_id=row.batch_id or record.id,
                    parsed_at=record.parsed_at,
                    # Fresh ingest — no remittance has been paired yet,
                    # so ``Received`` is necessarily 0.
                    received_total=0.0,
                )
                _sync_publish(event_bus, "claim_written", ui)
            for rid in remit_ids:
                row = s.get(Remittance, rid)
                if row is None:
                    continue
                ui = to_ui_remittance_from_orm(
                    row, batch_id=row.batch_id or record.id,
                    parsed_at=record.parsed_at,
                )
                _sync_publish(event_bus, "remittance_written", ui)
            # Activity events for this batch.
            from sqlalchemy import select
            activity_rows = s.execute(
                select(ActivityEvent).where(ActivityEvent.batch_id == record.id)
            ).scalars().all()
            for arow in activity_rows:
                ui = {
                    "kind": arow.kind,
                    "ts": arow.ts.isoformat().replace("+00:00", "Z"),
                    "batchId": arow.batch_id,
                    "claimId": arow.claim_id,
                    "remittanceId": arow.remittance_id,
                    "payload": arow.payload_json,
                }
                _sync_publish(event_bus, "activity_recorded", ui)
    except Exception:
        log.exception("add: event publish failed for batch %s", record.id)


def _sync_publish(event_bus, kind: str, payload: dict) -> None:
    """Synchronous fan-out helper. Mirrors ``EventBus.publish`` but
    bypasses the async wrapper so callers don't need an event loop.
    """
    event = {**payload, "_kind": kind}
    for queue in list(event_bus._subscribers.get(kind, ())):
        event_bus._enqueue_or_drop_oldest(queue, event)