"""SQLAlchemy 2.0 engine, session factory, and declarative Base.

`init_db()` is called from `cyclone.__main__:serve` before uvicorn starts.
It reads `CYCLONE_DB_URL` (default `sqlite:///~/.local/share/cyclone/cyclone.db`),
creates the engine, runs the migration runner, then runs `Base.metadata.create_all`
as a safety net (which is a no-op when the migration is current). Calling
`init_db()` is idempotent — calling it twice on the same URL re-uses the
same engine.
"""

from __future__ import annotations

import enum
import json
import os
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Optional

import sqlalchemy as sa
from sqlalchemy import (
    Boolean,
    Date,
    DateTime,
    Enum,
    ForeignKey,
    Index,
    Integer,
    Numeric,
    String,
    Text,
    text,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
from sqlalchemy.types import TypeDecorator
from sqlalchemy.schema import PrimaryKeyConstraint


DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "cyclone" / "cyclone.db"
DEFAULT_DB_URL = f"sqlite:///{DEFAULT_DB_PATH}"


class Base(DeclarativeBase):
    """Declarative base for all ORM models in the project."""


_engine: sa.Engine | None = None
_SessionLocal: sessionmaker[sa.orm.Session] | None = None


def _resolve_url() -> str:
    url = os.environ.get("CYCLONE_DB_URL")
    if url:
        return url
    DEFAULT_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    return DEFAULT_DB_URL


def _make_engine(url: str) -> sa.Engine:
    """Build an Engine with sensible defaults for SQLite + FastAPI.

    SP12: when ``cyclone.db_crypto.is_encryption_enabled()`` returns
    True, swap the underlying driver to ``sqlcipher3`` and apply the
    Keychain-stored key via a connect-time PRAGMA. Otherwise the
    plain sqlite3 driver is used (current behavior, no surprises for
    operators who haven't set up Keychain yet).
    """
    from cyclone import db_crypto  # late import to avoid cycles

    if url.startswith("sqlite") and db_crypto.is_encryption_enabled():
        key = db_crypto.get_db_key()
        if key:
            creator = db_crypto.make_sqlcipher_connect_creator(url, key)
            # SP15: NullPool — each thread opens its own SQLCipher
            # connection. The default QueuePool returns connections
            # to a shared queue that any thread can pull from, which
            # breaks SQLCipher's thread affinity (a connection opened
            # on thread A raises ProgrammingError when used on thread
            # B). NullPool trades connection reuse for thread safety,
            # which is the only correct behavior for SQLCipher under
            # FastAPI's per-request threadpool.
            from sqlalchemy.pool import NullPool
            return sa.create_engine(
                url,
                creator=creator,
                poolclass=NullPool,
                future=True,
            )

    connect_args: dict[str, object] = {}
    if url.startswith("sqlite"):
        connect_args = {"check_same_thread": False}
    return sa.create_engine(
        url,
        connect_args=connect_args,
        future=True,
    )


def init_db() -> None:
    """Create the engine + session factory; run create_all + migrations.

    Idempotent. Safe to call multiple times for the same URL.
    """
    global _engine, _SessionLocal
    if _engine is not None:
        return  # already initialized

    url = _resolve_url()
    _engine = _make_engine(url)

    # Import here to avoid circular imports with models.
    from cyclone import db_migrate  # noqa: WPS433 (deliberate late import)

    # Run migrations first so the schema is the source of truth.
    # `create_all` below is a safety net for tests that may not run
    # the migration runner; with the migration applied, it is a no-op.
    db_migrate.run(_engine)
    Base.metadata.create_all(_engine)

    _SessionLocal = sessionmaker(
        bind=_engine,
        autoflush=False,
        autocommit=False,
        expire_on_commit=False,
    )


def _reset_for_tests() -> None:
    """Clear module-level state. Test-only."""
    global _engine, _SessionLocal
    if _engine is not None:
        _engine.dispose()
    _engine = None
    _SessionLocal = None


def dispose_engine() -> None:
    """Close every pooled connection on the current engine.

    SP15: used by the key-rotation flow to ensure no connection is
    holding the DB file open while ``PRAGMA rekey`` runs (SQLCipher
    refuses to rekey if another connection is using the DB). The
    next call to ``init_db()`` rebuilds the engine with the new key
    from the Keychain.
    """
    global _engine
    if _engine is not None:
        _engine.dispose()


def reinit_engine() -> None:
    """Dispose the current engine and rebuild it from the current Keychain key.

    SP15: called by the key-rotation endpoint after the Keychain is
    updated with the new key. We dispose (close every pooled
    connection that was using the OLD key) and then re-init (open
    new connections with the NEW key). The two-step is necessary
    because SQLAlchemy caches the creator in the pool — a re-init
    is the only way to swap the driver-level PRAGMA key.
    """
    dispose_engine()
    init_db()


def engine() -> sa.Engine:
    """Return the process-wide Engine. Raises if `init_db()` was not called."""
    if _engine is None:
        raise RuntimeError("db.init_db() has not been called")
    return _engine


def SessionLocal() -> sessionmaker[sa.orm.Session]:
    """Return the session factory. Raises if `init_db()` was not called."""
    if _SessionLocal is None:
        raise RuntimeError("db.init_db() has not been called")
    return _SessionLocal


class ClaimState(str, enum.Enum):
    SUBMITTED = "submitted"
    RECEIVED = "received"
    REJECTED = "rejected"     # NEW — set by 999 AK9 set-level R/E
    PAID = "paid"
    PARTIAL = "partial"
    DENIED = "denied"
    RECONCILED = "reconciled"
    REVERSED = "reversed"


class JSONText(TypeDecorator):
    """Stores a dict as a JSON string in a TEXT column.

    Matches the migration's `totals_json TEXT`, `validation_json TEXT`,
    `raw_result_json TEXT`, `raw_json TEXT` columns. The application
    code works with dicts; the database stores JSON strings.
    """

    impl = Text
    cache_ok = True

    def process_bind_param(self, value, dialect):  # type: ignore[no-untyped-def]
        if value is None:
            return None
        return json.dumps(value)

    def process_result_value(self, value, dialect):  # type: ignore[no-untyped-def]
        if value is None:
            return None
        return json.loads(value)


class Batch(Base):
    __tablename__ = "batches"

    id: Mapped[str] = mapped_column(String(32), primary_key=True)
    kind: Mapped[str] = mapped_column(String(8), nullable=False)
    input_filename: Mapped[str] = mapped_column(String(512), nullable=False)
    parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    totals_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
    validation_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
    raw_result_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)

    claims: Mapped[list["Claim"]] = relationship(
        back_populates="batch", cascade="all, delete-orphan"
    )
    remittances: Mapped[list["Remittance"]] = relationship(
        back_populates="batch", cascade="all, delete-orphan"
    )

    __table_args__ = (
        # Migration is the source of truth: 0001_initial.sql declares this DESC.
        # `text(...)` keeps the ORM in sync so fresh DBs built via `create_all`
        # alone (no migration runner) get the same index direction as prod.
        Index("ix_batches_parsed_at", text("parsed_at DESC")),
    )


