"""Tests for the FastAPI surface in ``cyclone.api``.

All tests use ``fastapi.testclient.TestClient`` — no real network or uvicorn
process is started. The fixture file is the same one used by the parser's
own end-to-end test (``co_medicaid_837p.txt`` → 2 claims, both pass).
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

from cyclone.api import app
from cyclone import __version__


FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
FIXTURE_835 = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"


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


# --------------------------------------------------------------------------- #
# Health
# --------------------------------------------------------------------------- #


def test_health_endpoint(client: TestClient):
    """SP19: health endpoint now returns a subsystem snapshot."""
    resp = client.get("/api/health")
    assert resp.status_code == 200
    body = resp.json()
    # Old contract (status + version) is preserved.
    assert body["status"] == "ok"
    assert body["version"] == __version__
    # SP19 additions.
    assert "db" in body and body["db"].get("ok") is True
    assert "scheduler" in body
    assert "pubsub" in body
    assert "batch" in body


# --------------------------------------------------------------------------- #
# JSON response path
# --------------------------------------------------------------------------- #


def test_parse_837_endpoint_returns_json(client: TestClient):
    text = FIXTURE.read_text()
    resp = client.post(
        "/api/parse-837",
        files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert "envelope" in body
    assert body["envelope"] is not None
    assert "claims" in body and len(body["claims"]) == 2
    assert "summary" in body
    assert body["summary"]["total_claims"] == 2
    assert body["summary"]["passed"] == 2


# --------------------------------------------------------------------------- #
# NDJSON streaming path
# --------------------------------------------------------------------------- #


def test_parse_837_endpoint_streams_ndjson(client: TestClient):
    text = FIXTURE.read_text()
    resp = client.post(
        "/api/parse-837",
        files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
    )
    assert resp.status_code == 200, resp.text
    assert resp.headers["content-type"].startswith("application/x-ndjson")

    # Consume line-by-line (this is exactly what the React frontend will do).
    lines = list(resp.iter_lines())
    # 1 envelope + 2 claims + 1 summary
    assert len(lines) == 4

    parsed = [json.loads(line) for line in lines]
    assert parsed[0]["type"] == "envelope"
    assert parsed[0]["data"] is not None
    assert parsed[1]["type"] == "claim"
    assert parsed[2]["type"] == "claim"
    assert parsed[3]["type"] == "summary"

    # When include_raw_segments defaults to True, each claim carries raw segments.
    for obj in parsed[1:3]:
        assert "raw_segments" in obj["data"]
        assert isinstance(obj["data"]["raw_segments"], list)

    # Summary numbers match the JSON path.
    assert parsed[3]["data"]["total_claims"] == 2
    assert parsed[3]["data"]["passed"] == 2
    # The streaming summary carries the server-side batch id so the
    # frontend can call /api/batches/{id}/export-837 without a separate
    # GET /api/batches round-trip (regression: it used to be missing
    # on the stream path, only present on the JSON path, which made the
    # Upload page's Export button say "no batch to export").
    assert parsed[3]["type"] == "summary"
    assert isinstance(parsed[3]["data"]["batch_id"], str)
    assert len(parsed[3]["data"]["batch_id"]) == 32  # uuid4().hex


def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
    text = FIXTURE.read_text()
    resp = client.post(
        "/api/parse-837?include_raw_segments=false",
        files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
    )
    assert resp.status_code == 200
    assert resp.headers["content-type"].startswith("application/x-ndjson")

    claims = [json.loads(line) for line in resp.iter_lines() if json.loads(line)["type"] == "claim"]
    assert len(claims) == 2
    for c in claims:
        assert c["data"]["raw_segments"] == []


# --------------------------------------------------------------------------- #
# Validation / error paths
# --------------------------------------------------------------------------- #


def test_parse_837_endpoint_rejects_missing_file(client: TestClient):
    # FastAPI's `File(...)` (no default) → 422 Unprocessable Entity.
    resp = client.post("/api/parse-837")
    assert resp.status_code == 422


def test_parse_837_endpoint_handles_payer_query_param(client: TestClient):
    text = FIXTURE.read_text()
    for payer in ("co_medicaid", "generic_837p"):
        resp = client.post(
            f"/api/parse-837?payer={payer}",
            files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
            headers={"Accept": "application/json"},
        )
        assert resp.status_code == 200, (payer, resp.text)
        body = resp.json()
        assert body["summary"]["passed"] == 2
        assert body["summary"]["total_claims"] == 2


# --------------------------------------------------------------------------- #
# CORS
# --------------------------------------------------------------------------- #


def test_cors_headers_present(client: TestClient):
    # Simulate a preflight from the Vite dev origin.
    resp = client.options(
        "/api/parse-837",
        headers={
            "Origin": "http://localhost:5173",
            "Access-Control-Request-Method": "POST",
            "Access-Control-Request-Headers": "content-type",
        },
    )
    assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173"
    assert "POST" in resp.headers.get("access-control-allow-methods", "").upper()


def test_cors_headers_present_for_loopback_ip(client: TestClient):
    # ``http://127.0.0.1:5173`` is a distinct origin from
    # ``http://localhost:5173`` per the CORS spec, even though both resolve
    # to the same Vite dev server. Both must be allow-listed or tabs opened
    # via the IP form silently break.
    resp = client.options(
        "/api/parse-837",
        headers={
            "Origin": "http://127.0.0.1:5173",
            "Access-Control-Request-Method": "POST",
            "Access-Control-Request-Headers": "content-type",
        },
    )
    assert resp.headers.get("access-control-allow-origin") == "http://127.0.0.1:5173"


def test_cors_extra_origins_via_env(client: TestClient, monkeypatch):
    # LAN / staging hosts opt in via CYCLONE_ALLOWED_ORIGINS. The env var
    # is a comma-separated list; the middleware must reflect each entry.
    # The allow-list is built at module import, so we re-execute the
    # module under the env var and build a TestClient against the
    # reloaded app.
    monkeypatch.setenv(
        "CYCLONE_ALLOWED_ORIGINS", "http://192.168.1.42:5173,https://staging.example.com"
    )
    import importlib
    from cyclone import api as api_module
    from fastapi.testclient import TestClient as _TC
    importlib.reload(api_module)
    try:
        with _TC(api_module.app) as tc:
            for origin in ("http://192.168.1.42:5173", "https://staging.example.com"):
                resp = tc.options(
                    "/api/parse-837",
                    headers={
                        "Origin": origin,
                        "Access-Control-Request-Method": "POST",
                        "Access-Control-Request-Headers": "content-type",
                    },
                )
                assert resp.headers.get("access-control-allow-origin") == origin
    finally:
        monkeypatch.delenv("CYCLONE_ALLOWED_ORIGINS", raising=False)
        # Reload once more so the module-level allow-list returns to its
        # default for any test that imports `cyclone.api` after this one.
        importlib.reload(api_module)


# --------------------------------------------------------------------------- #
# SP35: parse-837 input guards (defense in depth against misroute ingest)
# --------------------------------------------------------------------------- #
#
# Before SP35, /api/parse-837 silently accepted any file that had a parseable
# ISA envelope. An 835 file dropped on the Upload page while the dropdown
# still said "837p" would land in the DB as an empty batch (claims=[]) and a
# bogus row on the History tab. SP35 fixes that at three layers:
#
#   1. Server envelope check: ST*837 (or ST*837P) required, else 400 with
#      error="Mismatched file kind".
#   2. Server empty-claims check: even with the right envelope, if zero CLM
#      segments were parsed, return 400 with error="No claims parsed"
#      and DO NOT persist the batch.
#   3. UI auto-detect (separate file: src/pages/Upload.test.tsx).
#
# These tests are the server-layer regression locks. They run against the
# TestClient and use the existing fixtures. The 835 fixture has an ST*835
# envelope; posting it to /api/parse-837 must surface a 400 and must not
# create a BatchRecord.


def test_parse_837_endpoint_rejects_835_input(client: TestClient):
    """Uploading an 835 file to /api/parse-837 must fail loudly, not persist.

    Repro for the original bug: user drops an 835 file on the Upload page
    while the kind dropdown still says "837p" (Upload.tsx default). Before
    SP35 the endpoint accepted it, ran it through the 837 parser (which
    found zero CLM segments because the file has none), and persisted a
    claims=[] batch — a bogus row on the History tab and on
    /api/batches. SP35 closes the door at the server so the UI bug becomes
    cosmetic instead of data-corrupting.
    """
    text_835 = FIXTURE_835.read_text()
    # Sanity check: the fixture really is an 835 file. If this ever flips,
    # the test would still pass for the wrong reason.
    assert "ST*835" in text_835, "fixture is no longer ST*835 — update SP35 tests"

    # Snapshot the batch count BEFORE the bad upload so we can assert the
    # request did NOT persist anything. Using the public /api/batches JSON
    # endpoint (already exercised by the Dashboard).
    before = client.get("/api/batches", headers={"Accept": "application/json"}).json()
    total_before = before.get("total", len(before.get("items", [])))

    resp = client.post(
        "/api/parse-837",
        files={"file": ("co_medicaid_835.txt", text_835, "text/plain")},
        headers={"Accept": "application/json"},
    )

    assert resp.status_code == 400, resp.text
    body = resp.json()
    assert body["error"] == "Mismatched file kind"
    assert body["expected"] == "837p"
    assert body["detected_st"].startswith("835")

    after = client.get("/api/batches", headers={"Accept": "application/json"}).json()
    total_after = after.get("total", len(after.get("items", [])))
    assert total_after == total_before, "Server persisted a batch from a 835 file"


def test_parse_837_endpoint_rejects_empty_envelope(client: TestClient):
    """Right envelope (ST*837), zero CLM segments → 400 No claims parsed.

    Synthetic input: a complete ISA/GS/ST envelope with a BHT, a closing
    SE/GE/IEA, and no CLM loops. The 837 parser will tokenize and build
    the envelope cleanly, then return claims=[]. SP35 must surface this as
    a 400 with error="No claims parsed" and must not persist a batch.
    """
    # Bare 837 envelope — no HL/CLM loops. A real X12 file with ST*837
    # but no claims is unusual but possible (e.g. a header-only test file
    # or a truncated/cancelled run). The right behavior is to reject,
    # not to silently persist an empty batch.
    synthetic = (
        "ISA*00*          *00*          *ZZ*SENDER         *ZZ*RECEIVER       "
        "*260617*1937*^*00501*000000001*0*P*:~"
        "GS*HC*SENDER*RECEIVER*20260706*1937*1*X*005010X222A1~"
        "ST*837*0001*005010X222A1~"
        "BHT*0019*00*0001*20260706*1937*CH~"
        "SE*2*0001~"
        "GE*1*1~"
        "IEA*1*000000001~"
    )
    assert "ST*837" in synthetic

    before = client.get("/api/batches", headers={"Accept": "application/json"}).json()
    total_before = before.get("total", len(before.get("items", [])))

    resp = client.post(
        "/api/parse-837",
        files={"file": ("empty_837.txt", synthetic, "text/plain")},
        headers={"Accept": "application/json"},
    )

    assert resp.status_code == 400, resp.text
    body = resp.json()
    assert body["error"] == "No claims parsed"

    after = client.get("/api/batches", headers={"Accept": "application/json"}).json()
    total_after = after.get("total", len(after.get("items", [])))
    assert total_after == total_before, "Server persisted an empty-claims batch"


def test_parse_837_endpoint_happy_path_still_works(client: TestClient):
    """Regression guard: real 837 fixture must still parse → 200 with claims.

    Sits next to the new SP35 rejection tests so any future tightening of
    the guards that accidentally blocks the happy path fails here loudly.
    """
    text = FIXTURE.read_text()
    resp = client.post(
        "/api/parse-837",
        files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body.get("summary", {}).get("total_claims", 0) >= 1
    assert "batch_id" in body and len(body["batch_id"]) == 32
