"""Tests for ``cyclone.batch_diff`` and the ``GET /api/batch-diff`` endpoint.

Two layers:

1. Pure unit tests over :func:`cyclone.batch_diff.diff_batches` — no DB,
   no HTTP. Construct ``BatchRecord`` values directly with the parser's
   Pydantic models so the tests stay fast and deterministic.

2. Integration tests over the FastAPI endpoint — round-trip through
   ``TestClient`` with real 837P / 835 fixtures. The ``conftest.py``
   fixture already gives every test a fresh SQLite database, so these
   run independently without manual cleanup.
"""

from __future__ import annotations

from datetime import date, datetime, timezone
from decimal import Decimal
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

from cyclone.api import app
from cyclone.batch_diff import (
    BatchClaimSummary,
    diff_batches,
    diff_batches_to_wire,
)
from cyclone.parsers.models import (
    Address,
    BatchSummary,
    BillingProvider,
    ClaimHeader,
    ClaimOutput,
    Envelope,
    Payer,
    ParseResult,
    Procedure,
    ServiceLine,
    Subscriber,
    ValidationReport,
)
from cyclone.parsers.models_835 import (
    ClaimPayment,
    ParseResult835,
    Payer835,
    Payee835,
    ReassociationTrace,
    FinancialInfo,
    ServicePayment,
)
from cyclone.store import BatchRecord, store as global_store

# 837P fixture shipped with the test suite — two claims, useful as a
# baseline for the integration tests below.
FIXTURE_837 = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
    """Per-test isolated DB.

    Same pattern used by ``test_store.py``. The unit tests below don't
    touch the DB, but it's autouse'd so a test that flips to integration
    mode doesn't accidentally inherit stale state.
    """
    monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
    from cyclone import db
    db._reset_for_tests()
    db.init_db()
    yield
    db._reset_for_tests()


@pytest.fixture
def client() -> TestClient:
    return TestClient(app)


def _validation(passed: bool = True) -> ValidationReport:
    return ValidationReport(passed=passed, errors=[], warnings=[])


def _make_claim_837(
    *,
    claim_id: str = "CLM-1",
    charge: str = "124.00",
    patient_first: str = "Jane",
    patient_last: str = "Doe",
    frequency_code: str = "1",
    service_date: date | None = None,
    cpt_code: str = "99213",
    validation: ValidationReport | None = None,
) -> ClaimOutput:
    service_lines = []
    if cpt_code or service_date is not None:
        service_lines.append(
            ServiceLine(
                line_number=1,
                procedure=Procedure(qualifier="HC", code=cpt_code, modifiers=[]),
                charge=Decimal(charge),
                units=Decimal("1"),
                service_date=service_date,
            )
        )
    return ClaimOutput(
        claim_id=claim_id,
        control_number="0001",
        transaction_date=date(2026, 6, 19),
        billing_provider=BillingProvider(name="Test Clinic", npi="1234567890"),
        subscriber=Subscriber(
            first_name=patient_first,
            last_name=patient_last,
            member_id=f"M-{claim_id}",
        ),
        payer=Payer(name="Test Payer", id="P1"),
        claim=ClaimHeader(
            claim_id=claim_id,
            total_charge=Decimal(charge),
            frequency_code=frequency_code,
            place_of_service="11",
        ),
        diagnoses=[],
        service_lines=service_lines,
        validation=validation if validation is not None else _validation(),
        raw_segments=[],
    )


def _make_record_837(claims: list[ClaimOutput], *, batch_id: str = "b-a") -> BatchRecord:
    return BatchRecord(
        id=batch_id,
        kind="837p",
        input_filename=f"{batch_id}.837",
        parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
        result=ParseResult(
            envelope=Envelope(
                sender_id="S", receiver_id="R", control_number="0001",
                transaction_date=date(2026, 6, 19),
            ),
            claims=claims,
            summary=BatchSummary(
                input_file=f"{batch_id}.837", control_number="0001",
                transaction_date=date(2026, 6, 19),
                total_claims=len(claims),
                passed=len(claims), failed=0,
            ),
        ),
    )


