"""SDK-10 pins: no verb can hang unbounded, and one wedged call can no
longer stall the ones that passed a deadline.

**The bug** (mgf-hrb ≤ 0.1.9). 17 of 25 wire verbs took no
``deadline_ms`` — including the WRITE ``control()`` (authorize a device,
power a port, bind a driver) and ``attenuate()``. Worse, ``_Transport.
call`` holds ``self._lock`` for the whole round trip, so a single wedged
*unbounded* op stalled every other call on the same ``Client`` —
including the ones that carefully passed a deadline. The consumer's
deadline bounded the socket; nothing bounded the wait for the transport.

**The fix** (0.1.10). Every wire-speaking verb takes a keyword-only
``deadline_ms: int | None = None``, resolved against a new client-level
``default_deadline_ms``; a positive effective deadline bounds the lock
acquisition as well as the socket.

**The wedge fixture.** ``_FakeBroker(mode="wedge")`` is a Unix-socket
server that completes the hello handshake — so ``Client.connect``
succeeds and the SDK-06 hello deadline is out of the picture — and then
reads every request byte and never writes a reply. That is precisely a
wedged broker: reachable, accepting, answering nothing. Every pin below
that measures a timeout measures it against a peer that would otherwise
hang the caller forever, which is what makes the pins fail red on
0.1.9's code rather than merely passing quickly.

Two other modes serve the pump pins: ``"hangup"`` closes the connection
on the first request (broker crash), and ``"events_then_hangup"``
answers one drain with real events and then crashes — the ordering case
that proves the terminal ``gap`` line cannot interleave with a partial
event line.
"""

from __future__ import annotations

import json
import os
import random
import socket
import tempfile
import threading
import time
from collections import Counter
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any

import pytest

from mgf.hrb import Client
from mgf.hrb._client import Client as _ClientImpl
from mgf.hrb.exceptions import MgfHrbError, UnavailableError
from mgf.hrb.exceptions import TimeoutError as HrbTimeoutError

if TYPE_CHECKING:
    from collections.abc import Callable, Iterator

# Every pin that waits on a deadline uses this one, so a slow CI box
# shifts one constant rather than twenty literals. Small enough that the
# whole per-verb sweep stays under a second of real waiting.
_DEADLINE_MS = 200
# Generous upper bound on "the deadline fired". The deadline bounds each
# blocking PHASE (lock, then socket), so the documented worst case is
# ~2x; 2s of headroom over 200ms keeps the pin about the mechanism, not
# about scheduler jitter.
_BUDGET_S = 2.0


# ---------------------------------------------------------------------------
# The wedge fixture
# ---------------------------------------------------------------------------


def _op_of(line: bytes) -> str:
    """The `op` of one request line, or `""` if it isn't parseable."""
    try:
        return str(json.loads(line).get("op", ""))
    except ValueError:
        return ""


class _FakeBroker:
    """A Unix-socket peer that speaks the hello and then misbehaves.

    ``mode``:
      * ``"wedge"`` — read every request, never reply. The wedged
        broker: connected, accepting, silent.
      * ``"hangup"`` — reply to nothing; close the connection as soon
        as a request arrives. The crashed broker.
      * ``"events_then_hangup"`` — answer the first request with two
        real events, then close on the next one.
      * ``"broker_timeout"`` — answer the first request PROMPTLY with
        ``{"ok": false, "err": "Timeout"}``, then normally. The broker
        saying "the device timed out", which the SDK types identically
        to a client-side trip and must NOT treat identically.
      * ``"slow_reply"`` — answer every request, but ``reply_delay_s``
        LATE. The broker that is slow rather than wedged, which is the
        only shape that can desynchronise the stream (F-A1-01): the
        reply is real, it just arrives after the caller stopped
        waiting. Each reply echoes its own op so a pin can prove WHICH
        request a given reply belonged to.

    Each accepted connection is served by its own daemon thread, because
    ``monitor_fd`` opens a SECOND connection alongside the main client's
    and both must be served concurrently.
    """

    def __init__(
        self,
        mode: str = "wedge",
        *,
        cap_id: int = 42,
        reply_delay_s: float = 0.0,
        answer_subscribe: bool = False,
    ) -> None:
        self.mode = mode
        self.reply_delay_s = reply_delay_s
        # M-B B3 — answer the monitor family's setup call before the
        # mode's misbehaviour applies. See `_serve_conn`.
        self.answer_subscribe = answer_subscribe
        # `events_then_hangup`'s burst counter, broker-scoped so a
        # reconnect does not earn a second burst. See `_serve_conn`.
        self._bursts_served = 0
        self._tmpdir = tempfile.mkdtemp(prefix="sdk10-")
        self.path = str(Path(self._tmpdir) / "broker.sock")
        self._hello = (
            json.dumps({"ok": True, "hello": "mgf-hrb", "cap_id": cap_id}).encode() + b"\n"
        )
        self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        self._sock.bind(self.path)
        self._sock.listen(8)
        self._stop = threading.Event()
        self._conns: list[socket.socket] = []
        self._workers: list[threading.Thread] = []
        # Every request line the broker ever received, across all
        # connections — the evidence a pin uses to prove a deadline
        # actually rode the wire.
        self.received: list[dict[str, Any]] = []
        self._lock = threading.Lock()
        self._accept_thread = threading.Thread(target=self._accept_loop, daemon=True)
        self._accept_thread.start()

    # -- server internals ---------------------------------------------------

    def _accept_loop(self) -> None:
        self._sock.settimeout(0.05)
        while not self._stop.is_set():
            try:
                conn, _addr = self._sock.accept()
            except (TimeoutError, OSError):
                continue
            with self._lock:
                self._conns.append(conn)
            worker = threading.Thread(target=self._serve_conn, args=(conn,), daemon=True)
            worker.start()
            self._workers.append(worker)

    def _serve_conn(self, conn: socket.socket) -> None:
        with suppress(OSError):
            conn.sendall(self._hello)
        conn.settimeout(0.05)
        buf = b""
        served = 0
        while not self._stop.is_set():
            try:
                chunk = conn.recv(4096)
            except TimeoutError:
                continue
            except OSError:
                return
            if not chunk:
                return
            buf += chunk
            while b"\n" in buf:
                line, buf = buf.split(b"\n", 1)
                with suppress(ValueError):
                    with self._lock:
                        self.received.append(json.loads(line))
                # M-B B3 — `answer_subscribe` mode-crosscutting opt-in.
                #
                # Since 0.1.11 the monitor family's first wire op is a
                # `subscribe` on its own connection, so a fake that
                # wedges or hangs up on it never lets a pump start at
                # all — and the monitor pins below are about what the
                # pump does once it is RUNNING (its drain's bound, its
                # death, its terminal gap). Answering the setup call is
                # what keeps those pins pointed at their own claims
                # instead of silently becoming setup-failure pins.
                #
                # OPT-IN, not default, and that matters: the verb sweep
                # (`test_every_verb_times_out_against_a_wedged_broker`)
                # includes `subscribe` itself, and a fake that always
                # answered it would quietly delete a verb from a
                # 25-verb pin.
                #
                # Deliberately not `served`-counted: a reconnect issues a
                # second subscribe on a second connection, and that one
                # must be answered too or the reconnect could never be
                # exercised.
                if self.answer_subscribe and _op_of(line) == "subscribe":
                    with suppress(OSError, ValueError):
                        conn.sendall(
                            json.dumps(
                                {
                                    "ok": True,
                                    "subscription_id": json.loads(line).get("id", "s"),
                                    "queue_max": 64,
                                }
                            ).encode()
                            + b"\n"
                        )
                    continue
                if self.mode == "wedge":
                    # Read it. Say nothing. Forever.
                    continue
                if self.mode == "broker_timeout":
                    # A prompt, well-formed `ok:false` carrying the wire's
                    # own Timeout code. The SDK types it identically to a
                    # client-side trip, which is the whole point of the
                    # pin that uses this mode.
                    op = ""
                    with suppress(ValueError):
                        op = str(json.loads(line).get("op", ""))
                    reply: dict[str, Any] = (
                        {"ok": False, "err": "Timeout"}
                        if served == 0
                        else {"ok": True, "echo_op": op, "resources": [f"hw:/reply-to/{op}"]}
                    )
                    served += 1
                    with suppress(OSError):
                        conn.sendall(json.dumps(reply).encode() + b"\n")
                    continue
                if self.mode == "slow_reply":
                    op = ""
                    with suppress(ValueError):
                        op = str(json.loads(line).get("op", ""))
                    time.sleep(self.reply_delay_s)
                    # One reply shape that satisfies every projection the
                    # pins exercise, plus `echo_op` so a test can prove
                    # which request produced it.
                    reply = {
                        "ok": True,
                        "echo_op": op,
                        "resources": [f"hw:/reply-to/{op}"],
                        "backends": [{"scope_prefix": f"echo:{op}", "backend_name": op}],
                    }
                    with suppress(OSError):
                        conn.sendall(json.dumps(reply).encode() + b"\n")
                    continue
                if self.mode == "hangup":
                    with suppress(OSError):
                        conn.close()
                    return
                if self.mode == "events_then_hangup":
                    # BROKER-scoped, not connection-scoped (M-B B3): the
                    # pump reconnects after the hangup, and a per-
                    # connection counter would hand the new connection a
                    # fresh burst — an endless events→hangup→reconnect
                    # cycle instead of the one crash this mode names.
                    with self._lock:
                        first = self._bursts_served == 0
                        self._bursts_served += 1
                    if first:
                        reply = {
                            "ok": True,
                            "events": [
                                {"kind": "added", "urn": "hw:/usb/bus1/port-1/dev/if0"},
                                {"kind": "removed", "urn": "hw:/usb/bus1/port-2/dev/if0"},
                            ],
                        }
                        with suppress(OSError):
                            conn.sendall(json.dumps(reply).encode() + b"\n")
                    else:
                        with suppress(OSError):
                            conn.close()
                        return

    # -- lifecycle ----------------------------------------------------------

    def request_ops(self) -> list[str]:
        with self._lock:
            return [str(r.get("op", "")) for r in self.received]

    def last_request(self, op: str) -> dict[str, Any] | None:
        with self._lock:
            for req in reversed(self.received):
                if req.get("op") == op:
                    return req
        return None

    def stop(self) -> None:
        self._stop.set()
        self._accept_thread.join(timeout=2.0)
        with suppress(OSError):
            self._sock.close()
        with self._lock:
            conns = list(self._conns)
        for conn in conns:
            with suppress(OSError):
                conn.close()
        for worker in self._workers:
            worker.join(timeout=2.0)
        with suppress(OSError):
            Path(self.path).unlink()


