"""Click-based CLI for the Cyclone 837P and 835 ERA parsers."""

from __future__ import annotations

import logging
import os
import sys
from datetime import date as _date
from decimal import Decimal as _Decimal
from pathlib import Path

import click

from cyclone.logging_config import setup_logging
from cyclone.parsers.exceptions import CycloneParseError, CycloneValidationError
from cyclone.parsers.payer import PayerConfig, PayerConfig835
from cyclone.parsers.parse_837 import parse as parse_837_text
from cyclone.parsers.parse_835 import parse as parse_835_text
from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.parsers.writer import write_outputs
from cyclone.parsers.writer_835 import write_outputs_835


_PAYER_FACTORIES = {
    "co_medicaid": PayerConfig.co_medicaid,
    "generic_837p": PayerConfig.generic_837p,
}

_PAYER_FACTORIES_835 = {
    "co_medicaid_835": PayerConfig835.co_medicaid_835,
    "generic_835": PayerConfig835.generic_835,
}


def _payer(name: str) -> PayerConfig:
    if name not in _PAYER_FACTORIES:
        raise click.BadParameter(f"Unknown payer {name!r}. Choose from: {', '.join(_PAYER_FACTORIES)}")
    return _PAYER_FACTORIES[name]()


def _payer_835(name: str) -> PayerConfig835:
    if name not in _PAYER_FACTORIES_835:
        raise click.BadParameter(f"Unknown payer {name!r}. Choose from: {', '.join(_PAYER_FACTORIES_835)}")
    return _PAYER_FACTORIES_835[name]()


@click.group()
@click.option(
    "--log-format",
    default=None,
    type=click.Choice(["json", "dev"]),
    help="Log format (default: json; honors CYCLONE_LOG_JSON).",
)
@click.option(
    "--log-file",
    default=None,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Optional rotating log file (honors CYCLONE_LOG_FILE).",
)
@click.pass_context
def main(ctx: click.Context, log_format: str | None, log_file: Path | None) -> None:
    """Cyclone EDI suite — X12 parser."""
    # SP18: structured JSON logging. Run once per CLI invocation; each
    # subcommand still gets its own --log-level to override.
    json_format = True
    if log_format == "dev":
        json_format = False
    elif os.environ.get("CYCLONE_LOG_JSON", "").lower() in ("false", "0", "no"):
        json_format = False
    setup_logging(
        level=os.environ.get("CYCLONE_LOG_LEVEL", "INFO"),
        log_file=str(log_file) if log_file else None,
        json_format=json_format,
    )
    # Stash on context so subcommands can read it.
    ctx.ensure_object(dict)


# Register the auth users subgroup. Imported here (not at module top) to
# avoid pulling passlib / bcrypt at CLI parse-only import time.
from cyclone.auth.cli import users_cli  # noqa: E402
main.add_command(users_cli)

# Register the dev seed subcommand. Imported here for the same lazy-load
# reason as users_cli — keeps passlib/bcrypt + SQLAlchemy out of the
# parse-only path so ``python -m cyclone --help`` stays snappy.
from cyclone.seed_cli import seed_cli  # noqa: E402
main.add_command(seed_cli)


