"""
LLM model providers: Ollama, NVIDIA, OpenAI, Gemini.

Model listing, caching, and synchronous/async call functions.
"""

import asyncio
import re
import subprocess as _subprocess
import shutil as _shutil
import shlex as _shlex
import time
from html import unescape
from pathlib import Path

import requests

from backend.config import (
    OLLAMA_URL, NVIDIA_URL, OPENAI_URL, GEMINI_URL,
    NVIDIA_PREFIX, OPENAI_PREFIX, GEMINI_PREFIX,
    LLAMACPP_URL, LLAMACPP_PREFIX,
    _load_settings,
)

# ── llama.cpp process state ────────────────────────────────────────────────────
_llamacpp_proc: "_subprocess.Popen | None" = None
_LLAMACPP_LOCAL_DIR = (Path(__file__).parent.parent / "tools" / "llama-cpp").resolve()


def _find_llama_server(settings: dict) -> str | None:
    """Return path to llama-server executable, or None if not found."""
    exe = (settings.get("llamacpp_server_exe") or "").strip()
    if exe and Path(exe).is_file():
        return exe
    local = _LLAMACPP_LOCAL_DIR / "llama-server.exe"
    if local.is_file():
        return str(local)
    return _shutil.which("llama-server")


def _ensure_llamacpp_running(settings: dict) -> bool:
    """Auto-start llama-server if not reachable. Returns True when server is ready."""
    global _llamacpp_proc
    url = (settings.get("llamacpp_url") or LLAMACPP_URL).rstrip("/")
    try:
        r = requests.get(f"{url}/v1/models", timeout=2)
        if r.status_code == 200:
            return True
    except Exception:
        pass
    if _llamacpp_proc is None or _llamacpp_proc.poll() is not None:
        _llamacpp_proc = None
        model_path = (settings.get("llamacpp_model_path") or "").strip()
        server_exe = _find_llama_server(settings)
        if not model_path or not server_exe:
            return False
        if not Path(model_path).exists():
            return False
        cmd = [server_exe, "-m", model_path]
        extra = (settings.get("llamacpp_extra_args") or "").strip()
        if extra:
            cmd += _shlex.split(extra)
        try:
            _llamacpp_proc = _subprocess.Popen(
                cmd,
                stdout=_subprocess.DEVNULL,
                stderr=_subprocess.DEVNULL,
                creationflags=_subprocess.CREATE_NO_WINDOW if hasattr(_subprocess, "CREATE_NO_WINDOW") else 0,
            )
        except Exception:
            return False
    # Wait up to 120 s for the model to load
    deadline = time.monotonic() + 120
    while time.monotonic() < deadline:
        if _llamacpp_proc is not None and _llamacpp_proc.poll() is not None:
            return False
        try:
            r = requests.get(f"{url}/v1/models", timeout=2)
            if r.status_code == 200:
                return True
        except Exception:
            pass
        time.sleep(1)
    return False


# ── NVIDIA models ──────────────────────────────────────────────────────────────
NVIDIA_MODELS = [
    "moonshotai/kimi-k2.5",
]

# ── OpenAI models ──────────────────────────────────────────────────────────────
OPENAI_MODELS_FALLBACK = [
    "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano",
    "gpt-4o", "gpt-4o-mini", "o3", "o3-mini", "o4-mini",
]

# ── Gemini models ──────────────────────────────────────────────────────────────
GEMINI_MODELS_FALLBACK = [
    "gemini-2.5-pro", "gemini-2.5-flash",
    "gemini-2.0-flash", "gemini-2.0-flash-lite",
]

# ── Ollama model management ────────────────────────────────────────────────────
_ollama_active_model: str = ""
_ollama_model_set: set[str] = set()


def _ollama_ensure_model(model: str):
    global _ollama_active_model
    if not model or not model.strip():
        return
    model = model.strip()
    _ollama_model_set.add(model)
    _ollama_active_model = model


def _ollama_unload_all():
    global _ollama_active_model
    for m in list(_ollama_model_set):
        try:
            requests.post(
                f"{OLLAMA_URL}/generate",
                json={"model": m, "keep_alive": 0},
                timeout=5,
            )
            print(f"[ollama] Unloaded model: {m}")
        except Exception:
            pass
    _ollama_model_set.clear()
    _ollama_active_model = ""


# ── Dynamic model cache ───────────────────────────────────────────────────────
_model_cache: dict[str, tuple[float, list[str]]] = {}
_MODEL_CACHE_TTL = 300


def _get_cached_models(key: str) -> list[str] | None:
    if key in _model_cache:
        ts, models = _model_cache[key]
        if time.time() - ts < _MODEL_CACHE_TTL:
            return models
    return None


def _set_cached_models(key: str, models: list[str]):
    _model_cache[key] = (time.time(), models)


# ── OpenAI model fetching ─────────────────────────────────────────────────────
_OPENAI_CHAT_PREFIXES = ("gpt-", "o1", "o3", "o4", "chatgpt-")
_OPENAI_EXCLUDE = ("audio", "realtime", "transcribe", "search")