@pytest.fixture
def wedge() -> Iterator[_FakeBroker]:
    """A broker that completes the hello and then never replies."""
    srv = _FakeBroker("wedge")
    yield srv
    srv.stop()


@pytest.fixture
def wedge_client(wedge: _FakeBroker) -> Iterator[Client]:
    """A Client connected to the wedge. Closing it is what releases any
    thread still parked in an unbounded recv."""
    client = Client.connect(wedge.path)
    yield client
    client.close()


# ---------------------------------------------------------------------------
# The verb table — every method that speaks the wire
# ---------------------------------------------------------------------------

# (name, invocation). Each invocation takes the client and the deadline
# to use, and issues exactly one wire op. `doctor` is absent on purpose:
# it CATCHES every exception from the broker-side check and turns it
# into a FAIL finding, so it gets its own pin below rather than a
# `pytest.raises`.
# (method name, the WIRE OP it must send, invocation). The middle column
# is not decoration: several verbs deliberately map to a different op
# (`inspect_raw` → `inspect`, `predicate_grammar_version` →
# `capabilities`, `reconcile_after_restart` → `reconcile`), so pinning
# the mapping catches a mis-routed verb as well as a missing deadline.
_WIRE_VERBS: list[tuple[str, str, Callable[[Client, int], object]]] = [
    ("list", "list", lambda c, d: c.list("hw:/**", deadline_ms=d)),
    ("drain_events", "drain_events", lambda c, d: c.drain_events("hw:/**", deadline_ms=d)),
    ("subscribe", "subscribe", lambda c, d: c.subscribe("sub-1", deadline_ms=d)),
    ("unsubscribe", "unsubscribe", lambda c, d: c.unsubscribe("sub-1", deadline_ms=d)),
    (
        "control",
        "control",
        lambda c, d: c.control("hw:/usb/x", "set_authorized", True, deadline_ms=d),
    ),
    ("inspect", "inspect", lambda c, d: c.inspect("hw:/usb/x", deadline_ms=d)),
    ("inspect_raw", "inspect", lambda c, d: c.inspect_raw("hw:/usb/x", deadline_ms=d)),
    ("dump_all", "dump_all", lambda c, d: c.dump_all("hw:/**", deadline_ms=d)),
    ("inspect_full", "inspect_full", lambda c, d: c.inspect_full("hw:/usb/x", deadline_ms=d)),
    (
        "inspect_full_raw",
        "inspect_full",
        lambda c, d: c.inspect_full_raw("hw:/usb/x", deadline_ms=d),
    ),
    ("dump_chain", "dump_chain", lambda c, d: c.dump_chain("hw:/usb/x", deadline_ms=d)),
    ("dump_chain_raw", "dump_chain", lambda c, d: c.dump_chain_raw("hw:/usb/x", deadline_ms=d)),
    ("capabilities", "capabilities", lambda c, d: c.capabilities(deadline_ms=d)),
    ("capabilities_raw", "capabilities", lambda c, d: c.capabilities_raw(deadline_ms=d)),
    ("driver_info", "driver_info", lambda c, d: c.driver_info("uvcvideo", deadline_ms=d)),
    ("audit_tail", "audit_tail", lambda c, d: c.audit_tail(10, deadline_ms=d)),
    ("state_snapshot", "state_snapshot", lambda c, d: c.state_snapshot(deadline_ms=d)),
    (
        "predicate_grammar_version",
        "capabilities",
        lambda c, d: c.predicate_grammar_version(deadline_ms=d),
    ),
    ("list_leases", "list_leases", lambda c, d: c.list_leases("hw:/**", deadline_ms=d)),
    ("claim", "claim", lambda c, d: c.claim("hw:/usb/x", deadline_ms=d)),
    ("release", "release", lambda c, d: c.release("hw:/usb/x", 1, deadline_ms=d)),
    ("revoke", "revoke", lambda c, d: c.revoke("hw:/usb/x", deadline_ms=d)),
    ("attenuate", "attenuate", lambda c, d: c.attenuate(scope="hw:/usb/**", deadline_ms=d)),
    ("recover", "recover", lambda c, d: c.recover("hw:/usb/x", deadline_ms=d)),
    (
        "reconcile_after_restart",
        "reconcile",
        lambda c, d: c.reconcile_after_restart(deadline_ms=d),
    ),
    ("irq_scan", "irq_scan", lambda c, d: c.irq_scan(deadline_ms=d)),
    ("raw", "raw", lambda c, d: c.raw("hw:/usb/x", 1, deadline_ms=d)),
    ("open_shm_channel", "open_shm_channel", lambda c, d: c.open_shm_channel(deadline_ms=d)),
    ("drain_shm_channel", "drain_shm_channel", lambda c, d: c.drain_shm_channel(1, deadline_ms=d)),
    ("start_shm_drain", "start_shm_drain", lambda c, d: c.start_shm_drain(1, deadline_ms=d)),
]

