"""API tests for SP28 ack-claim surface.

Spec §6 mandates these named tests:

* ``test_claim_detail_includes_ack_links`` — GET ``/api/claims/{id}``
  after ingesting a 999, assert ``ack_links`` is populated.
* ``test_acks_list_includes_linked_claim_ids`` — GET ``/api/acks``
  after ingesting, assert ``linked_claim_ids`` is populated per row.
* ``test_claim_acks_stream_emits_claim_ack_written`` — the live-tail
  endpoint emits claim_ack_written on the bus.

Plus manual-match (D5/D9):

* ``test_manual_match_creates_link_via_api``
* ``test_manual_match_idempotent_returns_existing``
* ``test_manual_match_terminal_claim_returns_409``
* ``test_manual_unlink_via_api``
"""
from __future__ import annotations

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

import pytest
from fastapi.testclient import TestClient

from cyclone import db
from cyclone.api import app
from cyclone.db import Batch, Claim, ClaimAck, ClaimState
from cyclone.store import store


GAINWELL_999 = (
    Path(__file__).parent / "fixtures" / "minimal_999_ik5_gainwell.txt"
)
ACCEPTED_999 = Path(__file__).parent / "fixtures" / "minimal_999.txt"


@pytest.fixture(autouse=True)
def clear_store():
    with store._lock:
        store._batches.clear()
    yield
    with store._lock:
        store._batches.clear()


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


def _seed_batch_envelope(*, batch_id: str, envelope_control: str):
    with db.SessionLocal()() as s:
        b = Batch(
            id=batch_id,
            kind="837p",
            input_filename=f"{batch_id}.txt",
            parsed_at=datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc),
            raw_result_json={
                "envelope": {
                    "sender_id": "SUBMITTER",
                    "receiver_id": "RECEIVER",
                    "control_number": envelope_control,
                    "transaction_date": "2026-07-02",
                },
            },
        )
        s.add(b)
        s.commit()


def _seed_claim(*, claim_id: str, batch_id: str, patient_control_number: str,
                state: ClaimState = ClaimState.SUBMITTED):
    with db.SessionLocal()() as s:
        c = Claim(
            id=claim_id,
            batch_id=batch_id,
            patient_control_number=patient_control_number,
            charge_amount=Decimal("100.00"),
            state=state,
        )
        s.add(c)
        s.commit()


