"""Reconcile local model tiers with what's actually installed in Ollama.

At startup in local mode, we query Ollama's `/api/tags` and check the
`local.ollama.{reasoning,capable,fast}` tiers in `models.yaml`. If any tier
points to a model that isn't installed, we pick a replacement from the
installed set and rewrite the yaml so the ModelRouter loads correct values.

Why: shipped defaults use qwen2.5, but users may only have llama3 or
something else. Daily background tasks (reflection, EOD, memory) go through
tier routing — a stale tier => "Model not found" errors until the user
manually edits the yaml. This makes tier routing self-heal on every boot.

Robust to vault switches: `main.py` runs this before creating ModelRouter,
so every restart (including the one triggered by a vault change) reconciles
against the new vault's `models.yaml`.
"""

from __future__ import annotations

import logging
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Any

import httpx

log = logging.getLogger(__name__)

OLLAMA_BASE = "http://localhost:11434"

# Track auto-start state so we don't spam attempts
_last_auto_start: float = 0.0
_AUTO_START_COOLDOWN = 120  # seconds between auto-start attempts

# Models that shouldn't be used for general agent tasks even if installed
# (embeddings, vision-only, etc.)
_EXCLUDED_PATTERNS = (  # arch:deterministic — model-name substrings that structurally identify embedding/vision models (not chat models); technical fact of how Ollama names these, not a tunable policy
    "embed",
    "nomic-embed",
    "bge",
    "clip",
)


def _installed_models(base_url: str = OLLAMA_BASE, timeout: float = 3.0) -> list[dict[str, Any]]:
    """Query Ollama for installed models.

    Returns a list of dicts with `name` and `params_b` (parameter count in
    billions, float). Empty list if Ollama is offline.
    """
    try:
        resp = httpx.get(f"{base_url}/api/tags", timeout=timeout)
        resp.raise_for_status()
        data = resp.json()
    except Exception as exc:
        log.debug("Ollama discovery: not reachable (%s)", exc)
        return []

    models: list[dict[str, Any]] = []
    for entry in data.get("models", []):
        name = entry.get("name") or entry.get("model")
        if not name:
            continue
        if any(pat in name.lower() for pat in _EXCLUDED_PATTERNS):
            continue
        params_b = _parse_param_size(entry.get("details", {}).get("parameter_size", ""))
        models.append({"name": name, "params_b": params_b})
    return models


def _parse_param_size(s: str) -> float:
    """Parse '70.6B', '8.0B', '500M' → billions as float. Returns 0.0 on failure."""
    if not s:
        return 0.0
    m = re.match(r"([\d.]+)\s*([BM]?)", s.strip())
    if not m:
        return 0.0
    try:
        val = float(m.group(1))
    except ValueError:
        return 0.0
    unit = m.group(2).upper()
    if unit == "M":
        return val / 1000.0
    return val  # B or unknown → treat as billions