_VERB_IDS = [name for name, _, _ in _WIRE_VERBS]


def test_the_verb_table_covers_every_public_wire_method() -> None:
    """Meta-pin: the sweep below is only worth its name if the table is
    complete. Every public `Client` method that reaches the transport
    must appear in `_WIRE_VERBS` (or in the small, NAMED exemption set)
    — otherwise a verb added later silently escapes the deadline
    contract, which is exactly how the 0.1.9 hole opened."""
    # Exempt, each for a stated reason:
    #   doctor         — swallows broker errors into findings (own pin)
    #   monitor/…      — pump surfaces, bounded by _pump_deadline_ms (own pins)
    #   connect/close  — lifecycle, not a verb
    #   correlation_scope — returns a context manager, issues no op
    exempt = {
        "doctor",
        "monitor",
        "amonitor",
        "monitor_fd",
        "connect",
        "close",
        "correlation_scope",
    }
    covered = set(_VERB_IDS) | exempt
    import inspect as _inspect

    public = {
        name
        for name, member in _inspect.getmembers(_ClientImpl)
        if not name.startswith("_") and (_inspect.isfunction(member) or _inspect.ismethod(member))
    }
    assert public - covered == set(), (
        f"public Client methods missing from the deadline sweep: {sorted(public - covered)}"
    )


@pytest.mark.parametrize(("name", "wire_op", "invoke"), _WIRE_VERBS, ids=_VERB_IDS)
def test_every_verb_times_out_against_a_wedged_broker(
    wedge_client: Client,
    wedge: _FakeBroker,
    name: str,
    wire_op: str,
    invoke: Callable[[Client, int], object],
) -> None:
    """THE sweep. Against a broker that accepted the connection, spoke
    the hello, and then went silent, every wire verb given a deadline
    raises the SDK's typed `TimeoutError` inside the budget.

    On 0.1.9 the 17 verbs without a `deadline_ms` parameter could not
    even be CALLED this way (TypeError), and the rest would have hung on
    recv until the pytest timeout — which is the failure this sweep
    exists to make impossible."""
    started = time.monotonic()
    with pytest.raises(HrbTimeoutError) as excinfo:
        invoke(wedge_client, _DEADLINE_MS)
    elapsed = time.monotonic() - started
    assert elapsed < _BUDGET_S, f"{name} took {elapsed:.2f}s with a {_DEADLINE_MS}ms deadline"
    # The message must be about a DEADLINE, not a generic wire error —
    # a consumer branches on the type, an operator reads the string.
    assert "deadline" in str(excinfo.value).lower()
    # And the op really reached the wire, so the pin cannot pass by
    # short-circuiting before the socket. Asserted as an EQUALITY against
    # the recorded ops: the wedge is function-scoped and the client
    # issues nothing at connect time (the hello is server→client), so
    # exactly this one op should have arrived. Checking the exact op also
    # pins the method→op mapping.
    assert wedge.request_ops() == [wire_op]


@pytest.mark.parametrize(("name", "wire_op", "invoke"), _WIRE_VERBS, ids=_VERB_IDS)
def test_every_verb_honors_the_client_level_default(
    wedge: _FakeBroker,
    name: str,
    wire_op: str,
    invoke: Callable[[Client, int], object],
) -> None:
    """The same sweep through the OTHER door: the verb is called with
    `deadline_ms=None` (its default) and the bound comes from
    `Client.connect(default_deadline_ms=...)`. This is the parameter
    hrb-app sets once instead of threading a number through every call
    site, so every verb must actually consult it."""
    client = Client.connect(wedge.path, default_deadline_ms=_DEADLINE_MS)
    try:
        started = time.monotonic()
        with pytest.raises(HrbTimeoutError):
            # `d` is ignored: the point is the None path.
            _invoke_with_default(invoke, client)
        elapsed = time.monotonic() - started
        assert elapsed < _BUDGET_S, f"{name} ignored default_deadline_ms ({elapsed:.2f}s)"
        assert wedge.request_ops() == [wire_op]
    finally:
        client.close()


def _invoke_with_default(invoke: Callable[[Client, int], object], client: Client) -> object:
    """Re-issue a table entry with `deadline_ms=None`.

    The table's lambdas take an int, so this rebinds through the same
    call by passing `None` — typed as int in the table signature for
    readability, and deliberately violated here because `None` IS the
    default the pin is about."""
    return invoke(client, None)  # type: ignore[arg-type]


def test_doctor_degrades_to_a_finding_instead_of_hanging(wedge: _FakeBroker) -> None:
    """`doctor()` is the diagnostic an operator reaches for when the
    broker is ALREADY misbehaving, so it is the one call that must not
    hang. It catches the timeout and reports it: a FAIL finding naming
    the deadline, inside the budget."""
    client = Client.connect(wedge.path, default_deadline_ms=_DEADLINE_MS)
    try:
        started = time.monotonic()
        findings = client.doctor()
        elapsed = time.monotonic() - started
    finally:
        client.close()
    assert elapsed < _BUDGET_S, f"doctor() took {elapsed:.2f}s against a wedged broker"
    backend = [f for f in findings if f.code == "BACKEND_CAPABILITIES"]
    assert len(backend) == 1
    assert backend[0].severity.value == "fail"
    assert "deadline" in backend[0].detail.lower() or "timeout" in backend[0].detail.lower()


# ---------------------------------------------------------------------------
# The lock amplifier (M-A exit gate MA-2)
# ---------------------------------------------------------------------------


