"""Tests for the Wave-B observation contracts (``observe-surfaces``).

Covers the three additions that let a consumer port its snapshot-tree /
metrics / host-network reads off the umbrella provider onto neutral
surfaces, in their RATIFIED placement:

1. Richer ``_SnapshotInfoContract`` (``description`` + ``parent_name``).
2. ``MetricsSample`` / ``DiskIo`` / ``NicIo`` + the OPTIONAL ``MetricsBackend``
   Protocol + ``FakeMetricsBackend`` (D-G13).
3. Host-network observation as methods ON ``HypervisorBackend``
   (``list_networks`` / ``network_leases`` / ``bridge_for_domain``) +
   ``NetworkProfile`` / ``NetworkLease`` (D-G14).
"""

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 (
    DiskIo,
    HypervisorBackend,
    MetricsBackend,
    MetricsSample,
    NetworkLease,
    NetworkProfile,
    NicIo,
    _SnapshotInfoContract,
)
from mgf.vm.core.fakes import FakeHypervisorBackend, FakeMetricsBackend
from mgf.vm.core.ids import DomainId, SnapshotId, VolumeId

# ===========================================================================
# 1. Richer SnapshotInfo (additive-with-defaults)
# ===========================================================================


def _snap(**kw: object) -> _SnapshotInfoContract:
    base: dict[str, object] = {
        "snapshot_id": SnapshotId("snap-1"),
        "parent": VolumeId("vol-1"),
        "name": "s1",
        "created_at": datetime(2026, 1, 1, tzinfo=UTC),
    }
    base.update(kw)
    return _SnapshotInfoContract(**base)  # type: ignore[arg-type]


class TestRicherSnapshotInfo:
    def test_new_fields_default_when_omitted(self) -> None:
        """The minimal (pre-Wave-B) construction stays valid (D-D6)."""
        s = _snap()
        assert s.description == ""
        assert s.parent_name is None
        assert s.has_memory is False

    def test_description_and_parent_name_carry(self) -> None:
        s = _snap(description="before upgrade", parent_name="root")
        assert s.description == "before upgrade"
        assert s.parent_name == "root"

    def test_parent_link_by_name_builds_a_tree(self) -> None:
        """Two snapshots link parent↔child by name for a tree view."""
        root = _snap(snapshot_id=SnapshotId("snap-root"), name="root")
        child = _snap(
            snapshot_id=SnapshotId("snap-child"),
            name="child",
            parent_name="root",
        )
        assert root.parent_name is None
        assert child.parent_name == root.name

    def test_is_frozen(self) -> None:
        s = _snap()
        with pytest.raises(dataclasses.FrozenInstanceError):
            s.description = "x"  # type: ignore[misc]


# ===========================================================================
# 2. MetricsSample / DiskIo / NicIo + the OPTIONAL MetricsBackend (D-G13)
# ===========================================================================


class TestMetricsTypes:
    def test_disk_io_defaults_and_target(self) -> None:
        d = DiskIo(target="vda")
        assert d.target == "vda"
        assert (d.read_bytes, d.write_bytes, d.read_reqs, d.write_reqs) == (0, 0, 0, 0)

    def test_nic_io_defaults_and_mac(self) -> None:
        n = NicIo(mac="52:54:00:00:00:01")
        assert n.mac == "52:54:00:00:00:01"
        assert (n.rx_bytes, n.tx_bytes, n.rx_packets, n.tx_packets) == (0, 0, 0, 0)

    def test_metrics_sample_minimal_construction(self) -> None:
        m = MetricsSample(
            cpu_time_ns=123,
            memory_used_bytes=456,
            sampled_at=datetime(2026, 5, 31, tzinfo=UTC),
        )
        assert m.cpu_percent is None
        assert m.memory_available_bytes is None
        assert m.disks == ()
        assert m.nics == ()

    def test_metrics_sample_full(self) -> None:
        m = MetricsSample(
            cpu_time_ns=10,
            memory_used_bytes=20,
            sampled_at=datetime(2026, 5, 31, tzinfo=UTC),
            cpu_percent=12.5,
            memory_available_bytes=99,
            disks=(DiskIo(target="vda", read_bytes=1),),
            nics=(NicIo(mac="aa", tx_bytes=2),),
        )
        assert m.cpu_percent == 12.5
        assert m.disks[0].read_bytes == 1
        assert m.nics[0].tx_bytes == 2

    def test_metrics_sample_rejects_naive_datetime(self) -> None:
        """DP-12 — naive datetimes rejected at the boundary."""
        with pytest.raises(ValueError, match="timezone-aware"):
            MetricsSample(
                cpu_time_ns=0,
                memory_used_bytes=0,
                sampled_at=datetime(2026, 5, 31),
            )

    def test_metrics_sample_rejects_non_utc(self) -> None:
        plus2 = timezone(timedelta(hours=2))
        with pytest.raises(ValueError, match="must be UTC"):
            MetricsSample(
                cpu_time_ns=0,
                memory_used_bytes=0,
                sampled_at=datetime(2026, 5, 31, tzinfo=plus2),
            )

    def test_types_are_frozen(self) -> None:
        assert dataclasses.is_dataclass(MetricsSample)
        assert dataclasses.is_dataclass(DiskIo)
        assert dataclasses.is_dataclass(NicIo)
        d = DiskIo(target="vda")
        with pytest.raises(dataclasses.FrozenInstanceError):
            d.target = "vdb"  # type: ignore[misc]


