"""Contract: the `revoke` wire op (Tier-0 #4, survivability §3.4).

The wire surface:
  * `{op:"revoke", urn:"...", cap_id:N, deadline_ms:N?}`
  * Success: `{ok:true}`.
  * Errors: NotPermitted (no/wrong cap), NotFound (unrouted URN),
    Unsupported (backend has no claim to tear down — fake; PCI
    without VFIO), BadRequest (missing/malformed fields).
  * Cap-gated by `steal` (separate from `claim_exclusive`).

The SUCCESS path's full semantics (lease moves to revoked +
client's dup'd fd returns ENODEV) need a real held USB claim,
which requires root in CI. The contract test covers the WIRE
surface + the cap gate; the lease-state-machine semantics are
Zig-side unit-tested in `core_test.zig`.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

from mgf.hrb import (
    Client,
    NotPermittedError,
    UnsupportedError,
)
from tests import _broker
from tests.contract.conftest import _SOCK_PATH, rt

if TYPE_CHECKING:
    # `socket` is only used in the type annotations of the raw_broker_with_cap
    # fixture parameter; runtime use lives in tests/contract/conftest.py.
    import socket

pytestmark = pytest.mark.contract


# ---------------------------------------------------------------------------
# Cap gate.
# ---------------------------------------------------------------------------


def test_revoke_without_cap_rejects(
    raw_broker_with_cap: tuple[socket.socket, int],
) -> None:
    sock, _ = raw_broker_with_cap
    r = rt(sock, {"op": "revoke", "urn": "hw:/usb/bus3/port-9/dev/if1"})
    assert r.get("ok") is False
    assert r.get("err") == "NotPermitted"


def test_revoke_wrong_cap_rejects(
    raw_broker_with_cap: tuple[socket.socket, int],
) -> None:
    sock, _ = raw_broker_with_cap
    r = rt(sock, {"op": "revoke", "urn": "hw:/usb/bus3/port-9/dev/if1", "cap_id": 42})
    assert r.get("ok") is False
    assert r.get("err") == "NotPermitted"


# ---------------------------------------------------------------------------
# Routing + per-backend support.
# ---------------------------------------------------------------------------


def test_revoke_fake_backend_idempotent_when_unclaimed(
    raw_broker_with_cap: tuple[socket.socket, int],
) -> None:
    """The fake backend implements revoke as "mark the slot free
    + bump safe-reset" (no real kernel resource to tear down).
    On an unclaimed URN this is an idempotent no-op success —
    matches the contract for revoke against any URN whose
    backend has nothing held for it. The success-path lease-
    revocation E2E lives in `tests/e2e/test_revoke_lifecycle.py`."""
    sock, cap_id = raw_broker_with_cap
    r = rt(sock, {"op": "revoke", "urn": "hw:/test/dev0", "cap_id": cap_id})
    assert r.get("ok") is True


def test_revoke_unknown_urn_is_not_found(
    raw_broker_with_cap: tuple[socket.socket, int],
) -> None:
    sock, cap_id = raw_broker_with_cap
    r = rt(sock, {"op": "revoke", "urn": "hw:/never", "cap_id": cap_id})
    assert r.get("ok") is False
    assert r.get("err") == "NotFound"


def test_revoke_usb_with_no_held_claim_is_idempotent(
    raw_broker_with_cap: tuple[socket.socket, int],
) -> None:
    """Idempotent: revoke on a URN that has no held claim returns
    OK. The post-condition ("nobody holds this URN") already
    holds; honest emptiness."""
    sock, cap_id = raw_broker_with_cap
    # Pick a real USB urn from list.
    listing = rt(sock, {"op": "list", "scope": "hw:/usb/**"})
    if not listing["resources"]:
        pytest.skip("no real USB devices on this host")
    r = rt(sock, {"op": "revoke", "urn": listing["resources"][0], "cap_id": cap_id})
    assert r.get("ok") is True


def test_revoke_pci_unheld_device_is_idempotent_ok(
    raw_broker_with_cap: tuple[socket.socket, int],
) -> None:
    """The PCI VFIO revoke path is now wired (R5): revoke is the
    forced 'tear down below the fd'. Revoking a device the broker
    does NOT currently hold (nothing is VFIO-bound on this host —
    binding needs root + a passthrough device) is an idempotent
    success: the post-condition 'the broker no longer holds it'
    already holds, so OK — not Unsupported. The real teardown
    (close the VFIO fds, reattach the host driver) runs only when
    a device is actually bound, which is hardware-gated."""
    sock, cap_id = raw_broker_with_cap
    listing = rt(sock, {"op": "list", "scope": "hw:/pci/**"})
    if not listing["resources"]:
        pytest.skip("no real PCI devices on this host")
    r = rt(sock, {"op": "revoke", "urn": listing["resources"][0], "cap_id": cap_id})
    assert r.get("ok") is True


# ---------------------------------------------------------------------------
# Bad-request shapes.
# ---------------------------------------------------------------------------


def test_revoke_missing_urn_is_bad_request(
    raw_broker_with_cap: tuple[socket.socket, int],
) -> None:
    sock, cap_id = raw_broker_with_cap
    r = rt(sock, {"op": "revoke", "cap_id": cap_id})
    assert r.get("ok") is False
    assert r.get("err") == "BadRequest"


def test_revoke_non_integer_deadline_rejects(
    raw_broker_with_cap: tuple[socket.socket, int],
) -> None:
    sock, cap_id = raw_broker_with_cap
    r = rt(sock, {"op": "revoke", "urn": "hw:/test/dev0", "cap_id": cap_id, "deadline_ms": "soon"})
    assert r.get("ok") is False
    assert r.get("err") == "BadRequest"


# ---------------------------------------------------------------------------
# Audit-ring integration: revoke records itself.
# ---------------------------------------------------------------------------


def test_revoke_appears_in_audit_tail(
    raw_broker_with_cap: tuple[socket.socket, int],
) -> None:
    """A revoke op records into the audit ring with its URN +
    cap_id + outcome. Catches a regression where the new op
    skipped the audit-record path. Fake backend now implements
    revoke as no-op-success against an unclaimed URN (E1, commit
    closing the audit-coverage gap), so outcome=ok with no err
    field. The audit visibility itself is what this test pins."""
    sock, cap_id = raw_broker_with_cap
    rt(sock, {"op": "revoke", "urn": "hw:/test/dev0", "cap_id": cap_id})
    r = rt(sock, {"op": "audit_tail", "n": 5, "cap_id": cap_id})
    revoke_recs = [
        rec for rec in r["records"] if rec["op"] == "revoke" and rec.get("urn") == "hw:/test/dev0"
    ]
    assert len(revoke_recs) > 0
    assert revoke_recs[0]["cap_id"] == cap_id
    assert revoke_recs[0]["outcome"] == "ok"


# ---------------------------------------------------------------------------
# SDK projection.
# ---------------------------------------------------------------------------


def test_sdk_revoke_propagates_typed_errors() -> None:
    """`Client.revoke()` raises the typed exception family on
    each error path. Catches a regression where the new method
    bypasses `_t.call`'s error-projection layer."""
    sock_path = _SOCK_PATH
    proc = _broker.spawn_broker(sock_path)
    try:
        with Client.connect(sock_path) as c:
            # Fake backend now implements revoke as no-op-success
            # when the URN is unclaimed (E1, 2026-05-26).
            c.revoke("hw:/test/dev0")  # no exception
            # USB with no held claim → silent OK (idempotent).
            usb_urns = c.list("hw:/usb/**")
            if usb_urns:
                c.revoke(usb_urns[0])  # no exception
    finally:
        _broker.teardown_broker(proc, sock_path)


def test_sdk_revoke_without_audit_read_still_works() -> None:
    """The SDK forwards `self._cap` automatically (root cap from
    the hello carries `steal`). This test pins that the
    forwarding works — a regression where the SDK forgets to
    pass cap_id would surface as NotPermitted."""
    sock_path = _SOCK_PATH
    proc = _broker.spawn_broker(sock_path)
    try:
        with Client.connect(sock_path) as c:
            # Fake backend's revoke (E1) returns success when the
            # URN is unclaimed. Pre-E1 it returned Unsupported.
            # The pin here is that the cap IS forwarded — without
            # forwarding we'd see NotPermittedError instead of
            # success (cap_id=0 carries no rights).
            c.revoke("hw:/test/dev0")  # no exception → cap forwarded
            # Sanity: NotPermittedError IS distinct from
            # UnsupportedError in the exception hierarchy (the
            # original cap-forwarding-failure shape).
            assert not issubclass(UnsupportedError, NotPermittedError)
    finally:
        _broker.teardown_broker(proc, sock_path)