def test_a_wedged_unbounded_call_no_longer_stalls_a_bounded_one(
    wedge: _FakeBroker,
) -> None:
    """THE amplifier pin.

    Thread A issues an explicitly UNBOUNDED call (`deadline_ms=0`) and
    parks forever inside `_Transport.call`, holding the transport lock
    for the whole round trip. Thread B — the operator's click — then
    issues a call with a 200ms deadline.

    On 0.1.9, B blocked on `with self._lock:` and hung as long as A did:
    the deadline B passed bounded the socket it never got to touch. B's
    deadline now bounds the LOCK too, so B fails fast with a typed
    `TimeoutError` whose message names the transport — an operator can
    tell "the broker is slow" from "another call on this Client is
    holding the wire"."""
    client = Client.connect(wedge.path)
    a_started = threading.Event()

    def _thread_a() -> None:
        a_started.set()
        with suppress(MgfHrbError, OSError):
            # Unbounded on purpose: this is the back-compat escape that
            # 0.1.9's consumers get by default, and it must remain
            # possible — it just must not take everyone else down.
            client.list("hw:/**", deadline_ms=0)

    thread_a = threading.Thread(target=_thread_a, daemon=True, name="wedged-unbounded")
    thread_a.start()
    assert a_started.wait(timeout=2.0)

    # Wait until A actually OWNS the lock — otherwise B might win the
    # race, acquire freely, and the pin would prove nothing.
    transport = client._t
    deadline = time.monotonic() + 2.0
    while not transport._lock.locked() and time.monotonic() < deadline:
        time.sleep(0.005)
    assert transport._lock.locked(), "thread A never took the transport lock"

    started = time.monotonic()
    with pytest.raises(HrbTimeoutError) as excinfo:
        client.list("hw:/**", deadline_ms=_DEADLINE_MS)
    elapsed = time.monotonic() - started

    assert elapsed < 1.0, f"bounded call waited {elapsed:.2f}s behind an unbounded one"
    message = str(excinfo.value).lower()
    assert "lock" in message and "transport" in message, (
        f"the timeout must name the transport lock, not just 'timeout': {excinfo.value}"
    )
    # Closing releases thread A from its recv; the daemon thread would
    # not block teardown either way, but leaving it parked would leak a
    # socket across the rest of the session.
    client.close()
    thread_a.join(timeout=2.0)


def test_the_lock_is_released_after_a_socket_timeout(wedge_client: Client) -> None:
    """No lock leak on the timeout path: after a call times out on the
    SOCKET (holding the lock the whole time), the lock must be free —
    otherwise the amplifier is closed in one direction and reopened in
    the other, and the second call would hang forever."""
    with pytest.raises(HrbTimeoutError):
        wedge_client.list("hw:/**", deadline_ms=_DEADLINE_MS)
    transport = wedge_client._t
    assert not transport._lock.locked(), "the transport lock leaked on the timeout path"
    # The second call must therefore not BLOCK — and after F-A1-01 it must
    # not be answered either: the socket-phase timeout poisoned the
    # transport, so it is refused typed and instantly rather than served a
    # reply that might belong to the previous call.
    started = time.monotonic()
    with pytest.raises(UnavailableError):
        wedge_client.capabilities(deadline_ms=_DEADLINE_MS)
    assert time.monotonic() - started < _BUDGET_S


def test_the_lock_is_released_after_a_lock_timeout(wedge: _FakeBroker) -> None:
    """The other half: a call that times out waiting for the LOCK must
    not have acquired it. Proven by a third call succeeding in taking
    the lock once the holder lets go."""
    client = Client.connect(wedge.path)
    holder_done = threading.Event()

    def _holder() -> None:
        with suppress(MgfHrbError, OSError):
            client.list("hw:/**", deadline_ms=600)
        holder_done.set()

    thread = threading.Thread(target=_holder, daemon=True)
    thread.start()
    transport = client._t
    deadline = time.monotonic() + 2.0
    while not transport._lock.locked() and time.monotonic() < deadline:
        time.sleep(0.005)

    with pytest.raises(HrbTimeoutError):
        client.capabilities(deadline_ms=100)  # loses the race for the lock

    assert holder_done.wait(timeout=3.0)
    thread.join(timeout=2.0)
    assert not transport._lock.locked(), "a lock-timeout path acquired the lock anyway"
    client.close()


# ---------------------------------------------------------------------------
# F-A1-01 — a timed-out call desynchronises the stream, so it poisons it
# ---------------------------------------------------------------------------


def test_a_timed_out_call_never_lets_the_next_one_read_its_reply() -> None:
    """THE F-A1-01 pin, in the court's exact attack shape.

    The hazard needs a SLOW broker, not a wedged one. Call 1 gives up at
    its deadline; the broker's reply is real and arrives afterwards, and
    sits in the socket buffer. The protocol has no request ids, so call 2
    cannot tell that reply from its own — pre-fix it returned call 1's
    answer as its own result. A wrong answer delivered confidently is
    strictly worse than the hang the deadline was added to prevent.

    Post-fix the socket-phase timeout poisons the transport and call 2 is
    REFUSED — typed, so the consumer's existing reconnect path takes over
    (D-024, crash-only). What must never happen is call 2 succeeding."""
    broker = _FakeBroker("slow_reply", reply_delay_s=0.6)
    try:
        client = Client.connect(broker.path)
        # Call 1: 200ms deadline against a 600ms reply → trips.
        with pytest.raises(HrbTimeoutError):
            client.list("hw:/**", deadline_ms=200)

        # Call 2 with a deadline generous enough to READ the late reply
        # if the transport let it — that generosity is the point: it
        # removes "call 2 just timed out too" as an explanation.
        started = time.monotonic()
        with pytest.raises(UnavailableError) as excinfo:
            client.capabilities_raw(deadline_ms=2_000)
        elapsed = time.monotonic() - started

        message = str(excinfo.value).lower()
        assert "desynchronised" in message
        assert "reconnect" in message
        # Refused immediately, not after waiting for a reply it must not use.
        assert elapsed < 0.5, f"the refusal took {elapsed:.2f}s — it should be instant"
        client.close()
    finally:
        broker.stop()


def test_the_poison_survives_and_refuses_every_later_verb() -> None:
    """Poison is terminal for the connection, not a one-call hiccup: the
    stream stays unknowable until someone reconnects. Every verb refuses,
    including ones that never touched the wire before the trip."""
    broker = _FakeBroker("slow_reply", reply_delay_s=0.6)
    try:
        client = Client.connect(broker.path)
        with pytest.raises(HrbTimeoutError):
            client.list("hw:/**", deadline_ms=200)
        for invoke in (
            lambda: client.capabilities_raw(deadline_ms=2_000),
            lambda: client.list("hw:/**", deadline_ms=2_000),
            lambda: client.state_snapshot(deadline_ms=2_000),
            # Unbounded too — the refusal is about the stream, not the deadline.
            lambda: client.list("hw:/**", deadline_ms=0),
        ):
            with pytest.raises(UnavailableError):
                invoke()
        client.close()
    finally:
        broker.stop()


def test_a_reconnect_clears_the_poison() -> None:
    """The recovery path the error message names must actually work — a
    refusal that cannot be recovered from is just a hang with better
    prose. A fresh `Client.connect` is a fresh transport, and it serves
    normally against the same broker."""
    broker = _FakeBroker("slow_reply", reply_delay_s=0.6)
    try:
        client = Client.connect(broker.path)
        with pytest.raises(HrbTimeoutError):
            client.list("hw:/**", deadline_ms=200)
        with pytest.raises(UnavailableError):
            client.list("hw:/**", deadline_ms=2_000)
        client.close()

        fresh = Client.connect(broker.path)
        try:
            # Generous deadline: the 600ms reply is slow, not late.
            assert fresh.list("hw:/**", deadline_ms=3_000) == ["hw:/reply-to/list"]
        finally:
            fresh.close()
    finally:
        broker.stop()


