"""Model provisioner -- downloads a starter GGUF model matched to hardware.

LLMHosts Core is the inference engine (compiled in, no install needed).
The provisioner's job is now simply:
1. Select the best model for the detected hardware (VRAM-aware)
2. Download it via ``llmhosts.models.download.download_model()``

Safety rules:
- NEVER auto-installs GPU drivers (wrong driver = black screen)
- Prints clear instructions with links for driver installation
- Model selection is VRAM-aware using the existing ModelSuggester catalog
"""

from __future__ import annotations

import logging
import platform
import shutil
import time
from typing import TYPE_CHECKING

from llmhosts.discovery.models import HardwareProfile, ProvisionResult
from llmhosts.progress import (
    NullProgressReporter,
    ProgressEvent,
    ProgressPhase,
    customer_message,
)

if TYPE_CHECKING:
    from collections.abc import Callable

    from llmhosts.progress import ProgressReporter

logger = logging.getLogger(__name__)

_ATLAS_BASE_URL = "https://llmhosts.com/api/atlas"
_ATLAS_TIMEOUT_SECONDS = 2.0


async def fetch_atlas_recommendations(gpu_model: str, vram_mb: int) -> list[dict]:
    """Fetch hardware-matched model recommendations from the Performance Atlas.

    Returns a list of ``{model_id, tps_p50, success_rate}`` dicts sorted by
    tps_p50 descending. On ANY failure (network, timeout, parse error) returns
    an empty list silently -- the static VRAM table remains the primary fallback.
    """
    try:
        import httpx

        params = {"gpu": gpu_model, "vram_mb": str(vram_mb)}
        async with httpx.AsyncClient(timeout=_ATLAS_TIMEOUT_SECONDS) as client:
            resp = await client.get(_ATLAS_BASE_URL, params=params)
            resp.raise_for_status()
            data = resp.json()

        if not isinstance(data, list):
            return []

        results: list[dict] = []
        for entry in data:
            if not isinstance(entry, dict):
                continue
            model_id = entry.get("modelId") if entry.get("modelId") is not None else entry.get("model_id")
            tps_p50 = entry.get("tpsP50") if entry.get("tpsP50") is not None else entry.get("tps_p50")
            success_rate = (
                entry.get("successRate") if entry.get("successRate") is not None else entry.get("success_rate")
            )
            if model_id and tps_p50 is not None and success_rate is not None:
                results.append(
                    {
                        "model_id": str(model_id),
                        "tps_p50": float(tps_p50),
                        "success_rate": float(success_rate),
                        "measurementCount": int(entry.get("measurementCount", 0)),
                    }
                )

        return sorted(results, key=lambda x: x["tps_p50"], reverse=True)

    except Exception:
        # Network errors, timeouts, parse errors -- all silent
        return []


def format_atlas_recommendation(entry: dict) -> str:
    """Format a single atlas entry as a human-readable recommendation string."""
    return (
        f"Based on {entry['measurementCount']} users with your GPU: "
        f"{entry['model_id']} runs at ~{entry['tps_p50']:.0f} tok/s"
    )


# VRAM-based default model selection -- updated February 2026.
#
# Sources:
#   - awesomeagents.ai/leaderboards/home-gpu-llm-leaderboard
#   - localllm.in/blog/ollama-vram-requirements-for-local-llms
#   - localllm.in/blog/best-local-llms-24gb-vram
#   - ollama.com/library
#
# Selection criteria: best *general-purpose* model at each tier on MMLU,
# HumanEval, GSM8K, and real-world coding/reasoning benchmarks (Feb 2026).
# Models must leave >=20 % VRAM free for KV-cache / context overhead.
#
# Tier  | min VRAM | Model               | DL size | VRAM Q4 | Rationale
# ------|----------|---------------------|---------|---------|------------------------------------
#  T0   | 0 GB     | qwen3:1.7b          | 1.4 GB  | ~2 GB   | Best tiny; matches Qwen2.5-3B quality
#  T1   | 4 GB     | qwen3:4b            | 2.5 GB  | ~3.5 GB | Strong for size; 110 tok/s
#  T2   | 7 GB     | qwen3:8b            | 5.2 GB  | ~6.2 GB | Dominates 8B tier (MMLU 76.9)
#  T3   | 10 GB    | qwen3:14b           | 9.3 GB  | ~10.7 GB| Dramatic jump (MMLU 81.1, HumanEval 72.2)
#  T4   | 16 GB    | gpt-oss:20b         | 14 GB   | ~14 GB  | OpenAI open MoE; 139 tok/s, 128K ctx
#  T5   | 22 GB    | qwen3:32b           | 20 GB   | ~22.2 GB| Best dense quality (MMLU 83.6, GPQA 49.5)
#  T6   | 40 GB    | llama3.3:70b        | 40 GB   | ~45.6 GB| Frontier; HumanEval 80.5, GSM8K 95.1
_VRAM_MODEL_MAP: list[tuple[float, str, float]] = [
    # (min_vram_gb, model_tag, approximate_download_gb)
    (0.0, "qwen3:1.7b", 1.4),  # CPU-only / <4 GB -- ultra-light, rivals Qwen2.5-3B
    (4.0, "qwen3:4b", 2.5),  # 4-7 GB -- integrated GPUs, old discrete cards
    (7.0, "qwen3:8b", 5.2),  # 7-10 GB -- RTX 3060 8 GB, RTX 4060 8 GB
    (10.0, "qwen3:14b", 9.3),  # 10-16 GB -- RTX 3060 12 GB, RTX 4060 Ti 16 GB
    (16.0, "gpt-oss:20b", 14.0),  # 16-22 GB -- RTX 4080 16 GB, RTX 4090M 18 GB
    (22.0, "qwen3:32b", 20.0),  # 22-40 GB -- RTX 4090 24 GB, RTX 5090 32 GB
    (40.0, "llama3.3:70b", 40.0),  # 40 GB+ -- dual GPU / A100 / H100
]


