"""Comprehensive system health verification for ``llmhost doctor``.

The :class:`Doctor` class runs a suite of diagnostic checks concurrently and
returns structured :class:`DoctorCheck` results.  This module centralises
all doctor logic that previously lived in ``cli.py``.
"""

from __future__ import annotations

import asyncio
import importlib
import logging
import platform
import shutil
import sys
from pathlib import Path
from typing import Any

from llmhosts.banner import DoctorCheck

logger = logging.getLogger(__name__)


class Doctor:
    """Comprehensive system health verification.

    Checks: Python, config, inference engine, keys, port, tier, disk,
    hardware, proxy responsiveness, cache, database integrity.

    Usage::

        doctor = Doctor()
        results = await doctor.run_all()
        print_doctor_report(results)
    """

    def __init__(self, config: Any | None = None, data_dir: Path | None = None) -> None:
        """Initialise the doctor.

        Args:
            config: An :class:`~llmhost.config.LLMHostsConfig` instance.
                    If ``None``, one is loaded from default location.
            data_dir: Override for ``~/.llmhosts``.  Used mainly in tests.
        """
        if config is None:
            from llmhosts.config import load_config

            config = load_config()
        self._config = config
        self._data_dir = data_dir or self._default_data_dir()

    @staticmethod
    def _default_data_dir() -> Path:
        from llmhosts.constants import DATA_DIR_NAME

        return Path.home() / DATA_DIR_NAME

    # ------------------------------------------------------------------
    # Main entry point
    # ------------------------------------------------------------------

    async def run_all(self) -> list[DoctorCheck]:
        """Run all checks concurrently where possible.

        Returns:
            A list of :class:`DoctorCheck` results ordered by category.
        """
        # Group into concurrent and sequential checks
        tasks = [
            self.check_python(),
            self.check_config(),
            self.check_ollama(),
            self.check_keys(),
            self.check_port(),
            self.check_tier(),
            self.check_disk(),
            self.check_disk_for_models(),
            self.check_hardware(),
            self.check_gpu_drivers(),
            self.check_gpu_utilization(),
            self.check_model_registry_network(),
            self.check_database(),
            self.check_cache(),
        ]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        checks: list[DoctorCheck] = []
        for result in results:
            if isinstance(result, DoctorCheck):
                checks.append(result)
            elif isinstance(result, BaseException):
                checks.append(
                    DoctorCheck(
                        name="Unknown",
                        status="fail",
                        message=f"Check raised exception: {result}",
                    )
                )
        return checks

    # ------------------------------------------------------------------
    # Individual checks
    # ------------------------------------------------------------------

    async def check_python(self) -> DoctorCheck:
        """Verify Python version >= 3.10."""
        py_ver = platform.python_version()
        py_ok = sys.version_info >= (3, 10)
        return DoctorCheck(
            name="Python version",
            status="ok" if py_ok else "fail",
            message=f"Python {py_ver}",
            suggestion="LLMHosts requires Python 3.10+. Upgrade your Python installation." if not py_ok else None,
        )

    async def check_config(self) -> DoctorCheck:
        """Verify config file exists and is valid TOML."""
        from llmhosts.config import llmhosts_dir

        cfg_path = llmhosts_dir() / "config.toml"
        if not cfg_path.is_file():
            return DoctorCheck(
                name="Config file",
                status="warn",
                message="Not found (using defaults)",
                suggestion="Run: mkdir -p ~/.llmhosts && llmhost config init",
            )

        # Validate TOML syntax
        try:
            import toml  # type: ignore[import-untyped]

            toml.load(cfg_path)
            return DoctorCheck(
                name="Config file",
                status="ok",
                message=str(cfg_path),
            )
        except Exception as exc:
            return DoctorCheck(
                name="Config file",
                status="fail",
                message=f"Invalid TOML: {exc}",
                suggestion="Fix syntax errors in your config.toml or delete it to use defaults.",
            )

    async def check_ollama(self) -> DoctorCheck:
        """Ping inference engine, list models."""
        try:
            from llmhosts.discovery.ollama import OllamaDiscovery

            ollama_host = self._config.ollama.host
            async with OllamaDiscovery(host=ollama_host, timeout=5.0) as ollama:
                if await ollama.is_available():
                    models = await ollama.list_models()
                    model_names = ", ".join(m.name for m in models[:5])
                    suffix = f" (+{len(models) - 5} more)" if len(models) > 5 else ""
                    if models:
                        return DoctorCheck(
                            name="Inference Engine",
                            status="ok",
                            message=f"Running at {ollama_host}, {len(models)} model(s): {model_names}{suffix}",
                        )
                    return DoctorCheck(
                        name="Inference Engine",
                        status="warn",
                        message=f"Running at {ollama_host} but no models installed",
                        suggestion="Pull a model with: llmhosts serve (auto-provisions)",
                    )
                return DoctorCheck(
                    name="Inference Engine",
                    status="warn",
                    message=f"Not reachable at {ollama_host}",
                    suggestion="Run: llmhosts serve (auto-installs inference engine)",
                )
        except Exception as exc:
            return DoctorCheck(name="Inference Engine", status="fail", message=str(exc))

    async def check_keys(self) -> DoctorCheck:
        """Verify stored keys are decryptable and optionally valid."""
        try:
            from llmhosts.keys.manager import KeyManager

            mgr = KeyManager(data_dir=self._data_dir)
            providers = mgr.list_providers()
            if providers:
                names = ", ".join(p.provider for p in providers)
                # Check if any have decrypt errors
                decrypt_errors = [p for p in providers if p.masked_key == "***decrypt-error***"]
                if decrypt_errors:
                    bad = ", ".join(p.provider for p in decrypt_errors)
                    return DoctorCheck(
                        name="BYOK API keys",
                        status="warn",
                        message=f"{len(providers)} key(s): {names} ({len(decrypt_errors)} decrypt error(s): {bad})",
                        suggestion="Some keys cannot be decrypted. Re-add them with: llmhost keys add <provider> <key>",
                    )
                return DoctorCheck(
                    name="BYOK API keys",
                    status="ok",
                    message=f"{len(providers)} key(s): {names}",
                )
            return DoctorCheck(
                name="BYOK API keys",
                status="warn",
                message="No keys configured",
                suggestion="Add cloud keys with: llmhost keys add <provider> <key>",
            )
        except Exception as exc:
            return DoctorCheck(name="BYOK API keys", status="fail", message=str(exc))

    async def check_port(self) -> DoctorCheck:
        """Verify default port is available."""
        port = self._config.server.port
        host = self._config.server.host
        port_free = self._is_port_free(host, port)
        return DoctorCheck(
            name="Proxy port",
            status="ok" if port_free else "warn",
            message=f"{host}:{port} {'available' if port_free else 'in use'}",
            suggestion=f"Port {port} is occupied. Use --port to pick another." if not port_free else None,
        )

    async def check_tier(self) -> DoctorCheck:
        """Detect installed pip tier (core/smart/full)."""
        tiers_found: list[str] = ["core"]

        # Smart tier markers
        smart_modules = ["onnxruntime", "faiss", "tokenizers"]
        smart_available = all(self._can_import(m) for m in smart_modules)
        if smart_available:
            tiers_found.append("smart")

        # Full tier markers
        full_modules = ["torch", "transformers", "sentence_transformers"]
        full_available = all(self._can_import(m) for m in full_modules)
        if full_available:
            tiers_found.append("full")

        tier_str = " + ".join(tiers_found)
        if full_available:
            msg = f"Installed: [{tier_str}] — all router tiers available"
        elif smart_available:
            msg = f"Installed: [{tier_str}] — kNN + ModernBERT router available"
        else:
            msg = f"Installed: [{tier_str}] — rule-based router only"

        return DoctorCheck(
            name="Installed tier",
            status="ok",
            message=msg,
            suggestion="Upgrade with: pip install llmhosts[smart] or llmhost[full]" if not smart_available else None,
        )

    async def check_disk(self) -> DoctorCheck:
        """Verify sufficient disk space (>1GB free)."""
        disk_total, disk_free = self._get_disk_space()
        disk_ok = disk_free > 1.0
        return DoctorCheck(
            name="Disk space",
            status="ok" if disk_ok else "warn",
            message=f"{disk_free:.1f} GB free / {disk_total:.1f} GB total",
            suggestion="Low disk space may prevent model downloads." if not disk_ok else None,
        )

    async def check_hardware(self) -> DoctorCheck:
        """Detect GPU, RAM, CPU."""
        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 VRAM)")
                gpu_str = ", ".join(parts)
            else:
                gpu_str = "No GPU detected"
            msg = f"{gpu_str}, {hw.ram_total_gb:.0f}GB RAM, {hw.cpu_cores} CPU cores"
            return DoctorCheck(
                name="Hardware",
                status="ok" if hw.gpus else "warn",
                message=msg,
                suggestion="A GPU is recommended for local inference performance." if not hw.gpus else None,
            )
        except Exception as exc:
            return DoctorCheck(name="Hardware", status="fail", message=f"Detection failed: {exc}")

    async def check_database(self) -> DoctorCheck:
        """Verify SQLite database is accessible and tables exist."""
        from llmhosts.constants import DB_FILENAME

        db_path = self._data_dir / DB_FILENAME
        if not db_path.exists():
            return DoctorCheck(
                name="Database",
                status="warn",
                message=f"Database not found at {db_path}",
                suggestion="Database will be created on first server start.",
            )

        try:
            import aiosqlite

            async with aiosqlite.connect(str(db_path)) as db:
                # Check for expected tables
                cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table'")
                tables = [row[0] for row in await cursor.fetchall()]

                if not tables:
                    return DoctorCheck(
                        name="Database",
                        status="warn",
                        message=f"Database exists but has no tables ({db_path})",
                        suggestion="Tables will be created on first server start.",
                    )

                # Quick integrity check
                cursor = await db.execute("PRAGMA integrity_check")
                row = await cursor.fetchone()
                integrity = row[0] if row else "unknown"
                if integrity != "ok":
                    return DoctorCheck(
                        name="Database",
                        status="fail",
                        message=f"Database integrity check failed: {integrity}",
                        suggestion="Delete the database file and restart. Data will be re-created.",
                    )

                return DoctorCheck(
                    name="Database",
                    status="ok",
                    message=f"{len(tables)} table(s): {', '.join(sorted(tables)[:5])}",
                )
        except Exception as exc:
            return DoctorCheck(name="Database", status="fail", message=f"Database error: {exc}")

    async def check_cache(self) -> DoctorCheck:
        """Verify cache is functional (write + read test)."""
        if not self._config.cache.enabled:
            return DoctorCheck(
                name="Cache",
                status="ok",
                message="Cache is disabled in config",
            )

        try:
            import hashlib
            import time

            # Simple write/read test using a temp file in data dir
            test_dir = self._data_dir / "cache"
            test_dir.mkdir(parents=True, exist_ok=True)

            test_key = f"doctor-test-{time.time_ns()}"
            test_value = b"llmhost-cache-test-payload"
            test_hash = hashlib.sha256(test_key.encode()).hexdigest()[:16]
            test_file = test_dir / f".doctor-{test_hash}"

            # Write
            start = asyncio.get_event_loop().time()
            test_file.write_bytes(test_value)

            # Read back
            read_back = test_file.read_bytes()
            elapsed_ms = (asyncio.get_event_loop().time() - start) * 1000

            # Cleanup
            test_file.unlink(missing_ok=True)

            if read_back == test_value:
                # Check cache directory size
                cache_size_mb = sum(f.stat().st_size for f in test_dir.rglob("*") if f.is_file()) / (1024 * 1024)
                return DoctorCheck(
                    name="Cache",
                    status="ok",
                    message=f"Write/read OK ({elapsed_ms:.1f}ms), cache dir: {cache_size_mb:.1f}MB",
                )
            return DoctorCheck(
                name="Cache",
                status="fail",
                message="Cache read-back mismatch",
                suggestion="Check filesystem permissions on ~/.llmhosts/cache/",
            )
        except Exception as exc:
            return DoctorCheck(name="Cache", status="fail", message=f"Cache test failed: {exc}")

    # ------------------------------------------------------------------
    # New checks: driver staleness, disk for models, network, GPU util
    # ------------------------------------------------------------------

    async def check_disk_for_models(self) -> DoctorCheck:
        """Verify enough disk space for at least the smallest AI model (1.4GB + 20% overhead)."""
        min_required_gb = 1.4 * 1.2  # 1.68 GB minimum
        _, disk_free = self._get_disk_space()

        if disk_free >= min_required_gb * 3:
            return DoctorCheck(
                name="Disk (models)",
                status="ok",
                message=f"{disk_free:.1f} GB free -- room for multiple AI models",
            )
        elif disk_free >= min_required_gb:
            return DoctorCheck(
                name="Disk (models)",
                status="warn",
                message=f"{disk_free:.1f} GB free -- tight for larger models",
                suggestion="Free up disk space for better model selection. Models range from 1.4GB to 40GB.",
            )
        else:
            return DoctorCheck(
                name="Disk (models)",
                status="fail",
                message=f"Only {disk_free:.1f} GB free -- insufficient for any AI model (need {min_required_gb:.1f} GB)",
                suggestion="Free up at least 2 GB of disk space. "
                "The smallest model needs 1.7 GB. Larger models need 5-40 GB.",
            )

    async def check_gpu_drivers(self) -> DoctorCheck:
        """Check GPU driver version staleness with platform-specific update commands."""
        try:
            from llmhosts.discovery.hardware import HardwareDetector

            hw = await HardwareDetector.detect()
        except Exception:
            return DoctorCheck(
                name="GPU drivers",
                status="ok",
                message="Hardware detection unavailable -- skipped",
            )

        if not hw.gpus:
            return DoctorCheck(
                name="GPU drivers",
                status="ok",
                message="No GPU detected -- driver check not applicable",
            )

        system = platform.system().lower()
        issues: list[str] = []

        for gpu in hw.gpus:
            if gpu.gpu_type == "nvidia" and gpu.driver_version:
                # Parse major version
                import re

                match = re.match(r"(\d+)", gpu.driver_version.strip())
                if match:
                    major = int(match.group(1))
                    min_driver = 560
                    if major < min_driver:
                        if system == "linux":
                            issues.append(
                                f"NVIDIA driver v{gpu.driver_version} is outdated "
                                f"(need >={min_driver} for CUDA 13.1). "
                                f"Update: sudo apt install nvidia-driver-{min_driver}"
                            )
                        elif system == "windows":
                            issues.append(
                                f"NVIDIA driver v{gpu.driver_version} is outdated "
                                f"(need >={min_driver}). "
                                "Update: https://www.nvidia.com/download"
                            )
                        elif system == "darwin":
                            issues.append(
                                f"NVIDIA driver v{gpu.driver_version} is outdated. "
                                "Update to the latest driver for your platform."
                            )
            elif gpu.gpu_type == "amd":
                # Check for ROCm
                rocm_path = Path("/opt/rocm/.info/version")
                if system == "linux":
                    if not rocm_path.exists() and not shutil.which("rocm-smi"):
                        issues.append(
                            "AMD GPU detected but ROCm not found. "
                            "Install ROCm 6.4+: https://rocm.docs.amd.com/projects/install-on-linux/en/latest/"
                        )
                    elif rocm_path.exists():
                        try:
                            version_str = rocm_path.read_text().strip()
                            import re

                            match = re.match(r"(\d+)\.(\d+)", version_str)
                            if match and (int(match.group(1)), int(match.group(2))) < (6, 4):
                                issues.append(
                                    f"ROCm v{version_str} is outdated (need >=6.4). "
                                    "Update: https://rocm.docs.amd.com/projects/install-on-linux/en/latest/"
                                )
                        except (OSError, ValueError):
                            pass

        if issues:
            return DoctorCheck(
                name="GPU drivers",
                status="warn",
                message="; ".join(issues),
                suggestion=issues[0],  # Show the first fix as suggestion
            )

        # All good
        gpu_summaries = []
        for gpu in hw.gpus:
            driver_info = f" (driver {gpu.driver_version})" if gpu.driver_version else ""
            gpu_summaries.append(f"{gpu.name}{driver_info}")
        return DoctorCheck(
            name="GPU drivers",
            status="ok",
            message=f"Up to date: {', '.join(gpu_summaries)}",
        )

    async def check_gpu_utilization(self) -> DoctorCheck:
        """Show current GPU utilization (informational, not pass/fail)."""
        if not shutil.which("nvidia-smi"):
            return DoctorCheck(
                name="GPU utilization",
                status="ok",
                message="No NVIDIA GPU or nvidia-smi not available -- skipped",
            )

        try:
            import asyncio as _asyncio

            proc = await _asyncio.create_subprocess_exec(
                "nvidia-smi",
                "--query-gpu=name,utilization.gpu,memory.used,memory.total",
                "--format=csv,nounits,noheader",
                stdout=_asyncio.subprocess.PIPE,
                stderr=_asyncio.subprocess.PIPE,
            )
            stdout, _ = await _asyncio.wait_for(proc.communicate(), timeout=10.0)
            if proc.returncode != 0:
                return DoctorCheck(
                    name="GPU utilization",
                    status="ok",
                    message="Could not query GPU utilization",
                )

            parts_list = []
            for line in stdout.decode().strip().splitlines():
                parts = [p.strip() for p in line.split(",")]
                if len(parts) >= 4:
                    name, util_pct, mem_used, mem_total = parts[0], parts[1], parts[2], parts[3]
                    parts_list.append(f"{name}: {util_pct}% GPU, {mem_used}/{mem_total} MB VRAM")

            if parts_list:
                return DoctorCheck(
                    name="GPU utilization",
                    status="ok",
                    message="; ".join(parts_list),
                )
            return DoctorCheck(
                name="GPU utilization",
                status="ok",
                message="No GPU utilization data available",
            )
        except Exception as exc:
            return DoctorCheck(
                name="GPU utilization",
                status="ok",
                message=f"GPU utilization check skipped: {exc}",
            )

    async def check_model_registry_network(self) -> DoctorCheck:
        """Check network connectivity to the model registry."""
        try:
            import httpx

            async with httpx.AsyncClient(timeout=5.0) as client:
                resp = await client.head("https://registry.ollama.ai/v2/")
                if resp.status_code in (200, 401):
                    return DoctorCheck(
                        name="Model registry",
                        status="ok",
                        message="Model registry reachable",
                    )
                return DoctorCheck(
                    name="Model registry",
                    status="warn",
                    message=f"Model registry returned HTTP {resp.status_code}",
                    suggestion="Model downloads may fail. Check your internet connection.",
                )
        except Exception:
            return DoctorCheck(
                name="Model registry",
                status="warn",
                message="Model registry unreachable (offline mode)",
                suggestion="No internet connectivity. Existing local models will still work. "
                "New model downloads require internet access.",
            )

    # ------------------------------------------------------------------
    # Static helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _is_port_free(host: str, port: int) -> bool:
        """Check if a TCP port is available (connect-based, not bind-based)."""
        from llmhosts.ports import is_port_free

        return is_port_free(port)

    @staticmethod
    def _can_import(module: str) -> bool:
        """Check if a module can be imported without triggering side effects."""
        try:
            importlib.import_module(module)
            return True
        except ImportError:
            return False

    @staticmethod
    def _get_disk_space() -> tuple[float, float]:
        """Return (total_gb, free_gb) for the home directory filesystem."""
        try:
            usage = shutil.disk_usage(str(Path.home()))
            return (round(usage.total / (1024**3), 1), round(usage.free / (1024**3), 1))
        except OSError:
            return (0.0, 0.0)
