"""Use case: a GUI subscribes to broker watch events via the
three-layer SDK-01 event API.

Cites: FEEDBACK SDK-01 — the paper that motivated `Client.monitor`
(sync iterator), `Client.amonitor` (async iterator), and
`Client.monitor_fd` (pipe fd). Three shapes, same event stream;
this test pins that the three shapes are interchangeable.

The scenario in words:

  1. A GUI process subscribes to `hw:/usb/**` events via the API
     shape that fits its event loop:
       * Qt / Gtk → `monitor_fd` (the pipe fd plugs into
         `QSocketNotifier` / `Glib.IOChannel`).
       * asyncio / qasync → `amonitor` (async iterator).
       * worker thread / CLI / blocking script → `monitor`
         (sync iterator).
  2. An event fires (USB add, USB remove, or a gap signalling the
     consumer missed events).
  3. ALL three subscription shapes deliver the event identically.
     The GUI's choice of API is a developer-ergonomics decision,
     not a semantics decision.

What we pin:

  * `monitor`, `amonitor`, and `monitor_fd` see the same JSON
    payload for the same event.
  * Ordering is preserved across all three shapes.
  * The pipe shape (`monitor_fd`) is line-delimited JSON — one
    event per line — exactly as documented in the Client docstring.

How we generate the event stream without real hardware: the same
monkey-patch trick the stress test uses. We swap in a fake
`drain_events` on the monitor's dedicated Client; every shape
sees the same canned burst. The FakeBackend has no native event
source, so this is the only way to exercise the consumer surface
deterministically.
"""

from __future__ import annotations

import asyncio
import json
import os
import select
from typing import Any

import pytest

from mgf.hrb import Client

pytestmark = [pytest.mark.use_case, pytest.mark.timeout(60)]

# Every shape test below asks the broker to hold each drain this long.
# Short because these are shape pins, not latency pins — the real
# latency measurement lives in `tests/contract/test_sdk_longpoll_monitor.py`
# against a real broker.
_WAIT_MS = 10

# The deadline the monitor family MUST bound its internal `drain_events`
# with (M-A A1 item 4, as amended by M-B B3-AM1): `wait_ms + 2000`.
#
# Restated here rather than imported from `mgf.hrb._client`. A pin that
# reads the SDK's own constant moves with it and can therefore never
# catch a change to it — it would assert that the code equals itself.
# The arithmetic is duplicated ON PURPOSE; that duplication IS the pin.
_PUMP_FLOOR_MS = 2000
_EXPECTED_PUMP_DEADLINE_MS = _WAIT_MS + _PUMP_FLOOR_MS  # 2010


class _BurstDrain:
    """A `drain_events` stand-in that delivers one burst and then
    nothing — and PINS the deadline it was called with.

    Two jobs, deliberately in one object. Delivering the burst is what
    makes the consumer surface testable without real hardware. Recording
    and asserting `deadline_ms` is what stops this tier from silently
    ignoring the bound: before A1 these fakes took `(scope)` only, so
    when `monitor` grew `deadline_ms=` the whole tier failed with
    `TypeError` — it had no opinion about the deadline, it just could
    not be called. Now it has one.

    `deadlines` is recorded BEFORE the assertion so that a failure
    inside the `monitor_fd` pump thread — where an AssertionError only
    kills the pump and surfaces to the reader as a bare EOF — still
    leaves the wrong value visible to the test's own assertions.
    """

    def __init__(self, events: list[dict[str, Any]]) -> None:
        self.events = events
        self.deadlines: list[int | None] = []
        self.waits: list[int] = []
        self.subscription_ids: list[str | None] = []
        self._delivered = False

    def __call__(
        self,
        scope: str = "hw:/**",
        *,
        deadline_ms: int | None = None,
        subscription_id: str | None = None,
        wait_ms: int = 0,
    ) -> list[dict[str, Any]]:
        self.deadlines.append(deadline_ms)
        self.waits.append(wait_ms)
        self.subscription_ids.append(subscription_id)
        assert deadline_ms == _EXPECTED_PUMP_DEADLINE_MS, (
            f"the monitor pump must bound drain_events at "
            f"{_EXPECTED_PUMP_DEADLINE_MS}ms for wait_ms={_WAIT_MS} "
            f"(floor = wait_ms + {_PUMP_FLOOR_MS}); got {deadline_ms!r}"
        )
        assert wait_ms == _WAIT_MS, (
            f"the monitor must ask the BROKER to wait (wait_ms={_WAIT_MS}); "
            f"got {wait_ms!r} — a zero here means the SDK went back to "
            "spinning on non-blocking drains"
        )
        assert subscription_id, (
            "the monitor must drain its OWN subscription (SDK-08); a bare "
            "scope drain is the legacy path, not the subscription path"
        )
        if not self._delivered:
            self._delivered = True
            return list(self.events)
        return []

    def assert_bound_was_pinned(self) -> None:
        """Post-hoc check, for the shapes whose assertion would fire on
        a background thread rather than in the test body."""
        assert self.deadlines, "the monitor never called drain_events at all"
        assert set(self.deadlines) == {_EXPECTED_PUMP_DEADLINE_MS}, (
            f"pump deadlines were {sorted(set(self.deadlines), key=repr)}, "
            f"expected every call at {_EXPECTED_PUMP_DEADLINE_MS}ms"
        )