def _select_model_for_hardware(hardware: HardwareProfile, *, available_vram_mb: int | None = None) -> tuple[str, float]:
    """Pick the best default model based on available VRAM (or RAM/2 for CPU-only).

    Uses the *largest single GPU* rather than total VRAM across all GPUs,
    because most inference engines use a single GPU (pipeline parallelism,
    not tensor parallelism).  The ``largest_gpu_vram_mb`` property already
    accounts for Apple Silicon's unified-memory OS overhead.

    When *available_vram_mb* is provided (from :class:`GPUMonitor`), it takes
    precedence over hardware-reported totals.  This implements **GPU Respect**:
    if the user's GPU is partially occupied, we select a smaller model that
    fits in the *actually* free VRAM rather than assuming the whole GPU is ours.
    """
    if available_vram_mb is not None:
        effective_gb = available_vram_mb / 1024.0
    elif hardware.has_gpu and hardware.largest_gpu_vram_mb > 0:
        effective_gb = hardware.largest_gpu_vram_mb / 1024.0
    else:
        effective_gb = hardware.ram_total_gb / 2.0

    model_name = _VRAM_MODEL_MAP[0][1]
    model_size = _VRAM_MODEL_MAP[0][2]
    for min_vram, name, size in _VRAM_MODEL_MAP:
        if effective_gb >= min_vram:
            model_name = name
            model_size = size
    return model_name, model_size


class EngineProvisioner:
    """Downloads a starter model matched to the detected hardware.

    LLMHosts Core is compiled-in -- no engine install step needed.
    The provisioner simply selects and downloads a GGUF model.
    """

    def __init__(
        self,
        on_status: Callable[[str], None] | None = None,
        progress: ProgressReporter | None = None,
    ) -> None:
        self._on_status = on_status or (lambda msg: logger.info(msg))
        self._progress: ProgressReporter = progress or NullProgressReporter()

    def _report(
        self, phase: ProgressPhase, message: str, progress_pct: float | None = None, detail: str | None = None
    ) -> None:
        """Report progress via both the ProgressReporter and the legacy on_status callback."""
        event = ProgressEvent(phase=phase, message=message, progress_pct=progress_pct, detail=detail)
        self._progress.report(event)
        self._on_status(message)

    async def provision(self, hardware: HardwareProfile) -> ProvisionResult:
        """Select and download a starter model for the detected hardware."""
        start = time.monotonic()

        # Step 1: Select model
        model_name, model_size = _select_model_for_hardware(hardware)

        # Step 2: Download model
        pull_msg = customer_message("pull_model")
        self._report(ProgressPhase.DOWNLOAD_MODEL, pull_msg, detail=f"model={model_name}, size={model_size:.1f}GB")

        pulled = await self._download_model(model_name)

        if pulled:
            if hardware.has_gpu and hardware.largest_gpu_vram_mb > 0:
                vram_gb = hardware.largest_gpu_vram_mb / 1024.0
                done_msg = customer_message("pull_model_complete_gpu", vram_gb=vram_gb)
            else:
                done_msg = customer_message("pull_model_complete")
            self._report(ProgressPhase.DOWNLOAD_MODEL, done_msg, progress_pct=100.0)

        return ProvisionResult(
            success=pulled,
            engine_installed=True,  # Core is always available (compiled in)
            model_pulled=model_name if pulled else "",
            model_size_gb=model_size if pulled else 0.0,
            install_method="core",
            error="" if pulled else customer_message("pull_model_failed"),
            duration_seconds=time.monotonic() - start,
        )

    async def suggest_and_pull(self, hardware: HardwareProfile) -> tuple[str, bool]:
        """Suggest a model for the hardware and download it. Returns (model_name, success)."""
        model_name, _ = _select_model_for_hardware(hardware)
        success = await self._download_model(model_name)
        return model_name, success

    # ------------------------------------------------------------------
    # Model download (delegates to llmhosts.models.download)
    # ------------------------------------------------------------------

    async def _download_model(self, model_name: str) -> bool:
        """Download a GGUF model via llmhosts.models.download."""
        import asyncio

        try:
            from llmhosts.models.download import download_model

            # download_model is synchronous -- run in executor to avoid blocking
            loop = asyncio.get_event_loop()
            await loop.run_in_executor(None, download_model, model_name)
            return True
        except Exception as exc:
            self._report(
                ProgressPhase.DOWNLOAD_MODEL,
                customer_message("pull_model_failed"),
                detail=str(exc),
            )
            return False

    # ------------------------------------------------------------------
    # GPU driver recommendations
    # ------------------------------------------------------------------

    @staticmethod
    def gpu_driver_recommendations(hardware: HardwareProfile) -> list[str]:
        """Return human-readable driver install/update recommendations.

        **GPU Respect Principle**: We only READ GPU state passively.
        All recommendations are informational -- we never auto-install or
        modify drivers, GPU settings, or interfere with running workloads.
        """
        recs: list[str] = []
        system = platform.system().lower()

        if not hardware.has_gpu:
            if system == "linux":
                recs.append(
                    "No GPU detected. For NVIDIA: sudo apt install nvidia-driver-560 "
                    "(https://docs.nvidia.com/cuda/cuda-installation-guide-linux/)"
                )
                recs.append("For AMD: Install ROCm 6.4+ (https://rocm.docs.amd.com/)")
            elif system == "windows":
                recs.append(
                    "No GPU detected. Install latest drivers from: "
                    "NVIDIA: https://www.nvidia.com/download | AMD: https://www.amd.com/support"
                )
            return recs

        # Check existing GPUs for driver staleness
        for gpu in hardware.gpus:
            if gpu.gpu_type == "nvidia" and gpu.driver_version:
                recs.extend(_check_nvidia_driver(gpu.driver_version, system))
            elif gpu.gpu_type == "amd":
                recs.extend(_check_rocm_version(system))

        return recs