def _fetch_openai_models() -> list[str]:
    cached = _get_cached_models("openai")
    if cached is not None:
        return cached
    settings = _load_settings()
    api_key = (settings.get("openai_api_key", "") or "").strip()
    if not api_key:
        return OPENAI_MODELS_FALLBACK
    try:
        r = requests.get(
            "https://api.openai.com/v1/models",
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=10,
        )
        r.raise_for_status()
        raw = r.json().get("data", [])
        names = []
        for m in raw:
            mid = m.get("id", "")
            if not any(mid.startswith(p) for p in _OPENAI_CHAT_PREFIXES):
                continue
            if any(ex in mid for ex in _OPENAI_EXCLUDE):
                continue
            names.append(mid)
        names.sort(key=lambda n: (not n.startswith("gpt-4.1"), not n.startswith("o4"), not n.startswith("o3"), n))
        if names:
            _set_cached_models("openai", names)
            return names
    except Exception as e:
        print(f"[models] Failed to fetch OpenAI models: {e}")
    return OPENAI_MODELS_FALLBACK


# ── Gemini model fetching ─────────────────────────────────────────────────────
_GEMINI_GENERATE_METHODS = ("generateContent", "streamGenerateContent")
_GEMINI_EXCLUDE = ("embedding", "aqa", "imagen", "veo", "learnlm", "tts", "live",
                   "gemma", "robotics", "deep-research", "nano-banana", "customtools")


def _fetch_gemini_models() -> list[str]:
    cached = _get_cached_models("gemini")
    if cached is not None:
        return cached
    settings = _load_settings()
    api_key = (settings.get("gemini_api_key", "") or "").strip()
    if not api_key:
        return GEMINI_MODELS_FALLBACK
    try:
        r = requests.get(f"{GEMINI_URL}?key={api_key}", timeout=10)
        r.raise_for_status()
        raw = r.json().get("models", [])
        names = []
        for m in raw:
            full_name = m.get("name", "")
            short = full_name.replace("models/", "")
            methods = m.get("supportedGenerationMethods", [])
            if not any(gm in methods for gm in _GEMINI_GENERATE_METHODS):
                continue
            if any(ex in short for ex in _GEMINI_EXCLUDE):
                continue
            names.append(short)
        names.sort(key=lambda n: (
            not n.startswith("gemini-2.5"), not n.startswith("gemini-2.0"),
            not n.startswith("gemini-1.5"), n,
        ))
        if names:
            _set_cached_models("gemini", names)
            return names
    except Exception as e:
        print(f"[models] Failed to fetch Gemini models: {e}")
    return GEMINI_MODELS_FALLBACK


# ── Model listing ─────────────────────────────────────────────────────────────
def get_ollama_models():
    try:
        response = requests.get(f"{OLLAMA_URL}/tags")
        response.raise_for_status()
        return [model["name"] for model in response.json().get("models", [])]
    except Exception as e:
        print(f"Error fetching Ollama models: {e}")
        return []


def _ollama_blob_to_name(blob_path: str) -> str | None:
    """Given an Ollama blob path (sha256-... file), return the human-readable model name."""
    import os as _os2, json as _json2, re as _re2
    basename = _os2.path.basename(blob_path)
    if not _re2.match(r'^sha256-[0-9a-f]+$', basename, _re2.IGNORECASE):
        return None
    digest = basename.replace("-", ":")  # sha256-abc → sha256:abc
    ollama_models = (
        _os2.environ.get("OLLAMA_MODELS")
        or _os2.path.join(_os2.path.expanduser("~"), ".ollama", "models")
    )
    manifests_root = _os2.path.join(ollama_models, "manifests")
    if not _os2.path.isdir(manifests_root):
        return None
    for root, _dirs, files in _os2.walk(manifests_root):
        for fname in files:
            try:
                manifest_path = _os2.path.join(root, fname)
                with open(manifest_path, "r", encoding="utf-8") as fh:
                    manifest = _json2.load(fh)
                for layer in manifest.get("layers", []):
                    if (layer.get("mediaType") == "application/vnd.ollama.image.model"
                            and layer.get("digest") == digest):
                        # Derive friendly name from manifest path:
                        # manifests_root/registry/namespace/model/tag
                        rel = _os2.path.relpath(manifest_path, manifests_root).replace("\\", "/")
                        parts = rel.split("/")
                        if len(parts) >= 3:
                            return "/".join(parts[1:-1]) + ":" + parts[-1]
                        return fname
            except Exception:
                pass
    return None


def _friendly_name_from_path(model_path: str) -> str:
    """Return a human-readable model name from a file path, resolving Ollama blobs if needed."""
    import os as _os2
    friendly = _ollama_blob_to_name(model_path)
    if friendly:
        return friendly
    basename = _os2.path.basename(model_path)
    for ext in (".gguf", ".bin"):
        if basename.lower().endswith(ext):
            basename = basename[: -len(ext)]
            break
    return basename


