"""Contract tests for the :class:`MonitorHistory` Protocol + pure row types.

Asserts the structural shape of the D-G10 seam:

* :class:`FakeMonitorHistory` satisfies the ``runtime_checkable``
  Protocol via ``isinstance``;
* the row types :class:`EventRow` / :class:`HourlyMetrics` are frozen
  (immutable value types);
* :func:`infer_trigger_source` returns the documented labels.
"""

from __future__ import annotations

from dataclasses import FrozenInstanceError
from datetime import UTC, datetime

import pytest

from mgf.vm.core.domain import VMStatus
from mgf.vm.core.monitor import (
    EventRow,
    FakeMonitorHistory,
    HourlyMetrics,
    MonitorHistory,
    infer_trigger_source,
)


def _hour() -> datetime:
    return datetime(2026, 5, 29, 12, 0, 0, tzinfo=UTC)


def test_fake_satisfies_protocol_isinstance() -> None:
    assert isinstance(FakeMonitorHistory(), MonitorHistory)


def test_protocol_is_runtime_checkable_and_rejects_non_impl() -> None:
    class NotAHistory:
        pass

    assert not isinstance(NotAHistory(), MonitorHistory)


def _protocol_members(proto: type) -> set[str]:
    """The structural attribute set of a Protocol, portably across versions.

    3.12 caches it on ``__protocol_attrs__``; 3.11 has only the private
    ``typing._get_protocol_attrs`` helper that the cache is computed from.
    """
    cached = getattr(proto, "__protocol_attrs__", None)
    if cached is not None:
        return set(cached)
    from typing import _get_protocol_attrs  # type: ignore[attr-defined]

    return set(_get_protocol_attrs(proto))


def test_protocol_lists_expected_public_methods() -> None:
    expected = {
        "record_lifecycle",
        "record_config_change",
        "query_events",
        "record_metrics",
        "record_metrics_batch",
        "query_metrics",
        "prune_metrics_older_than",
        "rollup_hourly_metrics",
        "query_metrics_hourly",
        "prune_metrics_hourly_older_than",
        "close",
    }
    members = _protocol_members(MonitorHistory)
    assert expected <= members


def test_event_row_is_frozen() -> None:
    row = EventRow(
        vm_name="vm1",
        occurred_at=_hour(),
        from_state="shut off",
        to_state="running",
        trigger="lifecycle",
        detail="started",
    )
    with pytest.raises(FrozenInstanceError):
        row.vm_name = "vm2"  # type: ignore[misc]


def test_hourly_metrics_is_frozen() -> None:
    row = HourlyMetrics(
        vm_name="vm1",
        hour=_hour(),
        sample_count=2,
        cpu_pct_avg=10.0,
        cpu_pct_max=20.0,
        ram_used_mb_avg=512.0,
        ram_used_mb_max=600,
        disk_read_bps_avg=1.0,
        disk_read_bps_max=2,
        disk_write_bps_avg=3.0,
        disk_write_bps_max=4,
        net_rx_bps_avg=5.0,
        net_rx_bps_max=6,
        net_tx_bps_avg=7.0,
        net_tx_bps_max=8,
    )
    with pytest.raises(FrozenInstanceError):
        row.sample_count = 99  # type: ignore[misc]


@pytest.mark.parametrize(
    ("previous", "new", "expected"),
    [
        (VMStatus.STOPPED, VMStatus.RUNNING, "started"),
        (VMStatus.PAUSED, VMStatus.RUNNING, "resumed"),
        (VMStatus.RUNNING, VMStatus.PAUSED, "paused"),
        (VMStatus.RUNNING, VMStatus.STOPPED, "stopped"),
        (VMStatus.RUNNING, VMStatus.CRASHED, "crashed"),
        (VMStatus.STOPPED, VMStatus.CRASHED, "crashed"),
        (VMStatus.STOPPED, VMStatus.PAUSED, "transition"),
        (VMStatus.UNKNOWN, VMStatus.RUNNING, "transition"),
    ],
)
def test_infer_trigger_source(previous: VMStatus, new: VMStatus, expected: str) -> None:
    assert infer_trigger_source(previous, new) == expected
