"""E2E: USB-02 Slice B happy path (R1 closure).

Root-gated + hardware-gated. Skips honestly when:
  * Not running as root (USBDEVFS open returns EACCES).
  * The usbdevfs device nodes (`/dev/bus/usb/...`) are absent —
    i.e. this host exposes no USB hardware to us. See
    `_skip_unless_usb_claimable`; this is the condition that kept
    `main` red for weeks (FEEDBACK `CI-01`).
  * No USB devices on the host.
  * Every USB device has a driver bound + the test isn't
    invoked with the explicit `--detach-test-device` env flag.

The error paths (NotPermitted on EACCES, NotFound on unrouted
URN, BadRequest on legacy field names) are pinned in
`tests/contract/test_r1_usb_claim_paths.py` — those don't need
root. This file covers the part of R1 that CAN'T be tested
without privileges: a real USBDEVFS open + the SCM_RIGHTS fd
hand-off via `lease.raw()`.

The Zig backing (`hrb-code/src/linux_usb.zig` lines 1055-1535)
implements Phases U3a end-to-end: `vAcquire` does the
USBDEVFS_CLAIMINTERFACE; `vRawChannel` returns the fd;
`vFdForToken` plumbs the fd through SCM_RIGHTS; `vRelease`
issues USBDEVFS_RELEASEINTERFACE + close.

If you DO run as root + have a driver-free test device, this
test exercises the full claim → raw → release lifecycle.
"""

from __future__ import annotations

import os
from pathlib import Path
from typing import TYPE_CHECKING

import pytest

from mgf.hrb import ClaimMode
from mgf.hrb.exceptions import (
    ConflictError,
    NotPermittedError,
)

if TYPE_CHECKING:
    from collections.abc import Iterator

    from mgf.hrb import Client

pytestmark = pytest.mark.e2e

#: The usbdevfs tree. This — not euid — is the thing a USB claim
#: actually needs: the broker's `vAcquire` opens
#: `/dev/bus/usb/<bus:03d>/<devnum:03d>` and ioctls
#: USBDEVFS_CLAIMINTERFACE on the resulting fd.
_USBDEVFS_ROOT = Path("/dev/bus/usb")

#: Where the kernel publishes USB topology. Note this is a SEPARATE
#: filesystem from the one above, and the two can disagree — which is
#: the whole point of CI-01 (see `_skip_unless_usb_claimable`).
_SYSFS_USB_DEVICES = Path("/sys/bus/usb/devices")


def _skip_unless_usb_claimable() -> None:
    """Skip unless this host can actually perform a USB claim.

    Two independent preconditions, and *both* must hold:

    1. **root** — USBDEVFS_CLAIMINTERFACE on a node we don't own is
       EACCES.
    2. **the usbdevfs device nodes exist** — `/dev/bus/usb/...`.

    Precondition 2 used to be missing, and that is exactly why
    `main` was red for weeks (FEEDBACK `CI-01`). A CI container is
    the case that breaks the "root implies USB hardware" assumption
    in both directions at once:

      * it runs as **root**, so the euid check passed;
      * it bind-mounts the host's **`/sys`**, so
        `broker.list("hw:/usb/**")` happily returned real URNs for
        the host's real USB devices;
      * but it has **no `/dev/bus/usb`** — no device nodes are
        mapped in.

    So the test sailed past its guard, picked a device it could see
    but could not open, and the claim came back `NotFoundError` —
    which reads like a broker bug and is really "there is no USB
    hardware here". Gate on the resource we need, never on a proxy
    for it.
    """
    if os.geteuid() != 0:
        pytest.skip("USB claim requires root (USBDEVFS open returns EACCES)")
    if not _USBDEVFS_ROOT.is_dir():
        pytest.skip(
            f"no usbdevfs device nodes at {_USBDEVFS_ROOT}: this host exposes no "
            "USB hardware to us (typical of a CI container, which sees the host's "
            "/sys/bus/usb but gets no /dev/bus/usb). Run on real hardware, or pass "
            "`--device /dev/bus/usb` / `-v /dev/bus/usb:/dev/bus/usb` to the container."
        )