class Claim(Base):
    __tablename__ = "claims"

    # Migration 0014: composite PRIMARY KEY (batch_id, id). Enables
    # resubmits (same CLM01 in different batches) and makes the
    # pre-flight dedup workflow exercisable. The composite PK is declared
    # explicitly in __table_args__ below so the column order matches the
    # migration (`PRIMARY KEY (batch_id, id)`).
    id: Mapped[str] = mapped_column(String(64))
    batch_id: Mapped[str] = mapped_column(
        String(32),
        ForeignKey("batches.id", ondelete="CASCADE"),
    )
    patient_control_number: Mapped[str] = mapped_column(String(64), nullable=False)
    service_date_from: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
    service_date_to: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
    charge_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
    provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
    payer_id: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
    state: Mapped[ClaimState] = mapped_column(
        Enum(ClaimState, native_enum=False), nullable=False, default=ClaimState.SUBMITTED
    )
    state_changed_at: Mapped[Optional[datetime]] = mapped_column(
        DateTime(timezone=True), nullable=True
    )
    state_before_reversal: Mapped[Optional[ClaimState]] = mapped_column(
        Enum(ClaimState, native_enum=False), nullable=True
    )
    rejection_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    rejected_at: Mapped[Optional[datetime]] = mapped_column(
        DateTime(timezone=True), nullable=True
    )
    # SP10: payer-side rejection (277CA STC A4/A6/A7) — distinct from
    # the 999 envelope rejection above. A claim can be rejected at the
    # envelope level (bad file) or at the payer-adjudication level
    # (good file, bad claim). We track them separately so the Inbox
    # Payer-Rejected lane can distinguish.
    payer_rejected_at: Mapped[Optional[datetime]] = mapped_column(
        DateTime(timezone=True), nullable=True
    )
    payer_rejected_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    payer_rejected_status_code: Mapped[Optional[str]] = mapped_column(
        String(8), nullable=True
    )
    payer_rejected_by_277ca_id: Mapped[Optional[str]] = mapped_column(
        String(64), nullable=True
    )
    # SP14: when the operator hits "Acknowledge" on the Payer-Rejected
    # lane, we set this timestamp. The lane query filters on it being
    # NULL so acknowledged claims drop out of the working surface. The
    # original payer_rejected_* fields stay intact for audit (SP11).
    payer_rejected_acknowledged_at: Mapped[Optional[datetime]] = mapped_column(
        DateTime(timezone=True), nullable=True
    )
    payer_rejected_acknowledged_actor: Mapped[Optional[str]] = mapped_column(
        String(64), nullable=True
    )
    resubmit_count: Mapped[int] = mapped_column(
        Integer, nullable=False, default=0, server_default=text("0")
    )
    # Back-reference to remittances. The composite pointer is split into
    # matched_remittance_id + matched_remittance_batch_id. Migration 0014
    # drops the SQL-level FK (composite FK to remittances(batch_id, id)
    # cannot be declared as inline REFERENCES in SQLite; no ALTER CONSTRAINT).
    # App-layer invariants in store.manual_match / manual_unmatch / dedup
    # keep these consistent. matched_remittance_id points at
    # remittances.id (a non-PK column under composite PK) — SQLAlchemy
    # accepts this because FKs reference any column, not just PKs.
    matched_remittance_id: Mapped[Optional[str]] = mapped_column(
        String(64),
        ForeignKey("remittances.id", ondelete="SET NULL"),
        nullable=True,
    )
    matched_remittance_batch_id: Mapped[Optional[str]] = mapped_column(
        String(32),
        nullable=True,
    )
    raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)

    batch: Mapped["Batch"] = relationship(back_populates="claims")

    __table_args__ = (
        # Migration 0014: explicit composite PK column order (batch_id, id).
        # SQLAlchemy's default composite-PK assembly is by declaration order,
        # but `id` is declared first for readability; we override here so
        # ``s.get(Claim, (batch_id, id))`` looks up by the right key order.
        PrimaryKeyConstraint("batch_id", "id", name="pk_claims"),
        # NOTE: no (batch_id, patient_control_number) unique constraint.
        # X12 837P allows any number of CLM segments inside one 2000B
        # subscriber loop, so a single member routinely has multiple
        # claims in a single submission batch. Claim identity is provided
        # by the composite primary key (batch_id, id).
        Index("ix_claims_state", "state"),
        Index("ix_claims_patient_control_number", "patient_control_number"),
        Index("ix_claims_service_date_from", "service_date_from"),
    )


