"""B1+B2 unit tests — ``InProcessTransport`` engine + lifecycle orchestration.

Drives the SDK against ``mgf.vm.core`` fakes (no infra). Covers:

* the create → start → stop → delete round-trip through ``VmManagerClient``;
* ``OperationHandle`` transitions (PENDING → RUNNING → SUCCEEDED) recorded in
  the registry and surfaced via ``watch_op``;
* the per-VM ``VmLifecycleStateMachine`` transitions + ``LifecycleObserver``
  firing on each;
* the error bridge (original typed exception re-raised by the client);
* cancellation (CANCELLED status, ``OperationCancelled`` re-raised);
* per-``VmId`` serialization (same VM serializes; different VMs concurrent).
"""

from __future__ import annotations

import threading
import time
from typing import TYPE_CHECKING

import pytest
from mgf.vm.core.cancellation import CancellationEvent
from mgf.vm.core.contracts import (
    OperationKind,
    OperationStatus,
    TransitionEvent,
    Vm,
    VmSpec,
    VmState,
)
from mgf.vm.core.exceptions import (
    DomainNotFound,
    DomainStateInvalid,
    GuestUnresponsive,
    OperationCancelled,
    TransportError,
)
from mgf.vm.core.fakes.hypervisor import FakeHypervisorBackend
from mgf.vm.core.ids import DomainId, OperationId, VmId

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

if TYPE_CHECKING:
    from collections.abc import Callable

    from mgf.vm.core.cancellation import CancellationToken


class _RecordingObserver:
    """A :class:`LifecycleObserver` that records every transition it sees."""

    def __init__(self) -> None:
        self.events: list[TransitionEvent] = []

    def on_transition(self, event: TransitionEvent) -> None:
        self.events.append(event)


def _spec(name: str = "vm-a", cpu: int = 2, mem_gib: int = 2) -> VmSpec:
    return VmSpec(name=name, cpu=cpu, memory_bytes=mem_gib * (1 << 30))


# -- happy-path round-trip -------------------------------------------------


def test_create_start_stop_delete_round_trip() -> None:
    backend = FakeHypervisorBackend()
    transport = InProcessTransport.from_adapters(backend)
    with VmManagerClient(transport) as client:
        vm = client.create_vm(_spec("rt"))
        # create_vm returns the canonical Vm: submitted spec + observed status.
        assert isinstance(vm, Vm)
        assert vm.spec.name == "rt"
        assert vm.status.state is VmState.STOPPED

        vm_id = vm.vm_id
        assert vm_id == VmId(vm.vm_id.value)

        client.start_vm(vm_id)
        started = client.get_vm(vm_id)
        assert isinstance(started, Vm)
        assert started.status.state is VmState.RUNNING
        # get_vm carries the remembered submitted spec.
        assert started.spec == _spec("rt")

        client.stop_vm(vm_id)
        assert client.get_vm(vm_id).status.state is VmState.STOPPED

        client.delete_vm(vm_id)
        with pytest.raises(DomainNotFound):
            client.get_vm(vm_id)


def test_list_vms_and_capabilities() -> None:
    backend = FakeHypervisorBackend()
    transport = InProcessTransport.from_adapters(backend)
    client = VmManagerClient(transport)
    client.create_vm(_spec("one"))
    client.create_vm(_spec("two"))
    vms = client.list_vms()
    assert all(isinstance(v, Vm) for v in vms)
    names = sorted(v.spec.name for v in vms)
    assert names == ["one", "two"]
    assert client.capabilities().engine == "fake"


def test_pause_resume_cycle() -> None:
    backend = FakeHypervisorBackend()
    transport = InProcessTransport.from_adapters(backend)
    client = VmManagerClient(transport)
    info = client.create_vm(_spec("pr"))
    vm_id = info.vm_id
    client.start_vm(vm_id)
    client.pause_vm(vm_id)
    client.resume_vm(vm_id)
    client.reboot_vm(vm_id)
    assert client.get_vm(vm_id).status.state is VmState.RUNNING


def test_redefine_replaces_stopped_definition() -> None:
    backend = FakeHypervisorBackend()
    transport = InProcessTransport.from_adapters(backend)
    client = VmManagerClient(transport)
    info = client.create_vm(_spec("rd", cpu=2))
    vm_id = info.vm_id
    new = client.redefine_vm(vm_id, _spec("rd", cpu=4))
    assert new.spec.cpu == 4


# -- operation-handle transitions ------------------------------------------