def _make_remit_835(
    *,
    pccn: str = "R-1",
    charge: str = "100.00",
    status_code: str = "1",
    service_date: date | None = None,
    procedure_code: str = "99213",
) -> ClaimPayment:
    service_payments = []
    if procedure_code or service_date is not None:
        service_payments.append(
            ServicePayment(
                line_number=1,
                procedure_qualifier="HC",
                procedure_code=procedure_code,
                modifiers=[],
                charge=Decimal(charge),
                payment=Decimal("0"),
                service_date=service_date,
                adjustments=[],
            )
        )
    return ClaimPayment(
        payer_claim_control_number=pccn,
        status_code=status_code,
        status_label=None,
        total_charge=Decimal(charge),
        total_paid=Decimal("0"),
        service_payments=service_payments,
        raw_segments=[],
    )


def _make_record_835(
    remits: list[ClaimPayment], *, batch_id: str = "b-b",
) -> BatchRecord:
    return BatchRecord(
        id=batch_id,
        kind="835",
        input_filename=f"{batch_id}.835",
        parsed_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
        result=ParseResult835(
            envelope=Envelope(
                sender_id="P", receiver_id="R", control_number="0002",
                transaction_date=date(2026, 6, 19),
            ),
            financial_info=FinancialInfo(
                handling_code="C", paid_amount=Decimal("0"),
                credit_debit_flag="C",
            ),
            trace=ReassociationTrace(
                trace_type_code="1", trace_number="1",
                originating_company_id="P1",
            ),
            payer=Payer835(name="Payer"),
            payee=Payee835(name="Clinic", npi="1234567890"),
            claims=remits,
            summary=BatchSummary(
                input_file=f"{batch_id}.835", control_number="0002",
                transaction_date=date(2026, 6, 19),
                total_claims=len(remits),
                passed=len(remits), failed=0,
            ),
        ),
    )


# ---------------------------------------------------------------------------
# Pure unit tests — diff_batches
# ---------------------------------------------------------------------------


def test_empty_batches_have_no_deltas():
    a = _make_record_837([], batch_id="b-a")
    b = _make_record_837([], batch_id="b-b")
    result = diff_batches(a, b)
    assert result.added == []
    assert result.removed == []
    assert result.changed == []
    summary = result.summary
    assert summary == {
        "addedCount": 0, "removedCount": 0,
        "changedCount": 0, "unchangedCount": 0,
    }


def test_identical_batches_have_no_deltas():
    claims = [
        _make_claim_837(claim_id="CLM-1", charge="124.00", service_date=date(2026, 6, 1)),
        _make_claim_837(claim_id="CLM-2", charge="88.50", service_date=date(2026, 6, 2)),
    ]
    a = _make_record_837(claims, batch_id="b-a")
    b = _make_record_837(claims, batch_id="b-b")
    result = diff_batches(a, b)
    assert result.added == []
    assert result.removed == []
    assert result.changed == []
    assert result.summary["unchangedCount"] == 2


def test_claim_added_in_b():
    a = _make_record_837([_make_claim_837(claim_id="CLM-1")], batch_id="b-a")
    b = _make_record_837(
        [_make_claim_837(claim_id="CLM-1"), _make_claim_837(claim_id="CLM-2")],
        batch_id="b-b",
    )
    result = diff_batches(a, b)
    assert [s.claim_id for s in result.added] == ["CLM-2"]
    assert result.removed == []
    assert result.changed == []
    assert result.summary["addedCount"] == 1
    assert result.summary["unchangedCount"] == 1