class Remittance(Base):
    __tablename__ = "remittances"

    # Migration 0014: composite PRIMARY KEY (batch_id, id). Same rationale
    # as Claim: enables resubmits (same CLP01 in different batches). The
    # composite PK is declared explicitly in __table_args__ below so the
    # column order matches the migration.
    id: Mapped[str] = mapped_column(String(64))
    batch_id: Mapped[str] = mapped_column(
        String(32),
        ForeignKey("batches.id", ondelete="CASCADE"),
    )
    payer_claim_control_number: Mapped[str] = mapped_column(String(64), nullable=False)
    # Forward FK from Remittance → Claim. As with Claim.matched_remittance_id,
    # migration 0014 drops the SQL-level FK (cannot declare composite FK
    # inline in SQLite). App-layer invariants keep these consistent.
    claim_id: Mapped[Optional[str]] = mapped_column(
        String(64), ForeignKey("claims.id"), nullable=True
    )
    status_code: Mapped[str] = mapped_column(String(4), nullable=False)
    status_label: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
    total_charge: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
    total_paid: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
    patient_responsibility: Mapped[Optional[Decimal]] = mapped_column(Numeric(12, 2), nullable=True)
    adjustment_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
    claim_level_adjustment_amount: Mapped[Decimal] = mapped_column(
        Numeric(12, 2), nullable=False, default=Decimal("0"), server_default=text("0")
    )
    received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    service_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
    is_reversal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
    raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)

    batch: Mapped["Batch"] = relationship(back_populates="remittances")
    cas_adjustments: Mapped[list["CasAdjustment"]] = relationship(
        back_populates="remittance",
        cascade="all, delete-orphan",
        # Migration 0014: CasAdjustment has TWO FKs to remittances
        # (remittance_id and remittance_batch_id). Tell SQLAlchemy to use
        # remittance_id for the relationship (the batch_id side is just a
        # denormalized copy used for the composite FK constraint).
        foreign_keys="CasAdjustment.remittance_id",
    )

    __table_args__ = (
        # Migration 0014: explicit composite PK column order (batch_id, id).
        # See Claim.__table_args__ for the rationale — same as for Claim.
        PrimaryKeyConstraint("batch_id", "id", name="pk_remittances"),
        # NOTE: no (batch_id, payer_claim_control_number) unique constraint.
        # An 835 ERA can contain multiple CLP segments that share a
        # payer_claim_control_number (e.g. reversals re-referencing the
        # original PCN). Remittance identity is provided by the composite
        # PK (batch_id, id).
        Index("ix_remittances_claim_id", "claim_id"),
        Index("ix_remittances_payer_claim_control_number", "payer_claim_control_number"),
        Index("ix_remittances_status_code", "status_code"),
    )


