"""Tests for the D-G11 operation model.

Covers :class:`OperationStatus`, :class:`OperationKind`, and the
:class:`OperationHandle` oneof / invariant enforcement: per-status outcome
rules, naive-datetime rejection (DP-12), ``finished_at >= started_at``,
terminal-needs-finished, enum membership, and frozen/immutability.
"""

from __future__ import annotations

import dataclasses
from datetime import UTC, datetime, timedelta, timezone

import pytest

import mgf.vm.core as core
from mgf.vm.core.contracts import (
    OperationHandle,
    OperationKind,
    OperationStatus,
)
from mgf.vm.core.exceptions import VmManagerError
from mgf.vm.core.ids import OperationId

T0 = datetime(2026, 5, 30, 12, 0, 0, tzinfo=UTC)
T1 = T0 + timedelta(seconds=5)
OP = OperationId("op-1")


# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------


def test_operation_status_members() -> None:
    assert {s.value for s in OperationStatus} == {
        "pending",
        "running",
        "succeeded",
        "failed",
        "cancelled",
    }


def test_operation_status_is_str_enum() -> None:
    assert OperationStatus.PENDING == "pending"
    assert isinstance(OperationStatus.RUNNING, str)


@pytest.mark.parametrize(
    ("status", "terminal"),
    [
        (OperationStatus.PENDING, False),
        (OperationStatus.RUNNING, False),
        (OperationStatus.SUCCEEDED, True),
        (OperationStatus.FAILED, True),
        (OperationStatus.CANCELLED, True),
    ],
)
def test_status_is_terminal(status: OperationStatus, terminal: bool) -> None:
    assert status.is_terminal is terminal


def test_operation_kind_member_count() -> None:
    # 15 VM + 2 domain-policy (D-G25) + 6 volume + 2 pool + 3 device
    # + 3 host-disk (D-G18) + 4 network (D-G15) = 35 ops.
    assert len(list(OperationKind)) == 35


def test_operation_kind_covers_three_backends() -> None:
    names = {k.name for k in OperationKind}
    # A representative member from each backend family must be present.
    assert {"VM_CREATE", "VM_MIGRATE", "VM_DETACH_DEVICE"} <= names
    assert {"VOLUME_CREATE", "VOLUME_CONVERT", "POOL_DELETE"} <= names
    assert {"DEVICE_CLAIM", "DEVICE_RELEASE", "DEVICE_TRANSFER"} <= names
    # Host disk-image I/O (HostDiskBackend, D-G18).
    assert {"VM_BACKUP", "VM_CLONE", "VM_RESTORE"} <= names
    # Virtual-network management (NetworkBackend, D-G15).
    assert {"NETWORK_DEFINE", "NETWORK_START", "NETWORK_STOP", "NETWORK_DELETE"} <= names


def test_kind_is_str_enum() -> None:
    assert OperationKind.VM_CREATE == "vm_create"


def test_reexported_from_package() -> None:
    assert core.OperationHandle is OperationHandle
    assert core.OperationKind is OperationKind
    assert core.OperationStatus is OperationStatus
    for name in ("OperationHandle", "OperationKind", "OperationStatus"):
        assert name in core.__all__


# ---------------------------------------------------------------------------
# Happy-path construction per status
# ---------------------------------------------------------------------------


def test_pending_handle() -> None:
    h = OperationHandle.pending(OP, OperationKind.VM_START, T0)
    assert h.status is OperationStatus.PENDING
    assert h.finished_at is None
    assert h.result is None
    assert h.error is None


def test_running_handle() -> None:
    h = OperationHandle(
        op_id=OP,
        kind=OperationKind.VM_START,
        status=OperationStatus.RUNNING,
        started_at=T0,
    )
    assert h.status is OperationStatus.RUNNING
    assert h.finished_at is None


def test_succeeded_handle_carries_result() -> None:
    h = OperationHandle(
        op_id=OP,
        kind=OperationKind.VM_CREATE,
        status=OperationStatus.SUCCEEDED,
        started_at=T0,
        finished_at=T1,
        result="vm-123",
    )
    assert h.result == "vm-123"
    assert h.error is None


