"""Pure-Python unit tests for the SDK Client + Lease.

The Client/Lease verbs need a peer that speaks the line-JSON protocol.
Rather than spinning up a real broker (that's the e2e test's job), we
construct a ``Client`` from a fake ``_Transport`` and assert the
request/response shapes, the lease context-manager semantics, and the
typed-exception translation at the wire boundary (EH-02).
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

import pytest

from mgf.hrb import ClaimMode, ConflictError
from mgf.hrb._client import Client, Lease
from mgf.hrb.exceptions import from_wire

if TYPE_CHECKING:
    from pathlib import Path


class _FakeTransport:
    """Records each call's request and replays a scripted response.

    Mirrors the real ``_Transport.call`` contract: when ``ok`` is false
    in the scripted response, raise the typed exception via ``from_wire``
    rather than returning the dict (EH-02)."""

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

    def call(self, **req: Any) -> dict[str, Any]:
        self.requests.append(req)
        if not self.responses:
            raise AssertionError(f"unexpected extra call: {req}")
        resp = self.responses.pop(0)
        if not resp.get("ok"):
            raise from_wire(resp.get("err", "Unknown"))
        return resp

    def close(self) -> None:
        self.closed = True


def _make_client(
    responses: list[dict[str, Any]], *, socket_path: str | None = None
) -> tuple[Client, _FakeTransport]:
    t = _FakeTransport(responses)
    # `socket_path` is only needed by the monitor family (M-B B3): those
    # surfaces open their own connection, so they refuse a Client that
    # cannot say where the broker is. Every other verb ignores it.
    return Client(t, cap_id=1, client_id=1, socket_path=socket_path), t  # type: ignore[arg-type]


def test_list_returns_urns_from_wire() -> None:
    client, t = _make_client([{"ok": True, "resources": ["hw:/pci/0", "hw:/pci/1"]}])
    urns = client.list("hw:/**")
    assert urns == ["hw:/pci/0", "hw:/pci/1"]
    # R-T1-11C: list forwards the connection-bound cap_id so the broker
    # can scope-filter (root cap here ⇒ no narrowing, but the field must
    # be on the wire for an attenuated cap to take effect).
    assert t.requests == [{"op": "list", "scope": "hw:/**", "cap_id": 1}]


def test_claim_returns_lease_and_release_round_trip() -> None:
    client, t = _make_client(
        [
            {"ok": True, "lease_id": 42},  # claim
            {"ok": True},  # release
        ]
    )
    lease = client.claim("hw:/pci/0", ClaimMode.EXCLUSIVE, deadline_ms=1_000)
    assert isinstance(lease, Lease)
    assert lease.lease_id == 42
    assert lease.urn == "hw:/pci/0"

    lease.release()
    # Idempotent: second release is a no-op (no extra wire call).
    lease.release()
    # Two wire calls in total: the claim and the one release.
    assert len(t.requests) == 2
    assert t.requests[0]["op"] == "claim"
    assert t.requests[0]["mode"] == ClaimMode.EXCLUSIVE.value
    assert t.requests[0]["deadline_ms"] == 1_000
    assert t.requests[1]["op"] == "release"


def test_lease_context_manager_releases_on_exit() -> None:
    client, t = _make_client(
        [
            {"ok": True, "lease_id": 7},  # claim
            {"ok": True},  # release
        ]
    )
    with client.claim("hw:/pci/0", ClaimMode.OBSERVE) as lease:
        assert lease.lease_id == 7
    assert t.requests[-1]["op"] == "release"


def test_lease_raw_returns_dict() -> None:
    client, t = _make_client(
        [
            {"ok": True, "lease_id": 1},
            {"ok": True, "fd_token": "placeholder-handle-id"},
        ]
    )
    lease = client.claim("hw:/pci/0", ClaimMode.EXCLUSIVE)
    raw = lease.raw(deadline_ms=500)
    assert raw["fd_token"] == "placeholder-handle-id"  # noqa: S105 — opaque handle id, not a secret
    assert t.requests[-1]["op"] == "raw"
    assert t.requests[-1]["deadline_ms"] == 500


def test_wire_error_translates_to_typed_exception() -> None:
    """EH-02: wire ``err`` code → typed Python exception at the seam."""
    client, _ = _make_client([{"ok": False, "err": "Conflict"}])
    with pytest.raises(ConflictError):
        client.claim("hw:/pci/0", ClaimMode.EXCLUSIVE)


def test_client_close_closes_transport() -> None:
    client, t = _make_client([])
    client.close()
    assert t.closed is True


def test_client_context_manager_closes() -> None:
    client, t = _make_client([])
    with client:
        pass
    assert t.closed is True


def test_capabilities_returns_wire_payload() -> None:
    """`Client.capabilities()` returns the broker's `backends` array
    verbatim. Pinned shape so a wire-protocol change can't silently
    break the consumer (mgf-hrb doctor) without a test catching it.

    Includes the rich §6.2.1 fields (per-op support, mode, version) so a
    backend that drops one of those is caught here, not in production."""
    client, t = _make_client(
        [
            {
                "ok": True,
                "backends": [
                    {
                        "scope_prefix": "hw:/pci/",
                        "backend_name": "mgf-hrb-linux",
                        "backend_version": "0.1.0",
                        "resource_count": 36,
                        "mode": "full",
                        "mode_reason": "",
                        "ops": {
                            "enumerate": True,
                            "query_topology": True,
                            "watch": False,
                            "acquire": True,
                            "release": True,
                            "reset": True,
                            "map_region": True,
                            "raw_channel": True,
                            "enforce_cap": False,
                        },
                    },
                    {
                        "scope_prefix": "hw:/usb/",
                        "backend_name": "mgf-hrb-usb",
                        "backend_version": "0.1.0",
                        "resource_count": 15,
                        "mode": "full",
                        "mode_reason": "",
                        "ops": {
                            "enumerate": True,
                            "query_topology": True,
                            "watch": True,
                            "acquire": True,
                            "release": True,
                            "reset": True,
                            "map_region": False,
                            "raw_channel": True,
                            "enforce_cap": False,
                        },
                    },
                ],
            },
        ]
    )
    # FEEDBACK SDK-05: capabilities() returns typed BackendCapability
    # records; the raw dict shape is preserved on capabilities_raw().
    backends = client.capabilities_raw()
    assert len(backends) == 2
    assert backends[0]["scope_prefix"] == "hw:/pci/"
    assert backends[0]["backend_name"] == "mgf-hrb-linux"
    assert backends[0]["resource_count"] == 36
    assert backends[0]["mode"] == "full"
    # PCI's `watch` is honestly false (no netlink wiring on the PCI side today).
    assert backends[0]["ops"]["watch"] is False
    # USB's `map_region` is honestly false (USB has no MMIO regions per §3).
    assert backends[1]["ops"]["map_region"] is False
    assert t.requests == [{"op": "capabilities"}]


def test_capabilities_degraded_mode_carries_reason() -> None:
    """When a backend reports a degraded mode (e.g. PCI without VFIO),
    the wire carries `mode_reason` so doctor can render the operator
    fix line. Pinned so the field doesn't get dropped silently."""
    client, _ = _make_client(
        [
            {
                "ok": True,
                "backends": [
                    {
                        "scope_prefix": "hw:/pci/",
                        "backend_name": "mgf-hrb-linux",
                        "backend_version": "0.1.0",
                        "resource_count": 36,
                        "mode": "read_only",
                        "mode_reason": "no /dev/vfio — acquire returns Unsupported",
                        "ops": {
                            "enumerate": True,
                            "query_topology": True,
                            "watch": False,
                            "acquire": False,
                            "release": True,
                            "reset": False,
                            "map_region": False,
                            "raw_channel": False,
                            "enforce_cap": False,
                        },
                    },
                ],
            },
        ]
    )
    backends = client.capabilities()  # typed projection
    assert backends[0].mode == "read_only"
    assert backends[0].mode_reason is not None
    assert "no /dev/vfio" in backends[0].mode_reason
    # The doctor uses these to flip its OK → WARN status.
    # `ops` projects to a sorted tuple of (name, bool) pairs.
    ops_dict = dict(backends[0].ops)
    assert ops_dict["acquire"] is False


