"""Tests for the DiscoveryWatcher.

Validates:
- Model change detection (add/remove via filesystem polling)
- Engine discovery and loss callbacks (backward compat)
- Proper start/stop lifecycle
- Known engines tracking
"""

from __future__ import annotations

from unittest.mock import AsyncMock, patch

import pytest

from llmhosts.discovery.models import DiscoveredEngine, DiscoveredModel
from llmhosts.discovery.scanner import UniversalScanner
from llmhosts.discovery.watcher import DiscoveryWatcher

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


def _make_engine(
    engine_type: str = "llmhosts-core",
    host: str = "127.0.0.1",
    port: int = 0,
    models: list[str] | None = None,
) -> DiscoveredEngine:
    """Build a DiscoveredEngine with optional model IDs."""
    model_list = [DiscoveredModel(id=m, engine_type=engine_type) for m in (models or [])]
    return DiscoveredEngine(
        engine_type=engine_type,
        host=host,
        port=port,
        models=model_list,
        api_compat="openai",
    )


# ---------------------------------------------------------------------------
# Lifecycle tests
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_watcher_start_stop() -> None:
    """Watcher should start and stop cleanly."""
    scanner = AsyncMock(spec=UniversalScanner)
    watcher = DiscoveryWatcher(scanner=scanner, model_interval=100, engine_interval=100)

    await watcher.start()
    assert watcher._model_task is not None
    assert watcher._engine_task is not None

    await watcher.stop()
    assert watcher._model_task is None
    assert watcher._engine_task is None


@pytest.mark.unit
async def test_watcher_double_start_safe() -> None:
    """Calling start() twice should not leak orphan tasks."""
    scanner = AsyncMock(spec=UniversalScanner)
    watcher = DiscoveryWatcher(scanner=scanner, model_interval=100, engine_interval=100)

    await watcher.start()
    first_model_task = watcher._model_task
    first_engine_task = watcher._engine_task

    # Start again -- should stop old tasks first
    await watcher.start()
    assert first_model_task.cancelled() or first_model_task.done()
    assert first_engine_task.cancelled() or first_engine_task.done()
    assert watcher._model_task is not None
    assert watcher._engine_task is not None

    await watcher.stop()


@pytest.mark.unit
async def test_watcher_start_with_initial_engines() -> None:
    """Watcher should register initial engines on start."""
    scanner = AsyncMock(spec=UniversalScanner)
    watcher = DiscoveryWatcher(scanner=scanner, model_interval=100, engine_interval=100)

    engines = [
        _make_engine("llmhosts-core", models=["qwen3:8b"]),
        _make_engine("generic", host="192.168.1.50", port=8000, models=["Llama-3.2-8B"]),
    ]

    await watcher.start(initial_engines=engines)

    assert len(watcher.known_engines) == 2
    assert len(watcher.all_models) == 2

    await watcher.stop()


# ---------------------------------------------------------------------------
# Known engines tracking
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_known_engines_property() -> None:
    """known_engines should reflect the current state."""
    scanner = AsyncMock(spec=UniversalScanner)
    watcher = DiscoveryWatcher(scanner=scanner, model_interval=100, engine_interval=100)

    engine = _make_engine("llmhosts-core", models=["model-a", "model-b"])
    await watcher.start(initial_engines=[engine])

    known = watcher.known_engines
    assert len(known) == 1
    assert known[0].engine_type == "llmhosts-core"

    await watcher.stop()


@pytest.mark.unit
async def test_all_models_aggregates() -> None:
    """all_models should return flat list across all engines."""
    scanner = AsyncMock(spec=UniversalScanner)
    watcher = DiscoveryWatcher(scanner=scanner, model_interval=100, engine_interval=100)

    engines = [
        _make_engine("llmhosts-core", models=["a", "b"]),
        _make_engine("generic", host="192.168.1.50", port=8000, models=["c"]),
    ]
    await watcher.start(initial_engines=engines)

    assert set(watcher.all_models) == {"a", "b", "c"}

    await watcher.stop()


# ---------------------------------------------------------------------------
# Engine scan callback tests (backward compat)
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_engine_discovered_callback() -> None:
    """on_engine_discovered should fire when a new engine appears in scan."""
    scanner = AsyncMock(spec=UniversalScanner)
    discovered_engines: list[DiscoveredEngine] = []

    watcher = DiscoveryWatcher(
        scanner=scanner,
        model_interval=100,
        engine_interval=100,
        on_engine_discovered=lambda e: discovered_engines.append(e),
    )

    # Start with no engines
    await watcher.start()

    # Simulate a scan finding a new engine
    new_engine = _make_engine("llmhosts-core", models=["qwen3:8b"])
    scanner.scan = AsyncMock(return_value=[new_engine])

    await watcher._scan_engines_once()

    assert len(discovered_engines) == 1
    assert discovered_engines[0].engine_type == "llmhosts-core"
    assert len(watcher.known_engines) == 1

    await watcher.stop()


