"""Tests for the multi-device mesh system (F-018).

Covers: DeviceManager (registration, removal, listing, persistence, health),
        MeshRouter (routing priority, fallback), and model aggregation.
"""

from __future__ import annotations

import json
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch

import pytest

from llmhosts.mesh.manager import DeviceManager, _parse_vram_from_summary
from llmhosts.mesh.models import Device, DeviceRegistration, DeviceRole, MeshStatus
from llmhosts.mesh.router import MeshRoute, MeshRouter

if TYPE_CHECKING:
    from pathlib import Path


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture()
def mesh_dir(tmp_path: Path) -> Path:
    """Temporary directory for mesh device storage."""
    d = tmp_path / ".llmhosts"
    d.mkdir()
    return d


@pytest.fixture()
def manager(mesh_dir: Path) -> DeviceManager:
    """DeviceManager pointing at a temporary directory."""
    return DeviceManager(mesh_dir, max_devices=5)


def _make_device(
    *,
    name: str = "test-device",
    role: DeviceRole = DeviceRole.HYBRID,
    is_local: bool = True,
    tunnel_url: str | None = None,
    models: list[str] | None = None,
    healthy: bool = True,
    latency_ms: float | None = 0.0,
    hardware_summary: str = "",
) -> Device:
    """Helper to build a Device with sensible defaults."""
    import uuid

    return Device(
        id=str(uuid.uuid4()),
        name=name,
        hostname="testhost",
        role=role,
        tunnel_url=tunnel_url,
        hardware_summary=hardware_summary,
        models=models or [],
        last_seen=datetime.now(tz=timezone.utc),
        is_local=is_local,
        healthy=healthy,
        latency_ms=latency_ms,
    )


# ---------------------------------------------------------------------------
# DeviceManager: Registration
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_register_device(manager: DeviceManager) -> None:
    """Registering a local device should detect hardware and assign an ID."""
    await manager.initialize()

    with (
        patch(
            "llmhosts.mesh.manager._detect_hardware_summary",
            new_callable=AsyncMock,
            return_value="RTX 4090 (24.0GB), 64GB RAM",
        ),
        patch(
            "llmhosts.mesh.manager._discover_local_models", new_callable=AsyncMock, return_value=["llama3.2", "mistral"]
        ),
    ):
        reg = DeviceRegistration(name="my-desktop", role=DeviceRole.COMPUTE)
        device = await manager.register(reg)

    assert device.id  # UUID assigned
    assert device.name == "my-desktop"
    assert device.role == DeviceRole.COMPUTE
    assert device.is_local is True
    assert device.healthy is True
    assert device.hardware_summary == "RTX 4090 (24.0GB), 64GB RAM"
    assert device.models == ["llama3.2", "mistral"]
    assert device.last_seen is not None


@pytest.mark.asyncio
@pytest.mark.unit
async def test_register_remote_device(manager: DeviceManager) -> None:
    """Registering a remote device (with tunnel_url) should skip local detection."""
    await manager.initialize()

    reg = DeviceRegistration(
        name="remote-gpu",
        role=DeviceRole.COMPUTE,
        tunnel_url="https://remote.tail123.ts.net",
    )
    device = await manager.register(reg)

    assert device.is_local is False
    assert device.tunnel_url == "https://remote.tail123.ts.net"
    assert device.hardware_summary == ""  # Not detected for remote
    assert device.models == []  # Not discovered for remote


@pytest.mark.asyncio
@pytest.mark.unit
async def test_register_duplicate_name_fails(manager: DeviceManager) -> None:
    """Registering two devices with the same name should raise ValueError."""
    await manager.initialize()

    with (
        patch("llmhosts.mesh.manager._detect_hardware_summary", new_callable=AsyncMock, return_value=""),
        patch("llmhosts.mesh.manager._discover_local_models", new_callable=AsyncMock, return_value=[]),
    ):
        await manager.register(DeviceRegistration(name="dev-box"))

    with (
        pytest.raises(ValueError, match="already exists"),
        patch("llmhosts.mesh.manager._detect_hardware_summary", new_callable=AsyncMock, return_value=""),
        patch("llmhosts.mesh.manager._discover_local_models", new_callable=AsyncMock, return_value=[]),
    ):
        await manager.register(DeviceRegistration(name="dev-box"))


