"""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)]


def _patch_drain_with_burst(
    monkeypatch: pytest.MonkeyPatch,
    events: list[dict[str, Any]],
) -> None:
    """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 all three shape tests so they
    see the SAME burst — that's the point.
    """
    delivered_once = {"flag": False}
    original_connect = Client.connect

    def patched_connect(path: str, *, client_id: int = 1) -> Client:
        c = original_connect(path, client_id=client_id)

        def fake_drain(scope: str = "hw:/**") -> list[dict[str, Any]]:
            if not delivered_once["flag"]:
                delivered_once["flag"] = True
                return events
            return []

        c.drain_events = fake_drain  # type: ignore[method-assign]
        return c

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


# 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)."""
    # `monitor` opens its OWN drain_events through `self.drain_events`,
    # not a dedicated client. Patch the broker fixture's own
    # `drain_events` directly so the patched burst flows through.
    delivered = {"flag": False}
    original = broker.drain_events

    def fake_drain(scope: str = "hw:/**") -> list[dict[str, Any]]:
        if not delivered["flag"]:
            delivered["flag"] = True
            return _BURST
        return []

    monkeypatch.setattr(broker, "drain_events", fake_drain)

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

    assert received == _BURST, f"sync monitor delivered {received}, expected {_BURST}"
    # Restore so the broker fixture's teardown doesn't hit a stub.
    monkeypatch.setattr(broker, "drain_events", original)


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`."""
    delivered = {"flag": False}

    def fake_drain(scope: str = "hw:/**") -> list[dict[str, Any]]:
        if not delivered["flag"]:
            delivered["flag"] = True
            return _BURST
        return []

    monkeypatch.setattr(broker, "drain_events", fake_drain)

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

    received = asyncio.run(consume())
    assert received == _BURST


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."""
    _patch_drain_with_burst(monkeypatch, _BURST)

    fd = broker.monitor_fd("hw:/usb/**", poll_interval_s=0.01)

    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


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
    delivered_sync = {"flag": False}

    def fake_drain_sync(scope: str = "hw:/**") -> list[dict[str, Any]]:
        if not delivered_sync["flag"]:
            delivered_sync["flag"] = True
            return _BURST
        return []

    monkeypatch.setattr(broker, "drain_events", fake_drain_sync)
    sync_received: list[dict[str, Any]] = []
    for ev in broker.monitor("hw:/usb/**", poll_interval_s=0.01):
        sync_received.append(ev)
        if len(sync_received) == len(_BURST):
            break

    # Async collection (re-arm)
    delivered_async = {"flag": False}

    def fake_drain_async(scope: str = "hw:/**") -> list[dict[str, Any]]:
        if not delivered_async["flag"]:
            delivered_async["flag"] = True
            return _BURST
        return []

    monkeypatch.setattr(broker, "drain_events", fake_drain_async)

    async def consume_async() -> list[dict[str, Any]]:
        out: list[dict[str, Any]] = []
        async for ev in broker.amonitor("hw:/usb/**", poll_interval_s=0.01):
            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