class CasAdjustment(Base):
    __tablename__ = "cas_adjustments"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    remittance_id: Mapped[str] = mapped_column(
        String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
    )
    # Migration 0014: remittances.id is no longer a single-column PK; we add
    # the batch side of the composite FK to make the constraint declarative
    # again. The SQL migration declares it as a real composite FK; here we
    # use a plain column reference because the application always queries
    # via remittance_id alone (the batch_id is denormalized for FK
    # enforcement only — same value for every row with a given remittance_id).
    remittance_batch_id: Mapped[str] = mapped_column(
        String(32), ForeignKey("remittances.batch_id"), nullable=False
    )
    group_code: Mapped[str] = mapped_column(String(4), nullable=False)
    reason_code: Mapped[str] = mapped_column(String(8), nullable=False)
    amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
    quantity: Mapped[Optional[Decimal]] = mapped_column(Numeric(10, 2), nullable=True)
    service_line_payment_id: Mapped[Optional[int]] = mapped_column(
        Integer,
        ForeignKey("service_line_payments.id", ondelete="SET NULL"),
        nullable=True,
    )

    remittance: Mapped["Remittance"] = relationship(
        back_populates="cas_adjustments",
        # See Remittance.cas_adjustments for the rationale: disambiguate
        # between the two FKs from CasAdjustment to remittances.
        foreign_keys="CasAdjustment.remittance_id",
    )

    __table_args__ = (
        Index("ix_cas_adjustments_remittance_id", "remittance_id"),
        Index("ix_cas_adjustments_service_line_payment_id", "service_line_payment_id"),
    )


class ServiceLinePayment(Base):
    """One row per 835 SVC composite (loop 2110).

    SP7. Persisted on 835 ingest *before* ``reconcile.run()`` decides
    which claim this line applies to. Independent of claim matching.

    See spec §3.1.
    """
    __tablename__ = "service_line_payments"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    remittance_id: Mapped[str] = mapped_column(
        String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
    )
    # Migration 0014: see CasAdjustment.remittance_batch_id for the same rationale.
    remittance_batch_id: Mapped[str] = mapped_column(
        String(32), ForeignKey("remittances.batch_id"), nullable=False
    )
    line_number: Mapped[int] = mapped_column(Integer, nullable=False)
    procedure_qualifier: Mapped[str] = mapped_column(String(4), nullable=False)
    procedure_code: Mapped[str] = mapped_column(String(16), nullable=False)
    modifiers_json: Mapped[str] = mapped_column(
        JSONText, nullable=False, default=lambda: "[]"
    )
    charge: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
    payment: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
    units: Mapped[Optional[Decimal]] = mapped_column(Numeric(10, 2), nullable=True)
    unit_type: Mapped[Optional[str]] = mapped_column(String(8), nullable=True)
    service_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
    ref_benefit_plan: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
    superseded_by_id: Mapped[Optional[int]] = mapped_column(
        Integer,
        ForeignKey("service_line_payments.id", ondelete="SET NULL"),
        nullable=True,
    )

    __table_args__ = (
        Index("ix_service_line_payments_remittance_id", "remittance_id"),
        Index(
            "ix_service_line_payments_procedure_code_date",
            "procedure_code",
            "service_date",
        ),
    )


class LineReconciliation(Base):
    """One row per matched (or explicitly unmatched) 837 service line within a claim.

    SP7. Created during ``reconcile.run()`` after the claim↔remit match.
    At least one of ``claim_service_line_number`` / ``service_line_payment_id``
    must be non-null; the application enforces this.

    Implementation note: 837 service lines live in ``Claim.raw_json.service_lines[]``
    keyed by ``line_number`` (1-based). They are NOT a separate ORM table, so the
    claim-side FK is the line number itself; lookups join via the parent ``claim_id``
    + this column to read the line from the JSON blob.

    See spec §3.2 (with this implementation note appended during T3).
    """
    __tablename__ = "line_reconciliations"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    claim_id: Mapped[str] = mapped_column(
        String(64), ForeignKey("claims.id", ondelete="CASCADE"), nullable=False
    )
    # Migration 0014: see CasAdjustment.remittance_batch_id for the same rationale.
    batch_id: Mapped[str] = mapped_column(
        String(32), ForeignKey("claims.batch_id"), nullable=False
    )
    claim_service_line_number: Mapped[Optional[int]] = mapped_column(
        Integer, nullable=True
    )
    service_line_payment_id: Mapped[Optional[int]] = mapped_column(
        Integer,
        ForeignKey("service_line_payments.id", ondelete="SET NULL"),
        nullable=True,
    )
    status: Mapped[str] = mapped_column(String(16), nullable=False)
    match_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
    reconciled_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False
    )

    __table_args__ = (
        Index("ix_line_reconciliations_claim_id", "claim_id"),
        Index(
            "ix_line_reconciliations_claim_service_line_number",
            "claim_service_line_number",
        ),
        Index(
            "ix_line_reconciliations_service_line_payment_id",
            "service_line_payment_id",
        ),
    )