def test_a_broker_returned_timeout_does_not_poison() -> None:
    """The boundary that makes the poison correct rather than merely
    safe. A broker-returned `Timeout` (`ok:false, err:"Timeout"`) is a
    REPLY: it was read, the stream is in sync, and the connection is
    perfectly usable. Poisoning on it would turn every device-level
    timeout into a forced reconnect — a self-inflicted outage.

    Client-side and broker-side timeouts share one exception type, so
    only the RAISE SITE can tell them apart — which is exactly why the
    poison is set inside `_call_locked`'s socket phase and nowhere else.
    """
    broker = _FakeBroker("broker_timeout")
    try:
        client = Client.connect(broker.path)
        with pytest.raises(HrbTimeoutError) as excinfo:
            client.list("hw:/**", deadline_ms=2_000)
        # It came off the wire as a typed error, not from our own clock.
        assert "client-side" not in str(excinfo.value).lower()

        transport = client._t
        assert transport._poisoned is None, (
            "a broker-returned Timeout poisoned the transport — the reply WAS "
            "read, so the stream is in sync and the connection is usable"
        )
        # Proof it is usable: the very next call is served normally.
        assert client.list("hw:/**", deadline_ms=2_000) == ["hw:/reply-to/list"]
        client.close()
    finally:
        broker.stop()


def test_a_lock_timeout_does_not_poison_the_transport() -> None:
    """F-A1-01's other boundary, and the one that is easy to get wrong.

    A call that loses the race for the transport lock NEVER TOUCHED THE
    SOCKET — no request sent, no reply abandoned, stream untouched. It
    must therefore be retryable on the same client. Poisoning here would
    turn ordinary contention (two threads, one busy Client — the exact
    situation the lock bound exists to make survivable) into a mandatory
    reconnect, which is a worse bug than the one being fixed."""
    broker = _FakeBroker("slow_reply", reply_delay_s=0.4)
    try:
        client = Client.connect(broker.path)
        holder_done = threading.Event()
        holder_result: list[object] = []

        def _holder() -> None:
            # Generous deadline: this call SUCCEEDS, holding the lock for
            # ~400ms while it waits for a reply that does arrive.
            with suppress(MgfHrbError, OSError):
                holder_result.append(client.list("hw:/**", deadline_ms=5_000))
            holder_done.set()

        thread = threading.Thread(target=_holder, daemon=True)
        thread.start()
        transport = client._t
        deadline = time.monotonic() + 2.0
        while not transport._lock.locked() and time.monotonic() < deadline:
            time.sleep(0.005)
        assert transport._lock.locked(), "the holder never took the transport lock"

        # Loses the lock race — a LOCK-phase timeout.
        with pytest.raises(HrbTimeoutError) as excinfo:
            client.capabilities_raw(deadline_ms=50)
        assert "lock" in str(excinfo.value).lower()

        assert holder_done.wait(timeout=5.0)
        thread.join(timeout=2.0)
        assert holder_result == [["hw:/reply-to/list"]], (
            f"the holder's own call should have succeeded: {holder_result}"
        )

        # THE ASSERTION: not poisoned, so the loser can simply retry.
        assert transport._poisoned is None, "a lock timeout poisoned the transport"
        assert client.capabilities_raw(deadline_ms=5_000) == [
            {"scope_prefix": "echo:capabilities", "backend_name": "capabilities"}
        ]
        client.close()
    finally:
        broker.stop()


# RC-A1-01 — tuning for the contention pin below. Chosen so the race
# LANDS (see the sabotage note in the pin's docstring) while the whole
# pin stays a few seconds: one round is roughly `_RC_REPLY_DELAY_S` plus
# thread/socket setup.
_RC_ROUNDS = 40
_RC_REPLY_DELAY_S = 0.15
_RC_A_DEADLINE_MS = 40  # A trips early → A poisons
_RC_B_DEADLINE_MS = 800  # B outlives A's reply → B could READ it
_RC_STAGGER_S = 0.015  # ± this, so every ordering gets exercised


def _rc_classify(name: str, own_op: str, other_op: str, result: dict[str, Any]) -> str:
    """One thread's outcome → a verdict token, or a violation.

    Acceptable for either thread: its OWN reply, a lock-phase
    `TimeoutError`, a socket-phase `TimeoutError`, or `UnavailableError`
    (it observed the poison). Unacceptable, and the whole point of the
    pin: the OTHER call's reply."""
    if result["kind"] == "exc":
        exc = result["exc"]
        if isinstance(exc, UnavailableError):
            assert "desynchronised" in str(exc).lower(), (
                f"{name}: UnavailableError without the desync sense: {exc}"
            )
            return "poison-refusal"
        if isinstance(exc, HrbTimeoutError):
            return "lock-timeout" if "lock" in str(exc).lower() else "socket-timeout"
        raise AssertionError(f"{name}: unexpected {type(exc).__name__}: {exc}")
    blob = repr(result["value"])
    assert other_op not in blob, (
        f"{name} RECEIVED THE OTHER CALL'S REPLY — stream desync, the F-A1-01 bug. "
        f"own_op={own_op!r}, got {blob}"
    )
    assert own_op in blob, f"{name}: unrecognisable reply {blob}"
    return "own-reply"


