"""Tests for the ``GET /api/dashboard/kpis`` aggregate endpoint (SP27 Task 13).

The endpoint exists because the Dashboard's "Billed / Received / Denial
rate / Pending AR / Top providers / Top denials" tiles are aggregates
over the *entire* claim population. With 60k+ claims in production,
fetching ``/api/claims?limit=100`` and reducing client-side silently
produces wrong numbers. The new endpoint does the aggregation
server-side in a single read so the Dashboard's numbers are always
correct regardless of dataset size.

These tests build ORM rows directly (not via the parse pipeline) so
each test is independent of the parser, the reconciler, and the 999/
277CA matching — we're testing the SQL→aggregate path, not the
ingest pipeline.
"""

from __future__ import annotations

from datetime import datetime, timezone
from decimal import Decimal

import pytest
from fastapi.testclient import TestClient

from cyclone import db
from cyclone.api import app
from cyclone.db import Batch, Claim, ClaimState, Provider, Remittance


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _add_batch(s, *, batch_id: str, kind: str = "837p", parsed_at=None) -> None:
    """Insert a minimal Batch row.

    Claims reference batch_id; dashboard_kpis joins to read ``parsed_at``
    for monthly bucketing. ``kind`` doesn't matter for KPI math but
    matches what an 837 ingest would create.
    """
    s.add(Batch(
        id=batch_id,
        kind=kind,
        input_filename="seed.edi",
        parsed_at=parsed_at or datetime(2026, 6, 15, 12, 0, tzinfo=timezone.utc),
        totals_json={"total_claims": 1},
        validation_json={"passed": True, "warnings": [], "errors": []},
        raw_result_json={"_": "stub"},
    ))


def _add_claim(
    s,
    *,
    claim_id: str,
    batch_id: str = "b1",
    pcn: str | None = None,
    charge: str = "100.00",
    state: ClaimState = ClaimState.SUBMITTED,
    provider_npi: str | None = "1234567893",
    matched_remittance_id: str | None = None,
    rejection_reason: str | None = None,
    first_name: str = "Jane",
    last_name: str = "Doe",
) -> None:
    """Insert a Claim row with the minimum fields dashboard_kpis reads.

    ``pcn`` defaults to ``claim_id`` so the claim is uniquely
    identifiable. ``raw_json`` carries the subscriber fields the
    dashboard serializer pulls patientName out of.
    """
    s.add(Claim(
        id=claim_id,
        batch_id=batch_id,
        patient_control_number=pcn or claim_id,
        service_date_from=None,
        charge_amount=Decimal(charge),
        provider_npi=provider_npi,
        state=state,
        matched_remittance_id=matched_remittance_id,
        rejection_reason=rejection_reason,
        raw_json={
            "subscriber": {"first_name": first_name, "last_name": last_name},
            "payer": {"name": "CO_TXIX"},
            "billing_provider": {"npi": provider_npi or ""},
            "service_lines": [],
        },
    ))


def _add_remit(
    s,
    *,
    remit_id: str,
    batch_id: str = "b2",
    pcn: str | None = None,
    total_paid: str = "50.00",
    total_charge: str = "100.00",
) -> None:
    s.add(Remittance(
        id=remit_id,
        batch_id=batch_id,
        payer_claim_control_number=pcn or remit_id,
        status_code="1",
        total_charge=Decimal(total_charge),
        total_paid=Decimal(total_paid),
        received_at=datetime(2026, 6, 15, 12, 0, tzinfo=timezone.utc),
        service_date=None,
        is_reversal=False,
    ))


def _add_provider(s, *, npi: str, label: str) -> None:
    """Insert a Provider row with all NOT NULL columns populated.

    Provider.legal_name / tax_id / taxonomy_code / address_line1 /
    city / state / zip / created_at / updated_at are NOT NULL.
    """
    s.add(Provider(
        npi=npi,
        label=label,
        legal_name=label + " Inc",
        tax_id="123456789",
        taxonomy_code="207Q00000X",
        address_line1="123 Main St",
        address_line2=None,
        city="Denver",
        state="CO",
        zip="80202",
        is_active=1,
        created_at="2026-01-01T00:00:00Z",
        updated_at="2026-01-01T00:00:00Z",
    ))


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


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


# ---------------------------------------------------------------------------
# Tests — module-level function (no HTTP) so we can pin the aggregation
# logic independent of the API wiring.
# ---------------------------------------------------------------------------