class Match(Base):
    __tablename__ = "matches"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    # Spec-bug fix (T10): not unique — a reversal adds a 2nd Match row
    # for the same claim (original payment + reversal). The design intent
    # is an audit trail.
    claim_id: Mapped[str] = mapped_column(
        String(64), ForeignKey("claims.id", ondelete="CASCADE"),
        nullable=False,
    )
    # Migration 0014: batch side of the composite FK to claims.
    batch_id: Mapped[str] = mapped_column(
        String(32), ForeignKey("claims.batch_id"), nullable=False
    )
    remittance_id: Mapped[str] = mapped_column(
        String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
    )
    # Migration 0014: batch side of the composite FK to remittances.
    remittance_batch_id: Mapped[str] = mapped_column(
        String(32), ForeignKey("remittances.batch_id"), nullable=False
    )
    strategy: Mapped[str] = mapped_column(String(16), nullable=False)
    matched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    prior_claim_state: Mapped[Optional[ClaimState]] = mapped_column(
        Enum(ClaimState, native_enum=False), nullable=True
    )
    is_reversal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)

    __table_args__ = (
        Index("ix_matches_claim_id", "claim_id"),  # non-unique: reversals add a 2nd row
        Index("ix_matches_remittance_id", "remittance_id"),
        Index("ix_matches_matched_at", "matched_at"),
    )


class ActivityEvent(Base):
    __tablename__ = "activity_events"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    kind: Mapped[str] = mapped_column(String(32), nullable=False)
    batch_id: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
    claim_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
    remittance_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
    payload_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)

    __table_args__ = (
        # Migration declares this as DESC; mirror the qualifier so create_all
        # on a fresh DB (without migrations) produces identical DDL.
        Index("ix_activity_events_ts", text("ts DESC")),
        Index("ix_activity_events_kind", "kind"),
    )


class Ack(Base):
    """SP3 P3 T13: persisted 999 ACK (Implementation Acknowledgment) row.

    One row per generated (auto-ACK) or received (manual upload) 999
    document. ``source_batch_id`` is a FK to ``batches.id``: for
    auto-generated ACKs this is the originating 837 batch; for
    received ACKs we synthesize an id (``999-<icn>``) when no batch
    is available — see the ``add_ack`` helper in ``cyclone.store``.

    The ``raw_json`` column carries the full ``ParseResult999`` as a
    dict (via the ``JSONText`` TypeDecorator) so the detail endpoint
    can surface the parsed envelope / AK1 / AK2 / AK5 segments
    without re-parsing the raw X12.
    """

    __tablename__ = "acks"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    source_batch_id: Mapped[str] = mapped_column(
        String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False,
    )
    accepted_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    rejected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    received_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    ack_code: Mapped[str] = mapped_column(String(8), nullable=False)
    parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)

    __table_args__ = (
        Index("ix_acks_source_batch_id", "source_batch_id"),
    )


class Ta1Ack(Base):
    """TA1 (Interchange Acknowledgment) row — one per parsed TA1 file.

    Mirrors :class:`Ack` but for the lower-level interchange envelope
    ack. A TA1 acknowledges the entire ISA/IEA interchange envelope
    (vs a 999 which acknowledges individual transaction sets inside it).

    ``source_batch_id`` is the synthetic id used by the parser
    (``TA1-<ISA13>``) so we can FK to a stable identifier even though we
    don't have an inbound 837 batch to point at — same convention as
    the 999 path (``999-<ISA13>``).

    The flat columns (control_number, ack_code, note_code, dates) are
    promoted out of ``raw_json`` so the list endpoint can sort/filter
    without a JSON parse. The full parsed envelope is still preserved
    in ``raw_json`` for the detail endpoint.
    """

    __tablename__ = "ta1_acks"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    source_batch_id: Mapped[str] = mapped_column(
        String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False,
    )
    control_number: Mapped[str] = mapped_column(String(32), nullable=False)
    interchange_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
    interchange_time: Mapped[Optional[str]] = mapped_column(String(8), nullable=True)
    ack_code: Mapped[str] = mapped_column(String(8), nullable=False)
    note_code: Mapped[Optional[str]] = mapped_column(String(8), nullable=True)
    ack_generated_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
    sender_id: Mapped[str] = mapped_column(String(64), nullable=False, default="")
    receiver_id: Mapped[str] = mapped_column(String(64), nullable=False, default="")
    parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)

    __table_args__ = (
        Index("ix_ta1_acks_source_batch_id", "source_batch_id"),
        Index("ix_ta1_acks_ack_code", "ack_code"),
    )