def _patch_drain_with_burst(
    monkeypatch: pytest.MonkeyPatch,
    events: list[dict[str, Any]],
) -> _BurstDrain:
    """Patch the next `Client.connect` to return a Client whose
    `drain_events` delivers `events` (in order) on the first call,
    then `[]` forever after. Used by the fd shape, whose polling
    Client is opened internally.

    Returns the fake so the caller can assert on what the pump asked
    for. Also pins `default_deadline_ms`: `monitor_fd` hands the pump's
    own connection the pump deadline as its client-level default, so a
    regression there would otherwise be invisible.
    """
    fake = _BurstDrain(events)
    original_connect = Client.connect

    def patched_connect(
        path: str,
        *,
        client_id: int = 1,
        hello_deadline_ms: int = 2000,
        default_deadline_ms: int = 0,
    ) -> Client:
        assert default_deadline_ms == _EXPECTED_PUMP_DEADLINE_MS, (
            f"monitor_fd must open its pump connection with "
            f"default_deadline_ms={_EXPECTED_PUMP_DEADLINE_MS}; "
            f"got {default_deadline_ms!r}"
        )
        c = original_connect(
            path,
            client_id=client_id,
            hello_deadline_ms=hello_deadline_ms,
            default_deadline_ms=default_deadline_ms,
        )
        c.drain_events = fake  # type: ignore[method-assign]
        return c

    monkeypatch.setattr(Client, "connect", staticmethod(patched_connect))
    return fake


# Synthesised event stream — three adds, one gap, one remove. Mirrors
# what a real USB worker emits over a couple of seconds during a
# plug-replug-remove cycle. Used by every shape so cross-shape
# equality is meaningful.
_BURST: list[dict[str, Any]] = [
    {"kind": "added", "urn": "hw:/usb/000/002.if0"},
    {"kind": "added", "urn": "hw:/usb/000/002.if1"},
    {"kind": "gap", "source": "netlink_uevent", "since_ms": 12345},
    {"kind": "added", "urn": "hw:/usb/000/003.if0"},
    {"kind": "removed", "urn": "hw:/usb/000/003.if0"},
]


def test_sync_iterator_delivers_every_event_in_order(
    broker: Client, monkeypatch: pytest.MonkeyPatch
) -> None:
    """`Client.monitor` (sync iterator) is the simplest shape — a
    worker thread or CLI loop iterates the generator. Pin that
    every burst event arrives in order, then the iterator can be
    stopped by closing the generator (no separate cancellation
    primitive needed for this shape)."""
    # Since 0.1.11 `monitor` opens its OWN connection, exactly like
    # `monitor_fd` — so ONE patch now covers all three shapes, where the
    # sync and async ones used to need the caller's `drain_events`
    # stubbed instead. That collapse IS the chunk's first item ("one
    # event path"); the shapes differ in how they hand events to the
    # consumer and in nothing else.
    fake = _patch_drain_with_burst(monkeypatch, _BURST)

    received: list[dict[str, Any]] = []
    for ev in broker.monitor("hw:/usb/**", wait_ms=_WAIT_MS):
        received.append(ev)
        if len(received) == len(_BURST):
            break

    assert received == _BURST, f"sync monitor delivered {received}, expected {_BURST}"
    # …and it was a BOUNDED, WAITING drain on its OWN subscription, not
    # merely a working one.
    fake.assert_bound_was_pinned()