@pytest.mark.asyncio
@pytest.mark.unit
async def test_register_max_devices(manager: DeviceManager) -> None:
    """Exceeding the device limit should raise ValueError."""
    mgr = DeviceManager(manager._data_dir, max_devices=2)
    await mgr.initialize()

    with (
        patch("llmhosts.mesh.manager._detect_hardware_summary", new_callable=AsyncMock, return_value=""),
        patch("llmhosts.mesh.manager._discover_local_models", new_callable=AsyncMock, return_value=[]),
    ):
        await mgr.register(DeviceRegistration(name="dev-1"))
        await mgr.register(DeviceRegistration(name="dev-2"))

    with (
        pytest.raises(ValueError, match="Device limit reached"),
        patch("llmhosts.mesh.manager._detect_hardware_summary", new_callable=AsyncMock, return_value=""),
        patch("llmhosts.mesh.manager._discover_local_models", new_callable=AsyncMock, return_value=[]),
    ):
        await mgr.register(DeviceRegistration(name="dev-3"))


# ---------------------------------------------------------------------------
# DeviceManager: Removal
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_remove_device(manager: DeviceManager) -> None:
    """Removing a registered device by ID should succeed."""
    await manager.initialize()

    with (
        patch("llmhosts.mesh.manager._detect_hardware_summary", new_callable=AsyncMock, return_value=""),
        patch("llmhosts.mesh.manager._discover_local_models", new_callable=AsyncMock, return_value=[]),
    ):
        dev = await manager.register(DeviceRegistration(name="to-remove"))

    removed = await manager.remove(dev.id)
    assert removed is True

    devices = await manager.list_devices()
    assert len(devices) == 0


@pytest.mark.asyncio
@pytest.mark.unit
async def test_remove_device_by_name(manager: DeviceManager) -> None:
    """Removing a device by name should also work."""
    await manager.initialize()

    with (
        patch("llmhosts.mesh.manager._detect_hardware_summary", new_callable=AsyncMock, return_value=""),
        patch("llmhosts.mesh.manager._discover_local_models", new_callable=AsyncMock, return_value=[]),
    ):
        await manager.register(DeviceRegistration(name="named-dev"))

    removed = await manager.remove("named-dev")
    assert removed is True


@pytest.mark.asyncio
@pytest.mark.unit
async def test_remove_nonexistent_device(manager: DeviceManager) -> None:
    """Removing a device that doesn't exist should return False."""
    await manager.initialize()
    removed = await manager.remove("does-not-exist")
    assert removed is False


# ---------------------------------------------------------------------------
# DeviceManager: Listing
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_list_devices_empty(manager: DeviceManager) -> None:
    """Empty mesh should return empty list."""
    await manager.initialize()
    devices = await manager.list_devices()
    assert devices == []


@pytest.mark.asyncio
@pytest.mark.unit
async def test_list_devices_with_entries(manager: DeviceManager) -> None:
    """After registering devices, list should return them all."""
    await manager.initialize()

    with (
        patch("llmhosts.mesh.manager._detect_hardware_summary", new_callable=AsyncMock, return_value=""),
        patch("llmhosts.mesh.manager._discover_local_models", new_callable=AsyncMock, return_value=[]),
    ):
        await manager.register(DeviceRegistration(name="dev-a"))
        await manager.register(DeviceRegistration(name="dev-b", tunnel_url="https://b.ts.net"))

    devices = await manager.list_devices()
    assert len(devices) == 2
    names = {d.name for d in devices}
    assert names == {"dev-a", "dev-b"}


# ---------------------------------------------------------------------------
# DeviceManager: Health check
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_health_check_local(manager: DeviceManager) -> None:
    """Local device should always be marked healthy."""
    await manager.initialize()

    with (
        patch(
            "llmhosts.mesh.manager._detect_hardware_summary",
            new_callable=AsyncMock,
            return_value="RTX 4090 (24.0GB), 32GB RAM",
        ),
        patch("llmhosts.mesh.manager._discover_local_models", new_callable=AsyncMock, return_value=["llama3.2"]),
    ):
        await manager.register(DeviceRegistration(name="local-box"))

    status = await manager.health_check()

    assert isinstance(status, MeshStatus)
    assert status.local_device is not None
    assert status.local_device.healthy is True
    assert status.local_device.latency_ms == 0.0
    assert status.mesh_healthy is True
    assert status.total_models == 1


# ---------------------------------------------------------------------------
# DeviceManager: Model aggregation
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_all_models(manager: DeviceManager) -> None:
    """get_all_models should return model lists keyed by device ID."""
    await manager.initialize()

    with (
        patch("llmhosts.mesh.manager._detect_hardware_summary", new_callable=AsyncMock, return_value=""),
        patch(
            "llmhosts.mesh.manager._discover_local_models", new_callable=AsyncMock, return_value=["llama3.2", "mistral"]
        ),
    ):
        dev = await manager.register(DeviceRegistration(name="gpu-box"))

    models = await manager.get_all_models()
    assert dev.id in models
    assert "llama3.2" in models[dev.id]
    assert "mistral" in models[dev.id]