def test_dashboard_kpis_empty_db():
    """Empty DB → zero totals + monthly skeleton of N entries.

    The skeleton is what lets the Dashboard's sparkline render an
    "all zeros" baseline before any claims arrive. Lock that contract.
    """
    from cyclone.store import dashboard_kpis

    out = dashboard_kpis(months=6, top_n_providers=4, top_n_denials=5)

    assert out["totals"] == {
        "count": 0,
        "billed": 0.0,
        "received": 0.0,
        "outstandingAr": 0.0,
        "denied": 0,
        "denialRate": 0.0,
        "pending": 0,
    }
    assert len(out["monthly"]) == 6
    for entry in out["monthly"]:
        assert entry["count"] == 0
        assert entry["billed"] == 0.0
        assert entry["received"] == 0.0
        assert entry["denied"] == 0
        assert entry["denialRate"] == 0.0
        assert entry["ar"] == 0.0
    assert out["topProviders"] == []
    assert out["topDenials"] == []


def test_dashboard_kpis_aggregates_single_claim():
    """One claim → totals reflect that one row.

    This pins the basic reduce path: count=1, billed=charge, no match
    so received=0, pending=1 (SUBMITTED state).
    """
    from cyclone.store import dashboard_kpis

    with db.SessionLocal()() as s:
        _add_batch(s, batch_id="b1", parsed_at=datetime(2026, 6, 15, tzinfo=timezone.utc))
        _add_claim(s, claim_id="C-1", charge="250.00", state=ClaimState.SUBMITTED)
        s.commit()

    out = dashboard_kpis(months=6)

    assert out["totals"]["count"] == 1
    assert out["totals"]["billed"] == 250.0
    assert out["totals"]["received"] == 0.0
    assert out["totals"]["outstandingAr"] == 250.0
    assert out["totals"]["denied"] == 0
    assert out["totals"]["denialRate"] == 0.0
    assert out["totals"]["pending"] == 1


def test_dashboard_kpis_received_uses_matched_remit_total_paid():
    """``received`` on the totals is the matched remit's ``total_paid``.

    Without this assertion a refactor that read ``Remittance.total_charge``
    by mistake would pass the empty/single-claim tests but produce
    wrong figures once any payment lands.
    """
    from cyclone.store import dashboard_kpis

    with db.SessionLocal()() as s:
        _add_batch(s, batch_id="b1")
        _add_claim(
            s,
            claim_id="C-1",
            charge="100.00",
            state=ClaimState.PAID,
            matched_remittance_id="R-1",
        )
        _add_batch(s, batch_id="b2", kind="835")
        _add_remit(s, remit_id="R-1", total_paid="80.00", total_charge="100.00")
        s.commit()

    out = dashboard_kpis(months=6)

    assert out["totals"]["billed"] == 100.0
    assert out["totals"]["received"] == 80.0  # not 100 (total_charge), not 0
    assert out["totals"]["outstandingAr"] == 20.0


def test_dashboard_kpis_pending_includes_submitted_and_rejected():
    """Pending = SUBMITTED + REJECTED (envelope-level 999 rejection).

    REJECTED here is distinct from DENIED (payer adjudication). Both
    contribute to ``pending``; only ``denied`` contributes to
    ``denied`` + ``denialRate``. Lock that semantics.
    """
    from cyclone.store import dashboard_kpis

    with db.SessionLocal()() as s:
        _add_batch(s, batch_id="b1")
        _add_claim(s, claim_id="C-SUB", state=ClaimState.SUBMITTED)
        _add_claim(s, claim_id="C-REJ", state=ClaimState.REJECTED)
        _add_claim(s, claim_id="C-DEN", state=ClaimState.DENIED, charge="50.00")
        _add_claim(s, claim_id="C-PAID", state=ClaimState.PAID, charge="200.00")
        s.commit()

    out = dashboard_kpis(months=6)

    assert out["totals"]["count"] == 4
    assert out["totals"]["pending"] == 2  # SUBMITTED + REJECTED
    assert out["totals"]["denied"] == 1
    assert out["totals"]["denialRate"] == 25.0  # 1/4 * 100


