"""Stress: monitor_fd's pipe pathway under sustained event load.

The SDK-01 fd-shaped event surface (`Client.monitor_fd`) spawns a
dedicated polling Client + a daemon thread that pumps events into
a self-pipe. The pipe is a 4-64 KB OS buffer — if the consumer
falls behind or the pump writes too aggressively, events could
back up or get lost.

Two tests:

  * **`test_pipe_consumer_sees_every_event`** — pin the core
    promise: every event that `drain_events` returns reaches the
    pipe consumer. Monkey-patches the monitor's dedicated Client
    to feed a known burst (10000 events). Counts what comes out
    the pipe; asserts exact match. The fake backend has no native
    event source so we synthesise — this is the only way to stress
    the pipe end of the surface without real hardware churn.

  * **`test_many_concurrent_monitor_fds_stay_alive`** — pin
    resilience: 10 clients each with a monitor_fd, run for a
    second of claim/release churn on a separate wire. Every
    pipe stays readable; every daemon thread stays alive; no
    exception. Regression target: a refcount bug or pipe-fd
    leak in `close()` would surface here as a stuck fd.
"""

from __future__ import annotations

import json
import os
import select
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any

import pytest

from mgf.hrb import ClaimMode, Client, ConflictError

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


def test_pipe_consumer_sees_every_event(
    broker: Client,
    sock_path: str,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Pin: every event in a synthesised 10000-event burst comes
    out the monitor_fd pipe, in order, exactly once.

    The fake backend has no native event source (its
    `drain_events` vtable slot is null — `fake_backend.zig:240`).
    To stress the pipe pathway we monkey-patch the dedicated
    monitor Client's `drain_events` to feed a known burst, then
    read the pipe and count.
    """
    n_events = 10000
    burst_size = 100  # 100 batches of 100 events each
    n_batches = n_events // burst_size
    # Plus one tail event after the bursts — proves drainage
    # completes, not just the bursts.
    tail_event = {"kind": "added", "urn": "hw:/test/synth/TAIL"}
    batches_queue: list[list[dict[str, Any]]] = [
        [
            {"kind": "added", "urn": f"hw:/test/synth/{i * burst_size + j}"}
            for j in range(burst_size)
        ]
        for i in range(n_batches)
    ]
    batches_queue.append([tail_event])

    original_connect = Client.connect
    swap_state = {"swapped": False}

    def patched_connect(path: str, *, client_id: int = 1) -> Client:
        c = original_connect(path, client_id=client_id)
        if swap_state["swapped"]:
            return c
        swap_state["swapped"] = True

        def fake_drain(scope: str = "hw:/**") -> list[dict[str, Any]]:
            if batches_queue:
                return batches_queue.pop(0)
            return []

        # Bind onto the instance — replaces the bound method for this
        # one Client only, leaving the class untouched.
        c.drain_events = fake_drain  # type: ignore[method-assign]
        return c

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

    fd = broker.monitor_fd("hw:/test/**", poll_interval_s=0.001)

    # Read until we've consumed every event (the tail + n_events) or
    # we hit the soft deadline. We assemble lines across read() calls
    # since one read might split a record.
    buf = b""
    received: list[dict[str, Any]] = []
    deadline = time.monotonic() + 10.0
    expected_total = n_events + 1  # bursts + tail
    while len(received) < expected_total and time.monotonic() < deadline:
        readable, _, _ = select.select([fd], [], [], 0.5)
        if not readable:
            continue
        chunk = os.read(fd, 65536)
        if not chunk:
            break
        buf += chunk
        while b"\n" in buf:
            line, _, buf = buf.partition(b"\n")
            received.append(json.loads(line))

    broker.close()  # tears down the monitor thread + pipe fds

    assert len(received) == expected_total, (
        f"lost events through the pipe — drained {expected_total}, "
        f"pipe delivered {len(received)} "
        f"(loss = {expected_total - len(received)})"
    )
    # Order preserved: the pump writes each event in the order the
    # batch returned them, and pipes are FIFO. Spot-check first +
    # last + tail.
    assert received[0] == {"kind": "added", "urn": "hw:/test/synth/0"}
    assert received[n_events - 1] == {
        "kind": "added",
        "urn": f"hw:/test/synth/{n_events - 1}",
    }
    assert received[-1] == tail_event


def test_many_concurrent_monitor_fds_stay_alive(broker: Client, sock_path: str) -> None:
    """10 monitor_fd's open simultaneously, each on its own Client.
    The broker holds 11 connections (10 monitors + 10 owners + the
    fixture) and survives a wire-pressure storm of claim/release.

    What this catches:
      * pipe-fd leak — a leak would surface as an `EMFILE` after
        the storm (or in a tight CI sandbox, during it).
      * pump-thread crash — `Client.close()` waits for the daemon
        thread to drain; a thread that died early would fail the
        idempotent close.
      * broker accept-loop starvation — the 10th monitor_fd's
        dedicated connection MUST be granted while the prior nine
        are still polling.
    """
    n_monitors = 10
    clients: list[Client] = []
    fds: list[int] = []
    try:
        for _ in range(n_monitors):
            c = Client.connect(sock_path)
            fd = c.monitor_fd("hw:/test/**", poll_interval_s=0.01)
            clients.append(c)
            fds.append(fd)

        # Every fd is open + non-blocking-readable for EOF check.
        for fd in fds:
            # select with timeout 0 should NOT show ready (no events
            # on fake backend) — it shows the fd is alive.
            readable, _, _ = select.select([fd], [], [], 0)
            assert readable == [], (
                "fd unexpectedly readable on fake backend — did the broker start emitting events?"
            )

        # Wire pressure: claim/release churn on a separate connection
        # while the monitor_fd's are polling.
        n_churn = 200

        def churn() -> int:
            c = Client.connect(sock_path)
            try:
                count = 0
                for i in range(n_churn):
                    urn = "hw:/test/dev0" if i % 2 == 0 else "hw:/test/dev1"
                    try:
                        with c.claim(urn, ClaimMode.EXCLUSIVE):
                            pass
                        count += 1
                    except ConflictError:
                        pass
                return count
            finally:
                c.close()

        with ThreadPoolExecutor(max_workers=4) as ex:
            churn_futs = [ex.submit(churn) for _ in range(4)]
            for f in as_completed(churn_futs, timeout=30):
                f.result()

        # All monitor fds still alive.
        for fd in fds:
            # poll() with POLLERR / POLLHUP would tell us if the
            # write end died; select() showing no error/no data is
            # the OK case.
            readable, _, exceptional = select.select([fd], [], [fd], 0)
            assert exceptional == [], f"fd {fd} in error state after churn"
    finally:
        for c in clients:
            c.close()
        # After every close(), the fds are closed too (monitor_fd's
        # contract is "torn down by Client.close()").
        for fd in fds:
            try:
                os.fstat(fd)
                pytest.fail(f"fd {fd} still open after Client.close()")
            except OSError:
                pass  # expected — close() reaped it