def test_capabilities_empty_backends_returns_empty_list() -> None:
    """The broker may report an empty constellation (no workers wired);
    capabilities() returns [] not None."""
    client, _ = _make_client([{"ok": True, "backends": []}])
    assert client.capabilities() == []


def test_drain_events_returns_wire_payload() -> None:
    """`Client.drain_events()` returns the broker's `events` array verbatim.
    Pinned shape so the polling iterator (`Client.monitor`) keeps reading
    the same field name as the broker writes."""
    client, t = _make_client(
        [
            {
                "ok": True,
                "events": [
                    {"kind": "added", "urn": "hw:/usb/bus3/port-9/dev/if0"},
                    {"kind": "removed", "urn": "hw:/usb/bus3/port-9/dev/if1"},
                ],
            },
        ]
    )
    events = client.drain_events("hw:/**")
    assert len(events) == 2
    assert events[0]["kind"] == "added"
    assert events[1]["kind"] == "removed"
    assert t.requests[0] == {"op": "drain_events", "scope": "hw:/**"}


def test_drain_events_empty_returns_empty_list() -> None:
    """Idle bus → `[]`, not `None`. Important for the polling iterator's
    loop to stay tight (it sleeps only when the list is empty)."""
    client, _ = _make_client([{"ok": True, "events": []}])
    assert client.drain_events("hw:/**") == []


def test_monitor_yields_events_and_loops_on_empty_drain() -> None:
    """`monitor()` yields whatever each drain returns, in order, across
    an empty one: three drains, four events, no gaps in the sequence.

    Rebuilt on a socket for 0.1.11 (M-B B3). It used to stub the main
    Client's `_t.call`, which cannot work any more and SHOULD not:
    `monitor()` now drains its OWN connection, and a test that reaches
    into the caller's transport would be asserting against an
    implementation the surface no longer has."""
    from tests.unit.mgf.hrb._monitor_fake import FakeEventBroker

    broker = FakeEventBroker(
        [
            [],  # tick 1 — the long poll came back empty
            [{"kind": "added", "urn": "hw:/usb/bus3/port-5/dev/if0"}],
            [
                {"kind": "added", "urn": "hw:/usb/bus3/port-5/dev/if1"},
                {"kind": "added", "urn": "hw:/usb/bus3/port-5/dev/if2"},
            ],
        ]
    )
    try:
        client = Client.connect(broker.path)
        try:
            collected: list[dict[str, Any]] = []
            it = client.monitor("hw:/**", wait_ms=100)
            for _ in range(3):
                collected.append(next(it))
            it.close()
        finally:
            client.close()
    finally:
        broker.stop()

    assert [e["urn"] for e in collected] == [
        "hw:/usb/bus3/port-5/dev/if0",
        "hw:/usb/bus3/port-5/dev/if1",
        "hw:/usb/bus3/port-5/dev/if2",
    ]
    # …on a subscription of its own, drained with the wait it was given.
    subscribes = broker.requests_for("subscribe")
    assert len(subscribes) == 1, subscribes
    drains = broker.requests_for("drain_events")
    assert drains, "monitor() never drained"
    assert {d.get("subscription_id") for d in drains} == {subscribes[0]["id"]}
    assert {d.get("wait_ms") for d in drains} == {100}


def test_claim_default_does_not_send_detach_true() -> None:
    """Without detach=, the wire MUST carry detach=false. The broker treats
    that as "never silently steal devices" — a plain claim against a
    kernel-driver-bound interface returns ConflictError, not a silent
    detach. Pinned because flipping the default would be a security
    regression."""
    client, t = _make_client([{"ok": True, "lease_id": 1}])
    client.claim("hw:/test/dev0", ClaimMode.EXCLUSIVE)
    assert t.requests[0]["detach"] is False