def _parse_nvidia_driver_major(driver_version: str) -> int | None:
    """Extract the major version number from an NVIDIA driver string.

    Examples: "560.35.03" -> 560, "560.94" -> 560, "560" -> 560.
    Returns None if the version cannot be parsed.
    """
    import re as _re

    match = _re.match(r"(\d+)", driver_version.strip())
    if match:
        return int(match.group(1))
    return None


def _check_nvidia_driver(driver_version: str, system: str) -> list[str]:
    """Check NVIDIA driver version against minimum for CUDA 13.1 (driver >= 560)."""
    recs: list[str] = []
    major = _parse_nvidia_driver_major(driver_version)
    if major is None:
        return recs

    min_driver = 560
    if major < min_driver:
        if system == "linux":
            recs.append(
                f"NVIDIA driver v{driver_version} is outdated (minimum {min_driver} for CUDA 13.1). "
                f"Update: sudo apt install nvidia-driver-{min_driver} "
                "(https://docs.nvidia.com/cuda/cuda-installation-guide-linux/)"
            )
        elif system == "windows":
            recs.append(
                f"NVIDIA driver v{driver_version} is outdated (minimum {min_driver} for CUDA 13.1). "
                "Download latest: https://www.nvidia.com/download"
            )
        else:
            # macOS doesn't have NVIDIA support anymore, but be safe
            recs.append(
                f"NVIDIA driver v{driver_version} is outdated (minimum {min_driver} for CUDA 13.1). "
                "Update to the latest driver for your platform."
            )
    return recs


def _parse_rocm_version(version_str: str) -> tuple[int, int] | None:
    """Parse a ROCm version string like '6.4.0' into (major, minor).

    Returns None if the string cannot be parsed.
    """
    import re as _re

    match = _re.match(r"(\d+)\.(\d+)", version_str.strip())
    if match:
        return int(match.group(1)), int(match.group(2))
    return None


def _check_rocm_version(system: str) -> list[str]:
    """Check ROCm version (synchronous -- uses cached info or returns empty).

    Only provides recommendations; never installs or modifies anything.
    """
    recs: list[str] = []

    # ROCm version file is the most reliable source
    rocm_version = _read_rocm_version_file()
    if rocm_version is None:
        if not shutil.which("rocm-smi"):
            if system == "linux":
                recs.append(
                    "AMD GPU detected but ROCm not found. Install ROCm 6.4+: "
                    "https://rocm.docs.amd.com/projects/install-on-linux/en/latest/"
                )
            return recs
        return recs  # rocm-smi exists but no version file -- can't determine version

    parsed = _parse_rocm_version(rocm_version)
    if parsed is None:
        return recs

    major, minor = parsed
    min_major, min_minor = 6, 4
    if (major, minor) < (min_major, min_minor):
        recs.append(
            f"ROCm v{rocm_version} is outdated (minimum {min_major}.{min_minor} recommended). "
            "Update: https://rocm.docs.amd.com/projects/install-on-linux/en/latest/"
        )
    return recs


def _read_rocm_version_file() -> str | None:
    """Read ``/opt/rocm/.info/version`` (standard ROCm install location)."""
    try:
        with open("/opt/rocm/.info/version") as f:
            return f.read().strip()
    except (FileNotFoundError, PermissionError, OSError):
        return None