@pytest.mark.concurrency
@pytest.mark.timeout(180)
def test_the_in_lock_poison_check_holds_under_contention() -> None:
    """RC-A1-01 — the pin that makes the IN-LOCK poison check load-bearing.

    The re-court's finding: hoisting the poison read above
    ``self._lock.acquire()`` — i.e. checking a value captured BEFORE the
    wait, the natural "optimise away the double check" refactor — passes
    all 634 other tests in this repo while reintroducing the original
    desync 60 times out of 60. Every single-threaded poison pin stays
    green, because the window only exists when a second thread is
    *already blocked on the lock* while the first one poisons.

    The race, per round:
      * A issues `list` with a SHORT deadline against a broker that
        replies late → A trips on the socket and poisons the transport.
      * B issues `capabilities_raw` with a GENEROUS deadline, staggered
        randomly by ±15 ms so that B sometimes queues behind A (the
        ordering the in-lock check exists for), sometimes wins the lock
        outright, and sometimes arrives simultaneously.
      * B's deadline deliberately outlives A's abandoned reply, so if the
        check is stale B *can* read it. That is what makes the sabotage
        land rather than merely being possible in principle.

    Every acceptable outcome is enumerated in `_rc_classify`; the one
    forbidden one is B (or A) receiving the other's reply. The round also
    checks the invariants the single-threaded pins cannot: the lock is
    free once both threads are done, a socket-phase trip always leaves
    the flag set, and a lock-only round never poisons.
    """
    rng = random.Random(20260812)  # noqa: S311 — test scheduling jitter, not crypto
    seen: Counter[str] = Counter()

    for round_index in range(_RC_ROUNDS):
        # Negative = B goes first; ~0 = simultaneous; positive = B queues
        # behind A, which is the ordering that exposes a stale check.
        stagger = rng.uniform(-_RC_STAGGER_S, _RC_STAGGER_S)
        broker = _FakeBroker("slow_reply", reply_delay_s=_RC_REPLY_DELAY_S)
        try:
            client = Client.connect(broker.path)
            transport = client._t
            gate = threading.Barrier(3)
            res_a: dict[str, Any] = {}
            res_b: dict[str, Any] = {}

            # Loop variables are bound as defaults (B023): each round's
            # threads must see THAT round's client/barrier/stagger, never
            # a later iteration's.
            def _run_a(
                res: dict[str, Any] = res_a,
                delay: float = stagger,
                cli: Client = client,
                barrier: threading.Barrier = gate,
            ) -> None:
                barrier.wait()
                if delay < 0:
                    time.sleep(-delay)
                try:
                    res["value"] = cli.list("hw:/**", deadline_ms=_RC_A_DEADLINE_MS)
                    res["kind"] = "value"
                except BaseException as exc:  # every outcome is classified, none swallowed
                    res["kind"] = "exc"
                    res["exc"] = exc

            def _run_b(
                res: dict[str, Any] = res_b,
                delay: float = stagger,
                cli: Client = client,
                barrier: threading.Barrier = gate,
            ) -> None:
                barrier.wait()
                if delay > 0:
                    time.sleep(delay)
                try:
                    res["value"] = cli.capabilities_raw(deadline_ms=_RC_B_DEADLINE_MS)
                    res["kind"] = "value"
                except BaseException as exc:
                    res["kind"] = "exc"
                    res["exc"] = exc

            thread_a = threading.Thread(target=_run_a, name="rc-A-poisoner", daemon=True)
            thread_b = threading.Thread(target=_run_b, name="rc-B-contender", daemon=True)
            thread_a.start()
            thread_b.start()
            gate.wait()

            # Hang detection: the documented worst case is ~2x the
            # deadline per phase, so this budget is generous by design —
            # it is here to fail a HANG, not to time anything.
            budget = (_RC_A_DEADLINE_MS + _RC_B_DEADLINE_MS) / 1000.0 + _RC_REPLY_DELAY_S * 2 + 3.0
            thread_a.join(timeout=budget)
            thread_b.join(timeout=budget)
            assert not thread_a.is_alive() and not thread_b.is_alive(), (
                f"round {round_index}: a thread hung past {budget:.1f}s"
            )

            token_a = _rc_classify("A", "list", "capabilities", res_a)
            token_b = _rc_classify("B", "capabilities", "list", res_b)
            seen[f"A:{token_a} | B:{token_b}"] += 1

            # Invariants only a contention round can check.
            assert not transport._lock.locked(), (
                f"round {round_index}: the transport lock is still held after both threads joined"
            )
            tokens = {token_a, token_b}
            if "socket-timeout" in tokens or "poison-refusal" in tokens:
                assert transport._poisoned is not None, (
                    f"round {round_index}: a socket-phase trip left the poison flag unset"
                )
            elif tokens == {"lock-timeout"} or tokens == {"lock-timeout", "own-reply"}:
                assert transport._poisoned is None, (
                    f"round {round_index}: a lock-only round poisoned the transport: "
                    f"{transport._poisoned!r}"
                )
            client.close()
        finally:
            broker.stop()

    # The pin is worthless if the race never actually happened. At least
    # one round must have put B behind A with A poisoning — otherwise the
    # tuning above has drifted and the sabotage would no longer land.
    contended = sum(
        n for combo, n in seen.items() if "B:poison-refusal" in combo or "B:lock-timeout" in combo
    )
    assert contended > 0, (
        f"no round exercised the contended ordering — the pin proves nothing as tuned. "
        f"outcomes: {dict(seen)}"
    )


def test_socket_timeout_is_restored_to_blocking_on_every_exit(wedge_client: Client) -> None:
    """`settimeout` restoration discipline (F-HRB-002). A bounded call
    that TRIPS must still leave the socket in blocking mode, or the next
    unbounded call would inherit a timeout nobody asked for."""
    sock = wedge_client._t._sock
    assert sock.gettimeout() is None
    with pytest.raises(HrbTimeoutError):
        wedge_client.list("hw:/**", deadline_ms=_DEADLINE_MS)
    assert sock.gettimeout() is None, "the socket kept the deadline's timeout after a trip"


# ---------------------------------------------------------------------------
# Wire shape — back-compat byte-for-byte when the new params are unused
# ---------------------------------------------------------------------------


class _RecordingTransport:
    """Records requests, replays one canned response. Only the REQUEST
    is under test here, so the response is deliberately generic and any
    projection error downstream is suppressed by the caller."""

    def __init__(self) -> None:
        self.requests: list[dict[str, Any]] = []

    def call(self, **req: Any) -> dict[str, Any]:
        self.requests.append(req)
        return {"ok": True}

    def close(self) -> None:
        pass


def _record(default_deadline_ms: int = 0) -> tuple[Client, _RecordingTransport]:
    transport = _RecordingTransport()
    client = Client(
        transport,  # type: ignore[arg-type]
        cap_id=1,
        client_id=1,
        default_deadline_ms=default_deadline_ms,
    )
    return client, transport


def test_untouched_verbs_send_the_0_1_9_request_byte_for_byte() -> None:
    """Back-compat, stated as an equality. A consumer that sets neither
    `default_deadline_ms` nor a per-call `deadline_ms` must produce the
    exact request dict 0.1.9 produced — including the ABSENCE of the
    `deadline_ms` key on the verbs that never carried it."""
    client, transport = _record()
    client.list("hw:/**")
    assert transport.requests == [{"op": "list", "scope": "hw:/**", "cap_id": 1}]

    client, transport = _record()
    with suppress(Exception):
        client.capabilities_raw()
    assert transport.requests == [{"op": "capabilities"}]

    client, transport = _record()
    client.drain_events()
    assert transport.requests == [{"op": "drain_events", "scope": "hw:/**"}]


def test_verbs_that_always_carried_the_key_still_always_carry_it() -> None:
    """The other half of byte-identity: `release` sent `deadline_ms: 0`
    in 0.1.9 and still does. Silently dropping the key would be a wire
    change for a broker that (correctly) reads only known keys today but
    need not tomorrow."""
    client, transport = _record()
    client.release("hw:/usb/x", 7)
    assert transport.requests == [
        {"op": "release", "urn": "hw:/usb/x", "lease_id": 7, "deadline_ms": 0}
    ]


def test_the_client_default_reaches_the_wire() -> None:
    """`default_deadline_ms` is not merely a socket knob — it rides the
    request, so the BROKER self-cancels too (D-029, end-to-end
    survivability)."""
    client, transport = _record(default_deadline_ms=7_000)
    client.list("hw:/**")
    assert transport.requests[-1]["deadline_ms"] == 7_000


def test_a_per_call_deadline_beats_the_client_default() -> None:
    client, transport = _record(default_deadline_ms=7_000)
    client.list("hw:/**", deadline_ms=250)
    assert transport.requests[-1]["deadline_ms"] == 250


def test_zero_means_explicitly_unbounded_and_beats_the_default() -> None:
    """`0` is the documented back-compat escape (S2's sentinel). It must
    override a positive client default — a consumer that says "this one
    call may take as long as it takes" is not asking for the policy."""
    client, transport = _record(default_deadline_ms=7_000)
    client.list("hw:/**", deadline_ms=0)
    assert "deadline_ms" not in transport.requests[-1]


@pytest.mark.parametrize(("name", "wire_op", "invoke"), _WIRE_VERBS, ids=_VERB_IDS)
def test_every_verb_threads_the_deadline_into_the_request(
    name: str,
    wire_op: str,
    invoke: Callable[[Client, int], object],
) -> None:
    """Every verb's deadline reaches the request dict, not just the
    socket. Projection failures are suppressed on purpose — the canned
    response is generic and only the REQUEST is under test — but the
    verb must have reached the transport under the right op name, which
    the assertions below enforce."""
    client, transport = _record()
    with suppress(Exception):
        invoke(client, 1_234)
    assert transport.requests, f"{name} never reached the transport"
    assert transport.requests[-1]["op"] == wire_op
    assert transport.requests[-1]["deadline_ms"] == 1_234


