"""Top-level orchestrator for SP41.

Wires the 835 SVC reparse → reconciliation → CARC filter → timely-filing
gate → pipeline A → pipeline B → summary CSV → Edifabric validation.

The CLI and the HTTP endpoint both call this. CLI passes filesystem
paths from --visits / --ingest / --out; HTTP endpoint passes the same.
"""
from __future__ import annotations

import csv as _csv
import logging
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path

from cyclone import edifabric as _edifabric
from cyclone.edi.filenames import build_outbound_filename
from cyclone.rebill.carc_filter import CarcDecision, decide_carc
from cyclone.rebill.parse_835_svc import SvcRow, parse_835_svc
from cyclone.rebill.pipeline_a import RebillClaim
from cyclone.rebill.pipeline_b import build_pipeline_b_batches
from cyclone.rebill.reconcile import (
    OutcomeCategory,
    VisitRow,
    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__)


@dataclass
class RunResult:
    """The return value of run_rebill — where the summary CSV landed, the
    per-disposition counts, and the list of pipeline A / B file paths.

    Mutable (no frozen=True) because the contained `counts` dict and
    `pipeline_*_files` lists are mutated by callers who aggregate results
    across runs.
    """

    summary_path: Path
    counts: dict[str, int]
    pipeline_a_files: list[Path]
    pipeline_b_files: list[Path]


