"""Live-tail NDJSON streaming endpoint tests (Phase 3).

Each ``/api/.../stream`` endpoint:
  1. yields the current snapshot as ``{"type":"item","data":<row>}`` lines
  2. terminates the snapshot with ``{"type":"snapshot_end","data":{"count":N}}``
  3. subscribes to the relevant EventBus kind and forwards new events
  4. emits a ``{"type":"heartbeat","data":{"ts":<iso>}}`` line every
     ``CYCLONE_TAIL_HEARTBEAT_S`` seconds (default 15) when idle
  5. returns cleanly when the client disconnects
  6. responds with ``application/x-ndjson``

Implementation note (deviation from plan)
-----------------------------------------
The plan suggested using ``httpx.AsyncClient(transport=ASGITransport(app))``
and ``client.stream()``. That doesn't work for true long-lived streams:

* ``httpx.ASGITransport``'s response is buffered (``ASGIResponseStream``
  joins every body chunk at the end), so ``client.stream()`` blocks until
  the app finishes — a live tail never finishes.
* ``starlette.requests.Request.is_disconnected()`` relies on a
  ``http.disconnect`` message in the ASGI receive channel, which
  ``ASGITransport`` only delivers *after* the response body completes
  (the receive coroutine awaits ``response_complete``). So even if the
  caller closes the client side, the endpoint never observes a
  disconnect.

Instead, we call the endpoint coroutine directly, build a synthetic
``Request`` and ``StreamingResponse``, and iterate ``body_iterator``
ourselves. That gives us byte-by-byte streaming, a real disconnect
signal via ``body_iterator.aclose()`` (which throws ``CancelledError``
into the generator), and works for arbitrarily long-lived tails.

The public surface (snapshot + subscribe + heartbeat + disconnect
cleanup) is verified exactly as the plan requires; only the transport
differs.
"""

from __future__ import annotations

import asyncio
import json
from datetime import date, datetime, timezone
from decimal import Decimal

import pytest
from starlette.requests import Request

from cyclone.api import app, claims_stream, remittances_stream, activity_stream
from cyclone.parsers.models import (
    BatchSummary,
    BillingProvider,
    ClaimHeader,
    ClaimOutput,
    Envelope,
    Payer,
    ParseResult,
    Subscriber,
    ValidationReport,
)
from cyclone.parsers.models_835 import (
    ClaimAdjustment,
    ClaimPayment,
    Envelope as Envelope835,
    FinancialInfo,
    ParseResult835,
    Payer835,
    Payee835,
    ReassociationTrace,
    ServicePayment,
    BatchSummary as BatchSummary835,
)
from cyclone.pubsub import EventBus
from cyclone.store import (
    BatchRecord,
    BatchRecord835,
    CycloneStore,
)


@pytest.fixture(autouse=True)
def _short_heartbeat(monkeypatch):
    """Set a 0.2s heartbeat for all stream tests in this module.

    The production default is 15s. Tests use a tight value so that
    generators sitting idle after the test reads its lines exit
    promptly — ``aclose()`` on the body iterator cancels the
    generator and the heartbeat timeout is the de-facto idle
    signal in these tests.
    """
    monkeypatch.setenv("CYCLONE_TAIL_HEARTBEAT_S", "0.2")


# --------------------------------------------------------------------------- #
# Test helpers
# --------------------------------------------------------------------------- #


def _make_claim_837(claim_id: str = "CLM-1", charge: str = "124.00") -> ClaimOutput:
    return ClaimOutput(
        claim_id=claim_id,
        control_number="0001",
        transaction_date=date(2026, 6, 19),
        billing_provider=BillingProvider(name="Test", npi="1234567890"),
        subscriber=Subscriber(
            first_name="Jane", last_name="Doe", 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="1", place_of_service="11",
        ),
        diagnoses=[],
        service_lines=[],
        validation=ValidationReport(passed=True, errors=[], warnings=[]),
        raw_segments=[],
    )