def get_llamacpp_models() -> list[str]:
    """List models available on the llama.cpp server, or derive one from the configured model path."""
    s = _load_settings()
    url = (s.get("llamacpp_url") or LLAMACPP_URL).rstrip("/")
    try:
        response = requests.get(f"{url}/v1/models", timeout=3)
        response.raise_for_status()
        raw_ids = [m["id"] for m in response.json().get("data", [])]
        if raw_ids:
            # llama-server sometimes returns full paths or blob hashes as IDs;
            # resolve them to friendly names but keep the original as-is if it's
            # already a clean name (no path separator, no sha256 prefix).
            import os as _os2, re as _re2
            result = []
            for mid in raw_ids:
                if _os2.sep in mid or "/" in mid or _re2.match(r'^sha256[:\-]', mid, _re2.IGNORECASE):
                    result.append(_friendly_name_from_path(mid))
                else:
                    result.append(mid)
            return result
    except Exception:
        pass
    # Server not running — synthesise a name from the configured model path so it
    # still shows up in the model selectors and can be saved in settings.
    model_path = (s.get("llamacpp_model_path") or "").strip()
    if model_path:
        return [_friendly_name_from_path(model_path)]
    return []


def get_all_models() -> list[dict]:
    models = []
    for name in get_ollama_models():
        models.append({"id": name, "label": name, "source": "ollama"})
    settings = _load_settings()
    nvidia_key = (settings.get("nvidia_api_key", "") or "").strip()
    if nvidia_key:
        for name in NVIDIA_MODELS:
            display = f"{NVIDIA_PREFIX}{name}"
            models.append({"id": display, "label": f"{name}  [NVIDIA]", "source": "nvidia"})
    openai_key = (settings.get("openai_api_key", "") or "").strip()
    if openai_key:
        for name in _fetch_openai_models():
            display = f"{OPENAI_PREFIX}{name}"
            models.append({"id": display, "label": f"{name}  [OpenAI]", "source": "openai"})
    gemini_key = (settings.get("gemini_api_key", "") or "").strip()
    if gemini_key:
        for name in _fetch_gemini_models():
            display = f"{GEMINI_PREFIX}{name}"
            models.append({"id": display, "label": f"{name}  [Gemini]", "source": "gemini"})
    for name in get_llamacpp_models():
        display = f"{LLAMACPP_PREFIX}{name}"
        models.append({"id": display, "label": name, "source": "llamacpp"})
    return models


# ── Ollama library & pull ──────────────────────────────────────────────────────

# Minimal fallback list used only when remote fetching fails entirely
_OLLAMA_FALLBACK_MODELS = [
    {"name": "qwen3", "desc": "Alibaba Qwen3 — fast general-purpose", "tags": ["8b", "4b", "14b", "30b", "32b"]},
    {"name": "llama3.1", "desc": "Meta Llama 3.1 — strong all-rounder", "tags": ["8b", "70b", "405b"]},
    {"name": "gemma3", "desc": "Google Gemma 3 — efficient and capable", "tags": ["1b", "4b", "12b", "27b"]},
    {"name": "phi4", "desc": "Microsoft Phi-4 — compact powerhouse", "tags": ["14b", "mini"]},
    {"name": "mistral", "desc": "Mistral AI — fast and capable", "tags": ["7b"]},
    {"name": "deepseek-r1", "desc": "DeepSeek R1 — reasoning model", "tags": ["7b", "8b", "14b", "32b", "70b", "671b"]},
    {"name": "qwen2.5-coder", "desc": "Qwen 2.5 Coder — code generation", "tags": ["7b", "14b", "32b"]},
    {"name": "nomic-embed-text", "desc": "Nomic — text embedding model", "tags": ["v1.5"]},
]

_ollama_library_cache: tuple[float, list[dict]] | None = None
_OLLAMA_LIBRARY_TTL = 1800  # 30 minutes


def _fetch_ollama_remote_library() -> list[dict]:
    """Fetch model list dynamically from ollama.com.

    Combines the library page (rich metadata: description, categories,
    size tags) with the /api/tags endpoint (additional trending models).
    Falls back to curated list when both sources fail.
    """
    models: dict[str, dict] = {}  # name -> {desc, categories, tags}

    # ── Source 1: Scrape ollama.com/library for descriptions + tags ──
    try:
        r = requests.get("https://ollama.com/library", timeout=15)
        r.raise_for_status()
        html = r.text

        card_re = re.compile(
            r'<a[^>]*href="/library/([a-zA-Z0-9._-]+)"[^>]*>(.*?)</a>',
            re.DOTALL,
        )
        for match in card_re.finditer(html):
            name = match.group(1)
            if name in models:
                continue
            card = match.group(2)

            # Description: longest plain-text line in the card
            desc = ""
            lines = re.sub(r"<[^>]+>", "\n", card).strip().split("\n")
            lines = [l.strip() for l in lines if l.strip() and len(l.strip()) > 15]
            if lines:
                desc = unescape(lines[0][:120])

            # Category badges
            categories = [
                cat
                for cat in ("cloud", "embedding", "vision", "tools", "thinking")
                if re.search(rf"\b{cat}\b", card, re.IGNORECASE)
            ]

            # Size tags shown on the card (e.g. 8b, 14b, 70b)
            tags_raw = re.findall(r"\b(\d+(?:\.\d+)?[bt])\b", card, re.IGNORECASE)
            tags = list(dict.fromkeys(t.lower() for t in tags_raw))

            models[name] = {"desc": desc or name, "categories": categories, "tags": tags}
    except Exception:
        pass

    # ── Source 2: /api/tags for additional trending models ──
    try:
        r = requests.get("https://ollama.com/api/tags", timeout=10)
        r.raise_for_status()
        for entry in r.json().get("models", []):
            full = entry.get("name", "")
            parts = full.split(":")
            name = parts[0]
            tag = parts[1] if len(parts) > 1 else "latest"
            if name not in models:
                models[name] = {"desc": name, "categories": [], "tags": []}
            if tag not in models[name]["tags"]:
                models[name]["tags"].append(tag)
    except Exception:
        pass

    # ── Fallback when both remote sources fail ──
    if not models:
        for m in _OLLAMA_FALLBACK_MODELS:
            models[m["name"]] = {"desc": m["desc"], "categories": [], "tags": list(m["tags"])}

    # Ensure every model has at least a 'latest' tag
    for data in models.values():
        if "latest" not in data["tags"]:
            data["tags"].insert(0, "latest")

    return [
        {"name": name, "desc": d["desc"], "categories": d["categories"], "tags": d["tags"]}
        for name, d in models.items()
    ]