@main.command("parse-837")
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
@click.option("--payer", default="co_medicaid", show_default=True, help="Payer configuration preset")
@click.option("--strict", is_flag=True, help="Promote warnings to errors")
@click.option("--max-retries", default=0, show_default=True, type=int, help="Re-validate failed claims (no fix in v1)")
@click.option("--include-raw-segments / --no-raw-segments", default=True, help="Embed raw segments in output JSON")
@click.option("--log-level", default="INFO", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
def parse_837(
    input_file: Path,
    output_dir: Path,
    payer: str,
    strict: bool,
    max_retries: int,
    include_raw_segments: bool,
    log_level: str,
) -> None:
    """Parse an X12 837P file into one JSON per claim."""
    # SP18: re-run setup so per-command --log-level overrides the
    # group default. ``setup_logging`` is idempotent.
    setup_logging(level=log_level)

    text = input_file.read_text()
    config = _payer(payer)

    try:
        result = parse_837_text(text, config, input_file=str(input_file))
    except CycloneParseError as exc:
        click.echo(f"PARSE ERROR: {exc}", err=True)
        sys.exit(2)

    if not result.claims:
        raise click.UsageError("No claims found in file") from None

    # Strict mode: rewrite reports
    if strict:
        for claim in result.claims:
            promoted = [
                issue.model_copy(update={"severity": "error"})
                for issue in claim.validation.warnings
            ]
            new_errors = claim.validation.errors + promoted
            claim.validation = claim.validation.model_copy(
                update={"errors": new_errors, "passed": not new_errors}
            )
        # Recompute summary
        passed = sum(1 for c in result.claims if c.validation.passed)
        result.summary = result.summary.model_copy(update={
            "passed": passed,
            "failed": len(result.claims) - passed,
            "failed_claim_ids": [c.claim_id for c in result.claims if not c.validation.passed],
        })

    # Drop raw_segments if disabled
    if not include_raw_segments:
        for claim in result.claims:
            claim.raw_segments = []

    # v1: max_retries is a no-op stub. Surface a log line so users know it's wired.
    if max_retries:
        logging.info("max-retries=%d (no auto-fix in v1; would re-validate here)", max_retries)

    paths = write_outputs(result, output_dir)
    result.summary = result.summary.model_copy(update={"output_dir": str(output_dir)})
    (output_dir / "summary.json").write_text(result.summary.model_dump_json(indent=2))

    click.echo(
        f"parsed={result.summary.total_claims}  "
        f"passed={result.summary.passed}  "
        f"failed={result.summary.failed}  "
        f"output={output_dir}"
    )


@main.command("parse-835")
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
@click.option("--payer", default="co_medicaid_835", show_default=True, help="Payer configuration preset")
@click.option("--strict", is_flag=True, help="Promote warnings to errors")
@click.option("--include-raw-segments / --no-raw-segments", default=True, help="Embed raw segments in output JSON")
@click.option("--log-level", default="INFO", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
def parse_835(
    input_file: Path,
    output_dir: Path,
    payer: str,
    strict: bool,
    include_raw_segments: bool,
    log_level: str,
) -> None:
    """Parse an X12 835 ERA file into one JSON per claim payment."""
    # SP18: re-run setup so per-command --log-level overrides the
    # group default. ``setup_logging`` is idempotent.
    setup_logging(level=log_level)

    text = input_file.read_text()
    config = _payer_835(payer)

    try:
        result = parse_835_text(text, config, input_file=str(input_file))
    except CycloneParseError as exc:
        click.echo(f"PARSE ERROR: {exc}", err=True)
        sys.exit(2)

    if not result.claims:
        raise click.UsageError("No claim payments found in file") from None

    # Run validation; populate summary.passed/failed from the report.
    # 835 validation is batch-level (BPR/CLP/SVC balancing is cross-cutting),
    # so pass/fail applies uniformly to every claim payment in the batch:
    # when the batch passes, all N claims "passed" (passed=N, failed=0);
    # when the batch fails, all N claims "failed" (passed=0, failed=N).
    report = validate_835(result, config)
    if strict:
        promoted = [i.model_copy(update={"severity": "error"}) for i in report.warnings]
        new_errors = report.errors + promoted
        report = report.model_copy(update={"errors": new_errors, "passed": not new_errors})
    n = len(result.claims)
    claim_ids = [c.payer_claim_control_number for c in result.claims]
    if report.passed:
        passed, failed, failed_claim_ids = n, 0, []
    else:
        passed, failed, failed_claim_ids = 0, n, claim_ids
    result = result.model_copy(update={"validation": report})
    result.summary = result.summary.model_copy(update={
        "passed": passed,
        "failed": failed,
        "failed_claim_ids": failed_claim_ids,
        "issues_by_rule": _count_issues(report),
    })

    # Drop raw_segments if disabled
    if not include_raw_segments:
        result = result.model_copy(update={"claims": [c.model_copy(update={"raw_segments": []}) for c in result.claims]})

    write_outputs_835(result, output_dir)
    result.summary = result.summary.model_copy(update={"output_dir": str(output_dir)})
    (output_dir / "summary.json").write_text(result.summary.model_dump_json(indent=2))

    click.echo(
        f"parsed={result.summary.total_claims}  "
        f"passed={result.summary.passed}  "
        f"failed={result.summary.failed}  "
        f"output={output_dir}"
    )


def _count_issues(report) -> dict[str, int]:
    """Build an issues_by_rule dict from a ValidationReport (for the summary)."""
    counts: dict[str, int] = {}
    for issue in report.errors + report.warnings:
        counts[issue.rule] = counts.get(issue.rule, 0) + 1
    return counts


# ---------------------------------------------------------------------------
# SP20: `cyclone validate-npi` + `cyclone validate-tax-id`
#
# Pure local validators. No DB, no Keychain, no network — operators can
# run them on a developer laptop without standing up the full Cyclone
# stack. Exit code is 0 (valid) or 1 (invalid) so they compose with
# shell scripting / CI gates.
# ---------------------------------------------------------------------------


@main.command("validate-npi")
@click.argument("npi")
@click.option("--log-level", default="WARNING", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
def validate_npi_cmd(npi: str, log_level: str) -> None:
    """Validate a 10-digit NPI's Luhn checksum locally (SP20).

    Exit 0 if valid, 1 if not. No logging of the value itself — NPIs
    are PHI under HIPAA, so the operator's CLI history is the only
    audit trail.
    """
    # SP18: re-run so --log-level overrides the group default.
    setup_logging(level=log_level)
    from cyclone.npi import is_valid_npi
    if is_valid_npi(npi):
        click.echo(f"OK: {len(npi)}-digit NPI passes Luhn checksum")
        return
    click.echo(f"INVALID: {npi!r} fails NPI Luhn checksum", err=True)
    sys.exit(1)


@main.command("validate-tax-id")
@click.argument("tax_id")
@click.option("--log-level", default="WARNING", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
def validate_tax_id_cmd(tax_id: str, log_level: str) -> None:
    """Validate a 9-digit EIN's format + prefix locally (SP20).

    Accepts both ``XX-XXXXXXX`` and ``XXXXXXXXX``. Exit 0 if valid,
    1 if not. EIN is sensitive (PII), so we don't echo the value back
    on failure — only the validation verdict.
    """
    # SP18: re-run so --log-level overrides the group default.
    setup_logging(level=log_level)
    from cyclone.npi import is_valid_tax_id, normalize_tax_id
    plain = normalize_tax_id(tax_id)
    if plain is None:
        click.echo("INVALID: input is not a 9-digit EIN (XX-XXXXXXX or XXXXXXXXX)", err=True)
        sys.exit(1)
    if is_valid_tax_id(tax_id):
        click.echo(f"OK: 9-digit EIN (normalized={plain})")
        return
    click.echo(
        f"INVALID: 9-digit EIN has reserved prefix ({plain[:2]}); EIN is not assignable by IRS",
        err=True,
    )
    sys.exit(1)


# ---------------------------------------------------------------------------
# SP40: `cyclone validate-837 <file>`
#
# Sends the file to EdiFabric's /v2/x12/validate endpoint via the
# cyclone.edifabric HTTP client. Prints the OperationResult and exits:
#   0 — Status success (or warning-only)
#   2 — Status error (one or more Details items have Status="error")
#   1 — unexpected exception (network / 5xx / missing API key)
#
# Exit code contract mirrors the rest of the validator family
# (validate-npi, validate-tax-id): 0 valid, 1/2 invalid, file-level
# failures use 2.
# ---------------------------------------------------------------------------


@main.command("validate-837")
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--api-key", default=None, help="Override the Edifabric API key (else read from CYCLONE_EDIFABRIC_API_KEY or keychain).")
@click.option("--log-level", default="WARNING", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
def validate_837_cmd(input_file: Path, api_key: str | None, log_level: str) -> None:
    """Validate an 837P file via EdiFabric /v2/x12/validate (SP40).

    Sends the file to the Edifabric reference parser and prints the
    OperationResult. Exits 0 on success or warning-only, 2 on any
    error in Details. Use --api-key to override the key (else the
    CLI reads from secrets per the SP40 spec).
    """
    setup_logging(level=log_level)
    from cyclone import edifabric

    edi_bytes = input_file.read_bytes()
    try:
        result = edifabric.validate_edi(edi_bytes, api_key=api_key)
    except edifabric.EdifabricError as exc:
        click.echo(f"VALIDATION ERROR: {exc}", err=True)
        if exc.status_code == 0:
            # Missing API key / config — exit 1 (unexpected, fixable).
            sys.exit(1)
        # HTTP failure from Edifabric — exit 2 (file-level / upstream).
        sys.exit(2)
    except Exception as exc:  # noqa: BLE001
        click.echo(f"UNEXPECTED ERROR: {type(exc).__name__}: {exc}", err=True)
        sys.exit(1)

    status = result.get("Status", "unknown")
    details = result.get("Details") or []
    last_index = result.get("LastIndex", "?")

    # Print the OperationResult summary.
    click.echo(f"Status: {status}")
    click.echo(f"LastIndex: {last_index}")
    click.echo(f"Details: {len(details)} item(s)")
    for d in details:
        seg = d.get("SegmentId", "?")
        msg = d.get("Message", "")
        dstatus = d.get("Status", "?")
        click.echo(f"  [{dstatus}] {seg}: {msg}")

    if status == "error":
        click.echo(f"VALIDATION FAILED: {len(details)} error(s) in {input_file.name}", err=True)
        sys.exit(2)
    # success or warning — both exit 0 (warnings don't fail-closed).
    return


# ---------------------------------------------------------------------------
# SP32: `cyclone backfill-rendering-npi` (Task 6)
#
# Re-parses on-disk 837p/835 files and patches up the typed NPI columns
# (``Claim.rendering_provider_npi`` / ``Remittance.rendering_provider_npi``)
# for rows that were ingested before the T4 writers took effect.
# ---------------------------------------------------------------------------


@main.command("backfill-rendering-npi")
@click.option(
    "--file", "files",
    multiple=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help=(
        "Path to a specific file to re-parse. May be passed multiple times. "
        "Mutually informative with --input-dir (both are processed)."
    ),
)
@click.option(
    "--input-dir",
    type=click.Path(file_okay=False, path_type=Path),
    default=None,
    help=(
        "Directory to scan one level deep for *.txt / *.edi / *.x12 files. "
        "Honors CYCLONE_BACKFILL_INPUT_DIR if --input-dir is not passed."
    ),
)
@click.option(
    "--type", "transaction_type",
    type=click.Choice(["837p", "835"]),
    default=None,
    help=(
        "Pin the parser to use. Without --type, each file's transaction "
        "kind is sniffed (filename hint + ISA/ST prefix)."
    ),
)
@click.option(
    "--log-level",
    default="INFO",
    show_default=True,
    type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
)
def backfill_rendering_npi(
    files: tuple[Path, ...],
    input_dir: Path | None,
    transaction_type: str | None,
    log_level: str,
) -> None:
    """Re-parse on-disk X12 files to populate the rendering NPI columns.

    Idempotent: rows whose ``rendering_provider_npi`` is already non-NULL
    are left untouched. After patching, runs :func:`cyclone.reconcile.run`
    once across every 835 batch so the T5 scoring arm can fire
    retroactively on the touched pairs.

    Exit code: 0 on a successful run (zero populated rows is still exit 0).
    """
    from cyclone import db as _db
    from cyclone.store.backfill import backfill_rendering_provider_npi

    # SP18: re-run setup so per-command --log-level overrides the
    # group default. ``setup_logging`` is idempotent.
    setup_logging(level=log_level)

    # The backup pattern: each CLI subcommand that touches the DB
    # initializes it explicitly so ``python -m cyclone.cli backup list``
    # works on a fresh machine with no prior app boot.
    _db.init_db()

    # --input-dir falls back to CYCLONE_BACKFILL_INPUT_DIR.
    if input_dir is None and not files:
        env_dir = os.environ.get("CYCLONE_BACKFILL_INPUT_DIR", "").strip()
        if env_dir:
            input_dir = Path(env_dir)

    summary = backfill_rendering_provider_npi(
        files=list(files) if files else None,
        input_dir=input_dir,
        transaction_type=transaction_type,
    )

    # One-line summary so operators can grep a log scrape quickly.
    click.echo(
        f"claims_updated={summary.claims_updated} "
        f"remits_updated={summary.remits_updated} "
        f"files_processed={summary.files_processed} "
        f"files_skipped={summary.files_skipped}"
    )


# ---------------------------------------------------------------------------
# SP17: `cyclone backup` subcommands
#
# Operator-facing backup management. Mirrors the API surface but runs
# standalone (no FastAPI app needed) for cron / scripting / DR drills.
# Each subcommand initializes the DB + BackupService; if the
# service isn't configured (no Keychain passphrase etc.) the operator
# gets a clear error.
# ---------------------------------------------------------------------------


# ---------------------------------------------------------------------------
# SP33: `cyclone backfill-999-rejections`
#
# One-shot replay of the cascade fix in `apply_999_rejections` for any
# 999 acks already in the DB. Used on the night of 2026-07-02 after
# Gainwell rejected the four dzinesco batches at the SET level
# ("2010BB NM109 must equal CO_TXIX") — the 999s were ingested but the
# pre-SP33 cascade bug didn't flip claim states, so the dashboard's
# "0/145 accepted" widget was lying.
#
# Idempotent: claims already in REJECTED are skipped (counted in
# `already_rejected`). Safe to re-run.
# ---------------------------------------------------------------------------

@main.command("backfill-999-rejections")
@click.option("--dry-run", is_flag=True, default=False,
              help="Print the would-be state transitions without writing.")
@click.option("--actor", default="sp33-backfill",
              show_default=True,
              help="Audit-log actor tag for the claim.rejected events.")
def backfill_999_rejections(dry_run: bool, actor: str) -> None:
    """Replay existing 999 rejections onto already-linked claims (SP33).

    Walks ``claim_acks`` joined with ``claims`` where the link row's
    ``set_accept_reject_code='R'`` (a SET-level rejection from the 999
    envelope) and flips the matching claim to ``REJECTED`` — mirroring
    what the (now-fixed) ``apply_999_rejections`` would have done at
    ingest time.

    Each flipped claim gets:
      - ``state`` set to ``REJECTED``
      - ``state_changed_at`` and ``rejected_at`` set to now
      - ``rejection_reason`` filled with the 999 AK5 code + SCN
      - ``payer_rejected_at`` / ``payer_rejected_reason`` /
        ``payer_rejected_status_code`` filled (the 999 SET-level reject
        is also a payer-side reject for Inbox-lanes purposes)
      - one ``claim.rejected`` audit-log event

    Claims already in REJECTED are skipped (counted in the summary).
    """
    from datetime import datetime, timezone
    from sqlalchemy import select, func

    from cyclone import db as db_mod
    from cyclone.audit_log import AuditEvent, append_event

    db_mod.init_db()
    now = datetime.now(timezone.utc)

    with db_mod.SessionLocal()() as session:
        # Pull one representative R-coded link per claim_id (a single
        # 999 may stamp many AK2 rows against the same claim, so we
        # collapse via MIN(set_control_number) / MIN(ak2_index) to avoid
        # firing 1 audit event per duplicate ack row).
        #
        # 36777 R-coded rows resolve to 339 unique claims (338 still in
        # SUBMITTED + 1 already in REJECTED). Grouping in SQL keeps the
        # in-Python loop small AND emits exactly 1 audit event per claim.
        rows = session.execute(
            select(
                db_mod.Claim.id,
                db_mod.Claim.state,
                func.min(db_mod.ClaimAck.set_control_number).label("scn"),
                func.min(db_mod.ClaimAck.ak2_index).label("ak2"),
                func.min(db_mod.Ack.ack_code).label("ack_code"),
            )
            .join(db_mod.ClaimAck, db_mod.ClaimAck.claim_id == db_mod.Claim.id)
            .join(db_mod.Ack, db_mod.Ack.id == db_mod.ClaimAck.ack_id)
            .where(db_mod.ClaimAck.set_accept_reject_code == "R")
            .where(db_mod.ClaimAck.claim_id.is_not(None))
            .group_by(db_mod.Claim.id, db_mod.Claim.state)
        ).all()

        matched = 0
        already = 0
        errors = 0
        for (claim_id, current_state, scn, ak2_idx, ack_code) in rows:
            if current_state == db_mod.ClaimState.REJECTED:
                already += 1
                continue

            if dry_run:
                matched += 1
                continue

            claim = session.get(db_mod.Claim, claim_id)
            if claim is None:
                errors += 1
                continue

            claim.state = db_mod.ClaimState.REJECTED
            claim.state_changed_at = now
            claim.rejected_at = now
            claim.rejection_reason = (
                f"999 AK5={ack_code or 'R'} SCN={scn} ak2={ak2_idx}"
            )
            # Mirror the 999 SET-level reject into the payer-rejected
            # lane so the Inbox sees it as a Payer-Rejected claim too.
            claim.payer_rejected_at = now
            claim.payer_rejected_reason = f"999 SET-level reject at SCN={scn}"
            claim.payer_rejected_status_code = "R"

            append_event(session, AuditEvent(
                event_type="claim.rejected",
                entity_type="claim",
                entity_id=claim_id,
                payload={"source": "backfill-999-rejections",
                         "scn": scn, "ak2_index": ak2_idx,
                         "ack_code": ack_code},
                actor=actor,
            ))
            matched += 1

        if not dry_run:
            session.commit()

    click.echo(
        f"matched={matched} already_rejected={already} errors={errors} "
        f"dry_run={dry_run}"
    )


# ---------------------------------------------------------------------------
# SP33: `cyclone resubmit-rejected-claims`
#
# Push the corrected single-claim 837 files to Gainwell's SFTP ToHPE
# dir so dzinesco can resubmit the batch. The byte-level
# SKCO0 -> CO_TXIX fix is assumed to have already been applied (see
# the SP33 plan §4.4 / `docs/ingest/corrected/`). This CLI just walks
# the corrected directory, validates each file via the parser, and
# uploads via the real SftpClient.
#
# Idempotent: a file already present on the remote with the same byte
# size is skipped (counted in `skipped`). Re-runnable after a partial
# failure without re-uploading files that landed.
# ---------------------------------------------------------------------------

@main.command("resubmit-rejected-claims")
@click.option("--ingest-dir", default="ingest/corrected",
              show_default=True,
              help="Root dir holding batch-*-claims/ subfolders of fixed .x12 files.")
@click.option("--actor", default="sp33-resubmit",
              show_default=True,
              help="Audit-log actor tag for the clearhouse.submitted events.")
@click.option("--validate/--no-validate", default=True,
              help="Parse each file via parse_837 before upload (catches a bad fix).")
@click.option("--skip-edifabric-validate/--no-skip-edifabric-validate", default=False,
              help="Skip the SP40 Edifabric pre-upload validation gate. "
                   "Default: gate runs (any Status='error' aborts the file).")
@click.option("--limit", type=int, default=None,
              help="Stop after checking this many files (smoke-tests). Counts "
                   "all attempts, not just successful uploads.")
@click.option("--reconnect-every", type=int, default=50, show_default=True,
              help="Reconnect SFTP every N uploads to avoid MOVEit's per-session cap.")
def resubmit_rejected_claims(
    ingest_dir: str,
    actor: str,
    validate: bool,
    skip_edifabric_validate: bool,
    limit: int | None,
    reconnect_every: int,
) -> None:
    """Upload corrected 837 files to the Gainwell SFTP ToHPE dir (SP33).

    Walks every ``batch-*-claims/*.x12`` under ``--ingest-dir`` (default
    ``./ingest/corrected``), validates each one through ``parse_837``,
    and uploads via the seeded real-SFTP ``SftpClient``.

    Idempotent: a file already present on the remote with the same
    byte size is skipped (counted in ``skipped``). Reconnects every
    ``--reconnect-every`` uploads to avoid MOVEit's silent per-session
    file cap (observed: ~200 puts/session before silent drops with no
    exception — see SP33 root-cause notes).

    Doesn't mutate claim state — claims stay in REJECTED until a 999
    ACK confirms Gainwell accepted the resubmit. Emits one
    ``clearhouse.submitted`` audit event per successful upload.
    """
    import time

    from cyclone import db as db_mod
    from cyclone.audit_log import AuditEvent, append_event
    from cyclone.parsers.parse_837 import parse as parse_837_text
    from cyclone.parsers.payer import PayerConfig
    from cyclone.store import store as cycl_store

    db_mod.init_db()
    cycl_store.ensure_clearhouse_seeded()
    clearhouse = cycl_store.get_clearhouse()
    if clearhouse is None:
        click.echo("No clearhouse seeded; cannot resolve SFTP block.", err=True)
        sys.exit(2)
    sftp_block = clearhouse.sftp_block
    if sftp_block.stub:
        click.echo("Clearhouse SFTP block is in stub mode; refusing to upload.", err=True)
        sys.exit(2)
    remote_root = sftp_block.paths["outbound"]

    root = Path(ingest_dir).resolve()
    if not root.exists():
        click.echo(f"--ingest-dir does not exist: {root}", err=True)
        sys.exit(2)

    files: list[Path] = []
    for batch_dir in sorted(root.glob("batch-*-claims")):
        files.extend(sorted(p for p in batch_dir.glob("*.x12")
                            if not p.name.startswith("._")))
    if not files:
        click.echo(f"No .x12 files under {root}", err=True)
        sys.exit(2)

    click.echo(f"found {len(files)} files under {root}")

    uploaded = 0
    skipped = 0
    failed = 0
    validated = 0
    validate_failed = 0  # SP40: Edifabric gate aborted these
    validate_failures: list[tuple[str, list[dict]]] = []  # (filename, [Details...])
    payer_cfg = PayerConfig.co_medicaid()
    start = time.monotonic()
    last_progress = start

    # One persistent paramiko session per batch, with periodic
    # reconnect to dodge MOVEit's silent per-session file cap
    # (~200 puts/session, no exception — see SP33 root-cause notes).
    import paramiko
    from cyclone.secrets import get_secret

    def _open_session() -> tuple[paramiko.SSHClient, paramiko.SFTPClient]:
        pw = get_secret(sftp_block.auth.get("password_keychain_account", ""))
        ssh = paramiko.SSHClient()
        ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        ssh.connect(
            sftp_block.host, port=sftp_block.port,
            username=sftp_block.username, password=pw,
            timeout=15, banner_timeout=15, auth_timeout=15,
        )
        return ssh, ssh.open_sftp()

    def _close_session(ssh: paramiko.SSHClient | None) -> None:
        if ssh is None:
            return
        try: ssh.close()
        except Exception: pass

    ssh: paramiko.SSHClient | None = None
    sftp: paramiko.SFTPClient | None = None

    for i, src in enumerate(files, 1):
        if limit is not None and i > limit:
            break

        content = src.read_bytes()

        # SP39: always parse once so the instrumentation step below can
        # reach `parsed.claims` and `parsed.envelope` for the
        # Resubmission row (which needs claim_id + ICN/GCN). With
        # ``--no-validate`` the parse is still cheap for single-claim
        # files and avoids a re-parse just for instrumentation.
        try:
            parsed = parse_837_text(content.decode(), payer_cfg)
        except Exception as exc:  # noqa: BLE001
            failed += 1
            click.echo(f"PARSE FAIL  {src.name}: {exc.__class__.__name__}: {exc}", err=True)
            continue

        # Validate (when enabled): parse must succeed AND payer_id must
        # match the companion-guide CO_TXIX (catches a bad byte-fix
        # early).
        if validate:
            mismatch = next(
                (c for c in parsed.claims if c.payer.id != "CO_TXIX"), None
            )
            if mismatch is not None:
                failed += 1
                click.echo(
                    f"PAYER MISMATCH  {src.name}: payer.id={mismatch.payer.id!r} "
                    f"(expected 'CO_TXIX')", err=True,
                )
                continue
            validated += 1

        # SP40: Edifabric pre-upload gate. Fails closed on any
        # ``Status == "error"`` item — the file is recorded as
        # ``validate_failed`` and the batch continues with the other
        # files. Bypass with ``--skip-edifabric-validate`` (dev only).
        if not skip_edifabric_validate:
            from cyclone import edifabric
            try:
                op_result = edifabric.validate_edi(content)
            except edifabric.EdifabricError as exc:
                # Edifabric upstream failure (5xx / 4xx / config) —
                # treat as a hard fail for THIS file but don't stop
                # the batch. Operator should inspect.
                validate_failed += 1
                validate_failures.append((src.name, [
                    {"SegmentId": "?", "Message": f"edifabric: {exc}", "Status": "error"},
                ]))
                click.echo(
                    f"EDIFABRIC FAIL  {src.name}: {exc.__class__.__name__}: {exc}",
                    err=True,
                )
                continue
            if op_result.get("Status") == "error":
                validate_failed += 1
                details = op_result.get("Details") or []
                validate_failures.append((src.name, details))
                first = details[:5]
                click.echo(
                    f"EDIFABRIC VALIDATION FAIL  {src.name}: {len(details)} error(s)",
                    err=True,
                )
                for d in first:
                    seg = d.get("SegmentId", "?")
                    msg = d.get("Message", "")
                    click.echo(f"  [{d.get('Status', '?')}] {seg}: {msg}", err=True)
                if len(details) > len(first):
                    click.echo(f"  ... and {len(details) - len(first)} more", err=True)
                continue

        local_size = len(content)
        remote_path = f"{remote_root}/{src.name}"

        attempts = 0
        ok = False
        was_skipped = False
        while attempts < 3:
            attempts += 1
            if ssh is None:
                try:
                    ssh, sftp = _open_session()
                except Exception as exc:  # noqa: BLE001
                    click.echo(
                        f"SFTP CONNECT FAIL  attempt {attempts}: "
                        f"{exc.__class__.__name__}: {exc}", err=True,
                    )
                    continue
            try:
                # Idempotency check.
                try:
                    rs = sftp.stat(remote_path)  # type: ignore[union-attr]
                    if rs.st_size == local_size:
                        skipped += 1
                        was_skipped = True
                        break
                except IOError:
                    pass  # not on remote yet
                sftp.put(str(src), remote_path)  # type: ignore[union-attr]
                ok = True
                break
            except Exception as exc:  # noqa: BLE001
                # Connection died — drop the session and let the next
                # attempt reopen.
                _close_session(ssh)
                ssh, sftp = None, None
                if attempts >= 3:
                    click.echo(
                        f"UPLOAD FAIL  {src.name}: "
                        f"{exc.__class__.__name__}: {exc}", err=True,
                    )
                continue

        if not ok and not was_skipped:
            failed += 1
            continue

        # Audit + reconnect cadence apply only to real uploads.
        if not was_skipped:
            # Audit (best-effort; if the DB is unavailable we still
            # keep the file on the wire).
            try:
                with db_mod.SessionLocal()() as session:
                    append_event(session, AuditEvent(
                        event_type="clearhouse.submitted",
                        entity_type="claim_file",
                        entity_id=src.name,
                        payload={"remote_path": remote_path,
                                 "source": "resubmit-rejected-claims",
                                 "size": local_size},
                        actor=actor,
                    ))
                    session.commit()
            except Exception as exc:  # noqa: BLE001
                click.echo(
                    f"audit-log write failed for {src.name}: "
                    f"{exc.__class__.__name__}: {exc}", err=True,
                )

            # SP39: record one Resubmission row per claim per push.
            # The corrected-v2 regen preserves the original claim_id
            # from raw_json, so inbound 999 acks auto-link back to the
            # original claim row and the resubmissions status CLI can
            # surface the result via the existing SP28/31 auto-link.
            # Idempotent on (claim_id, interchange_control_number).
            try:
                # Derive batch_id from the parent dir name
                # (batch-<id>-<N>-claims -> <id>).
                dir_name = src.parent.name
                if dir_name.startswith("batch-") and "-" in dir_name[6:]:
                    push_batch_id = dir_name.split("-")[1]
                else:
                    push_batch_id = dir_name
                env = getattr(parsed, "envelope", None)
                icn = getattr(env, "control_number", None) or "000000000"
                gcn = getattr(env, "group_control_number", None) or "0"
                for claim in parsed.claims:
                    claim_id = getattr(claim, "claim_id", None)
                    if not claim_id:
                        continue
                    cycl_store.record_resubmission(
                        claim_id=claim_id,
                        batch_id=push_batch_id,
                        source_corrected_path=str(src),
                        interchange_control_number=icn,
                        group_control_number=gcn,
                    )
            except Exception as exc:  # noqa: BLE001
                click.echo(
                    f"resubmissions row write failed for {src.name}: "
                    f"{exc.__class__.__name__}: {exc}", err=True,
                )

            uploaded += 1

            # Reconnect periodically to dodge MOVEit's per-session cap.
            if uploaded % reconnect_every == 0:
                click.echo(f"reconnecting (after {uploaded} uploads)", err=True)
                _close_session(ssh)
                ssh, sftp = None, None

        # Progress every 10s of wall-clock (or at end).
        now = time.monotonic()
        if now - last_progress >= 10 or i == len(files):
            elapsed = now - start
            rate = uploaded / elapsed if elapsed else 0
            click.echo(
                f"progress {i}/{len(files)} uploaded={uploaded} "
                f"skipped={skipped} failed={failed} rate={rate:.2f}/s "
                f"elapsed={elapsed:.1f}s", err=True,
            )
            last_progress = now

    # Tear down the long-lived session if one is still open.
    if ssh is not None:
        _close_session(ssh)

    elapsed = time.monotonic() - start
    click.echo(
        f"DONE uploaded={uploaded} skipped={skipped} failed={failed} "
        f"validated={validated} validate_failed={validate_failed} "
        f"files_total={len(files)} elapsed={elapsed:.1f}s"
    )

    # SP40: print Edifabric validation failures as a group so the
    # operator can fix and re-run. One block, sorted by file name.
    if validate_failures:
        click.echo(
            f"\n{len(validate_failures)} file(s) blocked by Edifabric gate:",
            err=True,
        )
        for name, details in sorted(validate_failures):
            click.echo(f"  {name}", err=True)
            for d in details:
                seg = d.get("SegmentId", "?")
                msg = d.get("Message", "")
                click.echo(f"    [{d.get('Status', '?')}] {seg}: {msg}", err=True)


@main.command("submit-batch")
@click.option("--ingest-dir", default="ingest",
              show_default=True,
              help="Root dir holding batch-*-claims/ subfolders of .x12 files.")
@click.option("--actor", default="cli-submit-batch",
              show_default=True,
              help="Audit-log actor tag for the clearhouse.submitted events.")
@click.option("--validate/--no-validate", default=True,
              help="Parse each file via parse_837 before upload.")
@click.option("--limit", type=int, default=None,
              help="Stop after N files (smoke-tests).")
def submit_batch(
    ingest_dir: str,
    actor: str,
    validate: bool,
    limit: int | None,
) -> None:
    """Parse → DB-write → SFTP-upload each batch-*-claims/*.x12 (SP37).

    Canonical outbound path. Mirrors ``resubmit-rejected-claims`` but
    routes through ``cyclone.submission.submit_file``, which writes to
    the DB before uploading. Use this as the preferred outbound path;
    ``resubmit-rejected-claims`` remains for one-off cases where you
    don't want a DB row (dzinesco-generated fixes not ready for
    canonical tracking).

    Exit codes: 0 = run completed (even with per-file failures), 1 =
    unexpected exception, 2 = config-level failure (no clearhouse,
    SFTP block in stub mode, ingest-dir missing).
    """
    from cyclone import db as db_mod
    from cyclone.submission.core import submit_file
    from cyclone.submission.result import SubmitOutcome
    from cyclone.store import store as cycl_store

    db_mod.init_db()
    cycl_store.ensure_clearhouse_seeded()
    clearhouse = cycl_store.get_clearhouse()
    if clearhouse is None:
        click.echo("No clearhouse seeded; cannot resolve SFTP block.", err=True)
        sys.exit(2)
    sftp_block = clearhouse.sftp_block
    if sftp_block.stub:
        click.echo("Clearhouse SFTP block is in stub mode; refusing to upload.", err=True)
        sys.exit(2)

    root = Path(ingest_dir).resolve()
    if not root.exists():
        click.echo(f"--ingest-dir does not exist: {root}", err=True)
        sys.exit(2)

    files: list[Path] = []
    for batch_dir in sorted(root.glob("batch-*-claims")):
        files.extend(sorted(p for p in batch_dir.glob("*.x12")
                            if not p.name.startswith("._")))
    if not files:
        click.echo(f"No .x12 files under {root}", err=True)
        sys.exit(2)

    click.echo(f"found {len(files)} files under {root}")

    submitted = 0
    skipped = 0
    failed = 0
    for i, src in enumerate(files, 1):
        if limit is not None and i > limit:
            break
        try:
            result = submit_file(
                src, sftp_block=sftp_block, actor=actor, validate=validate,
            )
        except Exception as exc:  # noqa: BLE001
            click.echo(f"UNEXPECTED  {src.name}: {exc.__class__.__name__}: {exc}", err=True)
            failed += 1
            continue
        if result.outcome == SubmitOutcome.SUBMITTED:
            submitted += 1
            click.echo(f"submitted  {src.name}  batch={result.batch_id}")
        elif result.outcome == SubmitOutcome.SKIPPED:
            skipped += 1
            click.echo(f"skipped    {src.name}  (already uploaded)")
        else:
            failed += 1
            click.echo(
                f"{result.outcome.value:<10}  {src.name}  {result.error or ''}",
                err=True,
            )

    click.echo(f"\nsummary: submitted={submitted} skipped={skipped} failed={failed}")
    # Per-file failures don't bump exit code; details in stdout.
    # (Per cyclone-cli convention.)


@main.group()
def backup() -> None:
    """Encrypted DB backup management (SP17)."""


@backup.command("init-passphrase")
@click.option("--passphrase", required=True, help="The passphrase to set (will prompt if omitted)")
@click.option("--from-stdin", is_flag=True, help="Read passphrase from stdin instead of the argument")
def backup_init_passphrase(passphrase: str, from_stdin: bool) -> None:
    """Set the backup encryption passphrase in the macOS Keychain.

    Generates a fresh salt and stores both the passphrase (account
    ``backup.passphrase``) and the salt (account ``backup.salt``)
    under service ``cyclone``. Cyclone's BackupService reads them
    at startup. If the passphrase account is missing, the service
    falls back to deriving a key from the SQLCipher DB key
    (degraded posture, logged at WARNING).
    """
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod
    from getpass import getpass
    import os as _os

    if from_stdin:
        pp = getpass("Backup passphrase: ").strip()
        pp2 = getpass("Confirm: ").strip()
        if not pp or pp != pp2:
            click.echo("passphrase empty or mismatch", err=True)
            sys.exit(2)
    else:
        pp = passphrase

    if not pp or len(pp) < 12:
        click.echo("passphrase must be at least 12 characters", err=True)
        sys.exit(2)

    if not secrets_mod.set_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT, pp):
        click.echo("failed to store passphrase in Keychain", err=True)
        sys.exit(1)

    # Generate + persist a fresh salt. Same value must be used by
    # every subsequent invocation that uses this passphrase.
    salt = _os.urandom(16)
    if not secrets_mod.set_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT, salt.hex()):
        click.echo(
            "WARN: passphrase stored but salt write failed; backups may be unrecoverable",
            err=True,
        )
        sys.exit(1)

    click.echo(
        f"passphrase stored in Keychain account {svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT!r}\n"
        f"salt stored in Keychain account {svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT!r}"
    )


def _resolve_backup_dir(cli_override: str | None) -> "Path":
    """Resolve the backup directory: --backup-dir > $CYCLONE_BACKUP_DIR > default."""
    import os as _os
    from pathlib import Path as _Path
    from cyclone import db as db_mod
    if cli_override:
        return _Path(cli_override)
    env = _os.environ.get("CYCLONE_BACKUP_DIR")
    if env:
        return _Path(env)
    return _Path(db_mod.DEFAULT_DB_PATH.parent / "backups")


@backup.command("create")
@click.option("--backup-dir", default=None, help="Override CYCLONE_BACKUP_DIR (default: ~/.local/share/cyclone/backups)")
@click.option("--retention-days", default=None, type=int, help="Override CYCLONE_BACKUP_RETENTION_DAYS for this run's prune")
def backup_create(backup_dir: str | None, retention_days: int | None) -> None:
    """Take an encrypted backup right now."""
    from cyclone import db as db_mod
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod

    db_mod.init_db()

    passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
    salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
    salt = bytes.fromhex(salt_hex) if salt_hex else None
    target_dir = _resolve_backup_dir(backup_dir)
    svc_mod.reset_backup_service_for_tests()
    svc = svc_mod.configure_backup_service(
        backup_dir=target_dir,
        passphrase=passphrase,
        salt=salt,
        retention_days=retention_days or 30,
    )
    result = svc.create_now()
    click.echo(
        f"created backup id={result.backup.id} filename={result.backup.filename} "
        f"size={result.backup.size_bytes}B fp={result.backup.db_fingerprint[:24]}..."
    )


@backup.command("list")
@click.option("--limit", default=50, show_default=True)
@click.option("--status", default=None, help="Filter: ok|error|pending|pruned")
def backup_list(limit: int, status: str | None) -> None:
    """List existing backups (newest first)."""
    from cyclone import db as db_mod
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod

    db_mod.init_db()
    passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
    salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
    salt = bytes.fromhex(salt_hex) if salt_hex else None
    svc_mod.reset_backup_service_for_tests()
    svc = svc_mod.configure_backup_service(
        backup_dir=_resolve_backup_dir(None),
        passphrase=passphrase,
        salt=salt,
    )
    rows = svc.list_backups(limit=limit, status=status)
    if not rows:
        click.echo("(no backups)")
        return
    for r in rows:
        click.echo(
            f"{r.id:4d}  {r.status:7s}  {r.created_at.isoformat() if r.created_at else '-'}  "
            f"{r.size_bytes:>10d}B  {r.filename}  fp={r.db_fingerprint[:24] or '-':<24}"
        )


@backup.command("verify")
@click.argument("backup_id", type=int)
def backup_verify(backup_id: int) -> None:
    """Decrypt + checksum-verify a backup."""
    from cyclone import db as db_mod
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod

    db_mod.init_db()
    passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
    salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
    salt = bytes.fromhex(salt_hex) if salt_hex else None
    svc_mod.reset_backup_service_for_tests()
    svc = svc_mod.configure_backup_service(
        backup_dir=_resolve_backup_dir(None),
        passphrase=passphrase,
        salt=salt,
    )
    v = svc.verify(backup_id)
    if v.ok:
        click.echo(f"OK: id={v.backup_id} fp={v.actual_fingerprint[:24]}... table_count={v.table_count}")
        return
    click.echo(
        f"FAIL: id={v.backup_id} reason={v.reason} "
        f"expected={v.expected_fingerprint[:24] if v.expected_fingerprint else '-'}... "
        f"actual={v.actual_fingerprint[:24] if v.actual_fingerprint else '-'}...",
        err=True,
    )
    sys.exit(1)


@backup.command("restore")
@click.argument("backup_id", type=int)
@click.option("--yes", is_flag=True, help="Skip the interactive confirm prompt")
@click.option("--actor", default="operator-cli", show_default=True)
def backup_restore(backup_id: int, yes: bool, actor: str) -> None:
    """Restore the live DB from a backup (two-step, requires --yes)."""
    from cyclone import db as db_mod
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod

    db_mod.init_db()
    passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
    salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
    salt = bytes.fromhex(salt_hex) if salt_hex else None
    svc_mod.reset_backup_service_for_tests()
    svc = svc_mod.configure_backup_service(
        backup_dir=_resolve_backup_dir(None),
        passphrase=passphrase,
        salt=salt,
    )

    click.echo(f"Initiating restore from backup {backup_id}...")
    init = svc.restore_initiate(backup_id)
    click.echo(
        f"  backup:    {init.filename} ({init.size_bytes} bytes)\n"
        f"  fp:        {init.db_fingerprint[:24]}...\n"
        f"  tables:    {init.table_count}\n"
        f"  current:   fp={init.current_db_fingerprint[:24] if init.current_db_fingerprint else '-'}... "
        f"tables={init.current_table_count}\n"
        f"  token ttl: {(init.expires_at - __import__('datetime').datetime.now(__import__('datetime').timezone.utc)).total_seconds():.0f}s"
    )

    if not yes:
        click.confirm(
            "Replace the live DB with this backup? "
            "This will dispose the engine and rebuild it.",
            abort=True,
        )

    click.echo("Confirming restore...")
    result = svc.restore_confirm(backup_id, init.restore_token, actor=actor)
    click.echo(
        f"OK: restored from fp={result.restored_from_fingerprint[:24]}... "
        f"to fp={result.new_db_fingerprint[:24]}... at {result.restored_at.isoformat()}"
    )


@backup.command("prune")
@click.option("--retention-days", default=None, type=int)
@click.option("--yes", is_flag=True, help="Skip the confirm prompt")
def backup_prune(retention_days: int | None, yes: bool) -> None:
    """Apply the retention policy (delete old backups)."""
    from cyclone import db as db_mod
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod

    db_mod.init_db()
    passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
    salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
    salt = bytes.fromhex(salt_hex) if salt_hex else None
    svc_mod.reset_backup_service_for_tests()
    svc = svc_mod.configure_backup_service(
        backup_dir=_resolve_backup_dir(None),
        passphrase=passphrase,
        salt=salt,
        retention_days=retention_days or 30,
    )
    if not yes:
        click.confirm(
            f"Delete all backups older than {svc._retention_days} days?",
            abort=True,
        )
    deleted = svc.prune()
    click.echo(f"Deleted {len(deleted)} file(s):")
    for p in deleted:
        click.echo(f"  {p}")


@backup.command("status")
def backup_status() -> None:
    """Print the backup subsystem status snapshot."""
    from cyclone import db as db_mod
    from cyclone import backup_service as svc_mod
    from cyclone import secrets as secrets_mod

    db_mod.init_db()
    passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
    salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
    salt = bytes.fromhex(salt_hex) if salt_hex else None
    svc_mod.reset_backup_service_for_tests()
    svc = svc_mod.configure_backup_service(
        backup_dir=_resolve_backup_dir(None),
        passphrase=passphrase,
        salt=salt,
    )
    snap = svc.status()
    import json
    click.echo(json.dumps(snap, indent=2, default=str))


# ---------------------------------------------------------------------------
# SP-N fix: `cyclone pull-inbound` — targeted inbound pull + process
#
# Mirrors POST /api/admin/scheduler/pull-inbound. For operators who
# want to drive the daily "process today's 999s" workflow from a cron
# job or shell script without going through the HTTP API.
#
# Exit codes:
#   0  — processed ≥1 file (or processed 0 with no matches: not an
#        error, just nothing to do)
#   1  — unexpected exception
#   2  — SFTP / config error
# ---------------------------------------------------------------------------


@main.command("pull-inbound")
@click.option(
    "--date", "date_str",
    required=True,
    help="Date filter YYYYMMDD — only files whose 8-digit timestamp "
         "substring matches are downloaded and processed.",
)
@click.option(
    "--block", "sftp_block_name",
    default="dzinesco",
    show_default=True,
    help="Name of the SftpBlock in config/payers.yaml to use.",
)
@click.option(
    "--file-types", "file_types_csv",
    default=None,
    help="Comma-separated whitelist (default: 999,TA1).",
)
@click.option(
    "--limit", default=2000, show_default=True, type=int,
)
def pull_inbound(
    date_str: str,
    sftp_block_name: str,
    file_types_csv: str | None,
    limit: int,
) -> None:
    """List, date-filter, download, and process inbound MFT files.

    Bypasses the alphabetical full-listing pass so a daily pull of
    ~400 files takes seconds, not hours. Files already in the local
    cache are skipped (idempotent).
    """
    import asyncio as _asyncio
    from cyclone import db as db_mod
    from cyclone import scheduler as scheduler_mod
    from cyclone.edi.filenames import ALLOWED_FILE_TYPES
    from cyclone.clearhouse import SftpClient
    from cyclone.providers import SftpBlock

    # Validate the date filter.
    if not (len(date_str) == 8 and date_str.isdigit()):
        click.echo(f"--date must be YYYYMMDD, got {date_str!r}", err=True)
        sys.exit(2)

    db_mod.init_db()

    # Find the SftpBlock. The dzinesco clearhouse singleton is seeded
    # by store.ensure_clearhouse_seeded() (SP9) and carries the
    # production SFTP block; that's the only one we have at the
    # moment. For multi-provider SFTP, a config-loader is the right
    # thing — out of scope for this CLI which is the daily-pull path.
    from cyclone import store as store_mod
    store_mod.store.ensure_clearhouse_seeded()
    clearhouse = store_mod.store.get_clearhouse()
    if clearhouse is None or clearhouse.name != sftp_block_name:
        # Fall back: try the named block, but v1 only ships the
        # dzinesco singleton.
        if clearhouse is None:
            click.echo(
                f"No clearhouse seeded — cannot find SftpBlock "
                f"{sftp_block_name!r}.",
                err=True,
            )
            sys.exit(2)
        click.echo(
            f"SftpBlock {sftp_block_name!r} not seeded; only "
            f"{clearhouse.name!r} is available.",
            err=True,
        )
        sys.exit(2)
    block: SftpBlock = clearhouse.sftp_block

    if file_types_csv:
        wanted = {t.strip().upper() for t in file_types_csv.split(",") if t.strip()}
        unknown = wanted - ALLOWED_FILE_TYPES
        if unknown:
            click.echo(
                f"file_types {sorted(unknown)!r} not in {sorted(ALLOWED_FILE_TYPES)}",
                err=True,
            )
            sys.exit(2)
    else:
        wanted = {"999", "TA1"}

    # Wire up the scheduler singleton (re-use the same pipeline the
    # HTTP endpoint uses, including the dedup via processed_inbound_files).
    scheduler_mod.configure_scheduler(
        block, sftp_block_name=sftp_block_name, force=True,
    )
    sched = scheduler_mod.get_scheduler()
    client = SftpClient(block)

    async def _run() -> dict:
        from cyclone.edi.filenames import parse_inbound_filename

        all_files = await _asyncio.to_thread(client.list_inbound_names)
        matched: list = []
        for f in all_files:
            if f.name.find(date_str) == -1:
                continue
            try:
                parsed = parse_inbound_filename(f.name)
            except ValueError:
                continue
            if parsed.file_type not in wanted:
                continue
            matched.append(f)
            if len(matched) >= limit:
                break

        download_errors: list[str] = []
        downloaded = 0
        for f in matched:
            try:
                await _asyncio.to_thread(client.download_inbound, f)
                downloaded += 1
            except Exception as exc:  # noqa: BLE001
                download_errors.append(f"{f.name}: {type(exc).__name__}: {exc}")

        tick = await sched.process_inbound_files(matched)
        return {
            "listed": len(all_files),
            "matched": len(matched),
            "downloaded": downloaded,
            "download_errors": download_errors,
            "processed": tick.files_processed,
            "skipped": tick.files_skipped,
            "errored": tick.files_errored,
        }

    try:
        summary = _asyncio.run(_run())
    except Exception as exc:  # noqa: BLE001
        click.echo(f"pull-inbound failed: {type(exc).__name__}: {exc}", err=True)
        sys.exit(1)

    click.echo(
        f"date={date_str} file_types={sorted(wanted)} block={sftp_block_name} "
        f"listed={summary['listed']} matched={summary['matched']} "
        f"downloaded={summary['downloaded']} processed={summary['processed']} "
        f"skipped={summary['skipped']} errored={summary['errored']}"
    )
    if summary["download_errors"]:
        click.echo("download errors:", err=True)
        for e in summary["download_errors"]:
            click.echo(f"  {e}", err=True)


# ---------------------------------------------------------------------------
# SP38: ack-orphans status + reconcile
# ---------------------------------------------------------------------------


@main.group("ack-orphans")
def ack_orphans_group() -> None:
    """Inspect and reconcile 999 acks with no resolvable source claim.

    "Orphans" are 999 acks whose source 837 batch is not present in
    the ``batches`` table — typically because the source 837 was
    submitted to HPE before the current ``cyclone.db`` snapshot was
    created. They are real production data (valid audit history) but
    cannot be auto-linked to claims.

    See ``docs/superpowers/specs/2026-07-07-cyclone-orphan-ack-housekeeping-design.md``
    for the full design and operator triage workflow.
    """


@ack_orphans_group.command("status")
def ack_orphans_status_cmd() -> None:
    """Print a per-ST02 summary of orphan 999 acks.

    One row per distinct orphan ``set_control_number`` (ST02), ranked
    by ``ack_count DESC`` so the heaviest backlog surfaces first.
    Plus a TOTAL row at the bottom.

    Exit codes: 0 on success, 1 on DB error.
    """
    from cyclone import db as db_mod
    from cyclone.store import store as cycl_store

    try:
        db_mod.init_db()
        summary = cycl_store.find_ack_orphan_st02_summary()
    except Exception as exc:  # noqa: BLE001
        click.echo(f"ack-orphans status failed: {type(exc).__name__}: {exc}", err=True)
        sys.exit(1)

    if not summary:
        click.echo("no orphans (acks is empty or all are linked)")
        return

    # Table layout: ST02 | ACK COUNT | HAS BATCH
    click.echo(f"{'ST02':<15} {'ACK COUNT':>10}   HAS BATCH")
    click.echo(f"{'----':-<15} {'---------':->10}   ---------")
    total = 0
    for row in summary:
        marker = "yes" if row["has_batch"] else "no"
        click.echo(
            f"{row['st02']:<15} {row['ack_count']:>10}   {marker}"
        )
        total += row["ack_count"]
    click.echo(f"{'TOTAL':<15} {total:>10}")


@ack_orphans_group.command("reconcile")
@click.option("--dry-run", is_flag=True,
              help="Print the plan but do not insert any rows.")
def ack_orphans_reconcile_cmd(dry_run: bool) -> None:
    """Insert synthetic batches rows for orphan ST02s that lack one.

    For every orphan ST02 where no ``batches`` row exists, insert a
    synthetic row marked with ``input_filename =
    '<synthetic:orphan-reconcile>'`` so future 999 acks for the same
    ST02s can resolve against the ``batch_envelope_index`` (though
    they still won't link to claims — the source 837s were never
    ingested).

    Idempotent: re-running after a successful pass is a no-op.

    Exit codes: 0 on success, 1 on DB error.
    """
    from cyclone import db as db_mod
    from cyclone.store import store as cycl_store

    try:
        db_mod.init_db()
        plan = cycl_store.reconcile_orphan_st02s(dry_run=dry_run)
    except Exception as exc:  # noqa: BLE001
        click.echo(f"ack-orphans reconcile failed: {type(exc).__name__}: {exc}", err=True)
        sys.exit(1)

    click.echo(
        f"Created: {plan['created']} synthetic batch rows. "
        f"Skipped: {plan['skipped']} (already had a batch row)."
    )
    if dry_run:
        click.echo("(dry-run — no rows inserted)")


# ---------------------------------------------------------------------------
# SP39: `cyclone resubmissions status` — operator view onto the
# resubmissions audit table. Joins against claim_acks (via the
# existing SP28/31 auto-link) + remittances (via CLP->claim) so the
# operator can confirm post-submission outcomes of the corrected
# 837 files pushed via `cyclone resubmit-rejected-claims`.
# ---------------------------------------------------------------------------


@main.group("resubmissions")
def resubmissions_group() -> None:
    """SP39: inspect post-submission status of corrected-file pushes."""


@resubmissions_group.command("status")
@click.option("--batch-id", default=None,
              help="Restrict to a single source batch_id "
                   "(matches ingest/corrected-v2/<batch-id>-<N>-claims/).")
@click.option("--limit", default=200, type=int,
              help="Maximum rows to print (default 200).")
def resubmissions_status_cmd(batch_id: str | None, limit: int) -> None:
    """Print per-claim resubmission status (ack + payment).

    One row per ``resubmissions`` row, optionally filtered by
    ``--batch-id``. The ``ack`` column reflects the latest inbound
    999 / 277CA accept-reject code linked to the claim; the
    ``payment`` column is ``paid`` once a remittance CLP links.

    Exit codes: 0 on success, 1 on DB error.
    """
    from cyclone import db as db_mod
    from cyclone.store import store as cycl_store

    try:
        db_mod.init_db()
        rows = cycl_store.find_resubmission_status(batch_id=batch_id)
    except Exception as exc:  # noqa: BLE001
        click.echo(f"resubmissions status failed: {type(exc).__name__}: {exc}", err=True)
        sys.exit(1)

    if not rows:
        click.echo("no resubmissions recorded")
        return

    click.echo(
        f"{'claim_id':<22} {'batch_id':<36} {'resubmitted_at':<20} "
        f"{'ack':<16} {'payment':<14}"
    )
    click.echo("-" * 110)
    for r in rows[:limit]:
        click.echo(
            f"{r.claim_id:<22} {r.batch_id:<36} "
            f"{r.resubmitted_at.isoformat(timespec='seconds'):<20} "
            f"{(r.ack_status or 'pending_999'):<16} "
            f"{(r.payment_status or '-'):<14}"
        )
    if len(rows) > limit:
        click.echo(
            f"... ({len(rows) - limit} more; pass --limit to see all)"
        )


# ---------------------------------------------------------------------------
# SP25: ``cyclone recover-ingest`` — offline parse + DB-write helper for
# stranded source files in ingest/. Mirrors the data-writing half of
# ``submit-batch`` but does NOT SFTP-upload (the files were already
# uploaded by whatever path got them into ingest/) and does NOT write
# to audit_log (the submit event is already in history).
#
# Supports 837P and 835. 999/TA1/277CA ingestion lives on
# ``cyclone pull-inbound``; this command is for the larger source files
# that need a typed BatchRecord + the canonical CycloneStore.add path so
# live-tail pages light up.
#
# Exit codes (mirrors submit-batch):
#   0  — at least one file ingested OK (or all were duplicates).
#   2  — a file failed to parse or DB-write.
#   1  — unexpected exception (DB unreachable, etc.).
# ---------------------------------------------------------------------------


@main.command("recover-ingest")
@click.option(
    "--file", "files", multiple=True, required=True,
    type=click.Path(exists=True, dir_okay=False, path_type=Path),
    help="One or more X12 files to recover. Repeatable.",
)
@click.option(
    "--sftp-block-name", "sftp_block_name",
    default="manual-recover", show_default=True,
    help="Dedup key in processed_inbound_files. Defaults to "
         "'manual-recover' so it doesn't collide with the real "
         "dzinesco block.",
)
@click.option(
    "--actor", default="recover-ingest", show_default=True,
    help="Audit actor tag (reserved — recovery doesn't audit by default).",
)
def recover_ingest_cmd(
    files: tuple[Path, ...],
    sftp_block_name: str,
    actor: str,  # noqa: ARG001 — reserved
) -> None:
    """Parse X12 files from a local path and write them to the DB.

    Detects the kind (837P / 835) from the first ST segment; supports
    only those two. Dedupes by ``(sftp_block_name, filename)`` — a
    second invocation against the same file is a no-op.

    Use this when an 837P / 835 file landed in ``ingest/`` but the
    cyclic ``parse-837`` / ``parse-835`` CLI only emits JSON (it does
    NOT write to the DB) and ``submit-batch`` was never called.
    """
    from cyclone import db as db_mod
    from cyclone.submission.recover import recover_file

    # Pull-inbound convention — must init_db() before any SQL access.
    db_mod.init_db()

    click.echo(f"Recovering {len(files)} file(s) under sftp_block={sftp_block_name!r}")
    any_ok = False
    failed = 0
    for f in files:
        result = recover_file(f, sftp_block_name=sftp_block_name)
        if result["status"] == "ok":
            click.echo(
                f"  ok      {f}  kind={result['kind']}  "
                f"batch_id={result['batch_id']}"
            )
            any_ok = True
        elif result["status"] == "duplicate":
            click.echo(f"  skipped {f}  (already processed)")
        else:  # failed
            failed += 1
            click.echo(
                f"  FAILED  {f}  error={result['error']}", err=True,
            )

    if failed and not any_ok:
        click.echo(f"\n{failed} file(s) failed; nothing ingested.", err=True)
        sys.exit(2)
    if failed:
        # Mixed bag — some ok some failed.
        click.echo(f"\n{failed} file(s) failed; the rest ingested.", err=True)
        sys.exit(2)
    if any_ok:
        click.echo("\nDone.")


# ---------------------------------------------------------------------------
# SP41: `cyclone rebill-from-835` — in-window rebill pipeline.
#
# Reads an AxisCare visits CSV, walks 835 files for matched DOS, classifies
# each visit as PAID / PARTIAL / DENIED / NOT_IN_835, runs the CARC filter
# on DENIED visits, applies the 120-day timely-filing gate on NOT_IN_835
# visits, and writes a summary CSV + per-pipeline 837P files. Pass
# `--status` (with optional `--out`) to print a tally table for the most
# recent summary.csv.
#
# Exit codes:
#   0 — summary written (or `--status` printed, or help printed).
#   2 — file-level failure (e.g. visits CSV not found).
#   1 — unexpected exception.
# ---------------------------------------------------------------------------


def _parse_window(value: str) -> tuple[_date, _date]:
    """Parse ``YYYY-MM-DD..YYYY-MM-DD`` (inclusive both ends).

    Split manually because click.DateTime won't parse a range. The format
    is unambiguous (the ``..`` separator is the giveaway) and Click gives
    us a single string to consume here.
    """
    try:
        start_str, end_str = value.split("..", 1)
    except ValueError as exc:
        raise click.BadParameter(
            f"window must be 'YYYY-MM-DD..YYYY-MM-DD', got {value!r}"
        ) from exc
    try:
        start = _date.fromisoformat(start_str)
        end = _date.fromisoformat(end_str)
    except ValueError as exc:
        raise click.BadParameter(
            f"window must be 'YYYY-MM-DD..YYYY-MM-DD', got {value!r}: {exc}"
        ) from exc
    if start > end:
        raise click.BadParameter(
            f"window start {start.isoformat()} is after end {end.isoformat()}"
        )
    return start, end


def _parse_visits_csv(path: Path) -> list["VisitRow"]:
    """Read an AxisCare visits CSV into VisitRow dataclasses.

    Header: ``Visit Date,Member ID,Procedure Code,Billable Amount``.
    Visit Date is ``MM/DD/YYYY``; Billable Amount may carry a ``$`` prefix
    and ``,`` thousands separators. The header is validated up-front and
    per-row errors include the row number so operator typos surface as a
    clean ``BadParameter`` instead of silently dropping visits.
    """
    import csv as _csv

    from cyclone.rebill.reconcile import VisitRow

    expected = {"Visit Date", "Member ID", "Procedure Code", "Billable Amount"}

    out: list[VisitRow] = []
    with path.open(newline="") as f:
        reader = _csv.DictReader(f)
        got = set(reader.fieldnames or [])
        if got != expected:
            raise click.BadParameter(
                f"visits CSV missing columns; expected {sorted(expected)}, "
                f"got {sorted(reader.fieldnames or [])}"
            )
        for i, row in enumerate(reader, start=2):  # row 1 is the header
            try:
                visit_date_str = (row.get("Visit Date") or "").strip()
                member_id = (row.get("Member ID") or "").strip()
                procedure = (row.get("Procedure Code") or "").strip()
                billed_str = (row.get("Billable Amount") or "").strip()
                if not (visit_date_str and member_id and procedure and billed_str):
                    # Blank trail row from AxisCare exports — skip silently.
                    continue
                mm, dd, yyyy = visit_date_str.split("/")
                visit_date = _date(int(yyyy), int(mm), int(dd))
                billed = _Decimal(billed_str.replace("$", "").replace(",", ""))
                out.append(VisitRow(
                    date=visit_date,
                    member_id=member_id,
                    procedure=procedure,
                    billed=billed,
                ))
            except (ValueError, KeyError, TypeError) as exc:
                raise click.BadParameter(
                    f"visits CSV row {i} invalid ({row!r}): {exc}"
                ) from exc
    return out


def _print_status(out: Path | None) -> None:
    """Print a per-disposition tally for the most recent summary.csv.

    If ``out`` is None or doesn't contain a summary.csv, look under
    ``<repo>/dev/rebills/<today>/summary.csv``. Print a friendly
    "no prior runs" message if nothing is found; never exit nonzero
    from a status query.
    """
    candidates: list[Path] = []
    if out is not None:
        candidates.append(out / "summary.csv")
    # Fallback: the canonical dev output tree.
    repo_root = Path(__file__).resolve().parents[3]
    today = _date.today().isoformat()
    candidates.append(repo_root / "dev" / "rebills" / today / "summary.csv")
    # Also accept any YYYY-MM-DD subdir of dev/rebills (newest first by
    # directory mtime) so older runs surface too.
    rebills_root = repo_root / "dev" / "rebills"
    if rebills_root.exists():
        dated = sorted(
            [p for p in rebills_root.iterdir() if p.is_dir()],
            key=lambda p: p.stat().st_mtime,
            reverse=True,
        )
        for d in dated:
            candidates.append(d / "summary.csv")

    summary_path: Path | None = None
    for c in candidates:
        if c.exists():
            summary_path = c
            break

    if summary_path is None:
        click.echo("no prior rebill-from-835 runs found")
        return

    click.echo(f"status: {summary_path}")
    import csv as _csv
    tally: dict[str, int] = {}
    total = 0
    with summary_path.open(newline="") as f:
        reader = _csv.DictReader(f)
        for row in reader:
            disp = row.get("disposition") or "UNKNOWN"
            tally[disp] = tally.get(disp, 0) + 1
            total += 1

    if total == 0:
        click.echo("(empty summary)")
        return

    width = max(len(d) for d in tally)
    click.echo(f"{'disposition':<{width}}  count")
    click.echo(f"{'-' * width}  -----")
    for disp in sorted(tally):
        click.echo(f"{disp:<{width}}  {tally[disp]}")
    click.echo(f"{'-' * width}  -----")
    click.echo(f"{'TOTAL':<{width}}  {total}")


@main.command("rebill-from-835")
@click.option(
    "--window", default="2026-01-01..2026-06-27",
    help="DOS window as YYYY-MM-DD..YYYY-MM-DD (inclusive both ends).",
)
@click.option(
    "--visits", type=click.Path(exists=True, dir_okay=False, path_type=Path),
    default=None,
    help="Path to AxisCare visits CSV (header: Visit Date,Member ID,Procedure Code,Billable Amount).",
)
@click.option(
    "--ingest", type=click.Path(exists=True, file_okay=False, path_type=Path),
    default=None,
    help="Directory containing 835 files (*.835, *.x12).",
)
@click.option(
    "--out", type=click.Path(file_okay=False, path_type=Path),
    default=None,
    help="Output directory; will be created. Required for a real run; "
         "optional with --status (then defaults to <repo>/dev/rebills/<today>/).",
)
@click.option(
    "--as-of", default=None,
    help="Reference date for the 120-day timely-filing gate (default: today, YYYY-MM-DD).",
)
@click.option(
    "--override-filing/--no-override-filing", default=False,
    help="Relax the 120-day gate for past-window visits (per-batch).",
)
@click.option(
    "--tpid", default="11525703", show_default=True,
    help="Trading-partner ID for HCPF-spec outbound filenames.",
)
@click.option(
    "--status", "show_status", is_flag=True, default=False,
    help="Show the per-disposition tally for the most recent summary.csv and exit. "
         "Works without --visits / --ingest.",
)
def rebill_from_835(
    window: str,
    visits: Path | None,
    ingest: Path | None,
    out: Path | None,
    as_of: str | None,
    override_filing: bool,
    tpid: str,
    show_status: bool,
) -> None:
    """Generate rebill 837Ps for denied/partial and NOT_IN_835 visits in a DOS window.

    Walks the visits CSV against 835 files in the DOS window, classifies each
    visit, runs the CARC filter on DENIED outcomes, applies the 120-day timely-
    filing gate on NOT_IN_835 outcomes, and writes a summary CSV. Pipeline A
    files (denied/partial → frequency-7) and Pipeline B files (NOT_IN_835 →
    fresh 837P by member+ISO-week) are written into ``--out`` subdirectories.

    With ``--status``, prints a per-disposition tally for the most recent
    summary.csv (no other flags required).
    """
    if show_status:
        _print_status(out)
        return

    if visits is None or ingest is None or out is None:
        raise click.UsageError(
            "--visits, --ingest, and --out are required for a real run. "
            "Pass --status to print the tally for the most recent run."
        )

    start_dos, end_dos = _parse_window(window)
    as_of_date = _date.fromisoformat(as_of) if as_of else _date.today()

    # Imports stay local so the CLI doesn't pull in the whole rebill package
    # for `--status` / `--help` paths.
    from cyclone.rebill.carc_filter import decide_carc
    from cyclone.rebill.parse_835_svc import SvcRow, parse_835_svc
    from cyclone.rebill.reconcile import (
        OutcomeCategory,
        ReconcileOutcome,
        reconcile_visits_to_835,
    )
    from cyclone.rebill.summary import (
        EXCLUDED_CARC,
        EXCLUDED_TIMELY_FILING,
        REBILLED_A,
        REBILLED_B,
        SummaryRow,
        write_summary_csv,
    )
    from cyclone.rebill.timely_filing import timely_filing_decision

    log = logging.getLogger(__name__)

    visit_rows = _parse_visits_csv(visits)
    log.info("loaded %d visits from %s", len(visit_rows), visits)

    # Walk 835 dir for *.835 and *.x12, sorted for determinism. AppleDouble
    # shadow files (._foo.835) are filtered out — matches the convention used
    # by resubmit-rejected-claims, submit-batch, and api_routers/submission.py.
    eight_thirty_five_files: list[Path] = sorted(
        p for p in list(ingest.glob("*.835")) + list(ingest.glob("*.x12"))
        if not p.name.startswith("._")
    )
    log.info("walking %d 835 files under %s", len(eight_thirty_five_files), ingest)

    in_window_svcs: list[SvcRow] = []
    for f in eight_thirty_five_files:
        try:
            for s in parse_835_svc(f):
                if start_dos <= s.svc_date <= end_dos:
                    in_window_svcs.append(s)
        except Exception as exc:  # noqa: BLE001 — log + skip bad files
            log.warning("failed to parse %s: %s", f, exc)
    log.info("kept %d SVCs in window [%s, %s]", len(in_window_svcs), start_dos, end_dos)

    outcomes: list[ReconcileOutcome] = reconcile_visits_to_835(visit_rows, in_window_svcs)

    # Build a local (member_id, procedure, DOS) -> [SvcRow] index so we can
    # recover the CAS reasons for DENIED outcomes (ReconcileOutcome only
    # carries a count, not the matched svcs). This is a closure-local helper —
    # we deliberately do NOT touch reconcile.py's public API for it.
    by_key: dict[tuple[str, str, _date], list[SvcRow]] = {}
    for s in in_window_svcs:
        by_key.setdefault((s.member_id, s.procedure, s.svc_date), []).append(s)

    out.mkdir(parents=True, exist_ok=True)
    pipeline_a_dir = out / "pipeline-a"
    pipeline_b_dir = out / "pipeline-b"
    pipeline_a_dir.mkdir(exist_ok=True)
    pipeline_b_dir.mkdir(exist_ok=True)

    summary_rows: list[SummaryRow] = []
    for outcome in outcomes:
        if outcome.category == OutcomeCategory.PAID:
            # Already paid — skip; not part of any pipeline output.
            continue
        if outcome.category == OutcomeCategory.PARTIAL:
            # Pipeline A handles partials + denieds. The actual 837P
            # generation lives in pipeline_a.build_pipeline_a_claims +
            # write_pipeline_a_files (orchestrator-only); here we just
            # record the disposition + relative file_path for the
            # audit trail. Task 11 (HTTP) wires the real emission.
            summary_rows.append(SummaryRow(
                visit=outcome.visit,
                disposition=REBILLED_A,
                unpaid=outcome.unpaid,
                cas_reasons=(),
                file_path="pipeline-a/",
            ))
            continue
        if outcome.category == OutcomeCategory.DENIED:
            matched = by_key.get(
                (outcome.visit.member_id, outcome.visit.procedure, outcome.visit.date),
                [],
            )
            cas_reasons: tuple[str, ...] = tuple(
                r for s in matched for r in s.cas_reasons
            )
            decision = decide_carc(cas_reasons)
            if decision.value == "EXCLUDED":
                summary_rows.append(SummaryRow(
                    visit=outcome.visit,
                    disposition=EXCLUDED_CARC,
                    unpaid=outcome.unpaid,
                    cas_reasons=cas_reasons,
                    file_path="",
                ))
                continue
            # REVIEW and REBILL both land in Pipeline A; the
            # orchestrator flags REVIEW for human review.
            summary_rows.append(SummaryRow(
                visit=outcome.visit,
                disposition=REBILLED_A,
                unpaid=outcome.unpaid,
                cas_reasons=cas_reasons,
                file_path="pipeline-a/",
            ))
            continue
        # NOT_IN_835 — apply the timely-filing gate.
        tf = timely_filing_decision(
            outcome.visit.date, as_of_date, override_filing,
        )
        if tf.rebillable:
            summary_rows.append(SummaryRow(
                visit=outcome.visit,
                disposition=REBILLED_B,
                unpaid=outcome.unpaid,
                cas_reasons=(),
                file_path="pipeline-b/",
            ))
        else:
            summary_rows.append(SummaryRow(
                visit=outcome.visit,
                disposition=EXCLUDED_TIMELY_FILING,
                unpaid=outcome.unpaid,
                cas_reasons=(),
                file_path="",
            ))

    summary_path = out / "summary.csv"
    n = write_summary_csv(summary_rows, summary_path)
    click.echo(f"Wrote {n} summary rows to {summary_path}")


if __name__ == "__main__":
    main()
