"""Shared helpers used by ``cyclone.api`` route handlers.

Everything in this module is private to the API layer (no business
logic, no DB writes). It collects the cross-cutting concerns that used
to live inline at the top of ``api.py``:

* NDJSON wire-format primitives (``ndjson_line``, ``ndjson_stream_list``,
  ``ndjson_stream_837``, ``ndjson_stream_835``).
* Content negotiation (``client_wants_json``, ``wants_ndjson``).
* Strict / ``raw_segments`` rewrites applied before persisting parsed
  837P and 835 results.
* Validation-error probes for both transactions.
* The shared live-tail async generator (``tail_events``,
  ``heartbeat_seconds``) used by every ``/api/<resource>/stream``
  endpoint.

Extracted as part of the api.py router split (see /tmp/refactor-cyclone.md).
"""

from __future__ import annotations

import asyncio
import json
import os
from datetime import datetime, timezone
from typing import AsyncIterator, Iterator

from fastapi import Request

from cyclone.parsers.models import ClaimOutput, ParseResult
from cyclone.parsers.models_835 import ParseResult835
from cyclone.pubsub import EventBus


def utcnow() -> datetime:
    """tz-aware UTC ``datetime`` (matches :func:`cyclone.store.utcnow`)."""
    return datetime.now(timezone.utc)


def ndjson_line(event: dict) -> bytes:
    """Serialize one event dict as a single NDJSON line (UTF-8, trailing ``\\n``).

    Used by the live-tail streaming endpoints to emit a uniform wire format
    that the frontend ``tail-stream.ts`` parser can split on newlines.
    Compact separators keep each line small and avoid ambiguity with embedded
    whitespace.
    """
    return (json.dumps(event, separators=(",", ":")) + "\n").encode("utf-8")


def client_wants_json(request: Request) -> bool:
    """Content negotiation: prefer ``application/json`` when the client asks for it.

    NDJSON is the default for browser uploads that don't set ``Accept``. The
    frontend opts into JSON via ``Accept: application/json``.
    """
    accept = request.headers.get("accept", "")
    # If the client mentions JSON at all (and isn't asking for NDJSON
    # specifically) treat it as a single-object request. The browser default
    # ``*/*`` falls through to NDJSON.
    if "application/json" in accept and "application/x-ndjson" not in accept:
        return True
    return False


def wants_ndjson(request: Request) -> bool:
    """Content negotiation for list endpoints: NDJSON is an opt-in, JSON is the
    default (per spec 6.2: "Default JSON response wraps the same data in a
    {items, total, returned, has_more} envelope so the frontend can paginate
    uniformly").

    Used by the GET list routes (/api/batches, /api/claims, /api/remittances,
    /api/providers, /api/activity). NDJSON is returned only when the client
    explicitly sends ``Accept: application/x-ndjson`` (with or without
    ``application/json``). Bare ``*/*``, an empty Accept, or an explicit
    ``Accept: application/json`` all return the JSON envelope.
    """
    accept = request.headers.get("accept", "")
    return "application/x-ndjson" in accept


def ndjson_stream_list(
    items: list[dict], total: int, returned: int, has_more: bool,
) -> Iterator[str]:
    """Yield NDJSON lines for a list endpoint: one ``item`` per dict, then a
    final ``summary`` line. Mirrors spec section 6.2 streaming rule.
    """
    for it in items:
        yield json.dumps({"type": "item", "data": it}) + "\n"
    yield json.dumps({
        "type": "summary",
        "data": {"total": total, "returned": returned, "has_more": has_more},
    }) + "\n"


def ndjson_stream_837(result: ParseResult) -> Iterator[bytes]:
    """Yield one JSON object per line: envelope → claims → summary."""
    envelope_obj = (
        result.envelope.model_dump() if result.envelope is not None else None
    )
    yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
    for claim in result.claims:
        yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
    yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")