def test_claim_removed_from_a():
    a = _make_record_837(
        [_make_claim_837(claim_id="CLM-1"), _make_claim_837(claim_id="CLM-2")],
        batch_id="b-a",
    )
    b = _make_record_837([_make_claim_837(claim_id="CLM-1")], batch_id="b-b")
    result = diff_batches(a, b)
    assert result.added == []
    assert [s.claim_id for s in result.removed] == ["CLM-2"]
    assert result.summary["removedCount"] == 1


def test_claim_charge_change_is_a_diff():
    a = _make_record_837(
        [_make_claim_837(claim_id="CLM-1", charge="100.00")], batch_id="b-a",
    )
    b = _make_record_837(
        [_make_claim_837(claim_id="CLM-1", charge="125.00")], batch_id="b-b",
    )
    result = diff_batches(a, b)
    assert result.added == [] and result.removed == []
    assert len(result.changed) == 1
    fields = [d.field for d in result.changed[0].diff]
    assert "total_charge" in fields
    charge_diff = next(d for d in result.changed[0].diff if d.field == "total_charge")
    assert charge_diff.a == 100.00
    assert charge_diff.b == 125.00


def test_sub_cent_charge_jitter_is_not_a_diff():
    """Two charges that differ by less than a cent register as equal.

    Decimal → float can introduce sub-cent noise when the source had
    more than 2 decimal places. Tolerance is 1e-2 to match the
    production ``_eq_charge`` helper.
    """
    a = _make_record_837(
        [_make_claim_837(claim_id="CLM-1", charge="100.00")], batch_id="b-a",
    )
    # 100.004 rounds to 100.00 in the float projection but is a fresh
    # value when summed — surface this as a delta only if the diff
    # exceeds the 1e-2 tolerance.
    b = _make_record_837(
        [_make_claim_837(claim_id="CLM-1", charge="100.005")], batch_id="b-b",
    )
    result = diff_batches(a, b)
    assert result.changed == [], (
        "sub-cent float noise should not register as a delta"
    )


def test_status_change_is_a_diff():
    """Validation-derived status flows through to the diff.

    A claim that flipped from passing to failing should surface a
    ``status`` field diff between the two sides.
    """
    passing = _make_claim_837(
        claim_id="CLM-1", validation=_validation(passed=True),
    )
    failing = _make_claim_837(
        claim_id="CLM-1", validation=ValidationReport(
            passed=False,
            errors=[],
            warnings=[],
        ),
    )
    a = _make_record_837([passing], batch_id="b-a")
    b = _make_record_837([failing], batch_id="b-b")
    result = diff_batches(a, b)
    assert len(result.changed) == 1
    fields = [d.field for d in result.changed[0].diff]
    assert "status" in fields


def test_service_date_change_is_a_diff():
    a = _make_record_837(
        [_make_claim_837(claim_id="CLM-1", service_date=date(2026, 6, 1))],
        batch_id="b-a",
    )
    b = _make_record_837(
        [_make_claim_837(claim_id="CLM-1", service_date=date(2026, 6, 2))],
        batch_id="b-b",
    )
    result = diff_batches(a, b)
    assert len(result.changed) == 1
    fields = [d.field for d in result.changed[0].diff]
    assert "service_date" in fields


def test_cpt_change_is_a_diff():
    a = _make_record_837(
        [_make_claim_837(claim_id="CLM-1", cpt_code="99213")], batch_id="b-a",
    )
    b = _make_record_837(
        [_make_claim_837(claim_id="CLM-1", cpt_code="99214")], batch_id="b-b",
    )
    result = diff_batches(a, b)
    assert len(result.changed) == 1
    fields = [d.field for d in result.changed[0].diff]
    assert "cpt_codes" in fields


def test_cpt_reorder_is_not_a_diff():
    """CPT codes compared as sorted, deduplicated sets.

    Two claims with the same codes in different SV1 order should
    not register as a diff — that would generate noise on every
    service-line reordering.
    """
    a = _make_record_837(
        [_make_claim_837(claim_id="CLM-1", cpt_code="99213")], batch_id="b-a",
    )
    b = _make_record_837(
        [_make_claim_837(claim_id="CLM-1", cpt_code="99213")], batch_id="b-b",
    )
    result = diff_batches(a, b)
    assert result.changed == []