class TestMetricsBackendProtocol:
    def test_sample_metrics_in_metrics_protocol(self) -> None:
        members = {name for name in dir(MetricsBackend) if not name.startswith("_")}
        assert "sample_metrics" in members

    def test_sample_metrics_not_on_hypervisor(self) -> None:
        """D-G13 — sample_metrics moved OFF HypervisorBackend."""
        members = {name for name in dir(HypervisorBackend) if not name.startswith("_")}
        assert "sample_metrics" not in members

    def test_is_runtime_checkable_protocol(self) -> None:
        from typing import runtime_checkable

        # @runtime_checkable Protocols support isinstance/issubclass checks.
        assert MetricsBackend is runtime_checkable(MetricsBackend)

    def test_is_optional_and_injectable(self) -> None:
        """A composition may pass None for the metrics backend (OPTIONAL)."""
        metrics: MetricsBackend | None = None
        assert metrics is None  # the unaffected default path
        metrics = FakeMetricsBackend(known_domains=[DomainId("d-1")])
        assert isinstance(metrics, MetricsBackend)


class TestFakeMetricsBackend:
    def test_returns_deterministic_zero_sample(self) -> None:
        fixed = datetime(2026, 5, 31, 12, 0, tzinfo=UTC)
        d = DomainId("m-1")
        backend = FakeMetricsBackend(clock=lambda: fixed, known_domains=[d])
        sample = backend.sample_metrics(d)
        assert isinstance(sample, MetricsSample)
        assert sample.cpu_time_ns == 0
        assert sample.memory_used_bytes == 0
        assert sample.cpu_percent is None
        assert sample.memory_available_bytes is None
        assert sample.disks == ()
        assert sample.nics == ()
        assert sample.sampled_at == fixed

    def test_unknown_domain_raises(self) -> None:
        from mgf.vm.core.exceptions import DomainNotFound

        backend = FakeMetricsBackend()
        with pytest.raises(DomainNotFound):
            backend.sample_metrics(DomainId("nope"))

    def test_register_domain_helper(self) -> None:
        d = DomainId("late")
        backend = FakeMetricsBackend()
        backend._register_domain(d)
        assert isinstance(backend.sample_metrics(d), MetricsSample)

    def test_context_manager_closes(self) -> None:
        d = DomainId("d-1")
        with FakeMetricsBackend(known_domains=[d]) as backend:
            assert isinstance(backend.sample_metrics(d), MetricsSample)
        assert backend.closed is True

    def test_satisfies_protocol(self) -> None:
        assert isinstance(FakeMetricsBackend(), MetricsBackend)


# ===========================================================================
# 3. Host-network observation ON HypervisorBackend (D-G14)
#    + NetworkProfile / NetworkLease
# ===========================================================================