class Two77caAck(Base):
    """277CA (Claim Acknowledgment) row — one per parsed 277CA file.

    Mirrors :class:`Ta1Ack` but for the *semantic* claim-level ack.
    A 277CA acknowledges individual claims by REF*1K
    (payer_claim_control_number) rather than the whole envelope.

    Per X12 005010X214 a single 277CA can carry many claim statuses;
    we keep the per-claim detail in ``raw_json`` and promote only the
    counts + ICN here so the list endpoint stays fast.

    ``source_batch_id`` uses the synthetic ``277CA-<ISA13>`` id — same
    FK-is-no-op convention as the 999 / TA1 paths. The 277CA itself
    never has an inbound Cyclone batch to point at.
    """

    __tablename__ = "two77ca_acks"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    source_batch_id: Mapped[str] = mapped_column(
        String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False,
    )
    control_number: Mapped[str] = mapped_column(String(32), nullable=False)
    accepted_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    rejected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    paid_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    pended_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)

    __table_args__ = (
        Index("ix_two77ca_acks_source_batch_id", "source_batch_id"),
        Index("ix_two77ca_acks_control_number", "control_number"),
    )


# ---------------------------------------------------------------------------
# SP11: tamper-evident hash-chained audit_log
# ---------------------------------------------------------------------------


class AuditLog(Base):
    """One row per audit event. Append-only by convention.

    Each row's :attr:`hash` is SHA-256 of
    ``(id, event_type, entity_type, entity_id, actor, payload_json,
    created_at, prev_hash)`` — and :attr:`prev_hash` is the previous
    row's ``hash``. That forms a tamper-evident chain: changing any
    row's payload invalidates every subsequent row's hash.

    See ``cyclone.audit_log.append_event`` and ``verify_chain`` for
    the append + verify operations. The application code MUST NOT
    UPDATE or DELETE rows; doing so breaks the chain and is
    auditable via :func:`verify_chain`.
    """

    __tablename__ = "audit_log"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    event_type: Mapped[str] = mapped_column(String(64), nullable=False)
    entity_type: Mapped[str] = mapped_column(String(64), nullable=False)
    entity_id: Mapped[str] = mapped_column(String(64), nullable=False)
    actor: Mapped[str] = mapped_column(String(64), nullable=False, default="system")
    payload_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
    hash: Mapped[str] = mapped_column(String(64), nullable=False)

    __table_args__ = (
        Index("idx_audit_log_entity", "entity_type", "entity_id"),
        Index("idx_audit_log_event_type", "event_type"),
        Index("idx_audit_log_created_at", "created_at"),
    )


# ---------------------------------------------------------------------------
# SP16: inbound MFT scheduler
# ---------------------------------------------------------------------------


class ProcessedInboundFile(Base):
    """One row per inbound MFT file the scheduler has downloaded.

    SP16. Lets the scheduler be idempotent: a re-tick or restart must
    not re-parse the same inbound file. The unique index on
    (sftp_block_name, name) prevents duplicate inserts and lets the
    scheduler fast-skip already-processed files via a SELECT.

    Status values:
        * ok       - parsed cleanly, results persisted to the store
        * error    - parser raised; error_message captured
        * skipped  - file_type not in the scheduler's allowed set
        * pending  - file was downloaded but a downstream step failed;
                     the scheduler retries on the next tick
    """

    __tablename__ = "processed_inbound_files"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    sftp_block_name: Mapped[str] = mapped_column(String(128), nullable=False)
    name: Mapped[str] = mapped_column(String(256), nullable=False)
    size: Mapped[int] = mapped_column(Integer, nullable=False)
    modified_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
    file_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
    processed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    parser_used: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
    claim_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    status: Mapped[str] = mapped_column(String(16), nullable=False)
    error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)

    __table_args__ = (
        Index(
            "ux_processed_inbound_files_block_name",
            "sftp_block_name", "name", unique=True,
        ),
        Index("ix_processed_inbound_files_processed_at", "processed_at"),
        Index("ix_processed_inbound_files_status", "status"),
    )


# ---------------------------------------------------------------------------
# SP17: encrypted backup metadata
# ---------------------------------------------------------------------------


