"""Wave-B observation tests — network reads + sample_metrics + richer snapshots.

Drives both ``VmManagerClient`` (over ``InProcessTransport``) and
``AsyncVmManagerClient`` (over ``AsyncInProcessTransport``) against the core
fakes (``FakeHypervisorBackend`` — now implementing the D-G14 host-network
methods + seeding a default network/lease — and ``FakeMetricsBackend``,
D-G13). No libvirt.

Covers:

* ``list_networks`` / ``network_leases`` / ``bridge_for_domain`` round-trips
  on both surfaces, including the seeded default network + an injected
  domain→bridge mapping;
* ``sample_metrics`` round-trip through an injected ``FakeMetricsBackend`` on
  both surfaces, plus the **no-metrics-backend** typed error when none is
  configured (it does NOT silently return empty);
* the original backend error (``DomainNotFound``) preserved across the seam;
* richer ``SnapshotInfo`` (``description`` / ``parent_name``) propagating
  unchanged from a backend that sets them.
"""

from __future__ import annotations

from datetime import UTC, datetime
from typing import TYPE_CHECKING

import pytest
from mgf.vm.core.contracts import (
    NetworkLease,
    NetworkProfile,
    VmSpec,
    VmState,
    _SnapshotInfoContract,
)
from mgf.vm.core.exceptions import DomainNotFound, TransportError
from mgf.vm.core.fakes.hypervisor import FakeHypervisorBackend
from mgf.vm.core.fakes.metrics import FakeMetricsBackend
from mgf.vm.core.ids import DomainId, SnapshotId, VmId

from mgf.vm.sdk.client import AsyncVmManagerClient, VmManagerClient
from mgf.vm.sdk.transport.async_inprocess import AsyncInProcessTransport
from mgf.vm.sdk.transport.inprocess import InProcessTransport

if TYPE_CHECKING:
    from collections.abc import Sequence


def _spec(name: str = "obs-vm") -> VmSpec:
    return VmSpec(name=name, cpu=2, memory_bytes=2 << 30)


# ===========================================================================
# Network reads — sync
# ===========================================================================


def test_list_networks_sync_returns_seeded_default() -> None:
    hv = FakeHypervisorBackend()
    client = VmManagerClient(InProcessTransport.from_adapters(hv))
    nets = client.list_networks()
    assert [n.name for n in nets] == ["default"]
    default = nets[0]
    assert isinstance(default, NetworkProfile)
    assert default.bridge == "virbr0"
    assert default.active is True
    assert default.forward_mode == "nat"


def test_network_leases_sync_known_and_unknown() -> None:
    hv = FakeHypervisorBackend()
    client = VmManagerClient(InProcessTransport.from_adapters(hv))
    leases = client.network_leases("default")
    assert len(leases) == 1
    assert isinstance(leases[0], NetworkLease)
    assert leases[0].mac == "52:54:00:ab:cd:ef"
    assert leases[0].ip == "192.168.122.10"
    # Unknown network → no observed leases (empty), not an error.
    assert client.network_leases("nope") == []


def test_bridge_for_domain_sync_resolves_injected_mapping() -> None:
    hv = FakeHypervisorBackend()
    client = VmManagerClient(InProcessTransport.from_adapters(hv))
    vm_id = client.create_vm(_spec()).vm_id
    # No mapping seeded yet → None.
    assert client.bridge_for_domain(vm_id) is None
    # Seed a domain→bridge placement via the fake's test helper.
    hv._set_domain_bridge(DomainId(vm_id.value), "br-public")
    assert client.bridge_for_domain(vm_id) == "br-public"


# ===========================================================================
# Network reads — async
# ===========================================================================


async def test_list_networks_async_returns_seeded_default() -> None:
    hv = FakeHypervisorBackend()
    transport = AsyncInProcessTransport.from_adapters(hv)
    async with AsyncVmManagerClient(transport) as client:
        nets = await client.list_networks()
        assert [n.name for n in nets] == ["default"]
        assert nets[0].bridge == "virbr0"


async def test_network_leases_async_known_and_unknown() -> None:
    hv = FakeHypervisorBackend()
    transport = AsyncInProcessTransport.from_adapters(hv)
    async with AsyncVmManagerClient(transport) as client:
        leases = await client.network_leases("default")
        assert [lease.mac for lease in leases] == ["52:54:00:ab:cd:ef"]
        assert await client.network_leases("nope") == []


async def test_bridge_for_domain_async_resolves_injected_mapping() -> None:
    hv = FakeHypervisorBackend()
    transport = AsyncInProcessTransport.from_adapters(hv)
    async with AsyncVmManagerClient(transport) as client:
        vm_id = (await client.create_vm(_spec())).vm_id
        assert await client.bridge_for_domain(vm_id) is None
        hv._set_domain_bridge(DomainId(vm_id.value), "br-public")
        assert await client.bridge_for_domain(vm_id) == "br-public"