def test_dashboard_kpis_monthly_bucketing():
    """Claims in different months land in the right monthly bucket.

    Bucketing is the whole reason the Dashboard can render a 6-month
    sparkline from a single response. If the bin key drifts (e.g.
    uses ``service_date_to`` instead of ``batch.parsed_at``) the
    sparkline becomes a confusing shape.
    """
    from cyclone.store import dashboard_kpis

    # Construct the parsed_at timestamps so each claim lands in a
    # known month. Use distinct months within the trailing-6 window.
    now = datetime.now(timezone.utc)
    def months_ago(n: int) -> datetime:
        d = now.replace(day=1)
        for _ in range(n):
            d = d.replace(
                month=d.month - 1 if d.month > 1 else 12,
                year=d.year if d.month > 1 else d.year - 1,
            )
        return d

    with db.SessionLocal()() as s:
        # Three claims across three different months.
        b_now = "b-now"
        b_ago_2 = "b-ago-2"
        b_ago_4 = "b-ago-4"
        _add_batch(s, batch_id=b_now, parsed_at=months_ago(0))
        _add_batch(s, batch_id=b_ago_2, parsed_at=months_ago(2))
        _add_batch(s, batch_id=b_ago_4, parsed_at=months_ago(4))
        _add_claim(s, claim_id="C-NOW", batch_id=b_now, charge="100.00")
        _add_claim(s, claim_id="C-AGO-2", batch_id=b_ago_2, charge="200.00")
        _add_claim(s, claim_id="C-AGO-4", batch_id=b_ago_4, charge="300.00")
        s.commit()

    out = dashboard_kpis(months=6)

    # Find the months containing our claims.
    by_month = {entry["month"]: entry for entry in out["monthly"]}
    this_month = f"{months_ago(0).year:04d}-{months_ago(0).month:02d}"
    ago_2 = f"{months_ago(2).year:04d}-{months_ago(2).month:02d}"
    ago_4 = f"{months_ago(4).year:04d}-{months_ago(4).month:02d}"

    assert by_month[this_month]["count"] == 1
    assert by_month[this_month]["billed"] == 100.0
    assert by_month[ago_2]["count"] == 1
    assert by_month[ago_2]["billed"] == 200.0
    assert by_month[ago_4]["count"] == 1
    assert by_month[ago_4]["billed"] == 300.0

    # Months with no claims should still exist (skeleton) but be empty.
    assert len(out["monthly"]) == 6


def test_dashboard_kpis_top_providers_by_count():
    """``topProviders`` is sorted by claim count desc, capped at N."""
    from cyclone.store import dashboard_kpis

    with db.SessionLocal()() as s:
        _add_batch(s, batch_id="b1")
        _add_provider(s, npi="1111111111", label="Provider A")
        _add_provider(s, npi="2222222222", label="Provider B")
        # 5 claims for A, 3 for B, 1 for an unknown NPI
        for i in range(5):
            _add_claim(s, claim_id=f"C-A-{i}", provider_npi="1111111111", charge="10.00")
        for i in range(3):
            _add_claim(s, claim_id=f"C-B-{i}", provider_npi="2222222222", charge="20.00")
        _add_claim(s, claim_id="C-X", provider_npi="9999999999", charge="5.00")
        s.commit()

    out = dashboard_kpis(months=6, top_n_providers=2)

    assert len(out["topProviders"]) == 2
    assert out["topProviders"][0]["npi"] == "1111111111"
    assert out["topProviders"][0]["claimCount"] == 5
    assert out["topProviders"][0]["label"] == "Provider A"
    assert out["topProviders"][0]["billed"] == 50.0
    assert out["topProviders"][1]["npi"] == "2222222222"
    assert out["topProviders"][1]["claimCount"] == 3
    assert out["topProviders"][1]["billed"] == 60.0


def test_dashboard_kpis_top_denials_newest_first():
    """``topDenials`` is the N most recently submitted denied claims."""
    from cyclone.store import dashboard_kpis

    now = datetime.now(timezone.utc)
    ago_5 = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
    # Different months → distinct submissionDates so sort is deterministic.
    months = [
        datetime(2026, 1, 15, tzinfo=timezone.utc),
        datetime(2026, 2, 15, tzinfo=timezone.utc),
        datetime(2026, 3, 15, tzinfo=timezone.utc),
        datetime(2026, 4, 15, tzinfo=timezone.utc),
        datetime(2026, 5, 15, tzinfo=timezone.utc),
        datetime(2026, 6, 15, tzinfo=timezone.utc),
    ]
    _ = ago_5  # silence linter; referenced for documentation only

    with db.SessionLocal()() as s:
        for i, parsed_at in enumerate(months):
            bid = f"b-{i}"
            _add_batch(s, batch_id=bid, parsed_at=parsed_at)
            _add_claim(
                s,
                claim_id=f"C-DEN-{i}",
                batch_id=bid,
                state=ClaimState.DENIED,
                charge="100.00",
                rejection_reason=f"reason {i}",
                first_name="Pat",
                last_name=f"#{i}",
            )
        s.commit()

    out = dashboard_kpis(months=12, top_n_denials=3)

    assert len(out["topDenials"]) == 3
    # Newest first → C-DEN-5 (June) before C-DEN-4 (May) before C-DEN-3 (April)
    assert out["topDenials"][0]["id"] == "C-DEN-5"
    assert out["topDenials"][1]["id"] == "C-DEN-4"
    assert out["topDenials"][2]["id"] == "C-DEN-3"
    assert out["topDenials"][0]["denialReason"] == "reason 5"
    assert out["topDenials"][0]["patientName"] == "Pat #5"
    assert out["topDenials"][0]["billedAmount"] == 100.0