class DbBackup(Base):
    """One row per encrypted backup the BackupService has taken.

    The actual encrypted blob lives in a directory outside the DB
    (``~/.local/share/cyclone/backups/`` by default); this table is
    the index. Status values: ``pending``, ``ok``, ``error``,
    ``pruned``.

    SP17. The unique index on ``(backup_dir, filename)`` makes a
    duplicate ``create_now()`` race fail cleanly with an
    IntegrityError instead of clobbering an existing backup.
    """

    __tablename__ = "db_backups"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    filename: Mapped[str] = mapped_column(String(128), nullable=False)
    backup_dir: Mapped[str] = mapped_column(String(512), nullable=False)
    size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    db_fingerprint: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
    table_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
    status: Mapped[str] = mapped_column(String(16), nullable=False)
    error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)

    __table_args__ = (
        Index("ux_db_backups_filename", "backup_dir", "filename", unique=True),
        Index("ix_db_backups_created_at", "created_at"),
        Index("ix_db_backups_status", "status"),
    )


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


class Provider(Base):
    """One row per billing-provider NPI (Touch of Care has 3)."""

    __tablename__ = "providers"

    npi: Mapped[str] = mapped_column(String(10), primary_key=True)
    label: Mapped[str] = mapped_column(String(64), nullable=False)
    legal_name: Mapped[str] = mapped_column(String(128), nullable=False)
    tax_id: Mapped[str] = mapped_column(String(16), nullable=False)
    taxonomy_code: Mapped[str] = mapped_column(String(16), nullable=False)
    address_line1: Mapped[str] = mapped_column(String(128), nullable=False)
    address_line2: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
    city: Mapped[str] = mapped_column(String(64), nullable=False)
    state: Mapped[str] = mapped_column(String(2), nullable=False)
    zip: Mapped[str] = mapped_column(String(16), nullable=False)
    is_active: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
    created_at: Mapped[str] = mapped_column(String(32), nullable=False)
    updated_at: Mapped[str] = mapped_column(String(32), nullable=False)

    __table_args__ = (
        Index("ix_providers_active", "is_active"),
    )


class Payer(Base):
    """One row per payer identity."""

    __tablename__ = "payers"

    payer_id: Mapped[str] = mapped_column(String(32), primary_key=True)
    name: Mapped[str] = mapped_column(String(128), nullable=False)
    receiver_name: Mapped[str] = mapped_column(String(128), nullable=False)
    receiver_id: Mapped[str] = mapped_column(String(64), nullable=False)
    is_active: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
    created_at: Mapped[str] = mapped_column(String(32), nullable=False)
    updated_at: Mapped[str] = mapped_column(String(32), nullable=False)

    __table_args__ = (
        Index("ix_payers_active", "is_active"),
    )


class PayerConfigORM(Base):
    """One row per (payer_id, transaction_type). The config_json column
    carries the per-tx Pydantic config (PayerConfig837, PayerConfig835, etc.)
    serialized to JSON."""

    __tablename__ = "payer_configs"

    payer_id: Mapped[str] = mapped_column(
        String(32), ForeignKey("payers.payer_id", ondelete="CASCADE"), primary_key=True,
    )
    transaction_type: Mapped[str] = mapped_column(String(8), primary_key=True)
    config_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
    updated_at: Mapped[str] = mapped_column(String(32), nullable=False)


class ClearhouseORM(Base):
    """Singleton row (id always 1) holding dzinesco's identity + SFTP config."""

    __tablename__ = "clearhouse"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String(64), nullable=False)
    tpid: Mapped[str] = mapped_column(String(16), nullable=False)
    submitter_name: Mapped[str] = mapped_column(String(128), nullable=False)
    submitter_id_qual: Mapped[str] = mapped_column(String(8), nullable=False, default="46")
    submitter_contact_name: Mapped[str] = mapped_column(String(128), nullable=False)
    submitter_contact_email: Mapped[str] = mapped_column(String(128), nullable=False)
    filename_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
    sftp_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
    updated_at: Mapped[str] = mapped_column(String(32), nullable=False)