def get_ollama_library() -> list[dict]:
    """Return available Ollama models with per-tag install status."""
    global _ollama_library_cache
    if _ollama_library_cache:
        ts, cached = _ollama_library_cache
        if time.time() - ts < _OLLAMA_LIBRARY_TTL:
            return cached

    installed = set(get_ollama_models())  # e.g. {"qwen3:8b", "llama3.1:latest"}
    installed_bases = {n.split(":")[0] for n in installed}
    remote = _fetch_ollama_remote_library()

    result = []
    for m in remote:
        tags = []
        for tag in m["tags"]:
            full_name = f"{m['name']}:{tag}"
            is_installed = (
                full_name in installed
                or (tag == "latest" and m["name"] in installed_bases)
            )
            tags.append({"tag": tag, "full": full_name, "installed": is_installed})
        result.append({"name": m["name"], "desc": m["desc"], "tags": tags})

    _ollama_library_cache = (time.time(), result)
    return result


# Active pull tracking
_ollama_pulls: dict[str, dict] = {}  # model_name -> {status, progress, ...}
_ollama_pulls_lock = __import__("threading").Lock()


def ollama_pull_model(model_name: str) -> dict:
    """Start pulling an Ollama model. Returns immediately with status."""
    import threading

    model_name = model_name.strip()
    if not model_name or not re.match(r'^[a-zA-Z0-9._:/-]+$', model_name):
        return {"ok": False, "error": "Invalid model name"}

    with _ollama_pulls_lock:
        existing = _ollama_pulls.get(model_name)
        if existing and existing.get("status") == "pulling":
            return {"ok": True, "status": "pulling", "model": model_name}

    def _do_pull():
        with _ollama_pulls_lock:
            _ollama_pulls[model_name] = {"status": "pulling", "progress": 0, "error": None}
        try:
            r = requests.post(
                f"{OLLAMA_URL}/pull",
                json={"name": model_name, "stream": True},
                stream=True,
                timeout=600,
            )
            r.raise_for_status()
            for line in r.iter_lines():
                if not line:
                    continue
                import json
                data = json.loads(line)
                status = data.get("status", "")
                total = data.get("total", 0)
                completed = data.get("completed", 0)
                pct = int(completed / total * 100) if total > 0 else 0
                with _ollama_pulls_lock:
                    _ollama_pulls[model_name] = {
                        "status": "pulling",
                        "progress": pct,
                        "detail": status,
                        "error": None,
                    }
            with _ollama_pulls_lock:
                _ollama_pulls[model_name] = {"status": "done", "progress": 100, "error": None}
            # Invalidate caches
            global _ollama_library_cache
            _ollama_library_cache = None
            _model_cache.pop("ollama", None)
        except Exception as e:
            with _ollama_pulls_lock:
                _ollama_pulls[model_name] = {"status": "error", "progress": 0, "error": str(e)}

    threading.Thread(target=_do_pull, daemon=True).start()
    return {"ok": True, "status": "pulling", "model": model_name}


def ollama_pull_status(model_name: str | None = None) -> dict:
    """Get pull status for a model or all active pulls."""
    with _ollama_pulls_lock:
        if model_name:
            info = _ollama_pulls.get(model_name, {"status": "unknown"})
            return {"ok": True, "model": model_name, **info}
        return {"ok": True, "pulls": dict(_ollama_pulls)}


def ollama_delete_model(model_name: str) -> dict:
    """Delete an Ollama model."""
    model_name = model_name.strip()
    if not model_name or not re.match(r'^[a-zA-Z0-9._:/-]+$', model_name):
        return {"ok": False, "error": "Invalid model name"}
    try:
        r = requests.delete(f"{OLLAMA_URL}/delete", json={"name": model_name}, timeout=30)
        r.raise_for_status()
        global _ollama_library_cache
        _ollama_library_cache = None
        _model_cache.pop("ollama", None)
        return {"ok": True}
    except Exception as e:
        return {"ok": False, "error": str(e)}


# ── NVIDIA calling ─────────────────────────────────────────────────────────────
_NVIDIA_THINKING_MODELS = ("deepseek", "reasoning", "r1", "think")