@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_all_models_empty(manager: DeviceManager) -> None:
    """Empty mesh should return empty dict."""
    await manager.initialize()
    models = await manager.get_all_models()
    assert models == {}


# ---------------------------------------------------------------------------
# DeviceManager: Persistence
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_device_persistence(mesh_dir: Path) -> None:
    """Devices should survive save/load round-trip."""
    # Register a device, save
    mgr1 = DeviceManager(mesh_dir, max_devices=5)
    await mgr1.initialize()

    with (
        patch(
            "llmhosts.mesh.manager._detect_hardware_summary", new_callable=AsyncMock, return_value="RTX 3080 (10.0GB)"
        ),
        patch("llmhosts.mesh.manager._discover_local_models", new_callable=AsyncMock, return_value=["phi3"]),
    ):
        await mgr1.register(DeviceRegistration(name="persistent-dev", role=DeviceRole.COMPUTE))

    # Load in a fresh manager
    mgr2 = DeviceManager(mesh_dir, max_devices=5)
    await mgr2.initialize()

    devices = await mgr2.list_devices()
    assert len(devices) == 1
    assert devices[0].name == "persistent-dev"
    assert devices[0].role == DeviceRole.COMPUTE
    assert devices[0].hardware_summary == "RTX 3080 (10.0GB)"
    assert devices[0].models == ["phi3"]


@pytest.mark.asyncio
@pytest.mark.unit
async def test_persistence_file_is_valid_json(mesh_dir: Path) -> None:
    """The persisted devices.json should be valid JSON."""
    mgr = DeviceManager(mesh_dir, max_devices=5)
    await mgr.initialize()

    with (
        patch("llmhosts.mesh.manager._detect_hardware_summary", new_callable=AsyncMock, return_value=""),
        patch("llmhosts.mesh.manager._discover_local_models", new_callable=AsyncMock, return_value=[]),
    ):
        await mgr.register(DeviceRegistration(name="json-test"))

    devices_file = mesh_dir / "devices.json"
    assert devices_file.is_file()
    data = json.loads(devices_file.read_text(encoding="utf-8"))
    assert isinstance(data, list)
    assert len(data) == 1
    assert data[0]["name"] == "json-test"


# ---------------------------------------------------------------------------
# MeshRouter: Routing priority
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_mesh_router_prefers_local(mesh_dir: Path) -> None:
    """Router should prefer local device when model is available locally."""
    mgr = DeviceManager(mesh_dir, max_devices=5)
    await mgr.initialize()

    # Inject devices directly for precise control
    local = _make_device(name="local-gpu", is_local=True, models=["llama3.2"], latency_ms=0.0)
    remote = _make_device(
        name="remote-gpu",
        is_local=False,
        models=["llama3.2"],
        tunnel_url="https://remote.ts.net",
        latency_ms=50.0,
    )
    mgr._devices = [local, remote]

    router = MeshRouter(mgr)
    route = await router.route("llama3.2")

    assert route is not None
    assert route.is_local is True
    assert route.device_name == "local-gpu"
    assert route.reason == "Model available locally"


@pytest.mark.asyncio
@pytest.mark.unit
async def test_mesh_router_falls_back_to_remote(mesh_dir: Path) -> None:
    """Router should fall back to remote when model isn't local."""
    mgr = DeviceManager(mesh_dir, max_devices=5)
    await mgr.initialize()

    local = _make_device(name="local-client", is_local=True, models=[], role=DeviceRole.CLIENT)
    remote = _make_device(
        name="gpu-server",
        is_local=False,
        models=["llama3.2"],
        tunnel_url="https://gpu.ts.net",
        latency_ms=30.0,
        hardware_summary="RTX 4090 (24.0GB)",
    )
    mgr._devices = [local, remote]

    router = MeshRouter(mgr)
    route = await router.route("llama3.2")

    assert route is not None
    assert route.is_local is False
    assert route.device_name == "gpu-server"
    assert "gpu-server" in route.reason