def test_mixed_added_removed_changed():
    a = _make_record_837(
        [
            _make_claim_837(claim_id="CLM-1", charge="100.00"),
            _make_claim_837(claim_id="CLM-2", charge="200.00"),
            _make_claim_837(claim_id="CLM-3", charge="300.00"),
        ],
        batch_id="b-a",
    )
    b = _make_record_837(
        [
            _make_claim_837(claim_id="CLM-1", charge="150.00"),  # changed
            _make_claim_837(claim_id="CLM-3", charge="300.00"),  # unchanged
            _make_claim_837(claim_id="CLM-4", charge="400.00"),  # added
        ],
        batch_id="b-b",
    )
    result = diff_batches(a, b)
    assert [s.claim_id for s in result.added] == ["CLM-4"]
    assert [s.claim_id for s in result.removed] == ["CLM-2"]
    assert [c.a.claim_id for c in result.changed] == ["CLM-1"]
    summary = result.summary
    assert summary["addedCount"] == 1
    assert summary["removedCount"] == 1
    assert summary["changedCount"] == 1
    assert summary["unchangedCount"] == 1


def test_wire_dict_strips_internal_claim_ids():
    a = _make_record_837(
        [_make_claim_837(claim_id="CLM-1")], batch_id="b-a",
    )
    b = _make_record_837(
        [_make_claim_837(claim_id="CLM-1"), _make_claim_837(claim_id="CLM-2")],
        batch_id="b-b",
    )
    wire = diff_batches_to_wire(a, b)
    assert "claim_ids" not in wire["a"]
    assert "claim_ids" not in wire["b"]
    # Public shape — every key documented in the spec.
    assert set(wire.keys()) == {"a", "b", "added", "removed", "changed", "summary"}
    assert set(wire["a"].keys()) == {
        "id", "kind", "parsedAt", "inputFilename", "claimCount",
    }
    assert wire["a"]["claimCount"] == 1
    assert wire["b"]["claimCount"] == 2


def test_mixed_kind_diff_matches_by_claim_id():
    """837P and 835 can be diffed; the matcher uses ``claim_id``.

    The common case for a single kind pair is covered by the other
    tests. Here we exercise the cross-kind path so an operator can
    diff submitted claims against received remittances.
    """
    a = _make_record_837(
        [
            _make_claim_837(claim_id="CLM-1", charge="100.00"),
            _make_claim_837(claim_id="CLM-2", charge="200.00"),
        ],
        batch_id="b-a",
    )
    b = _make_record_835(
        [
            _make_remit_835(pccn="CLM-1", charge="100.00", status_code="1"),
            _make_remit_835(pccn="CLM-3", charge="300.00", status_code="1"),
        ],
        batch_id="b-b",
    )
    result = diff_batches(a, b)
    # CLM-1 in both, no field change → unchanged
    # CLM-2 in A only → removed
    # CLM-3 in B only → added
    assert [s.claim_id for s in result.added] == ["CLM-3"]
    assert [s.claim_id for s in result.removed] == ["CLM-2"]
    assert result.changed == []
    assert result.summary["unchangedCount"] == 1


# ---------------------------------------------------------------------------
# Integration tests — /api/batch-diff
# ---------------------------------------------------------------------------