def run_rebill(
    window_start: date,
    window_end: date,
    override_filing: bool,
    visits_csv_path: str | Path | None = None,
    ingest_dir: str | Path | None = None,
    out_dir: str | Path | None = None,
    tpid: str = "11525703",
    as_of: date | None = None,
) -> RunResult:
    """Run the full SP41 rebill pipeline for the given DOS window.

    Pipeline shape:

      1. Walk ``ingest_dir`` for *.835 / *.x12 (skipping AppleDouble shadow
         files matching ``._*``), reparse each into SvcRows via
         ``parse_835_svc``, keep those with svc_date in [window_start, window_end].
      2. Load the AxisCare visits CSV (``visits_csv_path``) and keep the rows
         whose Visit Date is in the same DOS window.
      3. Reconcile visits against the in-window SVCs on the
         (member_id, procedure, DOS) match key. Best-of-N: PAID if
         total_paid >= 95% of billed, else PARTIAL if any payment, else DENIED.
      4. Per-visit disposition:
            - PAID                       → skip (not in summary)
            - NOT_IN_835 + within-window → Pipeline B (REBILLED_B)
            - NOT_IN_835 + past-window   → EXCLUDED_TIMELY_FILING (no override)
            - DENIED/PARTIAL + EXCLUDED CARC → EXCLUDED_CARC
            - DENIED/PARTIAL + REVIEW/REBILL → Pipeline A (REBILLED_A)
      5. Emit Pipeline A files (``<out>/pipeline-a/tp{tpid}-837P-...-1of1.x12``)
         and Pipeline B files (``<out>/pipeline-b/...-1of1.{member}-W{week}.x12``).
         Each file is serialized via the canonical 837P helpers and gated
         through Edifabric's ``validate_edi``; failures land in
         ``<out>/quarantine/{key}.837`` so the operator can triage without
         blocking the batch.
      6. Write ``<out>/summary.csv`` with one row per non-PAID visit.

    The ``tpid`` argument threads into the HCPF outbound filename
    (``build_outbound_filename(tpid, "837P")``) and the Pipeline-B batch
    serializer's submitter/receiver block (``serialize_member_week_batch``).

    ``as_of`` defaults to ``date.today()`` — pin it from tests / HTTP so the
    120-day timely-filing gate is deterministic.
    """
    as_of_date = as_of or date.today()
    out_dir_p = Path(out_dir or f"dev/rebills/{date.today().isoformat()}")
    out_dir_p.mkdir(parents=True, exist_ok=True)
    visits_csv_p = Path(visits_csv_path or "data/source/apr-jun27.csv")
    ingest_dir_p = Path(ingest_dir or "ingest")

    # 1) Ingest 835s — walk *.835 and *.x12, skip AppleDouble shadow files
    #    (._foo.835 are macOS resource forks that the SFTP client surfaces
    #    alongside real files; matches the convention used by cli, submit-batch,
    #    and api_routers/submission.py).
    svcs: list[SvcRow] = []
    for p in sorted(
        list(ingest_dir_p.glob("*.835")) + list(ingest_dir_p.glob("*.x12"))
    ):
        if p.name.startswith("._"):
            continue
        for s in parse_835_svc(p):
            if window_start <= s.svc_date <= window_end:
                svcs.append(s)

    # 2) Load visits CSV — header:
    #    ``Visit Date,Member ID,Procedure Code,Billable Amount``
    #    DOS is MM/DD/YYYY; Billable Amount may carry a ``$`` prefix and
    #    ``,`` thousands separators.
    visits: list[VisitRow] = []
    with visits_csv_p.open(newline="") as f:
        for r in _csv.DictReader(f):
            dos = datetime.strptime(r["Visit Date"].strip(), "%m/%d/%Y").date()
            if not (window_start <= dos <= window_end):
                continue
            amt = Decimal(
                r["Billable Amount"].strip().lstrip("$").replace(",", "")
            )
            visits.append(VisitRow(
                date=dos,
                member_id=r["Member ID"].strip(),
                procedure=r["Procedure Code"].strip(),
                billed=amt,
            ))

    # 3) Reconcile visits against the in-window SVCs
    outcomes = reconcile_visits_to_835(visits, svcs)

    # 4) Per-visit disposition
    summary: list[SummaryRow] = []
    counts: dict[str, int] = {
        REBILLED_A: 0,
        REBILLED_B: 0,
        EXCLUDED_CARC: 0,
        EXCLUDED_TIMELY_FILING: 0,
        "PAID": 0,
        "DENIED_SKIPPED": 0,
    }
    pipeline_a_claims: list[RebillClaim] = []
    pipeline_b_visits: list[VisitRow] = []
    svc_lookup: dict[tuple[str, str, date], SvcRow] = {
        (s.member_id, s.procedure, s.svc_date): s for s in svcs
    }

    for o in outcomes:
        if o.category == OutcomeCategory.PAID:
            counts["PAID"] += 1
            continue

        if o.category == OutcomeCategory.NOT_IN_835:
            tf = timely_filing_decision(o.visit.date, as_of_date, override_filing)
            if tf.rebillable:
                pipeline_b_visits.append(o.visit)
                counts[REBILLED_B] += 1
                summary.append(SummaryRow(
                    visit=o.visit,
                    disposition=REBILLED_B,
                    unpaid=o.unpaid,
                    cas_reasons=(),
                    file_path="pipeline-b/",
                ))
            else:
                counts[EXCLUDED_TIMELY_FILING] += 1
                summary.append(SummaryRow(
                    visit=o.visit,
                    disposition=EXCLUDED_TIMELY_FILING,
                    unpaid=o.unpaid,
                    cas_reasons=(),
                    file_path="",
                ))
            continue

        # DENIED or PARTIAL — Pipeline A (after CARC filter).
        s = svc_lookup.get(
            (o.visit.member_id, o.visit.procedure, o.visit.date)
        )
        reasons: tuple[str, ...] = s.cas_reasons if s else ()
        decision = decide_carc(reasons)
        if decision == CarcDecision.EXCLUDED:
            counts[EXCLUDED_CARC] += 1
            summary.append(SummaryRow(
                visit=o.visit,
                disposition=EXCLUDED_CARC,
                unpaid=o.unpaid,
                cas_reasons=reasons,
                file_path="",
            ))
            continue

        # REBILL or REVIEW — emit a Pipeline A RebillClaim.
        # Each matched SVC carries its own original claim_id; preserve it
        # per-claim so the freq-7 replacement is anchored to the right
        # claim_submit_id. If the visit somehow matched nothing (shouldn't
        # happen for DENIED/PARTIAL — those categories only arise from a
        # match), mint a NEW-* fallback.
        claim_id = (
            s.claim_id if s
            else f"NEW-{o.visit.member_id}-{o.visit.date.isoformat()}"
        )
        claim = RebillClaim(
            claim_id=claim_id,
            member_id=o.visit.member_id,
            procedure=o.visit.procedure,
            svc_date=o.visit.date,
            charge=o.visit.billed,
            frequency_code="7",
            needs_review=(decision == CarcDecision.REVIEW),
            original_carc_reasons=reasons,
        )
        pipeline_a_claims.append(claim)
        counts[REBILLED_A] += 1
        summary.append(SummaryRow(
            visit=o.visit,
            disposition=REBILLED_A,
            unpaid=o.unpaid,
            cas_reasons=reasons,
            file_path="pipeline-a/",
        ))

    # 5) Emit Pipeline A files — serialize each RebillClaim, gate
    #    through Edifabric's validate_edi, write clean files into
    #    ``pipeline-a/`` and Edifabric-rejected files into
    #    ``quarantine/`` for operator triage.
    #
    #    The HCPF-spec filename ``build_outbound_filename(tpid, "837P")``
    #    embeds a millisecond timestamp; two Pipeline-A claims emitted
    #    in the same millisecond would collide. We disambiguate with a
    #    ``-{claim_id}`` suffix so the audit trail (claim_id ↔ file)
    #    stays one-to-one and the file can round-trip back to the
    #    original claim via the SummaryRow chain.
    a_dir = out_dir_p / "pipeline-a"
    a_dir.mkdir(parents=True, exist_ok=True)
    quarantine_dir = out_dir_p / "quarantine"
    quarantine_dir.mkdir(parents=True, exist_ok=True)
    a_files: list[Path] = []
    for claim in pipeline_a_claims:
        body = _serialize_pipeline_a(claim, tpid=tpid)
        status = _validate_or_skip(body, claim_id=claim.claim_id)
        if status == "quarantine":
            qp = quarantine_dir / f"{claim.claim_id}.837"
            qp.write_bytes(body)
        else:
            base = build_outbound_filename(tpid, "837P")
            # Disambiguate same-millisecond collisions across claims.
            stem, dot_ext = base.rsplit(".", 1)
            p = a_dir / f"{stem}-{claim.claim_id}.{dot_ext}"
            p.write_bytes(body)
            a_files.append(p)

    # 6) Build + emit Pipeline B batches. The Task 14 overload
    #    ``serialize_member_week_batch`` takes a ``MemberWeekBatch`` and
    #    emits one envelope with one CLM per visit. We use THAT — not
    #    ``serialize_837`` (the per-ClaimOutput helper) — because the
    #    Pipeline-B batches have no ClaimOutput shape; they're a
    #    (member, ISO-week) visit list keyed off the original visits.
    #
    #    Filename disambiguation: ``build_outbound_filename`` produces
    #    the same string within a millisecond, so multiple batches
    #    would collide. Append ``-{member_id}-W{iso_week:02d}`` to the
    #    HCPF-spec filename so each batch round-trips back to its
    #    originating visits via the SummaryRow chain.
    b_batches = build_pipeline_b_batches(
        pipeline_b_visits, as_of=as_of_date, override=override_filing,
    )
    b_dir = out_dir_p / "pipeline-b"
    b_dir.mkdir(parents=True, exist_ok=True)
    b_files: list[Path] = []
    from cyclone.parsers.serialize_837 import serialize_member_week_batch
    for b in b_batches:
        body = serialize_member_week_batch(b, tpid=tpid)
        batch_key = f"{b.member_id}-{b.iso_year}-W{b.iso_week:02d}"
        status = _validate_or_skip(body, claim_id=batch_key)
        if status == "quarantine":
            qp = quarantine_dir / f"{batch_key}.837"
            qp.write_bytes(body)
        else:
            base = build_outbound_filename(tpid, "837P")
            stem, dot_ext = base.rsplit(".", 1)
            p = b_dir / f"{stem}-{batch_key}.{dot_ext}"
            p.write_bytes(body)
            b_files.append(p)

    # 7) Summary CSV — one row per non-PAID visit, regardless of pipeline.
    summary_path = out_dir_p / "summary.csv"
    write_summary_csv(summary, summary_path)

    return RunResult(
        summary_path=summary_path,
        counts=counts,
        pipeline_a_files=a_files,
        pipeline_b_files=b_files,
    )