@pytest.mark.asyncio
@pytest.mark.unit
async def test_mesh_router_lowest_latency_wins(mesh_dir: Path) -> None:
    """Among remote devices with the model, lowest latency wins."""
    mgr = DeviceManager(mesh_dir, max_devices=5)
    await mgr.initialize()

    # No local device
    slow_remote = _make_device(
        name="slow-gpu",
        is_local=False,
        models=["llama3.2"],
        tunnel_url="https://slow.ts.net",
        latency_ms=200.0,
    )
    fast_remote = _make_device(
        name="fast-gpu",
        is_local=False,
        models=["llama3.2"],
        tunnel_url="https://fast.ts.net",
        latency_ms=15.0,
    )
    mgr._devices = [slow_remote, fast_remote]

    router = MeshRouter(mgr)
    route = await router.route("llama3.2")

    assert route is not None
    assert route.device_name == "fast-gpu"
    assert route.estimated_latency_ms == 15.0


@pytest.mark.asyncio
@pytest.mark.unit
async def test_mesh_route_model_not_anywhere(mesh_dir: Path) -> None:
    """When model doesn't exist on any device, router should still pick local for pull."""
    mgr = DeviceManager(mesh_dir, max_devices=5)
    await mgr.initialize()

    local = _make_device(name="local-box", is_local=True, models=["phi3"])
    mgr._devices = [local]

    router = MeshRouter(mgr)
    route = await router.route("llama3.2")

    assert route is not None
    assert route.is_local is True
    assert "not cached locally" in route.reason


@pytest.mark.asyncio
@pytest.mark.unit
async def test_mesh_router_empty_mesh(mesh_dir: Path) -> None:
    """Empty mesh should return None (cloud fallback)."""
    mgr = DeviceManager(mesh_dir, max_devices=5)
    await mgr.initialize()

    router = MeshRouter(mgr)
    route = await router.route("llama3.2")
    assert route is None


@pytest.mark.asyncio
@pytest.mark.unit
async def test_mesh_router_skips_unhealthy(mesh_dir: Path) -> None:
    """Unhealthy devices should be skipped during routing."""
    mgr = DeviceManager(mesh_dir, max_devices=5)
    await mgr.initialize()

    healthy = _make_device(
        name="healthy-remote",
        is_local=False,
        models=["llama3.2"],
        tunnel_url="https://healthy.ts.net",
        latency_ms=50.0,
        healthy=True,
    )
    unhealthy = _make_device(
        name="dead-remote",
        is_local=False,
        models=["llama3.2"],
        tunnel_url="https://dead.ts.net",
        latency_ms=10.0,
        healthy=False,
    )
    mgr._devices = [unhealthy, healthy]

    router = MeshRouter(mgr)
    route = await router.route("llama3.2")

    assert route is not None
    assert route.device_name == "healthy-remote"


# ---------------------------------------------------------------------------
# MeshRoute model
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_mesh_route_model_fields() -> None:
    """MeshRoute should serialize all fields correctly."""
    route = MeshRoute(
        device_id="abc-123",
        device_name="home-gpu",
        tunnel_url="https://home.ts.net",
        is_local=False,
        model="llama3.2",
        estimated_latency_ms=42.5,
        reason="Forwarded to home-gpu",
    )
    data = route.model_dump()
    assert data["device_id"] == "abc-123"
    assert data["estimated_latency_ms"] == 42.5
    assert data["is_local"] is False


# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_parse_vram_from_summary() -> None:
    """VRAM parser should extract GPU memory from hardware summary strings."""
    assert _parse_vram_from_summary("RTX 4090 (24.0GB), 64GB RAM") == 24.0
    assert _parse_vram_from_summary("RTX 3080 (10.0GB), RTX 3080 (10.0GB)") == 20.0
    assert _parse_vram_from_summary("64GB RAM") == 0.0
    assert _parse_vram_from_summary("") == 0.0


# ---------------------------------------------------------------------------
# Device model
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_device_model_defaults() -> None:
    """Device model should have sensible defaults."""
    dev = Device(
        id="test-id",
        name="test",
        hostname="host",
        role=DeviceRole.HYBRID,
    )
    assert dev.tunnel_url is None
    assert dev.models == []
    assert dev.is_local is False
    assert dev.healthy is True
    assert dev.latency_ms is None


@pytest.mark.unit
def test_device_role_values() -> None:
    """DeviceRole enum should have correct string values."""
    assert DeviceRole.COMPUTE.value == "compute"
    assert DeviceRole.CLIENT.value == "client"
    assert DeviceRole.HYBRID.value == "hybrid"


@pytest.mark.unit
def test_mesh_status_defaults() -> None:
    """MeshStatus should work with empty defaults."""
    status = MeshStatus()
    assert status.local_device is None
    assert status.remote_devices == []
    assert status.total_models == 0
    assert status.total_gpu_vram_gb == 0.0
    assert status.mesh_healthy is True
