"""Behaviour tests for :class:`FakeMonitorHistory`.

Exercises record→query round-trips on every Protocol method: event
recording + filtering + ``since`` cutoffs, metric batch round-trips +
pruning, and the hourly rollup + its prune, plus ``close``.
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta

import pytest

from mgf.vm.core.domain import VMHandle, VMMetrics, VMStatus
from mgf.vm.core.exceptions import OperationError
from mgf.vm.core.monitor import FakeMonitorHistory


def _metrics(
    sampled_at: datetime,
    *,
    cpu_pct: float = 10.0,
    ram_used_mb: int = 512,
) -> VMMetrics:
    return VMMetrics(
        cpu_pct=cpu_pct,
        ram_used_mb=ram_used_mb,
        ram_used_mb_guest=None,
        disk_read_bps=1,
        disk_write_bps=2,
        disk_actual_bytes=3,
        net_rx_bps=4,
        net_tx_bps=5,
        sampled_at=sampled_at,
    )


def _handle(name: str) -> VMHandle:
    return VMHandle(name=name, uuid=f"uuid-{name}", state=VMStatus.STOPPED)


# ---------------------------------------------------------------------------
# Events
# ---------------------------------------------------------------------------


def test_record_lifecycle_round_trip() -> None:
    h = FakeMonitorHistory()
    h.record_lifecycle("vm1", VMStatus.STOPPED, VMStatus.RUNNING, trigger_source="started")

    rows = h.query_events("vm1")
    assert len(rows) == 1
    row = rows[0]
    assert row.vm_name == "vm1"
    assert row.from_state == "shut off"
    assert row.to_state == "running"
    assert row.trigger == "lifecycle"
    assert row.detail == "started"


def test_record_lifecycle_none_from_state_and_default_trigger() -> None:
    h = FakeMonitorHistory()
    h.record_lifecycle("vm1", None, VMStatus.STOPPED)
    row = h.query_events("vm1")[0]
    assert row.from_state is None
    assert row.detail == "user"


def test_record_config_change_round_trip() -> None:
    h = FakeMonitorHistory()
    h.record_config_change(_handle("vm1"), "cpu", "vcpus 2->4")
    row = h.query_events("vm1")[0]
    assert row.from_state is None
    assert row.to_state is None
    assert row.trigger == "config_change:cpu"
    assert row.detail == "vcpus 2->4"


def test_query_events_all_vms_and_newest_first() -> None:
    times = [datetime(2026, 5, 29, 10 + i, 0, 0, tzinfo=UTC) for i in range(3)]
    clock = iter(times)
    h = FakeMonitorHistory(clock=lambda: next(clock))
    h.record_lifecycle("vm1", None, VMStatus.RUNNING)
    h.record_lifecycle("vm2", None, VMStatus.RUNNING)
    h.record_lifecycle("vm1", VMStatus.RUNNING, VMStatus.STOPPED)

    everything = h.query_events()
    assert len(everything) == 3
    assert [r.occurred_at for r in everything] == sorted(times, reverse=True)

    just_vm1 = h.query_events("vm1")
    assert {r.vm_name for r in just_vm1} == {"vm1"}
    assert len(just_vm1) == 2


def test_query_events_filter_and_since_and_limit() -> None:
    base = datetime(2026, 5, 29, 9, 0, 0, tzinfo=UTC)
    clock = iter([base, base + timedelta(hours=1), base + timedelta(hours=2)])
    h = FakeMonitorHistory(clock=lambda: next(clock))
    h.record_lifecycle("vm1", None, VMStatus.RUNNING)  # t0 lifecycle
    h.record_config_change(_handle("vm1"), "cpu", "vcpus 2->4")  # t1 config
    h.record_lifecycle("vm1", VMStatus.RUNNING, VMStatus.STOPPED)  # t2 lifecycle

    assert len(h.query_events("vm1", filter="lifecycle")) == 2
    assert len(h.query_events("vm1", filter="config")) == 1
    # since cutoff drops t0
    assert len(h.query_events("vm1", since=base + timedelta(minutes=30))) == 2
    # limit caps the (newest-first) result
    assert len(h.query_events("vm1", limit=1)) == 1


def test_query_events_naive_since_treated_as_utc() -> None:
    base = datetime(2026, 5, 29, 9, 0, 0, tzinfo=UTC)
    h = FakeMonitorHistory(clock=lambda: base)
    h.record_lifecycle("vm1", None, VMStatus.RUNNING)
    naive_cutoff = datetime(2026, 5, 29, 8, 0, 0)  # intentional naive input
    assert len(h.query_events("vm1", since=naive_cutoff)) == 1


def test_query_events_invalid_filter_raises() -> None:
    h = FakeMonitorHistory()
    with pytest.raises(OperationError):
        h.query_events("vm1", filter="bogus")  # type: ignore[arg-type]


# ---------------------------------------------------------------------------
# Raw metrics
# ---------------------------------------------------------------------------


def test_record_metrics_round_trip() -> None:
    h = FakeMonitorHistory()
    ts = datetime(2026, 5, 29, 12, 0, 0, tzinfo=UTC)
    h.record_metrics("vm1", _metrics(ts, cpu_pct=42.0))
    got = h.query_metrics("vm1")
    assert len(got) == 1
    assert got[0].cpu_pct == 42.0


def test_record_metrics_batch_empty_is_noop() -> None:
    h = FakeMonitorHistory()
    h.record_metrics_batch([])
    assert h.query_metrics("vm1") == []


def test_query_metrics_newest_first_since_and_limit() -> None:
    base = datetime(2026, 5, 29, 12, 0, 0, tzinfo=UTC)
    samples = [("vm1", _metrics(base + timedelta(minutes=i))) for i in range(3)]
    h = FakeMonitorHistory()
    h.record_metrics_batch(samples)

    got = h.query_metrics("vm1")
    assert [m.sampled_at for m in got] == [
        base + timedelta(minutes=2),
        base + timedelta(minutes=1),
        base,
    ]
    assert len(h.query_metrics("vm1", since=base + timedelta(minutes=1))) == 2
    assert len(h.query_metrics("vm1", limit=1)) == 1
    assert h.query_metrics("unknown") == []


def test_prune_metrics_older_than() -> None:
    base = datetime(2026, 5, 29, 12, 0, 0, tzinfo=UTC)
    h = FakeMonitorHistory()
    h.record_metrics_batch(
        [("vm1", _metrics(base + timedelta(minutes=i))) for i in range(4)],
    )
    removed = h.prune_metrics_older_than(base + timedelta(minutes=2))
    assert removed == 2
    assert len(h.query_metrics("vm1")) == 2


# ---------------------------------------------------------------------------
# Hourly rollup
# ---------------------------------------------------------------------------


def test_rollup_hourly_metrics_round_trip() -> None:
    # Two samples inside the 10:00 hour, one inside the 11:00 hour.
    h = FakeMonitorHistory()
    h.record_metrics_batch(
        [
            (
                "vm1",
                _metrics(datetime(2026, 5, 29, 10, 5, tzinfo=UTC), cpu_pct=10.0, ram_used_mb=400),
            ),
            (
                "vm1",
                _metrics(datetime(2026, 5, 29, 10, 45, tzinfo=UTC), cpu_pct=30.0, ram_used_mb=600),
            ),
            (
                "vm1",
                _metrics(datetime(2026, 5, 29, 11, 5, tzinfo=UTC), cpu_pct=50.0, ram_used_mb=800),
            ),
        ],
    )
    # "now" is 12:00 so hours 10 + 11 are both complete.
    written = h.rollup_hourly_metrics(
        since=datetime(2026, 5, 29, 9, 0, tzinfo=UTC),
        now=datetime(2026, 5, 29, 12, 0, tzinfo=UTC),
    )
    assert written == 2

    rows = h.query_metrics_hourly("vm1")
    assert [r.hour for r in rows] == [
        datetime(2026, 5, 29, 11, 0, tzinfo=UTC),
        datetime(2026, 5, 29, 10, 0, tzinfo=UTC),
    ]
    ten = rows[1]
    assert ten.sample_count == 2
    assert ten.cpu_pct_avg == 20.0
    assert ten.cpu_pct_max == 30.0
    assert ten.ram_used_mb_max == 600


def test_rollup_excludes_in_progress_hour() -> None:
    h = FakeMonitorHistory()
    h.record_metrics_batch(
        [("vm1", _metrics(datetime(2026, 5, 29, 12, 5, tzinfo=UTC)))],
    )
    # now == 12:30: the 12:00 hour is still in progress, so nothing rolls up.
    written = h.rollup_hourly_metrics(
        since=datetime(2026, 5, 29, 11, 0, tzinfo=UTC),
        now=datetime(2026, 5, 29, 12, 30, tzinfo=UTC),
    )
    assert written == 0
    assert h.query_metrics_hourly("vm1") == []


def test_rollup_idempotent() -> None:
    h = FakeMonitorHistory()
    h.record_metrics_batch(
        [("vm1", _metrics(datetime(2026, 5, 29, 10, 5, tzinfo=UTC)))],
    )
    kwargs = {
        "since": datetime(2026, 5, 29, 9, 0, tzinfo=UTC),
        "now": datetime(2026, 5, 29, 12, 0, tzinfo=UTC),
    }
    h.rollup_hourly_metrics(**kwargs)
    h.rollup_hourly_metrics(**kwargs)
    assert len(h.query_metrics_hourly("vm1")) == 1


def test_rollup_empty_window_returns_zero() -> None:
    h = FakeMonitorHistory()
    # since >= end -> empty window
    assert (
        h.rollup_hourly_metrics(
            since=datetime(2026, 5, 29, 12, 0, tzinfo=UTC),
            now=datetime(2026, 5, 29, 12, 0, tzinfo=UTC),
        )
        == 0
    )


def test_rollup_default_window_uses_clock() -> None:
    now = datetime(2026, 5, 29, 12, 0, tzinfo=UTC)
    h = FakeMonitorHistory(clock=lambda: now)
    h.record_metrics_batch(
        [("vm1", _metrics(datetime(2026, 5, 28, 10, 5, tzinfo=UTC)))],
    )
    # No since / no now -> defaults to (clock - 7d) .. top-of-clock-hour.
    assert h.rollup_hourly_metrics() == 1


def test_query_metrics_hourly_since_and_limit() -> None:
    h = FakeMonitorHistory()
    h.record_metrics_batch(
        [
            ("vm1", _metrics(datetime(2026, 5, 29, 9, 5, tzinfo=UTC))),
            ("vm1", _metrics(datetime(2026, 5, 29, 10, 5, tzinfo=UTC))),
        ],
    )
    h.rollup_hourly_metrics(
        since=datetime(2026, 5, 29, 8, 0, tzinfo=UTC),
        now=datetime(2026, 5, 29, 12, 0, tzinfo=UTC),
    )
    assert len(h.query_metrics_hourly("vm1", since=datetime(2026, 5, 29, 10, 0, tzinfo=UTC))) == 1
    assert len(h.query_metrics_hourly("vm1", limit=1)) == 1
    assert h.query_metrics_hourly("other") == []


def test_prune_metrics_hourly_older_than() -> None:
    h = FakeMonitorHistory()
    h.record_metrics_batch(
        [
            ("vm1", _metrics(datetime(2026, 5, 29, 9, 5, tzinfo=UTC))),
            ("vm1", _metrics(datetime(2026, 5, 29, 10, 5, tzinfo=UTC))),
        ],
    )
    h.rollup_hourly_metrics(
        since=datetime(2026, 5, 29, 8, 0, tzinfo=UTC),
        now=datetime(2026, 5, 29, 12, 0, tzinfo=UTC),
    )
    removed = h.prune_metrics_hourly_older_than(datetime(2026, 5, 29, 10, 0, tzinfo=UTC))
    assert removed == 1
    assert len(h.query_metrics_hourly("vm1")) == 1


# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------


def test_close_is_idempotent() -> None:
    h = FakeMonitorHistory()
    assert h.closed is False
    h.close()
    h.close()
    assert h.closed is True