def _serialize_pipeline_a(claim: RebillClaim, *, tpid: str) -> bytes:
    """Build a 837P byte string for a Pipeline-A RebillClaim.

    Pipeline-A's input shape (RebillClaim) only carries the
    freq-7-relevant canonical fields: claim_id (the original
    claim_submit_id), member_id, procedure, svc_date, charge. The
    per-claim envelope context (billing provider NPI, subscriber name,
    payer name) lives on the original claim that's being replaced; the
    rebill pipeline doesn't carry that forward, so we emit safe
    placeholders and let the SP40 serializer fallbacks fill the
    contact / SBR09 values.

    Returns ASCII bytes (the 837P envelope is pure ASCII).
    """
    # Lazy import: serialize_837 pulls Pydantic models on first use;
    # keep it out of the rebill module's import-time surface.
    from cyclone.parsers.models import (
        BillingProvider,
        ClaimHeader,
        ClaimOutput,
        Payer,
        Procedure,
        ServiceLine,
        Subscriber,
        ValidationReport,
    )
    from cyclone.parsers.serialize_837 import serialize_837

    # Deterministic control numbers derived from the original claim_id
    # so a retry of the same DOS window produces the same filenames
    # (the operator can then diff against the prior run).
    cn = claim.claim_id[:9].rjust(4, "0")[-4:]
    placeholder_claim = ClaimOutput(
        claim_id=claim.claim_id,
        control_number=cn,
        transaction_date=claim.svc_date,
        billing_provider=BillingProvider(
            name="REBILL PROVIDER",
            npi="0000000000",
        ),
        subscriber=Subscriber(
            first_name="REBILL",
            last_name=claim.member_id,  # surface the member id in NM103
            member_id=claim.member_id,
        ),
        payer=Payer(name="CO_TXIX", id="CO_TXIX"),
        claim=ClaimHeader(
            claim_id=claim.claim_id,
            total_charge=claim.charge,
            place_of_service="11",
            facility_code_qualifier="B",
            frequency_code=claim.frequency_code,  # always "7"
        ),
        service_lines=[
            ServiceLine(
                line_number=1,
                procedure=Procedure(qualifier="HC", code=claim.procedure),
                charge=claim.charge,
                units=Decimal("1"),
                unit_type="UN",
                place_of_service="11",
                service_date=claim.svc_date,
            ),
        ],
        validation=ValidationReport(passed=True),
        transaction_type_code="CH",
    )
    text = serialize_837(placeholder_claim, sender_id=tpid)
    return text.encode("ascii")