def _nvidia_supports_thinking(model_id: str) -> bool:
    ml = model_id.lower()
    return any(k in ml for k in _NVIDIA_THINKING_MODELS)


def call_nvidia_sync(prompt: str, system: str, model_id: str, thinking: bool = True) -> str:
    settings = _load_settings()
    nvidia_key = (settings.get("nvidia_api_key", "") or "").strip()
    if not nvidia_key:
        return "Error calling NVIDIA API: NVIDIA API key not configured in Settings."
    messages = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": prompt})

    def _do_request(include_thinking: bool):
        payload = {
            "model": model_id, "messages": messages,
            "max_tokens": 32768, "temperature": 0.7, "top_p": 1.0, "stream": False,
        }
        if include_thinking:
            payload["chat_template_kwargs"] = {"thinking": True}
        return requests.post(
            NVIDIA_URL,
            headers={"Authorization": f"Bearer {nvidia_key}", "Accept": "application/json"},
            json=payload, timeout=180,
        )

    try:
        use_thinking = thinking and _nvidia_supports_thinking(model_id)
        res = _do_request(use_thinking)
        if res.status_code == 500 and use_thinking:
            res = _do_request(False)
        res.raise_for_status()
        content = res.json()["choices"][0]["message"].get("content") or ""
        return content.strip()
    except Exception as e:
        return f"Error calling NVIDIA API: {e}"


# ── OpenAI calling ─────────────────────────────────────────────────────────────
_OPENAI_REASONING_PREFIXES = ("o1", "o3", "o4")

def _openai_is_reasoning(model_id: str) -> bool:
    return any(model_id.startswith(p) for p in _OPENAI_REASONING_PREFIXES)


def call_openai_sync(prompt: str, system: str, model_id: str, thinking: bool = True) -> str:
    settings = _load_settings()
    api_key = (settings.get("openai_api_key", "") or "").strip()
    if not api_key:
        return "Error calling OpenAI API: API key not configured in Settings."
    messages = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": prompt})
    payload = {
        "model": model_id, "messages": messages,
        "max_tokens": 16384, "temperature": 0.7, "stream": False,
    }
    if _openai_is_reasoning(model_id):
        payload["reasoning_effort"] = "high" if thinking else "low"
    try:
        res = requests.post(
            OPENAI_URL,
            headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
            json=payload, timeout=180,
        )
        res.raise_for_status()
        return (res.json()["choices"][0]["message"].get("content") or "").strip()
    except Exception as e:
        return f"Error calling OpenAI API: {e}"


# ── Gemini calling ─────────────────────────────────────────────────────────────
_GEMINI_THINKING_MODELS = ("thinking", "2.5-pro", "2.5-flash")

def _gemini_supports_thinking(model_id: str) -> bool:
    ml = model_id.lower()
    return any(k in ml for k in _GEMINI_THINKING_MODELS)


def call_gemini_sync(prompt: str, system: str, model_id: str, thinking: bool = True) -> str:
    settings = _load_settings()
    api_key = (settings.get("gemini_api_key", "") or "").strip()
    if not api_key:
        return "Error calling Gemini API: API key not configured in Settings."
    url = f"{GEMINI_URL}/{model_id}:generateContent?key={api_key}"
    contents = []
    if system:
        contents.append({"role": "user", "parts": [{"text": system}]})
        contents.append({"role": "model", "parts": [{"text": "Understood."}]})
    contents.append({"role": "user", "parts": [{"text": prompt}]})
    gen_config = {"temperature": 0.7, "maxOutputTokens": 16384}
    if _gemini_supports_thinking(model_id):
        if thinking:
            gen_config["thinkingConfig"] = {"thinkingBudget": 8192}
        else:
            gen_config["thinkingConfig"] = {"thinkingBudget": 0}
    try:
        res = requests.post(
            url,
            headers={"Content-Type": "application/json"},
            json={"contents": contents, "generationConfig": gen_config},
            timeout=180,
        )
        res.raise_for_status()
        parts = res.json().get("candidates", [{}])[0].get("content", {}).get("parts", [])
        return "".join(p.get("text", "") for p in parts).strip()
    except Exception as e:
        return f"Error calling Gemini API: {e}"


# ── llama.cpp sync caller ────────────────────────────────────────────────────
def call_llamacpp_sync(prompt: str, system: str, model_id: str, thinking: bool = False) -> str:
    """Call llama.cpp server (OpenAI-compatible /v1/chat/completions)."""
    s = _load_settings()
    url = (s.get("llamacpp_url") or LLAMACPP_URL).rstrip("/")
    if not _ensure_llamacpp_running(s):
        return "llama-server could not be started. Check the model path in Settings."
    messages = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": prompt})
    payload: dict = {
        "messages": messages,
        "temperature": 0.7,
        "max_tokens": 8192,
        "stream": False,
    }
    if model_id:
        payload["model"] = model_id
    try:
        resp = requests.post(
            f"{url}/v1/chat/completions",
            headers={"Content-Type": "application/json"},
            json=payload,
            timeout=180,
        )
        resp.raise_for_status()
        data = resp.json()
        return (data["choices"][0]["message"].get("content") or "") if data.get("choices") else ""
    except Exception as e:
        return f"Error calling llama.cpp: {e}"