def test_claim_with_detach_true_flows_to_wire() -> None:
    """Explicit detach=True is passed verbatim — the broker decides
    whether to honor it (cap-gated by detach_driver). The SDK doesn't
    second-guess."""
    client, t = _make_client([{"ok": True, "lease_id": 2}])
    client.claim("hw:/usb/bus3/port-9/dev/if0", ClaimMode.EXCLUSIVE, detach=True)
    assert t.requests[0]["detach"] is True
    assert t.requests[0]["op"] == "claim"


def test_raw_with_fd_via_socketpair() -> None:
    """End-to-end SCM_RIGHTS test using a real Unix socketpair: write a
    raw-style response with an attached fd from one side, read it via the
    real ``_Transport.call`` from the other. Pins the cmsg parsing — the
    bug that escaped the previous round was `array.frombytes` APPENDING
    to the sentinel array, leaving `fds[0]` as the stale initial -1
    instead of the freshly-received fd. struct.unpack is now used; this
    test catches a regression."""
    import json as _json
    import os
    import socket as _socket
    import threading

    from mgf.hrb._client import _Transport

    s_broker, s_client = _socket.socketpair(_socket.AF_UNIX, _socket.SOCK_STREAM)
    transport = _Transport(s_client)

    # Make a real fd to pass: a pipe's read end (we own + close it).
    r_fd, w_fd = os.pipe()
    try:
        # Broker-side write happens on a thread so the client-side
        # recvmsg in `_t.call` can block waiting for the cmsg.
        def broker_writer() -> None:
            # Wait for the request, then sendmsg the response + cmsg.
            line = b""
            while b"\n" not in line:
                line += s_broker.recv(4096)
            payload = (_json.dumps({"ok": True, "raw": True, "fd": True}) + "\n").encode()
            fds = bytes(__import__("struct").pack("i", r_fd))
            s_broker.sendmsg(
                [payload],
                [(_socket.SOL_SOCKET, _socket.SCM_RIGHTS, fds)],
            )

        t = threading.Thread(target=broker_writer)
        t.start()

        resp = transport.call(op="raw", urn="hw:/test/dev0", lease=1, caller=1)
        t.join()

        assert resp["ok"] is True
        assert resp["raw"] is True
        # The received fd is a fresh OS handle (not r_fd in our table — the
        # kernel installed a clone). It must fstat and be a pipe-like fd.
        received_fd = resp["fd"]
        assert isinstance(received_fd, int)
        assert received_fd >= 0
        os.fstat(received_fd)  # raises if invalid
        os.close(received_fd)
    finally:
        os.close(r_fd)
        os.close(w_fd)
        s_broker.close()
        s_client.close()


# ---- FEEDBACK TOOLS-02: Client.doctor ------------------------------------


def test_doctor_requires_socket_path_when_constructed_without_one() -> None:
    """A Client built directly (test fakes, internal use) has no
    socket_path; calling doctor() without an explicit path must surface
    the requirement clearly rather than silently raising elsewhere."""
    import pytest as _pytest

    client, _ = _make_client([])
    assert client.socket_path is None
    with _pytest.raises(ValueError, match="socket_path"):
        client.doctor()


def test_doctor_runs_local_checks_and_calls_capabilities(tmp_path: Path) -> None:
    """Client.doctor() with an explicit (nonexistent) socket_path runs
    the local-only check suite. The SOCKET_REACHABLE finding is FAIL,
    so the broker-side BACKEND_CAPABILITIES finding short-circuits to
    DEFERRED — no wire call is made to the fake transport."""
    from mgf.hrb.diagnostics import Severity

    client, t = _make_client([])  # zero scripted responses — none should be consumed
    findings = client.doctor(socket_path=str(tmp_path / "no.sock"))
    codes = [f.code for f in findings]
    # All engine codes are present, in stable order.
    # R3 (2026-05-27) — added USBFS_DEVNODE_PRESENT +
    # USBFS_DEVNODE_OPENABLE between USB_SYSFS_READABLE and
    # VFIO_DEVICE_PRESENT (USB worker substrate rows).
    assert codes == [
        "SOCKET_REACHABLE",
        "PCI_SYSFS_READABLE",
        "USB_SYSFS_READABLE",
        "USBFS_DEVNODE_PRESENT",
        "USBFS_DEVNODE_OPENABLE",
        "VFIO_DEVICE_PRESENT",
        "IOMMU_PRESENT",
        "UDEV_RULE_INSTALLED",
        "BACKEND_CAPABILITIES",
    ]
    # Socket missing → FAIL; capability check skipped → DEFERRED.
    sock = next(f for f in findings if f.code == "SOCKET_REACHABLE")
    backend = next(f for f in findings if f.code == "BACKEND_CAPABILITIES")
    assert sock.severity is Severity.FAIL
    assert backend.severity is Severity.DEFERRED
    # No wire requests sent.
    assert t.requests == []


# ---- FEEDBACK SDK-03: Client.list_leases --------------------------------


def test_list_leases_default_scope_sends_broadest_glob() -> None:
    """Default scope is `hw:/**`; the wire MUST carry that so a GUI
    asking for "all leases" doesn't accidentally narrow."""
    from mgf.hrb import LeaseRecord  # noqa: F401 — re-export check

    client, t = _make_client([{"ok": True, "leases": []}])
    result = client.list_leases()
    assert result == []
    assert t.requests[0]["op"] == "list_leases"
    assert t.requests[0]["scope"] == "hw:/**"


def test_list_leases_returns_typed_records() -> None:
    """Each wire row is projected into a frozen `LeaseRecord` with the
    five typed fields. The dataclass is hashable (slots=True + frozen)
    so a GUI can use it directly as a Qt model row."""
    from mgf.hrb import ClaimMode, LeaseRecord

    client, _ = _make_client(
        [
            {
                "ok": True,
                "leases": [
                    {
                        "urn": "hw:/pci/0000:01:00.0/fn0",
                        "lease_id": 1,
                        "mode": "exclusive",
                        "holder": 42,
                        "granted_at_ms": 100,
                    },
                    {
                        "urn": "hw:/usb/bus1/dev4/if0",
                        "lease_id": 2,
                        "mode": "observe",
                        "holder": 7,
                        "granted_at_ms": 200,
                    },
                ],
            }
        ]
    )
    leases = client.list_leases()
    assert len(leases) == 2
    assert isinstance(leases[0], LeaseRecord)
    assert leases[0].urn == "hw:/pci/0000:01:00.0/fn0"
    assert leases[0].mode is ClaimMode.EXCLUSIVE
    assert leases[0].holder == 42
    assert leases[0].granted_at_ms == 100
    assert leases[1].mode is ClaimMode.OBSERVE


