"""Tests for mDNS-based LAN discovery module."""

from __future__ import annotations

import time
from unittest.mock import patch

from llmhosts.discovery.mdns import (
    LANNode,
    MDNSDiscovery,
    _get_local_ip,
    _NodeRegistry,
    get_discovered_nodes,
)

# ---------------------------------------------------------------------------
# LANNode model tests
# ---------------------------------------------------------------------------


class TestLANNode:
    """Tests for the LANNode Pydantic model."""

    def test_base_url(self) -> None:
        node = LANNode(hostname="gpu-box", ip="192.168.1.50", port=4000)
        assert node.base_url == "http://192.168.1.50:4000"

    def test_is_stale_fresh_node(self) -> None:
        node = LANNode(hostname="gpu-box", ip="192.168.1.50", port=4000, last_seen=time.time())
        assert node.is_stale is False

    def test_is_stale_old_node(self) -> None:
        node = LANNode(hostname="gpu-box", ip="192.168.1.50", port=4000, last_seen=time.time() - 120)
        assert node.is_stale is True

    def test_model_dump(self) -> None:
        node = LANNode(
            hostname="gpu-box",
            ip="192.168.1.50",
            port=4000,
            version="0.1.0",
            gpu_summary="RTX 4090 24GB",
            models=["llama3.2", "mistral"],
            latency_ms=5.2,
        )
        data = node.model_dump()
        assert data["hostname"] == "gpu-box"
        assert data["gpu_summary"] == "RTX 4090 24GB"
        assert len(data["models"]) == 2

    def test_empty_models(self) -> None:
        node = LANNode(hostname="bare", ip="10.0.0.1", port=4000)
        assert node.models == []

    def test_default_values(self) -> None:
        node = LANNode(hostname="test", ip="10.0.0.1", port=4000)
        assert node.version == ""
        assert node.gpu_summary == ""
        assert node.latency_ms is None
        assert node.last_seen == 0.0


# ---------------------------------------------------------------------------
# _NodeRegistry tests
# ---------------------------------------------------------------------------


class TestNodeRegistry:
    """Tests for the in-memory node registry."""

    def test_upsert_and_active(self) -> None:
        registry = _NodeRegistry()
        node = LANNode(hostname="a", ip="10.0.0.1", port=4000, last_seen=time.time())
        registry.upsert("a._llmhosts._tcp.local.", node)

        active = registry.active_nodes()
        assert len(active) == 1
        assert active[0].hostname == "a"

    def test_remove(self) -> None:
        registry = _NodeRegistry()
        node = LANNode(hostname="a", ip="10.0.0.1", port=4000, last_seen=time.time())
        registry.upsert("key", node)
        assert len(registry.active_nodes()) == 1

        registry.remove("key")
        assert len(registry.active_nodes()) == 0

    def test_remove_nonexistent(self) -> None:
        registry = _NodeRegistry()
        registry.remove("nonexistent")  # should not raise

    def test_stale_nodes_excluded(self) -> None:
        registry = _NodeRegistry()
        fresh = LANNode(hostname="fresh", ip="10.0.0.1", port=4000, last_seen=time.time())
        stale = LANNode(hostname="stale", ip="10.0.0.2", port=4000, last_seen=time.time() - 120)
        registry.upsert("fresh", fresh)
        registry.upsert("stale", stale)

        active = registry.active_nodes()
        assert len(active) == 1
        assert active[0].hostname == "fresh"

    def test_all_nodes_includes_stale(self) -> None:
        registry = _NodeRegistry()
        fresh = LANNode(hostname="fresh", ip="10.0.0.1", port=4000, last_seen=time.time())
        stale = LANNode(hostname="stale", ip="10.0.0.2", port=4000, last_seen=time.time() - 120)
        registry.upsert("fresh", fresh)
        registry.upsert("stale", stale)

        all_nodes = registry.all_nodes()
        assert len(all_nodes) == 2

    def test_sorted_by_hostname(self) -> None:
        registry = _NodeRegistry()
        now = time.time()
        registry.upsert("z", LANNode(hostname="zebra", ip="10.0.0.3", port=4000, last_seen=now))
        registry.upsert("a", LANNode(hostname="alpha", ip="10.0.0.1", port=4000, last_seen=now))
        registry.upsert("m", LANNode(hostname="mid", ip="10.0.0.2", port=4000, last_seen=now))

        active = registry.active_nodes()
        names = [n.hostname for n in active]
        assert names == ["alpha", "mid", "zebra"]

    def test_upsert_overwrites(self) -> None:
        registry = _NodeRegistry()
        now = time.time()
        node1 = LANNode(hostname="box", ip="10.0.0.1", port=4000, models=["a"], last_seen=now)
        node2 = LANNode(hostname="box", ip="10.0.0.1", port=4000, models=["a", "b"], last_seen=now)

        registry.upsert("key", node1)
        assert len(registry.active_nodes()[0].models) == 1

        registry.upsert("key", node2)
        assert len(registry.active_nodes()[0].models) == 2


# ---------------------------------------------------------------------------
# MDNSDiscovery tests (mocked zeroconf)
# ---------------------------------------------------------------------------


class TestMDNSDiscovery:
    """Tests for the MDNSDiscovery class with mocked zeroconf."""

    def test_is_available_when_installed(self) -> None:
        mdns = MDNSDiscovery()
        # This tests whether zeroconf is importable in the test env
        # If not installed, it should return False -- both outcomes are valid
        result = mdns.is_available
        assert isinstance(result, bool)

    def test_is_available_when_not_installed(self) -> None:
        with patch.dict("sys.modules", {"zeroconf": None}):
            mdns = MDNSDiscovery()
            # When zeroconf module is None in sys.modules, import will fail
            # The is_available property catches ImportError
            # Note: patching sys.modules with None causes ImportError
            assert mdns.is_available is False

    async def test_start_without_zeroconf(self) -> None:
        with patch.dict("sys.modules", {"zeroconf": None}):
            mdns = MDNSDiscovery()
            result = await mdns.start(port=4000)
            assert result is False

    async def test_stop_when_not_started(self) -> None:
        mdns = MDNSDiscovery()
        await mdns.stop()  # should not raise

    def test_get_nodes_empty(self) -> None:
        mdns = MDNSDiscovery()
        nodes = mdns.get_nodes()
        assert isinstance(nodes, list)