def test_attenuated_child_inherits_the_parents_deadline_policy() -> None:
    """A child from `attenuate()` SHARES the parent's transport. A child
    that could still issue unbounded calls would re-open the amplifier
    the parent just closed, on the parent's own wire."""
    transport = _RecordingTransport()
    client = Client(
        transport,  # type: ignore[arg-type]
        cap_id=1,
        client_id=1,
        default_deadline_ms=3_000,
    )

    class _AttenTransport(_RecordingTransport):
        def call(self, **req: Any) -> dict[str, Any]:
            self.requests.append(req)
            return {"ok": True, "cap_id": 99}

    atten = _AttenTransport()
    client._t = atten  # type: ignore[assignment]
    child = client.attenuate(scope="hw:/usb/**")
    assert child.default_deadline_ms == 3_000
    child.list("hw:/**")
    assert atten.requests[-1]["deadline_ms"] == 3_000


@pytest.mark.regression
def test_a_client_built_without_init_still_resolves_the_deadline_rule() -> None:
    """Regression pin, caught by the contract suite acting as a compat
    oracle during A1.

    `Client.__new__(Client)` with only `_t` and `_cap` set is the shape
    `tests/contract/test_wire_drain_events.py` uses to exercise pure
    projection without a socket, and consumer test fakes copy it. The
    first cut of `_deadline` read an instance attribute only, so every
    such Client raised `AttributeError` from inside a verb — the SDK's
    own compat oracle red on a change that was supposed to be
    behaviour-preserving. `_default_deadline_ms` is now declared at CLASS
    level too, defaulting to the SDK's unbounded `0`."""
    client = Client.__new__(Client)
    client._t = _RecordingTransport()  # type: ignore[assignment]
    client._cap = 1

    assert client.default_deadline_ms == 0
    client.drain_events("hw:/usb/**")
    transport: _RecordingTransport = client._t  # type: ignore[assignment]
    # Unbounded, and byte-identical to 0.1.9: no `deadline_ms` key.
    assert transport.requests == [{"op": "drain_events", "scope": "hw:/usb/**"}]


# ---------------------------------------------------------------------------
# The monitor pump: bounded, and honest about its own death
# ---------------------------------------------------------------------------


def _read_lines(fd: int, *, budget_s: float = 5.0) -> tuple[list[dict[str, Any]], bool]:
    """Read the monitor pipe to EOF, returning (events, saw_eof)."""
    buf = b""
    saw_eof = False
    deadline = time.monotonic() + budget_s
    while time.monotonic() < deadline:
        chunk = os.read(fd, 4096)
        if not chunk:
            saw_eof = True
            break
        buf += chunk
    lines = [line for line in buf.split(b"\n") if line]
    return [json.loads(line) for line in lines], saw_eof


def test_pump_death_emits_one_terminal_gap_then_eof() -> None:
    """THE pump-death pin. The broker crashes under an active
    `monitor_fd`; the consumer must learn WHY.

    Pre-0.1.10 the pump swallowed the error and closed the write end, so
    the consumer saw a bare EOF — indistinguishable from a clean
    shutdown, and the GUI kept rendering a frozen pane. Now the pump
    writes exactly one `{"kind":"gap","source":"monitor_pump_error",
    "error":...}` line first. `gap` is already the "you missed things,
    rescan" signal every consumer handles, so the failure lands on a
    path that already exists.

    M-B B3 amends the ROUTE, not the claim: a dying stream now gets one
    reconnect first, so the pipe carries a `monitor_reconnect` gap ahead
    of the terminal one. The terminal line is still exactly one, still
    last, still followed by EOF."""
    broker = _FakeBroker("hangup", answer_subscribe=True)
    try:
        client = Client.connect(broker.path)
        fd = client.monitor_fd("hw:/**", wait_ms=50)
        events, saw_eof = _read_lines(fd)
        client.close()
    finally:
        broker.stop()

    assert saw_eof, "the pump must close the write end after the terminal gap"
    assert events, "the pump died silently — no terminal gap line"
    terminal = events[-1]
    assert terminal["kind"] == "gap"
    assert terminal["source"] == "monitor_pump_error"
    assert isinstance(terminal["error"], str)
    assert terminal["error"], "the gap must carry the cause, not an empty string"
    # Exactly ONE terminal gap — not one per retry.
    assert [e for e in events if e.get("source") == "monitor_pump_error"] == [terminal]
    # …and the reconnect was announced before it (B3 item 3).
    assert [e for e in events if e.get("source") == "monitor_reconnect"], events


def test_the_terminal_gap_never_interleaves_with_a_partial_event() -> None:
    """Ordering pin. The broker answers one drain with two real events
    and then crashes. Every line on the pipe must be complete, valid
    JSON, the two events must arrive in order, and the terminal gap must
    be LAST — a half-written event followed by the gap would produce one
    corrupt record and hide the failure.

    Since B3 the line between them is the `monitor_reconnect` gap: the
    stream announced the discontinuity, tried once, and only then
    died."""
    broker = _FakeBroker("events_then_hangup", answer_subscribe=True)
    try:
        client = Client.connect(broker.path)
        fd = client.monitor_fd("hw:/**", wait_ms=50)
        events, saw_eof = _read_lines(fd)
        client.close()
    finally:
        broker.stop()

    assert saw_eof
    assert len(events) == 4, f"expected 2 events + reconnect gap + terminal gap, got {events}"
    assert events[0] == {"kind": "added", "urn": "hw:/usb/bus1/port-1/dev/if0"}
    assert events[1] == {"kind": "removed", "urn": "hw:/usb/bus1/port-2/dev/if0"}
    assert events[2]["source"] == "monitor_reconnect"
    assert events[3]["source"] == "monitor_pump_error"


def test_a_clean_close_emits_no_gap() -> None:
    """The counterpart that gives the gap its meaning: a consumer-
    initiated `close()` is not a failure, so it writes nothing. If both
    paths emitted a gap, the signal would carry no information.

    Sharper since B3, and deliberately so: the pump is now parked in a
    5 s broker-side wait when `close()` arrives, and closing INTERRUPTS
    that wait — so the drain fails, and the failure path is one branch
    away from writing a reconnect gap. The `stop_event` check in front
    of it is the whole difference between "you asked me to stop" and
    "something broke"."""
    broker = _FakeBroker("wedge", answer_subscribe=True)
    try:
        client = Client.connect(broker.path)
        fd = client.monitor_fd("hw:/**", wait_ms=5_000)
        time.sleep(0.1)
        client.close()
        # `close()` closes the read end; reading it now is an error, so
        # the assertion is about what the consumer could have read
        # before: nothing.
        with pytest.raises(OSError):
            os.read(fd, 4096)
    finally:
        broker.stop()


def test_the_close_of_a_parked_pump_is_immediate(wedge: _FakeBroker) -> None:
    """B3's teardown pin, and the reason `_MonitorFdResources` carries
    the stream at all.

    The pump no longer sleeps in `stop_event.wait(interval)`; it blocks
    in a broker-side long poll that no client-side flag can wake. If
    `close()` only set the flag, it would block on its own 2 s join
    while the thread sat out the rest of a wait that can legally be 30
    seconds long — a GUI closing a window would freeze for as long as
    the consumer's own `wait_ms`. Shutting the stream's socket down is
    what makes the drain return NOW."""
    client = Client.connect(wedge.path)
    wedge.answer_subscribe = True
    try:
        client.monitor_fd("hw:/**", wait_ms=30_000)
        time.sleep(0.1)
        started = time.monotonic()
        client.close()
        elapsed = time.monotonic() - started
    finally:
        client.close()
    assert elapsed < 1.0, (
        f"close() took {elapsed:.2f}s with a 30s long poll in flight — the "
        "pump's wait was not interrupted, so teardown is waiting it out"
    )