def test_list_leases_narrower_scope_passed_through() -> None:
    """Scope arg flows to the wire so the broker filters server-side."""
    client, t = _make_client([{"ok": True, "leases": []}])
    client.list_leases("hw:/usb/**")
    assert t.requests[0]["scope"] == "hw:/usb/**"


def test_list_leases_empty_response_is_empty_list() -> None:
    """Honest emptiness: zero leases → empty list, never None."""
    client, _ = _make_client([{"ok": True, "leases": []}])
    assert client.list_leases() == []


def test_list_leases_missing_leases_key_is_empty_list() -> None:
    """An older broker that doesn't yet emit the key surfaces as empty,
    not as a KeyError — forward-compatibility for the wire shape."""
    client, _ = _make_client([{"ok": True}])
    assert client.list_leases() == []


# ---- FEEDBACK SDK-05: Client.capabilities (typed) -----------------------


def test_capabilities_returns_typed_backend_capability_records() -> None:
    """SDK-05: `capabilities()` now returns `list[BackendCapability]`,
    not raw dicts. Each row is a frozen dataclass with the five core
    fields + the forward-compatible optionals."""
    from mgf.hrb import BackendCapability

    client, _ = _make_client(
        [
            {
                "ok": True,
                "backends": [
                    {
                        "scope_prefix": "hw:/pci/",
                        "backend_name": "mgf-hrb-linux",
                        "backend_version": "0.1.0",
                        "resource_count": 36,
                        "mode": "full",
                        "ops": {"enumerate": True, "claim": True, "watch": False},
                    },
                    {
                        "scope_prefix": "hw:/usb/",
                        "backend_name": "mgf-hrb-usb",
                        "resource_count": 15,
                        "mode": "degraded_no_iommu",
                        "mode_reason": "/dev/vfio missing",
                    },
                ],
            }
        ]
    )
    caps = client.capabilities()
    assert len(caps) == 2
    assert isinstance(caps[0], BackendCapability)
    assert caps[0].scope_prefix == "hw:/pci/"
    assert caps[0].backend_version == "0.1.0"
    assert caps[0].mode == "full"
    # ops projects into a sorted tuple of (name, bool) pairs.
    assert caps[0].ops == (
        ("claim", True),
        ("enumerate", True),
        ("watch", False),
    )
    # Forward-compat: the second row has no backend_version / ops.
    assert caps[1].backend_version is None
    assert caps[1].ops == ()
    assert caps[1].mode_reason == "/dev/vfio missing"


def test_capabilities_raw_returns_dicts_unchanged() -> None:
    """SDK-05 escape hatch: `capabilities_raw()` returns the wire
    dicts un-projected, preserved for the CLI doctor's rich grid
    rendering (which reads fields not promised by the typed shape)."""
    row = {
        "scope_prefix": "hw:/pci/",
        "backend_name": "x",
        "resource_count": 1,
        "extra_field": "kept",
    }
    client, _ = _make_client([{"ok": True, "backends": [row]}])
    raw = client.capabilities_raw()
    assert raw == [row]


def test_capabilities_handles_resource_count_as_string() -> None:
    """Defensive at the seam: a broker that serialized resource_count
    as a string (older broker, future wire-format wobble) doesn't
    crash the typed projection — it parses."""
    client, _ = _make_client(
        [
            {
                "ok": True,
                "backends": [
                    {"scope_prefix": "hw:/x/", "backend_name": "x", "resource_count": "5"}
                ],
            }
        ]
    )
    caps = client.capabilities()
    assert caps[0].resource_count == 5


def test_capabilities_garbage_resource_count_falls_back_to_zero() -> None:
    """A garbage value (non-int, non-numeric string) falls back to 0
    rather than raising — the engine that consumes this should still
    report rather than crash."""
    client, _ = _make_client(
        [
            {
                "ok": True,
                "backends": [
                    {"scope_prefix": "x", "backend_name": "x", "resource_count": "not-a-number"}
                ],
            }
        ]
    )
    caps = client.capabilities()
    assert caps[0].resource_count == 0


# ---- Tier-0 #2 layer-1: U3 forgery closure via handshake -----------------


def test_connect_consumes_hello_and_stores_cap_id(tmp_path: Path) -> None:
    """Client.connect MUST read the broker's hello message and store
    the cap_id it carries. A connection that skips the handshake has
    no cap to present — U3 closure (Part 12 §3.3 Tier-0 #2).
    """
    import json as _json
    import socket as _socket
    import threading

    from mgf.hrb import Client

    sock_path = str(tmp_path / "hello-test.sock")
    server = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
    server.bind(sock_path)
    server.listen(1)

    handshake_cap = 0x1234_5678_DEAD_BEEF & 0x7FFF_FFFF_FFFF_FFFF
    accepted_holder: dict[str, object] = {}

    def serve() -> None:
        s_client, _ = server.accept()
        try:
            # Send the hello as soon as the client connects (matches the
            # real broker's accept-loop shape).
            hello = (
                _json.dumps(
                    {
                        "ok": True,
                        "hello": "mgf-hrb",
                        "cap_id": handshake_cap,
                        "version": "0.1.0",
                    }
                )
                + "\n"
            )
            s_client.sendall(hello.encode())
            accepted_holder["sock"] = s_client
        finally:
            pass

    t = threading.Thread(target=serve)
    t.start()
    try:
        client = Client.connect(sock_path)
        assert client._cap == handshake_cap
        assert client.socket_path == sock_path
        client.close()
    finally:
        t.join()
        sock = accepted_holder.get("sock")
        if isinstance(sock, _socket.socket):
            sock.close()
        server.close()


