"""Every request the web UI makes has a deadline (rule 156).

A source-inspection guard in the unit lane — there is no frontend test runner,
and this is a property of the source rather than of a rendered result, so
reading the source is the honest way to check it.

WHY. `fetch`'s default is to wait as long as the browser will. That is not a
long timeout, it is the absence of one, and there is no state a surface can
render for "pending forever" that is not a lie — the spinner that never
resolves is indistinguishable from work still in progress. Rule 156 names
`fetch` specifically:

    When a library's default is "wait indefinitely" — `fetch`, most HTTP
    clients, a bare `await` on a stream — supplying the deadline is part of
    using it, not a hardening pass for later.

Before this guard, no request in the app carried one.
"""
from __future__ import annotations

import re
from pathlib import Path

FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src"
CLIENT = FRONTEND / "api" / "client.ts"


def _call_text(src: str, start: int) -> str:
    """The source of one `fetch(...)` call, from its open paren to its close.

    Naive paren balancing. Adequate because every call site here passes an
    object literal, and a construct complex enough to defeat it is one worth
    looking at by hand anyway.
    """
    depth = 0
    for i in range(start, len(src)):
        if src[i] == "(":
            depth += 1
        elif src[i] == ")":
            depth -= 1
            if depth == 0:
                return src[start:i + 1]
    return src[start:]


def _fetch_calls() -> list[tuple[Path, str]]:
    calls: list[tuple[Path, str]] = []
    for path in list(FRONTEND.rglob("*.ts")) + list(FRONTEND.rglob("*.vue")):
        src = path.read_text()
        for m in re.finditer(r"\bfetch\(", src):
            calls.append((path, _call_text(src, m.end() - 1)))
    return calls


def test_every_fetch_passes_a_signal():
    """No bare `fetch` anywhere in the frontend.

    Stated on the SIGNAL rather than on a timeout value, because the two
    legitimate shapes here produce different values and only share this: an
    ordinary call takes the client's default, a stream bounds its CONNECT and
    then deliberately runs unbounded, and a bulk transfer passes minutes. What
    they must all do is pass something.
    """
    naked = [
        f"{path.relative_to(FRONTEND)}: {call[:70]}"
        for path, call in _fetch_calls()
        if "signal:" not in call
    ]
    assert not naked, (
        "these fetch calls carry no AbortSignal, so they wait forever "
        "(rule 156):\n  " + "\n  ".join(naked)
    )


def test_the_client_applies_its_deadline_by_default():
    """The specific regression that would silently undo this.

    An earlier pass (#3329) made `timeoutMs` OPT-IN and used it at exactly one
    call site, which left ~330 others waiting forever while the mechanism
    looked present. Reverting to that shape would not fail the guard above —
    every call would still reach `fetch` through `request()` — so the default
    is pinned here separately.

    `??` is the operative character: `opts?.timeoutMs || DEFAULT` would treat
    an explicit 0 as "use the default", and `opts?.timeoutMs` alone would
    reinstate the opt-in bug.
    """
    src = CLIENT.read_text()
    assert "opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS" in src, (
        "request() must fall back to DEFAULT_TIMEOUT_MS — without it the "
        "deadline is opt-in again and almost nothing opts in"
    )


def test_a_timeout_arrives_as_the_error_shape_callers_already_handle():
    """Rule 156's second half: expiry surfaces as a NAMED failure.

    A raw `DOMException: TimeoutError` reaches `apiErrorMessage(e, fallback)`
    as an object with no `body`, so every catch site in the app would print its
    generic fallback and the timeout would be invisible in exactly the
    situation it exists to expose. Rethrowing as `ApiError` is what makes the
    other ~330 call sites report it without being edited.
    """
    src = CLIENT.read_text()
    assert 'e.name === "TimeoutError"' in src, (
        "request() must recognise a timeout specifically"
    )
    assert "new ApiError(CLIENT_TIMEOUT_STATUS" in src, (
        "a timeout must be rethrown as ApiError so apiErrorMessage can read it"
    )


def test_a_deliberate_cancellation_is_not_reported_as_a_timeout():
    """Only `TimeoutError` is converted, never `AbortError`.

    A caller that cancelled its own request — a superseded search, a closed
    stream — must not have that surfaced to the user as a server failure. The
    guard is that the conversion is gated on the name, which the assertion
    above already pins; this states the intent so the gate is not "simplified"
    into catching every abort.
    """
    src = CLIENT.read_text()
    convert = src[src.index("async function request<"):]
    convert = convert[:convert.index("\n}")]
    assert "AbortError" not in convert, (
        "request() must not convert AbortError — a deliberate cancellation is "
        "not a timeout"
    )