def test_async_iterator_delivers_every_event_in_order(
    broker: Client, monkeypatch: pytest.MonkeyPatch
) -> None:
    """`Client.amonitor` (async iterator) is the asyncio shape.
    Same burst, same delivery contract, just routed through
    `asyncio.to_thread`."""
    fake = _patch_drain_with_burst(monkeypatch, _BURST)

    async def consume() -> list[dict[str, Any]]:
        received: list[dict[str, Any]] = []
        async for ev in broker.amonitor("hw:/usb/**", wait_ms=_WAIT_MS):
            received.append(ev)
            if len(received) == len(_BURST):
                break
        return received

    received = asyncio.run(consume())
    assert received == _BURST
    # `amonitor` runs the drain on a worker thread via `asyncio.to_thread`,
    # so the in-fake assertion would surface as a task exception rather
    # than a clean failure — check the record too.
    fake.assert_bound_was_pinned()


def test_monitor_fd_pipe_delivers_every_event_in_order(
    broker: Client,
    sock_path: str,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """`Client.monitor_fd` is the fd/pipe shape. The dedicated
    polling Client is opened internally — the monkey-patch
    intercepts that connect call so its `drain_events` returns
    the burst. Reads line-delimited JSON off the pipe."""
    fake = _patch_drain_with_burst(monkeypatch, _BURST)

    fd = broker.monitor_fd("hw:/usb/**", wait_ms=_WAIT_MS)

    buf = b""
    received: list[dict[str, Any]] = []
    try:
        # Bounded read loop — the pytest.mark.timeout above bounds
        # the worst case, but a per-iteration deadline keeps the
        # failure shape clean (TimeoutError vs hang).
        deadline_iters = 50
        for _ in range(deadline_iters):
            readable, _w, _e = select.select([fd], [], [], 0.2)
            if readable:
                chunk = os.read(fd, 65536)
                if not chunk:
                    break
                buf += chunk
                while b"\n" in buf:
                    line, _sep, buf = buf.partition(b"\n")
                    received.append(json.loads(line))
                if len(received) >= len(_BURST):
                    break
    finally:
        broker.close()  # tears down the monitor thread + pipe fds

    assert received == _BURST
    # The pump runs on a daemon thread, where a failed assertion inside
    # the fake only kills the pump and reaches the reader as a bare EOF.
    # Checking the record here turns that into a legible failure.
    fake.assert_bound_was_pinned()


def test_three_shapes_deliver_identical_streams(
    broker: Client,
    sock_path: str,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """The big pin: across the three API shapes, the SAME burst
    surfaces as the SAME JSON payloads in the SAME order. A GUI
    that switches between API shapes (e.g. asyncio → Qt port)
    must see no behaviour change."""
    # Sync collection
    fake_sync = _patch_drain_with_burst(monkeypatch, _BURST)
    sync_received: list[dict[str, Any]] = []
    for ev in broker.monitor("hw:/usb/**", wait_ms=_WAIT_MS):
        sync_received.append(ev)
        if len(sync_received) == len(_BURST):
            break

    # Async collection (re-arm)
    fake_async = _patch_drain_with_burst(monkeypatch, _BURST)

    async def consume_async() -> list[dict[str, Any]]:
        out: list[dict[str, Any]] = []
        async for ev in broker.amonitor("hw:/usb/**", wait_ms=_WAIT_MS):
            out.append(ev)
            if len(out) == len(_BURST):
                break
        return out

    async_received = asyncio.run(consume_async())

    # Identity invariant — three different APIs, ONE stream.
    assert sync_received == async_received == _BURST
    # And one bound: the shapes agree on the deadline too, not just the
    # payload. A shape that drained unbounded would deliver the same
    # events and hide the difference.
    fake_sync.assert_bound_was_pinned()
    fake_async.assert_bound_was_pinned()
