"""SQLAlchemy-backed batch store for parsed X12 files.

The package exposes a single ``CycloneStore`` class and a module-level
singleton (``store``). All persistence flows through SQLAlchemy
sessions via ``db.SessionLocal()()`` (the double-paren function-style
accessor).

This ``__init__.py`` is the facade — it re-exports every public name
plus the 3 private helpers imported by tests (``_claim_status_from_validation``,
``_persist_835_remit``, ``_remittance_835_row``) from the sibling modules:

    exceptions      AlreadyMatchedError, NotMatchedError, InvalidStateError
    records         BatchKind, BatchRecord, BatchRecord837, BatchRecord835
    orm_builders    ORM row builders + the 3 private-helper re-exports
    ui              UI serializers (to_ui_*)
    write           add_record + private event/reconcile helpers
    batches         Batch reads + _BatchesShim (test-cleanup shim)
    claim_detail    Single-claim reads + matched-pair drift audit
    kpis            Dashboard aggregation
    acks            999 / TA1 / 277CA ACK persistence
    backups         Backup-pending marker inserts
    inbox           Manual match/unmatch + lane listing
    providers       Provider/payer/clearhouse config

The ``CycloneStore`` class below keeps its current method signatures as
thin delegations for back-compat with existing callers and tests.

Public API (preserved verbatim):
    CycloneStore, store, BatchRecord, BatchRecord837, BatchRecord835,
    BatchKind, AlreadyMatchedError, NotMatchedError, InvalidStateError,
    utcnow, dashboard_kpis, check_matched_pair_drift

Backward-compat shims for tests:
    ``_lock`` — a threading.RLock used by 7 test files for cleanup.
    ``_batches.clear()`` — wipes all rows from the DB tables; the
        ``_BatchesShim`` class lives in ``batches.py``.
"""

from __future__ import annotations

import json
import threading
import uuid
from datetime import datetime, timezone


def utcnow() -> datetime:
    """tz-aware UTC `datetime` (replaces the old `utcnow_iso` string helper)."""
    return datetime.now(timezone.utc)


from . import write
from .batches import (
    _BatchesShim,
    _row_to_record,
    all_batches,
    get_batch,
    get_record,
    list_batches,
    load_two_for_diff,
)
from .claim_detail import (
    check_matched_pair_drift,
    count_claims,
    count_remittances,
    distinct_providers,
    get_claim_detail,
    get_remittance,
    iter_claims,
    iter_remittances,
    recent_activity,
    summarize_remittances,
)
from .acks import (
    add_277ca_ack,
    add_999_ack,
    add_ta1_ack,
    get_277ca_ack,
    get_ack,
    get_ta1_ack,
    list_277ca_acks,
    list_acks,
    list_ta1_acks,
)
from .claim_acks import (
    add_claim_ack as _add_claim_ack,
    batch_envelope_index as _batch_envelope_index,
    find_ack_orphans as _find_ack_orphans,
    list_acks_for_claim as _list_acks_for_claim,
    list_claims_for_ack as _list_claims_for_ack,
    remove_claim_ack as _remove_claim_ack,
    _iter_orphan_999_st02s as _iter_orphan_999_st02s,
)
from .backups import add_backup_pending
from .exceptions import AlreadyMatchedError, InvalidStateError, NotMatchedError
from .resubmissions import (
    ResubmissionStatus,
    find_resubmission_status,
    record_resubmission,
)
from .inbox import list_unmatched, manual_match, manual_unmatch
from .kpis import dashboard_kpis
from .orm_builders import (
    _claim_status_from_validation,
    _persist_835_remit,
    _remittance_835_row,
)
from .providers import (
    ensure_clearhouse_seeded,
    get_clearhouse,
    get_payer_config,
    get_provider,
    list_payers,
    list_providers,
    update_clearhouse,
    upsert_provider,
)
from .records import BatchKind, BatchRecord, BatchRecord837, BatchRecord835
from .ui import (
    to_ui_ack,
    to_ui_claim_ack,
    to_ui_claim_detail,
    to_ui_claim_from_orm,
    to_ui_provider,
    to_ui_remittance_from_orm,
    to_ui_remittance_with_adjustments,
    to_ui_ta1_ack,
    to_ui_two77ca_ack,
)


# ---------------------------------------------------------------------------
# UI mappers: ORM rows → simpler UI types.
# ---------------------------------------------------------------------------
# Backward-compat shim: tests called ``_batches.clear()`` on the in-memory
# store. The DB-backed store doesn't have an in-memory list, so we expose
# a tiny shim object whose ``.clear()`` wipes the DB. The class itself
# lives in ``batches.py`` (imported above as ``_BatchesShim``); the
# ``CycloneStore.__init__`` below instantiates that imported class.
# ---------------------------------------------------------------------------