def _normalize_openai_tool_response(response: dict) -> dict:
    """Normalize an OpenAI-style /v1/chat/completions response to the Ollama
    message dict format used by the native tool-calling loop in main.py.

    OpenAI format:
        {"choices": [{"message": {"content": "...", "tool_calls": [
            {"id": "...", "type": "function",
             "function": {"name": "...", "arguments": "{...}"}}]}}]}

    Ollama format (what the loop expects):
        {"message": {"content": "...", "tool_calls": [
            {"function": {"name": "...", "arguments": {...}}}]}}
    """
    choices = response.get("choices") or []
    if not choices:
        error = response.get("error") or response.get("message") or "unknown"
        return {"error": str(error), "message": {"content": "", "tool_calls": []}}
    openai_msg = choices[0].get("message") or {}
    content = str(openai_msg.get("content") or "")
    raw_calls = openai_msg.get("tool_calls") or []
    normalized_calls = []
    for tc in raw_calls:
        fn = tc.get("function") or {}
        name = str(fn.get("name") or "").strip()
        raw_args = fn.get("arguments") or {}
        if isinstance(raw_args, str):
            try:
                import json as _json
                raw_args = _json.loads(raw_args)
            except Exception:
                raw_args = {}
        if name:
            normalized_calls.append({"function": {"name": name, "arguments": raw_args}})
    return {"message": {"content": content, "tool_calls": normalized_calls}}


def call_openai_compat_with_tools_sync(
    messages: list[dict],
    tools: list[dict],
    *,
    model_id: str,
    api_key: str,
    base_url: str,
    extra_headers: dict | None = None,
    timeout: int = 180,
) -> dict:
    """Call an OpenAI-compatible /v1/chat/completions endpoint with tool schemas.

    Returns a normalized Ollama-style message dict:
        {"message": {"content": "...", "tool_calls": [...]}}
    """
    headers = {"Content-Type": "application/json"}
    if api_key:
        headers["Authorization"] = f"Bearer {api_key}"
    if extra_headers:
        headers.update(extra_headers)
    payload: dict = {
        "model": model_id,
        "messages": messages,
        "temperature": 0.7,
        "max_tokens": 16384,
        "stream": False,
    }
    if tools:
        payload["tools"] = tools
        payload["tool_choice"] = "auto"
    try:
        resp = requests.post(base_url, headers=headers, json=payload, timeout=timeout)
        resp.raise_for_status()
        return _normalize_openai_tool_response(resp.json())
    except Exception as e:
        return {"error": str(e), "message": {"content": "", "tool_calls": []}}


# ── Vision sync caller (image description) ───────────────────────────────────
# Set of Ollama base names that are known to support image input.  Used as a
# last-resort when no cloud vision provider is configured.
_OLLAMA_VISION_FAMILIES = {
    "llava", "llava-llama3", "llava-phi3", "bakllava",
    "llama3.2-vision", "llama4",
    "moondream", "moondream2",
    "qwen2-vl", "qwen2.5-vl", "qwen3-vl",
    "minicpm-v", "minicpm-o",
    "granite3.2-vision", "granite-vision",
    "gemma3",
}


def _looks_like_ollama_vision_model(name: str) -> bool:
    base = (name or "").split(":", 1)[0].lower()
    if base in _OLLAMA_VISION_FAMILIES:
        return True
    return any(family in base for family in ("vision", "vl", "llava", "moondream", "minicpm-v"))


def _pick_vision_model(settings: dict) -> str:
    """Return a fully qualified vision-capable model identifier or empty string."""
    explicit = (settings.get("vision_model") or "").strip()
    if explicit:
        return explicit
    if (settings.get("gemini_api_key") or "").strip():
        return f"{GEMINI_PREFIX}gemini-2.5-flash"
    if (settings.get("openai_api_key") or "").strip():
        return f"{OPENAI_PREFIX}gpt-4o-mini"
    if (settings.get("nvidia_api_key") or "").strip():
        return f"{NVIDIA_PREFIX}meta/llama-3.2-90b-vision-instruct"
    # Try locally installed Ollama vision model.
    try:
        r = requests.get(f"{OLLAMA_URL}/tags", timeout=5)
        r.raise_for_status()
        for entry in r.json().get("models", []) or []:
            name = str(entry.get("name", "") or "")
            if _looks_like_ollama_vision_model(name):
                return name
    except Exception:
        pass
    return ""


def _call_gemini_vision_sync(prompt: str, system: str, model_id: str, images: list[tuple[str, str]]) -> str:
    """images: list of (mime_type, base64_data) tuples."""
    settings = _load_settings()
    api_key = (settings.get("gemini_api_key", "") or "").strip()
    if not api_key:
        return "Error calling Gemini API: API key not configured in Settings."
    url = f"{GEMINI_URL}/{model_id}:generateContent?key={api_key}"
    parts: list[dict] = []
    if system:
        parts.append({"text": f"System instruction: {system}\n\n"})
    parts.append({"text": prompt})
    for mime, b64 in images:
        parts.append({"inlineData": {"mimeType": mime or "image/png", "data": b64}})
    contents = [{"role": "user", "parts": parts}]
    try:
        res = requests.post(
            url,
            headers={"Content-Type": "application/json"},
            json={"contents": contents, "generationConfig": {"temperature": 0.0, "maxOutputTokens": 4096}},
            timeout=180,
        )
        res.raise_for_status()
        out_parts = res.json().get("candidates", [{}])[0].get("content", {}).get("parts", [])
        return "".join(p.get("text", "") for p in out_parts).strip()
    except Exception as e:
        return f"Error calling Gemini API: {e}"


