"""B5 unit tests — device leases + THE ATTACH TRANSACTION through the SDK.

Drives ``VmManagerClient`` over ``InProcessTransport`` pointed at
``FakeDeviceBackend`` + ``FakeHypervisorBackend``. Covers:

* bare lease lifecycle (claim / release / transfer) + reads + caps;
* the full attach transaction (claim → assignment → attach → record);
* **the rollback path** — attach fails → the lease is released (the broker
  safe-resets) and the original error re-raises;
* **the detach-fails-no-release path** — hypervisor detach fails → the lease
  is NOT released and a ``DeviceDetachFailed`` (needs-human) re-raises;
* idempotency + typed errors (``DeviceContended`` / ``LeaseNotFound``).

NOTE: no real ``DeviceBackend`` exists yet (mgf-hrb is deferred — D-B4), so
these are fakes-only. There is no device integration test.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest
from mgf.vm.core.contracts import DeviceAssignment, VmSpec
from mgf.vm.core.exceptions import (
    DeviceAttachFailed,
    DeviceContended,
    DeviceDetachFailed,
    LeaseNotFound,
    TransportError,
)
from mgf.vm.core.fakes.device import FakeDeviceBackend
from mgf.vm.core.fakes.hypervisor import FakeHypervisorBackend
from mgf.vm.core.ids import DeviceId, LeaseId, VmId

from mgf.vm.sdk.client import VmManagerClient
from mgf.vm.sdk.transport.inprocess import InProcessTransport

if TYPE_CHECKING:
    from mgf.vm.core.ids import DomainId

_DEV = DeviceId("dev:pci:0000:01:00.0")


def _hv() -> FakeHypervisorBackend:
    return FakeHypervisorBackend()


def _dev() -> FakeDeviceBackend:
    return FakeDeviceBackend.with_default_devices()


def _client(
    hv: FakeHypervisorBackend | None = None,
    dev: FakeDeviceBackend | None = None,
) -> VmManagerClient:
    return VmManagerClient(InProcessTransport.from_adapters(hv or _hv(), None, dev or _dev()))


def _make_vm(client: VmManagerClient, name: str = "dev-vm") -> VmId:
    info = client.create_vm(VmSpec(name=name, cpu=2, memory_bytes=2 << 30))
    return info.vm_id


# -- bare lease lifecycle --------------------------------------------------


def test_claim_release_round_trip() -> None:
    dev = _dev()
    client = _client(dev=dev)
    lease = client.claim_device(_DEV)
    assert isinstance(lease, LeaseId)
    assert any(li.device_id == _DEV for li in client.list_leases())

    client.release_device(lease)
    assert client.list_leases() == []


def test_claim_idempotent_same_client_same_device() -> None:
    client = _client()
    assert client.claim_device(_DEV) == client.claim_device(_DEV)


def test_claim_contended_raises() -> None:
    # Our backend ("me") shares its lease tables with a foreign claimant that
    # grabs the device first → our claim sees an incompatible foreign lease.
    dev = FakeDeviceBackend.with_default_devices(claimant="me")
    other = FakeDeviceBackend.with_default_devices(claimant="someone-else")
    other._device_lease = dev._device_lease
    other._leases = dev._leases
    other.claim(_DEV)  # foreign lease lands in the shared tables

    client = _client(dev=dev)
    with pytest.raises(DeviceContended):
        client.claim_device(_DEV)


def test_release_idempotent() -> None:
    client = _client()
    client.release_device(LeaseId("never"))  # already-gone is success


def test_transfer_lease_rotates_identity() -> None:
    client = _client()
    lease = client.claim_device(_DEV)
    new_lease = client.transfer_lease(lease, "host-2")
    assert isinstance(new_lease, LeaseId)
    assert new_lease != lease


def test_transfer_unknown_lease_raises() -> None:
    client = _client()
    with pytest.raises(LeaseNotFound):
        client.transfer_lease(LeaseId("nope"), "host-2")


# -- reads + caps ----------------------------------------------------------


def test_device_reads_and_capabilities() -> None:
    client = _client()
    assert client.get_device(_DEV).device_id == _DEV
    assert any(d.device_id == _DEV for d in client.list_devices())
    caps = client.device_capabilities()
    assert caps.backend == "fake-hrb"


def test_device_op_without_backend_fails() -> None:
    client = VmManagerClient(InProcessTransport.from_adapters(_hv()))
    with pytest.raises(TransportError):
        client.claim_device(_DEV)
    with pytest.raises(TransportError):
        client.list_devices()


# -- THE ATTACH TRANSACTION: happy path ------------------------------------


def test_attach_transaction_claims_attaches_and_holds_lease() -> None:
    hv = _hv()
    dev = _dev()
    client = _client(hv, dev)
    vm_id = _make_vm(client)

    client.attach_device(vm_id, _DEV)

    # Lease is held (NOT released on success) and the device is plugged in.
    assert any(li.device_id == _DEV for li in client.list_leases())
    record = hv.state[_domain(vm_id)]
    assert any(a.device_id == _DEV for a in record.attached_devices)


def test_detach_transaction_unplugs_then_releases() -> None:
    hv = _hv()
    dev = _dev()
    client = _client(hv, dev)
    vm_id = _make_vm(client)
    client.attach_device(vm_id, _DEV)

    client.detach_device(vm_id, _DEV)

    assert client.list_leases() == []  # lease released after hypervisor detach
    record = hv.state[_domain(vm_id)]
    assert not any(a.device_id == _DEV for a in record.attached_devices)


# -- THE ATTACH TRANSACTION: rollback path ---------------------------------


class _AttachFailsHv(FakeHypervisorBackend):
    """Hypervisor whose ``attach_device`` always fails — drives rollback."""

    def attach_device(
        self,
        d: DomainId,
        assignment: DeviceAssignment,
        *,
        live: bool = True,
    ) -> None:
        raise DeviceAttachFailed(f"simulated attach failure for {assignment.device_id}")


def test_attach_failure_rolls_back_lease_and_reraises() -> None:
    hv = _AttachFailsHv()
    dev = _dev()
    client = _client(hv, dev)
    vm_id = _make_vm(client)

    with pytest.raises(DeviceAttachFailed):
        client.attach_device(vm_id, _DEV)

    # ROLLBACK: the lease the transaction claimed was released (broker
    # safe-resets), so no lease leaks.
    assert client.list_leases() == []
    # And nothing was recorded — a later detach is a no-op, not a ghost release.
    assert dev.list_leased() == []


# -- THE ATTACH TRANSACTION: detach-fails-no-release path -------------------


class _DetachFailsHv(FakeHypervisorBackend):
    """Hypervisor whose ``detach_device`` always fails — drives needs-human."""

    def detach_device(
        self,
        d: DomainId,
        assignment: DeviceAssignment,
        *,
        live: bool = True,
    ) -> None:
        raise DeviceDetachFailed(f"simulated detach failure for {assignment.device_id}")


def test_detach_failure_keeps_lease_and_raises_needs_human() -> None:
    hv = _DetachFailsHv()
    dev = _dev()
    client = _client(hv, dev)
    vm_id = _make_vm(client)
    client.attach_device(vm_id, _DEV)
    assert any(li.device_id == _DEV for li in client.list_leases())

    with pytest.raises(DeviceDetachFailed):
        client.detach_device(vm_id, _DEV)

    # The lease is NOT released — releasing would ghost a device still
    # presented to the guest (needs-human; contracts.md §5).
    assert any(li.device_id == _DEV for li in client.list_leases())


def test_detach_with_no_recorded_lease_is_noop() -> None:
    # Detaching a device never attached through us is a no-op (idempotent).
    client = _client()
    vm_id = _make_vm(client)
    client.detach_device(vm_id, _DEV)  # no raise, no lease churn
    assert client.list_leases() == []


class _DetachRaisesGenericHv(FakeHypervisorBackend):
    """Hypervisor whose ``detach_device`` raises a NON-detach typed error.

    Exercises the wrap path: any hypervisor failure on detach is surfaced as
    ``DeviceDetachFailed`` (needs-human), not the raw error.
    """

    def detach_device(
        self,
        d: DomainId,
        assignment: DeviceAssignment,
        *,
        live: bool = True,
    ) -> None:
        from mgf.vm.core.exceptions import HypervisorTransientError

        raise HypervisorTransientError("engine hiccup mid-detach")


def test_detach_generic_failure_wrapped_as_needs_human() -> None:
    hv = _DetachRaisesGenericHv()
    client = _client(hv, _dev())
    vm_id = _make_vm(client)
    client.attach_device(vm_id, _DEV)
    with pytest.raises(DeviceDetachFailed):
        client.detach_device(vm_id, _DEV)
    # Lease still held — not ghosted.
    assert any(li.device_id == _DEV for li in client.list_leases())


class _ReleaseFailsDev(FakeDeviceBackend):
    """Device backend whose ``release`` raises — tests rollback best-effort.

    The attach rollback must not let a release failure mask the original
    attach error.
    """

    def release(self, lease: LeaseId) -> None:
        from mgf.vm.core.exceptions import BrokerTransientError

        raise BrokerTransientError("broker unreachable during safe-reset")


def test_attach_rollback_swallows_release_failure_and_reraises_original() -> None:
    hv = _AttachFailsHv()
    dev = _ReleaseFailsDev.with_default_devices()
    client = _client(hv, dev)
    vm_id = _make_vm(client)
    # The ORIGINAL attach error wins; the failed rollback release is swallowed.
    with pytest.raises(DeviceAttachFailed):
        client.attach_device(vm_id, _DEV)


# -- helpers ---------------------------------------------------------------


def _domain(vm_id: VmId) -> DomainId:
    from mgf.vm.core.ids import DomainId

    return DomainId(vm_id.value)