class TestNetworkTypes:
    def test_network_profile_defaults(self) -> None:
        n = NetworkProfile(name="default")
        assert n.bridge is None
        assert n.active is False
        assert n.forward_mode is None

    def test_network_profile_full(self) -> None:
        n = NetworkProfile(name="lab", bridge="virbr1", active=True, forward_mode="nat")
        assert (n.name, n.bridge, n.active, n.forward_mode) == ("lab", "virbr1", True, "nat")

    def test_network_lease_defaults(self) -> None:
        lease = NetworkLease(mac="52:54:00:00:00:01")
        assert lease.ip is None
        assert lease.hostname is None
        assert lease.expires_at is None

    def test_network_lease_rejects_naive_expiry(self) -> None:
        with pytest.raises(ValueError, match="timezone-aware"):
            NetworkLease(mac="aa", expires_at=datetime(2026, 5, 31))

    def test_network_lease_utc_expiry_ok(self) -> None:
        exp = datetime(2026, 6, 1, tzinfo=UTC)
        lease = NetworkLease(mac="aa", ip="10.0.0.5", hostname="h", expires_at=exp)
        assert lease.expires_at == exp

    def test_types_frozen(self) -> None:
        assert dataclasses.is_dataclass(NetworkProfile)
        assert dataclasses.is_dataclass(NetworkLease)
        n = NetworkProfile(name="x")
        with pytest.raises(dataclasses.FrozenInstanceError):
            n.name = "y"  # type: ignore[misc]


class TestNetworkObservationOnHypervisor:
    def test_methods_in_protocol(self) -> None:
        members = {name for name in dir(HypervisorBackend) if not name.startswith("_")}
        assert {"list_networks", "network_leases", "bridge_for_domain"} <= members

    def test_network_observation_stays_on_hypervisor(self) -> None:
        """D-G14/D-G15 — *observation* stays on ``HypervisorBackend``; the
        separate ``NetworkBackend`` (D-G15) owns *management* only and carries
        none of the observation reads."""
        from mgf.vm.core.contracts import NetworkBackend

        net_members = {n for n in dir(NetworkBackend) if not n.startswith("_")}
        # Management lives on NetworkBackend …
        assert {
            "define_network",
            "start_network",
            "stop_network",
            "delete_network",
        } <= net_members
        # … but observation does NOT (it stays on HypervisorBackend, D-G14).
        assert not ({"list_networks", "network_leases", "bridge_for_domain"} & net_members)

    def test_default_network_listed(self) -> None:
        backend = FakeHypervisorBackend()
        nets = backend.list_networks()
        assert len(nets) == 1
        assert nets[0].name == "default"
        assert nets[0].active is True
        assert nets[0].forward_mode == "nat"
        assert isinstance(nets[0], NetworkProfile)

    def test_leases_for_known_network(self) -> None:
        backend = FakeHypervisorBackend()
        leases = backend.network_leases("default")
        assert len(leases) == 1
        assert leases[0].ip == "192.168.122.10"

    def test_leases_for_unknown_network_empty(self) -> None:
        backend = FakeHypervisorBackend()
        assert backend.network_leases("ghost") == ()

    def test_bridge_for_domain_none_by_default(self) -> None:
        backend = FakeHypervisorBackend()
        assert backend.bridge_for_domain(DomainId("d-1")) is None

    def test_bridge_for_domain_resolves_when_seeded(self) -> None:
        backend = FakeHypervisorBackend()
        d = DomainId("d-1")
        backend._set_domain_bridge(d, "virbr0")
        assert backend.bridge_for_domain(d) == "virbr0"

    def test_register_network_helper(self) -> None:
        backend = FakeHypervisorBackend()
        backend._register_network(
            NetworkProfile(name="lab", bridge="virbr2", active=True),
            leases=(NetworkLease(mac="bb", ip="10.0.0.9"),),
        )
        assert "lab" in [n.name for n in backend.list_networks()]
        assert backend.network_leases("lab")[0].ip == "10.0.0.9"

    def test_fake_still_satisfies_protocol(self) -> None:
        """The network methods keep the fake a structural HypervisorBackend."""
        assert isinstance(FakeHypervisorBackend(), HypervisorBackend)


# ===========================================================================
# Top-level re-exports (mgf.vm.core.__init__)
# ===========================================================================


def test_new_names_reexported_at_package_top_level() -> None:
    for name in (
        "MetricsSample",
        "DiskIo",
        "NicIo",
        "MetricsBackend",
        "FakeMetricsBackend",
        "NetworkProfile",
        "NetworkLease",
    ):
        assert name in core.__all__, f"{name} missing from mgf.vm.core.__all__"
        assert hasattr(core, name), f"mgf.vm.core has no {name}"

    # The top-level NetworkInfo binds the LEGACY libvirt-shaped
    # domain.NetworkInfo (reverted from the prior build's contract rebinding).
    from mgf.vm.core.domain import NetworkInfo as LegacyNetworkInfo

    assert core.NetworkInfo is LegacyNetworkInfo
