"""Tests for ``VmLifecycleStateMachine`` (contracts.md §6).

Verifies: legal-transitions table, observer dispatch (sync + async),
exception isolation, history recording, force_state bypass.
"""

from __future__ import annotations

import asyncio

import pytest

from mgf.vm.core.contracts import TransitionCause, TransitionEvent, VmState
from mgf.vm.core.ids import VmId
from mgf.vm.core.state_machine import InvalidTransition, VmLifecycleStateMachine

VM = VmId("vm-test")


def _sm() -> VmLifecycleStateMachine:
    return VmLifecycleStateMachine(VM)


# ===========================================================================
# Initial state and the legal-transitions table
# ===========================================================================


def test_initial_state_is_none() -> None:
    sm = _sm()
    assert sm.state is None


def test_first_transition_must_be_to_defined() -> None:
    sm = _sm()
    with pytest.raises(InvalidTransition):
        sm.transition(VmState.RUNNING, cause=TransitionCause.USER_REQUEST)
    # DEFINED is legal as the first transition.
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    assert sm.state is VmState.DEFINED


def test_defined_to_starting_to_running_is_legal() -> None:
    sm = _sm()
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    sm.transition(VmState.STARTING, cause=TransitionCause.USER_REQUEST)
    sm.transition(VmState.RUNNING, cause=TransitionCause.ENGINE_EVENT)
    assert sm.state is VmState.RUNNING


def test_running_directly_to_stopped_is_illegal() -> None:
    """STOPPING is on the path; can't skip it via .transition()."""
    sm = _sm()
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    sm.transition(VmState.STARTING, cause=TransitionCause.USER_REQUEST)
    sm.transition(VmState.RUNNING, cause=TransitionCause.ENGINE_EVENT)
    with pytest.raises(InvalidTransition):
        sm.transition(VmState.STOPPED, cause=TransitionCause.USER_REQUEST)


def test_failed_can_recover_via_defined() -> None:
    """FAILED → DEFINED is the recovery path."""
    sm = _sm()
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    sm.transition(VmState.STARTING, cause=TransitionCause.USER_REQUEST)
    sm.transition(VmState.FAILED, cause=TransitionCause.FAULT)
    sm.transition(VmState.DEFINED, cause=TransitionCause.RECONCILER)
    assert sm.state is VmState.DEFINED


def test_paused_to_stopping_is_legal() -> None:
    """Direct paused → stopping (forced stop bypasses resume)."""
    sm = _sm()
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    sm.transition(VmState.STARTING, cause=TransitionCause.USER_REQUEST)
    sm.transition(VmState.RUNNING, cause=TransitionCause.ENGINE_EVENT)
    sm.transition(VmState.PAUSING, cause=TransitionCause.USER_REQUEST)
    sm.transition(VmState.PAUSED, cause=TransitionCause.ENGINE_EVENT)
    sm.transition(VmState.STOPPING, cause=TransitionCause.USER_REQUEST)
    assert sm.state is VmState.STOPPING


# ===========================================================================
# History
# ===========================================================================


def test_history_records_every_transition() -> None:
    sm = _sm()
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    sm.transition(VmState.STARTING, cause=TransitionCause.USER_REQUEST)
    sm.transition(VmState.RUNNING, cause=TransitionCause.ENGINE_EVENT)
    assert len(sm.history) == 3
    assert [e.to_state for e in sm.history] == [
        VmState.DEFINED,
        VmState.STARTING,
        VmState.RUNNING,
    ]


def test_history_first_event_from_state_is_none() -> None:
    sm = _sm()
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    assert sm.history[0].from_state is None


def test_history_is_a_copy() -> None:
    """Caller cannot mutate the machine's history via the returned list."""
    sm = _sm()
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    h = sm.history
    h.clear()  # safe on the returned copy
    assert len(sm.history) == 1


# ===========================================================================
# Observers — sync
# ===========================================================================


class _CountingObserver:
    """Plain sync observer; records every event it sees."""

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

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


def test_sync_observer_fires_on_every_transition() -> None:
    sm = _sm()
    obs = _CountingObserver()
    sm.add_observer(obs)
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    sm.transition(VmState.STARTING, cause=TransitionCause.USER_REQUEST)
    assert len(obs.events) == 2


def test_observer_fires_in_registration_order() -> None:
    """contracts.md §6 — registration order."""
    sm = _sm()
    order: list[str] = []

    class _Named:
        def __init__(self, name: str) -> None:
            self.name = name

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

    sm.add_observer(_Named("alpha"))
    sm.add_observer(_Named("beta"))
    sm.add_observer(_Named("gamma"))
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    assert order == ["alpha", "beta", "gamma"]