def _make_result_837(n: int = 3, batch_id: str = "b-uuid-1") -> ParseResult:
    claims = [
        _make_claim_837(f"CLM-{i}", charge=str(100 + i))
        for i in range(1, n + 1)
    ]
    return ParseResult(
        envelope=Envelope(
            sender_id="S", receiver_id="R", control_number="0001",
            transaction_date=date(2026, 6, 19),
        ),
        claims=claims,
        summary=BatchSummary(
            input_file="test.txt", control_number="0001",
            transaction_date=date(2026, 6, 19),
            total_claims=n, passed=n, failed=0,
        ),
    )


def _make_batch_record(kind: str = "837p", result=None, batch_id: str = "b-uuid-1",
                       n: int = 3):
    return BatchRecord(
        id=batch_id, kind=kind, input_filename="test.txt",
        parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
        result=result if result is not None else _make_result_837(n=n),
    )


def _make_remit_with_cas(remit_id="CLP-1", status="1", charge="124.00",
                          paid="62.00", cas_amount="62.00", pcn=None):
    cp = ClaimPayment(
        payer_claim_control_number=pcn if pcn is not None else remit_id,
        status_code=status,
        status_label="Primary",
        total_charge=charge, total_paid=paid,
        service_payments=[
            ServicePayment(
                line_number=1, procedure_qualifier="HC", procedure_code="99213",
                charge=charge, payment=paid,
                adjustments=[ClaimAdjustment(group_code="CO", reason_code="45",
                                              amount=cas_amount)],
            ),
        ],
    )
    return cp


def _make_835_result(claims):
    return ParseResult835(
        envelope=Envelope835(
            sender_id="S", receiver_id="R", control_number="0001",
            transaction_date=date(2026, 6, 19),
        ),
        financial_info=FinancialInfo(
            handling_code="C", paid_amount=Decimal("0"),
            credit_debit_flag="C", payment_method=None,
        ),
        trace=ReassociationTrace(
            trace_type_code="1", trace_number="0001",
            originating_company_id="S",
        ),
        payer=Payer835(name="X", id="SKCO0"),
        payee=Payee835(name="Y", npi="1234567890"),
        claims=claims,
        summary=BatchSummary835(
            input_file="era.txt", control_number="0001",
            transaction_date=date(2026, 6, 19),
            total_claims=len(claims), passed=len(claims), failed=0,
        ),
    )


def _make_request(path: str = "/api/claims/stream",
                   query_string: bytes = b"") -> Request:
    """Build a Starlette ``Request`` wired to ``app.state`` with a no-op
    receive channel so the endpoint's ``is_disconnected()`` polling
    works without raising ``RuntimeError: Receive channel has not
    been made available``.
    """
    # ``receive`` blocks until a message is delivered. We never deliver
    # one, so the cancel scope inside ``is_disconnected()`` always
    # returns ``False`` — exactly what we want for tests that close
    # via ``body_iterator.aclose()`` rather than the disconnect path.
    receive_started = asyncio.Event()

    async def receive():
        receive_started.set()
        # Block forever; is_disconnected's cancel scope will cancel
        # this await and we'll return None (treated as "no message").
        await asyncio.Event().wait()

    scope = {
        "type": "http",
        "method": "GET",
        "headers": [],
        "query_string": query_string,
        "path": path,
        "app": app,
        "scheme": "http",
        "server": ("testserver", 80),
        "client": ("testclient", 50000),
    }
    return Request(scope, receive=receive)


def _resolve_query_defaults(endpoint) -> dict:
    """Extract the actual default values from a FastAPI endpoint signature.

    ``Query(None)`` etc. are marker objects — when called directly we
    need to substitute their ``.default`` so the endpoint function
    sees the same values it would receive over HTTP.
    """
    import inspect
    sig = inspect.signature(endpoint)
    resolved: dict = {}
    for name, param in sig.parameters.items():
        default = param.default
        if hasattr(default, "default"):
            resolved[name] = default.default
        else:
            resolved[name] = default
    return resolved


async def _call_endpoint(endpoint, path: str):
    """Invoke ``endpoint(request, **defaults)`` with resolved defaults."""
    request = _make_request(path)
    defaults = _resolve_query_defaults(endpoint)
    # Drop ``request`` — it's the positional we pass explicitly.
    defaults.pop("request", None)
    return await endpoint(request, **defaults)