def test_claim_detail_includes_ack_links(client: TestClient):
    """GET /api/claims/{id} after ingesting a 999, assert ack_links
    is populated."""
    _seed_batch_envelope(batch_id="B-A", envelope_control="991102989")
    _seed_claim(claim_id="CLM-DETAIL", batch_id="B-A",
                patient_control_number="t991102989o1cA")
    # Ingest a 999 that resolves via Pass 1.
    text = GAINWELL_999.read_text()
    resp = client.post(
        "/api/parse-999",
        files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    assert resp.status_code == 200, resp.text

    # GET /api/claims/{claim_id} — the SP4 detail endpoint must
    # surface ack_links so the ClaimDrawer panel can render.
    detail_resp = client.get("/api/claims/CLM-DETAIL")
    assert detail_resp.status_code == 200, detail_resp.text
    body = detail_resp.json()
    assert "ack_links" in body
    assert len(body["ack_links"]) == 1
    link = body["ack_links"][0]
    assert link["ack_kind"] == "999"
    assert link["set_accept_reject_code"] == "A"
    assert link["linked_by"] == "auto"


def test_acks_list_includes_linked_claim_ids(client: TestClient):
    """GET /api/acks after ingesting, assert linked_claim_ids is
    populated per row."""
    _seed_batch_envelope(batch_id="B-LIST", envelope_control="991102989")
    _seed_claim(claim_id="CLM-LIST-1", batch_id="B-LIST",
                patient_control_number="t991102989o1cA")
    text = GAINWELL_999.read_text()
    client.post(
        "/api/parse-999",
        files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
        headers={"Accept": "application/json"},
    )

    resp = client.get("/api/acks")
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["total"] == 1
    assert "linked_claim_ids" in body["items"][0]
    assert body["items"][0]["linked_claim_ids"] == ["CLM-LIST-1"]


def test_claim_acks_stream_emits_claim_ack_written(client: TestClient):
    """The per-claim live-tail endpoint streams claim_ack_written
    events the moment a link is created.

    Uses the direct-endpoint-invocation pattern from
    ``test_api_stream_live.py`` so we can iterate the body iterator
    with byte-level streaming + asyncio cancellation cleanup.
    """
    import asyncio
    from starlette.requests import Request as StarletteRequest
    from cyclone.api_routers.claim_acks import (
        claim_acks_stream,
    )

    _seed_batch_envelope(batch_id="B-STREAM", envelope_control="991102989")
    _seed_claim(claim_id="CLM-STREAM", batch_id="B-STREAM",
                patient_control_number="t991102989o1cA")

    async def _drain_until_disconnect(body_iter):
        try:
            await body_iter.aclose()
        except (asyncio.CancelledError, GeneratorExit):
            pass

    async def _read_until_type(body_iter, target, *, timeout=5.0):
        buffer = b""
        async with asyncio.timeout(timeout):
            while True:
                chunk = await body_iter.__anext__()
                buffer += chunk
                while b"\n" in buffer:
                    raw, buffer = buffer.split(b"\n", 1)
                    if not raw:
                        continue
                    obj = json.loads(raw.decode("utf-8"))
                    if obj.get("type") == target:
                        return obj
        return None

    async def _scenario():
        # Ingest a 999 that resolves via Pass 1 so we have a link
        # row to stream.
        text = GAINWELL_999.read_text()
        resp = client.post(
            "/api/parse-999",
            files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
            headers={"Accept": "application/json"},
        )
        assert resp.status_code == 200, resp.text

        # Build a synthetic request and call the endpoint coroutine
        # directly so we get true byte-streaming.
        async def receive():
            await asyncio.Event().wait()  # never delivered

        scope = {
            "type": "http",
            "method": "GET",
            "headers": [],
            "query_string": b"",
            "path": "/api/claims/CLM-STREAM/acks/stream",
            "app": app,
            "scheme": "http",
            "server": ("testserver", 80),
            "client": ("testclient", 50000),
        }
        request = StarletteRequest(scope, receive=receive)
        response = await claim_acks_stream(request, claim_id="CLM-STREAM")
        assert response.media_type.startswith("application/x-ndjson")

        # The snapshot has 1 item + 1 snapshot_end. Read until
        # snapshot_end.
        snap_end = await _read_until_type(
            response.body_iterator, "snapshot_end", timeout=2.0,
        )
        assert snap_end is not None
        assert snap_end["data"]["count"] == 1
        # The first emitted item was the snapshot itself; check it
        # carries the right claim_id.
        await _drain_until_disconnect(response.body_iterator)

    asyncio.run(_scenario())


def test_manual_match_creates_link_via_api(client: TestClient):
    """POST /api/acks/{kind}/{ack_id}/match-claim creates a link row."""
    # Pre-seed an ack via the API so we have an ack_id to reference.
    _seed_batch_envelope(batch_id="B-MM", envelope_control="991102989")
    _seed_claim(claim_id="CLM-MM", batch_id="B-MM",
                patient_control_number="t991102989o1cA")
    text = GAINWELL_999.read_text()
    resp = client.post(
        "/api/parse-999",
        files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    body = resp.json()
    ack_id = body["ack"]["id"]

    # Now manually match a second claim to the same ack.
    _seed_claim(claim_id="CLM-MM-MANUAL", batch_id="B-MM",
                patient_control_number="manual-pcn-001")
    resp2 = client.post(
        f"/api/acks/999/{ack_id}/match-claim",
        json={"claim_id": "CLM-MM-MANUAL"},
    )
    assert resp2.status_code == 200, resp2.text
    body2 = resp2.json()
    assert body2["created"] is True
    assert body2["link"]["claim_id"] == "CLM-MM-MANUAL"
    assert body2["link"]["linked_by"] == "manual"


def test_manual_match_idempotent_returns_existing(client: TestClient):
    """Re-calling match-claim with the same claim_id returns the
    existing row (200), not a duplicate.

    Uses a 999 that the auto-linker can't resolve (no matching
    Batch.envelope.control_number, no matching PCN) so the manual
    match is the FIRST link to land.
    """
    # Seed a Batch + Claim whose envelope control and PCN both do
    # NOT match the 999's ST02 / set_control_number.
    _seed_batch_envelope(batch_id="B-MM-IDEMP", envelope_control="UNRELATED")
    _seed_claim(claim_id="CLM-MM-IDEMP", batch_id="B-MM-IDEMP",
                patient_control_number="manual-test-pcn")
    text = GAINWELL_999.read_text()
    resp = client.post(
        "/api/parse-999",
        files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    ack_id = resp.json()["ack"]["id"]

    # First call — created.
    r1 = client.post(
        f"/api/acks/999/{ack_id}/match-claim",
        json={"claim_id": "CLM-MM-IDEMP"},
    )
    assert r1.status_code == 200, r1.text
    body1 = r1.json()
    assert body1["created"] is True

    # Second call — same row, idempotent.
    r2 = client.post(
        f"/api/acks/999/{ack_id}/match-claim",
        json={"claim_id": "CLM-MM-IDEMP"},
    )
    assert r2.status_code == 200, r2.text
    body2 = r2.json()
    assert body2["created"] is False
    assert body2["link"]["id"] == body1["link"]["id"]


def test_manual_match_terminal_claim_returns_409(client: TestClient):
    """A REVERSED claim cannot be matched — 409."""
    _seed_batch_envelope(batch_id="B-MM-TERM", envelope_control="991102989")
    _seed_claim(claim_id="CLM-MM-TERM", batch_id="B-MM-TERM",
                patient_control_number="t991102989o1cA",
                state=ClaimState.REVERSED)
    text = GAINWELL_999.read_text()
    resp = client.post(
        "/api/parse-999",
        files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    ack_id = resp.json()["ack"]["id"]

    resp2 = client.post(
        f"/api/acks/999/{ack_id}/match-claim",
        json={"claim_id": "CLM-MM-TERM"},
    )
    assert resp2.status_code == 409, resp2.text


def test_manual_unlink_via_api(client: TestClient):
    """DELETE /api/acks/{kind}/{ack_id}/match-claim/{claim_id} removes
    the link row and publishes claim_ack_dropped."""
    _seed_batch_envelope(batch_id="B-UNL", envelope_control="991102989")
    _seed_claim(claim_id="CLM-UNL", batch_id="B-UNL",
                patient_control_number="t991102989o1cA")
    text = GAINWELL_999.read_text()
    resp = client.post(
        "/api/parse-999",
        files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    ack_id = resp.json()["ack"]["id"]

    resp_del = client.delete(f"/api/acks/999/{ack_id}/match-claim/CLM-UNL")
    assert resp_del.status_code == 200, resp_del.text
    assert resp_del.json()["removed"] is True

    # Verify the row is gone.
    with db.SessionLocal()() as s:
        n = (
            s.query(ClaimAck)
            .filter_by(claim_id="CLM-UNL", ack_kind="999", ack_id=ack_id)
            .count()
        )
        assert n == 0


def test_inbox_ack_orphans_returns_unresolved_acks(client: TestClient):
    """An ack that resolves to no claim surfaces in the inbox
    ack-orphans lane."""
    # Ingest a 999 with no matching batch + no matching PCN — orphan.
    text = ACCEPTED_999.read_text()
    resp = client.post(
        "/api/parse-999",
        files={"file": ("minimal_999.txt", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    assert resp.status_code == 200, resp.text

    orphans_resp = client.get("/api/inbox/ack-orphans")
    assert orphans_resp.status_code == 200, orphans_resp.text
    body = orphans_resp.json()
    assert body["total"] >= 1
    # The 999 we just ingested is in the orphan list.
    kinds = [item["kind"] for item in body["items"]]
    assert "999" in kinds