def test_add_observer_idempotent_on_identity() -> None:
    sm = _sm()
    obs = _CountingObserver()
    sm.add_observer(obs)
    sm.add_observer(obs)  # second add is no-op
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    assert len(obs.events) == 1


def test_remove_observer() -> None:
    sm = _sm()
    obs = _CountingObserver()
    sm.add_observer(obs)
    sm.remove_observer(obs)
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    assert obs.events == []


def test_remove_missing_observer_is_idempotent() -> None:
    sm = _sm()
    obs = _CountingObserver()
    sm.remove_observer(obs)  # never added; no raise


# ===========================================================================
# Observer exception isolation — contracts.md §6
# ===========================================================================


class _BadObserver:
    """Observer that always raises."""

    def on_transition(self, event: TransitionEvent) -> None:
        raise RuntimeError("boom")


def test_observer_exception_does_not_break_transition() -> None:
    """contracts.md §6 — observer exceptions caught, never propagate."""
    sm = _sm()
    sm.add_observer(_BadObserver())
    # This should NOT raise.
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    assert sm.state is VmState.DEFINED


def test_one_bad_observer_does_not_prevent_others() -> None:
    sm = _sm()
    survivor = _CountingObserver()
    sm.add_observer(_BadObserver())
    sm.add_observer(survivor)
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    assert len(survivor.events) == 1


# ===========================================================================
# Observer budget — contracts.md §6 (<50ms)
# ===========================================================================


def test_slow_observer_logs_warning(caplog: pytest.LogCaptureFixture) -> None:
    import time as _time

    class _Slow:
        def on_transition(self, event: TransitionEvent) -> None:
            _time.sleep(0.06)  # 60 ms > 50 ms budget

    sm = _sm()
    sm.add_observer(_Slow())
    with caplog.at_level("WARNING"):
        sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    # Should log a budget exceedance warning, but the transition succeeds.
    assert any("budget" in r.message for r in caplog.records)
    assert sm.state is VmState.DEFINED


# ===========================================================================
# Async observers
# ===========================================================================


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

    async def on_transition(self, event: TransitionEvent) -> None:
        # An await point makes the observer truly async.
        await asyncio.sleep(0)
        self.events.append(event)


def test_async_observer_fires_when_no_event_loop_running() -> None:
    """Sync caller, async observer — asyncio.run path."""
    sm = _sm()
    obs = _AsyncCountingObserver()
    sm.add_observer(obs)
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    assert len(obs.events) == 1


# ===========================================================================
# force_state — reconciler bypass
# ===========================================================================


def test_force_state_bypasses_legal_transitions() -> None:
    """Out-of-band engine event: a defined VM goes FAILED before starting."""
    sm = _sm()
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    # DEFINED → FAILED is not in the legal table…
    with pytest.raises(InvalidTransition):
        sm.transition(VmState.FAILED, cause=TransitionCause.FAULT)
    # …but force_state allows it (reconciler's responsibility).
    sm.force_state(VmState.FAILED)
    assert sm.state is VmState.FAILED


def test_force_state_fires_observers() -> None:
    sm = _sm()
    obs = _CountingObserver()
    sm.add_observer(obs)
    sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST)
    sm.force_state(VmState.FAILED)
    assert len(obs.events) == 2
    assert obs.events[-1].to_state is VmState.FAILED


# ===========================================================================
# Returned TransitionEvent
# ===========================================================================


def test_transition_returns_event_with_correct_fields() -> None:
    from mgf.vm.core.ids import OperationId

    sm = _sm()
    op_id = OperationId("op-123")
    event = sm.transition(
        VmState.DEFINED,
        cause=TransitionCause.USER_REQUEST,
        operation_id=op_id,
        metadata={"requested_by": "alice"},
    )
    assert event.vm_id == VM
    assert event.from_state is None
    assert event.to_state is VmState.DEFINED
    assert event.cause is TransitionCause.USER_REQUEST
    assert event.operation_id == op_id
    assert event.metadata == {"requested_by": "alice"}


def test_event_metadata_is_independent_copy() -> None:
    """Mutating the input metadata dict after transition doesn't
    affect the event."""
    sm = _sm()
    meta = {"k": "v"}
    event = sm.transition(VmState.DEFINED, cause=TransitionCause.USER_REQUEST, metadata=meta)
    meta["k"] = "mutated"
    assert event.metadata == {"k": "v"}
