"""Discovery watcher -- monitors local model directory for changes.

Polls ``~/.llmhosts/models/`` for new or removed GGUF files.  Change
callbacks update the BackendDispatcher live, so new models appear in the
API within seconds of being downloaded.

The old engine-scanning loop is removed -- LLMHosts Core is the sole
local engine and doesn't need HTTP probing.
"""

from __future__ import annotations

import asyncio
import contextlib
import logging
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from collections.abc import Callable

    from llmhosts.discovery.models import DiscoveredEngine

logger = logging.getLogger(__name__)


class DiscoveryWatcher:
    """Background daemon that monitors local models for changes.

    Usage::

        watcher = DiscoveryWatcher(
            on_model_added=lambda name: dispatcher.add_model(name),
            on_model_removed=lambda name: dispatcher.remove_model(name),
        )
        await watcher.start()
        # ... runs in background ...
        await watcher.stop()
    """

    def __init__(
        self,
        scanner: Any = None,  # kept for API compat but unused
        *,
        model_interval: float = 5.0,
        engine_interval: float = 30.0,
        on_models_changed: Callable[[DiscoveredEngine, list[str]], Any] | None = None,
        on_engine_discovered: Callable[[DiscoveredEngine], Any] | None = None,
        on_engine_lost: Callable[[DiscoveredEngine], Any] | None = None,
        on_model_added: Callable[[str], Any] | None = None,
        on_model_removed: Callable[[str], Any] | None = None,
    ) -> None:
        self._scanner = scanner  # kept for backward compat
        self._model_interval = model_interval
        self._engine_interval = engine_interval
        self._on_models_changed = on_models_changed
        self._on_engine_discovered = on_engine_discovered
        self._on_engine_lost = on_engine_lost
        self._on_model_added = on_model_added
        self._on_model_removed = on_model_removed

        self._known_engines: dict[str, DiscoveredEngine] = {}  # key = "host:port"
        self._last_models: set[str] = set()
        self._model_task: asyncio.Task[None] | None = None
        self._engine_task: asyncio.Task[None] | None = None
        self._stop_event = asyncio.Event()
        self._callback_timeout = 5.0

    async def _invoke_callback(self, callback: Any, *args: Any) -> None:
        """Invoke a callback (sync or async) with a timeout guard."""
        try:
            result = callback(*args)
            if asyncio.iscoroutine(result):
                await asyncio.wait_for(result, timeout=self._callback_timeout)
        except asyncio.TimeoutError:
            logger.warning("Callback %s timed out after %.1fs", callback, self._callback_timeout)
        except Exception:
            logger.debug("Callback %s error", callback, exc_info=True)

    @property
    def known_engines(self) -> list[DiscoveredEngine]:
        """Return the current list of known engines."""
        return list(self._known_engines.values())

    @property
    def all_models(self) -> list[str]:
        """Return a flat list of all model IDs across all known engines."""
        models: list[str] = []
        for engine in self._known_engines.values():
            models.extend(m.id for m in engine.models)
        # Also include locally-discovered models
        models.extend(self._last_models - set(models))
        return models

    async def start(self, initial_engines: list[DiscoveredEngine] | None = None) -> None:
        """Start background polling tasks.

        Safe to call multiple times -- stops existing tasks before creating new ones.
        """
        if self._model_task or self._engine_task:
            await self.stop()

        if initial_engines:
            for engine in initial_engines:
                key = f"{engine.host}:{engine.port}"
                self._known_engines[key] = engine

        # Seed _last_models from initial engines
        for engine in initial_engines or []:
            for m in engine.models:
                self._last_models.add(m.id)

        # Also seed from local filesystem
        try:
            from llmhosts.models.download import list_local_models

            self._last_models.update(list_local_models())
        except Exception:
            pass

        self._stop_event.clear()
        self._model_task = asyncio.create_task(self._model_poll_loop())
        # Engine scan task kept as a no-op placeholder for API compat
        self._engine_task = asyncio.create_task(self._engine_scan_loop())
        logger.info(
            "DiscoveryWatcher started (model_interval=%.1fs, known_models=%d)",
            self._model_interval,
            len(self._last_models),
        )

    async def stop(self) -> None:
        """Stop background polling."""
        self._stop_event.set()
        for task in (self._model_task, self._engine_task):
            if task and not task.done():
                task.cancel()
                with contextlib.suppress(asyncio.CancelledError):
                    await task
        self._model_task = None
        self._engine_task = None
        logger.info("DiscoveryWatcher stopped")

    # ------------------------------------------------------------------
    # Model polling (filesystem-based)
    # ------------------------------------------------------------------

    async def _model_poll_loop(self) -> None:
        """Poll ~/.llmhosts/models/ for new or removed GGUF files."""
        while not self._stop_event.is_set():
            try:
                await asyncio.sleep(self._model_interval)
                if self._stop_event.is_set():
                    break
                await self._poll_models()
            except asyncio.CancelledError:
                break
            except Exception:
                logger.debug("Model poll error", exc_info=True)

    async def _poll_models(self) -> None:
        """Check for model changes in the local models directory."""
        from llmhosts.models.download import list_local_models

        current = set(list_local_models())
        if current != self._last_models:
            added = current - self._last_models
            removed = self._last_models - current

            for m in added:
                logger.info("Model added: %s", m)
                if self._on_model_added:
                    await self._invoke_callback(self._on_model_added, m)

            for m in removed:
                logger.info("Model removed: %s", m)
                if self._on_model_removed:
                    await self._invoke_callback(self._on_model_removed, m)

            # Fire legacy on_models_changed for backward compat
            if self._on_models_changed and self._known_engines:
                from llmhosts.discovery.models import DiscoveredModel

                # Update the first known engine's model list
                for _key, engine in list(self._known_engines.items()):
                    engine.models = [DiscoveredModel(id=m, engine_type=engine.engine_type) for m in current]
                    await self._invoke_callback(self._on_models_changed, engine, sorted(current))
                    break  # Only fire once

            self._last_models = current

    # ------------------------------------------------------------------
    # Engine scanning (no-op -- Core is always present)
    # ------------------------------------------------------------------

    async def _engine_scan_loop(self) -> None:
        """Placeholder -- engine scanning is no longer needed.

        LLMHosts Core is compiled-in and doesn't need HTTP probing.
        This loop exists for API compatibility and does nothing.
        """
        while not self._stop_event.is_set():
            try:
                await asyncio.sleep(self._engine_interval)
                if self._stop_event.is_set():
                    break
            except asyncio.CancelledError:
                break

    async def _scan_engines_once(self) -> None:
        """Run a single engine scan (uses scanner if provided, for backward compat)."""
        if self._scanner is None:
            return
        try:
            discovered = await self._scanner.scan()
        except Exception:
            logger.debug("Engine scan error", exc_info=True)
            return

        discovered_keys = {f"{e.host}:{e.port}" for e in discovered}
        current_keys = set(self._known_engines.keys())

        # New engines
        for engine in discovered:
            key = f"{engine.host}:{engine.port}"
            if key not in current_keys:
                self._known_engines[key] = engine
                logger.info("Engine discovered: %s at %s", engine.engine_type, key)
                if self._on_engine_discovered:
                    await self._invoke_callback(self._on_engine_discovered, engine)

        # Lost engines
        for key in current_keys - discovered_keys:
            lost_engine = self._known_engines.pop(key, None)
            if lost_engine:
                logger.info("Engine lost: %s at %s", lost_engine.engine_type, key)
                if self._on_engine_lost:
                    await self._invoke_callback(self._on_engine_lost, lost_engine)