async def _read_lines(body_iter, n: int, timeout: float = 5.0) -> list[str]:
    """Read up to ``n`` lines from a StreamingResponse body iterator."""
    buffer = b""
    lines: list[str] = []
    async with asyncio.timeout(timeout):
        while len(lines) < n:
            try:
                chunk = await body_iter.__anext__()
            except StopAsyncIteration:
                break
            buffer += chunk
            while b"\n" in buffer and len(lines) < n:
                raw, buffer = buffer.split(b"\n", 1)
                if raw:
                    lines.append(raw.decode("utf-8"))
    return lines


async def _read_one_line(body_iter, timeout: float = 5.0) -> str | None:
    """Read a single NDJSON line from the body iterator, or None on timeout."""
    buffer = b""
    async with asyncio.timeout(timeout):
        while True:
            try:
                chunk = await body_iter.__anext__()
            except StopAsyncIteration:
                return None
            buffer += chunk
            if b"\n" in buffer:
                raw, _ = buffer.split(b"\n", 1)
                if raw:
                    return raw.decode("utf-8")


async def _drain_until_disconnect(body_iter, max_chunks: int = 20) -> None:
    """Close the body iterator and swallow the cancellation."""
    try:
        await body_iter.aclose()
    except (asyncio.CancelledError, GeneratorExit):
        pass


async def _read_until_type(
    body_iter, target: str, *, timeout: float = 5.0, max_lines: int = 50
) -> dict | None:
    """Read lines from the body iterator until one has ``type == target``.

    Used by tests that need to skip past heartbeats or multi-item
    snapshot prefixes to assert on a specific event. Returns the first
    matching parsed object, or ``None`` if ``max_lines`` is reached
    without a match.
    """
    buffer = b""
    seen = 0
    async with asyncio.timeout(timeout):
        while seen < max_lines:
            try:
                chunk = await body_iter.__anext__()
            except StopAsyncIteration:
                return None
            buffer += chunk
            while b"\n" in buffer:
                raw, buffer = buffer.split(b"\n", 1)
                if not raw:
                    continue
                seen += 1
                obj = json.loads(raw.decode("utf-8"))
                if obj.get("type") == target:
                    return obj
    return None


# --------------------------------------------------------------------------- #
# Task 11 — claims stream
# --------------------------------------------------------------------------- #


async def test_claims_stream_yields_snapshot_then_snapshot_end():
    """3 claims pre-loaded → 3 item lines + 1 snapshot_end line."""
    s = CycloneStore()
    s.add(_make_batch_record(n=3))

    response = await _call_endpoint(claims_stream, "/api/claims/stream")
    assert response.media_type.startswith("application/x-ndjson")

    lines = await _read_lines(response.body_iterator, n=4)
    items = [json.loads(line) for line in lines]
    assert len(items) == 4
    # First three are items; the order isn't guaranteed without an
    # explicit sort, so check shape not exact ordering.
    for item in items[:3]:
        assert item["type"] == "item"
        assert "id" in item["data"]
    # Fourth is the snapshot_end terminator.
    assert items[3] == {"type": "snapshot_end", "data": {"count": 3}}
    await _drain_until_disconnect(response.body_iterator)


async def test_claims_stream_emits_new_item_after_publish():
    """After snapshot_end, a bus.publish('claim_written', …) → new item line."""
    s = CycloneStore()
    s.add(_make_batch_record(n=1))

    response = await _call_endpoint(claims_stream, "/api/claims/stream")
    # Drain snapshot phase: 1 item + 1 snapshot_end.
    lines = await _read_lines(response.body_iterator, n=2)
    assert json.loads(lines[1]) == {
        "type": "snapshot_end", "data": {"count": 1},
    }
    # Advance the generator one more step so ``subscribe_raw`` runs
    # and registers the subscriber queue. The next yield will be a
    # heartbeat (with the 0.2s env var set in some tests); we don't
    # care about its content here, just that we've moved past the
    # sync subscription code.
    _ = await _read_one_line(response.body_iterator, timeout=2.0)

    # Now publish a claim_written event and assert a new item line
    # streams out.
    payload = {
        "id": "NEW-CLM-1",
        "patientName": "Live Tail",
        "providerNpi": "1234567890",
        "payerName": "Live Payer",
        "cptCode": "99213",
        "billedAmount": 999.99,
        "receivedAmount": 0.0,
        "status": "submitted",
        "state": "submitted",
        "denialReason": None,
        "submissionDate": "2026-06-19T12:00:00Z",
        "batchId": "b-uuid-1",
        "parsedAt": "2026-06-19T12:00:00Z",
    }
    await app.state.event_bus.publish("claim_written", payload)

    new_line = await _read_one_line(response.body_iterator, timeout=1.0)
    assert new_line is not None, "no item line arrived after publish"
    obj = json.loads(new_line)
    assert obj["type"] == "item"
    assert obj["data"]["id"] == "NEW-CLM-1"
    await _drain_until_disconnect(response.body_iterator)