def test_connect_rejects_missing_hello(tmp_path: Path) -> None:
    """A broker that doesn't speak the handshake (or speaks the WRONG
    handshake) is refused at connect-time. We don't silently fall back
    to a guessable default — that would defeat U3 closure."""
    import socket as _socket
    import threading

    from mgf.hrb import Client, MgfHrbError

    sock_path = str(tmp_path / "no-hello.sock")
    server = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
    server.bind(sock_path)
    server.listen(1)

    def serve() -> None:
        s_client, _ = server.accept()
        try:
            # Send a malformed hello (missing cap_id).
            s_client.sendall(b'{"ok":true,"hello":"mgf-hrb"}\n')
        finally:
            s_client.close()

    t = threading.Thread(target=serve)
    t.start()
    try:
        with pytest.raises(MgfHrbError, match="cap_id"):
            Client.connect(sock_path)
    finally:
        t.join()
        server.close()


def test_connect_rejects_wrong_greeting(tmp_path: Path) -> None:
    """A peer that says 'hello' but isn't mgf-hrb (an impostor on the
    same socket path) is refused. The greeting acts as a small
    protocol marker — defense against accidental cross-talk."""
    import socket as _socket
    import threading

    from mgf.hrb import Client, MgfHrbError

    sock_path = str(tmp_path / "wrong-hello.sock")
    server = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
    server.bind(sock_path)
    server.listen(1)

    def serve() -> None:
        s_client, _ = server.accept()
        try:
            s_client.sendall(b'{"ok":true,"hello":"some-other-service","cap_id":1}\n')
        finally:
            s_client.close()

    t = threading.Thread(target=serve)
    t.start()
    try:
        with pytest.raises(MgfHrbError, match="unexpected broker greeting"):
            Client.connect(sock_path)
    finally:
        t.join()
        server.close()


def test_connect_rejects_cap_id_zero(tmp_path: Path) -> None:
    """The wire reserves cap_id=0 as the 'no cap presented' sentinel.
    A hello carrying cap_id=0 is malformed by construction and must
    be refused."""
    import socket as _socket
    import threading

    from mgf.hrb import Client, MgfHrbError

    sock_path = str(tmp_path / "zero-cap.sock")
    server = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
    server.bind(sock_path)
    server.listen(1)

    def serve() -> None:
        s_client, _ = server.accept()
        try:
            s_client.sendall(b'{"ok":true,"hello":"mgf-hrb","cap_id":0,"version":"0.1.0"}\n')
        finally:
            s_client.close()

    t = threading.Thread(target=serve)
    t.start()
    try:
        with pytest.raises(MgfHrbError, match="positive cap_id"):
            Client.connect(sock_path)
    finally:
        t.join()
        server.close()


# ---- FEEDBACK SDK-02 typed half: Client.inspect ------------------------


def test_inspect_returns_typed_resource_detail() -> None:
    """SDK-02 typed half: `inspect()` returns a `ResourceDetail`
    frozen dataclass, not a raw dict. The wire shape is the same;
    only the SDK's projection layer changed."""
    from mgf.hrb import Family, FieldFidelity, Kind, ResourceDetail

    client, t = _make_client(
        [
            {
                "ok": True,
                "resource": {
                    "urn": "hw:/usb/bus3/port-9/dev/if0",
                    "family": "claimable",
                    "kind": "usb_interface",
                    "fidelity": "live",
                    "attrs": {
                        "idVendor": "046d",
                        "idProduct": "c52b",
                        "serial": "LOGI-001",
                    },
                },
            }
        ]
    )
    detail = client.inspect("hw:/usb/bus3/port-9/dev/if0")
    assert isinstance(detail, ResourceDetail)
    assert detail.urn == "hw:/usb/bus3/port-9/dev/if0"
    assert detail.family is Family.CLAIMABLE
    assert detail.kind is Kind.USB_INTERFACE
    assert detail.fidelity is FieldFidelity.LIVE
    # Convenience accessor works against the typed shape.
    assert detail.attr("idVendor") == "046d"
    assert detail.attr("serial") == "LOGI-001"
    assert detail.attr("nonexistent") is None
    # Wire request shape preserved. R-T1-11C: cap_id rides the request.
    assert t.requests == [{"op": "inspect", "urn": "hw:/usb/bus3/port-9/dev/if0", "cap_id": 1}]


def test_inspect_raw_returns_dict_unchanged() -> None:
    """SDK-02 escape hatch: `inspect_raw()` returns the wire dict
    un-projected — preserved for the CLI info renderer which reads
    backend-specific attr keys + future forward-compat fields."""
    raw_payload = {
        "urn": "hw:/pci/0000:01:00.0/fn0",
        "family": "claimable",
        "kind": "pcie_function",
        "fidelity": "live",
        "attrs": {"vendor": "0x10de", "device": "0x1c82", "extra": "kept"},
    }
    client, _ = _make_client([{"ok": True, "resource": raw_payload}])
    raw = client.inspect_raw("hw:/pci/0000:01:00.0/fn0")
    assert raw == raw_payload
    # The "extra" field a future broker might add survives the raw
    # path even when the typed shape wouldn't surface it.
    assert raw["attrs"]["extra"] == "kept"


def test_inspect_typed_and_raw_consume_one_call_each() -> None:
    """The typed inspect() is a single wire call; the raw projection
    happens client-side. (Defensive: if the implementation ever
    issued two calls under the hood, the GUI's poll-every-N-seconds
    pattern would double broker load — caught here.)"""
    client, t = _make_client(
        [
            {
                "ok": True,
                "resource": {
                    "urn": "x",
                    "family": "claimable",
                    "kind": "usb_interface",
                    "fidelity": "live",
                    "attrs": {},
                },
            },
            {
                "ok": True,
                "resource": {
                    "urn": "y",
                    "family": "claimable",
                    "kind": "usb_interface",
                    "fidelity": "live",
                    "attrs": {},
                },
            },
        ]
    )
    client.inspect("x")
    client.inspect_raw("y")
    assert len(t.requests) == 2
    assert t.requests[0]["op"] == "inspect"
    assert t.requests[1]["op"] == "inspect"