def test_submit_op_records_pending_running_succeeded() -> None:
    backend = FakeHypervisorBackend()
    transport = InProcessTransport.from_adapters(backend)

    handle = transport.submit_op(OperationKind.VM_CREATE, _spec("h"), client_op_id="cid-1")
    # submit_op runs synchronously to terminal in-process.
    assert handle.status is OperationStatus.SUCCEEDED
    assert handle.finished_at is not None

    # watch_op on an already-settled op yields the terminal handle once.
    statuses = [h.status for h in transport.watch_op(handle.op_id)]
    assert statuses == [OperationStatus.SUCCEEDED]
    assert transport.get_op(handle.op_id).status is OperationStatus.SUCCEEDED


def test_dedup_returns_same_op() -> None:
    backend = FakeHypervisorBackend()
    transport = InProcessTransport.from_adapters(backend)
    first = transport.submit_op(OperationKind.VM_CREATE, _spec("dd"), client_op_id="dup")
    second = transport.submit_op(OperationKind.VM_CREATE, _spec("dd"), client_op_id="dup")
    assert first.op_id == second.op_id
    # Only one domain was actually defined.
    assert len(backend.list_domains()) == 1


# -- state machine + observers ---------------------------------------------


def test_observer_fires_on_each_transition() -> None:
    backend = FakeHypervisorBackend()
    observer = _RecordingObserver()
    transport = InProcessTransport.from_adapters(backend, observers=(observer,))
    client = VmManagerClient(transport)

    info = client.create_vm(_spec("obs"))
    vm_id = info.vm_id
    client.start_vm(vm_id)
    client.stop_vm(vm_id)

    seen = [(e.from_state, e.to_state) for e in observer.events]
    # create defines; start seeds observed STOPPED then drives STARTING→RUNNING;
    # stop drives STOPPING→STOPPED.
    assert (None, VmState.DEFINED) in seen
    assert (VmState.STOPPED, VmState.STARTING) in seen
    assert (VmState.STARTING, VmState.RUNNING) in seen
    assert (VmState.RUNNING, VmState.STOPPING) in seen
    assert (VmState.STOPPING, VmState.STOPPED) in seen


def test_illegal_transition_surfaces_domain_state_invalid() -> None:
    # stop before start: DEFINED has no STOPPING target → InvalidTransition,
    # bridged to DomainStateInvalid on the handle and re-raised.
    backend = FakeHypervisorBackend()
    transport = InProcessTransport.from_adapters(backend)
    client = VmManagerClient(transport)
    info = client.create_vm(_spec("ill"))
    vm_id = info.vm_id
    with pytest.raises(DomainStateInvalid):
        client.stop_vm(vm_id)


# -- error bridge ----------------------------------------------------------


def test_start_nonexistent_vm_reraises_domain_not_found() -> None:
    backend = FakeHypervisorBackend()
    transport = InProcessTransport.from_adapters(backend)
    client = VmManagerClient(transport)
    with pytest.raises(DomainNotFound):
        client.start_vm(VmId("fake-does-not-exist"))


def test_failed_handle_carries_original_exception() -> None:
    backend = FakeHypervisorBackend()
    transport = InProcessTransport.from_adapters(backend)
    handle = transport.submit_op(OperationKind.VM_START, VmId("fake-missing"), client_op_id="e1")
    assert handle.status is OperationStatus.FAILED
    assert isinstance(handle.error, DomainNotFound)


def test_graceful_stop_timeout_reraises_guest_unresponsive() -> None:
    backend = FakeHypervisorBackend(graceful_stop_succeeds=False)
    transport = InProcessTransport.from_adapters(backend)
    client = VmManagerClient(transport)
    info = client.create_vm(_spec("gu"))
    vm_id = info.vm_id
    client.start_vm(vm_id)
    with pytest.raises(GuestUnresponsive):
        client.stop_vm(vm_id)
    # force=True must succeed (the engine equivalent of pulling the plug).
    client.stop_vm(vm_id, force=True)
    assert client.get_vm(vm_id).status.state is VmState.STOPPED


# -- cancellation ----------------------------------------------------------


def test_cancellation_marks_cancelled() -> None:
    backend = FakeHypervisorBackend()
    transport = InProcessTransport.from_adapters(backend)
    info = client_create(transport, "cx")
    vm_id = info.vm_id

    token = CancellationEvent()
    token.cancel()  # pre-cancelled: the fake raises at its first yield point.
    handle = transport.submit_op(
        OperationKind.VM_START, vm_id, client_op_id="cancel-1", token=token
    )
    assert handle.status is OperationStatus.CANCELLED
    assert handle.error is None