async def test_claims_stream_heartbeat_after_idle(monkeypatch):
    """With heartbeat=0.2s, a heartbeat line appears within ~1s of idle."""
    monkeypatch.setenv("CYCLONE_TAIL_HEARTBEAT_S", "0.2")
    s = CycloneStore()
    s.add(_make_batch_record(n=1))

    response = await _call_endpoint(claims_stream, "/api/claims/stream")
    # Drain snapshot (2 lines).
    lines = await _read_lines(response.body_iterator, n=2)
    assert json.loads(lines[1]) == {
        "type": "snapshot_end", "data": {"count": 1},
    }

    # Read subsequent lines until we see a heartbeat or time out.
    deadline = asyncio.get_event_loop().time() + 1.5
    saw_heartbeat = False
    while asyncio.get_event_loop().time() < deadline:
        new_line = await _read_one_line(
            response.body_iterator, timeout=0.5,
        )
        if new_line is None:
            continue
        obj = json.loads(new_line)
        if obj["type"] == "heartbeat":
            assert "ts" in obj["data"]
            saw_heartbeat = True
            break
    assert saw_heartbeat, "no heartbeat line arrived within 1.5s"
    await _drain_until_disconnect(response.body_iterator)


# --------------------------------------------------------------------------- #
# Task 12 — remittances stream
# --------------------------------------------------------------------------- #


async def test_remittances_stream_yields_snapshot_then_subscribes():
    """Pre-loaded 835 batch → snapshot (1 item + snapshot_end)."""
    s = CycloneStore()
    cp = _make_remit_with_cas(remit_id="CLP-1", pcn="PCN-1")
    rec = BatchRecord835(
        id="b-835-1", kind="835", input_filename="era.txt",
        parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
        result=_make_835_result([cp]),
    )
    s.add(rec)

    response = await _call_endpoint(
        remittances_stream, "/api/remittances/stream",
    )
    assert response.media_type.startswith("application/x-ndjson")
    # Read the full snapshot (1 item + snapshot_end), skipping nothing.
    snap_end = await _read_until_type(
        response.body_iterator, "snapshot_end", timeout=2.0,
    )
    assert snap_end == {"type": "snapshot_end", "data": {"count": 1}}
    await _drain_until_disconnect(response.body_iterator)


async def test_remittances_stream_emits_after_upsert_remittance():
    """A bus.publish('remittance_written', …) → new item line on stream."""
    s = CycloneStore()
    cp = _make_remit_with_cas(remit_id="CLP-0", pcn="PCN-0")
    rec = BatchRecord835(
        id="b-835-0", kind="835", input_filename="era0.txt",
        parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
        result=_make_835_result([cp]),
    )
    s.add(rec)

    response = await _call_endpoint(
        remittances_stream, "/api/remittances/stream",
    )
    # Drain snapshot to the terminator.
    snap_end = await _read_until_type(
        response.body_iterator, "snapshot_end", timeout=2.0,
    )
    assert snap_end == {"type": "snapshot_end", "data": {"count": 1}}
    # Advance one more step so the subscription is registered before
    # we publish. The next yielded line may be a heartbeat (with the
    # 0.2s heartbeat fixture) or a real event — either is fine, we
    # just need ``subscribe_raw`` to have run.
    _ = await _read_one_line(response.body_iterator, timeout=2.0)

    payload = {
        "id": "PCN-NEW",
        "claimId": "",
        "payerName": "Tail Payer",
        "paidAmount": 12.34,
        "adjustmentAmount": 0.0,
        "status": "received",
        "denialReason": None,
        "validationWarnings": [],
        "receivedDate": "2026-06-19T12:00:00Z",
        "batchId": "b-835-1",
        "parsedAt": "2026-06-19T12:00:00Z",
        "adjustments": [],
    }
    await app.state.event_bus.publish("remittance_written", payload)

    # Read until the new item line appears (skip any heartbeats that
    # race in before the published event).
    new_obj = await _read_until_type(
        response.body_iterator, "item", timeout=2.0,
    )
    assert new_obj is not None, "no item line arrived after publish"
    assert new_obj["data"]["id"] == "PCN-NEW"
    await _drain_until_disconnect(response.body_iterator)