def test_inspect_typed_tolerates_unknown_kind() -> None:
    """G2 at the seam: a wire response with a kind value the SDK
    doesn't yet know about doesn't crash — it arrives as
    ``Kind.UNKNOWN`` with the raw string preserved (SDK-11)."""
    from mgf.hrb import Kind

    client, _ = _make_client(
        [
            {
                "ok": True,
                "resource": {
                    "urn": "hw:/future/widget0",
                    "family": "claimable",
                    "kind": "some_new_kind",  # not yet in the enum
                    "fidelity": "live",
                    "attrs": {},
                },
            }
        ]
    )
    detail = client.inspect("hw:/future/widget0")
    # Honest, not merely non-crashing: UNKNOWN, plus the raw string.
    assert detail.kind is Kind.UNKNOWN
    assert detail.kind_raw == "some_new_kind"
    # A known family alongside an unknown kind stays known, and
    # carries no raw (nothing fell back).
    assert detail.family_raw is None
    # urn still surfaces — the consumer can at least RENDER the
    # unknown resource even when it can't classify it.
    assert detail.urn == "hw:/future/widget0"


# ---- FEEDBACK SDK-01: three-layer event API ------------------------------


def test_monitor_returns_sync_generator() -> None:
    """Layer 1 — sync iterator. The shape pins so consumers can rely on
    it being a generator (close() works, etc.).

    No server: since B3 the connection opens on the FIRST drain, not at
    the call, so the shape is observable without a broker. The socket
    path still has to exist as a string — the surface refuses to build a
    monitor it could never connect."""
    import inspect as _inspect

    client, _ = _make_client([], socket_path="/nonexistent/mgf-hrb.sock")
    gen = client.monitor("hw:/**")
    try:
        assert _inspect.isgenerator(gen)
    finally:
        gen.close()


def test_monitor_refuses_a_client_that_has_no_socket_path() -> None:
    """The other half of the same fact, and the one a consumer hits:
    every monitor shape needs its own connection, so a `Client` built
    over a bare transport cannot have one. Refused at the call with a
    message that names the fix."""
    client, _ = _make_client([])
    for surface in (client.monitor, client.amonitor, client.monitor_fd):
        with pytest.raises(ValueError, match="socket_path"):
            surface("hw:/**")


def test_amonitor_returns_async_generator() -> None:
    """Layer 2 — async iterator. Constructs cleanly without blocking
    the loop; aclose() tears down the underlying poller. SDK-01."""
    import asyncio as _asyncio
    import inspect as _inspect

    client, _ = _make_client([], socket_path="/nonexistent/mgf-hrb.sock")

    async def _run() -> None:
        agen = client.amonitor("hw:/**")
        assert _inspect.isasyncgen(agen)
        await agen.aclose()

    _asyncio.run(_run())


def test_amonitor_yields_events_from_drain() -> None:
    """Layer 2 — verifies the async iterator yields scripted events
    from its own subscription, in arrival order."""
    import asyncio as _asyncio

    from tests.unit.mgf.hrb._monitor_fake import FakeEventBroker

    broker = FakeEventBroker(
        [
            [
                {"kind": "added", "urn": "hw:/test/dev0"},
                {"kind": "removed", "urn": "hw:/test/dev1"},
            ]
        ]
    )

    async def _run(client: Client) -> list[dict[str, Any]]:
        agen = client.amonitor("hw:/**", wait_ms=100)
        out: list[dict[str, Any]] = []
        for _ in range(2):
            out.append(await agen.__anext__())
        await agen.aclose()
        return out

    try:
        client = Client.connect(broker.path)
        try:
            events = _asyncio.run(_run(client))
        finally:
            client.close()
    finally:
        broker.stop()

    assert events[0]["kind"] == "added"
    assert events[1]["urn"] == "hw:/test/dev1"


def test_transport_call_holds_lock_across_send_recv() -> None:
    """Pin: the transport lock serializes wire access — two threads
    calling Client.list concurrently must not interleave their
    requests on the wire. (The lock is what makes amonitor and
    monitor_fd safe to share the main transport.)"""
    # Use the real _Transport (not _FakeTransport) over a paired
    # socketpair so we can prove the lock actually serializes.
    import socket as _socket
    import threading as _threading

    a, b = _socket.socketpair(_socket.AF_UNIX, _socket.SOCK_STREAM)
    try:
        from mgf.hrb._client import _Transport

        transport = _Transport(a)

        # Server side: read one line at a time, respond, then read
        # the next. If the client lock leaks, two requests arrive
        # back-to-back without responses in between and we see them
        # both before sending any.
        received: list[bytes] = []
        stop = _threading.Event()

        def server() -> None:
            buf = b""
            while not stop.is_set():
                try:
                    chunk = b.recv(4096)
                except OSError:
                    return
                if not chunk:
                    return
                buf += chunk
                while b"\n" in buf:
                    line, buf = buf.split(b"\n", 1)
                    received.append(line)
                    b.sendall(b'{"ok":true}\n')

        t = _threading.Thread(target=server, daemon=True)
        t.start()

        def caller(tag: str) -> None:
            for _ in range(20):
                transport.call(op="list", scope=tag)

        threads = [
            _threading.Thread(target=caller, args=("hw:/a/**",)),
            _threading.Thread(target=caller, args=("hw:/b/**",)),
        ]
        for th in threads:
            th.start()
        for th in threads:
            th.join(timeout=5)
        stop.set()

        # Each thread issued 20 requests, so 40 total. Lock proves
        # no garbled interleaving: each line is a complete request.
        assert len(received) == 40
        # Every line parses as a valid JSON request — proves no
        # send-mid-line interleaving.
        import json as _json

        for line in received:
            req = _json.loads(line)
            assert req["op"] == "list"
            assert req["scope"] in {"hw:/a/**", "hw:/b/**"}
    finally:
        a.close()
        b.close()