def _usb_interface_candidates(broker: Client) -> Iterator[tuple[str, Path]]:
    """Yield `(urn, sysfs_interface_dir)` for every USB interface the
    broker advertises that this host could really claim.

    A candidate must clear three bars: the URN parses, its sysfs
    interface dir exists, and — the bar CI-01 added — **the usbdevfs
    node the broker will open exists**. Without that last check a
    container yields candidates whose claim is guaranteed to fail
    with `NotFoundError`.

    Both tests below filter this stream on the `driver` symlink, which
    is the one axis they actually differ on.
    """
    for urn in broker.list("hw:/usb/**"):
        # URN: hw:/usb/bus<N>/port-<...>/dev/if<K>
        # sysfs device dir:    /sys/bus/usb/devices/<bus>-<chain>
        # sysfs interface dir: <device dir>:<config>.<iface>
        parts = urn.split("/")
        if len(parts) < 6:
            continue
        try:
            bus = int(parts[2][3:])
            chain_parts = [int(p[5:]) for p in parts if p.startswith("port-")]
            iface = int(parts[-1][2:])
        except (ValueError, IndexError):
            continue
        if not chain_parts or chain_parts == [0]:
            continue  # root hub, skip
        chain = ".".join(str(p) for p in chain_parts)
        device_dir = _SYSFS_USB_DEVICES / f"{bus}-{chain}"
        iface_dir = _SYSFS_USB_DEVICES / f"{bus}-{chain}:1.{iface}"  # config 1, iface K
        if not iface_dir.exists():
            continue
        # The node `vAcquire` opens. sysfs says the device is there;
        # only devfs says we can actually open it.
        try:
            devnum = int((device_dir / "devnum").read_text().strip())
        except (OSError, ValueError):
            continue
        if not (_USBDEVFS_ROOT / f"{bus:03d}" / f"{devnum:03d}").exists():
            continue
        yield urn, iface_dir


def _find_driver_free_usb_interface(broker: Client) -> str | None:
    """Pick a USB interface URN whose kernel driver is unbound.
    A claim against a driver-bound interface returns EBUSY →
    `ConflictError` — that's tested separately. The happy path
    needs an interface where the kernel hasn't already opened it."""
    return next(
        (
            urn
            for urn, iface_dir in _usb_interface_candidates(broker)
            if not (iface_dir / "driver").exists()
        ),
        None,
    )


def _find_driver_bound_usb_interface(broker: Client) -> str | None:
    """Pick a USB interface URN that DOES have a kernel driver bound
    — the input to the "claim must be refused, not stolen" pin."""
    return next(
        (
            urn
            for urn, iface_dir in _usb_interface_candidates(broker)
            if (iface_dir / "driver").exists()
        ),
        None,
    )


def test_claim_then_release_against_driver_free_usb_device(
    broker: Client,
) -> None:
    """The R1 happy path: a real USB interface with no driver
    bound is claimable; release tears down cleanly."""
    _skip_unless_usb_claimable()
    urn = _find_driver_free_usb_interface(broker)
    if urn is None:
        pytest.skip(
            "no driver-free USB interface on this host; "
            "claim against a driver-bound interface needs "
            "detach=True which would steal a kernel device — "
            "test author refuses to do that automatically"
        )
    # Use a context-managed lease so release runs in the
    # `__exit__` even on assertion failure.
    with broker.claim(urn, ClaimMode.EXCLUSIVE) as lease:
        assert lease.lease_id > 0, f"claim returned bogus lease: {lease}"
        # The raw channel: USBDEVFS fd via SCM_RIGHTS.
        ch = lease.raw()
        assert "fd" in ch, f"lease.raw() should carry fd; got {ch!r}"
        assert isinstance(ch["fd"], int), (
            f"fd should be int (SCM_RIGHTS-passed); got {type(ch['fd'])}"
        )
        assert ch["fd"] >= 0, f"invalid fd: {ch['fd']}"
        # Close the fd ourselves — the SDK transfers ownership.
        os.close(ch["fd"])
    # The `with` block's exit released the lease; subsequent
    # raw() against the now-stale lease would fail. Don't pin
    # that here (lease lifecycle has its own pins); this test's
    # job is "happy path round-trips end-to-end."


def test_claim_against_driver_bound_usb_is_conflict_without_detach(
    broker: Client,
) -> None:
    """A claim against a driver-bound USB interface MUST fail
    with ConflictError (the broker maps USBDEVFS_CLAIMINTERFACE's
    EBUSY → Err.Conflict). The opt-in `detach=True` path would
    succeed by atomically detaching the driver first; this test
    pins the default-safe behavior — no surprise driver theft."""
    _skip_unless_usb_claimable()
    bound_urn = _find_driver_bound_usb_interface(broker)
    if bound_urn is None:
        pytest.skip("no driver-bound USB interface on this host")
    with pytest.raises((ConflictError, NotPermittedError)):
        broker.claim(bound_urn, ClaimMode.EXCLUSIVE)