# ---------------------------------------------------------------------------
# CycloneStore: the SQLAlchemy-backed facade.
# ---------------------------------------------------------------------------


class CycloneStore:
    """SQLAlchemy-backed facade over the parsed X12 store.

    Each public method opens a short-lived session via
    ``db.SessionLocal()()`` so callers don't have to manage session
    lifecycles. Concurrency is handled by the SQLAlchemy engine; the
    ``_lock`` attribute is a no-op ``RLock`` retained for backward
    compatibility with code that wrapped cleanup in a lock context.
    """

    def __init__(self) -> None:
        self._lock = threading.RLock()
        self._batches = _BatchesShim()

    # -- write path -----------------------------------------------------

    def add(self, record: BatchRecord, *, event_bus=None):
        """Persist a parsed batch (837P or 835)."""
        return write.add_record(record, event_bus=event_bus)

    def _publish_events_sync(self, event_bus, record, claim_ids, remit_ids):
        return write.publish_events_sync(event_bus, record, claim_ids, remit_ids)

    @staticmethod
    def _sync_publish(event_bus, kind: str, payload: dict) -> None:
        return write._sync_publish(event_bus, kind, payload)

    # -- read path ------------------------------------------------------

    def get_batch(self, batch_id: str) -> dict | None:
        return get_batch(batch_id)

    def get(self, batch_id: str) -> BatchRecord | None:
        return get_record(batch_id)

    def list(self, *, limit: int = 100) -> list[BatchRecord]:
        return list_batches(limit=limit)

    def get_remittance(self, remittance_id: str) -> dict | None:
        return get_remittance(remittance_id)

    def get_claim_detail(self, claim_id: str) -> dict | None:
        return get_claim_detail(claim_id)

    def all(self) -> list[BatchRecord]:
        return all_batches()

    def load_two_for_diff(
        self,
        a_id: str,
        b_id: str,
    ) -> tuple[BatchRecord, BatchRecord]:
        return load_two_for_diff(a_id, b_id)

    def iter_claims(self, **kwargs):
        return iter_claims(**kwargs)

    def iter_remittances(self, **kwargs):
        return iter_remittances(**kwargs)

    # -- count helpers (SP27 Task 13b) ---------------------------------
    #
    # The list endpoints (``/api/claims`` and ``/api/remittances``)
    # previously computed ``total`` by calling ``iter_*`` with default
    # ``limit=100`` and taking ``len(...)``. With 60k+ claims and 800+
    # remits in production, the reported total silently capped at 100
    # even when the UI rendered a "100" KPI tile and a 100-row table —
    # the page looked complete but the population was 600× larger. These
    # helpers reuse the iter's filter pipeline with an effectively
    # unbounded ``limit`` so the count reflects the true DB population,
    # not a 100-row sample. Mirrors Dashboard's "server-aggregated
    # counts" fix from commit 59c3275.
    def count_claims(
        self,
        *,
        batch_id: str | None = None,
        status: str | None = None,
        provider_npi: str | None = None,
        payer: str | None = None,
        date_from: str | None = None,
        date_to: str | None = None,
    ) -> int:
        """Count claims that would be returned by ``iter_claims``."""
        return count_claims(
            batch_id=batch_id, status=status, provider_npi=provider_npi,
            payer=payer, date_from=date_from, date_to=date_to,
        )

    def count_remittances(
        self,
        *,
        batch_id: str | None = None,
        payer: str | None = None,
        claim_id: str | None = None,
        date_from: str | None = None,
        date_to: str | None = None,
    ) -> int:
        """Count remittances that would be returned by ``iter_remittances``."""
        return count_remittances(
            batch_id=batch_id, payer=payer, claim_id=claim_id,
            date_from=date_from, date_to=date_to,
        )

    def summarize_remittances(
        self,
        *,
        batch_id: str | None = None,
        payer: str | None = None,
        claim_id: str | None = None,
        date_from: str | None = None,
        date_to: str | None = None,
    ) -> dict:
        """Return ``{count, total_paid, total_adjustments}`` summed over
        the remittance population that ``iter_remittances`` would return.
        """
        return summarize_remittances(
            batch_id=batch_id, payer=payer, claim_id=claim_id,
            date_from=date_from, date_to=date_to,
        )

    def distinct_providers(self) -> list[dict]:
        return distinct_providers()

    def recent_activity(self, *, limit: int = 200) -> list[dict]:
        return recent_activity(limit=limit)

    # -- 999 ACKs (SP3 P3 T13) -------------------------------------------

    def add_ack(self, **kwargs):
        return add_999_ack(**kwargs)

    def list_acks(self):
        return list_acks()

    def get_ack(self, ack_id):
        return get_ack(ack_id)

    # -- TA1 (Interchange Acknowledgment) -------------------------------

    def add_ta1_ack(self, **kwargs):
        return add_ta1_ack(**kwargs)

    def list_ta1_acks(self):
        return list_ta1_acks()

    def get_ta1_ack(self, ack_id):
        return get_ta1_ack(ack_id)

    # -- 277CA (SP10) --------------------------------------------------

    def add_277ca_ack(self, **kwargs):
        return add_277ca_ack(**kwargs)

    def list_277ca_acks(self):
        return list_277ca_acks()

    def get_277ca_ack(self, ack_id):
        return get_277ca_ack(ack_id)

    # -- SP28: claim↔ack auto-link join table --------------------------

    def add_claim_ack(self, **kwargs):
        """Persist one claim_acks link row.

        Mirrors the publish-from-store contract used by the ACK paths:
        when ``event_bus`` is passed, the matching ``claim_ack_written``
        event fires after commit so live-tail subscribers on both the
        claim and the ack side see the row immediately.
        """
        return _add_claim_ack(**kwargs)

    def list_acks_for_claim(self, claim_id):
        """Return every ClaimAck row for one claim (newest first)."""
        return _list_acks_for_claim(claim_id)

    def list_claims_for_ack(self, kind, ack_id):
        """Return every ClaimAck row for one ack."""
        return _list_claims_for_ack(kind, ack_id)

    def find_ack_orphans(self, kind):
        """Return acks with no resolvable link (Inbox ack-orphans lane)."""
        return _find_ack_orphans(kind)

    def record_resubmission(self, *, claim_id, batch_id, source_corrected_path,
                            interchange_control_number, group_control_number,
                            resubmitted_at=None):
        """SP39: insert one Resubmission audit row. Idempotent on
        (claim_id, interchange_control_number). Returns True if
        inserted, False if a duplicate was suppressed."""
        return record_resubmission(
            claim_id=claim_id,
            batch_id=batch_id,
            source_corrected_path=source_corrected_path,
            interchange_control_number=interchange_control_number,
            group_control_number=group_control_number,
            resubmitted_at=resubmitted_at,
        )

    def find_resubmission_status(self, *, batch_id=None):
        """SP39: return joined status for every Resubmission row,
        optionally filtered by batch_id. Statuses are derived at
        read-time from claim_acks (SP28/31 auto-link) + remittances."""
        return find_resubmission_status(batch_id=batch_id)

    def find_ack_orphan_st02_summary(self):
        """Return per-ST02 summary of orphan 999 acks (sp38).

        Output is a list of dicts, sorted by ``ack_count DESC`` so
        the heaviest orphan ST02s surface first in CLI output:

          ``{"st02": str, "ack_count": int, "has_batch": bool,
             "batch_id": str | None}``

        ``has_batch`` is True if a row exists in ``batches`` with
        ``transaction_set_control_number == st02``. ``batch_id`` is
        that row's ``id`` (or ``None``).

        999-only. 277ca / ta1 orphans are tracked separately; their
        "ST02" semantics differ (it's the interchange control number,
        not the source 837's ST02) and aggregating them with 999
        ST02s would conflate unrelated identifiers.

        Used by the ``cyclone ack-orphans status`` and ``reconcile``
        CLI subcommands (sp38). Sibling to ``find_ack_orphans(kind)``
        which returns one row per orphan ack; the summary is the
        aggregated form.
        """
        from cyclone import db
        from cyclone.db import Batch

        # (st02, ack_count) from the 999-orphan walk.
        counts = dict(_iter_orphan_999_st02s())

        if not counts:
            return []

        # Map ST02 -> existing batch row (if any). One query.
        with db.SessionLocal()() as s:
            existing = {
                row.transaction_set_control_number: row.id
                for row in s.query(Batch)
                .filter(Batch.transaction_set_control_number.in_(counts.keys()))
                .all()
            }

        out = []
        for st02, ack_count in counts.items():
            batch_id = existing.get(st02)
            out.append({
                "st02": st02,
                "ack_count": ack_count,
                "has_batch": batch_id is not None,
                "batch_id": batch_id,
            })
        # Heaviest first; tie-break by st02 ascending for determinism.
        out.sort(key=lambda r: (-r["ack_count"], r["st02"]))
        return out

    def reconcile_orphan_st02s(self, *, dry_run: bool = False) -> dict:
        """One-shot synthetic-batch seeder for orphan ST02s (sp38).

        Inserts a ``batches`` row for every orphan ST02 that does
        NOT already have one. The synthetic row is marked with:

          * ``kind = '837p'``
          * ``input_filename = '<synthetic:orphan-reconcile>'``
          * ``parsed_at = utcnow()``
          * ``transaction_set_control_number = <orphan ST02>``
          * ``totals_json = {"orphan_reconcile": true,
                              "ack_count": <orphan count>}``
          * ``validation_json = {"orphan_reconcile": true,
                                 "note": "sp38 synthetic batch row;
                                 source 837 was never ingested into
                                 this DB snapshot"}``
          * ``id = uuid4().hex`` (no claim rows, no claim_acks links)

        Remaining columns (``claim_count``, ``received_count``, …) take
        their schema defaults.

        The sentinel ``<synthetic:orphan-reconcile>`` makes these
        rows trivially distinguishable in queries — grep for that
        string to find every row this method has created.

        Future 999 acks referencing these ST02s will resolve
        against the batch envelope index (so the operator can see
        "this 999 is for a known orphan source") but will not link
        to claims (because no claim rows exist for the synthetic
        batch).

        Args:
            dry_run: If True, returns the plan but does not insert
                any rows. Used by the CLI ``--dry-run`` flag so
                operators can preview the reconcile.

        Returns:
            ``{"created": int, "skipped": int,
               "synthetic_batch_ids": list[str]}``. ``created`` is
            the count of rows inserted (or that WOULD be inserted
            under dry_run). ``skipped`` is the count of orphan ST02s
            that already had a batches row.

        Idempotent: re-running after a successful pass is a no-op.
        """
        from cyclone import db
        from cyclone.db import Batch

        summary = self.find_ack_orphan_st02_summary()
        if not summary:
            return {"created": 0, "skipped": 0, "synthetic_batch_ids": []}

        created = 0
        skipped = 0
        synthetic_ids: list[str] = []

        if dry_run:
            for row in summary:
                if row["has_batch"]:
                    skipped += 1
                else:
                    created += 1  # would-create count
            return {"created": created, "skipped": skipped, "synthetic_batch_ids": []}

        now = utcnow()
        with db.SessionLocal()() as s:
            for row in summary:
                if row["has_batch"]:
                    skipped += 1
                    continue
                new_id = uuid.uuid4().hex
                s.add(Batch(
                    id=new_id,
                    kind="837p",
                    input_filename="<synthetic:orphan-reconcile>",
                    parsed_at=now,
                    transaction_set_control_number=row["st02"],
                    totals_json=json.dumps({
                        "orphan_reconcile": True,
                        "ack_count": row["ack_count"],
                    }),
                    validation_json=json.dumps({
                        "orphan_reconcile": True,
                        "note": (
                            "sp38 synthetic batch row; source 837 was "
                            "never ingested into this DB snapshot"
                        ),
                    }),
                ))
                synthetic_ids.append(new_id)
                created += 1
            s.commit()

        return {"created": created, "skipped": skipped, "synthetic_batch_ids": synthetic_ids}

    def remove_claim_ack(self, link_id, *, event_bus=None):
        """Unlink one row. Publishes ``claim_ack_dropped`` on the bus."""
        return _remove_claim_ack(link_id, event_bus=event_bus)

    def batch_envelope_index(self):
        """Return a {key: batch.id} map populated from two columns (D10 Pass 1).

        Each 837p batch contributes both ``Batch.raw_result_json.envelope.control_number``
        (ISA13) and ``Batch.transaction_set_control_number`` (ST02); 835 batches
        are excluded. Single ``.get(set_control_number)`` lookup resolves
        either key — SP37 closes the 999 AK201 → source-batch gap.
        """
        return _batch_envelope_index()

    # -- SP17: encrypted DB backups -------------------------------------

    def add_backup_pending(self, *, filename: str, backup_dir: str):
        return add_backup_pending(filename=filename, backup_dir=backup_dir)

    # -- manual reconciliation (T12) -----------------------------------

    def list_unmatched(self, *, kind="both"):
        return list_unmatched(kind=kind)

    def manual_match(self, claim_id, remit_id):
        return manual_match(claim_id, remit_id)

    def manual_unmatch(self, claim_id):
        return manual_unmatch(claim_id)

    # ------------------------------------------------------------------
    # SP9: providers / payers / payer_configs / clearhouse
    # ------------------------------------------------------------------

    def list_providers(self, *, is_active=True):
        return list_providers(is_active=is_active)

    def get_provider(self, npi):
        return get_provider(npi)

    def upsert_provider(self, provider):
        return upsert_provider(provider)

    def list_payers(self, *, is_active=True):
        return list_payers(is_active=is_active)

    def get_payer_config(self, payer_id, transaction_type):
        return get_payer_config(payer_id, transaction_type)

    def get_clearhouse(self):
        return get_clearhouse()

    def update_clearhouse(self, block):
        return update_clearhouse(block)

    def ensure_clearhouse_seeded(self):
        return ensure_clearhouse_seeded()


# Module-level singleton — same import path the old InMemoryStore used.
store = CycloneStore()