def test_monitor_fd_smoke_via_socketpair(tmp_path: Path) -> None:
    """Layer 3 — monitor_fd integration smoke. Spins a fake broker on
    a real Unix socket (so the SDK can `Client.connect` against it +
    open the dedicated monitor connection the multi-client broker
    now supports), verifies the pipe-fd → polling-thread → JSON-line
    pipeline end-to-end."""
    import json as _json
    import os
    import socket as _socket
    import threading as _threading
    import time as _time
    from contextlib import suppress as _suppress

    from mgf.hrb import Client

    sock_path = str(tmp_path / "monitor-fd.sock")
    server_sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
    server_sock.bind(sock_path)
    server_sock.listen(4)

    events_to_send = [
        {"kind": "added", "urn": "hw:/test/dev0"},
        {"kind": "removed", "urn": "hw:/test/dev0"},
    ]
    stop_server = _threading.Event()
    main_drain_seen = _threading.Event()
    accepted_socks: list[_socket.socket] = []

    def serve_one(sock: _socket.socket, *, is_monitor: bool) -> None:
        """Send hello, then echo a single drain response with events
        (only the monitor connection ever gets a drain request in
        this test). Run until the client closes its end."""
        try:
            hello = (
                _json.dumps(
                    {
                        "ok": True,
                        "hello": "mgf-hrb",
                        "cap_id": 1234567890,
                        "version": "0.1.0",
                    }
                )
                + "\n"
            )
            sock.sendall(hello.encode())
            buf = b""
            first_drain = True
            while not stop_server.is_set():
                try:
                    chunk = sock.recv(4096)
                except OSError:
                    return
                if not chunk:
                    return
                buf += chunk
                while b"\n" in buf:
                    line, buf = buf.split(b"\n", 1)
                    try:
                        req = _json.loads(line)
                    except ValueError:
                        continue
                    # Every send is best-effort: the pump long-polls back
                    # to back with no sleep between drains (M-B B3), so a
                    # reply racing the client's `close()` is routine, and
                    # an EPIPE escaping this thread surfaces as a
                    # PytestUnhandledThreadExceptionWarning against
                    # whichever test happens to run NEXT.
                    with _suppress(OSError):
                        if req["op"] == "drain_events":
                            payload = events_to_send if (is_monitor and first_drain) else []
                            if not payload:
                                # Stand in for the broker's long poll;
                                # without it this fake answers instantly
                                # and the pump spins at full CPU.
                                _time.sleep(0.02)
                            sock.sendall(
                                (_json.dumps({"ok": True, "events": payload}) + "\n").encode()
                            )
                            if first_drain:
                                first_drain = False
                                if is_monitor:
                                    main_drain_seen.set()
                        else:
                            sock.sendall(b'{"ok":true}\n')
        finally:
            sock.close()

    accept_count = [0]

    def acceptor() -> None:
        """Accept loop — first connection is the main Client, second
        is the monitor thread's dedicated connection."""
        while not stop_server.is_set():
            try:
                client_sock, _ = server_sock.accept()
            except OSError:
                return
            accepted_socks.append(client_sock)
            accept_count[0] += 1
            is_monitor = accept_count[0] >= 2  # 2nd+ accept is the monitor
            t2 = _threading.Thread(
                target=serve_one,
                args=(client_sock,),
                kwargs={"is_monitor": is_monitor},
                daemon=True,
            )
            t2.start()

    t = _threading.Thread(target=acceptor, daemon=True)
    t.start()

    try:
        client = Client.connect(sock_path)

        try:
            fd = client.monitor_fd("hw:/**", wait_ms=100)
            # The monitor's dedicated connection issues drain_events;
            # the first response carries the two events.
            ok = main_drain_seen.wait(2.0)
            if not ok:
                msg = f"accept_count={accept_count[0]}, sockets={len(accepted_socks)}"
                raise AssertionError("no drain_events seen: " + msg)
            # Read two JSON lines from the pipe.
            received: list[dict[str, Any]] = []
            buf = b""
            deadline_iters = 200  # ~2s at 10ms each
            while len(received) < 2 and deadline_iters > 0:
                try:
                    chunk = os.read(fd, 4096)
                except OSError:
                    break
                if not chunk:
                    break
                buf += chunk
                while b"\n" in buf:
                    line, buf = buf.split(b"\n", 1)
                    if line:
                        received.append(_json.loads(line))
                deadline_iters -= 1
            assert len(received) == 2, f"only got {len(received)} events"
            assert received[0]["kind"] == "added"
            assert received[1]["kind"] == "removed"

            # Double-spawn must raise.
            with pytest.raises(RuntimeError, match="already active"):
                client.monitor_fd("hw:/**")
        finally:
            stop_server.set()
            client.close()
    finally:
        server_sock.close()
        for s in accepted_socks:
            try:
                s.close()
            except OSError:
                pass


# ---- R-T1-11C: enumeration ops forward the connection-bound cap_id -------
#
# The broker mechanism for capability-filtered enumeration shipped in
# R-T1-11A/B/D — every enum op (`list`, `list_leases`, `dump_all`,
# `inspect`, `inspect_full`, `dump_chain`) honours a `cap_id` on the wire
# (cap_id=0 ⇒ legacy unfiltered; non-zero ⇒ intersect the requested scope
# with the cap's scope). R-T1-11C threads the SDK's connection-bound cap
# (from the broker hello) through these ops so the forwarding is
# consistent with claim/raw/revoke/audit ops, which already pass it.
#
# Today every client holds the ROOT cap (scope hw:/**), so the
# intersection is the request itself and nothing narrows — these pins
# therefore assert the FIELD reaches the wire (not behavioural
# narrowing). The narrowing itself is pinned at the broker layer
# (facade_test.zig capPermitsNaming + the wire contract suites). The
# end-to-end narrowing through a REAL attenuated grant can't be exercised
# from Python yet — there is no wire op to mint an attenuated sub-cap
# (per-client attenuation is U-future, server.zig §"Per-client cap
# attenuation"). That gap is tracked in ROADMAP.md as R-CAP-ATTEN-WIRE.


def _make_client_with_cap(
    cap_id: int, responses: list[dict[str, Any]]
) -> tuple[Client, _FakeTransport]:
    t = _FakeTransport(responses)
    return Client(t, cap_id=cap_id, client_id=1), t  # type: ignore[arg-type]