# ===========================================================================
# Metrics — sync
# ===========================================================================


def test_sample_metrics_sync_round_trip() -> None:
    hv = FakeHypervisorBackend()
    metrics = FakeMetricsBackend()
    client = VmManagerClient(InProcessTransport.from_adapters(hv, metrics=metrics))
    vm_id = client.create_vm(_spec()).vm_id
    # The metrics backend must know the domain to sample it.
    metrics._register_domain(DomainId(vm_id.value))
    sample = client.sample_metrics(vm_id)
    assert sample.cpu_time_ns == 0
    assert sample.memory_used_bytes == 0
    assert sample.sampled_at.tzinfo is not None  # UTC-aware (DP-12)


def test_sample_metrics_sync_unknown_domain_raises_original_error() -> None:
    hv = FakeHypervisorBackend()
    metrics = FakeMetricsBackend()
    client = VmManagerClient(InProcessTransport.from_adapters(hv, metrics=metrics))
    # Backend's own typed error is preserved across the seam (not re-wrapped).
    with pytest.raises(DomainNotFound):
        client.sample_metrics(VmId("never-registered"))


def test_sample_metrics_sync_without_backend_raises_transport_error() -> None:
    hv = FakeHypervisorBackend()
    # No metrics backend injected → typed TransportError, NOT a silent empty.
    client = VmManagerClient(InProcessTransport.from_adapters(hv))
    vm_id = client.create_vm(_spec()).vm_id
    with pytest.raises(TransportError, match="MetricsBackend"):
        client.sample_metrics(vm_id)


# ===========================================================================
# Metrics — async
# ===========================================================================


async def test_sample_metrics_async_round_trip() -> None:
    hv = FakeHypervisorBackend()
    metrics = FakeMetricsBackend()
    transport = AsyncInProcessTransport.from_adapters(hv, metrics=metrics)
    async with AsyncVmManagerClient(transport) as client:
        vm_id = (await client.create_vm(_spec())).vm_id
        metrics._register_domain(DomainId(vm_id.value))
        sample = await client.sample_metrics(vm_id)
        assert sample.cpu_time_ns == 0
        assert sample.sampled_at.tzinfo is not None


async def test_sample_metrics_async_without_backend_raises_transport_error() -> None:
    hv = FakeHypervisorBackend()
    transport = AsyncInProcessTransport.from_adapters(hv)  # no metrics
    async with AsyncVmManagerClient(transport) as client:
        vm_id = (await client.create_vm(_spec())).vm_id
        with pytest.raises(TransportError, match="MetricsBackend"):
            await client.sample_metrics(vm_id)


# ===========================================================================
# Richer snapshots — description / parent_name propagate unchanged
# ===========================================================================


class _RicherSnapshotHypervisor(FakeHypervisorBackend):
    """A fake that surfaces a snapshot carrying ``description`` + ``parent_name``.

    The base fake's ``snapshot()`` leaves the Wave-B fields at their defaults;
    here we override ``list_snapshots`` to return a snapshot that *sets* them,
    proving they flow through the SDK unchanged (the SDK does no reshape).
    """

    def list_snapshots(self, d: DomainId) -> list[_SnapshotInfoContract]:
        return [
            _SnapshotInfoContract(
                snapshot_id=SnapshotId("snap-child"),
                parent=d,
                name="child",
                created_at=datetime(2026, 5, 31, tzinfo=UTC),
                has_memory=True,
                description="nightly backup",
                parent_name="root",
                state=VmState.RUNNING,
            ),
        ]


def _assert_richer(snaps: Sequence[_SnapshotInfoContract]) -> None:
    assert len(snaps) == 1
    snap = snaps[0]
    assert snap.name == "child"
    assert snap.description == "nightly backup"
    assert snap.parent_name == "root"
    assert snap.has_memory is True
    # D-G21: the captured VM state propagates through the SDK unchanged.
    assert snap.state is VmState.RUNNING


def test_list_snapshots_sync_propagates_description_and_parent() -> None:
    hv = _RicherSnapshotHypervisor()
    client = VmManagerClient(InProcessTransport.from_adapters(hv))
    vm_id = client.create_vm(_spec()).vm_id
    _assert_richer(client.list_snapshots(vm_id))


async def test_list_snapshots_async_propagates_description_and_parent() -> None:
    hv = _RicherSnapshotHypervisor()
    transport = AsyncInProcessTransport.from_adapters(hv)
    async with AsyncVmManagerClient(transport) as client:
        vm_id = (await client.create_vm(_spec())).vm_id
        _assert_richer(await client.list_snapshots(vm_id))