def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
    """Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary."""
    yield (json.dumps({"type": "envelope", "data": json.loads(result.envelope.model_dump_json())}) + "\n").encode("utf-8")
    yield (json.dumps({"type": "financial_info", "data": json.loads(result.financial_info.model_dump_json())}) + "\n").encode("utf-8")
    yield (json.dumps({"type": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8")
    yield (json.dumps({"type": "payer", "data": json.loads(result.payer.model_dump_json())}) + "\n").encode("utf-8")
    yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
    for claim in result.claims:
        yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
    yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")


def strict_rewrite_837(result: ParseResult) -> ParseResult:
    """Promote warnings to errors (mirrors the CLI's --strict)."""
    claims: list[ClaimOutput] = []
    for claim in result.claims:
        promoted = [
            issue.model_copy(update={"severity": "error"})
            for issue in claim.validation.warnings
        ]
        new_errors = claim.validation.errors + promoted
        claims.append(
            claim.model_copy(
                update={
                    "validation": claim.validation.model_copy(
                        update={"errors": new_errors, "passed": not new_errors}
                    )
                }
            )
        )
    passed = sum(1 for c in claims if c.validation.passed)
    failed = len(claims) - passed
    summary = result.summary.model_copy(
        update={
            "passed": passed,
            "failed": failed,
            "failed_claim_ids": [c.claim_id for c in claims if not c.validation.passed],
        }
    )
    return result.model_copy(update={"claims": claims, "summary": summary})


def strict_rewrite_835(result: ParseResult835) -> ParseResult835:
    """Promote warnings to errors (mirrors the CLI's --strict)."""
    if result.validation is None:
        return result
    report = result.validation
    promoted = [i.model_copy(update={"severity": "error"}) for i in report.warnings]
    new_errors = report.errors + promoted
    new_report = report.model_copy(update={"errors": new_errors, "passed": not new_errors})
    passed = 1 if new_report.passed else 0
    failed = 1 if not new_report.passed else 0
    new_summary = result.summary.model_copy(
        update={"passed": passed, "failed": failed}
    )
    return result.model_copy(update={"validation": new_report, "summary": new_summary})


def drop_raw_segments_837(result: ParseResult) -> ParseResult:
    """Return a copy of ``result`` with ``raw_segments`` cleared on every claim."""
    claims = [c.model_copy(update={"raw_segments": []}) for c in result.claims]
    return result.model_copy(update={"claims": claims})


def drop_raw_segments_835(result: ParseResult835) -> ParseResult835:
    """Return a copy of ``result`` with ``raw_segments`` cleared on every claim."""
    claims = [c.model_copy(update={"raw_segments": []}) for c in result.claims]
    return result.model_copy(update={"claims": claims})


def has_claim_validation_errors(result: ParseResult) -> bool:
    return any(not c.validation.passed for c in result.claims)


def has_835_validation_errors(result: ParseResult835) -> bool:
    return result.validation is not None and not result.validation.passed


def heartbeat_seconds() -> float:
    """Return the configured tail heartbeat interval.

    Read from ``CYCLONE_TAIL_HEARTBEAT_S`` at call time so tests can
    monkeypatch the env var without reloading the module. Defaults to
    15s (the production cadence); tests override to a small value (e.g.
    0.2s) to keep their runtime bounded.
    """
    raw = os.environ.get("CYCLONE_TAIL_HEARTBEAT_S", "15")
    try:
        v = float(raw)
    except ValueError:
        return 15.0
    return v if v > 0 else 15.0


async def tail_events(
    request: Request, bus: EventBus, kinds: list[str]
) -> AsyncIterator[bytes]:
    """Forward subscribed events as ``item`` lines with periodic heartbeats.

    Polls the underlying ``asyncio.Queue`` directly (via
    :meth:`EventBus.subscribe_raw`) instead of awaiting the bus's
    async-iterator wrapper. ``asyncio.wait_for`` cancels the inner
    future on timeout, which would otherwise terminate the bus
    iterator at its ``await`` point and break subsequent
    ``__anext__`` calls with ``StopAsyncIteration``. Polling
    ``queue.get()`` is idempotent under cancellation, so heartbeats
    don't poison the subscription.

    A ``try/finally`` unsubscribes the queue from the bus when the
    caller disconnects or the generator is garbage collected —
    otherwise the bus would leak one queue per open stream.
    """
    hb_s = heartbeat_seconds()
    queue, _sub = bus.subscribe_raw(kinds)
    try:
        while True:
            if await request.is_disconnected():
                return
            get_task = asyncio.ensure_future(queue.get())
            sleep_task = asyncio.ensure_future(asyncio.sleep(hb_s))
            try:
                done, pending = await asyncio.wait(
                    {get_task, sleep_task},
                    return_when=asyncio.FIRST_COMPLETED,
                )
            except BaseException:
                get_task.cancel()
                sleep_task.cancel()
                raise
            for t in pending:
                t.cancel()
            if get_task in done:
                event = get_task.result()
                yield ndjson_line({"type": "item", "data": event})
            else:
                yield ndjson_line({
                    "type": "heartbeat",
                    "data": {"ts": utcnow().isoformat().replace("+00:00", "Z")},
                })
    finally:
        bus.unsubscribe(queue, kinds)