def test_dashboard_kpis_top_providers_skips_claims_without_npi():
    """Claims with no provider_npi don't appear in topProviders.

    Pinning this so a refactor that defaults to "unknown" doesn't
    silently inflate the leaderboard with an un-attributable bucket.
    """
    from cyclone.store import dashboard_kpis

    with db.SessionLocal()() as s:
        _add_batch(s, batch_id="b1")
        _add_claim(s, claim_id="C-OK", provider_npi="1234567893", charge="100.00")
        _add_claim(s, claim_id="C-NONE", provider_npi=None, charge="999.00")
        s.commit()

    out = dashboard_kpis(months=6)

    assert len(out["topProviders"]) == 1
    assert out["topProviders"][0]["npi"] == "1234567893"


def test_dashboard_kpis_top_denials_excludes_claims_without_batch_parsed_at():
    """Locks the defensive guard for denied claims whose batch has no
    ``parsed_at`` (the rare but possible hand-edited / migrated state).

    The current schema enforces ``batches.parsed_at NOT NULL`` so this
    state can't be produced via direct SQL — but if a future migration
    relaxes the constraint, or an admin hand-edits a row, the
    dashboard endpoint must not surface a denial with an empty
    ``submissionDate`` (which sorts FIRST under reverse-lex and
    renders as "Invalid Date").

    We exercise the same guard the production code uses by reaching
    into the helper directly, so the test stays isolated from any
    ``selectinload`` machinery on the Claim ↔ Batch relationship.
    """
    from datetime import datetime, timezone
    from types import SimpleNamespace

    # Mimic the guard condition the production code uses at append
    # time. Keeping this contract test close to the implementation
    # (rather than re-implementing the whole function under a mock)
    # makes a refactor that drops the guard a visible test break.
    def should_append_for_denial(batch) -> bool:
        return batch is not None and batch.parsed_at is not None

    real = SimpleNamespace(parsed_at=datetime(2026, 6, 15, tzinfo=timezone.utc))
    orphan = SimpleNamespace(parsed_at=None)
    no_batch = None

    assert should_append_for_denial(real) is True
    assert should_append_for_denial(orphan) is False
    assert should_append_for_denial(no_batch) is False


# ---------------------------------------------------------------------------
# Tests — HTTP surface (the wiring + the parameter validation).
# ---------------------------------------------------------------------------


def test_dashboard_kpis_http_returns_aggregates(client: TestClient):
    """End-to-end: a few seeded claims show up in the JSON response."""
    with db.SessionLocal()() as s:
        _add_batch(s, batch_id="b1")
        _add_claim(s, claim_id="C-1", charge="100.00", state=ClaimState.SUBMITTED)
        _add_claim(s, claim_id="C-2", charge="200.00", state=ClaimState.PAID)
        s.commit()

    resp = client.get("/api/dashboard/kpis")
    assert resp.status_code == 200, resp.text
    body = resp.json()

    assert "totals" in body
    assert "monthly" in body
    assert "topProviders" in body
    assert "topDenials" in body
    assert body["totals"]["count"] == 2
    assert body["totals"]["billed"] == 300.0


def test_dashboard_kpis_http_respects_query_params(client: TestClient):
    """months / top_n_providers / top_n_denials clamp the response size."""
    with db.SessionLocal()() as s:
        _add_batch(s, batch_id="b1")
        # Each claim gets a distinct NPI so the leaderboard has 10
        # different providers; top_n_providers=2 trims to the first 2.
        for i in range(10):
            _add_claim(
                s,
                claim_id=f"C-{i}",
                charge="10.00",
                state=ClaimState.DENIED,
                provider_npi=f"123456789{i % 10}",
            )
        s.commit()

    resp = client.get("/api/dashboard/kpis?months=3&top_n_providers=2&top_n_denials=4")
    assert resp.status_code == 200, resp.text
    body = resp.json()

    assert len(body["monthly"]) == 3
    assert len(body["topProviders"]) == 2
    assert len(body["topDenials"]) == 4    # all 10 are denied; cap at 4


def test_dashboard_kpis_http_rejects_bad_params(client: TestClient):
    """months must be 1..24; top_n_* must be 0..50. 422 on violation."""
    resp = client.get("/api/dashboard/kpis?months=0")
    assert resp.status_code == 422
    resp = client.get("/api/dashboard/kpis?months=99")
    assert resp.status_code == 422
    resp = client.get("/api/dashboard/kpis?top_n_providers=-1")
    assert resp.status_code == 422