def _pick_tiers(installed: list[dict[str, Any]]) -> dict[str, str]:
    """Map reasoning/capable/fast to installed model names.

    Strategy:
    - Sort by parameter count descending.
    - reasoning = largest available
    - fast      = smallest available
    - capable   = middle (or largest if only one model)
    """
    if not installed:
        return {}

    ranked = sorted(installed, key=lambda m: m["params_b"], reverse=True)
    names = [m["name"] for m in ranked]

    if len(names) == 1:
        only = names[0]
        return {"reasoning": only, "capable": only, "fast": only}

    if len(names) == 2:
        return {"reasoning": names[0], "capable": names[0], "fast": names[1]}

    return {
        "reasoning": names[0],
        "capable": names[len(names) // 2],
        "fast": names[-1],
    }


def reconcile_local_tiers(skills_dir: Path) -> dict[str, Any]:
    """Reconcile `local.ollama` tiers in models.yaml with installed Ollama models.

    Returns a result dict:
        {
            "status": "ok" | "skipped" | "unchanged" | "updated",
            "reason": str,
            "installed": list[str],
            "before": dict | None,
            "after": dict | None,
        }
    """
    yaml_path = skills_dir / "_meta" / "model-routing" / "config" / "models.yaml"
    if not yaml_path.is_file():
        return {"status": "skipped", "reason": "models.yaml not found", "installed": []}

    installed = _installed_models()
    if not installed:
        return {
            "status": "skipped",
            "reason": "Ollama not reachable or no models installed",
            "installed": [],
        }

    installed_names = {m["name"] for m in installed}

    try:
        import yaml
    except ImportError:
        return {"status": "skipped", "reason": "PyYAML not available", "installed": list(installed_names)}

    try:
        doc = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
    except Exception as exc:
        log.warning("Failed to parse models.yaml: %s", exc)
        return {"status": "skipped", "reason": f"parse error: {exc}", "installed": list(installed_names)}

    profiles = doc.setdefault("profiles", {})
    local = profiles.setdefault("local", {})
    ollama_tiers = local.setdefault("ollama", {})
    before = dict(ollama_tiers)

    # Check which configured tiers are broken.
    # Strip ollama/ prefix if present — the yaml should store bare names but
    # the UI or hot-swap might have written prefixed values.
    def _bare(n: str) -> str:
        return n.split("/", 1)[-1] if "/" in n else n

    broken = {tier: name for tier, name in ollama_tiers.items() if _bare(name) not in installed_names}

    # Also fill in missing tiers
    missing = [t for t in ("reasoning", "capable", "fast") if t not in ollama_tiers]

    if not broken and not missing:
        return {
            "status": "unchanged",
            "reason": "all local tiers match installed models",
            "installed": sorted(installed_names),
            "before": before,
            "after": before,
        }

    picks = _pick_tiers(installed)
    if not picks:
        return {"status": "skipped", "reason": "no usable models to pick from", "installed": list(installed_names)}

    # Apply fixes: replace broken, fill missing, leave valid tiers alone
    for tier in ("reasoning", "capable", "fast"):
        current = ollama_tiers.get(tier)
        if current and _bare(current) in installed_names:
            continue
        ollama_tiers[tier] = picks[tier]

    try:
        yaml_path.write_text(
            yaml.safe_dump(doc, sort_keys=False, allow_unicode=True),
            encoding="utf-8",
        )
    except Exception as exc:
        log.warning("Failed to write reconciled models.yaml: %s", exc)
        return {"status": "skipped", "reason": f"write error: {exc}", "installed": list(installed_names)}

    log.info(
        "Local model tiers reconciled against Ollama: %s -> %s",
        {k: v for k, v in before.items() if v not in installed_names} or "(missing tiers)",
        {k: ollama_tiers[k] for k in ("reasoning", "capable", "fast") if k in ollama_tiers},
    )
    return {
        "status": "updated",
        "reason": f"broken={list(broken)} missing={missing}",
        "installed": sorted(installed_names),
        "before": before,
        "after": dict(ollama_tiers),
    }


def is_ollama_reachable(base_url: str = OLLAMA_BASE, timeout: float = 2.0) -> bool:
    """Quick check whether Ollama is listening."""
    try:
        resp = httpx.get(f"{base_url}/api/tags", timeout=timeout)
        return resp.status_code == 200
    except Exception:
        return False


def ensure_ollama_running(base_url: str = OLLAMA_BASE) -> bool:
    """Start Ollama if it's installed but not running.

    Returns True if Ollama is now reachable (was already running or
    successfully started).  Returns False if Ollama isn't installed
    or failed to start within the wait window.

    Cooldown: won't attempt more than once per _AUTO_START_COOLDOWN
    seconds to avoid spamming process spawns.
    """
    global _last_auto_start  # noqa: PLW0603

    # Already running — nothing to do
    if is_ollama_reachable(base_url):
        return True

    # Cooldown guard
    now = time.monotonic()
    if now - _last_auto_start < _AUTO_START_COOLDOWN:
        log.debug("Ollama auto-start: skipping — cooldown (%ds remaining)",
                  int(_AUTO_START_COOLDOWN - (now - _last_auto_start)))
        return False

    _last_auto_start = now

    # Find the ollama binary
    ollama_bin = shutil.which("ollama")
    if not ollama_bin:
        log.debug("Ollama auto-start: 'ollama' not found on PATH")
        return False

    # Spawn `ollama serve` as a detached background process
    log.info("Ollama auto-start: launching 'ollama serve' in background")
    try:
        if sys.platform == "win32":
            # Windows: CREATE_NO_WINDOW so no console pops up
            CREATE_NO_WINDOW = 0x08000000  # noqa: N806
            subprocess.Popen(
                [ollama_bin, "serve"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                stdin=subprocess.DEVNULL,
                creationflags=CREATE_NO_WINDOW,
            )
        else:
            # macOS / Linux: detach from session
            subprocess.Popen(
                [ollama_bin, "serve"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                stdin=subprocess.DEVNULL,
                start_new_session=True,
            )
    except Exception as exc:
        log.warning("Ollama auto-start: failed to spawn process — %s", exc)
        return False

    # Wait for it to become reachable (up to 10 seconds)
    for attempt in range(20):
        time.sleep(0.5)
        if is_ollama_reachable(base_url):
            log.info("Ollama auto-start: ready after %.1fs", (attempt + 1) * 0.5)
            return True

    log.warning("Ollama auto-start: spawned but not reachable after 10s")
    return False