def test_list_forwards_connection_cap_id() -> None:
    client, t = _make_client_with_cap(0xABCD, [{"ok": True, "resources": []}])
    client.list("hw:/**")
    assert t.requests[0]["op"] == "list"
    assert t.requests[0]["cap_id"] == 0xABCD


def test_list_leases_forwards_connection_cap_id() -> None:
    client, t = _make_client_with_cap(0xABCD, [{"ok": True, "leases": []}])
    client.list_leases("hw:/**")
    assert t.requests[0]["op"] == "list_leases"
    assert t.requests[0]["cap_id"] == 0xABCD


def test_dump_all_forwards_connection_cap_id() -> None:
    client, t = _make_client_with_cap(0xABCD, [{"ok": True, "items": []}])
    client.dump_all("hw:/**")
    assert t.requests[0]["op"] == "dump_all"
    assert t.requests[0]["cap_id"] == 0xABCD


def test_inspect_forwards_connection_cap_id() -> None:
    resource = {
        "urn": "hw:/test/dev0",
        "family": "claimable",
        "kind": "usb_interface",
        "fidelity": "live",
        "attrs": {},
    }
    # Both the typed projection and the raw escape hatch ride the same
    # wire op, so both must forward cap_id.
    client, t = _make_client_with_cap(
        0xABCD,
        [{"ok": True, "resource": resource}, {"ok": True, "resource": resource}],
    )
    client.inspect("hw:/test/dev0")
    client.inspect_raw("hw:/test/dev0")
    assert [r["cap_id"] for r in t.requests] == [0xABCD, 0xABCD]
    assert {r["op"] for r in t.requests} == {"inspect"}


def test_inspect_full_forwards_connection_cap_id() -> None:
    client, t = _make_client_with_cap(
        0xABCD,
        [{"ok": True, "tree": {}}, {"ok": True, "tree": {}}],
    )
    client.inspect_full("hw:/test/dev0")
    client.inspect_full_raw("hw:/test/dev0")
    assert [r["cap_id"] for r in t.requests] == [0xABCD, 0xABCD]
    assert {r["op"] for r in t.requests} == {"inspect_full"}


def test_dump_chain_forwards_connection_cap_id() -> None:
    client, t = _make_client_with_cap(
        0xABCD,
        [{"ok": True, "chain": []}, {"ok": True, "chain": []}],
    )
    client.dump_chain("hw:/test/dev0")
    client.dump_chain_raw("hw:/test/dev0")
    assert [r["cap_id"] for r in t.requests] == [0xABCD, 0xABCD]
    assert {r["op"] for r in t.requests} == {"dump_chain"}


def test_cap_id_zero_is_forwarded_when_connection_has_no_cap() -> None:
    """A Client constructed with cap_id=0 (test fakes, a future
    no-cap broker) forwards cap_id=0 — the documented sentinel for
    'no cap presented' ⇒ broker runs the legacy unfiltered path. The
    SDK doesn't special-case 0; it forwards whatever the hello gave."""
    client, t = _make_client_with_cap(0, [{"ok": True, "resources": []}])
    client.list("hw:/**")
    assert t.requests[0]["cap_id"] == 0


# ---- R-CAP-ATTEN-WIRE: Client.attenuate -> child view -------------------


def test_attenuate_sends_parent_cap_and_narrowings() -> None:
    client, t = _make_client_with_cap(0xABCD, [{"ok": True, "cap_id": 0x1234}])
    child = client.attenuate(scope="hw:/usb/**", rights=["observe"], mode_ceiling="observe")
    req = t.requests[0]
    assert req["op"] == "attenuate"
    assert req["cap_id"] == 0xABCD  # parent = this connection's cap
    assert req["scope"] == "hw:/usb/**"
    assert req["rights"] == ["observe"]
    assert req["mode_ceiling"] == "observe"
    # The child is a Client presenting the sub-cap, sharing the transport.
    assert isinstance(child, Client)
    assert child._cap == 0x1234
    assert child._t is t
    assert child._owns_transport is False


def test_attenuate_child_ops_present_the_sub_cap() -> None:
    client, t = _make_client_with_cap(0xABCD, [{"ok": True, "cap_id": 0x1234}])
    child = client.attenuate(scope="hw:/test/**")
    # Omitted axes are not sent (inherited from the parent).
    assert "rights" not in t.requests[0]
    assert "mode_ceiling" not in t.requests[0]
    t.responses.append({"ok": True, "resources": []})
    child.list("hw:/**")
    assert t.requests[1]["op"] == "list"
    assert t.requests[1]["cap_id"] == 0x1234  # the sub-cap, not the root


def test_attenuate_accepts_claim_mode_enum_for_ceiling() -> None:
    client, t = _make_client_with_cap(1, [{"ok": True, "cap_id": 2}])
    client.attenuate(mode_ceiling=ClaimMode.OBSERVE)
    assert t.requests[0]["mode_ceiling"] == "observe"


def test_attenuated_child_close_leaves_shared_transport_open() -> None:
    client, t = _make_client_with_cap(1, [{"ok": True, "cap_id": 2}])
    child = client.attenuate(scope="hw:/test/**")
    child.close()
    assert t.closed is False, "closing a child view must NOT close the shared socket"
    client.close()
    assert t.closed is True, "the owning client closes the socket"


# ---- R-T1-12B: Client.recover (reset-escalation ladder) -----------------


def test_recover_sends_cap_and_returns_outcome() -> None:
    client, t = _make_client_with_cap(0xABCD, [{"ok": True, "outcome": "recovered_flr"}])
    outcome = client.recover("hw:/test/dev0", deadline_ms=500)
    req = t.requests[0]
    assert req["op"] == "recover"
    assert req["urn"] == "hw:/test/dev0"
    assert req["cap_id"] == 0xABCD  # steal-gated; presents the connection cap
    assert req["deadline_ms"] == 500
    assert outcome == "recovered_flr"


def test_recover_noop_outcome() -> None:
    client, _ = _make_client_with_cap(1, [{"ok": True, "outcome": "not_quarantined"}])
    assert client.recover("hw:/test/dev0") == "not_quarantined"
