"""Use case: claim a device for VFIO passthrough.

Cites: `recipes/claim-a-device-for-vfio-passthrough.md`.

The scenario in words (the **VManager pattern**):

  1. The host agent connects to the broker.
  2. It enumerates `hw:/pci/**` and picks a target PCI function.
  3. It calls `Client.claim(urn, ClaimMode.EXCLUSIVE)` — the
     broker atomically VFIO-binds + leases the function. On
     success the agent holds an exclusive lease.
  4. It calls `lease.raw()` to obtain the raw channel (the fd
     handed back via SCM_RIGHTS once VFIO is wired; a placeholder
     dict in the skeleton).
  5. It passes the assignment to libvirt to plug into the guest.
  6. Eventually, releasing the lease (`with` exit) detaches VFIO
     and reverts ownership.

What this test pins, end-to-end:

  * The find → claim → raw → release pattern works against a
    real broker.
  * Re-claim after release succeeds — no ghost ownership (G3)
    even under the VFIO-style "raw fd held" usage.
  * A second client's claim while the first holds an exclusive
    lease gets `ConflictError` — the broker enforces exclusivity
    end-to-end.
  * The lease appears in `list_leases` across both client
    connections (matches the VManager observability story).

The test runs against the `hw:/test/` fake worker (synthetic URNs,
host-independent). It skips with a clear message when run on a
host where VFIO is genuinely needed AND not present — but it
DOES NOT require VFIO to exercise the broker-side flow, which
is the recipe's §1-§3 contract.

The §4 event-driven release-wait (where the agent waits for an
external trigger to release) is the same `monitor`/`amonitor`
shape pinned by `test_gui_event_monitor.py` — kept separate so
each scenario fails for one reason at a time.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from mgf.hrb import ClaimMode, Client, ConflictError

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

# Synthetic PCI-shaped URNs the broker seeds for host-independent
# testing (see `hrb-code/src/server.zig`). The recipe's flow is
# `hw:/pci/<bus>:<slot>.<fn>/fn0`; the fake URNs stand in for that
# shape on hosts without real claimable PCI functions.
_TARGET_URN = "hw:/test/dev0"
_OTHER_URN = "hw:/test/dev1"


def _vfio_kernel_available() -> bool:
    """Best-effort: is the vfio-pci driver actually loaded? Used
    only by `test_real_vfio_path_when_available`. Returns False on
    laptops/CI runners that don't have VFIO."""
    return Path("/dev/vfio").exists() or Path("/sys/bus/pci/drivers/vfio-pci").exists()


def test_vmanager_pattern_find_claim_raw_release(broker: Client) -> None:
    """The full §1 pattern walked end-to-end against the broker's
    fake worker. Asserts each step's observable state — a future
    regression in any of `list`, `claim`, `raw`, or release
    semantics fails at the step that broke."""
    # Step 1: find.
    urns = broker.list("hw:/test/**")
    assert _TARGET_URN in urns, f"fake worker should seed {_TARGET_URN}; saw {urns}"

    # Step 2 + 3: claim + raw. `with` blocks own the lease — the
    # context manager releases it on exit (the VManager handoff
    # to libvirt would happen INSIDE the with block).
    with broker.claim(_TARGET_URN, ClaimMode.EXCLUSIVE) as lease:
        assert lease.urn == _TARGET_URN
        assert lease.lease_id > 0

        # The fake worker is configured with `pass_fd_for_raw=True`
        # (server.zig line ~107), so the raw call returns a dict
        # carrying the SCM_RIGHTS-passed memfd. This is the same
        # shape a real VFIO backend would return (with a vfio
        # container fd in place of the memfd).
        raw = lease.raw()
        assert isinstance(raw, dict), f"raw() returned {type(raw)!r}"
        assert "ok" in raw, f"raw() response missing 'ok' key: {raw}"

        # The lease is visible cross-broker — `list_leases` is how
        # an observer (a different VManager pane, a doctor pane)
        # would see what's held.
        leases = broker.list_leases("hw:/test/**")
        assert len(leases) == 1
        assert leases[0].lease_id == lease.lease_id

    # Step 4 (post-with): G3 — release means re-claimable now.
    assert broker.list_leases("hw:/test/**") == []
    with broker.claim(_TARGET_URN, ClaimMode.EXCLUSIVE) as release:
        assert release.urn == _TARGET_URN


def test_second_agent_claim_during_vfio_hold_fails(broker: Client, sock_path: str) -> None:
    """While the host agent holds the VFIO lease, a second agent
    (a second VManager instance, a competing tool) attempting the
    SAME claim must get `ConflictError`. Pins the exclusivity
    contract under multi-client load — the property a VFIO
    passthrough flow most depends on."""
    second = Client.connect(sock_path)
    try:
        with broker.claim(_TARGET_URN, ClaimMode.EXCLUSIVE):
            # Both agents see the lease.
            assert len(second.list_leases("hw:/test/**")) == 1

            # Second agent's claim attempt fails with the typed
            # exception VManager-style consumers handle.
            with pytest.raises(ConflictError):
                with second.claim(_TARGET_URN, ClaimMode.EXCLUSIVE):
                    pass

            # First agent's lease is still healthy — the failed
            # second attempt didn't disturb it.
            assert len(broker.list_leases("hw:/test/**")) == 1
    finally:
        second.close()


def test_two_devices_concurrent_passthrough(broker: Client, sock_path: str) -> None:
    """Two distinct PCI functions can be passed through to two
    different guests concurrently — the broker's exclusivity is
    per-URN, not global. VManager hosting multiple guests is the
    motivating case."""
    second = Client.connect(sock_path)
    try:
        with broker.claim(_TARGET_URN, ClaimMode.EXCLUSIVE) as lease_a:
            with second.claim(_OTHER_URN, ClaimMode.EXCLUSIVE) as lease_b:
                assert lease_a.urn != lease_b.urn
                # Both leases visible.
                leases = broker.list_leases("hw:/test/**")
                assert len(leases) == 2
                urns_held = {r.urn for r in leases}
                assert urns_held == {_TARGET_URN, _OTHER_URN}
    finally:
        second.close()


@pytest.mark.skipif(
    not _vfio_kernel_available(),
    reason=(
        "VFIO not available on this host (/dev/vfio missing AND "
        "vfio-pci driver not loaded). This test exercises only the "
        "skeleton's fake worker; the real-VFIO end-to-end path is "
        "hardware-gated. See `recipes/claim-a-device-for-vfio-passthrough.md` "
        "§1 for the VManager wiring against a real backend."
    ),
)
def test_real_vfio_path_when_available(broker: Client) -> None:
    """On a host with VFIO available, exercise the same find/claim/raw
    flow against `hw:/pci/**` (the LinuxBackend). Skipped on CI and
    most contributor laptops; runs locally on a host with IOMMU
    configured + a device bound to vfio-pci.

    Today (LinuxBackend mode is `read_only` when /dev/vfio is
    absent — see `linux_backend.zig`), the broker's PCI worker
    returns `Unsupported` on claim attempts even when VFIO is
    present at the kernel level; the binding step is still the
    next implementation pass. We pin the OBSERVATION half here
    (broker enumerates real PCI functions) so the wiring lands
    against a working test the day the binding flips on.
    """
    urns = broker.list("hw:/pci/**")
    # A real host will have at least one PCI function visible
    # (the root complex, if nothing else). An empty list here would
    # indicate the LinuxBackend silently degraded.
    assert len(urns) >= 1, (
        "LinuxBackend should enumerate PCI functions when /sys/bus/pci is populated; got empty list"
    )