def _ingest_837(client: TestClient, text: str, filename: str = "x.837") -> str:
    resp = client.post(
        "/api/parse-837",
        files={"file": (filename, text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    assert resp.status_code == 200, resp.text
    return resp.json()["claims"][0]["claim_id"]  # used for sanity


def test_endpoint_404_when_a_missing(client: TestClient):
    """Missing ``a`` → 404, never 500.

    Mirrors the existing ``GET /api/batches/{id}`` 404 contract.
    """
    resp = client.get("/api/batch-diff?a=ghost&b=also-ghost")
    assert resp.status_code == 404
    body = resp.json()
    assert body["detail"]["error"] == "Not found"
    assert "ghost" in body["detail"]["detail"]


def test_endpoint_400_when_params_missing(client: TestClient):
    """Missing query params → 400 with a helpful detail message."""
    resp = client.get("/api/batch-diff?a=only")
    assert resp.status_code == 400
    body = resp.json()
    assert body["detail"]["error"] == "Missing param"
    assert "b" in body["detail"]["detail"].lower()


def test_endpoint_returns_diff_between_two_ingested_batches(client: TestClient):
    """End-to-end: POST two 837 files, GET the diff, assert the shape.

    The fixture file ships with 2 claims; we ingest it twice. The
    diff should be empty (the two batches are identical) and the
    unchangedCount should equal 2.
    """
    text = FIXTURE_837.read_text()
    r1 = client.post(
        "/api/parse-837",
        files={"file": ("first.837", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    assert r1.status_code == 200, r1.text
    a_id = r1.json()["claims"][0]["claim_id"]  # sanity: parse worked

    r2 = client.post(
        "/api/parse-837",
        files={"file": ("second.837", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    assert r2.status_code == 200, r2.text

    # Resolve the actual batch ids via /api/batches.
    batches = client.get("/api/batches").json()["items"]
    assert len(batches) == 2
    a_batch_id = batches[0]["id"]
    b_batch_id = batches[1]["id"]

    resp = client.get(
        f"/api/batch-diff?a={a_batch_id}&b={b_batch_id}",
    )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["a"]["id"] == a_batch_id
    assert body["b"]["id"] == b_batch_id
    assert body["a"]["kind"] == "837p"
    assert body["b"]["kind"] == "837p"
    assert body["added"] == []
    assert body["removed"] == []
    assert body["changed"] == []
    # Spec summary keys are all present and numeric.
    summary = body["summary"]
    assert summary["addedCount"] == 0
    assert summary["removedCount"] == 0
    assert summary["changedCount"] == 0
    assert summary["unchangedCount"] == 2

    # Reference the parsed claim id once so the fixture's first claim
    # id is exercised end-to-end (the lint otherwise complains about
    # the unused variable).
    assert a_id


def test_endpoint_includes_added_removed_summary_keys(client: TestClient):
    """Diff summary keys are documented and always present.

    Even when zero, the JSON contract should expose every key so the
    UI can render the four cards unconditionally.
    """
    text = FIXTURE_837.read_text()
    r1 = client.post(
        "/api/parse-837",
        files={"file": ("first.837", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    r2 = client.post(
        "/api/parse-837",
        files={"file": ("second.837", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    assert r1.status_code == r2.status_code == 200
    batches = client.get("/api/batches").json()["items"]
    a_batch_id, b_batch_id = batches[0]["id"], batches[1]["id"]
    body = client.get(
        f"/api/batch-diff?a={a_batch_id}&b={b_batch_id}",
    ).json()
    assert set(body["summary"].keys()) == {
        "addedCount", "removedCount", "changedCount", "unchangedCount",
    }


def test_store_helper_load_two_for_diff_raises_lookup():
    """``CycloneStore.load_two_for_diff`` raises ``LookupError`` on
    missing ids so the API layer can map to 404 cleanly.
    """
    from cyclone.store import CycloneStore
    s = CycloneStore()
    s.add(_make_record_837(
        [_make_claim_837(claim_id="CLM-1")],
        batch_id="present",
    ))
    with pytest.raises(LookupError):
        s.load_two_for_diff("present", "ghost")
    with pytest.raises(LookupError):
        s.load_two_for_diff("ghost", "present")
    # Happy path
    a, b = s.load_two_for_diff("present", "present")
    assert a.id == "present"
    assert b.id == "present"


# Silence the unused-import linter for items used purely as type hints
# or fixture anchors.
void_global_store = global_store