# =============================================================================
# Migration 0014: ORM `before_insert` events auto-populate the batch side
# of composite FKs.
# =============================================================================
#
# **What.** The four listeners below (``_match_before_insert``,
# ``_cas_before_insert``, ``_slp_before_insert``, ``_lr_before_insert``)
# run on every INSERT of a ``Match`` / ``CasAdjustment`` /
# ``ServiceLinePayment`` / ``LineReconciliation`` row. They look up the
# parent ``Claim`` / ``Remittance`` and copy its ``batch_id`` (and
# ``remittance_batch_id``) into the new row's composite-FK column.
#
# **Why.** After migration 0014, every child table has a composite FK to
# its parent: ``(batch_id, claim_id)`` for Match / LineReconciliation and
# ``(remittance_batch_id, remittance_id)`` for CasAdjustment /
# ServiceLinePayment. Both sides are ``NOT NULL`` at the SQL level, so
# every insert must supply ``batch_id``. Application code that creates
# these rows usually has the parent ``claim_id`` / ``remittance_id`` in
# scope (often as a string) but does not always have ``batch_id``
# readily available — the batch is created elsewhere in the same ingest
# transaction, or the parent is fetched by id alone. Threading
# ``batch_id`` through every call site would be error-prone. The events
# solve this transparently: caller passes the parent id as before, and
# the event fills in the batch side.
#
# **Lookup order** (preferred-to-fallback, to minimize round-trips):
#   1. ``session.new`` — pending parents added in this transaction
#      (covers the common ingest/match flow where parent + child are
#      added in the same session).
#   2. ``session.identity_map`` — already-persisted parents loaded
#      earlier in this session.
#   3. ``session.execute(select(...))`` — DB query as a last resort.
#      ``autoflush`` is OFF on our ``SessionLocal``, so this won't
#      trigger an unintended flush of ``session.new``.
#
# **When it fails.** If the parent is in none of the above (e.g. the
# caller passed a parent id that was never added to the session and is
# not in the DB), the column stays NULL and the INSERT fails with
# ``IntegrityError: NOT NULL constraint failed: <table>.batch_id`` (or
# ``remittance_batch_id``). The error message is misleading — it looks
# like the column was forgotten, not that the parent id was wrong.
# Workarounds:
#   * If the parent exists in another session, ``session.add(parent)``
#     first (or use ``session.merge(parent)``) so the lookup finds it
#     in ``session.new`` / ``session.identity_map``.
#   * If you have the parent object in scope and know its ``batch_id``,
#     set ``child.batch_id = ...`` explicitly — the event only fills
#     in when the column is None, so explicit values win.
#   * If the parent is on a different connection entirely, populate the
#     column by hand before ``session.add(child)``.
#
# **Idempotency.** The events only act when ``target.batch_id is None``
# (or ``remittance_batch_id``). Application code may explicitly set
# either column — e.g., a bulk-loader that already has batch_id in hand
# skips the lookup.
#
# **Scope.** ``propagate=True`` so subclasses of these mappers (in
# tests, mainly) inherit the listeners. Production code does not use
# mapper inheritance for these tables.

from sqlalchemy import event as _sa_event  # noqa: E402
from sqlalchemy.orm import Session as _Session  # noqa: E402


def _resolve_claim_batch_id(session: "_Session", claim_id: "str | None") -> "str | None":
    """Look up a Claim's batch_id, preferring session-cached state to SQL."""
    if claim_id is None:
        return None
    for obj in session.new:
        if isinstance(obj, Claim) and obj.id == claim_id:
            return obj.batch_id
    for obj in session.identity_map.values():
        if isinstance(obj, Claim) and obj.id == claim_id:
            return obj.batch_id
    from sqlalchemy import select as _select
    return session.execute(
        _select(Claim.batch_id).where(Claim.id == claim_id)
    ).scalar_one_or_none()


def _resolve_remit_batch_id(session: "_Session", remittance_id: "str | None") -> "str | None":
    """Look up a Remittance's batch_id, preferring session-cached state to SQL."""
    if remittance_id is None:
        return None
    for obj in session.new:
        if isinstance(obj, Remittance) and obj.id == remittance_id:
            return obj.batch_id
    for obj in session.identity_map.values():
        if isinstance(obj, Remittance) and obj.id == remittance_id:
            return obj.batch_id
    from sqlalchemy import select as _select
    return session.execute(
        _select(Remittance.batch_id).where(Remittance.id == remittance_id)
    ).scalar_one_or_none()


@_sa_event.listens_for(Match, "before_insert", propagate=True)
def _match_before_insert(mapper, connection, target):
    if target.batch_id is None or target.remittance_batch_id is None:
        session = _Session.object_session(target)
        if session is None:
            return
        if target.batch_id is None:
            target.batch_id = _resolve_claim_batch_id(session, target.claim_id)
        if target.remittance_batch_id is None:
            target.remittance_batch_id = _resolve_remit_batch_id(session, target.remittance_id)


@_sa_event.listens_for(CasAdjustment, "before_insert", propagate=True)
def _cas_before_insert(mapper, connection, target):
    if target.remittance_batch_id is None:
        session = _Session.object_session(target)
        if session is None:
            return
        target.remittance_batch_id = _resolve_remit_batch_id(session, target.remittance_id)


@_sa_event.listens_for(ServiceLinePayment, "before_insert", propagate=True)
def _slp_before_insert(mapper, connection, target):
    if target.remittance_batch_id is None:
        session = _Session.object_session(target)
        if session is None:
            return
        target.remittance_batch_id = _resolve_remit_batch_id(session, target.remittance_id)


@_sa_event.listens_for(LineReconciliation, "before_insert", propagate=True)
def _lr_before_insert(mapper, connection, target):
    if target.batch_id is None:
        session = _Session.object_session(target)
        if session is None:
            return
        target.batch_id = _resolve_claim_batch_id(session, target.claim_id)