# --------------------------------------------------------------------------- #
# Task 13 — activity stream
# --------------------------------------------------------------------------- #


async def test_activity_stream_yields_snapshot_then_subscribes():
    """An 837 batch seeds activity rows → snapshot has at least 1 item."""
    s = CycloneStore()
    s.add(_make_batch_record(n=2))

    response = await _call_endpoint(
        activity_stream, "/api/activity/stream",
    )
    assert response.media_type.startswith("application/x-ndjson")
    # Read to snapshot_end — activity may have multiple items.
    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
    await _drain_until_disconnect(response.body_iterator)


async def test_activity_stream_emits_after_record_activity():
    """A bus.publish('activity_recorded', …) → new item line on stream."""
    s = CycloneStore()
    s.add(_make_batch_record(n=1))

    response = await _call_endpoint(
        activity_stream, "/api/activity/stream",
    )
    # Drain snapshot.
    snap_end = await _read_until_type(
        response.body_iterator, "snapshot_end", timeout=2.0,
    )
    assert snap_end is not None
    # Advance one step so subscription is registered.
    _ = await _read_one_line(response.body_iterator, timeout=2.0)

    payload = {
        "kind": "claim_submitted",
        "ts": "2026-06-19T12:00:00Z",
        "batchId": "b-uuid-1",
        "claimId": "LIVE-CLM",
        "remittanceId": None,
        "payload": {"message": "live tail event"},
    }
    await app.state.event_bus.publish("activity_recorded", payload)

    new_obj = await _read_until_type(
        response.body_iterator, "item", timeout=2.0,
    )
    assert new_obj is not None, "no item line arrived after publish"
    assert new_obj["data"]["kind"] == "claim_submitted"
    assert new_obj["data"]["claimId"] == "LIVE-CLM"
    await _drain_until_disconnect(response.body_iterator)


# --------------------------------------------------------------------------- #
# Task 14 — disconnect cancels subscription
# --------------------------------------------------------------------------- #


async def test_client_disconnect_cancels_subscription(monkeypatch):
    """After closing the body iterator, no claim_written subscribers remain.

    Closes the body iterator (the test-side disconnect signal — see
    the module docstring). The generator must propagate the
    ``CancelledError`` and tear down its bus subscription.
    """
    monkeypatch.setenv("CYCLONE_TAIL_HEARTBEAT_S", "0.1")
    s = CycloneStore()
    s.add(_make_batch_record(n=1))

    bus: EventBus = app.state.event_bus
    response = await _call_endpoint(claims_stream, "/api/claims/stream")
    # Read the full snapshot to the terminator.
    snap_end = await _read_until_type(
        response.body_iterator, "snapshot_end", timeout=2.0,
    )
    assert snap_end == {"type": "snapshot_end", "data": {"count": 1}}
    # Advance one step into the subscription phase so subscribe_raw
    # has run and registered the queue.
    _ = await _read_one_line(response.body_iterator, timeout=2.0)

    # Pre-condition: at least one subscriber registered while the
    # stream was open.
    assert len(bus._subscribers.get("claim_written", [])) >= 1

    # Client disconnect: close the body iterator. The endpoint
    # generator receives ``CancelledError`` via Starlette's
    # cancellation path and returns; its `bus.subscribe_raw` queue is
    # released. Wait briefly for cleanup.
    await _drain_until_disconnect(response.body_iterator)
    for _ in range(50):
        if not bus._subscribers.get("claim_written"):
            break
        await asyncio.sleep(0.02)

    assert bus._subscribers.get("claim_written", []) == [], (
        f"subscribers leaked after disconnect: "
        f"{bus._subscribers.get('claim_written')!r}"
    )