def _call_openai_vision_sync(prompt: str, system: str, model_id: str, images: list[tuple[str, str]]) -> str:
    settings = _load_settings()
    api_key = (settings.get("openai_api_key", "") or "").strip()
    if not api_key:
        return "Error calling OpenAI API: API key not configured in Settings."
    user_content: list[dict] = [{"type": "text", "text": prompt}]
    for mime, b64 in images:
        user_content.append({
            "type": "image_url",
            "image_url": {"url": f"data:{mime or 'image/png'};base64,{b64}"},
        })
    messages: list[dict] = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": user_content})
    payload = {"model": model_id, "messages": messages, "max_tokens": 4096, "temperature": 0.0, "stream": False}
    try:
        res = requests.post(
            OPENAI_URL,
            headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
            json=payload, timeout=180,
        )
        res.raise_for_status()
        return (res.json()["choices"][0]["message"].get("content") or "").strip()
    except Exception as e:
        return f"Error calling OpenAI API: {e}"


def _call_nvidia_vision_sync(prompt: str, system: str, model_id: str, images: list[tuple[str, str]]) -> str:
    settings = _load_settings()
    api_key = (settings.get("nvidia_api_key", "") or "").strip()
    if not api_key:
        return "Error calling NVIDIA API: NVIDIA API key not configured in Settings."
    user_content: list[dict] = [{"type": "text", "text": prompt}]
    for mime, b64 in images:
        user_content.append({
            "type": "image_url",
            "image_url": {"url": f"data:{mime or 'image/png'};base64,{b64}"},
        })
    messages: list[dict] = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": user_content})
    payload = {"model": model_id, "messages": messages, "max_tokens": 4096, "temperature": 0.0, "stream": False}
    try:
        res = requests.post(
            NVIDIA_URL,
            headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"},
            json=payload, timeout=180,
        )
        res.raise_for_status()
        return (res.json()["choices"][0]["message"].get("content") or "").strip()
    except Exception as e:
        return f"Error calling NVIDIA API: {e}"


def _call_ollama_vision_sync(prompt: str, system: str, model_id: str, images: list[tuple[str, str]]) -> str:
    try:
        _ollama_ensure_model(model_id)
    except Exception:
        pass
    messages: list[dict] = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": prompt, "images": [b64 for _mime, b64 in images]})
    payload = {
        "model": model_id, "messages": messages, "stream": False,
        "options": {"num_ctx": 16384, "temperature": 0.0},
        "keep_alive": "10m",
    }
    try:
        res = requests.post(f"{OLLAMA_URL}/chat", json=payload, timeout=300)
        res.raise_for_status()
        return ((res.json().get("message") or {}).get("content") or "").strip()
    except Exception as e:
        return f"Error calling Ollama vision: {e}"


def call_vision_sync(
    prompt: str,
    images: list[tuple[str, str]],
    system: str = "",
    model: str = "",
) -> str:
    """Send an image-understanding request. ``images`` is a list of
    (mime_type, base64_data) tuples. Picks a vision-capable provider based on
    settings; returns the model output or an error string starting with
    "Error calling ".
    """
    if not images:
        return "Error calling vision: no images provided."
    settings = _load_settings()
    chosen = (model or "").strip() or _pick_vision_model(settings)
    if not chosen:
        return (
            "Error calling vision: no vision-capable model available. "
            "Configure a Gemini, OpenAI or NVIDIA API key, install a local "
            "Ollama vision model (e.g. llava, llama3.2-vision, moondream, "
            "qwen2.5-vl), or set 'vision_model' in settings."
        )
    if chosen.startswith(GEMINI_PREFIX):
        return _call_gemini_vision_sync(prompt, system, chosen[len(GEMINI_PREFIX):], images)
    if chosen.startswith(OPENAI_PREFIX):
        return _call_openai_vision_sync(prompt, system, chosen[len(OPENAI_PREFIX):], images)
    if chosen.startswith(NVIDIA_PREFIX):
        return _call_nvidia_vision_sync(prompt, system, chosen[len(NVIDIA_PREFIX):], images)
    return _call_ollama_vision_sync(prompt, system, chosen, images)