# ---------------------------------------------------------------------------
# Utility tests
# ---------------------------------------------------------------------------


class TestGetLocalIP:
    """Tests for the _get_local_ip utility."""

    def test_returns_string(self) -> None:
        ip = _get_local_ip()
        assert isinstance(ip, str)
        # Should be an IP address format (or 127.0.0.1 fallback)
        parts = ip.split(".")
        assert len(parts) == 4


# ---------------------------------------------------------------------------
# Integration test for get_discovered_nodes
# ---------------------------------------------------------------------------


class TestGetDiscoveredNodes:
    """Tests for the module-level get_discovered_nodes function."""

    def test_returns_list(self) -> None:
        nodes = get_discovered_nodes()
        assert isinstance(nodes, list)


# ---------------------------------------------------------------------------
# Dispatcher LAN integration tests
# ---------------------------------------------------------------------------


class TestDispatcherLANIntegration:
    """Tests for LAN node integration in the BackendDispatcher."""

    async def test_discover_lan_nodes_without_zeroconf(self) -> None:
        """Dispatcher should handle missing zeroconf gracefully."""
        from llmhosts.proxy.dispatcher import BackendDispatcher

        dispatcher = BackendDispatcher()
        dispatcher._backends = []

        with patch.dict("sys.modules", {"llmhosts.discovery.mdns": None}):
            await dispatcher._discover_lan_nodes()

        assert len(dispatcher._backends) == 0

    async def test_discover_lan_nodes_with_nodes(self) -> None:
        """Dispatcher should add LAN nodes as backends."""
        from llmhosts.proxy.dispatcher import BackendDispatcher

        dispatcher = BackendDispatcher()
        dispatcher._backends = []

        mock_nodes = [
            LANNode(
                hostname="gpu-box",
                ip="192.168.1.50",
                port=4000,
                models=["llama3.2", "mistral"],
                last_seen=time.time(),
            ),
        ]

        with patch("llmhosts.discovery.mdns.get_discovered_nodes", return_value=mock_nodes):
            await dispatcher._discover_lan_nodes()

        assert len(dispatcher._backends) == 1
        assert dispatcher._backends[0].name == "lan-gpu-box"
        assert dispatcher._backends[0].priority == 5
        assert "llama3.2" in dispatcher._backends[0].models

    async def test_refresh_lan_nodes(self) -> None:
        """refresh_lan_nodes should clear old LAN entries and re-discover."""
        from llmhosts.proxy.dispatcher import BackendDispatcher, BackendEntry

        dispatcher = BackendDispatcher()

        # Pre-populate with a LAN entry and a local entry
        dispatcher._backends = [
            BackendEntry(name="ollama-local", base_url="http://127.0.0.1:11434", backend_type="ollama", models=["x"]),
            BackendEntry(name="lan-old", base_url="http://10.0.0.1:4000", backend_type="ollama", models=["y"]),
        ]

        with patch("llmhosts.discovery.mdns.get_discovered_nodes", return_value=[]):
            count = await dispatcher.refresh_lan_nodes()

        assert count == 0
        # Only the local entry should remain
        assert len(dispatcher._backends) == 1
        assert dispatcher._backends[0].name == "ollama-local"

    async def test_lan_nodes_avoid_duplicate_local(self) -> None:
        """LAN discovery should not duplicate the local Ollama backend URL."""
        from llmhosts.proxy.dispatcher import BackendDispatcher, BackendEntry

        dispatcher = BackendDispatcher()
        dispatcher._backends = [
            BackendEntry(
                name="ollama-local",
                base_url="http://192.168.1.50:4000",
                backend_type="ollama",
                models=["x"],
            ),
        ]

        mock_nodes = [
            LANNode(
                hostname="self",
                ip="192.168.1.50",
                port=4000,
                models=["x"],
                last_seen=time.time(),
            ),
        ]

        with patch("llmhosts.discovery.mdns.get_discovered_nodes", return_value=mock_nodes):
            await dispatcher._discover_lan_nodes()

        # Should not add a duplicate
        assert len(dispatcher._backends) == 1


# ---------------------------------------------------------------------------
# Server startup integration
# ---------------------------------------------------------------------------


class TestServerLANDiscovery:
    """Tests for LAN discovery integration in the LLMHostsServer."""

    async def test_start_lan_discovery_without_zeroconf(self) -> None:
        """Server should handle missing zeroconf gracefully."""
        from llmhosts.config import LLMHostsConfig
        from llmhosts.server import LLMHostsServer, StartupReport

        config = LLMHostsConfig()
        server = LLMHostsServer(config)
        report = StartupReport()
        warnings: list[str] = []

        with patch.dict("sys.modules", {"llmhosts.discovery.mdns": None}):
            result = await server._start_lan_discovery(report, warnings)

        assert result.lan_discovery is False

    async def test_startup_report_has_lan_fields(self) -> None:
        """StartupReport should include LAN discovery fields."""
        from llmhosts.server import StartupReport

        report = StartupReport()
        assert report.lan_nodes == 0
        assert report.lan_discovery is False

        report.lan_discovery = True
        report.lan_nodes = 3
        assert report.lan_nodes == 3
        assert report.lan_discovery is True