def test_succeeded_with_none_result_is_legal() -> None:
    # A void op (e.g. VM_START) succeeds with result=None and no error.
    h = OperationHandle(
        op_id=OP,
        kind=OperationKind.VM_START,
        status=OperationStatus.SUCCEEDED,
        started_at=T0,
        finished_at=T1,
    )
    assert h.result is None
    assert h.error is None


def test_failed_handle_carries_error() -> None:
    err = VmManagerError("boom")
    h = OperationHandle(
        op_id=OP,
        kind=OperationKind.VM_STOP,
        status=OperationStatus.FAILED,
        started_at=T0,
        finished_at=T1,
        error=err,
    )
    assert h.error is err
    assert h.result is None


def test_cancelled_handle_carries_neither() -> None:
    h = OperationHandle(
        op_id=OP,
        kind=OperationKind.VM_MIGRATE,
        status=OperationStatus.CANCELLED,
        started_at=T0,
        finished_at=T1,
    )
    assert h.result is None
    assert h.error is None


# ---------------------------------------------------------------------------
# Oneof enforcement per status
# ---------------------------------------------------------------------------


def test_succeeded_with_error_rejected() -> None:
    with pytest.raises(ValueError, match=r"SUCCEEDED.*must not carry an error"):
        OperationHandle(
            op_id=OP,
            kind=OperationKind.VM_CREATE,
            status=OperationStatus.SUCCEEDED,
            started_at=T0,
            finished_at=T1,
            error=VmManagerError("x"),
        )


def test_failed_without_error_rejected() -> None:
    with pytest.raises(ValueError, match=r"FAILED.*requires an error"):
        OperationHandle(
            op_id=OP,
            kind=OperationKind.VM_STOP,
            status=OperationStatus.FAILED,
            started_at=T0,
            finished_at=T1,
        )


def test_failed_with_result_rejected() -> None:
    with pytest.raises(ValueError, match=r"FAILED.*must not carry a result"):
        OperationHandle(
            op_id=OP,
            kind=OperationKind.VM_STOP,
            status=OperationStatus.FAILED,
            started_at=T0,
            finished_at=T1,
            result="x",
            error=VmManagerError("y"),
        )


@pytest.mark.parametrize(
    "status",
    [OperationStatus.PENDING, OperationStatus.RUNNING, OperationStatus.CANCELLED],
)
def test_nonsuccess_with_result_rejected(status: OperationStatus) -> None:
    finished = T1 if status.is_terminal else None
    with pytest.raises(ValueError, match="must not carry a result"):
        OperationHandle(
            op_id=OP,
            kind=OperationKind.VM_START,
            status=status,
            started_at=T0,
            finished_at=finished,
            result="x",
        )


@pytest.mark.parametrize(
    "status",
    [OperationStatus.PENDING, OperationStatus.RUNNING, OperationStatus.CANCELLED],
)
def test_nonfailed_with_error_rejected(status: OperationStatus) -> None:
    finished = T1 if status.is_terminal else None
    with pytest.raises(ValueError, match="must not carry an error"):
        OperationHandle(
            op_id=OP,
            kind=OperationKind.VM_START,
            status=status,
            started_at=T0,
            finished_at=finished,
            error=VmManagerError("x"),
        )


# ---------------------------------------------------------------------------
# Datetime invariants (DP-12 / D-G6)
# ---------------------------------------------------------------------------


def test_naive_started_at_rejected() -> None:
    with pytest.raises(ValueError, match="started_at must be timezone-aware"):
        OperationHandle(
            op_id=OP,
            kind=OperationKind.VM_START,
            status=OperationStatus.PENDING,
            started_at=datetime(2026, 5, 30, 12, 0, 0),
        )