@pytest.mark.unit
async def test_engine_lost_callback() -> None:
    """on_engine_lost should fire when a known engine disappears from scan."""
    scanner = AsyncMock(spec=UniversalScanner)
    lost_engines: list[DiscoveredEngine] = []

    watcher = DiscoveryWatcher(
        scanner=scanner,
        model_interval=100,
        engine_interval=100,
        on_engine_lost=lambda e: lost_engines.append(e),
    )

    initial = _make_engine("llmhosts-core", models=["qwen3:8b"])
    await watcher.start(initial_engines=[initial])
    assert len(watcher.known_engines) == 1

    # Simulate scan returning empty (engine went offline)
    scanner.scan = AsyncMock(return_value=[])

    await watcher._scan_engines_once()

    assert len(lost_engines) == 1
    assert lost_engines[0].engine_type == "llmhosts-core"
    assert len(watcher.known_engines) == 0

    await watcher.stop()


@pytest.mark.unit
async def test_engine_stays_known_when_still_alive() -> None:
    """If an engine is still in scan results, no callback should fire."""
    scanner = AsyncMock(spec=UniversalScanner)
    discovered_engines: list[DiscoveredEngine] = []
    lost_engines: list[DiscoveredEngine] = []

    watcher = DiscoveryWatcher(
        scanner=scanner,
        model_interval=100,
        engine_interval=100,
        on_engine_discovered=lambda e: discovered_engines.append(e),
        on_engine_lost=lambda e: lost_engines.append(e),
    )

    initial = _make_engine("llmhosts-core", models=["qwen3:8b"])
    await watcher.start(initial_engines=[initial])

    # Scan returns the same engine
    scanner.scan = AsyncMock(return_value=[initial])

    await watcher._scan_engines_once()

    assert len(discovered_engines) == 0  # No new engines
    assert len(lost_engines) == 0  # No lost engines

    await watcher.stop()


# ---------------------------------------------------------------------------
# Model change detection (filesystem-based)
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_model_added_detected() -> None:
    """Watcher should detect new models via filesystem polling."""
    watcher = DiscoveryWatcher(model_interval=100, engine_interval=100)
    watcher._last_models = {"qwen3:8b"}

    added: list[str] = []
    watcher._on_model_added = lambda m: added.append(m)

    with patch("llmhosts.models.download.list_local_models", return_value=["qwen3:8b", "llama3.3:70b"]):
        await watcher._poll_models()

    assert "llama3.3:70b" in added
    assert watcher._last_models == {"qwen3:8b", "llama3.3:70b"}


@pytest.mark.unit
async def test_model_removed_detected() -> None:
    """Watcher should detect removed models via filesystem polling."""
    watcher = DiscoveryWatcher(model_interval=100, engine_interval=100)
    watcher._last_models = {"qwen3:8b", "llama3.3:70b"}

    removed: list[str] = []
    watcher._on_model_removed = lambda m: removed.append(m)

    with patch("llmhosts.models.download.list_local_models", return_value=["qwen3:8b"]):
        await watcher._poll_models()

    assert "llama3.3:70b" in removed
    assert watcher._last_models == {"qwen3:8b"}


@pytest.mark.unit
async def test_no_callback_when_models_unchanged() -> None:
    """No callbacks should fire when model list hasn't changed."""
    watcher = DiscoveryWatcher(model_interval=100, engine_interval=100)
    watcher._last_models = {"qwen3:8b"}

    added: list[str] = []
    removed: list[str] = []
    watcher._on_model_added = lambda m: added.append(m)
    watcher._on_model_removed = lambda m: removed.append(m)

    with patch("llmhosts.models.download.list_local_models", return_value=["qwen3:8b"]):
        await watcher._poll_models()

    assert len(added) == 0
    assert len(removed) == 0


@pytest.mark.unit
async def test_legacy_models_changed_callback() -> None:
    """Legacy on_models_changed callback should fire for backward compat."""
    scanner = AsyncMock(spec=UniversalScanner)
    model_changes: list[tuple[str, list[str]]] = []

    watcher = DiscoveryWatcher(
        scanner=scanner,
        model_interval=100,
        engine_interval=100,
        on_models_changed=lambda eng, models: model_changes.append((eng.engine_type, models)),
    )

    initial = _make_engine("llmhosts-core", models=["qwen3:8b"])
    await watcher.start(initial_engines=[initial])

    # Simulate model added
    with patch("llmhosts.models.download.list_local_models", return_value=["qwen3:8b", "llama3.3:70b"]):
        await watcher._poll_models()

    assert len(model_changes) == 1
    assert model_changes[0][0] == "llmhosts-core"
    assert set(model_changes[0][1]) == {"qwen3:8b", "llama3.3:70b"}

    await watcher.stop()


# ---------------------------------------------------------------------------
# Async callback support
# ---------------------------------------------------------------------------


@pytest.mark.unit
async def test_async_callback_support() -> None:
    """Watcher should handle both sync and async callbacks."""
    scanner = AsyncMock(spec=UniversalScanner)
    results: list[str] = []

    async def async_on_discovered(engine: DiscoveredEngine) -> None:
        results.append(f"async:{engine.engine_type}")

    watcher = DiscoveryWatcher(
        scanner=scanner,
        model_interval=100,
        engine_interval=100,
        on_engine_discovered=async_on_discovered,
    )

    await watcher.start()

    new_engine = _make_engine("generic", host="192.168.1.50", port=8000, models=["model-1"])
    scanner.scan = AsyncMock(return_value=[new_engine])

    await watcher._scan_engines_once()

    assert len(results) == 1
    assert results[0] == "async:generic"

    await watcher.stop()