def test_the_pump_drain_is_never_unbounded(wedge: _FakeBroker) -> None:
    """Item 4's floor, on the wire. The pump's `drain_events` must carry
    a positive `deadline_ms` — `wait_ms + 2000` at minimum — so a wedged
    broker cannot wedge the event stream into total silence.

    The arithmetic moved with B3 (`poll_interval_s * 1000 + 2000` →
    `wait_ms + 2000`) and the claim did not: whatever the wait strategy,
    the pump's drain carries a bound."""
    client = Client.connect(wedge.path)
    wedge.answer_subscribe = True
    try:
        client.monitor_fd("hw:/**", wait_ms=250)
        deadline = time.monotonic() + 3.0
        request = None
        while time.monotonic() < deadline and request is None:
            request = wedge.last_request("drain_events")
            time.sleep(0.02)
        assert request is not None, "the pump never issued a drain"
        assert request["deadline_ms"] == 250 + 2000
        # …and it asked the BROKER to do the waiting (B3 item 1).
        assert request["wait_ms"] == 250
    finally:
        client.close()


def test_an_explicit_zero_cannot_unbound_the_pump(wedge: _FakeBroker) -> None:
    """`deadline_ms=0` means unbounded EVERYWHERE ELSE, and deliberately
    does not here: an unbounded pump drain against a wedged broker
    produces no events, no error and no EOF — the exact silence
    `monitor_fd` exists to prevent.

    A1 clamped it to the floor silently. B3 REFUSES it instead, at the
    call, before a socket exists — the same rule, said out loud. The
    silent clamp was a value the caller asked for and did not get, which
    is the shape of every deadline bug this milestone has found."""
    client = Client.connect(wedge.path, default_deadline_ms=0)
    try:
        with pytest.raises(ValueError, match="unbounded"):
            client.monitor_fd("hw:/**", wait_ms=100, deadline_ms=0)
        # Refused BEFORE the wire: no connection, no subscribe, nothing.
        assert wedge.last_request("subscribe") is None
    finally:
        client.close()


def test_a_caller_deadline_can_raise_the_pump_bound_but_not_lower_it(
    wedge: _FakeBroker,
) -> None:
    """A consumer that wants a laxer pump gets it; one that asks for less
    than the floor is REFUSED (B3-AM1), because below the floor the
    deadline fires mid-wait — and that does not shorten the poll, it
    poisons the connection the poll runs on."""
    client = Client.connect(wedge.path)
    wedge.answer_subscribe = True
    try:
        client.monitor_fd("hw:/**", wait_ms=100, deadline_ms=9_000)
        deadline = time.monotonic() + 3.0
        request = None
        while time.monotonic() < deadline and request is None:
            request = wedge.last_request("drain_events")
            time.sleep(0.02)
        assert request is not None
        assert request["deadline_ms"] == 9_000
    finally:
        client.close()


def test_a_deadline_inside_the_wait_window_is_refused_before_the_wire(
    wedge: _FakeBroker,
) -> None:
    """B3-AM1's headline, stated as a pin.

    The broker has no `deadline_ms` field on `drain_events` at all
    (`request.zig`'s `DrainEventsReq`), so nothing server-side can
    reconcile a 5 s wait with a 1 s bound. Client-side, the consequence
    of not reconciling them is NOT a short poll: the deadline fires
    while the broker is still legitimately holding the request, the
    reply arrives afterwards, and F-A1-01's poison refuses every later
    call on that connection. So the combination is refused at the
    boundary."""
    client = Client.connect(wedge.path)
    try:
        with pytest.raises(ValueError, match="B3-AM1"):
            client.monitor_fd("hw:/**", wait_ms=5_000, deadline_ms=1_000)
        with pytest.raises(ValueError, match="B3-AM1"):
            # Longer than the wait, but inside the grace: still refused.
            client.monitor_fd("hw:/**", wait_ms=5_000, deadline_ms=5_500)
        assert wedge.request_ops() == [], (
            "a refused monitor must not have spoken to the broker first"
        )
    finally:
        client.close()


def test_a_wedged_broker_kills_the_pump_via_its_own_deadline() -> None:
    """End to end: nothing crashes, nothing closes — the broker simply
    stops answering. The pump's bound is what converts that silence into
    a terminal gap the consumer can read. Costs one floor's worth of
    waiting by construction; that IS the contract."""
    broker = _FakeBroker("wedge", answer_subscribe=True)
    try:
        client = Client.connect(broker.path)
        fd = client.monitor_fd("hw:/**", wait_ms=10)
        events, saw_eof = _read_lines(fd, budget_s=12.0)
        client.close()
    finally:
        broker.stop()

    assert saw_eof
    assert events, "a wedged broker produced no terminal gap — the pump hung"
    assert events[-1]["source"] == "monitor_pump_error"
    # A1's assertion, restored unchanged (F-B3-05): the TERMINAL gap
    # still names the deadline. Verified rather than assumed — the
    # stream spends its one reconnect, the second drain trips the same
    # bound, and that trip is what ends the stream, so its message is
    # the deadline's. The reconnect gap is asserted alongside rather
    # than instead: moving the claim to `events[0]` would have left the
    # terminal line — the one a consumer reads last — unpinned.
    assert "deadline" in events[-1]["error"].lower(), events
    assert "deadline" in events[0]["error"].lower(), events


@pytest.mark.timeout(30)
def test_monitor_iterator_surfaces_the_timeout_instead_of_going_quiet(
    wedge: _FakeBroker,
) -> None:
    """`monitor()` is bounded by the same rule. A wedged broker must
    reach the consumer rather than leave a `for event in
    client.monitor(...)` loop parked forever with nothing to show.

    Two yields, and the pair is the contract: the gap says "the stream
    broke, rescan", and the raise says "and it is not coming back". A1
    had only the second; B3 adds the first, which is the one a GUI can
    act on."""
    wedge.answer_subscribe = True
    client = Client.connect(wedge.path)
    started = time.monotonic()
    try:
        it = iter(client.monitor("hw:/**", wait_ms=10))
        first = next(it)
        assert first["kind"] == "gap"
        assert first["source"] == "monitor_reconnect"
        assert "deadline" in first["error"].lower()
        # A1's exact type, restored (F-B3-05). The fold had widened this
        # to `MgfHrbError` when the route changed; it did not need to.
        # The terminal raise is the SECOND drain's own trip, re-raised
        # verbatim because the stream had already spent its one
        # reconnect — so the consumer still gets `TimeoutError`, and
        # widening the assertion would have quietly accepted any broker
        # error at all in its place.
        with pytest.raises(HrbTimeoutError):
            next(it)
    finally:
        client.close()
    # Two bounded drains (10ms + 2000ms floor each) plus one bounded
    # reconnect — not an unbounded park. Measured 4.03s; the bound is
    # 8s rather than the fold's original 20s, which was loose enough to
    # pass while something waited out a whole extra deadline.
    assert time.monotonic() - started < 8.0
