"""LLMHosts server orchestrator -- wires all components and runs uvicorn.

The :class:`LLMHostsServer` is created by ``llmhost serve`` and manages
the full lifecycle: config loading, Ollama discovery, hardware detection,
router/cache initialisation, FastAPI app creation, and uvicorn execution.
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any

import uvicorn
from pydantic import BaseModel, Field

if TYPE_CHECKING:
    from fastapi import FastAPI

    from llmhosts.config import LLMHostsConfig

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Startup report
# ---------------------------------------------------------------------------


class StartupReport(BaseModel):
    """Report of what was discovered / initialised at startup."""

    ollama_available: bool = False
    ollama_models: list[str] = Field(default_factory=list)
    cloud_providers: list[str] = Field(default_factory=list)
    hardware_summary: str = ""
    router_tier: str = "Rule-based (Tier 0)"
    cache_tier: str = "Exact hash"
    lan_nodes: int = 0
    lan_discovery: bool = False
    endpoints: list[str] = Field(default_factory=list)
    warnings: list[str] = Field(default_factory=list)


# ---------------------------------------------------------------------------
# Server orchestrator
# ---------------------------------------------------------------------------


class LLMHostsServer:
    """Orchestrates all LLMHosts components for the ``serve`` command."""

    def __init__(self, config: LLMHostsConfig) -> None:
        self.config = config
        self._app: FastAPI | None = None
        self._report: StartupReport | None = None
        self._mdns: Any = None  # MDNSDiscovery instance (lazy)

    # -- public API --------------------------------------------------------

    @staticmethod
    def read_only_notice() -> str:
        """Return the auto-provision notice for display."""
        return (
            "LLMHosts auto-provisions: if no inference engine is found, "
            "it installs Ollama and pulls a starter model for your hardware."
        )

    async def startup(self) -> StartupReport:
        """Initialise all components and return a :class:`StartupReport`.

        Steps:
        1. Ensure data directory exists
        2. Discover Ollama (if ``auto_discover=True``)
        3. Detect hardware
        4. Enumerate BYOK cloud keys
        5. Determine router / cache tiers
        6. Create FastAPI app
        7. Return report
        """
        from llmhosts.config import llmhosts_dir

        report = StartupReport()
        warnings: list[str] = []

        logger.info("Starting LLMHosts server...")

        # 1. Data directory
        data_dir = llmhosts_dir()
        logger.debug("Data directory: %s", data_dir)

        # 2. Ollama discovery
        if self.config.ollama.auto_discover:
            report = await self._discover_ollama(report, warnings)
        else:
            warnings.append("Ollama auto-discovery disabled in config")

        # 3. Hardware detection
        report = await self._detect_hardware(report)

        # 3b. LAN discovery (mDNS background)
        report = await self._start_lan_discovery(report, warnings)

        # 4. BYOK cloud keys
        report = self._enumerate_cloud_keys(report, warnings)

        # 5. Router tier
        report = self._determine_router_tier(report)

        # 6. Cache tier
        report = self._determine_cache_tier(report)

        # 7. Endpoints
        report.endpoints = self._list_endpoints()

        # Auto-provision if no backends found (same provisioner as `llmhosts up`).
        # Only when auto_discover is enabled — respect explicit config disabling.
        if self.config.ollama.auto_discover and not report.ollama_available and not report.cloud_providers:
            report = await self._auto_provision(report, warnings)

        # Final warning check
        if not report.ollama_available and not report.cloud_providers:
            warnings.append("No backends available — add Ollama models or BYOK cloud keys")

        report.warnings = warnings
        self._report = report

        # 8. Observability (Sentry + structured logging)
        from llmhosts.observability import init_observability

        init_observability(self.config.observability)

        # 9. Create FastAPI app
        self._app = self._create_app()

        return report

    def run(self) -> None:
        """Start the uvicorn server. This is a **blocking** call."""
        import platform

        if self._app is None:
            raise RuntimeError("Call startup() before run()")

        log_level = self.config.server.log_level.lower()

        # On Windows, httptools (C extension) can silently fail to deliver
        # HTTP data to the ASGI app.  Force h11 (pure-Python) for reliability.
        http_impl = "h11" if platform.system() == "Windows" else "auto"

        uvicorn.run(
            self._app,
            host=self.config.server.host,
            port=self.config.server.port,
            workers=self.config.server.workers,
            log_level=log_level,
            access_log=log_level == "debug",
            http=http_impl,
        )

    async def shutdown(self) -> None:
        """Clean shutdown of all components."""
        if self._mdns is not None:
            await self._mdns.stop()
        logger.info("LLMHosts server shutting down")

    # -- internal helpers --------------------------------------------------

    async def _auto_provision(self, report: StartupReport, warnings: list[str]) -> StartupReport:
        """Auto-install Ollama and pull a starter model when no backends exist.

        Uses the same EngineProvisioner as ``llmhosts up``. On success,
        re-discovers Ollama so the server starts with working backends.
        """
        try:
            from llmhosts.discovery.models import HardwareProfile
            from llmhosts.discovery.provisioner import EngineProvisioner

            logger.info("No backends found — auto-provisioning inference engine...")

            # Build a minimal hardware profile from what we already detected
            hardware = HardwareProfile(ram_total_gb=8.0)
            try:
                from llmhosts.discovery.hardware import HardwareDetector

                hardware = await HardwareDetector.detect()
            except Exception:
                pass

            provisioner = EngineProvisioner(
                on_status=lambda msg: logger.info("Provision: %s", msg),
            )
            result = await provisioner.provision(hardware)

            if result.success:
                logger.info(
                    "Provisioned %s (%.1fGB) — re-discovering backends",
                    result.model_pulled,
                    result.model_size_gb,
                )
                # Re-run Ollama discovery now that it's installed + has a model
                report = await self._discover_ollama(report, warnings)
            else:
                logger.warning("Auto-provision failed: %s", result.error)
                warnings.append(f"Auto-provision failed: {result.error}")
        except Exception as exc:
            logger.debug("Auto-provision error: %s", exc)
            warnings.append(f"Auto-provision error: {exc}")

        return report

    async def _discover_ollama(self, report: StartupReport, warnings: list[str]) -> StartupReport:
        """Probe the Ollama instance for availability and installed models."""
        from llmhosts.discovery.ollama import OllamaDiscovery

        async with OllamaDiscovery(
            host=self.config.ollama.host,
            timeout=min(self.config.ollama.timeout, 10.0),  # quick probe at startup
        ) as ollama:
            available = await ollama.is_available()
            report.ollama_available = available
            if available:
                models = await ollama.list_models()
                report.ollama_models = [m.name for m in models]
                if not models:
                    warnings.append("Ollama is running but has no models installed")
            else:
                warnings.append(
                    f"Ollama not reachable at {self.config.ollama.host} — "
                    "install from https://ollama.com or set [ollama] host"
                )
        return report

    async def _detect_hardware(self, report: StartupReport) -> StartupReport:
        """Run hardware detection and build a human-readable summary."""
        try:
            from llmhosts.discovery.hardware import HardwareDetector

            hw = await HardwareDetector.detect()
            parts: list[str] = []
            if hw.gpus:
                for gpu in hw.gpus:
                    vram_gb = round(gpu.vram_total_mb / 1024, 1)
                    parts.append(f"{gpu.name} ({vram_gb}GB)")
            ram_str = f"{hw.ram_total_gb:.0f}GB RAM"
            if parts:
                report.hardware_summary = ", ".join(parts) + f", {ram_str}"
            else:
                report.hardware_summary = f"CPU only, {ram_str}"
        except Exception as exc:
            logger.debug("Hardware detection failed: %s", exc)
            report.hardware_summary = "detection failed"
        return report

    async def _start_lan_discovery(self, report: StartupReport, warnings: list[str]) -> StartupReport:
        """Start mDNS advertising and browsing in the background."""
        try:
            from llmhosts.discovery.mdns import MDNSDiscovery

            mdns = MDNSDiscovery()
            if not mdns.is_available:
                logger.debug("zeroconf not installed -- LAN discovery skipped")
                return report

            gpu_summary = report.hardware_summary
            models = report.ollama_models
            started = await mdns.start(
                port=self.config.server.port,
                gpu_summary=gpu_summary,
                models=models,
            )
            if started:
                self._mdns = mdns
                report.lan_discovery = True
                # Give a brief moment for initial discovery
                import asyncio

                await asyncio.sleep(1.0)
                nodes = mdns.get_nodes()
                report.lan_nodes = len(nodes)
        except Exception as exc:
            logger.debug("LAN discovery startup failed: %s", exc)
            warnings.append(f"LAN discovery failed: {exc}")
        return report

    def _enumerate_cloud_keys(self, report: StartupReport, warnings: list[str]) -> StartupReport:
        """Check which BYOK keys are configured."""
        try:
            from llmhosts.keys.manager import KeyManager

            mgr = KeyManager()
            providers = mgr.list_providers()
            report.cloud_providers = [p.provider for p in providers]
        except Exception as exc:
            logger.debug("Key enumeration failed: %s", exc)
            warnings.append(f"Could not read BYOK keys: {exc}")
        return report

    def _determine_router_tier(self, report: StartupReport) -> StartupReport:
        """Determine which router tier is active based on config + installed deps."""
        cfg = self.config.router
        if cfg.enable_qwen and _is_importable("torch"):
            report.router_tier = "Full hybrid (Tier 3: kNN + ModernBERT + Qwen-0.5B)"
        elif cfg.enable_modernbert and _is_importable("onnxruntime"):
            report.router_tier = "Smart (Tier 2: kNN + ModernBERT)"
        elif cfg.enable_knn and _is_importable("faiss"):
            report.router_tier = "kNN + Rule-based (Tier 1)"
        else:
            report.router_tier = "Rule-based (Tier 0)"
        return report

    def _determine_cache_tier(self, report: StartupReport) -> StartupReport:
        """Determine which cache tier is active based on config."""
        cfg = self.config.cache
        if not cfg.enabled:
            report.cache_tier = "Disabled"
        elif cfg.enable_vcache:
            report.cache_tier = "vCache + Namespace + Exact hash"
        elif cfg.enable_namespace:
            report.cache_tier = "Namespace + Exact hash"
        else:
            report.cache_tier = "Exact hash"
        return report

    def _list_endpoints(self) -> list[str]:
        """Return the list of API endpoint paths the proxy exposes."""
        endpoints = [
            "/v1/chat/completions  (OpenAI)",
            "/v1/messages           (Anthropic)",
            "/v1/models             (model list)",
            "/health                (health check)",
            "/ready                 (readiness probe)",
            "/api/savings           (dashboard savings)",
            "/api/metrics           (dashboard metrics)",
            "/api/requests          (request log)",
            "/api/backends          (backend status)",
            "/api/config            (config - sanitised)",
            "/api/ws/live           (WebSocket live)",
        ]
        if self.config.dashboard.enabled and self.config.dashboard.web:
            endpoints.append("/dashboard              (web UI)")
        return endpoints

    def _create_app(self) -> FastAPI:
        """Create the FastAPI application with security middleware from config."""
        from llmhosts.proxy.app import create_app

        app = create_app(config=self.config)

        app.state.llmhosts_config = self.config
        app.state.startup_report = self._report

        return app


# ---------------------------------------------------------------------------
# Utility: port availability check
# ---------------------------------------------------------------------------


def is_port_available(host: str, port: int) -> bool:
    """Return *True* if *port* is free on *host*.

    Delegates to :func:`llmhosts.ports.is_port_free` which uses connect-based
    checking (not bind-based) — critical on Windows where ``bind(0.0.0.0)``
    succeeds even when another app holds the specific ``127.0.0.1`` binding.

    The *host* parameter is accepted for backward-compat but ignored; the
    check always probes loopback addresses.
    """
    from llmhosts.ports import is_port_free

    return is_port_free(port)


# ---------------------------------------------------------------------------
# Utility: safe importability check
# ---------------------------------------------------------------------------


def _is_importable(module_name: str) -> bool:
    """Return *True* if *module_name* can be imported."""
    import importlib

    try:
        importlib.import_module(module_name)
        return True
    except ImportError:
        return False