def _validate_or_skip(body: bytes, *, claim_id: str) -> str:
    """Run Edifabric's validate_edi on the emitted 837P bytes.

    Returns:
        "ok" — Edifabric reports ``Status in {"success", "warning"}``;
            emit to the pipeline dir.
        "quarantine" — Edifabric reports ``Status == "error"``; emit
            to ``<out>/quarantine/`` for operator triage.
        "skip" — Edifabric was unreachable (no API key, network error,
            5xx, etc.); treat as ``ok`` and emit to the pipeline dir.
            A WARNING is logged so the operator knows the gate didn't
            actually run. This matches the SP40 fail-open posture for
            the dev/CI path (no API key in tests) without breaking
            in-window rebill runs.
    """
    try:
        result = _edifabric.validate_edi(body)
    except _edifabric.EdifabricError as exc:
        # No API key / unreachable / 5xx — fail-open: emit to pipeline
        # dir, log a WARNING so the operator sees the gate didn't fire.
        _log.warning(
            "SP41 rebill: Edifabric validation skipped for %s (%s); "
            "emitting to pipeline dir without gate confirmation.",
            claim_id, exc,
        )
        return "skip"
    status = result.get("Status", "")
    if status == "error":
        details = result.get("Details") or []
        msgs = "; ".join(
            f"{d.get('SegmentId', '?')}: {d.get('Message', '?')}"
            for d in details[:5]
        )
        _log.warning(
            "SP41 rebill: Edifabric rejected %s — quarantining. %s",
            claim_id, msgs,
        )
        return "quarantine"
    return "ok"