# ── Ollama async caller ───────────────────────────────────────────────────────
async def call_ollama(prompt: str, model: str, system: str = "", num_ctx: int = 16384, thinking: bool = False):
    loop = asyncio.get_event_loop()
    def _call():
        if not (model or "").strip():
            return "Error calling Ollama: no model selected."
        _ollama_ensure_model(model)
        messages = []
        if system:
            messages.append({"role": "system", "content": system})
        messages.append({"role": "user", "content": prompt})
        tried_ctx: list[int] = []
        base = max(1024, int(num_ctx or 16384))
        candidates = [base, min(base // 2, 12288), 8192, 4096, 2048]
        last_error = ""
        for ctx in candidates:
            if ctx in tried_ctx or ctx <= 0:
                continue
            tried_ctx.append(ctx)
            try:
                payload = {
                    "model": model, "messages": messages,
                    "stream": False,
                    "options": {"num_ctx": ctx, "temperature": 0.7},
                    "keep_alive": "10m",
                }
                if thinking:
                    payload["think"] = True
                res = requests.post(
                    f"{OLLAMA_URL}/chat",
                    json=payload,
                    timeout=180,
                )
                if res.status_code >= 500:
                    body = (res.text or "").strip()
                    last_error = f"HTTP {res.status_code} (num_ctx={ctx}){': ' + body[:220] if body else ''}"
                    continue
                res.raise_for_status()
                data = res.json() if res.content else {}
                msg = data.get("message") or {}
                content = msg.get("content", "")
                think_text = msg.get("thinking", "")
                if think_text:
                    return f"<think>{think_text}</think>{content}"
                return content
            except Exception as e:
                last_error = f"{e} (num_ctx={ctx})"
        return f"Error calling Ollama: {last_error or 'unknown error'}"
    return await loop.run_in_executor(None, _call)


# ── Unified text model caller ─────────────────────────────────────────────────
def _is_llm_error(result: str) -> bool:
    """Check if an LLM result string indicates a call failure."""
    return isinstance(result, str) and result.startswith("Error calling ")

async def _call_single_model(prompt: str, model: str, system: str = "", num_ctx: int = 8192, thinking: bool = False) -> str:
    loop = asyncio.get_event_loop()
    if model.startswith(NVIDIA_PREFIX):
        nvidia_model_id = model[len(NVIDIA_PREFIX):]
        return await loop.run_in_executor(None, call_nvidia_sync, prompt, system, nvidia_model_id, thinking)
    if model.startswith(OPENAI_PREFIX):
        openai_model_id = model[len(OPENAI_PREFIX):]
        return await loop.run_in_executor(None, call_openai_sync, prompt, system, openai_model_id, thinking)
    if model.startswith(GEMINI_PREFIX):
        gemini_model_id = model[len(GEMINI_PREFIX):]
        return await loop.run_in_executor(None, call_gemini_sync, prompt, system, gemini_model_id, thinking)
    if model.startswith(LLAMACPP_PREFIX):
        llamacpp_model_id = model[len(LLAMACPP_PREFIX):]
        return await loop.run_in_executor(None, call_llamacpp_sync, prompt, system, llamacpp_model_id, thinking)
    return await call_ollama(prompt, model, system=system, num_ctx=num_ctx, thinking=thinking)

async def _call_text_model(prompt: str, model: str, system: str = "", num_ctx: int = 8192, thinking: bool = False) -> str:
    result = await _call_single_model(prompt, model, system=system, num_ctx=num_ctx, thinking=thinking)
    if _is_llm_error(result):
        settings = _load_settings()
        fallback = (settings.get("fallback_model") or "").strip()
        if fallback and fallback != model:
            print(f"[failover] Primary model '{model}' failed, trying fallback '{fallback}'")
            fb_result = await _call_single_model(prompt, fallback, system=system, num_ctx=num_ctx, thinking=thinking)
            if not _is_llm_error(fb_result):
                return fb_result
            print(f"[failover] Fallback model '{fallback}' also failed")
    return result


def _unique_model_candidates(*candidates: str) -> list[str]:
    ordered: list[str] = []
    seen: set[str] = set()
    for candidate in candidates:
        model = (candidate or "").strip()
        if not model or model in seen:
            continue
        ordered.append(model)
        seen.add(model)
    return ordered


async def _call_text_model_candidates(
    prompt: str,
    candidates: list[str],
    system: str = "",
    num_ctx: int = 8192,
    thinking: bool = False,
    per_model_timeout: float | None = None,
) -> tuple[str, str]:
    ordered = _unique_model_candidates(*candidates)
    if not ordered:
        return "Error calling model: no model selected.", ""

    last_result = "Error calling model: no candidates succeeded."
    last_model = ordered[0]
    for model in ordered:
        last_model = model
        try:
            if per_model_timeout and per_model_timeout > 0:
                result = await asyncio.wait_for(
                    _call_single_model(prompt, model, system=system, num_ctx=num_ctx, thinking=thinking),
                    timeout=per_model_timeout,
                )
            else:
                result = await _call_single_model(prompt, model, system=system, num_ctx=num_ctx, thinking=thinking)
        except asyncio.TimeoutError:
            last_result = f"Error calling model: timed out after {per_model_timeout:.1f}s ({model})"
            print(f"[failover] Model '{model}' timed out after {per_model_timeout:.1f}s")
            continue
        except Exception as exc:
            last_result = f"Error calling model: {exc}"
            print(f"[failover] Model '{model}' raised exception: {exc}")
            continue

        if _is_llm_error(result):
            last_result = result
            print(f"[failover] Model '{model}' failed: {result[:220]}")
            continue
        return result, model

    return last_result, last_model