def test_client_reraises_operation_cancelled() -> None:
    # The client has no token param in this slice; assert the transport-level
    # CANCELLED handle unpacks to OperationCancelled when the client unpacks it.
    backend = FakeHypervisorBackend()
    transport = InProcessTransport.from_adapters(backend)
    info = client_create(transport, "cc")
    vm_id = info.vm_id
    token = CancellationEvent()
    token.cancel()
    handle = transport.submit_op(
        OperationKind.VM_START, vm_id, client_op_id="cancel-2", token=token
    )
    client = VmManagerClient(transport)
    with pytest.raises(OperationCancelled):
        client._unpack(handle)


def test_success_wins_over_late_cancel() -> None:
    # A token cancelled AFTER the backend already finished must not turn a
    # SUCCEEDED op into CANCELLED (D-D7). The fake checks the token only at
    # entry, so an un-cancelled token → SUCCEEDED.
    backend = FakeHypervisorBackend()
    transport = InProcessTransport.from_adapters(backend)
    info = client_create(transport, "sw")
    vm_id = info.vm_id
    token = CancellationEvent()  # never cancelled
    handle = transport.submit_op(OperationKind.VM_START, vm_id, client_op_id="late", token=token)
    assert handle.status is OperationStatus.SUCCEEDED


# -- per-VmId serialization ------------------------------------------------


def test_same_vm_ops_serialize() -> None:
    """Two ops on the SAME VM never overlap — the per-VmId lock serializes."""
    backend = _BlockingBackend()
    transport = InProcessTransport.from_adapters(backend)
    info = client_create(transport, "ser")
    vm_id = info.vm_id

    backend.block_starts = True
    overlap = _run_two_concurrent(
        transport,
        OperationKind.VM_START,
        vm_id,
        OperationKind.VM_REBOOT,
        PowerPayload(vm_id),
        release=lambda: backend.release(),
    )
    assert overlap is False


def test_different_vms_run_concurrently() -> None:
    """Ops on DIFFERENT VMs overlap — separate locks, true concurrency."""
    backend = _BlockingBackend()
    transport = InProcessTransport.from_adapters(backend)
    a = client_create(transport, "a").vm_id
    b = client_create(transport, "b").vm_id

    backend.block_starts = True
    overlap = _run_two_concurrent(
        transport,
        OperationKind.VM_START,
        a,
        OperationKind.VM_START,
        b,
        release=lambda: backend.release(),
    )
    assert overlap is True


# -- reads + close ---------------------------------------------------------


def test_read_unsupported_query_raises() -> None:
    transport = InProcessTransport.from_adapters(FakeHypervisorBackend())
    with pytest.raises(TransportError):
        transport.read(object())


def test_watch_stream_returns_iterator() -> None:
    transport = InProcessTransport.from_adapters(FakeHypervisorBackend())
    stream = transport.watch_stream(None)
    assert list(stream) == []


def test_close_is_idempotent_and_closes_backend() -> None:
    backend = FakeHypervisorBackend()
    transport = InProcessTransport.from_adapters(backend)
    transport.close()
    transport.close()
    assert backend.closed
    with pytest.raises(TransportError):
        transport.submit_op(OperationKind.VM_CREATE, _spec(), client_op_id="x")


def test_get_op_unknown_raises() -> None:
    transport = InProcessTransport.from_adapters(FakeHypervisorBackend())
    with pytest.raises(TransportError):
        transport.get_op(OperationId("nope"))
    with pytest.raises(TransportError):
        list(transport.watch_op(OperationId("nope")))


def test_read_and_watch_stream_after_close_raise() -> None:
    transport = InProcessTransport.from_adapters(FakeHypervisorBackend())
    transport.close()
    from mgf.vm.sdk.queries import ListVms

    with pytest.raises(TransportError):
        transport.read(ListVms())
    with pytest.raises(TransportError):
        transport.watch_stream(None)


def test_close_closes_storage_and_device() -> None:
    backend = FakeHypervisorBackend()

    class _Closeable:
        def __init__(self) -> None:
            self.closed = False

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

    storage = _Closeable()
    device = _Closeable()
    transport = InProcessTransport.from_adapters(
        backend,
        storage,
        device,  # type: ignore[arg-type]
    )
    transport.close()
    assert storage.closed
    assert device.closed


def test_unwired_kind_raises() -> None:
    # A storage op kind is not wired in this slice → TransportError on handle.
    transport = InProcessTransport.from_adapters(FakeHypervisorBackend())
    info = client_create(transport, "uw")
    vm_id = info.vm_id
    handle = transport.submit_op(OperationKind.VM_ATTACH_DEVICE, vm_id, client_op_id="uw-1")
    assert handle.status is OperationStatus.FAILED
    assert isinstance(handle.error, TransportError)