def test_naive_finished_at_rejected() -> None:
    with pytest.raises(ValueError, match="finished_at must be timezone-aware"):
        OperationHandle(
            op_id=OP,
            kind=OperationKind.VM_START,
            status=OperationStatus.SUCCEEDED,
            started_at=T0,
            finished_at=datetime(2026, 5, 30, 12, 0, 5),
        )


def test_non_utc_offset_rejected() -> None:
    eastern = timezone(timedelta(hours=-5))
    with pytest.raises(ValueError, match="started_at must be UTC"):
        OperationHandle(
            op_id=OP,
            kind=OperationKind.VM_START,
            status=OperationStatus.PENDING,
            started_at=T0.astimezone(eastern).replace(tzinfo=eastern),
        )


def test_finished_before_started_rejected() -> None:
    with pytest.raises(ValueError, match="before"):
        OperationHandle(
            op_id=OP,
            kind=OperationKind.VM_START,
            status=OperationStatus.SUCCEEDED,
            started_at=T1,
            finished_at=T0,
        )


def test_finished_equal_started_allowed() -> None:
    h = OperationHandle(
        op_id=OP,
        kind=OperationKind.VM_START,
        status=OperationStatus.SUCCEEDED,
        started_at=T0,
        finished_at=T0,
    )
    assert h.finished_at == h.started_at


# ---------------------------------------------------------------------------
# Terminal / finished_at coupling
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "status",
    [OperationStatus.SUCCEEDED, OperationStatus.FAILED, OperationStatus.CANCELLED],
)
def test_terminal_requires_finished_at(status: OperationStatus) -> None:
    kwargs: dict[str, object] = {
        "op_id": OP,
        "kind": OperationKind.VM_STOP,
        "status": status,
        "started_at": T0,
    }
    if status is OperationStatus.FAILED:
        kwargs["error"] = VmManagerError("x")
    with pytest.raises(ValueError, match="requires finished_at"):
        OperationHandle(**kwargs)  # type: ignore[arg-type]


@pytest.mark.parametrize("status", [OperationStatus.PENDING, OperationStatus.RUNNING])
def test_nonterminal_forbids_finished_at(status: OperationStatus) -> None:
    with pytest.raises(ValueError, match="forbids finished_at"):
        OperationHandle(
            op_id=OP,
            kind=OperationKind.VM_START,
            status=status,
            started_at=T0,
            finished_at=T1,
        )


# ---------------------------------------------------------------------------
# Transition helpers (immutable)
# ---------------------------------------------------------------------------


def test_started_transition() -> None:
    h = OperationHandle.pending(OP, OperationKind.VM_START, T0).started()
    assert h.status is OperationStatus.RUNNING
    assert h.finished_at is None


def test_succeeded_transition() -> None:
    h = OperationHandle.pending(OP, OperationKind.VM_CREATE, T0).started()
    done = h.succeeded("vm-9", finished_at=T1)
    assert done.status is OperationStatus.SUCCEEDED
    assert done.result == "vm-9"
    assert done.finished_at == T1
    # original handle unchanged (immutability of the dataclass)
    assert h.status is OperationStatus.RUNNING


def test_failed_transition() -> None:
    err = VmManagerError("nope")
    done = OperationHandle.pending(OP, OperationKind.VM_STOP, T0).failed(err, finished_at=T1)
    assert done.status is OperationStatus.FAILED
    assert done.error is err


def test_cancelled_transition() -> None:
    done = OperationHandle.pending(OP, OperationKind.VM_MIGRATE, T0).cancelled(finished_at=T1)
    assert done.status is OperationStatus.CANCELLED
    assert done.result is None
    assert done.error is None


# ---------------------------------------------------------------------------
# Frozen / immutability
# ---------------------------------------------------------------------------


def test_handle_is_frozen() -> None:
    h = OperationHandle.pending(OP, OperationKind.VM_START, T0)
    with pytest.raises(dataclasses.FrozenInstanceError):
        h.status = OperationStatus.RUNNING  # type: ignore[misc]


def test_handle_uses_slots() -> None:
    h = OperationHandle.pending(OP, OperationKind.VM_START, T0)
    assert not hasattr(h, "__dict__")