def test_bad_payloads_fail_the_op() -> None:
    transport = InProcessTransport.from_adapters(FakeHypervisorBackend())
    bad_create = transport.submit_op(OperationKind.VM_CREATE, object(), client_op_id="bad-c")
    assert isinstance(bad_create.error, TransportError)
    bad_redef = transport.submit_op(OperationKind.VM_REDEFINE, object(), client_op_id="bad-r")
    assert isinstance(bad_redef.error, TransportError)


def test_live_watch_op_streams_transitions() -> None:
    """A concurrent watcher on an in-flight op sees RUNNING then SUCCEEDED."""
    backend = _BlockingBackend()
    transport = InProcessTransport.from_adapters(backend)
    info = client_create(transport, "live")
    vm_id = info.vm_id

    backend.block_starts = True
    seen: list[OperationStatus] = []
    op_holder: dict[str, OperationId] = {}

    def submit() -> None:
        handle = transport.submit_op(OperationKind.VM_START, vm_id, client_op_id="live-1")
        op_holder["id"] = handle.op_id

    worker = threading.Thread(target=submit)
    worker.start()
    # Wait until the op is registered + RUNNING (backend blocked).
    deadline = time.monotonic() + 2.0
    op_id = None
    while time.monotonic() < deadline:
        ops = transport._registry.list()
        running = [o for o in ops if o.kind is OperationKind.VM_START]
        if running and running[0].status is OperationStatus.RUNNING:
            op_id = running[0].op_id
            break
        time.sleep(0.01)
    assert op_id is not None

    def watch() -> None:
        for h in transport.watch_op(op_id):
            seen.append(h.status)

    watcher = threading.Thread(target=watch)
    watcher.start()
    time.sleep(0.05)
    backend.release()
    watcher.join(timeout=3.0)
    worker.join(timeout=3.0)
    assert OperationStatus.SUCCEEDED in seen


# -- test helpers ----------------------------------------------------------


def client_create(transport: InProcessTransport, name: str) -> Vm:
    client = VmManagerClient(transport)
    return client.create_vm(_spec(name))


class _BlockingBackend(FakeHypervisorBackend):
    """A fake whose ``start`` blocks on an event so we can probe concurrency."""

    def __init__(self) -> None:
        super().__init__()
        self.block_starts = False
        self._gate = threading.Event()
        self._active = 0
        self._max_active = 0
        self._active_lock = threading.Lock()

    def release(self) -> None:
        self._gate.set()

    @property
    def max_active(self) -> int:
        return self._max_active

    def _gate_through(self) -> None:
        if self.block_starts:
            with self._active_lock:
                self._active += 1
                self._max_active = max(self._max_active, self._active)
            self._gate.wait(timeout=2.0)
            with self._active_lock:
                self._active -= 1

    def start(self, d: DomainId, *, token: CancellationToken | None = None) -> None:
        self._gate_through()
        super().start(d, token=token)

    def reboot(
        self,
        d: DomainId,
        *,
        force: bool = False,
        token: CancellationToken | None = None,
    ) -> None:
        self._gate_through()
        # reboot requires running; start it first to keep the fake happy.
        super().start(d, token=token)
        super().reboot(d, force=force, token=token)


def _run_two_concurrent(
    transport: InProcessTransport,
    kind_a: OperationKind,
    payload_a: object,
    kind_b: OperationKind,
    payload_b: object,
    *,
    release: Callable[[], None],
) -> bool:
    """Submit two ops on threads; report whether their critical sections overlapped.

    Returns ``True`` if both backend calls were simultaneously in-flight at any
    point (max concurrency reached 2), ``False`` if they serialized.
    """
    backend = transport._hypervisor
    assert isinstance(backend, _BlockingBackend)

    def submit(kind: OperationKind, payload: object, cid: str) -> None:
        transport.submit_op(kind, payload, client_op_id=cid)

    t1 = threading.Thread(target=submit, args=(kind_a, payload_a, "c-a"))
    t2 = threading.Thread(target=submit, args=(kind_b, payload_b, "c-b"))
    t1.start()
    # Give t1 a moment to enter the blocked backend call.
    time.sleep(0.05)
    t2.start()
    time.sleep(0.05)
    overlapped = backend.max_active >= 2
    release()
    t1.join(timeout=3.0)
    t2.join(timeout=3.0)
    return overlapped
