//! Runtime model providers (Ollama, llama.cpp, MLX, Docker Model Runner, LM Studio, vLLM).
//!
//! Each provider can list locally installed models and pull new ones.

use std::collections::HashSet;
use std::path::{Path, PathBuf};

// ---------------------------------------------------------------------------
// Provider trait
// ---------------------------------------------------------------------------

/// A runtime provider that can serve LLM models locally.
pub trait ModelProvider {
    /// Human-readable name shown in the UI.
    fn name(&self) -> &str;

    /// Whether the provider service is reachable right now.
    fn is_available(&self) -> bool;

    /// Return the set of model name stems that are currently installed.
    /// Names are normalised lowercase, e.g. "llama3.1:8b".
    fn installed_models(&self) -> HashSet<String>;

    /// Start pulling a model. Returns immediately; progress is polled
    /// via `pull_progress()`.
    fn start_pull(&self, model_tag: &str) -> Result<PullHandle, String>;
}

/// Handle returned by `start_pull`. The TUI polls this in a background
/// thread and reads status/progress.
pub struct PullHandle {
    pub model_tag: String,
    pub receiver: std::sync::mpsc::Receiver<PullEvent>,
}

#[derive(Debug, Clone)]
pub enum PullEvent {
    Progress {
        status: String,
        percent: Option<f64>,
    },
    Done,
    Error(String),
}

// ---------------------------------------------------------------------------
// Ollama provider
// ---------------------------------------------------------------------------

pub struct OllamaProvider {
    base_url: String,
    /// Fallback URL to try when `base_url` is unreachable.
    /// Set when using the default `localhost` address so that systems where
    /// `localhost` resolves to `::1` (IPv6) can fall back to `127.0.0.1`.
    fallback_url: Option<String>,
}

fn normalize_ollama_host(raw: &str) -> Option<String> {
    let host = raw.trim();
    if host.is_empty() {
        return None;
    }

    if host.starts_with("http://") || host.starts_with("https://") {
        return Some(host.to_string());
    }

    if host.contains("://") {
        // Unsupported scheme (e.g. ftp://)
        return None;
    }

    Some(format!("http://{host}"))
}

/// Returns true if the URL's host is a wildcard bind address — `0.0.0.0`
/// (IPv4) or `[::]` (IPv6). Servers listen on these to accept traffic on
/// every interface, but they are never valid as a connect target. When
/// Ollama is started with `OLLAMA_HOST=0.0.0.0`, that value leaks into the
/// environment and we must not pass it to a client.
fn is_wildcard_bind_address(url: &str) -> bool {
    let after_scheme = url
        .strip_prefix("http://")
        .or_else(|| url.strip_prefix("https://"))
        .unwrap_or(url);
    let host_port = after_scheme
        .split(['/', '?', '#'])
        .next()
        .unwrap_or(after_scheme);

    if let Some(rest) = host_port.strip_prefix('[') {
        if let Some(end_idx) = rest.find(']') {
            let host = &rest[..end_idx];
            return host == "::" || host == "0:0:0:0:0:0:0:0";
        }
        return false;
    }

    let host = host_port.split(':').next().unwrap_or("");
    host == "0.0.0.0"
}

impl Default for OllamaProvider {
    fn default() -> Self {
        let explicit = std::env::var("OLLAMA_HOST").ok().and_then(|raw| {
            let Some(normalized) = normalize_ollama_host(&raw) else {
                eprintln!(
                    "Warning: could not parse OLLAMA_HOST='{}'. Expected host:port or http(s)://host:port",
                    raw
                );
                return None;
            };
            if is_wildcard_bind_address(&normalized) {
                eprintln!(
                    "Warning: OLLAMA_HOST='{}' is a wildcard bind address; falling back to localhost.",
                    raw
                );
                return None;
            }
            Some(normalized)
        });

        if let Some(base_url) = explicit {
            // User supplied an explicit host — use it as-is, no fallback.
            Self {
                base_url,
                fallback_url: None,
            }
        } else {
            // Default: try `localhost` first; fall back to `127.0.0.1` for
            // systems where `localhost` resolves to the IPv6 loopback `::1`
            // while Ollama is only listening on the IPv4 `127.0.0.1`.
            Self {
                base_url: "http://localhost:11434".to_string(),
                fallback_url: Some("http://127.0.0.1:11434".to_string()),
            }
        }
    }
}

impl OllamaProvider {
    pub fn new() -> Self {
        Self::default()
    }

    /// Build the full API URL for a given endpoint path.
    fn api_url(&self, path: &str) -> String {
        format!("{}/api/{}", self.base_url.trim_end_matches('/'), path)
    }

    /// Delete a model from Ollama via its API.
    pub fn delete_model(&self, model_tag: &str) -> Result<(), String> {
        // Ollama DELETE /api/delete requires a JSON body.
        // ureq v3's delete() doesn't support request bodies, so we build a
        // raw http::Request and pass it to the agent's `run()` method.
        let body = serde_json::json!({ "name": model_tag }).to_string();
        let url = self.api_url("delete");
        let request = http::Request::builder()
            .method("DELETE")
            .uri(&url)
            .header("content-type", "application/json")
            .body(body)
            .map_err(|e| format!("Failed to build request: {}", e))?;
        let agent: ureq::Agent = ureq::Agent::config_builder()
            .timeout_global(Some(std::time::Duration::from_secs(10)))
            .build()
            .into();
        let resp = agent
            .run(request)
            .map_err(|e| format!("Ollama delete request failed: {}", e))?;
        if resp.status() == 200 {
            Ok(())
        } else {
            Err(format!("Ollama returned status {}", resp.status()))
        }
    }

    /// Single-pass startup probe to avoid duplicate `/api/tags` calls.
    /// Returns `(available, installed_models)`.
    /// When the primary URL (`localhost`) fails and a fallback (`127.0.0.1`)
    /// is configured, the fallback is tried and—if successful—adopted as the
    /// provider's base URL for all subsequent requests (pull, show, …).
    pub fn detect_with_installed(&mut self) -> (bool, HashSet<String>, usize) {
        let set = HashSet::new();

        let primary_ok = ureq::get(&self.api_url("tags"))
            .config()
            .timeout_global(Some(std::time::Duration::from_millis(800)))
            .build()
            .call();

        let resp = match primary_ok {
            Ok(r) => r,
            Err(_) => {
                // Primary URL failed — try the fallback if one is set.
                let Some(ref fallback) = self.fallback_url.clone() else {
                    return (false, set, 0);
                };
                let fallback_url = format!("{}/api/tags", fallback.trim_end_matches('/'));
                let Ok(r) = ureq::get(&fallback_url)
                    .config()
                    .timeout_global(Some(std::time::Duration::from_millis(800)))
                    .build()
                    .call()
                else {
                    return (false, set, 0);
                };
                // Fallback worked: adopt it so that pull/show use 127.0.0.1.
                self.base_url = fallback.clone();
                self.fallback_url = None;
                r
            }
        };

        let Ok(tags): Result<TagsResponse, _> = resp.into_body().read_json() else {
            return (true, set, 0);
        };
        let (set, count) = build_installed_set(tags.models);
        (true, set, count)
    }

    /// Like `installed_models`, but also returns the true model count.
    /// The HashSet may have fewer entries than 2*count due to family-name deduplication,
    /// so `len() / 2` is unreliable for counting models.
    pub fn installed_models_counted(&self) -> (HashSet<String>, usize) {
        let Ok(resp) = ureq::get(&self.api_url("tags"))
            .config()
            .timeout_global(Some(std::time::Duration::from_secs(5)))
            .build()
            .call()
        else {
            return (HashSet::new(), 0);
        };
        let Ok(tags): Result<TagsResponse, _> = resp.into_body().read_json() else {
            return (HashSet::new(), 0);
        };
        build_installed_set(tags.models)
    }

    /// Best-effort check that a tag exists in Ollama's remote registry.
    /// Uses the local Ollama daemon's `/api/show` resolution path.
    pub fn has_remote_tag(&self, model_tag: &str) -> bool {
        let body = serde_json::json!({ "model": model_tag });
        ureq::post(&self.api_url("show"))
            .config()
            .timeout_global(Some(std::time::Duration::from_millis(1200)))
            .build()
            .send_json(&body)
            .is_ok()
    }
}

// -- JSON response types for Ollama API --

#[derive(serde::Deserialize)]
struct TagsResponse {
    models: Vec<OllamaModel>,
}

#[derive(serde::Deserialize)]
struct OllamaModel {
    /// e.g. "llama3.1:8b-instruct-q4_K_M"
    name: String,
    /// On-disk size in bytes. Cloud-hosted models are served remotely and
    /// report `0` because nothing is stored locally.
    #[serde(default)]
    size: u64,
}

impl OllamaModel {
    /// Whether this entry is a cloud-hosted model rather than a local install.
    /// Ollama surfaces cloud models with a `-cloud` tag suffix (e.g.
    /// `qwen3-coder:480b-cloud`) and a zero on-disk size.
    fn is_cloud(&self) -> bool {
        let tag = self.name.rsplit(':').next().unwrap_or("");
        tag.ends_with("-cloud") || self.size == 0
    }
}

/// Build the set of installed model name stems from Ollama's tag list, plus the
/// count of locally-installed models. Cloud-hosted models are skipped entirely:
/// they are not installed locally, and inserting their family stem (e.g.
/// `qwen3-coder` from `qwen3-coder:480b-cloud`) would falsely mark unrelated
/// models as installed (#619).
fn build_installed_set(models: Vec<OllamaModel>) -> (HashSet<String>, usize) {
    let mut set = HashSet::new();
    let mut count = 0;
    for m in models {
        if m.is_cloud() {
            continue;
        }
        count += 1;
        let lower = m.name.to_lowercase();
        set.insert(lower.clone());
        if let Some(family) = lower.split(':').next() {
            set.insert(family.to_string());
        }
    }
    (set, count)
}

#[derive(serde::Deserialize)]
struct PullStreamLine {
    #[serde(default)]
    status: String,
    #[serde(default)]
    total: Option<u64>,
    #[serde(default)]
    completed: Option<u64>,
    #[serde(default)]
    error: Option<String>,
}

impl ModelProvider for OllamaProvider {
    fn name(&self) -> &str {
        "Ollama"
    }

    fn is_available(&self) -> bool {
        ureq::get(&self.api_url("tags"))
            .config()
            .timeout_global(Some(std::time::Duration::from_secs(2)))
            .build()
            .call()
            .is_ok()
    }

    fn installed_models(&self) -> HashSet<String> {
        let (set, _) = self.installed_models_counted();
        set
    }

    fn start_pull(&self, model_tag: &str) -> Result<PullHandle, String> {
        let url = self.api_url("pull");
        let tag = model_tag.to_string();
        let (tx, rx) = std::sync::mpsc::channel();

        let body = serde_json::json!({
            "model": tag,
            "stream": true,
        });

        std::thread::spawn(move || {
            let resp = ureq::post(&url)
                .config()
                .timeout_global(Some(std::time::Duration::from_secs(3600)))
                .build()
                .send_json(&body);

            match resp {
                Ok(resp) => {
                    let reader = std::io::BufReader::new(resp.into_body().into_reader());
                    use std::io::BufRead;
                    for line in reader.lines() {
                        let Ok(line) = line else { break };
                        if line.is_empty() {
                            continue;
                        }
                        if let Ok(parsed) = serde_json::from_str::<PullStreamLine>(&line) {
                            // Check for error responses from Ollama
                            if let Some(ref err) = parsed.error {
                                let _ = tx.send(PullEvent::Error(err.clone()));
                                return;
                            }
                            let percent = match (parsed.completed, parsed.total) {
                                (Some(c), Some(t)) if t > 0 => Some(c as f64 / t as f64 * 100.0),
                                _ => None,
                            };
                            let _ = tx.send(PullEvent::Progress {
                                status: parsed.status.clone(),
                                percent,
                            });
                            if parsed.status == "success" {
                                let _ = tx.send(PullEvent::Done);
                                return;
                            }
                        }
                    }
                    // Stream ended without "success" — treat as error
                    let _ = tx.send(PullEvent::Error(
                        "Pull ended without success (model may not exist in Ollama registry)"
                            .to_string(),
                    ));
                }
                Err(e) => {
                    let _ = tx.send(PullEvent::Error(format!("{e}")));
                }
            }
        });

        Ok(PullHandle {
            model_tag: model_tag.to_string(),
            receiver: rx,
        })
    }
}

// ---------------------------------------------------------------------------
// OpenAI-compatible provider helpers
// ---------------------------------------------------------------------------

#[derive(serde::Deserialize)]
struct OpenAiModelList {
    data: Vec<OpenAiModel>,
}

#[derive(serde::Deserialize)]
struct OpenAiModel {
    /// Model id, e.g. "meta-llama/Llama-3.1-8B-Instruct".
    id: String,
    /// OpenAI-compatible providers may include an owner string. oMLX uses
    /// `owned_by: "omlx"`, which lets us disambiguate it from vLLM.
    owned_by: Option<String>,
}

fn openai_models_url(base_url: &str) -> String {
    format!("{}/v1/models", base_url.trim_end_matches('/'))
}

fn fetch_openai_model_list(
    base_url: &str,
    timeout: std::time::Duration,
) -> Option<OpenAiModelList> {
    let resp = ureq::get(&openai_models_url(base_url))
        .config()
        .timeout_global(Some(timeout))
        .build()
        .call()
        .ok()?;
    resp.into_body().read_json::<OpenAiModelList>().ok()
}

fn openai_model_list_is_omlx(list: &OpenAiModelList) -> bool {
    list.data.iter().any(|model| {
        model
            .owned_by
            .as_deref()
            .is_some_and(|owner| owner.eq_ignore_ascii_case("omlx"))
    })
}

fn openai_model_ids(list: &OpenAiModelList) -> impl Iterator<Item = &str> {
    list.data.iter().map(|model| model.id.as_str())
}

fn is_omlx_status_payload(json: &serde_json::Value) -> bool {
    json.get("status").and_then(|v| v.as_str()) == Some("ok")
        && json.get("version").and_then(|v| v.as_str()).is_some()
        && (json.get("models_discovered").is_some()
            || json.get("model_memory_max").is_some()
            || json.get("cache_efficiency").is_some())
}

fn endpoint_has_omlx_status(base_url: &str, timeout: std::time::Duration) -> bool {
    let url = format!("{}/api/status", base_url.trim_end_matches('/'));
    let Ok(resp) = ureq::get(&url)
        .config()
        .timeout_global(Some(timeout))
        .build()
        .call()
    else {
        return false;
    };
    let Ok(json) = resp.into_body().read_json::<serde_json::Value>() else {
        return false;
    };
    is_omlx_status_payload(&json)
}

// ---------------------------------------------------------------------------
// MLX provider (Apple MLX framework via HuggingFace cache)
// ---------------------------------------------------------------------------

const MLX_DEFAULT_SERVER_URL: &str = "http://localhost:8080";
const OMLX_DEFAULT_SERVER_URL: &str = "http://127.0.0.1:8000";

struct MlxServerCandidate<'a> {
    base_url: &'a str,
    require_omlx_identity: bool,
}

pub struct MlxProvider {
    server_url: String,
    server_url_explicit: bool,
}

impl Default for MlxProvider {
    fn default() -> Self {
        let explicit = std::env::var("MLX_LM_HOST").ok().and_then(|url| {
            if url.starts_with("http://") || url.starts_with("https://") {
                Some(url)
            } else {
                eprintln!(
                    "Warning: MLX_LM_HOST must start with http:// or https://, ignoring: {}",
                    url
                );
                None
            }
        });
        let server_url_explicit = explicit.is_some();
        let server_url = explicit.unwrap_or_else(|| MLX_DEFAULT_SERVER_URL.to_string());
        Self {
            server_url,
            server_url_explicit,
        }
    }
}

impl MlxProvider {
    pub fn new() -> Self {
        Self::default()
    }

    fn server_candidates(&self) -> Vec<MlxServerCandidate<'_>> {
        let mut candidates = vec![MlxServerCandidate {
            base_url: self.server_url.as_str(),
            require_omlx_identity: false,
        }];
        if !self.server_url_explicit && self.server_url != OMLX_DEFAULT_SERVER_URL {
            candidates.push(MlxServerCandidate {
                base_url: OMLX_DEFAULT_SERVER_URL,
                require_omlx_identity: true,
            });
        }
        candidates
    }

    fn fetch_candidate_models(
        candidate: &MlxServerCandidate<'_>,
        timeout: std::time::Duration,
    ) -> Option<OpenAiModelList> {
        let has_omlx_status = candidate.require_omlx_identity
            && endpoint_has_omlx_status(candidate.base_url, timeout);
        let list = fetch_openai_model_list(candidate.base_url, timeout)?;
        if candidate.require_omlx_identity && !has_omlx_status && !openai_model_list_is_omlx(&list)
        {
            return None;
        }
        Some(list)
    }

    /// Single-pass startup probe for MLX.
    /// On non-macOS, skips network checks and reports `available=false`.
    pub fn detect_with_installed(&self) -> (bool, HashSet<String>) {
        let mut set = scan_hf_cache_for_mlx();
        if !cfg!(target_os = "macos") {
            return (false, set);
        }

        for candidate in self.server_candidates() {
            if let Some(list) =
                Self::fetch_candidate_models(&candidate, std::time::Duration::from_millis(800))
            {
                for id in openai_model_ids(&list) {
                    set.insert(id.to_lowercase());
                }
                return (true, set);
            }
        }

        (check_mlx_python(), set)
    }
}

/// Cache whether mlx_lm Python package is importable.
static MLX_PYTHON_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();

fn check_mlx_python() -> bool {
    *MLX_PYTHON_AVAILABLE.get_or_init(|| {
        std::process::Command::new("python3")
            .args(["-c", "import mlx_lm"])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
    })
}

fn is_likely_mlx_repo(owner: &str, repo: &str) -> bool {
    let owner_lower = owner.to_lowercase();
    let repo_lower = repo.to_lowercase();
    // Exclude GGUF repos — they belong to llama.cpp, not MLX
    if is_likely_gguf_repo(&repo_lower) {
        return false;
    }
    owner_lower == "mlx-community"
        || repo_lower.contains("-mlx-")
        || repo_lower.ends_with("-mlx")
        || repo_lower.contains("mlx-")
        || repo_lower.ends_with("mlx")
}

fn is_likely_gguf_repo(repo_lower: &str) -> bool {
    repo_lower.contains("-gguf") || repo_lower.ends_with("gguf")
}

/// Detect repos whose name marks a pre-quantized non-MLX format (AWQ, GPTQ,
/// AutoRound). These are vLLM/CUDA formats: guessing an `mlx-community`
/// equivalent for them almost always fabricates a repo that does not exist
/// (issue #294). A name that also carries an MLX marker (e.g. an
/// mlx-community conversion that keeps "AWQ" in its name) is not excluded —
/// callers check MLX markers first.
pub fn is_likely_prequantized_repo(repo_lower: &str) -> bool {
    ["awq", "gptq", "autoround", "auto-round"]
        .iter()
        .any(|marker| repo_lower.contains(marker))
}

/// Scan HuggingFace cache directories for MLX model directories.
fn scan_hf_cache_for_mlx() -> HashSet<String> {
    let mut set = HashSet::new();
    for cache_dir in dirs_hf_cache_all() {
        let Ok(entries) = std::fs::read_dir(&cache_dir) else {
            continue;
        };
        for entry in entries.flatten() {
            let name = entry.file_name();
            let name_str = name.to_string_lossy();
            let Some(rest) = name_str.strip_prefix("models--") else {
                continue;
            };
            let mut parts = rest.splitn(2, "--");
            let Some(owner) = parts.next() else {
                continue;
            };
            let Some(repo) = parts.next() else {
                continue;
            };

            if !is_likely_mlx_repo(owner, repo) {
                continue;
            }

            let owner_lower = owner.to_lowercase();
            let repo_lower = repo.to_lowercase();
            set.insert(format!("{}/{}", owner_lower, repo_lower));
            set.insert(repo_lower);
        }
    }
    set
}

/// Scan HuggingFace cache directories for GGUF model directories.
fn scan_hf_cache_for_gguf() -> (HashSet<String>, usize) {
    let mut set = HashSet::new();
    let mut count = 0usize;
    for cache_dir in dirs_hf_cache_all() {
        let Ok(entries) = std::fs::read_dir(&cache_dir) else {
            continue;
        };
        for entry in entries.flatten() {
            let name = entry.file_name();
            let name_str = name.to_string_lossy();
            let Some(rest) = name_str.strip_prefix("models--") else {
                continue;
            };
            let mut parts = rest.splitn(2, "--");
            let Some(owner) = parts.next() else {
                continue;
            };
            let Some(repo) = parts.next() else {
                continue;
            };

            if !is_likely_gguf_repo(&repo.to_lowercase()) {
                continue;
            }

            count += 1;
            let owner_lower = owner.to_lowercase();
            let repo_lower = repo.to_lowercase();
            set.insert(format!("{}/{}", owner_lower, repo_lower));
            set.insert(repo_lower);
        }
    }
    (set, count)
}

/// Return all candidate HuggingFace cache directories.
///
/// The HF CLI always uses `~/.cache/huggingface/hub` (XDG-style) regardless
/// of platform, but `dirs::cache_dir()` returns `~/Library/Caches` on macOS.
/// We check both to handle either location.
fn dirs_hf_cache_all() -> Vec<std::path::PathBuf> {
    let mut dirs = Vec::new();

    if let Ok(cache) = std::env::var("HF_HOME") {
        dirs.push(std::path::PathBuf::from(cache).join("hub"));
        return dirs;
    }

    // Platform-native cache dir (e.g. ~/Library/Caches on macOS)
    if let Some(cache) = dirs::cache_dir() {
        dirs.push(cache.join("huggingface").join("hub"));
    }

    // XDG-style ~/.cache (what the HF CLI actually uses on all platforms)
    if let Some(home) = dirs::home_dir() {
        let xdg = home.join(".cache").join("huggingface").join("hub");
        if !dirs.iter().any(|d| d == &xdg) {
            dirs.push(xdg);
        }
    }

    if dirs.is_empty() {
        dirs.push(std::path::PathBuf::from("/tmp/.cache/huggingface/hub"));
    }
    dirs
}

impl ModelProvider for MlxProvider {
    fn name(&self) -> &str {
        "MLX"
    }

    fn is_available(&self) -> bool {
        if !cfg!(target_os = "macos") {
            return false;
        }
        // Try MLX-compatible servers first.
        for candidate in self.server_candidates() {
            if Self::fetch_candidate_models(&candidate, std::time::Duration::from_secs(2)).is_some()
            {
                return true;
            }
        }
        // Fall back to checking if mlx_lm is installed
        check_mlx_python()
    }

    fn installed_models(&self) -> HashSet<String> {
        let mut set = scan_hf_cache_for_mlx();
        if !cfg!(target_os = "macos") {
            return set;
        }
        // Also try querying MLX-compatible servers if running.
        for candidate in self.server_candidates() {
            if let Some(list) =
                Self::fetch_candidate_models(&candidate, std::time::Duration::from_secs(2))
            {
                for id in openai_model_ids(&list) {
                    set.insert(id.to_lowercase());
                }
                break;
            }
        }
        set
    }

    fn start_pull(&self, model_tag: &str) -> Result<PullHandle, String> {
        let repo_id = resolve_mlx_fallback_repo(model_tag, &hf_repo_exists)?;
        let repo_for_thread = repo_id.clone();
        let (tx, rx) = std::sync::mpsc::channel();

        // Resolve the hf binary path before spawning the thread so we can
        // give a clear "not found" error instead of a confusing OS error.
        let hf_bin = find_binary("hf").ok_or_else(|| {
            "hf not found in PATH. Install it with: uv tool install 'huggingface_hub[cli]'"
                .to_string()
        })?;

        std::thread::spawn(move || {
            let _ = tx.send(PullEvent::Progress {
                status: format!("Downloading {}...", repo_for_thread),
                percent: None,
            });

            // Download from Hugging Face using their CLI tool.
            // `--` terminates option parsing so a repo id beginning with `-`
            // (reachable via the unauthenticated localhost /api/v1/download
            // endpoint) cannot be misinterpreted as a flag like --local-dir.
            let result = std::process::Command::new(&hf_bin)
                .args(["download", "--", &repo_for_thread])
                .stdout(std::process::Stdio::piped())
                .stderr(std::process::Stdio::piped())
                .output();

            match result {
                Ok(output) if output.status.success() => {
                    let _ = tx.send(PullEvent::Done);
                }
                Ok(output) => {
                    let stderr = String::from_utf8_lossy(&output.stderr);
                    let _ = tx.send(PullEvent::Error(format!(
                        "hf download failed (exit {}): {}",
                        output.status.code().unwrap_or(-1),
                        stderr.trim()
                    )));
                }
                Err(e) => {
                    let _ = tx.send(PullEvent::Error(format!("failed to run hf: {e}")));
                }
            }
        });

        Ok(PullHandle {
            model_tag: repo_id,
            receiver: rx,
        })
    }
}

// ---------------------------------------------------------------------------
// llama.cpp provider (direct GGUF download from HuggingFace)
// ---------------------------------------------------------------------------

/// A provider that downloads GGUF model files directly from HuggingFace
/// and uses llama.cpp binaries (`llama-cli`, `llama-server`) to run them.
///
/// Unlike Ollama, this doesn't require a running daemon — it downloads
/// GGUF files to a local cache directory and invokes llama.cpp directly.
pub struct LlamaCppProvider {
    /// Directory where GGUF models are stored.
    models_dir: PathBuf,
    /// Path to llama-cli binary, if found.
    llama_cli: Option<String>,
    /// Path to llama-server binary, if found.
    llama_server: Option<String>,
    /// Whether a running llama-server was detected via health probe.
    server_running: bool,
}

impl Default for LlamaCppProvider {
    fn default() -> Self {
        let models_dir = llamacpp_models_dir();
        let llama_cli = find_binary("llama-cli");
        let llama_server = find_binary("llama-server");

        // If no binaries found, check if a server is already running
        let server_running = if llama_cli.is_none() && llama_server.is_none() {
            let port = std::env::var("LLAMA_SERVER_PORT").unwrap_or_else(|_| "8080".to_string());
            probe_llama_server(&format!("http://localhost:{}", port))
        } else {
            false
        };

        Self {
            models_dir,
            llama_cli,
            llama_server,
            server_running,
        }
    }
}

impl LlamaCppProvider {
    pub fn new() -> Self {
        Self::default()
    }

    /// Like `installed_models`, but also returns the true GGUF file count.
    /// The HashSet may have fewer entries than 2*count due to deduplication
    /// when stripping quantization suffixes, so `len() / 2` is unreliable.
    pub fn installed_models_counted(&self) -> (HashSet<String>, usize) {
        let mut set = HashSet::new();
        let mut count = 0usize;
        for path in self.list_gguf_files() {
            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
                count += 1;
                let lower = stem.to_lowercase();
                set.insert(lower.clone());
                if let Some(base) = strip_gguf_quant_suffix(&lower) {
                    set.insert(base);
                }
            }
        }
        // Also scan the HuggingFace cache for GGUF repos downloaded via `hf download`
        let (hf_set, hf_count) = scan_hf_cache_for_gguf();
        count += hf_count;
        set.extend(hf_set);
        (set, count)
    }

    /// Return the directory where GGUF models are cached.
    pub fn models_dir(&self) -> &std::path::Path {
        &self.models_dir
    }

    /// Override the models directory at runtime.
    pub fn set_models_dir(&mut self, dir: PathBuf) {
        self.models_dir = dir;
    }

    /// Delete a GGUF model file by tag (file stem match).
    pub fn delete_model(&self, model_tag: &str) -> Result<(), String> {
        let tag_lower = model_tag.to_lowercase();
        for path in self.list_gguf_files() {
            if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
                && stem.to_lowercase() == tag_lower
            {
                return std::fs::remove_file(&path)
                    .map_err(|e| format!("Failed to delete {}: {}", path.display(), e));
            }
        }
        Err(format!("Model file not found for '{}'", model_tag))
    }

    /// Path to `llama-cli` if detected.
    pub fn llama_cli_path(&self) -> Option<&str> {
        self.llama_cli.as_deref()
    }

    /// Path to `llama-server` if detected.
    pub fn llama_server_path(&self) -> Option<&str> {
        self.llama_server.as_deref()
    }

    /// Whether a running llama-server was detected via health probe.
    pub fn server_running(&self) -> bool {
        self.server_running
    }

    /// Return a short status hint describing how llama.cpp was (or wasn't) detected.
    pub fn detection_hint(&self) -> &'static str {
        if self.llama_cli.is_some() || self.llama_server.is_some() {
            ""
        } else if self.server_running {
            "server detected"
        } else {
            "not in PATH, set LLAMA_CPP_PATH"
        }
    }

    /// List all `.gguf` files in the cache directory.
    pub fn list_gguf_files(&self) -> Vec<PathBuf> {
        let mut files = Vec::new();
        if let Ok(entries) = std::fs::read_dir(&self.models_dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.extension().and_then(|e| e.to_str()) == Some("gguf") {
                    files.push(path);
                }
            }
        }
        files
    }

    /// Search HuggingFace for GGUF repositories matching a query.
    /// Returns a list of (repo_id, description) tuples.
    pub fn search_hf_gguf(query: &str) -> Vec<(String, String)> {
        let url = format!(
            "https://huggingface.co/api/models?library=gguf&search={}&sort=trending&limit=20",
            urlencoding::encode(query)
        );
        let Ok(resp) = ureq::get(&url)
            .config()
            .timeout_global(Some(std::time::Duration::from_secs(15)))
            .build()
            .call()
        else {
            return Vec::new();
        };
        let Ok(models) = resp.into_body().read_json::<Vec<serde_json::Value>>() else {
            return Vec::new();
        };
        models
            .into_iter()
            .filter_map(|m| {
                let id = m.get("id")?.as_str()?.to_string();
                let desc = m
                    .get("pipeline_tag")
                    .and_then(|v| v.as_str())
                    .unwrap_or("model")
                    .to_string();
                Some((id, desc))
            })
            .collect()
    }

    /// List GGUF files available in a HuggingFace repository.
    /// Returns a list of (filename, size_bytes) tuples.
    pub fn list_repo_gguf_files(repo_id: &str) -> Vec<(String, u64)> {
        let url = format!(
            "https://huggingface.co/api/models/{}/tree/main?recursive=true",
            repo_id
        );
        let Ok(resp) = ureq::get(&url)
            .config()
            .timeout_global(Some(std::time::Duration::from_secs(15)))
            .build()
            .call()
        else {
            return Vec::new();
        };
        let Ok(entries) = resp.into_body().read_json::<Vec<serde_json::Value>>() else {
            return Vec::new();
        };
        parse_repo_gguf_entries(entries)
    }

    /// Select the best GGUF file from a repo that fits within a memory budget.
    /// Prefers higher quality quantizations (Q8 > Q6 > Q5 > Q4 > Q3 > Q2).
    /// `budget_gb` is the available memory in gigabytes.
    ///
    /// Sharded models (e.g. `model-00001-of-00003.gguf`) are treated as a
    /// single candidate: the returned path is the first shard and the
    /// returned size is the sum of all shards in the set. The download path
    /// expands the first shard back into the full set.
    pub fn select_best_gguf(files: &[(String, u64)], budget_gb: f64) -> Option<(String, u64)> {
        // Quant preference order (best quality first)
        let quant_order = [
            "Q8_0", "q8_0", "Q6_K", "q6_k", "Q6_K_L", "q6_k_l", "Q5_K_M", "q5_k_m", "Q5_K_S",
            "q5_k_s", "Q4_K_M", "q4_k_m", "Q4_K_S", "q4_k_s", "Q4_0", "q4_0", "Q3_K_M", "q3_k_m",
            "Q3_K_S", "q3_k_s", "Q2_K", "q2_k", "IQ4_XS", "iq4_xs", "IQ3_M", "iq3_m", "IQ2_M",
            "iq2_m", "IQ1_M", "iq1_m",
        ];
        let budget_bytes = (budget_gb * 1024.0 * 1024.0 * 1024.0) as u64;
        let candidates = build_gguf_candidates(files);

        // Try each quant level in preference order
        for quant in &quant_order {
            for (filename, size) in &candidates {
                if *size > 0 && *size <= budget_bytes && filename.contains(quant) {
                    return Some((filename.clone(), *size));
                }
            }
        }

        // Fallback: largest candidate that still fits
        let mut fitting: Vec<_> = candidates
            .iter()
            .filter(|(_, s)| *s > 0 && *s <= budget_bytes)
            .collect();
        fitting.sort_by_key(|(_, s)| *s);
        fitting.last().map(|(f, s)| (f.clone(), *s))
    }

    /// Download a GGUF file from a HuggingFace repository.
    /// `repo_id` is e.g. "bartowski/Llama-3.1-8B-Instruct-GGUF"
    /// `filename` is e.g. "Llama-3.1-8B-Instruct-Q4_K_M.gguf"
    ///
    /// If `filename` is one shard of a multi-part model
    /// (e.g. `...-00001-of-00003.gguf`), all sibling shards are fetched from
    /// the repo tree and downloaded sequentially.
    pub fn download_gguf(&self, repo_id: &str, filename: &str) -> Result<PullHandle, String> {
        // Validate the repo path (may include subdirectories like "Q4_K_M/model.gguf")
        validate_gguf_repo_path(filename)?;

        // If this looks like a shard, expand to the full set by listing the
        // repo tree. Fall through to a single-file download otherwise (or if
        // expansion fails, e.g. the listing is empty).
        let paths: Vec<String> = if parse_shard_info(filename).is_some() {
            let listing = Self::list_repo_gguf_files(repo_id);
            match collect_shard_set(&listing, filename) {
                Some(shards) if !shards.is_empty() => shards.into_iter().map(|(f, _)| f).collect(),
                _ => vec![filename.to_string()],
            }
        } else {
            vec![filename.to_string()]
        };

        self.download_gguf_paths(repo_id, paths)
    }

    /// Download one or more GGUF files from the same HuggingFace repository
    /// into the local cache. Used by `download_gguf` to handle shard sets.
    fn download_gguf_paths(&self, repo_id: &str, paths: Vec<String>) -> Result<PullHandle, String> {
        if paths.is_empty() {
            return Err("download_gguf_paths called with no paths".to_string());
        }

        let models_dir = self.models_dir.clone();

        // Validate every path and pre-compute (url, dest_path) pairs.
        let mut jobs: Vec<(String, PathBuf)> = Vec::with_capacity(paths.len());
        for path in &paths {
            validate_gguf_repo_path(path)?;
            let local_filename = std::path::Path::new(path)
                .file_name()
                .and_then(|n| n.to_str())
                .ok_or_else(|| format!("Invalid filename in path: {}", path))?;
            validate_gguf_filename(local_filename)?;
            let dest_path = models_dir.join(local_filename);

            // Final safety check: ensure resolved path stays within models_dir
            if let (Ok(canonical_dir), Ok(canonical_dest)) = (
                std::fs::create_dir_all(&models_dir).and_then(|_| models_dir.canonicalize()),
                dest_path
                    .parent()
                    .ok_or_else(|| std::io::Error::other("no parent"))
                    .and_then(|p| {
                        std::fs::create_dir_all(p)?;
                        p.canonicalize()
                    }),
            ) && !canonical_dest.starts_with(&canonical_dir)
            {
                return Err(format!(
                    "Security: download path escapes cache directory: {}",
                    dest_path.display()
                ));
            }

            let url = format!("https://huggingface.co/{}/resolve/main/{}", repo_id, path);
            jobs.push((url, dest_path));
        }

        let tag = format!("{}/{}", repo_id, paths[0]);
        let total_parts = jobs.len();
        let (tx, rx) = std::sync::mpsc::channel();

        std::thread::spawn(move || {
            for (idx, (url, dest_path)) in jobs.into_iter().enumerate() {
                let part_num = idx + 1;
                let part_label = if total_parts > 1 {
                    format!("[{}/{}] ", part_num, total_parts)
                } else {
                    String::new()
                };
                let display_name = dest_path
                    .file_name()
                    .and_then(|n| n.to_str())
                    .unwrap_or("")
                    .to_string();

                let _ = tx.send(PullEvent::Progress {
                    status: format!("{}Connecting to {}...", part_label, display_name),
                    percent: Some(0.0),
                });

                let resp = ureq::get(&url)
                    .config()
                    .timeout_global(Some(std::time::Duration::from_secs(7200)))
                    .build()
                    .call();

                let resp = match resp {
                    Ok(r) => r,
                    Err(e) => {
                        let _ = tx.send(PullEvent::Error(format!(
                            "{}Download failed: {}",
                            part_label, e
                        )));
                        return;
                    }
                };

                let total_size = resp
                    .headers()
                    .get("content-length")
                    .and_then(|v| v.to_str().ok())
                    .and_then(|s| s.parse::<u64>().ok())
                    .unwrap_or(0);

                let _ = tx.send(PullEvent::Progress {
                    status: format!(
                        "{}Downloading {} ({:.1} GB)...",
                        part_label,
                        display_name,
                        total_size as f64 / 1_073_741_824.0
                    ),
                    percent: Some(0.0),
                });

                // Write to a temp file, then rename to avoid partial files.
                // Remove any pre-existing entry and open with create_new
                // (O_EXCL) so a planted symlink at tmp_path cannot redirect
                // the write outside models_dir.
                let tmp_path = dest_path.with_extension("gguf.part");
                let _ = std::fs::remove_file(&tmp_path);
                let file = match std::fs::OpenOptions::new()
                    .write(true)
                    .create_new(true)
                    .open(&tmp_path)
                {
                    Ok(f) => f,
                    Err(e) => {
                        let _ = tx.send(PullEvent::Error(format!("Failed to create file: {}", e)));
                        return;
                    }
                };

                let mut writer = std::io::BufWriter::new(file);
                let mut reader = resp.into_body().into_reader();
                let mut downloaded: u64 = 0;
                let mut buf = [0u8; 128 * 1024]; // 128 KB buffer
                let mut last_report = std::time::Instant::now();

                loop {
                    match std::io::Read::read(&mut reader, &mut buf) {
                        Ok(0) => break, // EOF
                        Ok(n) => {
                            if let Err(e) = std::io::Write::write_all(&mut writer, &buf[..n]) {
                                let _ = tx.send(PullEvent::Error(format!("Write error: {}", e)));
                                let _ = std::fs::remove_file(&tmp_path);
                                return;
                            }
                            downloaded += n as u64;

                            if last_report.elapsed() >= std::time::Duration::from_millis(200) {
                                // Per-part percent (kept simple; aggregate progress
                                // across shards is shown via the [i/N] label).
                                let pct = if total_size > 0 {
                                    downloaded as f64 / total_size as f64 * 100.0
                                } else {
                                    0.0
                                };
                                let dl_gb = downloaded as f64 / 1_073_741_824.0;
                                let total_gb = total_size as f64 / 1_073_741_824.0;
                                let _ = tx.send(PullEvent::Progress {
                                    status: format!(
                                        "{}Downloading {:.1}/{:.1} GB",
                                        part_label, dl_gb, total_gb
                                    ),
                                    percent: Some(pct),
                                });
                                last_report = std::time::Instant::now();
                            }
                        }
                        Err(e) => {
                            let _ = tx.send(PullEvent::Error(format!("Download error: {}", e)));
                            let _ = std::fs::remove_file(&tmp_path);
                            return;
                        }
                    }
                }

                if let Err(e) = std::io::Write::flush(&mut writer) {
                    let _ = tx.send(PullEvent::Error(format!("Flush error: {}", e)));
                    let _ = std::fs::remove_file(&tmp_path);
                    return;
                }
                drop(writer);

                // Sanity check: refuse to keep an obviously bogus tiny file
                // when content-length advertised something larger. This
                // catches truncated transfers and HTML error responses.
                if total_size > 0 && downloaded < total_size {
                    let _ = std::fs::remove_file(&tmp_path);
                    let _ = tx.send(PullEvent::Error(format!(
                        "{}Truncated download: got {} bytes, expected {}",
                        part_label, downloaded, total_size
                    )));
                    return;
                }

                if let Err(e) = std::fs::rename(&tmp_path, &dest_path) {
                    let _ = tx.send(PullEvent::Error(format!(
                        "Failed to finalize download: {}",
                        e
                    )));
                    let _ = std::fs::remove_file(&tmp_path);
                    return;
                }

                let _ = tx.send(PullEvent::Progress {
                    status: format!("{}Saved {}", part_label, display_name),
                    percent: Some(100.0),
                });
            }

            let _ = tx.send(PullEvent::Progress {
                status: "Download complete!".to_string(),
                percent: Some(100.0),
            });
            let _ = tx.send(PullEvent::Done);
        });

        Ok(PullHandle {
            model_tag: tag,
            receiver: rx,
        })
    }
}

/// Validate a GGUF filename used for local cache writes.
fn validate_gguf_filename(filename: &str) -> Result<(), String> {
    if filename.is_empty() {
        return Err("GGUF filename must not be empty".to_string());
    }

    if filename.contains('/') || filename.contains('\\') {
        return Err(format!(
            "Security: path separators not allowed in GGUF filename: {}",
            filename
        ));
    }

    let path = std::path::Path::new(filename);

    if path.is_absolute() {
        return Err(format!(
            "Security: absolute paths not allowed in GGUF filename: {}",
            filename
        ));
    }

    if !filename.ends_with(".gguf") {
        return Err(format!(
            "GGUF filename must end in .gguf, got: {}",
            filename
        ));
    }

    if path.file_name().and_then(|n| n.to_str()) != Some(filename) {
        return Err(format!(
            "Security: GGUF filename must be a basename without path components: {}",
            filename
        ));
    }

    Ok(())
}

/// If `filename` ends with `-NNNNN-of-MMMMM.gguf`, return `(index, total)`.
/// Both numbers must be ASCII digits, `index >= 1`, and `index <= total`.
fn parse_shard_info(filename: &str) -> Option<(u32, u32)> {
    let stem = filename.strip_suffix(".gguf")?;
    let of_pos = stem.rfind("-of-")?;
    let total_str = &stem[of_pos + 4..];
    if total_str.is_empty() || !total_str.chars().all(|c| c.is_ascii_digit()) {
        return None;
    }
    let total: u32 = total_str.parse().ok()?;
    let before = &stem[..of_pos];
    let dash_pos = before.rfind('-')?;
    let index_str = &before[dash_pos + 1..];
    if index_str.is_empty() || !index_str.chars().all(|c| c.is_ascii_digit()) {
        return None;
    }
    let index: u32 = index_str.parse().ok()?;
    if index == 0 || index > total {
        return None;
    }
    Some((index, total))
}

/// Given a shard path and a listing of repo files, return all sibling shards
/// in the same set, sorted by index. Returns `None` if `path` isn't a shard.
/// The returned vec is empty only if no matching siblings exist (which
/// shouldn't normally happen since the path itself is a shard).
pub fn collect_shard_set(files: &[(String, u64)], path: &str) -> Option<Vec<(String, u64)>> {
    let (_, total) = parse_shard_info(path)?;
    let stem = path.strip_suffix(".gguf")?;
    let of_pos = stem.rfind("-of-")?;
    let before = &stem[..of_pos];
    let dash_pos = before.rfind('-')?;
    // `prefix` includes the trailing '-' that separates the shard index.
    let prefix = &path[..=dash_pos];
    // `suffix` is the "-of-MMMMM.gguf" tail (positions in `path` and `stem`
    // align since `stem` is just `path` minus the trailing ".gguf").
    let suffix = &path[of_pos..];

    let mut matches: Vec<(u32, String, u64)> = files
        .iter()
        .filter_map(|(f, s)| {
            if !f.starts_with(prefix) || !f.ends_with(suffix) {
                return None;
            }
            let (idx, t) = parse_shard_info(f)?;
            if t != total {
                return None;
            }
            Some((idx, f.clone(), *s))
        })
        .collect();
    matches.sort_by_key(|(i, _, _)| *i);
    if matches.is_empty() {
        return None;
    }
    Some(matches.into_iter().map(|(_, f, s)| (f, s)).collect())
}

/// Convert a flat repo file listing into selection candidates. Each shard
/// group is collapsed to a single entry whose path is the first shard and
/// whose size is the sum of all shards. Non-shard files are passed through
/// unchanged. Order is preserved relative to the first occurrence.
fn build_gguf_candidates(files: &[(String, u64)]) -> Vec<(String, u64)> {
    let mut seen_groups: HashSet<String> = HashSet::new();
    let mut out: Vec<(String, u64)> = Vec::new();
    for (f, s) in files {
        if parse_shard_info(f).is_some() {
            // Build a stable group key from prefix + suffix.
            let Some(stem) = f.strip_suffix(".gguf") else {
                continue;
            };
            let Some(of_pos) = stem.rfind("-of-") else {
                continue;
            };
            let before = &stem[..of_pos];
            let Some(dash_pos) = before.rfind('-') else {
                continue;
            };
            let key = format!("{}|{}", &f[..=dash_pos], &f[of_pos..]);
            if !seen_groups.insert(key) {
                continue;
            }
            if let Some(shards) = collect_shard_set(files, f) {
                let total: u64 = shards.iter().map(|(_, sz)| *sz).sum();
                let rep = shards[0].0.clone();
                out.push((rep, total));
            }
        } else {
            out.push((f.clone(), *s));
        }
    }
    out
}

/// Validate a GGUF path returned from the HuggingFace API.
/// Unlike `validate_gguf_filename`, this allows subdirectory paths (e.g.
/// `Q4_K_M/model.gguf`) but still rejects path traversal and non-GGUF files.
fn validate_gguf_repo_path(path: &str) -> Result<(), String> {
    if path.is_empty() {
        return Err("GGUF path must not be empty".to_string());
    }

    // Reject path-traversal components
    for component in path.split('/') {
        if component == ".." || component == "." {
            return Err(format!(
                "Security: path traversal not allowed in GGUF path: {}",
                path
            ));
        }
    }

    // Reject backslashes (Windows-style paths)
    if path.contains('\\') {
        return Err(format!(
            "Security: backslash not allowed in GGUF path: {}",
            path
        ));
    }

    // Reject absolute paths
    if path.starts_with('/') {
        return Err(format!(
            "Security: absolute paths not allowed in GGUF path: {}",
            path
        ));
    }

    if !path.ends_with(".gguf") {
        return Err(format!("GGUF path must end in .gguf, got: {}", path));
    }

    Ok(())
}

fn parse_repo_gguf_entries(entries: Vec<serde_json::Value>) -> Vec<(String, u64)> {
    entries
        .into_iter()
        .filter_map(|e| {
            let path = e.get("path")?.as_str()?.to_string();
            if validate_gguf_repo_path(&path).is_err() {
                return None;
            }
            let size = e.get("size").and_then(|v| v.as_u64()).unwrap_or(0);
            // Skip split files (e.g., model-00001-of-00003.gguf) but not the
            // primary file. We look for files that look like quantized models.
            Some((path, size))
        })
        .collect()
}

/// Default directory for llama.cpp GGUF model cache.
pub fn llamacpp_models_dir() -> PathBuf {
    if let Ok(dir) = std::env::var("LLMFIT_MODELS_DIR") {
        PathBuf::from(dir)
    } else if let Some(cache) = dirs::cache_dir() {
        cache.join("llmfit").join("models")
    } else {
        PathBuf::from(".llmfit").join("models")
    }
}

/// Check whether a binary is available on the system PATH.
/// Cross-platform: uses the `which` crate rather than shelling out to a
/// Unix-only `which` command, so it works on Windows too.
pub fn command_exists(name: &str) -> bool {
    which::which(name).is_ok()
}

/// Find a binary by checking `LLAMA_CPP_PATH` env var, common install
/// locations, and finally the system PATH via `which`.
fn find_binary(name: &str) -> Option<String> {
    // 1. Check LLAMA_CPP_PATH env var first
    if let Ok(dir) = std::env::var("LLAMA_CPP_PATH") {
        let candidate = PathBuf::from(&dir).join(name);
        if candidate.is_file() {
            return Some(candidate.to_string_lossy().to_string());
        }
    }

    // 2. Check common install locations
    let mut common_dirs: Vec<PathBuf> = vec![
        PathBuf::from("/usr/local/bin"),
        PathBuf::from("/opt/llama.cpp/build/bin"),
    ];
    if let Some(home) = dirs::home_dir() {
        common_dirs.push(home.join(".local").join("bin"));
    }
    for dir in common_dirs {
        let candidate = dir.join(name);
        if candidate.is_file() {
            return Some(candidate.to_string_lossy().to_string());
        }
    }

    // 3. Fall back to PATH lookup
    which::which(name)
        .ok()
        .map(|p| p.to_string_lossy().to_string())
}

/// Check if a llama-server is reachable at the given URL by probing its
/// health endpoint. Returns `true` if the server responds.
fn probe_llama_server(base_url: &str) -> bool {
    let url = format!("{}/health", base_url.trim_end_matches('/'));
    std::process::Command::new("curl")
        .args(["-sf", "--max-time", "2", &url])
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

/// Simple percent-encoding for URL query parameters.
mod urlencoding {
    pub fn encode(s: &str) -> String {
        let mut result = String::with_capacity(s.len() * 3);
        for byte in s.bytes() {
            match byte {
                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                    result.push(byte as char);
                }
                _ => {
                    result.push('%');
                    result.push_str(&format!("{:02X}", byte));
                }
            }
        }
        result
    }
}

impl ModelProvider for LlamaCppProvider {
    fn name(&self) -> &str {
        "llama.cpp"
    }

    fn is_available(&self) -> bool {
        self.llama_cli.is_some() || self.llama_server.is_some() || self.server_running
    }

    fn installed_models(&self) -> HashSet<String> {
        let (set, _) = self.installed_models_counted();
        set
    }

    fn start_pull(&self, model_tag: &str) -> Result<PullHandle, String> {
        // model_tag can be:
        // 1. A HuggingFace repo ID like "bartowski/Llama-3.1-8B-Instruct-GGUF"
        // 2. A repo_id/filename like "bartowski/Llama-3.1-8B-Instruct-GGUF/Q4_K_M.gguf"
        // 3. A short search term like "llama-3.1-8b"

        // If it contains a slash and ends with .gguf, treat as repo/file
        if model_tag.matches('/').count() >= 2 && model_tag.ends_with(".gguf") {
            let parts: Vec<&str> = model_tag.splitn(3, '/').collect();
            if parts.len() == 3 {
                let repo = format!("{}/{}", parts[0], parts[1]);
                let filename = parts[2];
                return self.download_gguf(&repo, filename);
            }
        }

        // If it looks like a repo (org/name), list files and pick the best
        if model_tag.contains('/') {
            let files = Self::list_repo_gguf_files(model_tag);
            if files.is_empty() {
                return Err(format!("No GGUF files found in repository '{}'", model_tag));
            }
            // Pick a reasonable default (Q4_K_M or similar)
            if let Some((filename, _)) = Self::select_best_gguf(&files, 999.0) {
                return self.download_gguf(model_tag, &filename);
            }
            // Fallback: just pick the first
            let (filename, _) = &files[0];
            return self.download_gguf(model_tag, filename);
        }

        // Otherwise, search HuggingFace for GGUF repos
        let results = Self::search_hf_gguf(model_tag);
        if results.is_empty() {
            return Err(format!(
                "No GGUF models found on HuggingFace for '{}'",
                model_tag
            ));
        }
        // Use the first result
        let (repo_id, _) = &results[0];
        let files = Self::list_repo_gguf_files(repo_id);
        if files.is_empty() {
            return Err(format!("No GGUF files found in repository '{}'", repo_id));
        }
        if let Some((filename, _)) = Self::select_best_gguf(&files, 999.0) {
            return self.download_gguf(repo_id, &filename);
        }
        let (filename, _) = &files[0];
        self.download_gguf(repo_id, filename)
    }
}

// ---------------------------------------------------------------------------
// Docker Model Runner provider
// ---------------------------------------------------------------------------

/// Docker Model Runner — Docker Desktop's built-in model serving feature.
///
/// Exposes an OpenAI-compatible API at `http://localhost:12434` by default.
/// Models are listed via `GET /engines` and pulled via `docker model pull`.
pub struct DockerModelRunnerProvider {
    base_url: String,
}

/// Check if Docker Desktop is running on Linux by looking for its socket or process.
/// Returns `true` if Docker Desktop appears to be active, `false` otherwise.
/// This avoids a slow HTTP timeout on Linux systems without Docker Desktop.
fn is_docker_desktop_running() -> bool {
    // Docker Desktop on Linux creates a specific socket path
    if std::path::Path::new("/run/docker-desktop/docker.sock").exists()
        || std::path::Path::new(
            &std::env::var("HOME")
                .map(|h| format!("{h}/.docker/desktop/docker.sock"))
                .unwrap_or_default(),
        )
        .exists()
    {
        return true;
    }
    // Fall back to checking if the DOCKER_MODEL_RUNNER_HOST env var is explicitly set
    // to a non-empty value (an empty string means the user hasn't configured it).
    std::env::var("DOCKER_MODEL_RUNNER_HOST")
        .map(|v| !v.trim().is_empty())
        .unwrap_or(false)
}

/// Check whether the Docker Desktop application is installed, regardless of
/// whether it is currently running (#731). The Model Runner API probe only
/// succeeds while Docker Desktop is up, so this is what lets the UI say
/// "installed (not running)" instead of "not detected".
pub fn docker_desktop_installed() -> bool {
    docker_desktop_install_candidates(
        std::env::var("ProgramFiles").ok().as_deref(),
        dirs::home_dir().as_deref(),
    )
    .iter()
    .any(|p| p.exists())
}

/// Filesystem locations that identify a Docker Desktop (or docker-model
/// plugin) install. Pure so tests can cover the per-OS layouts; `exists()`
/// checks happen in [`docker_desktop_installed`].
fn docker_desktop_install_candidates(
    program_files: Option<&str>,
    home: Option<&Path>,
) -> Vec<PathBuf> {
    let mut candidates = Vec::new();
    if let Some(pf) = program_files {
        let docker = Path::new(pf).join("Docker").join("Docker");
        // Classic layout, and the frontend/ layout used by newer releases
        // (e.g. C:\Program Files\Docker\Docker\frontend\Docker Desktop.exe).
        candidates.push(docker.join("Docker Desktop.exe"));
        candidates.push(docker.join("frontend").join("Docker Desktop.exe"));
        candidates.push(Path::new(pf).join("Docker").join("cli-plugins"));
    }
    candidates.push(PathBuf::from("/Applications/Docker.app"));
    candidates.push(PathBuf::from("/opt/docker-desktop"));
    if let Some(home) = home {
        candidates.push(home.join("Applications").join("Docker.app"));
        candidates.push(home.join(".docker").join("desktop"));
        // Standalone Model Runner plugin (docker-model), installable
        // without Docker Desktop on Docker CE.
        let plugins = home.join(".docker").join("cli-plugins");
        candidates.push(plugins.join("docker-model"));
        candidates.push(plugins.join("docker-model.exe"));
    }
    candidates
}

fn normalize_docker_mr_host(raw: &str) -> Option<String> {
    let host = raw.trim();
    if host.is_empty() {
        return None;
    }

    if host.starts_with("http://") || host.starts_with("https://") {
        return Some(host.to_string());
    }

    if host.contains("://") {
        return None;
    }

    Some(format!("http://{host}"))
}

impl Default for DockerModelRunnerProvider {
    fn default() -> Self {
        let base_url = std::env::var("DOCKER_MODEL_RUNNER_HOST")
            .ok()
            .and_then(|raw| {
                let normalized = normalize_docker_mr_host(&raw);
                if normalized.is_none() {
                    eprintln!(
                        "Warning: could not parse DOCKER_MODEL_RUNNER_HOST='{}'. \
                         Expected host:port or http(s)://host:port",
                        raw
                    );
                }
                normalized
            })
            .unwrap_or_else(|| "http://localhost:12434".to_string());
        Self { base_url }
    }
}

impl DockerModelRunnerProvider {
    pub fn new() -> Self {
        Self::default()
    }

    fn models_url(&self) -> String {
        format!("{}/v1/models", self.base_url.trim_end_matches('/'))
    }

    /// Single-pass startup probe.
    /// Returns `(available, installed_models, count)`.
    pub fn detect_with_installed(&self) -> (bool, HashSet<String>, usize) {
        // Docker Model Runner is a Docker Desktop feature. On Linux, Docker Desktop
        // is uncommon. Skip the HTTP probe if Docker Desktop is not running to avoid
        // a ~800ms timeout on every startup.
        if cfg!(target_os = "linux") && !is_docker_desktop_running() {
            return (false, HashSet::new(), 0);
        }

        let mut set = HashSet::new();
        let Ok(resp) = ureq::get(&self.models_url())
            .config()
            .timeout_global(Some(std::time::Duration::from_millis(800)))
            .build()
            .call()
        else {
            return (false, set, 0);
        };

        let Ok(list) = resp.into_body().read_json::<DockerModelList>() else {
            return (true, set, 0);
        };
        let engines = list.data;
        let count = engines.len();
        for e in engines {
            let lower = e.id.to_lowercase();
            set.insert(lower.clone());
            // Also insert the model part after the namespace (e.g. "ai/llama3.1" → "llama3.1")
            if let Some(name) = lower.split('/').next_back()
                && name != lower
            {
                set.insert(name.to_string());
            }
            // Strip quantization tag if present (e.g. "llama3.1:8B-Q4_K_M" → "llama3.1:8b")
            if let Some(base) = lower.split(':').next() {
                set.insert(base.to_string());
            }
        }
        (true, set, count)
    }

    pub fn installed_models_counted(&self) -> (HashSet<String>, usize) {
        let (_, set, count) = self.detect_with_installed();
        (set, count)
    }
}

#[derive(serde::Deserialize)]
struct DockerModelList {
    data: Vec<DockerEngine>,
}

#[derive(serde::Deserialize)]
struct DockerEngine {
    /// Model ID, e.g. "ai/llama3.1:8B-Q4_K_M"
    id: String,
}

impl ModelProvider for DockerModelRunnerProvider {
    fn name(&self) -> &str {
        "Docker Model Runner"
    }

    fn is_available(&self) -> bool {
        ureq::get(&self.models_url())
            .config()
            .timeout_global(Some(std::time::Duration::from_secs(2)))
            .build()
            .call()
            .is_ok()
    }

    fn installed_models(&self) -> HashSet<String> {
        let (set, _) = self.installed_models_counted();
        set
    }

    fn start_pull(&self, model_tag: &str) -> Result<PullHandle, String> {
        let tag = model_tag.to_string();
        let (tx, rx) = std::sync::mpsc::channel();

        std::thread::spawn(move || {
            let _ = tx.send(PullEvent::Progress {
                status: format!("Pulling {} via docker model pull...", tag),
                percent: None,
            });

            // `--` terminates option parsing so a tag beginning with `-`
            // cannot inject docker CLI flags.
            let result = std::process::Command::new("docker")
                .args(["model", "pull", "--", &tag])
                .stdout(std::process::Stdio::piped())
                .stderr(std::process::Stdio::piped())
                .output();

            match result {
                Ok(output) if output.status.success() => {
                    let _ = tx.send(PullEvent::Done);
                }
                Ok(output) => {
                    let stderr = String::from_utf8_lossy(&output.stderr);
                    let _ = tx.send(PullEvent::Error(format!(
                        "docker model pull failed: {}",
                        stderr.trim()
                    )));
                }
                Err(e) => {
                    let _ = tx.send(PullEvent::Error(format!("Failed to run docker: {e}")));
                }
            }
        });

        Ok(PullHandle {
            model_tag: model_tag.to_string(),
            receiver: rx,
        })
    }
}

// ---------------------------------------------------------------------------
// LM Studio provider
// ---------------------------------------------------------------------------

/// LM Studio — local model server with REST API for model management.
///
/// Exposes an OpenAI-compatible API plus management endpoints at
/// `http://127.0.0.1:1234` by default. Models are downloaded via
/// `POST /api/v1/models/download` and listed via `GET /v1/models`.
pub struct LmStudioProvider {
    base_url: String,
    api_key: Option<String>,
}

/// Check whether the LM Studio application is installed, regardless of
/// whether its local server is running (#731). LM Studio's REST API is off
/// until the user starts the server (or `lms server start`), so the HTTP
/// probe alone reports installed-but-idle copies as missing.
pub fn lmstudio_app_installed() -> bool {
    if command_exists("lms") {
        return true;
    }
    lmstudio_install_candidates(
        std::env::var("ProgramFiles").ok().as_deref(),
        std::env::var("LOCALAPPDATA").ok().as_deref(),
        dirs::home_dir().as_deref(),
    )
    .iter()
    .any(|p| p.exists())
}

/// Filesystem locations that identify an LM Studio install. Pure so tests
/// can cover the per-OS layouts; `exists()` checks happen in
/// [`lmstudio_app_installed`].
fn lmstudio_install_candidates(
    program_files: Option<&str>,
    local_app_data: Option<&str>,
    home: Option<&Path>,
) -> Vec<PathBuf> {
    let mut candidates = Vec::new();
    // Windows per-machine install (e.g. C:\Program Files\LM Studio\LM Studio.exe).
    if let Some(pf) = program_files {
        candidates.push(Path::new(pf).join("LM Studio").join("LM Studio.exe"));
    }
    // Windows per-user install (the installer default).
    if let Some(lad) = local_app_data {
        candidates.push(
            Path::new(lad)
                .join("Programs")
                .join("LM Studio")
                .join("LM Studio.exe"),
        );
    }
    candidates.push(PathBuf::from("/Applications/LM Studio.app"));
    if let Some(home) = home {
        candidates.push(home.join("Applications").join("LM Studio.app"));
        // ~/.lmstudio is created on first run on every OS (models, lms CLI).
        candidates.push(home.join(".lmstudio"));
    }
    candidates
}

fn normalize_lmstudio_host(raw: &str) -> Option<String> {
    let host = raw.trim();
    if host.is_empty() {
        return None;
    }

    if host.starts_with("http://") || host.starts_with("https://") {
        return Some(host.to_string());
    }

    if host.contains("://") {
        return None;
    }

    Some(format!("http://{host}"))
}

impl Default for LmStudioProvider {
    fn default() -> Self {
        let base_url = std::env::var("LMSTUDIO_HOST")
            .ok()
            .and_then(|raw| {
                let normalized = normalize_lmstudio_host(&raw);
                if normalized.is_none() {
                    eprintln!(
                        "Warning: could not parse LMSTUDIO_HOST='{}'. \
                         Expected host:port or http(s)://host:port",
                        raw
                    );
                }
                normalized
            })
            .unwrap_or_else(|| "http://127.0.0.1:1234".to_string());
        let api_key = std::env::var("LMSTUDIO_API_KEY")
            .ok()
            .filter(|k| !k.is_empty());
        Self { base_url, api_key }
    }
}

impl LmStudioProvider {
    pub fn new() -> Self {
        Self::default()
    }

    fn models_url(&self) -> String {
        format!("{}/v1/models", self.base_url.trim_end_matches('/'))
    }

    fn download_url(&self) -> String {
        format!(
            "{}/api/v1/models/download",
            self.base_url.trim_end_matches('/')
        )
    }

    /// Single-pass startup probe.
    /// Returns `(available, installed_models, count)`.
    pub fn detect_with_installed(&self) -> (bool, HashSet<String>, usize) {
        let mut set = HashSet::new();
        let Ok(resp) = ({
            let mut req = ureq::get(&self.models_url())
                .config()
                .timeout_global(Some(std::time::Duration::from_millis(800)))
                .build();
            if let Some(ref key) = self.api_key {
                req = req.header("Authorization", &format!("Bearer {}", key));
            }
            req.call()
        }) else {
            return (false, set, 0);
        };

        let Ok(list) = resp.into_body().read_json::<LmStudioModelList>() else {
            return (true, set, 0);
        };
        let models = list.data;
        let count = models.len();
        for m in models {
            let lower = m.id.to_lowercase();
            set.insert(lower.clone());
            // Also insert the model part after the publisher (e.g. "lmstudio-community/Qwen3-1.7B-MLX-4bit" → "qwen3-1.7b-mlx-4bit")
            if let Some(name) = lower.split('/').next_back()
                && name != lower
            {
                set.insert(name.to_string());
            }
        }
        (true, set, count)
    }

    pub fn installed_models_counted(&self) -> (HashSet<String>, usize) {
        let (_, set, count) = self.detect_with_installed();
        (set, count)
    }
}

#[derive(serde::Deserialize)]
struct LmStudioModelList {
    data: Vec<LmStudioModel>,
}

#[derive(serde::Deserialize)]
struct LmStudioModel {
    /// Model id, e.g. "lmstudio-community/Qwen3-1.7B-MLX-4bit"
    id: String,
}

fn lmstudio_download_status_url(base_url: &str, job_id: &str) -> String {
    format!(
        "{}/api/v1/models/download/status/{job_id}",
        base_url.trim_end_matches('/')
    )
}

#[derive(serde::Deserialize)]
struct LmStudioDownloadResponse {
    #[serde(default)]
    job_id: Option<String>,
    #[serde(default)]
    status: String,
    #[serde(default)]
    #[allow(dead_code)]
    total_size_bytes: Option<u64>,
}

fn lmstudio_response_job_id(resp: &LmStudioDownloadResponse) -> Option<&str> {
    resp.job_id.as_deref().filter(|job_id| !job_id.is_empty())
}

#[derive(serde::Deserialize)]
struct LmStudioDownloadStatus {
    #[serde(default)]
    status: String,
    #[serde(default)]
    progress: Option<f64>,
    #[serde(default)]
    downloaded_bytes: Option<u64>,
    #[serde(default)]
    total_size_bytes: Option<u64>,
}

#[derive(Debug, PartialEq, Eq)]
enum LmStudioDownloadTerminalStatus {
    Done,
    Failed,
}

fn lmstudio_download_status_percent(st: &LmStudioDownloadStatus) -> Option<f64> {
    st.progress
        .map(|p| p * 100.0)
        .or_else(|| match (st.downloaded_bytes, st.total_size_bytes) {
            (Some(dl), Some(total)) if total > 0 => Some(dl as f64 / total as f64 * 100.0),
            _ => None,
        })
}

fn lmstudio_download_terminal_status(status: &str) -> Option<LmStudioDownloadTerminalStatus> {
    match status {
        "completed" | "already_downloaded" => Some(LmStudioDownloadTerminalStatus::Done),
        "failed" => Some(LmStudioDownloadTerminalStatus::Failed),
        _ => None,
    }
}

fn lmstudio_empty_status_limit_reached(status: &str, empty_statuses: &mut usize) -> bool {
    if status.is_empty() {
        *empty_statuses += 1;
    } else {
        *empty_statuses = 0;
    }
    *empty_statuses >= 3
}

#[derive(Debug, PartialEq, Eq)]
enum LmStudioStatusPollResult {
    Finished,
    Fallback,
}

fn poll_lmstudio_download_status(
    status_url: &str,
    api_key: Option<&str>,
    tx: &std::sync::mpsc::Sender<PullEvent>,
    poll_interval: std::time::Duration,
    poll_budget: &mut usize,
) -> LmStudioStatusPollResult {
    let _ = tx.send(PullEvent::Progress {
        status: "Downloading via LM Studio (tracking status)...".to_string(),
        percent: None,
    });

    let mut empty_statuses = 0;
    while *poll_budget > 0 {
        *poll_budget -= 1;
        std::thread::sleep(poll_interval);

        let mut req = ureq::get(status_url)
            .config()
            .timeout_global(Some(std::time::Duration::from_secs(5)))
            .build();
        if let Some(key) = api_key {
            req = req.header("Authorization", &format!("Bearer {}", key));
        }
        let Ok(resp) = req.call() else {
            return LmStudioStatusPollResult::Fallback;
        };

        let Ok(st) = resp.into_body().read_json::<LmStudioDownloadStatus>() else {
            return LmStudioStatusPollResult::Fallback;
        };

        if lmstudio_empty_status_limit_reached(&st.status, &mut empty_statuses) {
            return LmStudioStatusPollResult::Fallback;
        }

        match lmstudio_download_terminal_status(&st.status) {
            Some(LmStudioDownloadTerminalStatus::Done) => {
                let _ = tx.send(PullEvent::Progress {
                    status: "Download complete".to_string(),
                    percent: Some(100.0),
                });
                let _ = tx.send(PullEvent::Done);
                return LmStudioStatusPollResult::Finished;
            }
            Some(LmStudioDownloadTerminalStatus::Failed) => {
                let _ = tx.send(PullEvent::Error("LM Studio download failed".to_string()));
                return LmStudioStatusPollResult::Finished;
            }
            None => {
                let _ = tx.send(PullEvent::Progress {
                    status: "Downloading via LM Studio...".to_string(),
                    percent: lmstudio_download_status_percent(&st),
                });
            }
        }
    }

    let _ = tx.send(PullEvent::Error("LM Studio download timed out".to_string()));
    LmStudioStatusPollResult::Finished
}

fn poll_lmstudio_installed_models(
    models_url: &str,
    api_key: Option<&str>,
    model_tag: &str,
    tx: &std::sync::mpsc::Sender<PullEvent>,
    poll_interval: std::time::Duration,
    max_polls: usize,
) {
    let candidates = hf_name_to_lmstudio_candidates(model_tag);

    let _ = tx.send(PullEvent::Progress {
        status: "Downloading via LM Studio (tracking)...".to_string(),
        percent: None,
    });

    for poll_num in 0..max_polls {
        std::thread::sleep(poll_interval);

        let mut req = ureq::get(models_url)
            .config()
            .timeout_global(Some(std::time::Duration::from_secs(5)))
            .build();
        if let Some(key) = api_key {
            req = req.header("Authorization", &format!("Bearer {}", key));
        }
        let Ok(resp) = req.call() else {
            continue;
        };

        let Ok(list) = resp.into_body().read_json::<LmStudioModelList>() else {
            continue;
        };

        let installed: HashSet<String> =
            list.data.into_iter().map(|m| m.id.to_lowercase()).collect();

        for candidate in &candidates {
            if installed.contains(candidate.as_str()) {
                let _ = tx.send(PullEvent::Progress {
                    status: "Download complete".to_string(),
                    percent: Some(100.0),
                });
                let _ = tx.send(PullEvent::Done);
                return;
            }
        }

        // Send periodic progress so the UI knows we're still tracking the
        // background download.
        if poll_num % 10 == 9 {
            let elapsed_secs = (poll_num + 1) as u64 * poll_interval.as_secs();
            let _ = tx.send(PullEvent::Progress {
                status: format!("Downloading via LM Studio ({}s elapsed)...", elapsed_secs),
                percent: None,
            });
        }
    }

    let _ = tx.send(PullEvent::Error("LM Studio download timed out".to_string()));
}

impl ModelProvider for LmStudioProvider {
    fn name(&self) -> &str {
        "LM Studio"
    }

    fn is_available(&self) -> bool {
        let mut req = ureq::get(&self.models_url())
            .config()
            .timeout_global(Some(std::time::Duration::from_secs(2)))
            .build();
        if let Some(ref key) = self.api_key {
            req = req.header("Authorization", &format!("Bearer {}", key));
        }
        req.call().is_ok()
    }

    fn installed_models(&self) -> HashSet<String> {
        let (set, _) = self.installed_models_counted();
        set
    }

    fn start_pull(&self, model_tag: &str) -> Result<PullHandle, String> {
        let download_url = self.download_url();
        let models_url = self.models_url();
        let base_url = self.base_url.clone();
        let api_key = self.api_key.clone();
        let tag = match lmstudio_pull_tag(model_tag) {
            Some(t) => t,
            None => {
                return Err(format!(
                    "Could not find a GGUF file for '{model_tag}'. \
                     LM Studio downloads need a HuggingFace repo that contains \
                     GGUF weights (e.g. bartowski/ or ggml-org/ variants)."
                ));
            }
        };
        let model_tag_owned = model_tag.to_string();
        let (tx, rx) = std::sync::mpsc::channel();

        let body = serde_json::json!({
            "model": tag,
        });

        std::thread::spawn(move || {
            // LM Studio may stream download progress as newline-delimited JSON
            // from the POST response, or it may acknowledge the request and
            // close the stream while the download proceeds in the background.
            // In the latter case we poll the per-job status endpoint when a
            // job id is available, falling back to the installed models list.
            let mut req = ureq::post(&download_url)
                .config()
                .timeout_global(Some(std::time::Duration::from_secs(3600)))
                .build();
            if let Some(ref key) = api_key {
                req = req.header("Authorization", &format!("Bearer {}", key));
            }
            let resp = req.send_json(&body);

            match resp {
                Ok(resp) => {
                    use std::io::Read;
                    let mut body = String::new();
                    let _ = resp.into_body().into_reader().read_to_string(&mut body);

                    // LM Studio 0.4.20 answers the POST with a single
                    // pretty-printed JSON object spanning multiple lines;
                    // older/streaming variants emit NDJSON or SSE lines.
                    // Parse the whole body as one JSON document first so the
                    // job_id is not lost, then fall back to per-line parsing.
                    let chunks: Vec<String> =
                        if serde_json::from_str::<serde_json::Value>(body.trim()).is_ok() {
                            vec![body.trim().to_string()]
                        } else {
                            body.lines().map(str::to_string).collect()
                        };

                    let mut saw_completion = false;
                    let mut job_id: Option<String> = None;
                    for line in chunks {
                        if line.is_empty() {
                            continue;
                        }

                        // Handle SSE "data: {json}" or plain JSON lines
                        let json_str = line.strip_prefix("data: ").unwrap_or(&line);

                        if let Ok(dl_resp) =
                            serde_json::from_str::<LmStudioDownloadResponse>(json_str)
                            && let Some(id) = lmstudio_response_job_id(&dl_resp)
                        {
                            job_id = Some(id.to_string());
                        }

                        // Try single status object, then first element of an array
                        let status_opt: Option<LmStudioDownloadStatus> =
                            serde_json::from_str(json_str).ok().or_else(|| {
                                serde_json::from_str::<Vec<LmStudioDownloadStatus>>(json_str)
                                    .ok()
                                    .and_then(|v| v.into_iter().next())
                            });

                        // Also try the initial response format.
                        if status_opt.is_none() {
                            if let Ok(dl_resp) =
                                serde_json::from_str::<LmStudioDownloadResponse>(json_str)
                            {
                                if dl_resp.status == "already_downloaded" {
                                    let _ = tx.send(PullEvent::Progress {
                                        status: "Already downloaded".to_string(),
                                        percent: Some(100.0),
                                    });
                                    let _ = tx.send(PullEvent::Done);
                                    return;
                                }
                                if dl_resp.status == "failed" {
                                    let _ = tx.send(PullEvent::Error(
                                        "LM Studio download failed".to_string(),
                                    ));
                                    return;
                                }

                                let _ = tx.send(PullEvent::Progress {
                                    status: format!(
                                        "Downloading via LM Studio ({})",
                                        dl_resp.status
                                    ),
                                    percent: Some(0.0),
                                });
                                continue;
                            }
                            continue;
                        }

                        let st = status_opt.unwrap();

                        match lmstudio_download_terminal_status(&st.status) {
                            Some(LmStudioDownloadTerminalStatus::Done) => {
                                let _ = tx.send(PullEvent::Progress {
                                    status: "Download complete".to_string(),
                                    percent: Some(100.0),
                                });
                                let _ = tx.send(PullEvent::Done);
                                saw_completion = true;
                                break;
                            }
                            Some(LmStudioDownloadTerminalStatus::Failed) => {
                                let _ = tx.send(PullEvent::Error(
                                    "LM Studio download failed".to_string(),
                                ));
                                return;
                            }
                            None => {}
                        }

                        let _ = tx.send(PullEvent::Progress {
                            status: "Downloading via LM Studio...".to_string(),
                            percent: lmstudio_download_status_percent(&st),
                        });
                    }

                    if !saw_completion {
                        // Stream ended without a completion event. The POST
                        // succeeded so LM Studio accepted the request — it
                        // is likely downloading in the background. Poll
                        // per-job status when possible; otherwise fall back
                        // to the installed models list to detect completion.
                        let poll_interval = std::time::Duration::from_secs(3);
                        let mut poll_budget = 600; // 30 minutes max

                        if let Some(ref job_id) = job_id {
                            let status_url = lmstudio_download_status_url(&base_url, job_id);
                            if poll_lmstudio_download_status(
                                &status_url,
                                api_key.as_deref(),
                                &tx,
                                poll_interval,
                                &mut poll_budget,
                            ) == LmStudioStatusPollResult::Finished
                            {
                                return;
                            }
                        }

                        poll_lmstudio_installed_models(
                            &models_url,
                            api_key.as_deref(),
                            &model_tag_owned,
                            &tx,
                            poll_interval,
                            poll_budget,
                        );
                    }
                }
                Err(e) => {
                    let _ = tx.send(PullEvent::Error(format!("LM Studio download error: {e}")));
                }
            }
        });

        Ok(PullHandle {
            model_tag: model_tag.to_string(),
            receiver: rx,
        })
    }
}

// ---------------------------------------------------------------------------
// LM Studio name-matching helpers
// ---------------------------------------------------------------------------

/// LM Studio uses HuggingFace model names directly. We match against the
/// model's GGUF sources and common naming patterns.
pub fn hf_name_to_lmstudio_candidates(hf_name: &str) -> Vec<String> {
    let repo = hf_name
        .split('/')
        .next_back()
        .unwrap_or(hf_name)
        .to_lowercase();
    let mut candidates = vec![hf_name.to_lowercase()];
    if repo != hf_name.to_lowercase() {
        candidates.push(repo.clone());
    }
    // Strip common suffixes for matching
    let stripped = repo
        .replace("-instruct", "")
        .replace("-chat", "")
        .replace("-hf", "")
        .replace("-it", "");
    if stripped != repo {
        candidates.push(stripped);
    }
    candidates
}

/// Check if any LM Studio candidates for an HF model appear in the installed set.
pub fn is_model_installed_lmstudio(hf_name: &str, installed: &HashSet<String>) -> bool {
    let candidates = hf_name_to_lmstudio_candidates(hf_name);
    candidates.iter().any(|candidate| {
        installed
            .iter()
            .any(|installed_name| installed_name.contains(candidate))
    })
}

/// Returns `true` when we can reasonably expect LM Studio to download this
/// model. LM Studio requires a direct `.gguf` file link, so we check for
/// known GGUF repos or heuristic candidates. Catalog short names (no slash)
/// and full URLs are always accepted.
pub fn has_lmstudio_mapping(hf_name: &str) -> bool {
    if hf_name.is_empty() {
        return false;
    }
    // Full URLs and catalog short names are always accepted
    if hf_name.starts_with("http://") || hf_name.starts_with("https://") || !hf_name.contains('/') {
        return true;
    }
    // Check for known GGUF repo mapping (local, no network)
    if lookup_gguf_repo(hf_name).is_some() {
        return true;
    }
    // Heuristic: check if any candidate GGUF repo exists (may probe network)
    first_existing_gguf_repo(hf_name).is_some()
}

/// Build a HuggingFace resolve URL for a specific GGUF file.
fn lmstudio_gguf_resolve_url(repo_id: &str, filename: &str) -> String {
    format!(
        "https://huggingface.co/{}/resolve/main/{}",
        repo_id, filename
    )
}

/// Try to find a direct GGUF file URL for an HF model name.
///
/// Used to verify a repo actually ships llama.cpp-compatible GGUF weights
/// before we hand LM Studio its repo URL (LM Studio 404s on repos without
/// downloadable artifacts). This function looks up known GGUF repos, lists
/// their files, selects the best quantization that fits in system RAM, and
/// returns a resolve URL; `lmstudio_pull_tag` converts it back to the repo
/// URL form the download API accepts.
///
/// Returns `None` if no GGUF files are found or the network is unavailable.
fn lmstudio_find_gguf_url(hf_name: &str) -> Option<String> {
    let mut sys = sysinfo::System::new_all();
    sys.refresh_memory();
    let system_ram_gb = sys.total_memory() as f64 / (1024.0 * 1024.0 * 1024.0);
    // Leave headroom for OS and overhead
    let budget_gb = system_ram_gb * 0.85;

    // Try known mappings first
    if let Some(repo) = lookup_gguf_repo(hf_name)
        && let Some(url) = try_gguf_repo(repo, budget_gb)
    {
        return Some(url);
    }

    // Try heuristic candidates (bartowski/, ggml-org/, TheBloke/)
    for candidate in hf_name_to_gguf_candidates(hf_name) {
        if let Some(url) = try_gguf_repo(&candidate, budget_gb) {
            return Some(url);
        }
    }

    // Try the base repo itself (some repos host GGUF directly)
    if hf_name.contains('/')
        && let Some(url) = try_gguf_repo(hf_name, budget_gb)
    {
        return Some(url);
    }

    None
}

/// Try to find a GGUF file in a specific repo.
fn try_gguf_repo(repo_id: &str, budget_gb: f64) -> Option<String> {
    let files = LlamaCppProvider::list_repo_gguf_files(repo_id);
    if files.is_empty() {
        return None;
    }
    let (filename, _) = LlamaCppProvider::select_best_gguf(&files, budget_gb)?;
    Some(lmstudio_gguf_resolve_url(repo_id, &filename))
}

/// Given an HF model name, return the model identifier to use for LM Studio download.
///
/// LM Studio's `POST /api/v1/models/download` accepts the HuggingFace repo
/// URL form (`https://huggingface.co/{owner}/{repo}`) and selects the
/// quantization itself. Verified against LM Studio 0.4.20: a direct link to
/// a `.gguf` file is rejected with HTTP 400 "Invalid HuggingFace model URL
/// format", and a bare `owner/repo` id is rejected for community artifacts.
/// We still resolve a concrete GGUF file first so we only hand LM Studio
/// repos that actually ship llama.cpp-compatible weights, then convert the
/// resolve URL back to the repo URL form the API accepts.
///
/// Full HTTP(S) URLs are passed through unchanged. Bare short names (no slash)
/// are assumed to be LM Studio first-party catalog entries.
pub fn lmstudio_pull_tag(hf_name: &str) -> Option<String> {
    if hf_name.is_empty() {
        return None;
    }

    // Pass through existing URLs and catalog short names
    if hf_name.starts_with("https://") || hf_name.starts_with("http://") || !hf_name.contains('/') {
        return Some(hf_name.to_string());
    }

    // Verify the repo ships GGUF weights, then send the repo URL form.
    if let Some(url) = lmstudio_find_gguf_url(hf_name) {
        return Some(lmstudio_repo_url_from_gguf_url(&url));
    }

    // No GGUF file found — return None so the caller can produce a
    // helpful error instead of sending a bare repo URL that LM Studio
    // will reject with HTTP 404.
    None
}

/// Convert a GGUF resolve URL (`https://huggingface.co/{repo}/resolve/main/{file}`)
/// into the repo URL form LM Studio's download API accepts.
fn lmstudio_repo_url_from_gguf_url(url: &str) -> String {
    match url.split_once("/resolve/") {
        Some((repo_url, _)) => repo_url.to_string(),
        None => url.to_string(),
    }
}

// ---------------------------------------------------------------------------
// vLLM provider
// ---------------------------------------------------------------------------

/// vLLM — high-throughput inference server with an OpenAI-compatible API.
///
/// Exposes `GET /v1/models` to list loaded models at
/// `http://localhost:8000` by default. Override with `VLLM_HOST`.
///
/// vLLM does not have a pull/download endpoint — models are loaded at
/// server start via HuggingFace. The `start_pull` implementation
/// returns an informational error directing users to restart vLLM with
/// the desired model.
pub struct VllmProvider {
    base_url: String,
}

fn normalize_vllm_host(raw: &str) -> Option<String> {
    let host = raw.trim();
    if host.is_empty() {
        return None;
    }

    if host.starts_with("http://") || host.starts_with("https://") {
        return Some(host.to_string());
    }

    if host.contains("://") {
        return None;
    }

    Some(format!("http://{host}"))
}

impl Default for VllmProvider {
    fn default() -> Self {
        let base_url = std::env::var("VLLM_HOST")
            .ok()
            .and_then(|raw| {
                let normalized = normalize_vllm_host(&raw);
                if normalized.is_none() {
                    eprintln!(
                        "Warning: could not parse VLLM_HOST='{}'. \
                         Expected host:port or http(s)://host:port",
                        raw
                    );
                }
                normalized
            })
            .unwrap_or_else(|| "http://localhost:8000".to_string());
        Self { base_url }
    }
}

impl VllmProvider {
    pub fn new() -> Self {
        Self::default()
    }

    fn models_url(&self) -> String {
        openai_models_url(&self.base_url)
    }

    /// Single-pass startup probe.
    /// Returns `(available, installed_models, count)`.
    pub fn detect_with_installed(&self) -> (bool, HashSet<String>, usize) {
        let mut set = HashSet::new();
        let Ok(resp) = ureq::get(&self.models_url())
            .config()
            .timeout_global(Some(std::time::Duration::from_millis(800)))
            .build()
            .call()
        else {
            return (false, set, 0);
        };

        let Ok(list) = resp.into_body().read_json::<OpenAiModelList>() else {
            if endpoint_has_omlx_status(&self.base_url, std::time::Duration::from_millis(800)) {
                return (false, set, 0);
            }
            return (true, set, 0);
        };
        if openai_model_list_is_omlx(&list)
            || (list.data.is_empty()
                && endpoint_has_omlx_status(&self.base_url, std::time::Duration::from_millis(800)))
        {
            return (false, set, 0);
        }
        let models = list.data;
        let count = models.len();
        for m in models {
            let lower = m.id.to_lowercase();
            set.insert(lower.clone());
            // Also insert the model part after the publisher
            // e.g. "meta-llama/Llama-3.1-8B-Instruct" → "llama-3.1-8b-instruct"
            if let Some(name) = lower.split('/').next_back()
                && name != lower
            {
                set.insert(name.to_string());
            }
        }
        (true, set, count)
    }

    pub fn installed_models_counted(&self) -> (HashSet<String>, usize) {
        let (_, set, count) = self.detect_with_installed();
        (set, count)
    }
}

impl ModelProvider for VllmProvider {
    fn name(&self) -> &str {
        "vLLM"
    }

    fn is_available(&self) -> bool {
        let Ok(resp) = ureq::get(&self.models_url())
            .config()
            .timeout_global(Some(std::time::Duration::from_secs(2)))
            .build()
            .call()
        else {
            return false;
        };
        match resp.into_body().read_json::<OpenAiModelList>() {
            Ok(list) => {
                !openai_model_list_is_omlx(&list)
                    && (!list.data.is_empty()
                        || !endpoint_has_omlx_status(
                            &self.base_url,
                            std::time::Duration::from_secs(2),
                        ))
            }
            Err(_) => !endpoint_has_omlx_status(&self.base_url, std::time::Duration::from_secs(2)),
        }
    }

    fn installed_models(&self) -> HashSet<String> {
        let (set, _) = self.installed_models_counted();
        set
    }

    fn start_pull(&self, _model_tag: &str) -> Result<PullHandle, String> {
        Err("vLLM does not support downloading models at runtime. \
             Restart the vLLM server with the desired model \
             (e.g. `vllm serve <model>`)."
            .to_string())
    }
}

// ---------------------------------------------------------------------------
// vLLM name-matching helpers
// ---------------------------------------------------------------------------

/// vLLM uses HuggingFace model names directly. We match against the
/// model's full HF name and common naming patterns.
pub fn hf_name_to_vllm_candidates(hf_name: &str) -> Vec<String> {
    let repo = hf_name
        .split('/')
        .next_back()
        .unwrap_or(hf_name)
        .to_lowercase();
    let mut candidates = vec![hf_name.to_lowercase()];
    if repo != hf_name.to_lowercase() {
        candidates.push(repo.clone());
    }
    // Strip common suffixes for matching
    let stripped = repo
        .replace("-instruct", "")
        .replace("-chat", "")
        .replace("-hf", "")
        .replace("-it", "");
    if stripped != repo {
        candidates.push(stripped);
    }
    candidates
}

/// Check if any vLLM candidates for an HF model appear in the installed set.
pub fn is_model_installed_vllm(hf_name: &str, installed: &HashSet<String>) -> bool {
    let candidates = hf_name_to_vllm_candidates(hf_name);
    candidates.iter().any(|candidate| {
        installed
            .iter()
            .any(|installed_name| installed_name.contains(candidate))
    })
}

/// vLLM can serve any HuggingFace model, so we always return true.
pub fn has_vllm_mapping(hf_name: &str) -> bool {
    !hf_name.is_empty()
}

/// Given an HF model name, return the model identifier to use for vLLM.
/// vLLM accepts HF model names directly.
pub fn vllm_pull_tag(hf_name: &str) -> Option<String> {
    if hf_name.is_empty() {
        return None;
    }
    Some(hf_name.to_string())
}

// ---------------------------------------------------------------------------
// RamaLama provider
// ---------------------------------------------------------------------------

/// RamaLama — container-based model runner with an OpenAI-compatible API.
///
/// Exposes `GET /v1/models` to list served models at
/// `http://localhost:8080` by default. Override with `RAMALAMA_HOST`.
///
/// Like vLLM, RamaLama has no runtime pull endpoint — models are served
/// via `ramalama serve <model>`. The `start_pull` implementation returns
/// an informational error directing users to serve the desired model.
pub struct RamaLamaProvider {
    base_url: String,
}

fn normalize_ramalama_host(raw: &str) -> Option<String> {
    let host = raw.trim();
    if host.is_empty() {
        return None;
    }

    if host.starts_with("http://") || host.starts_with("https://") {
        return Some(host.to_string());
    }

    if host.contains("://") {
        return None;
    }

    Some(format!("http://{host}"))
}

impl Default for RamaLamaProvider {
    fn default() -> Self {
        let base_url = std::env::var("RAMALAMA_HOST")
            .ok()
            .and_then(|raw| {
                let normalized = normalize_ramalama_host(&raw);
                if normalized.is_none() {
                    eprintln!(
                        "Warning: could not parse RAMALAMA_HOST='{}'. \
                         Expected host:port or http(s)://host:port",
                        raw
                    );
                }
                normalized
            })
            .unwrap_or_else(|| "http://localhost:8080".to_string());
        Self { base_url }
    }
}

impl RamaLamaProvider {
    pub fn new() -> Self {
        Self::default()
    }

    fn models_url(&self) -> String {
        format!("{}/v1/models", self.base_url.trim_end_matches('/'))
    }

    /// Single-pass startup probe.
    ///
    /// Prefers the running server's `/v1/models`. When that is unreachable,
    /// falls back to the local store via `ramalama ls --json`, so installed
    /// models are still detected without a served endpoint (mirrors how Docker
    /// Model Runner is recognized while "installed but not running").
    /// Returns `(available, installed_models, count)`.
    pub fn detect_with_installed(&self) -> (bool, HashSet<String>, usize) {
        let Ok(resp) = ureq::get(&self.models_url())
            .config()
            .timeout_global(Some(std::time::Duration::from_millis(800)))
            .build()
            .call()
        else {
            // Server not reachable — fall back to the on-disk store.
            return match Self::installed_from_store() {
                Some((set, count)) => (true, set, count),
                None => (false, HashSet::new(), 0),
            };
        };

        let Ok(list) = resp.into_body().read_json::<RamaLamaModelList>() else {
            return (true, HashSet::new(), 0);
        };
        let count = list.data.len();
        let mut set = HashSet::new();
        for m in list.data {
            insert_ramalama_name(&mut set, &m.id);
        }
        (true, set, count)
    }

    /// Detect installed models from the local RamaLama store using the CLI,
    /// so detection works without a running server. Returns `None` when the
    /// `ramalama` binary is absent or the command fails.
    fn installed_from_store() -> Option<(HashSet<String>, usize)> {
        let output = std::process::Command::new("ramalama")
            .args(["ls", "--json"])
            .output()
            .ok()?;
        if !output.status.success() {
            return None;
        }
        parse_ramalama_store(&output.stdout)
    }

    pub fn installed_models_counted(&self) -> (HashSet<String>, usize) {
        let (_, set, count) = self.detect_with_installed();
        (set, count)
    }
}

#[derive(serde::Deserialize)]
struct RamaLamaModelList {
    data: Vec<RamaLamaModel>,
}

#[derive(serde::Deserialize)]
struct RamaLamaModel {
    /// Model id, e.g. "meta-llama/Llama-3.1-8B-Instruct"
    id: String,
}

/// A row from `ramalama ls --json`. Extra fields (modified, size) are ignored.
#[derive(serde::Deserialize)]
struct RamaLamaStoreModel {
    /// Transport-qualified name, e.g. "huggingface://meta-llama/Llama-3.1-8B-Instruct".
    name: String,
    /// Optional friendly alias from shortnames.conf; empty when unset.
    #[serde(default)]
    shortname: String,
}

/// Insert a RamaLama model identifier into the installed set: the full
/// lowercased identifier plus its trailing path component, so both
/// "huggingface://meta-llama/llama-3.1-8b-instruct" and "llama-3.1-8b-instruct"
/// match. Matching against these is substring-based (see
/// `is_model_installed_ramalama`).
fn insert_ramalama_name(set: &mut HashSet<String>, raw: &str) {
    let lower = raw.to_lowercase();
    if let Some(name) = lower.split('/').next_back()
        && name != lower
    {
        set.insert(name.to_string());
    }
    set.insert(lower);
}

/// Parse `ramalama ls --json` output into `(installed_set, count)`.
fn parse_ramalama_store(json: &[u8]) -> Option<(HashSet<String>, usize)> {
    let models: Vec<RamaLamaStoreModel> = serde_json::from_slice(json).ok()?;
    let count = models.len();
    let mut set = HashSet::new();
    for m in &models {
        insert_ramalama_name(&mut set, &m.name);
        if !m.shortname.is_empty() {
            insert_ramalama_name(&mut set, &m.shortname);
        }
    }
    Some((set, count))
}

impl ModelProvider for RamaLamaProvider {
    fn name(&self) -> &str {
        "RamaLama"
    }

    fn is_available(&self) -> bool {
        ureq::get(&self.models_url())
            .config()
            .timeout_global(Some(std::time::Duration::from_secs(2)))
            .build()
            .call()
            .is_ok()
    }

    fn installed_models(&self) -> HashSet<String> {
        let (set, _) = self.installed_models_counted();
        set
    }

    fn start_pull(&self, _model_tag: &str) -> Result<PullHandle, String> {
        Err("RamaLama does not support downloading models at runtime. \
             Serve the desired model with `ramalama serve <model>`."
            .to_string())
    }
}

// ---------------------------------------------------------------------------
// RamaLama name-matching helpers
// ---------------------------------------------------------------------------

/// RamaLama serves HuggingFace/OCI model names directly. We match against the
/// model's full HF name and common naming patterns.
pub fn hf_name_to_ramalama_candidates(hf_name: &str) -> Vec<String> {
    let repo = hf_name
        .split('/')
        .next_back()
        .unwrap_or(hf_name)
        .to_lowercase();
    let mut candidates = vec![hf_name.to_lowercase()];
    if repo != hf_name.to_lowercase() {
        candidates.push(repo.clone());
    }
    // Strip common suffixes for matching
    let stripped = repo
        .replace("-instruct", "")
        .replace("-chat", "")
        .replace("-hf", "")
        .replace("-it", "");
    if stripped != repo {
        candidates.push(stripped);
    }
    candidates
}

/// Check if any RamaLama candidates for an HF model appear in the installed set.
pub fn is_model_installed_ramalama(hf_name: &str, installed: &HashSet<String>) -> bool {
    let candidates = hf_name_to_ramalama_candidates(hf_name);
    candidates.iter().any(|candidate| {
        installed
            .iter()
            .any(|installed_name| installed_name.contains(candidate))
    })
}

/// RamaLama can serve any HuggingFace model, so we always return true.
pub fn has_ramalama_mapping(hf_name: &str) -> bool {
    !hf_name.is_empty()
}

/// Given an HF model name, return the model identifier to use for RamaLama.
/// RamaLama accepts HF model names directly.
pub fn ramalama_pull_tag(hf_name: &str) -> Option<String> {
    if hf_name.is_empty() {
        return None;
    }
    Some(hf_name.to_string())
}

// ---------------------------------------------------------------------------
// Docker Model Runner name-matching helpers
// ---------------------------------------------------------------------------

/// Embedded catalog of HF models confirmed to exist in Docker Hub's ai/ namespace.
/// Generated by `scripts/scrape_docker_models.py` and refreshed alongside the model DB.
const DOCKER_MODELS_JSON: &str = include_str!("../data/docker_models.json");

#[derive(serde::Deserialize)]
struct DockerModelCatalog {
    models: Vec<DockerModelEntry>,
}

#[derive(serde::Deserialize)]
struct DockerModelEntry {
    hf_name: String,
    docker_tag: String,
}

/// Lazily parsed Docker Model Runner catalog.
fn docker_mr_catalog() -> &'static [(String, String)] {
    use std::sync::OnceLock;
    static CATALOG: OnceLock<Vec<(String, String)>> = OnceLock::new();
    CATALOG.get_or_init(|| {
        let Ok(catalog) = serde_json::from_str::<DockerModelCatalog>(DOCKER_MODELS_JSON) else {
            return Vec::new();
        };
        catalog
            .models
            .into_iter()
            .map(|e| (e.hf_name.to_lowercase(), e.docker_tag))
            .collect()
    })
}

/// Returns `true` if this HF model has a confirmed Docker Model Runner image.
pub fn has_docker_mr_mapping(hf_name: &str) -> bool {
    docker_mr_pull_tag(hf_name).is_some()
}

/// Given an HF model name, return the Docker Model Runner tag to use for pulling.
/// Returns `None` if the model has no confirmed Docker image.
pub fn docker_mr_pull_tag(hf_name: &str) -> Option<String> {
    let lower = hf_name.to_lowercase();
    docker_mr_catalog()
        .iter()
        .find(|(name, _)| *name == lower)
        .map(|(_, tag)| tag.clone())
}

/// Docker Model Runner uses the Ollama naming convention (e.g. "ai/llama3.1:8b").
/// We generate candidates from the confirmed catalog, plus base-name variants for
/// matching against locally installed models.
pub fn hf_name_to_docker_mr_candidates(hf_name: &str) -> Vec<String> {
    let Some(tag) = docker_mr_pull_tag(hf_name) else {
        return Vec::new();
    };
    let mut candidates = vec![tag.clone()];
    // Also add without "ai/" prefix for matching installed models
    if let Some(stripped) = tag.strip_prefix("ai/") {
        candidates.push(stripped.to_string());
    }
    // Add base repo name (without size tag) e.g. "ai/llama3.1"
    if let Some(base) = tag.split(':').next() {
        candidates.push(base.to_string());
    }
    candidates
}

/// Check if any of the Docker Model Runner candidates for an HF model
/// appear in the installed set.
pub fn is_model_installed_docker_mr(hf_name: &str, installed: &HashSet<String>) -> bool {
    let candidates = hf_name_to_docker_mr_candidates(hf_name);
    candidates.iter().any(|candidate| {
        installed
            .iter()
            .any(|installed_name| docker_mr_installed_matches(installed_name, candidate))
    })
}

fn docker_mr_installed_matches(installed_name: &str, candidate: &str) -> bool {
    if installed_name == candidate {
        return true;
    }
    // Allow variant tags, e.g. candidate "ai/llama3.1:8b" matching
    // installed "ai/llama3.1:8b-q4_k_m"
    if candidate.contains(':') {
        return installed_name.starts_with(&format!("{candidate}-"));
    }
    false
}

/// Strip quantization suffix from a GGUF file stem.
/// "llama-3.1-8b-instruct-q4_k_m" → "llama-3.1-8b-instruct"
pub fn strip_gguf_quant_suffix(stem: &str) -> Option<String> {
    let quant_patterns = [
        "-q8_0", "-q6_k", "-q6_k_l", "-q5_k_m", "-q5_k_s", "-q4_k_m", "-q4_k_s", "-q4_0",
        "-q3_k_m", "-q3_k_s", "-q2_k", "-iq4_xs", "-iq3_m", "-iq2_m", "-iq1_m", "-f16", "-f32",
        "-bf16", ".q8_0", ".q6_k", ".q5_k_m", ".q4_k_m", ".q4_0", ".q3_k_m", ".q2_k",
    ];
    for pat in &quant_patterns {
        if let Some(pos) = stem.rfind(pat) {
            let base = &stem[..pos];
            // Unsloth "Dynamic" GGUFs embed a `-ud` marker between the model
            // name and the quant (e.g. `qwen3.6-35b-a3b-ud-q4_k_m`). It is not
            // part of the canonical model name, so strip it too — otherwise the
            // stem never reduces to the catalog id and the file reads as neither
            // installed nor served.
            let base = base.strip_suffix("-ud").unwrap_or(base);
            return Some(base.to_string());
        }
    }
    None
}

// ---------------------------------------------------------------------------
// llama.cpp name-matching helpers
// ---------------------------------------------------------------------------

/// Authoritative mapping from HF repo names to known GGUF repository IDs on HuggingFace.
/// Models not in this table fall back to a heuristic search.
const LLAMACPP_GGUF_MAPPINGS: &[(&str, &str)] = &[
    // Meta Llama
    (
        "llama-3.3-70b-instruct",
        "bartowski/Llama-3.3-70B-Instruct-GGUF",
    ),
    (
        "llama-3.2-3b-instruct",
        "bartowski/Llama-3.2-3B-Instruct-GGUF",
    ),
    (
        "llama-3.2-1b-instruct",
        "bartowski/Llama-3.2-1B-Instruct-GGUF",
    ),
    (
        "llama-3.1-8b-instruct",
        "bartowski/Llama-3.1-8B-Instruct-GGUF",
    ),
    (
        "llama-3.1-70b-instruct",
        "bartowski/Llama-3.1-70B-Instruct-GGUF",
    ),
    (
        "llama-3.1-405b-instruct",
        "bartowski/Meta-Llama-3.1-405B-Instruct-GGUF",
    ),
    (
        "meta-llama-3-8b-instruct",
        "bartowski/Meta-Llama-3-8B-Instruct-GGUF",
    ),
    // Qwen
    (
        "qwen2.5-72b-instruct",
        "bartowski/Qwen2.5-72B-Instruct-GGUF",
    ),
    (
        "qwen2.5-32b-instruct",
        "bartowski/Qwen2.5-32B-Instruct-GGUF",
    ),
    (
        "qwen2.5-14b-instruct",
        "bartowski/Qwen2.5-14B-Instruct-GGUF",
    ),
    ("qwen2.5-7b-instruct", "bartowski/Qwen2.5-7B-Instruct-GGUF"),
    ("qwen2.5-3b-instruct", "bartowski/Qwen2.5-3B-Instruct-GGUF"),
    (
        "qwen2.5-1.5b-instruct",
        "bartowski/Qwen2.5-1.5B-Instruct-GGUF",
    ),
    (
        "qwen2.5-0.5b-instruct",
        "bartowski/Qwen2.5-0.5B-Instruct-GGUF",
    ),
    (
        "qwen2.5-coder-32b-instruct",
        "bartowski/Qwen2.5-Coder-32B-Instruct-GGUF",
    ),
    (
        "qwen2.5-coder-14b-instruct",
        "bartowski/Qwen2.5-Coder-14B-Instruct-GGUF",
    ),
    (
        "qwen2.5-coder-7b-instruct",
        "bartowski/Qwen2.5-Coder-7B-Instruct-GGUF",
    ),
    ("qwen3-32b", "bartowski/Qwen3-32B-GGUF"),
    ("qwen3-14b", "bartowski/Qwen3-14B-GGUF"),
    ("qwen3-8b", "bartowski/Qwen3-8B-GGUF"),
    ("qwen3-4b", "bartowski/Qwen3-4B-GGUF"),
    ("qwen3-0.6b", "bartowski/Qwen3-0.6B-GGUF"),
    // Mistral
    (
        "mistral-7b-instruct-v0.3",
        "bartowski/Mistral-7B-Instruct-v0.3-GGUF",
    ),
    (
        "mistral-small-24b-instruct-2501",
        "bartowski/Mistral-Small-24B-Instruct-2501-GGUF",
    ),
    (
        "mixtral-8x7b-instruct-v0.1",
        "bartowski/Mixtral-8x7B-Instruct-v0.1-GGUF",
    ),
    // Google Gemma
    ("gemma-3-12b-it", "bartowski/gemma-3-12b-it-GGUF"),
    ("gemma-2-27b-it", "bartowski/gemma-2-27b-it-GGUF"),
    ("gemma-2-9b-it", "bartowski/gemma-2-9b-it-GGUF"),
    ("gemma-2-2b-it", "bartowski/gemma-2-2b-it-GGUF"),
    // Microsoft Phi
    ("phi-4", "bartowski/phi-4-GGUF"),
    ("phi-4-mini-instruct", "bartowski/phi-4-mini-instruct-GGUF"),
    (
        "phi-3.5-mini-instruct",
        "bartowski/Phi-3.5-mini-instruct-GGUF",
    ),
    (
        "phi-3-mini-4k-instruct",
        "bartowski/Phi-3-mini-4k-instruct-GGUF",
    ),
    // DeepSeek
    ("deepseek-r1", "bartowski/DeepSeek-R1-GGUF"),
    (
        "deepseek-r1-distill-qwen-32b",
        "bartowski/DeepSeek-R1-Distill-Qwen-32B-GGUF",
    ),
    (
        "deepseek-r1-distill-qwen-14b",
        "bartowski/DeepSeek-R1-Distill-Qwen-14B-GGUF",
    ),
    (
        "deepseek-r1-distill-qwen-7b",
        "bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF",
    ),
    ("deepseek-v3", "bartowski/DeepSeek-V3-GGUF"),
    // Community
    (
        "tinyllama-1.1b-chat-v1.0",
        "TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF",
    ),
    ("falcon-7b-instruct", "TheBloke/falcon-7b-instruct-GGUF"),
    (
        "smollm2-135m-instruct",
        "bartowski/SmolLM2-135M-Instruct-GGUF",
    ),
];

/// Look up a known GGUF repo for an HF model name.
fn lookup_gguf_repo(hf_name: &str) -> Option<&'static str> {
    let repo = hf_name
        .split('/')
        .next_back()
        .unwrap_or(hf_name)
        .to_lowercase();
    LLAMACPP_GGUF_MAPPINGS
        .iter()
        .find(|&&(hf_suffix, _)| repo == hf_suffix)
        .map(|&(_, gguf_repo)| gguf_repo)
}

/// Map a HuggingFace model name to candidate GGUF repo IDs.
pub fn hf_name_to_gguf_candidates(hf_name: &str) -> Vec<String> {
    if let Some(repo) = lookup_gguf_repo(hf_name) {
        return vec![repo.to_string()];
    }

    // Heuristic: try common GGUF repo naming patterns
    let base = hf_name.split('/').next_back().unwrap_or(hf_name);

    vec![
        format!("bartowski/{}-GGUF", base),
        format!("ggml-org/{}-GGUF", base),
        format!("TheBloke/{}-GGUF", base),
    ]
}

/// Returns `true` if this HF model has a known GGUF mapping.
pub fn has_gguf_mapping(hf_name: &str) -> bool {
    lookup_gguf_repo(hf_name).is_some()
}

/// Check if a model is installed in the llama.cpp cache.
pub fn is_model_installed_llamacpp(hf_name: &str, installed: &HashSet<String>) -> bool {
    let repo = hf_name
        .split('/')
        .next_back()
        .unwrap_or(hf_name)
        .to_lowercase();

    // Direct match on model name stem. The installed set already contains
    // both raw file stems and quant-suffix-stripped bases (see
    // `installed_models_counted`), so exact lookups cover files like
    // "qwen2.5-7b-instruct-q4_k_m.gguf" matched against the plain repo name.
    if installed.contains(&repo) {
        return true;
    }

    // Also accept a match with common variant suffixes stripped.
    //
    // Deliberately no substring matching here: a single "gemma-3.gguf" on
    // disk must not mark every gemma-3-* model in the database as installed
    // (`repo.contains("gemma-3")` is true for all of them).
    let stripped = repo
        .replace("-instruct", "")
        .replace("-chat", "")
        .replace("-hf", "")
        .replace("-it", "");
    installed.contains(&stripped)
}

/// Given an HF model name, return the best GGUF repo to pull from.
pub fn gguf_pull_tag(hf_name: &str) -> Option<String> {
    lookup_gguf_repo(hf_name).map(|s| s.to_string())
}

/// Best-effort check that a Hugging Face model repository exists.
pub fn hf_repo_exists(repo_id: &str) -> bool {
    let url = format!("https://huggingface.co/api/models/{}", repo_id);
    ureq::get(&url)
        .config()
        .timeout_global(Some(std::time::Duration::from_millis(1200)))
        .build()
        .call()
        .is_ok()
}

/// Resolve the first GGUF repo that appears to exist remotely.
pub fn first_existing_gguf_repo(hf_name: &str) -> Option<String> {
    if let Some(repo) = gguf_pull_tag(hf_name)
        && hf_repo_exists(&repo)
    {
        return Some(repo);
    }
    let candidates = hf_name_to_gguf_candidates(hf_name);
    candidates.into_iter().find(|repo| hf_repo_exists(repo))
}

// ---------------------------------------------------------------------------
// MLX name-matching helpers
// ---------------------------------------------------------------------------

fn push_unique_candidate(candidates: &mut Vec<String>, candidate: String) {
    if !candidate.is_empty() && !candidates.iter().any(|c| c == &candidate) {
        candidates.push(candidate);
    }
}

fn strip_trailing_quant_suffix(name: &str) -> String {
    for suffix in ["-4bit", "-6bit", "-8bit"] {
        if let Some(stripped) = name.strip_suffix(suffix) {
            return stripped.to_string();
        }
    }
    name.to_string()
}

fn normalize_mlx_repo_base(repo_lower: &str) -> String {
    let without_quant = strip_trailing_quant_suffix(repo_lower);

    without_quant
        .strip_suffix("-mlx")
        .unwrap_or(&without_quant)
        .trim_matches('-')
        .to_string()
}

fn strip_trailing_common_model_suffixes(name: &str) -> String {
    let mut out = name.to_string();
    loop {
        let mut changed = false;
        for suffix in ["-instruct", "-chat", "-hf", "-it", "-base"] {
            if let Some(stripped) = out.strip_suffix(suffix) {
                out = stripped.trim_end_matches('-').to_string();
                changed = true;
                break;
            }
        }
        if !changed {
            break;
        }
    }
    out
}

fn explicit_mlx_repo_id(hf_name: &str) -> Option<String> {
    if hf_name.matches('/').count() != 1 {
        return None;
    }
    let mut parts = hf_name.splitn(2, '/');
    let owner = parts.next()?.trim();
    let repo = parts.next()?.trim();
    if owner.is_empty() || repo.is_empty() || !is_likely_mlx_repo(owner, repo) {
        return None;
    }
    Some(format!("{}/{}", owner.to_lowercase(), repo.to_lowercase()))
}

/// Map a HuggingFace model name to mlx-community repo name candidates.
/// Pattern: mlx-community/{RepoName}-{quant}bit
pub fn hf_name_to_mlx_candidates(hf_name: &str) -> Vec<String> {
    let mut candidates = Vec::new();

    if let Some(repo_id) = explicit_mlx_repo_id(hf_name) {
        push_unique_candidate(&mut candidates, repo_id.clone());
        if let Some(repo_name) = repo_id.split('/').next_back() {
            push_unique_candidate(&mut candidates, repo_name.to_string());
        }
    }

    let repo = hf_name.split('/').next_back().unwrap_or(hf_name);
    let repo_lower = repo.to_lowercase();
    push_unique_candidate(&mut candidates, repo_lower.clone());

    let normalized_repo = normalize_mlx_repo_base(&repo_lower);

    // Explicit mappings: HF repo suffix → mlx-community repo name (without quant suffix)
    let mappings: &[(&str, &str)] = &[
        // Meta Llama
        ("Llama-3.3-70B-Instruct", "Llama-3.3-70B-Instruct"),
        ("Llama-3.2-3B-Instruct", "Llama-3.2-3B-Instruct"),
        ("Llama-3.2-1B-Instruct", "Llama-3.2-1B-Instruct"),
        ("Llama-3.1-8B-Instruct", "Llama-3.1-8B-Instruct"),
        ("Llama-3.1-70B-Instruct", "Llama-3.1-70B-Instruct"),
        // Qwen
        ("Qwen2.5-72B-Instruct", "Qwen2.5-72B-Instruct"),
        ("Qwen2.5-32B-Instruct", "Qwen2.5-32B-Instruct"),
        ("Qwen2.5-14B-Instruct", "Qwen2.5-14B-Instruct"),
        ("Qwen2.5-7B-Instruct", "Qwen2.5-7B-Instruct"),
        ("Qwen2.5-Coder-32B-Instruct", "Qwen2.5-Coder-32B-Instruct"),
        ("Qwen2.5-Coder-14B-Instruct", "Qwen2.5-Coder-14B-Instruct"),
        ("Qwen2.5-Coder-7B-Instruct", "Qwen2.5-Coder-7B-Instruct"),
        ("Qwen3-32B", "Qwen3-32B"),
        ("Qwen3-14B", "Qwen3-14B"),
        ("Qwen3-8B", "Qwen3-8B"),
        ("Qwen3-4B", "Qwen3-4B"),
        ("Qwen3-1.7B", "Qwen3-1.7B"),
        ("Qwen3-0.6B", "Qwen3-0.6B"),
        ("Qwen3-30B-A3B", "Qwen3-30B-A3B"),
        ("Qwen3-235B-A22B", "Qwen3-235B-A22B"),
        // Qwen3.5
        ("Qwen3.5-0.6B", "Qwen3.5-0.6B"),
        ("Qwen3.5-1.7B", "Qwen3.5-1.7B"),
        ("Qwen3.5-4B", "Qwen3.5-4B"),
        ("Qwen3.5-8B", "Qwen3.5-8B"),
        ("Qwen3.5-9B", "Qwen3.5-9B"),
        ("Qwen3.5-14B", "Qwen3.5-14B"),
        ("Qwen3.5-27B", "Qwen3.5-27B"),
        ("Qwen3.5-32B", "Qwen3.5-32B"),
        ("Qwen3.5-35B-A3B", "Qwen3.5-35B-A3B"),
        ("Qwen3.5-72B", "Qwen3.5-72B"),
        ("Qwen3.5-122B-A10B", "Qwen3.5-122B-A10B"),
        ("Qwen3.5-397B-A17B", "Qwen3.5-397B-A17B"),
        // Mistral
        ("Mistral-7B-Instruct-v0.3", "Mistral-7B-Instruct-v0.3"),
        (
            "Mistral-Small-24B-Instruct-2501",
            "Mistral-Small-24B-Instruct-2501",
        ),
        ("Mixtral-8x7B-Instruct-v0.1", "Mixtral-8x7B-Instruct-v0.1"),
        (
            "Mistral-Small-3.1-24B-Instruct-2503",
            "Mistral-Small-3.1-24B-Instruct-2503",
        ),
        ("Ministral-8B-Instruct-2410", "Ministral-8B-Instruct-2410"),
        ("Mistral-Nemo-Instruct-2407", "Mistral-Nemo-Instruct-2407"),
        // DeepSeek
        (
            "DeepSeek-R1-Distill-Qwen-32B",
            "DeepSeek-R1-Distill-Qwen-32B",
        ),
        ("DeepSeek-R1-Distill-Qwen-7B", "DeepSeek-R1-Distill-Qwen-7B"),
        (
            "DeepSeek-R1-Distill-Qwen-14B",
            "DeepSeek-R1-Distill-Qwen-14B",
        ),
        (
            "DeepSeek-R1-Distill-Llama-8B",
            "DeepSeek-R1-Distill-Llama-8B",
        ),
        (
            "DeepSeek-R1-Distill-Llama-70B",
            "DeepSeek-R1-Distill-Llama-70B",
        ),
        // Gemma
        ("gemma-3-12b-it", "gemma-3-12b-it"),
        ("gemma-2-27b-it", "gemma-2-27b-it"),
        ("gemma-2-9b-it", "gemma-2-9b-it"),
        ("gemma-2-2b-it", "gemma-2-2b-it"),
        ("gemma-3-1b-it", "gemma-3-1b-it"),
        ("gemma-3-4b-it", "gemma-3-4b-it"),
        ("gemma-3-27b-it", "gemma-3-27b-it"),
        ("gemma-3n-E4B-it", "gemma-3n-E4B-it"),
        ("gemma-3n-E2B-it", "gemma-3n-E2B-it"),
        // Phi
        ("Phi-4", "Phi-4"),
        ("Phi-3.5-mini-instruct", "Phi-3.5-mini-instruct"),
        ("Phi-3-mini-4k-instruct", "Phi-3-mini-4k-instruct"),
        ("Phi-4-mini-instruct", "Phi-4-mini-instruct"),
        ("Phi-4-reasoning", "Phi-4-reasoning"),
        ("Phi-4-mini-reasoning", "Phi-4-mini-reasoning"),
        // Llama 4
        (
            "Llama-4-Scout-17B-16E-Instruct",
            "Llama-4-Scout-17B-16E-Instruct",
        ),
        (
            "Llama-4-Maverick-17B-128E-Instruct",
            "Llama-4-Maverick-17B-128E-Instruct",
        ),
    ];

    for &(hf_suffix, mlx_base) in mappings {
        let mapped_suffix = hf_suffix.to_lowercase();
        if repo_lower == mapped_suffix || normalized_repo == mapped_suffix {
            let base_lower = mlx_base.to_lowercase();
            push_unique_candidate(&mut candidates, format!("{}-4bit", base_lower));
            push_unique_candidate(&mut candidates, format!("{}-8bit", base_lower));
            push_unique_candidate(&mut candidates, base_lower);
            return candidates;
        }
    }

    // Fallback heuristic: normalize explicit MLX names and try common variants.
    if !normalized_repo.is_empty() {
        push_unique_candidate(&mut candidates, format!("{}-4bit", normalized_repo));
        push_unique_candidate(&mut candidates, format!("{}-8bit", normalized_repo));
        // Some mlx-community repos use a -MLX- infix (e.g. Model-MLX-4bit)
        push_unique_candidate(&mut candidates, format!("{}-mlx-4bit", normalized_repo));
        push_unique_candidate(&mut candidates, format!("{}-mlx-8bit", normalized_repo));
        push_unique_candidate(&mut candidates, normalized_repo.clone());
    }

    let stripped = strip_trailing_common_model_suffixes(&normalized_repo);
    if !stripped.is_empty() && stripped != normalized_repo {
        push_unique_candidate(&mut candidates, format!("{}-4bit", stripped));
        push_unique_candidate(&mut candidates, format!("{}-8bit", stripped));
        push_unique_candidate(&mut candidates, format!("{}-mlx-4bit", stripped));
        push_unique_candidate(&mut candidates, format!("{}-mlx-8bit", stripped));
        push_unique_candidate(&mut candidates, stripped);
    }

    candidates
}

/// Check if any MLX candidates for an HF model appear in the installed set.
pub fn is_model_installed_mlx(hf_name: &str, installed: &HashSet<String>) -> bool {
    // Quick check: installed set may contain the full HF name (lowercased)
    if installed.contains(&hf_name.to_lowercase()) {
        return true;
    }

    let candidates = hf_name_to_mlx_candidates(hf_name);
    candidates.iter().any(|c| installed.contains(c))
}

/// Given an HF model name, return the best MLX tag to use for pulling.
pub fn mlx_pull_tag(hf_name: &str) -> String {
    if let Some(repo_id) = explicit_mlx_repo_id(hf_name) {
        return repo_id;
    }
    let candidates = hf_name_to_mlx_candidates(hf_name);
    // Prefer 4bit (smaller download) for pulling
    candidates
        .iter()
        .find(|c| c.ends_with("-4bit"))
        .cloned()
        .unwrap_or_else(|| {
            candidates.into_iter().next().unwrap_or_else(|| {
                hf_name
                    .split('/')
                    .next_back()
                    .unwrap_or(hf_name)
                    .to_lowercase()
            })
        })
}

/// Resolve the repo id an MLX pull should download, guarding the
/// `mlx-community/{tag}` fallback (issue #294).
///
/// An explicit `owner/name` tag is trusted as typed. A bare tag is a guess:
/// llmfit assumes an `mlx-community` equivalent exists. Before that guess is
/// handed to `hf download` we (a) refuse names that mark a pre-quantized
/// non-MLX format (AWQ/GPTQ/AutoRound have no fabricated MLX twin), and
/// (b) verify the guessed repo actually exists, so the user gets a clear
/// error instead of a "Model not found" pull failure against a repo llmfit
/// invented.
///
/// `repo_exists` is injected so tests don't hit the network.
fn resolve_mlx_fallback_repo(
    model_tag: &str,
    repo_exists: &dyn Fn(&str) -> bool,
) -> Result<String, String> {
    if model_tag.contains('/') {
        return Ok(model_tag.to_string());
    }

    let tag_lower = model_tag.to_lowercase();
    let has_mlx_marker = tag_lower.contains("mlx");
    if !has_mlx_marker && is_likely_prequantized_repo(&tag_lower) {
        return Err(format!(
            "{model_tag} looks like a pre-quantized AWQ/GPTQ repo — that's a vLLM/CUDA \
             format, not MLX, and there is no mlx-community equivalent to guess. If an \
             MLX build of this model exists, pass its full repo id (owner/name)."
        ));
    }

    let candidate = format!("mlx-community/{model_tag}");
    if !repo_exists(&candidate) {
        return Err(format!(
            "No MLX build found: {candidate} does not exist on Hugging Face (or could not \
             be reached). If an MLX build exists under a different name, pass its full \
             repo id (owner/name)."
        ));
    }
    Ok(candidate)
}

// ---------------------------------------------------------------------------
// Ollama name-matching helpers
// ---------------------------------------------------------------------------

/// Authoritative mapping from HF repo name (lowercased, after slash) to Ollama tag.
/// Only models with a known Ollama registry entry are listed here.
/// If a model is not in this table, it cannot be pulled from Ollama.
const OLLAMA_MAPPINGS: &[(&str, &str)] = &[
    // Meta Llama family
    ("llama-3.3-70b-instruct", "llama3.3:70b"),
    ("llama-3.2-11b-vision-instruct", "llama3.2-vision:11b"),
    ("llama-3.2-3b-instruct", "llama3.2:3b"),
    ("llama-3.2-3b", "llama3.2:3b"),
    ("llama-3.2-1b-instruct", "llama3.2:1b"),
    ("llama-3.2-1b", "llama3.2:1b"),
    ("llama-3.1-405b-instruct", "llama3.1:405b"),
    ("llama-3.1-405b", "llama3.1:405b"),
    ("llama-3.1-70b-instruct", "llama3.1:70b"),
    ("llama-3.1-8b-instruct", "llama3.1:8b"),
    ("llama-3.1-8b", "llama3.1:8b"),
    ("meta-llama-3-8b-instruct", "llama3:8b"),
    ("meta-llama-3-8b", "llama3:8b"),
    ("llama-2-7b-hf", "llama2:7b"),
    ("codellama-34b-instruct-hf", "codellama:34b"),
    ("codellama-13b-instruct-hf", "codellama:13b"),
    ("codellama-7b-instruct-hf", "codellama:7b"),
    // Google Gemma
    ("gemma-3-12b-it", "gemma3:12b"),
    ("gemma-2-27b-it", "gemma2:27b"),
    ("gemma-2-9b-it", "gemma2:9b"),
    ("gemma-2-2b-it", "gemma2:2b"),
    // Microsoft Phi
    ("phi-4", "phi4"),
    ("phi-4-mini-instruct", "phi4-mini"),
    ("phi-3.5-mini-instruct", "phi3.5"),
    ("phi-3-mini-4k-instruct", "phi3"),
    ("phi-3-medium-14b-instruct", "phi3:14b"),
    ("phi-2", "phi"),
    ("orca-2-7b", "orca2:7b"),
    ("orca-2-13b", "orca2:13b"),
    // Mistral
    ("mistral-7b-instruct-v0.3", "mistral:7b"),
    ("mistral-7b-instruct-v0.2", "mistral:7b"),
    ("mistral-nemo-instruct-2407", "mistral-nemo"),
    ("mistral-small-24b-instruct-2501", "mistral-small:24b"),
    ("mistral-small-3.1-24b-instruct-2503", "mistral-small3.1"),
    ("mistral-large-instruct-2407", "mistral-large"),
    ("devstral-small-2505", "devstral"),
    ("mixtral-8x7b-instruct-v0.1", "mixtral:8x7b"),
    ("mixtral-8x22b-instruct-v0.1", "mixtral:8x22b"),
    // Qwen 2 / 2.5
    ("qwen2-1.5b-instruct", "qwen2:1.5b"),
    ("qwen2.5-72b-instruct", "qwen2.5:72b"),
    ("qwen2.5-32b-instruct", "qwen2.5:32b"),
    ("qwen2.5-14b-instruct", "qwen2.5:14b"),
    ("qwen2.5-7b-instruct", "qwen2.5:7b"),
    ("qwen2.5-7b", "qwen2.5:7b"),
    ("qwen2.5-3b-instruct", "qwen2.5:3b"),
    ("qwen2.5-1.5b-instruct", "qwen2.5:1.5b"),
    ("qwen2.5-1.5b", "qwen2.5:1.5b"),
    ("qwen2.5-0.5b-instruct", "qwen2.5:0.5b"),
    ("qwen2.5-0.5b", "qwen2.5:0.5b"),
    ("qwen2.5-coder-32b-instruct", "qwen2.5-coder:32b"),
    ("qwen2.5-coder-14b-instruct", "qwen2.5-coder:14b"),
    ("qwen2.5-coder-7b-instruct", "qwen2.5-coder:7b"),
    ("qwen2.5-coder-1.5b-instruct", "qwen2.5-coder:1.5b"),
    ("qwen2.5-coder-0.5b-instruct", "qwen2.5-coder:0.5b"),
    ("qwen2.5-vl-72b-instruct", "qwen2.5vl:72b"),
    ("qwen2.5-vl-7b-instruct", "qwen2.5vl:7b"),
    ("qwen2.5-vl-3b-instruct", "qwen2.5vl:3b"),
    ("qwq-32b", "qwq"),
    // Qwen 3
    ("qwen3-235b-a22b", "qwen3:235b"),
    ("qwen3-32b", "qwen3:32b"),
    ("qwen3-30b-a3b", "qwen3:30b-a3b"),
    ("qwen3-30b-a3b-instruct-2507", "qwen3:30b-a3b"),
    ("qwen3-14b", "qwen3:14b"),
    ("qwen3-8b", "qwen3:8b"),
    ("qwen3-4b", "qwen3:4b"),
    ("qwen3-4b-instruct-2507", "qwen3:4b"),
    ("qwen3-1.7b-base", "qwen3:1.7b"),
    ("qwen3-0.6b", "qwen3:0.6b"),
    ("qwen3-coder-30b-a3b-instruct", "qwen3-coder"),
    // Qwen 3.5
    ("qwen3.5-27b", "qwen3.5"),
    ("qwen3.5-35b-a3b", "qwen3.5:35b"),
    ("qwen3.5-122b-a10b", "qwen3.5:122b"),
    // Qwen3-Coder-Next
    ("qwen3-coder-next", "qwen3-coder-next"),
    // DeepSeek
    ("deepseek-v3", "deepseek-v3"),
    ("deepseek-v3.2", "deepseek-v3"),
    ("deepseek-r1", "deepseek-r1"),
    ("deepseek-r1-0528", "deepseek-r1"),
    ("deepseek-r1-distill-qwen-32b", "deepseek-r1:32b"),
    ("deepseek-r1-distill-qwen-14b", "deepseek-r1:14b"),
    ("deepseek-r1-distill-qwen-7b", "deepseek-r1:7b"),
    ("deepseek-r1-distill-qwen-1.5b", "deepseek-r1:1.5b"),
    ("deepseek-r1-distill-llama-70b", "deepseek-r1:70b"),
    ("deepseek-r1-distill-llama-8b", "deepseek-r1:8b"),
    ("deepseek-coder-v2-lite-instruct", "deepseek-coder-v2:16b"),
    // Community / other
    ("tinyllama-1.1b-chat-v1.0", "tinyllama"),
    ("stablelm-2-1_6b-chat", "stablelm2:1.6b"),
    ("yi-6b-chat", "yi:6b"),
    ("yi-34b-chat", "yi:34b"),
    ("starcoder2-7b", "starcoder2:7b"),
    ("starcoder2-15b", "starcoder2:15b"),
    ("falcon-7b-instruct", "falcon:7b"),
    ("falcon-40b-instruct", "falcon:40b"),
    ("falcon-180b-chat", "falcon:180b"),
    ("falcon3-1b-instruct", "falcon3:1b"),
    ("falcon3-3b-instruct", "falcon3:3b"),
    ("falcon3-7b-instruct", "falcon3:7b"),
    ("openchat-3.5-0106", "openchat:7b"),
    ("vicuna-7b-v1.5", "vicuna:7b"),
    ("vicuna-13b-v1.5", "vicuna:13b"),
    ("glm-4-9b-chat", "glm4:9b"),
    ("solar-10.7b-instruct-v1.0", "solar:10.7b"),
    ("zephyr-7b-beta", "zephyr:7b"),
    ("c4ai-command-r-v01", "command-r"),
    ("c4ai-command-r-plus-08-2024", "command-r-plus"),
    ("c4ai-command-a-03-2025", "command-a"),
    (
        "nous-hermes-2-mixtral-8x7b-dpo",
        "nous-hermes2-mixtral:8x7b",
    ),
    ("hermes-3-llama-3.1-8b", "hermes3:8b"),
    ("nomic-embed-text-v1.5", "nomic-embed-text"),
    ("bge-large-en-v1.5", "bge-large"),
    ("smollm2-1.7b-instruct", "smollm2:1.7b"),
    ("smollm2-135m-instruct", "smollm2:135m"),
    ("smollm2-135m", "smollm2:135m"),
    // Google Gemma 3n
    ("gemma-3n-e4b-it", "gemma3n:e4b"),
    ("gemma-3n-e2b-it", "gemma3n:e2b"),
    // Microsoft Phi-4 reasoning
    ("phi-4-reasoning", "phi4-reasoning"),
    ("phi-4-mini-reasoning", "phi4-mini-reasoning"),
    // NVIDIA Nemotron
    ("llama-3.1-nemotron-70b-instruct-hf", "nemotron:70b"),
    ("llama-3.3-nemotron-super-49b-v1", "nemotron:49b"),
    // EXAONE Deep reasoning
    ("exaone-deep-2.4b", "exaone-deep:2.4b"),
    ("exaone-deep-7.8b", "exaone-deep:7.8b"),
    ("exaone-deep-32b", "exaone-deep:32b"),
    // OLMo 2
    ("olmo-2-1124-7b-instruct", "olmo2:7b"),
    ("olmo-2-1124-13b-instruct", "olmo2:13b"),
    ("olmo-2-0325-32b-instruct", "olmo2:32b"),
    // DeepSeek V3.2 Speciale (no local Ollama tag yet, maps to v3)
    ("deepseek-v3.2-speciale", "deepseek-v3"),
    // Liquid AI LFM2
    ("lfm2-350m", "lfm2:350m"),
    ("lfm2-700m", "lfm2:700m"),
    ("lfm2-1.2b", "lfm2:1.2b"),
    ("lfm2-2.6b", "lfm2:2.6b"),
    ("lfm2-2.6b-exp", "lfm2:2.6b"),
    ("lfm2-8b-a1b", "lfm2:8b-a1b"),
    ("lfm2-24b-a2b", "lfm2:24b"),
    // Liquid AI LFM2.5
    ("lfm2.5-1.2b-instruct", "lfm2.5:1.2b"),
    ("lfm2.5-1.2b-thinking", "lfm2.5-thinking:1.2b"),
];

/// Split a lowercased model name into (family_name, size_tag) by finding
/// the rightmost segment that looks like a parameter size (e.g. "7b", "70b",
/// "30b-a3b" for MoE).  Returns `None` if no size-like segment is found.
///
/// Examples:
///   "qwen2.5-coder-14b"       → Some(("qwen2.5-coder", "14b"))
///   "deepseek-r1-distill-qwen-32b" → Some(("deepseek-r1-distill-qwen", "32b"))
///   "qwen3-coder-30b-a3b"     → Some(("qwen3-coder", "30b-a3b"))
///   "phi-4"                    → None (no "b" suffix — "4" isn't a size tag)
fn split_name_and_size(name: &str) -> Option<(&str, &str)> {
    // Walk segments from the right looking for one that matches a size
    // pattern like "7b", "70b", "1.7b", "30b-a3b" (MoE active params).
    let segments: Vec<&str> = name.split('-').collect();
    for i in (0..segments.len()).rev() {
        let seg = segments[i];
        // Check for a segment ending in 'b' with digits (e.g. "7b", "70b", "1.7b")
        if seg.ends_with('b') && seg.len() > 1 {
            let before_b = &seg[..seg.len() - 1];
            if before_b.chars().all(|c| c.is_ascii_digit() || c == '.') {
                // Include any trailing MoE segment like "-a3b"
                let size_start = segments[..i]
                    .iter()
                    .map(|s| s.len() + 1) // +1 for the '-'
                    .sum::<usize>();
                if size_start == 0 || size_start > name.len() {
                    return None;
                }
                let family = &name[..size_start - 1]; // trim trailing '-'
                let size = &name[size_start..];
                if !family.is_empty() && !size.is_empty() {
                    return Some((family, size));
                }
            }
        }
    }
    None
}

/// Look up the Ollama tag for an HF repo name. Returns the first match
/// from `OLLAMA_MAPPINGS`, or `None` if the model has no known Ollama equivalent.
fn lookup_ollama_tag(hf_name: &str) -> Option<&'static str> {
    let repo = hf_name
        .split('/')
        .next_back()
        .unwrap_or(hf_name)
        .to_lowercase();
    OLLAMA_MAPPINGS
        .iter()
        .find(|&&(hf_suffix, _)| repo == hf_suffix)
        .map(|&(_, tag)| tag)
}

/// Map a HuggingFace model name to Ollama candidate tags for install checking.
/// Tries the authoritative mapping table first, then falls back to heuristic
/// candidate generation so models without explicit mappings can still be
/// detected as installed.
pub fn hf_name_to_ollama_candidates(hf_name: &str) -> Vec<String> {
    if let Some(tag) = lookup_ollama_tag(hf_name) {
        return vec![tag.to_string()];
    }

    // Fallback: generate candidates from the HF repo name convention.
    // e.g. "Qwen/Qwen3-Coder-30B-A3B-Instruct" → ["qwen3-coder-30b-a3b", "qwen3-coder:30b-a3b", ...]
    let repo = hf_name
        .split('/')
        .next_back()
        .unwrap_or(hf_name)
        .to_lowercase();

    let base = strip_trailing_common_model_suffixes(&repo);

    let mut candidates = Vec::new();

    // Try to split off the size tag (e.g. "qwen3-coder-30b-a3b" → ("qwen3-coder", "30b-a3b"))
    // Ollama uses "name:size" format, so we look for a size-like segment.
    if let Some((name, size)) = split_name_and_size(&base) {
        // "name:size" is the primary Ollama format
        candidates.push(format!("{}:{}", name, size));
        // Also try bare family name (Ollama inserts both into the installed set)
        candidates.push(name.to_string());
    }

    // Also try the full lowered name and stripped name as-is
    candidates.push(base.clone());
    if base != repo {
        candidates.push(repo);
    }

    candidates.dedup();
    candidates
}

/// Returns `true` if this HF model has a known Ollama registry entry
/// and can be pulled.
pub fn has_ollama_mapping(hf_name: &str) -> bool {
    lookup_ollama_tag(hf_name).is_some()
}

fn ollama_installed_matches_candidate(installed_name: &str, candidate: &str) -> bool {
    if installed_name == candidate {
        return true;
    }

    // Allow variant tags reported by `ollama list`, e.g.
    // candidate: "qwen2.5-coder:7b"
    // installed: "qwen2.5-coder:7b-instruct-q4_K_M"
    if candidate.contains(':') {
        return installed_name.starts_with(&format!("{candidate}-"));
    }

    false
}

/// Check if any of the Ollama candidates for an HF model appear in the
/// installed set.
pub fn is_model_installed(hf_name: &str, installed: &HashSet<String>) -> bool {
    // Quick check: the installed set may contain the full HF name (lowercased)
    // from providers that report it verbatim (e.g. MLX server, /api/v1/installed).
    if installed.contains(&hf_name.to_lowercase()) {
        return true;
    }

    let candidates = hf_name_to_ollama_candidates(hf_name);
    candidates.iter().any(|candidate| {
        installed
            .iter()
            .any(|installed_name| ollama_installed_matches_candidate(installed_name, candidate))
    })
}

/// Given an HF model name, return the Ollama tag to use for pulling.
/// Returns `None` if the model has no known Ollama mapping.
pub fn ollama_pull_tag(hf_name: &str) -> Option<String> {
    lookup_ollama_tag(hf_name).map(|s| s.to_string())
}

/// Match a running provider's model tag (an Ollama-style id, or a GGUF file
/// path/stem as reported by llama-server) against an HF-style model name,
/// reusing the installed-column heuristics.
///
/// Two deliberately separate passes: Ollama-style candidate matching runs
/// only against the verbatim id, while file paths (".../gemma-3.Q8_0.gguf")
/// get exact stem matching only — feeding a bare stem into the Ollama
/// candidate heuristics would match whole families.
pub fn tag_matches_model(tag: &str, hf_name: &str) -> bool {
    let lower = tag.to_lowercase();

    let mut tag_set = HashSet::new();
    tag_set.insert(lower.clone());
    if is_model_installed(hf_name, &tag_set) {
        return true;
    }

    let stem = lower
        .rsplit(['/', '\\'])
        .next()
        .unwrap_or(&lower)
        .trim_end_matches(".gguf")
        .to_string();
    let mut stem_set = HashSet::new();
    if let Some(base) = strip_gguf_quant_suffix(&stem) {
        stem_set.insert(base);
    }
    stem_set.insert(stem);
    is_model_installed_llamacpp(hf_name, &stem_set)
}

#[cfg(test)]
mod tests {
    use super::*;

    // Install layouts from issue #731 (Windows, LM Studio + Docker Desktop
    // installed but their servers not running) must be recognized. Expected
    // paths are built with join() so separators stay portable across the
    // 3-OS CI matrix.
    #[test]
    fn test_lmstudio_install_candidates_windows_layouts() {
        let pf = Path::new(r"C:\Program Files");
        let lad = Path::new(r"C:\Users\ben\AppData\Local");
        let home = Path::new(r"C:\Users\ben");
        let candidates = lmstudio_install_candidates(pf.to_str(), lad.to_str(), Some(home));
        // Reporter's per-machine install.
        assert!(candidates.contains(&pf.join("LM Studio").join("LM Studio.exe")));
        // Installer's per-user default.
        assert!(candidates.contains(&lad.join("Programs").join("LM Studio").join("LM Studio.exe")));
        // First-run data dir (any OS).
        assert!(candidates.contains(&home.join(".lmstudio")));
    }

    #[test]
    fn test_lmstudio_install_candidates_unix_layouts() {
        let home = Path::new("/home/ben");
        let candidates = lmstudio_install_candidates(None, None, Some(home));
        assert!(candidates.contains(&PathBuf::from("/Applications/LM Studio.app")));
        assert!(candidates.contains(&home.join("Applications").join("LM Studio.app")));
        assert!(candidates.contains(&home.join(".lmstudio")));
    }

    #[test]
    fn test_docker_desktop_install_candidates_windows_layouts() {
        let pf = Path::new(r"C:\Program Files");
        let home = Path::new(r"C:\Users\ben");
        let candidates = docker_desktop_install_candidates(pf.to_str(), Some(home));
        let docker = pf.join("Docker").join("Docker");
        // Classic exe location.
        assert!(candidates.contains(&docker.join("Docker Desktop.exe")));
        // Reporter's frontend\ layout from newer Docker Desktop releases.
        assert!(candidates.contains(&docker.join("frontend").join("Docker Desktop.exe")));
        assert!(
            candidates.contains(
                &home
                    .join(".docker")
                    .join("cli-plugins")
                    .join("docker-model.exe")
            )
        );
    }

    #[test]
    fn test_docker_desktop_install_candidates_unix_layouts() {
        let home = Path::new("/home/ben");
        let candidates = docker_desktop_install_candidates(None, Some(home));
        assert!(candidates.contains(&PathBuf::from("/Applications/Docker.app")));
        assert!(candidates.contains(&PathBuf::from("/opt/docker-desktop")));
        assert!(candidates.contains(&home.join(".docker").join("desktop")));
        assert!(
            candidates.contains(
                &home
                    .join(".docker")
                    .join("cli-plugins")
                    .join("docker-model")
            )
        );
    }

    #[test]
    fn test_hf_name_to_mlx_candidates() {
        let candidates = hf_name_to_mlx_candidates("meta-llama/Llama-3.1-8B-Instruct");
        assert!(
            candidates
                .iter()
                .any(|c| c.contains("llama-3.1-8b-instruct"))
        );
        assert!(candidates.iter().any(|c| c.ends_with("-4bit")));
        assert!(candidates.iter().any(|c| c.ends_with("-8bit")));

        let qwen = hf_name_to_mlx_candidates("Qwen/Qwen2.5-Coder-14B-Instruct");
        assert!(
            qwen.iter()
                .any(|c| c.contains("qwen2.5-coder-14b-instruct"))
        );
    }

    #[test]
    fn test_hf_name_to_mlx_candidates_qwen35() {
        let candidates = hf_name_to_mlx_candidates("Qwen/Qwen3.5-9B");
        assert!(candidates.iter().any(|c| c == "qwen3.5-9b-4bit"));
        assert!(candidates.iter().any(|c| c == "qwen3.5-9b-8bit"));
    }

    #[test]
    fn test_hf_name_to_mlx_candidates_llama4() {
        let candidates = hf_name_to_mlx_candidates("meta-llama/Llama-4-Scout-17B-16E-Instruct");
        assert!(candidates.iter().any(|c| c.contains("llama-4-scout")));
        assert!(candidates.iter().any(|c| c.ends_with("-4bit")));
    }

    #[test]
    fn test_hf_name_to_mlx_candidates_gemma3() {
        let candidates = hf_name_to_mlx_candidates("google/gemma-3-27b-it");
        assert!(candidates.iter().any(|c| c == "gemma-3-27b-it-4bit"));
        assert!(candidates.iter().any(|c| c == "gemma-3-27b-it-8bit"));
    }

    #[test]
    fn test_hf_name_to_mlx_fallback_generates_mlx_infix_candidates() {
        // For models not in the explicit mapping, the fallback should also
        // generate candidates with the -mlx- infix pattern
        let candidates = hf_name_to_mlx_candidates("SomeOrg/SomeNewModel-7B");
        assert!(candidates.iter().any(|c| c == "somenewmodel-7b-mlx-4bit"));
        assert!(candidates.iter().any(|c| c == "somenewmodel-7b-mlx-8bit"));
    }

    #[test]
    fn test_hf_name_to_mlx_candidates_normalizes_explicit_mlx_repo() {
        let candidates =
            hf_name_to_mlx_candidates("lmstudio-community/Qwen3-Coder-30B-A3B-Instruct-MLX-8bit");

        assert!(
            candidates
                .contains(&"lmstudio-community/qwen3-coder-30b-a3b-instruct-mlx-8bit".to_string())
        );
        assert!(candidates.contains(&"qwen3-coder-30b-a3b-instruct-4bit".to_string()));
        assert!(candidates.contains(&"qwen3-coder-30b-a3b-instruct-8bit".to_string()));
        assert!(!candidates.iter().any(|c| c.contains("-8bit-4bit")));
        assert!(!candidates.iter().any(|c| c.contains("-8bit-8bit")));
    }

    #[test]
    fn test_mlx_pull_tag_prefers_explicit_repo_id() {
        let tag = mlx_pull_tag("lmstudio-community/Qwen3-Coder-30B-A3B-Instruct-MLX-8bit");
        assert_eq!(
            tag,
            "lmstudio-community/qwen3-coder-30b-a3b-instruct-mlx-8bit"
        );
    }

    #[test]
    fn test_mlx_cache_scan_parsing() {
        // Test that the candidate matching works with cache-style names
        let mut installed = HashSet::new();
        installed.insert("llama-3.1-8b-instruct-4bit".to_string());

        assert!(is_model_installed_mlx(
            "meta-llama/Llama-3.1-8B-Instruct",
            &installed
        ));
        // Should not match unrelated model
        assert!(!is_model_installed_mlx(
            "Qwen/Qwen2.5-7B-Instruct",
            &installed
        ));
    }

    #[test]
    fn test_is_model_installed_mlx() {
        let mut installed = HashSet::new();
        installed.insert("qwen2.5-coder-14b-instruct-8bit".to_string());

        assert!(is_model_installed_mlx(
            "Qwen/Qwen2.5-Coder-14B-Instruct",
            &installed
        ));
        assert!(!is_model_installed_mlx(
            "Qwen/Qwen2.5-14B-Instruct",
            &installed
        ));
    }

    #[test]
    fn test_lmstudio_pull_tag_resolves_gguf_url() {
        // HF repo IDs resolve through GGUF verification but must come back
        // as the repo URL form: LM Studio 0.4.20 rejects direct .gguf links
        // with HTTP 400 "Invalid HuggingFace model URL format".
        // Returns None when no GGUF file can be found (no fallback to bare repo URL).
        if let Some(tag) = lmstudio_pull_tag("deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct") {
            assert!(
                tag.starts_with("https://huggingface.co/")
                    && !tag.contains("/resolve/")
                    && !tag.ends_with(".gguf")
            );
        }
    }

    #[test]
    fn test_lmstudio_repo_url_from_gguf_url() {
        assert_eq!(
            lmstudio_repo_url_from_gguf_url(
                "https://huggingface.co/bartowski/SmolLM2-135M-Instruct-GGUF/resolve/main/SmolLM2-135M-Instruct-Q4_K_M.gguf"
            ),
            "https://huggingface.co/bartowski/SmolLM2-135M-Instruct-GGUF"
        );
        // URLs without a /resolve/ segment pass through unchanged.
        assert_eq!(
            lmstudio_repo_url_from_gguf_url("https://huggingface.co/org/repo"),
            "https://huggingface.co/org/repo"
        );
    }

    #[test]
    fn test_lmstudio_pull_tag_no_gguf_returns_none() {
        // A repo with no GGUF files should return None, not a bare HF URL
        // that LM Studio would reject with 404.
        let result = lmstudio_pull_tag("some-org/safetensors-only-model");
        assert!(
            result.is_none(),
            "expected None for repo without GGUF files, got: {:?}",
            result
        );
    }

    #[test]
    fn test_lmstudio_pull_tag_passes_through_full_url() {
        let url = "https://huggingface.co/lmstudio-community/deepseek-coder-v2-lite-instruct-gguf";
        assert_eq!(lmstudio_pull_tag(url).unwrap(), url);

        let http = "http://example.com/some/model";
        assert_eq!(lmstudio_pull_tag(http).unwrap(), http);
    }

    #[test]
    fn test_lmstudio_pull_tag_leaves_catalog_short_name_unchanged() {
        // No slash → assumed to be an LM Studio first-party catalog entry.
        assert_eq!(lmstudio_pull_tag("llama-3.1-8b").unwrap(), "llama-3.1-8b");
    }

    #[test]
    fn test_lmstudio_pull_tag_empty_returns_none() {
        assert!(lmstudio_pull_tag("").is_none());
    }

    #[test]
    fn test_lmstudio_pull_tag_is_idempotent() {
        // A resolved URL must be safe to apply twice — start_pull and the TUI
        // both route through the same resolver.
        if let Some(once) = lmstudio_pull_tag("Qwen/Qwen2.5-7B-Instruct") {
            let twice = lmstudio_pull_tag(&once).unwrap();
            assert_eq!(once, twice);
        }
        // Catalog short names are always idempotent
        let once = lmstudio_pull_tag("llama-3.1-8b").unwrap();
        let twice = lmstudio_pull_tag(&once).unwrap();
        assert_eq!(once, twice);
    }

    #[test]
    fn test_lmstudio_gguf_resolve_url_format() {
        let url = lmstudio_gguf_resolve_url(
            "lmstudio-community/DeepSeek-Coder-V2-Lite-Instruct-GGUF",
            "DeepSeek-Coder-V2-Lite-Instruct-Q6_K.gguf",
        );
        assert_eq!(
            url,
            "https://huggingface.co/lmstudio-community/DeepSeek-Coder-V2-Lite-Instruct-GGUF/resolve/main/DeepSeek-Coder-V2-Lite-Instruct-Q6_K.gguf"
        );
    }

    #[test]
    fn test_hf_name_to_lmstudio_candidates_full_repo() {
        let candidates = hf_name_to_lmstudio_candidates("lmstudio-community/Qwen3-1.7B-GGUF");
        assert!(candidates.contains(&"lmstudio-community/qwen3-1.7b-gguf".to_string()));
        assert!(candidates.contains(&"qwen3-1.7b-gguf".to_string()));
    }

    #[test]
    fn test_hf_name_to_lmstudio_candidates_strips_suffixes() {
        let candidates = hf_name_to_lmstudio_candidates("meta-llama/Llama-3-8B-Instruct");
        assert!(candidates.contains(&"meta-llama/llama-3-8b-instruct".to_string()));
        assert!(candidates.contains(&"llama-3-8b-instruct".to_string()));
        // Stripped variant (without -instruct)
        assert!(candidates.contains(&"llama-3-8b".to_string()));
    }

    #[test]
    fn test_hf_name_to_lmstudio_candidates_bare_name() {
        let candidates = hf_name_to_lmstudio_candidates("qwen3");
        assert!(candidates.contains(&"qwen3".to_string()));
        // No slash, so repo == full name — no duplicate
        assert_eq!(candidates.len(), 1);
    }

    #[test]
    fn test_lmstudio_api_key_filtering() {
        // Test the api_key filtering logic without mutating the process
        // environment. LmStudioProvider::default() applies
        // `.filter(|k| !k.is_empty())` to the env var value.
        fn filter_key(val: Option<&str>) -> Option<String> {
            val.map(String::from).filter(|k| !k.is_empty())
        }

        // Missing env var → None
        assert!(filter_key(None).is_none());
        // Real value → Some
        assert_eq!(
            filter_key(Some("my-secret-key")),
            Some("my-secret-key".to_string())
        );
        // Empty string → None (must not produce Some(""))
        assert!(filter_key(Some("")).is_none());
    }

    #[test]
    fn test_lmstudio_download_status_url_formats_base_urls() {
        assert_eq!(
            lmstudio_download_status_url("http://127.0.0.1:1234", "abc123"),
            "http://127.0.0.1:1234/api/v1/models/download/status/abc123"
        );

        assert_eq!(
            lmstudio_download_status_url("http://lmstudio.example.test:4321/", "job-42"),
            "http://lmstudio.example.test:4321/api/v1/models/download/status/job-42"
        );
    }

    #[test]
    fn test_lmstudio_download_response_parses_optional_job_id() {
        let with_job: LmStudioDownloadResponse =
            serde_json::from_str(r#"{"job_id":"abc123","status":"download_started"}"#).unwrap();
        assert_eq!(lmstudio_response_job_id(&with_job), Some("abc123"));

        let without_job: LmStudioDownloadResponse =
            serde_json::from_str(r#"{"status":"download_started"}"#).unwrap();
        assert_eq!(lmstudio_response_job_id(&without_job), None);
    }

    #[test]
    fn test_lmstudio_download_status_percent_and_terminal_mapping() {
        let progress: LmStudioDownloadStatus =
            serde_json::from_str(r#"{"status":"downloading","progress":0.42}"#).unwrap();
        assert_eq!(lmstudio_download_status_percent(&progress), Some(42.0));
        assert_eq!(lmstudio_download_terminal_status(&progress.status), None);

        assert_eq!(
            lmstudio_download_terminal_status("completed"),
            Some(LmStudioDownloadTerminalStatus::Done)
        );
        assert_eq!(
            lmstudio_download_terminal_status("already_downloaded"),
            Some(LmStudioDownloadTerminalStatus::Done)
        );
        assert_eq!(
            lmstudio_download_terminal_status("failed"),
            Some(LmStudioDownloadTerminalStatus::Failed)
        );

        let mut empty_statuses = 0;
        for expected in [false, false, true] {
            assert_eq!(
                lmstudio_empty_status_limit_reached("", &mut empty_statuses),
                expected
            );
        }
    }

    #[test]
    fn test_lmstudio_status_poll_error_falls_back_without_error() {
        let (tx, rx) = std::sync::mpsc::channel();
        let mut poll_budget = 1;
        let result = poll_lmstudio_download_status(
            "http://127.0.0.1:1/api/v1/models/download/status/abc123",
            None,
            &tx,
            std::time::Duration::from_millis(0),
            &mut poll_budget,
        );

        assert_eq!(result, LmStudioStatusPollResult::Fallback);
        assert_eq!(poll_budget, 0);
        assert!(
            !rx.try_iter()
                .any(|event| matches!(event, PullEvent::Error(_))),
            "status polling errors must fall back instead of emitting an error"
        );
    }

    #[test]
    fn test_is_model_installed_mlx_with_owner_prefixed_repo_id() {
        let mut installed = HashSet::new();
        installed.insert("lmstudio-community/qwen3-coder-30b-a3b-instruct-mlx-8bit".to_string());

        assert!(is_model_installed_mlx(
            "lmstudio-community/Qwen3-Coder-30B-A3B-Instruct-MLX-8bit",
            &installed
        ));
    }

    #[test]
    fn test_qwen_coder_14b_matches_coder_entry() {
        // "qwen2.5-coder:14b" from `ollama list` should match
        // the HF entry "Qwen/Qwen2.5-Coder-14B-Instruct", NOT
        // the base "Qwen/Qwen2.5-14B-Instruct".
        let mut installed = HashSet::new();
        installed.insert("qwen2.5-coder:14b".to_string());
        installed.insert("qwen2.5-coder".to_string());

        assert!(is_model_installed(
            "Qwen/Qwen2.5-Coder-14B-Instruct",
            &installed
        ));
        // Must NOT match the non-coder model
        assert!(!is_model_installed("Qwen/Qwen2.5-14B-Instruct", &installed));
    }

    #[test]
    fn test_qwen_base_does_not_match_coder() {
        // "qwen2.5:14b" from `ollama list` should match the base model,
        // not the coder variant.
        let mut installed = HashSet::new();
        installed.insert("qwen2.5:14b".to_string());
        installed.insert("qwen2.5".to_string());

        assert!(is_model_installed("Qwen/Qwen2.5-14B-Instruct", &installed));
        assert!(!is_model_installed(
            "Qwen/Qwen2.5-Coder-14B-Instruct",
            &installed
        ));
    }

    #[test]
    fn test_installed_variant_suffix_matches_ollama_candidate() {
        // Real-world `ollama list` may include variant suffixes that still map
        // to the canonical pull tag in OLLAMA_MAPPINGS.
        let mut installed = HashSet::new();
        installed.insert("qwen2.5-coder:7b-instruct".to_string());

        assert!(is_model_installed(
            "Qwen/Qwen2.5-Coder-7B-Instruct",
            &installed
        ));
    }

    #[test]
    fn test_candidates_for_coder_model() {
        let candidates = hf_name_to_ollama_candidates("Qwen/Qwen2.5-Coder-14B-Instruct");
        assert!(candidates.contains(&"qwen2.5-coder:14b".to_string()));
    }

    #[test]
    fn test_candidates_for_base_model() {
        let candidates = hf_name_to_ollama_candidates("Qwen/Qwen2.5-14B-Instruct");
        assert!(candidates.contains(&"qwen2.5:14b".to_string()));
    }

    #[test]
    fn test_llama_mapping() {
        let candidates = hf_name_to_ollama_candidates("meta-llama/Llama-3.1-8B-Instruct");
        assert!(candidates.contains(&"llama3.1:8b".to_string()));
    }

    #[test]
    fn test_deepseek_coder_mapping() {
        let candidates =
            hf_name_to_ollama_candidates("deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct");
        assert!(candidates.contains(&"deepseek-coder-v2:16b".to_string()));
    }

    #[test]
    fn test_normalize_ollama_host_with_scheme() {
        assert_eq!(
            normalize_ollama_host("https://ollama.example.com:11434"),
            Some("https://ollama.example.com:11434".to_string())
        );
    }

    #[test]
    fn test_normalize_ollama_host_without_scheme() {
        assert_eq!(
            normalize_ollama_host("ollama.example.com:11434"),
            Some("http://ollama.example.com:11434".to_string())
        );
    }

    #[test]
    fn test_normalize_ollama_host_rejects_unsupported_scheme() {
        assert_eq!(
            normalize_ollama_host("ftp://ollama.example.com:11434"),
            None
        );
    }

    #[test]
    fn test_is_wildcard_bind_address_ipv4() {
        assert!(is_wildcard_bind_address("0.0.0.0"));
        assert!(is_wildcard_bind_address("0.0.0.0:11434"));
        assert!(is_wildcard_bind_address("http://0.0.0.0"));
        assert!(is_wildcard_bind_address("http://0.0.0.0:11434"));
        assert!(is_wildcard_bind_address("https://0.0.0.0:11434"));
        assert!(is_wildcard_bind_address("http://0.0.0.0:11434/api/tags"));
    }

    #[test]
    fn test_is_wildcard_bind_address_ipv6() {
        assert!(is_wildcard_bind_address("[::]"));
        assert!(is_wildcard_bind_address("[::]:11434"));
        assert!(is_wildcard_bind_address("http://[::]:11434"));
        assert!(is_wildcard_bind_address("http://[0:0:0:0:0:0:0:0]:11434"));
    }

    #[test]
    fn test_is_wildcard_bind_address_rejects_routable_hosts() {
        assert!(!is_wildcard_bind_address("localhost"));
        assert!(!is_wildcard_bind_address("http://localhost:11434"));
        assert!(!is_wildcard_bind_address("127.0.0.1"));
        assert!(!is_wildcard_bind_address("http://127.0.0.1:11434"));
        assert!(!is_wildcard_bind_address("http://[::1]:11434"));
        assert!(!is_wildcard_bind_address("http://ollama.example.com:11434"));
        // Hostnames or IPs that merely contain "0.0.0.0" as a substring must not match.
        assert!(!is_wildcard_bind_address("http://10.0.0.0.example.com"));
        assert!(!is_wildcard_bind_address("http://10.0.0.1:11434"));
    }

    #[test]
    fn test_validate_gguf_filename_valid() {
        assert!(validate_gguf_filename("Llama-3.1-8B-Q4_K_M.gguf").is_ok());
        assert!(validate_gguf_filename("model.gguf").is_ok());
    }

    #[test]
    fn test_validate_gguf_filename_traversal() {
        assert!(validate_gguf_filename("../../outside.gguf").is_err());
        assert!(validate_gguf_filename("../evil.gguf").is_err());
        assert!(validate_gguf_filename("foo/../bar.gguf").is_err());
    }

    #[test]
    fn test_validate_gguf_filename_absolute() {
        assert!(validate_gguf_filename("/etc/passwd").is_err());
        assert!(validate_gguf_filename("/tmp/model.gguf").is_err());
    }

    #[test]
    fn test_validate_gguf_filename_bad_extension() {
        assert!(validate_gguf_filename("malware.exe").is_err());
        assert!(validate_gguf_filename("script.sh").is_err());
        assert!(validate_gguf_filename("./model.guuf").is_err());
    }

    #[test]
    fn test_validate_gguf_filename_empty() {
        assert!(validate_gguf_filename("").is_err());
    }

    #[test]
    fn test_validate_gguf_filename_subdirectory() {
        assert!(validate_gguf_filename("subdir/model.gguf").is_err());
    }

    #[test]
    fn test_validate_gguf_filename_rejects_non_basename_forms() {
        assert!(validate_gguf_filename("./model.gguf").is_err());
        assert!(validate_gguf_filename("model.gguf/").is_err());
        assert!(validate_gguf_filename(".\\model.gguf").is_err());
        assert!(validate_gguf_filename("C:/models/model.gguf").is_err());
        assert!(validate_gguf_filename("C:\\models\\model.gguf").is_err());
    }

    // ── validate_gguf_repo_path ────────────────────────────────────

    #[test]
    fn test_validate_gguf_repo_path_valid() {
        assert!(validate_gguf_repo_path("model.gguf").is_ok());
        assert!(validate_gguf_repo_path("Q4_K_M/model.gguf").is_ok());
        assert!(validate_gguf_repo_path("deep/nested/model.gguf").is_ok());
    }

    #[test]
    fn test_validate_gguf_repo_path_rejects_traversal() {
        assert!(validate_gguf_repo_path("../escape.gguf").is_err());
        assert!(validate_gguf_repo_path("foo/../bar.gguf").is_err());
        assert!(validate_gguf_repo_path("./model.gguf").is_err());
    }

    #[test]
    fn test_validate_gguf_repo_path_rejects_absolute() {
        assert!(validate_gguf_repo_path("/etc/passwd").is_err());
        assert!(validate_gguf_repo_path("/tmp/model.gguf").is_err());
    }

    #[test]
    fn test_validate_gguf_repo_path_rejects_backslash() {
        assert!(validate_gguf_repo_path("dir\\model.gguf").is_err());
        assert!(validate_gguf_repo_path("C:\\models\\model.gguf").is_err());
    }

    #[test]
    fn test_validate_gguf_repo_path_rejects_non_gguf() {
        assert!(validate_gguf_repo_path("malware.exe").is_err());
        assert!(validate_gguf_repo_path("subdir/readme.md").is_err());
    }

    #[test]
    fn test_validate_gguf_repo_path_rejects_empty() {
        assert!(validate_gguf_repo_path("").is_err());
    }

    #[test]
    fn test_parse_repo_gguf_entries_filters_unsafe_paths() {
        let entries = vec![
            serde_json::json!({"path": "good.gguf", "size": 123u64}),
            serde_json::json!({"path": "../escape.gguf", "size": 456u64}),
            serde_json::json!({"path": "nested/model.gguf", "size": 789u64}),
            serde_json::json!({"path": "./model.gguf", "size": 99u64}),
            serde_json::json!({"path": "readme.md", "size": 12u64}),
        ];

        let files = parse_repo_gguf_entries(entries);
        assert_eq!(
            files,
            vec![
                ("good.gguf".to_string(), 123u64),
                ("nested/model.gguf".to_string(), 789u64),
            ]
        );
    }

    // ────────────────────────────────────────────────────────────────────
    // GGUF candidate generation tests
    // ────────────────────────────────────────────────────────────────────

    #[test]
    fn test_hf_name_to_gguf_candidates_generates_common_patterns() {
        // Use a model without a hardcoded mapping to test heuristic generation
        let candidates = hf_name_to_gguf_candidates("SomeOrg/Cool-Model-7B");
        assert!(
            candidates
                .iter()
                .any(|c| c == "bartowski/Cool-Model-7B-GGUF"),
            "Should generate bartowski candidate, got: {:?}",
            candidates
        );
        assert!(
            candidates
                .iter()
                .any(|c| c == "ggml-org/Cool-Model-7B-GGUF"),
            "Should generate ggml-org candidate, got: {:?}",
            candidates
        );
        assert!(
            candidates
                .iter()
                .any(|c| c == "TheBloke/Cool-Model-7B-GGUF"),
            "Should generate TheBloke candidate, got: {:?}",
            candidates
        );
    }

    #[test]
    fn test_hf_name_to_gguf_candidates_strips_owner() {
        // Should use the model name part, not the full "owner/name"
        let candidates = hf_name_to_gguf_candidates("Qwen/Qwen2.5-7B-Instruct");
        for c in &candidates {
            assert!(
                !c.contains("Qwen/Qwen"),
                "Candidate should not contain original owner prefix: {}",
                c
            );
        }
    }

    #[test]
    fn test_lookup_gguf_repo_known_mappings() {
        // Models with hardcoded mappings should be found
        assert!(lookup_gguf_repo("meta-llama/Llama-3.1-8B-Instruct").is_some());
        assert!(lookup_gguf_repo("deepseek-r1").is_some());
    }

    #[test]
    fn test_lookup_gguf_repo_unknown_returns_none() {
        assert!(lookup_gguf_repo("totally-unknown/model-xyz").is_none());
    }

    #[test]
    fn test_has_gguf_mapping_matches_known_models() {
        assert!(has_gguf_mapping("meta-llama/Llama-3.1-8B-Instruct"));
        assert!(!has_gguf_mapping("some-random/UnknownModel"));
    }

    #[test]
    fn test_gguf_candidates_fallback_covers_major_providers() {
        // For a model without a hardcoded mapping, candidates should cover
        // the major GGUF providers
        let candidates = hf_name_to_gguf_candidates("SomeOrg/NewModel-7B");
        assert!(candidates.iter().any(|c| c.starts_with("bartowski/")));
        assert!(candidates.iter().any(|c| c.starts_with("ggml-org/")));
        assert!(candidates.iter().any(|c| c.starts_with("TheBloke/")));
        assert!(candidates.iter().all(|c| c.ends_with("-GGUF")));
    }

    #[test]
    fn test_gguf_candidates_known_mapping_returns_single() {
        // Models with a hardcoded mapping should return just that repo
        let candidates = hf_name_to_gguf_candidates("meta-llama/Llama-3.1-8B-Instruct");
        assert_eq!(candidates.len(), 1);
        assert!(candidates[0].contains("GGUF"));
    }

    // ── select_best_gguf ─────────────────────────────────────────────

    #[test]
    fn test_select_best_gguf_prefers_higher_quality() {
        let files = vec![
            ("model-Q2_K.gguf".to_string(), 2_000_000_000u64),
            ("model-Q4_K_M.gguf".to_string(), 4_000_000_000u64),
            ("model-Q8_0.gguf".to_string(), 8_000_000_000u64),
        ];
        let result = LlamaCppProvider::select_best_gguf(&files, 10.0);
        assert!(result.is_some());
        let (name, _) = result.unwrap();
        assert!(name.contains("Q8_0"), "should prefer Q8, got: {}", name);
    }

    #[test]
    fn test_select_best_gguf_respects_budget() {
        let files = vec![
            ("model-Q2_K.gguf".to_string(), 2_000_000_000u64),
            ("model-Q4_K_M.gguf".to_string(), 4_000_000_000u64),
            ("model-Q8_0.gguf".to_string(), 8_000_000_000u64),
        ];
        // Budget ~3.7GB → Q2_K fits
        let result = LlamaCppProvider::select_best_gguf(&files, 3.7);
        assert!(result.is_some());
        let (name, _) = result.unwrap();
        assert!(
            name.contains("Q2_K"),
            "should select Q2_K for 3.7GB budget, got: {}",
            name
        );
    }

    #[test]
    fn test_select_best_gguf_nothing_fits() {
        let files = vec![("model-Q2_K.gguf".to_string(), 8_000_000_000u64)];
        let result = LlamaCppProvider::select_best_gguf(&files, 1.0);
        assert!(result.is_none());
    }

    #[test]
    fn test_select_best_gguf_prefers_shard_group_over_lower_quant() {
        // A complete Q4_K_M shard set should beat a non-shard Q2_K when both
        // fit in the budget (Q4 > Q2 in the preference order).
        let files = vec![
            (
                "model-Q4_K_M-00001-of-00003.gguf".to_string(),
                4_000_000_000u64,
            ),
            (
                "model-Q4_K_M-00002-of-00003.gguf".to_string(),
                4_000_000_000u64,
            ),
            (
                "model-Q4_K_M-00003-of-00003.gguf".to_string(),
                4_000_000_000u64,
            ),
            ("model-Q2_K.gguf".to_string(), 2_000_000_000u64),
        ];
        let (name, size) = LlamaCppProvider::select_best_gguf(&files, 16.0).unwrap();
        assert!(name.contains("Q4_K_M-00001-of-00003"), "got: {}", name);
        assert_eq!(size, 12_000_000_000u64);
    }

    #[test]
    fn test_select_best_gguf_empty_list() {
        let result = LlamaCppProvider::select_best_gguf(&[], 10.0);
        assert!(result.is_none());
    }

    // ── parse_shard_info smoke checks ────────────────────────────────

    #[test]
    fn test_parse_shard_info_smoke() {
        assert!(parse_shard_info("model-00001-of-00003.gguf").is_some());
        assert!(parse_shard_info("model-Q4_K_M.gguf").is_none());
        assert!(parse_shard_info("model.gguf").is_none());
    }

    // ── parse_shard_info ─────────────────────────────────────────────

    #[test]
    fn test_parse_shard_info_basic() {
        assert_eq!(
            parse_shard_info("Qwen3-Coder-Next-Q5_K_M-00001-of-00003.gguf"),
            Some((1, 3))
        );
        assert_eq!(
            parse_shard_info("Q5_K_M/Qwen3-Coder-Next-Q5_K_M-00003-of-00003.gguf"),
            Some((3, 3))
        );
    }

    #[test]
    fn test_parse_shard_info_rejects_non_shards() {
        assert_eq!(parse_shard_info("model.gguf"), None);
        assert_eq!(parse_shard_info("model-Q4_K_M.gguf"), None);
        // "of" without trailing digits
        assert_eq!(parse_shard_info("model-of-tea.gguf"), None);
        // wrong extension
        assert_eq!(parse_shard_info("model-00001-of-00003.bin"), None);
        // index out of range
        assert_eq!(parse_shard_info("model-00004-of-00003.gguf"), None);
        // index zero
        assert_eq!(parse_shard_info("model-00000-of-00003.gguf"), None);
    }

    // ── collect_shard_set ────────────────────────────────────────────

    #[test]
    fn test_collect_shard_set_returns_all_shards_sorted() {
        let files = vec![
            (
                "Q5_K_M/Qwen3-Coder-Next-Q5_K_M-00002-of-00003.gguf".to_string(),
                3_000_000_000u64,
            ),
            (
                "Q5_K_M/Qwen3-Coder-Next-Q5_K_M-00001-of-00003.gguf".to_string(),
                3_000_000_000u64,
            ),
            (
                "Q5_K_M/Qwen3-Coder-Next-Q5_K_M-00003-of-00003.gguf".to_string(),
                2_500_000_000u64,
            ),
            // Unrelated file in the same listing
            (
                "Q4_K_M/Qwen3-Coder-Next-Q4_K_M.gguf".to_string(),
                4_000_000_000u64,
            ),
        ];
        let shards =
            collect_shard_set(&files, "Q5_K_M/Qwen3-Coder-Next-Q5_K_M-00001-of-00003.gguf")
                .expect("should detect shard set");
        assert_eq!(shards.len(), 3);
        assert!(shards[0].0.contains("00001-of-00003"));
        assert!(shards[1].0.contains("00002-of-00003"));
        assert!(shards[2].0.contains("00003-of-00003"));
    }

    #[test]
    fn test_collect_shard_set_returns_none_for_non_shard() {
        let files = vec![("model-Q4_K_M.gguf".to_string(), 4_000_000_000u64)];
        assert!(collect_shard_set(&files, "model-Q4_K_M.gguf").is_none());
    }

    #[test]
    fn test_collect_shard_set_does_not_mix_groups() {
        // Two distinct shard groups in the same repo (different quants).
        let files = vec![
            ("Q4_K_M/m-Q4_K_M-00001-of-00002.gguf".to_string(), 1_000),
            ("Q4_K_M/m-Q4_K_M-00002-of-00002.gguf".to_string(), 1_000),
            ("Q5_K_M/m-Q5_K_M-00001-of-00003.gguf".to_string(), 2_000),
            ("Q5_K_M/m-Q5_K_M-00002-of-00003.gguf".to_string(), 2_000),
            ("Q5_K_M/m-Q5_K_M-00003-of-00003.gguf".to_string(), 2_000),
        ];
        let q4 = collect_shard_set(&files, "Q4_K_M/m-Q4_K_M-00001-of-00002.gguf").unwrap();
        assert_eq!(q4.len(), 2);
        let q5 = collect_shard_set(&files, "Q5_K_M/m-Q5_K_M-00002-of-00003.gguf").unwrap();
        assert_eq!(q5.len(), 3);
    }

    // ── select_best_gguf shard awareness ─────────────────────────────

    #[test]
    fn test_select_best_gguf_picks_shard_group() {
        // Repo only has a Q5_K_M shard set; it should be selected (and the
        // returned size should be the sum of all shards).
        let files = vec![
            (
                "Q5_K_M/m-Q5_K_M-00001-of-00003.gguf".to_string(),
                3_000_000_000u64,
            ),
            (
                "Q5_K_M/m-Q5_K_M-00002-of-00003.gguf".to_string(),
                3_000_000_000u64,
            ),
            (
                "Q5_K_M/m-Q5_K_M-00003-of-00003.gguf".to_string(),
                2_000_000_000u64,
            ),
        ];
        let (path, size) = LlamaCppProvider::select_best_gguf(&files, 16.0)
            .expect("shard group should be selectable");
        assert!(path.contains("00001-of-00003"), "got: {}", path);
        assert_eq!(size, 8_000_000_000u64);
    }

    #[test]
    fn test_select_best_gguf_shard_group_respects_budget() {
        let files = vec![
            (
                "Q5_K_M/m-Q5_K_M-00001-of-00003.gguf".to_string(),
                3_000_000_000u64,
            ),
            (
                "Q5_K_M/m-Q5_K_M-00002-of-00003.gguf".to_string(),
                3_000_000_000u64,
            ),
            (
                "Q5_K_M/m-Q5_K_M-00003-of-00003.gguf".to_string(),
                2_000_000_000u64,
            ),
            ("Q2_K/m-Q2_K.gguf".to_string(), 1_500_000_000u64),
        ];
        // 4GB budget: shard group (8GB) doesn't fit, Q2_K does.
        let (path, _) = LlamaCppProvider::select_best_gguf(&files, 4.0).unwrap();
        assert!(path.contains("Q2_K") && !path.contains("-of-"));
    }

    // ── urlencoding ──────────────────────────────────────────────────

    #[test]
    fn test_urlencoding_ascii() {
        assert_eq!(urlencoding::encode("hello"), "hello");
        assert_eq!(urlencoding::encode("test-model_v1.0"), "test-model_v1.0");
    }

    #[test]
    fn test_urlencoding_special_chars() {
        assert_eq!(urlencoding::encode("hello world"), "hello%20world");
        assert_eq!(urlencoding::encode("a+b"), "a%2Bb");
        assert_eq!(urlencoding::encode("foo/bar"), "foo%2Fbar");
    }

    #[test]
    fn test_urlencoding_empty() {
        assert_eq!(urlencoding::encode(""), "");
    }

    // ── is_model_installed_llamacpp ──────────────────────────────────

    #[test]
    fn test_is_model_installed_llamacpp_exact() {
        let mut installed = HashSet::new();
        installed.insert("llama-3.1-8b-instruct".to_string());
        assert!(is_model_installed_llamacpp(
            "meta-llama/Llama-3.1-8B-Instruct",
            &installed
        ));
    }

    #[test]
    fn test_is_model_installed_llamacpp_stripped_suffixes() {
        let mut installed = HashSet::new();
        installed.insert("llama-3.1-8b".to_string());
        assert!(is_model_installed_llamacpp(
            "meta-llama/Llama-3.1-8B-Instruct",
            &installed
        ));
    }

    #[test]
    fn test_strip_gguf_quant_suffix_unsloth_ud_marker() {
        // Unsloth "Dynamic" GGUFs carry a `-ud` marker before the quant; it
        // must be stripped alongside the quant so the stem reduces to the
        // canonical model name.
        assert_eq!(
            strip_gguf_quant_suffix("qwen3.6-35b-a3b-ud-q4_k_m").as_deref(),
            Some("qwen3.6-35b-a3b")
        );
        // Non-Unsloth files are unaffected.
        assert_eq!(
            strip_gguf_quant_suffix("qwen2.5-7b-instruct-q4_k_m").as_deref(),
            Some("qwen2.5-7b-instruct")
        );
    }

    #[test]
    fn test_is_model_installed_llamacpp_unsloth_ud() {
        // `Qwen3.6-35B-A3B-UD-Q4_K_M.gguf` on disk yields these set entries
        // (see `installed_models_counted`) and must mark the catalog model as
        // installed despite the embedded `-ud` marker.
        let installed: HashSet<String> = ["qwen3.6-35b-a3b-ud-q4_k_m", "qwen3.6-35b-a3b"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        assert!(is_model_installed_llamacpp(
            "Qwen/Qwen3.6-35B-A3B",
            &installed
        ));
    }

    #[test]
    fn test_tag_matches_model_unsloth_ud_gguf() {
        // End-to-end: a llama-server serving an Unsloth UD GGUF reports the
        // file name as the model id; it must match the catalog HF name so the
        // model is benchmarkable (regression: `-ud` broke the exact stem match).
        assert!(tag_matches_model(
            "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf",
            "Qwen/Qwen3.6-35B-A3B"
        ));
    }

    #[test]
    fn test_is_model_installed_llamacpp_not_installed() {
        let installed = HashSet::new();
        assert!(!is_model_installed_llamacpp(
            "meta-llama/Llama-3.1-8B-Instruct",
            &installed
        ));
    }

    #[test]
    fn test_is_model_installed_llamacpp_no_family_false_positives() {
        // A single "gemma-3.Q8_0.gguf" on disk yields these stems — it must
        // mark ONLY repos actually named "gemma-3" as installed, not the
        // whole gemma-3 family (regression: substring matching ticked every
        // gemma-3-* model in the table).
        let installed: HashSet<String> = ["gemma-3.q8_0", "gemma-3"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        assert!(is_model_installed_llamacpp(
            "tiny-random/gemma-3",
            &installed
        ));
        assert!(!is_model_installed_llamacpp(
            "google/gemma-3-27b-it",
            &installed
        ));
        assert!(!is_model_installed_llamacpp(
            "google/gemma-3-4b-it",
            &installed
        ));
        assert!(!is_model_installed_llamacpp(
            "unsloth/gemma-3-270m-it",
            &installed
        ));
    }

    // ── gguf_pull_tag ────────────────────────────────────────────────

    #[test]
    fn test_gguf_pull_tag_known() {
        let tag = gguf_pull_tag("meta-llama/Llama-3.1-8B-Instruct");
        assert!(tag.is_some());
        assert!(tag.unwrap().contains("GGUF"));
    }

    #[test]
    fn test_gguf_pull_tag_unknown() {
        assert!(gguf_pull_tag("totally-unknown/model-xyz").is_none());
    }

    // ── has_ollama_mapping ───────────────────────────────────────────

    #[test]
    fn test_has_ollama_mapping_known() {
        assert!(has_ollama_mapping("meta-llama/Llama-3.1-8B-Instruct"));
        assert!(has_ollama_mapping("Qwen/Qwen2.5-7B-Instruct"));
    }

    #[test]
    fn test_has_ollama_mapping_unknown() {
        assert!(!has_ollama_mapping("totally-unknown/model-xyz"));
    }

    // ── ollama_pull_tag ──────────────────────────────────────────────

    #[test]
    fn test_ollama_pull_tag_known() {
        let tag = ollama_pull_tag("meta-llama/Llama-3.1-8B-Instruct");
        assert_eq!(tag, Some("llama3.1:8b".to_string()));
    }

    #[test]
    fn test_ollama_pull_tag_unknown() {
        assert!(ollama_pull_tag("totally-unknown/model-xyz").is_none());
    }

    // ── mlx_pull_tag ─────────────────────────────────────────────────

    #[test]
    fn test_mlx_pull_tag_prefers_4bit() {
        let tag = mlx_pull_tag("meta-llama/Llama-3.1-8B-Instruct");
        assert!(tag.ends_with("-4bit"), "should prefer 4bit, got: {}", tag);
    }

    #[test]
    fn test_mlx_pull_tag_fallback() {
        let tag = mlx_pull_tag("SomeUnknown/Model-7B");
        assert!(!tag.is_empty());
    }

    // ── resolve_mlx_fallback_repo (issue #294) ───────────────────────

    #[test]
    fn test_resolve_mlx_explicit_repo_passes_through_unverified() {
        // An explicit owner/name is trusted as typed — no existence probe.
        let repo = resolve_mlx_fallback_repo("mlx-community/Qwen3-8B-4bit", &|_: &str| {
            panic!("explicit repo ids must not be probed")
        });
        assert_eq!(repo.unwrap(), "mlx-community/Qwen3-8B-4bit");
    }

    #[test]
    fn test_resolve_mlx_fallback_verified_when_repo_exists() {
        let repo = resolve_mlx_fallback_repo("qwen3-8b-4bit", &|r: &str| {
            r == "mlx-community/qwen3-8b-4bit"
        });
        assert_eq!(repo.unwrap(), "mlx-community/qwen3-8b-4bit");
    }

    #[test]
    fn test_resolve_mlx_fallback_errors_when_repo_missing() {
        let err = resolve_mlx_fallback_repo("qwen3-30b-a3b-instruct-2507-4bit", &|_: &str| false)
            .unwrap_err();
        assert!(
            err.contains("mlx-community/qwen3-30b-a3b-instruct-2507-4bit"),
            "error should name the guessed repo, got: {err}"
        );
    }

    #[test]
    fn test_resolve_mlx_fallback_refuses_awq_tag() {
        // The #294 shape: an AWQ repo name with no MLX marker must be
        // refused before any network probe or download starts.
        let err = resolve_mlx_fallback_repo("qwen3-30b-a3b-instruct-2507-awq-4bit", &|_: &str| {
            panic!("prequantized tags must be refused before probing")
        })
        .unwrap_err();
        assert!(err.contains("AWQ/GPTQ"), "got: {err}");
    }

    #[test]
    fn test_resolve_mlx_fallback_allows_awq_named_mlx_conversion() {
        // mlx-community conversions sometimes keep "AWQ" in the name; an MLX
        // marker wins over the prequantized marker (existence still checked).
        let repo = resolve_mlx_fallback_repo("qwen3-8b-awq-mlx-4bit", &|_: &str| true);
        assert_eq!(repo.unwrap(), "mlx-community/qwen3-8b-awq-mlx-4bit");
    }

    #[test]
    fn test_is_likely_prequantized_repo() {
        assert!(is_likely_prequantized_repo(
            "qwen3-30b-a3b-instruct-2507-awq-4bit"
        ));
        assert!(is_likely_prequantized_repo("model-gptq-int4"));
        assert!(is_likely_prequantized_repo("model-autoround-4bit"));
        assert!(!is_likely_prequantized_repo("qwen3-8b-4bit"));
        assert!(!is_likely_prequantized_repo("llama-3.1-8b-instruct"));
    }

    // ── ollama_installed_matches_candidate ────────────────────────────

    #[test]
    fn test_ollama_installed_matches_exact() {
        assert!(ollama_installed_matches_candidate(
            "llama3.1:8b",
            "llama3.1:8b"
        ));
    }

    #[test]
    fn test_ollama_installed_matches_variant_suffix() {
        assert!(ollama_installed_matches_candidate(
            "llama3.1:8b-instruct-q4_K_M",
            "llama3.1:8b"
        ));
    }

    #[test]
    fn test_ollama_installed_no_match() {
        assert!(!ollama_installed_matches_candidate(
            "qwen2.5:7b",
            "llama3.1:8b"
        ));
    }

    // ── parse_repo_gguf_entries ──────────────────────────────────────

    #[test]
    fn test_parse_repo_gguf_entries_valid() {
        let entries = vec![
            serde_json::json!({"path": "model-Q4_K_M.gguf", "size": 4_000_000_000u64}),
            serde_json::json!({"path": "model-Q8_0.gguf", "size": 8_000_000_000u64}),
        ];
        let files = parse_repo_gguf_entries(entries);
        assert_eq!(files.len(), 2);
        assert_eq!(files[0].0, "model-Q4_K_M.gguf");
        assert_eq!(files[1].0, "model-Q8_0.gguf");
    }

    #[test]
    fn test_parse_repo_gguf_entries_missing_size_defaults_to_zero() {
        let entries = vec![serde_json::json!({"path": "model.gguf"})];
        let files = parse_repo_gguf_entries(entries);
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].1, 0);
    }

    #[test]
    fn test_parse_repo_gguf_entries_skips_non_gguf() {
        let entries = vec![
            serde_json::json!({"path": "README.md", "size": 1000u64}),
            serde_json::json!({"path": "config.json", "size": 500u64}),
            serde_json::json!({"path": "model.gguf", "size": 4_000_000_000u64}),
        ];
        let files = parse_repo_gguf_entries(entries);
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].0, "model.gguf");
    }

    // ── hf_name_to_mlx_candidates edge cases ─────────────────────────

    #[test]
    fn test_hf_name_to_mlx_candidates_bare_model_name() {
        let candidates = hf_name_to_mlx_candidates("Phi-4");
        assert!(candidates.iter().any(|c| c.contains("phi-4")));
        assert!(candidates.iter().any(|c| c.ends_with("-4bit")));
    }

    #[test]
    fn test_hf_name_to_mlx_candidates_no_duplicates() {
        let candidates = hf_name_to_mlx_candidates("meta-llama/Llama-3.1-8B-Instruct");
        let unique: HashSet<_> = candidates.iter().collect();
        assert_eq!(
            unique.len(),
            candidates.len(),
            "candidates should have no duplicates: {:?}",
            candidates
        );
    }

    // ── hf_name_to_ollama_candidates edge cases ──────────────────────

    #[test]
    fn test_hf_name_to_ollama_candidates_unknown_generates_fallback() {
        // Models without an explicit mapping should still generate
        // heuristic candidates so installed detection has something to match.
        let candidates = hf_name_to_ollama_candidates("totally-unknown/model-xyz");
        assert!(
            !candidates.is_empty(),
            "fallback candidate generation should produce at least one entry"
        );
        // All candidates should be lowercased
        for c in &candidates {
            assert_eq!(c, &c.to_lowercase(), "candidate should be lowercase: {c}");
        }
    }

    #[test]
    fn test_hf_name_to_ollama_candidates_multiple_models() {
        // Test a variety of known models
        assert!(!hf_name_to_ollama_candidates("meta-llama/Llama-3.1-8B-Instruct").is_empty());
        assert!(!hf_name_to_ollama_candidates("Qwen/Qwen2.5-Coder-7B-Instruct").is_empty());
        assert!(!hf_name_to_ollama_candidates("google/gemma-2-9b-it").is_empty());
    }

    // ── split_name_and_size ───────────────────────────────────────

    #[test]
    fn test_split_name_and_size_basic() {
        assert_eq!(
            split_name_and_size("qwen2.5-coder-14b"),
            Some(("qwen2.5-coder", "14b"))
        );
    }

    #[test]
    fn test_split_name_and_size_moe() {
        assert_eq!(
            split_name_and_size("qwen3-coder-30b-a3b"),
            Some(("qwen3-coder", "30b-a3b"))
        );
    }

    #[test]
    fn test_split_name_and_size_no_size() {
        // "phi-4" has no "b" suffix — "4" is not a size tag
        assert_eq!(split_name_and_size("phi-4"), None);
    }

    #[test]
    fn test_split_name_and_size_deepseek() {
        assert_eq!(
            split_name_and_size("deepseek-r1-distill-qwen-32b"),
            Some(("deepseek-r1-distill-qwen", "32b"))
        );
    }

    #[test]
    fn test_split_name_and_size_fractional() {
        assert_eq!(split_name_and_size("qwen3-1.7b"), Some(("qwen3", "1.7b")));
    }

    // ── fallback ollama candidate matching ──────────────────────────

    #[test]
    fn test_fallback_ollama_candidates_match_installed() {
        // Simulate a model NOT in OLLAMA_MAPPINGS but running in Ollama
        let candidates = hf_name_to_ollama_candidates("SomeOrg/CoolModel-13B-Instruct");
        // Should generate "coolmodel:13b" as a candidate
        assert!(
            candidates.contains(&"coolmodel:13b".to_string()),
            "expected 'coolmodel:13b' in candidates: {:?}",
            candidates
        );

        // Verify it matches against an installed set
        let mut installed = HashSet::new();
        installed.insert("coolmodel:13b".to_string());
        installed.insert("coolmodel".to_string());
        assert!(is_model_installed(
            "SomeOrg/CoolModel-13B-Instruct",
            &installed
        ));
    }

    #[test]
    fn test_fallback_ollama_moe_candidate() {
        // Use a fictitious MoE model that is NOT in OLLAMA_MAPPINGS
        let candidates = hf_name_to_ollama_candidates("FakeOrg/FakeModel-30B-A3B-Instruct");
        assert!(
            candidates.contains(&"fakemodel:30b-a3b".to_string()),
            "expected 'fakemodel:30b-a3b' in candidates: {:?}",
            candidates
        );
    }

    #[test]
    fn test_installed_hf_name_direct_match() {
        // /api/v1/installed returns the full HF name lowercased
        let mut installed = HashSet::new();
        installed.insert("deepseek-ai/deepseek-r1-distill-qwen-32b".to_string());
        assert!(is_model_installed(
            "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
            &installed
        ));
    }

    // ── Docker Model Runner ─────────────────────────────────────────

    #[test]
    fn test_docker_mr_catalog_parses() {
        // The embedded catalog should parse without errors
        let catalog = docker_mr_catalog();
        assert!(!catalog.is_empty(), "Docker MR catalog should not be empty");
    }

    #[test]
    fn test_has_docker_mr_mapping_known() {
        // Llama 3.1 70B is in both our HF database and Docker Hub ai/ namespace
        assert!(has_docker_mr_mapping("meta-llama/Llama-3.1-70B-Instruct"));
    }

    #[test]
    fn test_has_docker_mr_mapping_unknown() {
        assert!(!has_docker_mr_mapping("totally-unknown/model-xyz"));
    }

    #[test]
    fn test_docker_mr_pull_tag_returns_ai_prefixed() {
        let tag = docker_mr_pull_tag("meta-llama/Llama-3.1-70B-Instruct");
        assert!(tag.is_some());
        assert!(tag.unwrap().starts_with("ai/"));
    }

    #[test]
    fn test_docker_mr_candidates_includes_ai_prefix() {
        let candidates = hf_name_to_docker_mr_candidates("meta-llama/Llama-3.1-70B-Instruct");
        assert!(candidates.iter().any(|c| c.starts_with("ai/")));
    }

    #[test]
    fn test_docker_mr_candidates_unknown_returns_empty() {
        let candidates = hf_name_to_docker_mr_candidates("totally-unknown/model-xyz");
        assert!(candidates.is_empty());
    }

    #[test]
    fn test_is_model_installed_docker_mr_exact() {
        let mut installed = HashSet::new();
        installed.insert("ai/llama3.1:70b".to_string());
        installed.insert("llama3.1:70b".to_string());
        installed.insert("llama3.1".to_string());
        assert!(is_model_installed_docker_mr(
            "meta-llama/Llama-3.1-70B-Instruct",
            &installed
        ));
    }

    #[test]
    fn test_is_model_installed_docker_mr_variant_suffix() {
        let mut installed = HashSet::new();
        installed.insert("ai/llama3.1:70b-q4_k_m".to_string());
        assert!(is_model_installed_docker_mr(
            "meta-llama/Llama-3.1-70B-Instruct",
            &installed
        ));
    }

    #[test]
    fn test_is_model_installed_docker_mr_not_installed() {
        let installed = HashSet::new();
        assert!(!is_model_installed_docker_mr(
            "meta-llama/Llama-3.1-70B-Instruct",
            &installed
        ));
    }

    #[test]
    fn test_normalize_docker_mr_host_with_scheme() {
        assert_eq!(
            normalize_docker_mr_host("https://docker.example.com:12434"),
            Some("https://docker.example.com:12434".to_string())
        );
    }

    #[test]
    fn test_normalize_docker_mr_host_without_scheme() {
        assert_eq!(
            normalize_docker_mr_host("docker.example.com:12434"),
            Some("http://docker.example.com:12434".to_string())
        );
    }

    #[test]
    fn test_normalize_docker_mr_host_rejects_unsupported_scheme() {
        assert_eq!(
            normalize_docker_mr_host("ftp://docker.example.com:12434"),
            None
        );
    }

    // ── OpenAI-compatible identity disambiguation ─────────────────────

    #[test]
    fn test_omlx_status_payload_detected() {
        let payload = serde_json::json!({
            "status": "ok",
            "version": "0.4.4",
            "models_discovered": 1,
            "model_memory_max": 12_649_259_752u64,
            "cache_efficiency": 0.0
        });

        assert!(is_omlx_status_payload(&payload));
    }

    #[test]
    fn test_omlx_status_payload_rejects_generic_status() {
        let payload = serde_json::json!({
            "status": "ok",
            "version": "1.0.0"
        });

        assert!(!is_omlx_status_payload(&payload));
    }

    #[test]
    fn test_openai_model_list_detects_omlx_owner() {
        let list: OpenAiModelList = serde_json::from_value(serde_json::json!({
            "object": "list",
            "data": [
                {
                    "id": "Qwen2.5-0.5B-Instruct-4bit",
                    "object": "model",
                    "owned_by": "omlx",
                    "max_model_len": 32768
                }
            ]
        }))
        .expect("test payload should parse");

        assert!(openai_model_list_is_omlx(&list));
    }

    #[test]
    fn test_openai_model_list_keeps_regular_vllm_available() {
        let list: OpenAiModelList = serde_json::from_value(serde_json::json!({
            "object": "list",
            "data": [
                {
                    "id": "meta-llama/Llama-3.1-8B-Instruct",
                    "object": "model",
                    "owned_by": "vllm"
                }
            ]
        }))
        .expect("test payload should parse");

        assert!(!openai_model_list_is_omlx(&list));
    }

    #[test]
    fn test_openai_model_list_without_owner_is_not_omlx() {
        let list: OpenAiModelList = serde_json::from_value(serde_json::json!({
            "object": "list",
            "data": [
                {
                    "id": "meta-llama/Llama-3.1-8B-Instruct",
                    "object": "model"
                }
            ]
        }))
        .expect("test payload should parse");

        assert!(!openai_model_list_is_omlx(&list));
    }

    // ── vLLM ──────────────────────────────────────────────────────────

    #[test]
    fn test_hf_name_to_vllm_candidates() {
        let candidates = hf_name_to_vllm_candidates("meta-llama/Llama-3.1-8B-Instruct");
        assert!(
            candidates
                .iter()
                .any(|c| c == "meta-llama/llama-3.1-8b-instruct")
        );
        assert!(candidates.iter().any(|c| c == "llama-3.1-8b-instruct"));
        // stripped variant (without -instruct)
        assert!(candidates.iter().any(|c| c == "llama-3.1-8b"));
    }

    #[test]
    fn test_is_model_installed_vllm() {
        let mut installed = HashSet::new();
        installed.insert("meta-llama/llama-3.1-8b-instruct".to_string());
        assert!(is_model_installed_vllm(
            "meta-llama/Llama-3.1-8B-Instruct",
            &installed
        ));
        assert!(!is_model_installed_vllm(
            "meta-llama/Llama-3.1-70B-Instruct",
            &installed
        ));
    }

    #[test]
    fn test_has_vllm_mapping() {
        assert!(has_vllm_mapping("meta-llama/Llama-3.1-8B-Instruct"));
        assert!(!has_vllm_mapping(""));
    }

    #[test]
    fn test_vllm_pull_tag() {
        assert_eq!(
            vllm_pull_tag("meta-llama/Llama-3.1-8B-Instruct"),
            Some("meta-llama/Llama-3.1-8B-Instruct".to_string())
        );
        assert_eq!(vllm_pull_tag(""), None);
    }

    #[test]
    fn test_normalize_vllm_host_with_scheme() {
        assert_eq!(
            normalize_vllm_host("http://myhost:8000"),
            Some("http://myhost:8000".to_string())
        );
    }

    #[test]
    fn test_normalize_vllm_host_without_scheme() {
        assert_eq!(
            normalize_vllm_host("myhost:8000"),
            Some("http://myhost:8000".to_string())
        );
    }

    #[test]
    fn test_normalize_vllm_host_rejects_unsupported_scheme() {
        assert_eq!(normalize_vllm_host("ftp://myhost:8000"), None);
    }

    #[test]
    fn test_normalize_vllm_host_empty() {
        assert_eq!(normalize_vllm_host(""), None);
        assert_eq!(normalize_vllm_host("  "), None);
    }

    #[test]
    fn test_hf_name_to_ramalama_candidates() {
        let candidates = hf_name_to_ramalama_candidates("meta-llama/Llama-3.1-8B-Instruct");
        assert!(
            candidates
                .iter()
                .any(|c| c == "meta-llama/llama-3.1-8b-instruct")
        );
        assert!(candidates.iter().any(|c| c == "llama-3.1-8b-instruct"));
        // stripped variant (without -instruct)
        assert!(candidates.iter().any(|c| c == "llama-3.1-8b"));
    }

    #[test]
    fn test_is_model_installed_ramalama() {
        let mut installed = HashSet::new();
        installed.insert("meta-llama/llama-3.1-8b-instruct".to_string());
        assert!(is_model_installed_ramalama(
            "meta-llama/Llama-3.1-8B-Instruct",
            &installed
        ));
        assert!(!is_model_installed_ramalama(
            "meta-llama/Llama-3.1-70B-Instruct",
            &installed
        ));
    }

    #[test]
    fn test_has_ramalama_mapping() {
        assert!(has_ramalama_mapping("meta-llama/Llama-3.1-8B-Instruct"));
        assert!(!has_ramalama_mapping(""));
    }

    #[test]
    fn test_ramalama_pull_tag() {
        assert_eq!(
            ramalama_pull_tag("meta-llama/Llama-3.1-8B-Instruct"),
            Some("meta-llama/Llama-3.1-8B-Instruct".to_string())
        );
        assert_eq!(ramalama_pull_tag(""), None);
    }

    #[test]
    fn test_parse_ramalama_store_extracts_names() {
        let json = br#"[
            {"shortname":"granite","name":"ollama://granite-code:8b","modified":"2026-01-01","size":123},
            {"shortname":"","name":"huggingface://meta-llama/Llama-3.1-8B-Instruct","modified":"2026-01-01","size":456}
        ]"#;
        let (set, count) = parse_ramalama_store(json).expect("valid json parses");
        assert_eq!(count, 2);
        // Full transport-qualified names, lowercased.
        assert!(set.contains("ollama://granite-code:8b"));
        assert!(set.contains("huggingface://meta-llama/llama-3.1-8b-instruct"));
        // Trailing path components, for substring matching.
        assert!(set.contains("granite-code:8b"));
        assert!(set.contains("llama-3.1-8b-instruct"));
        // Shortname included when present.
        assert!(set.contains("granite"));
    }

    #[test]
    fn test_parse_ramalama_store_matches_hf_model() {
        let json = br#"[{"shortname":"","name":"huggingface://meta-llama/Llama-3.1-8B-Instruct","modified":"x","size":1}]"#;
        let (set, _) = parse_ramalama_store(json).expect("valid json parses");
        // Store-detected models resolve through the same matcher as the server path.
        assert!(is_model_installed_ramalama(
            "meta-llama/Llama-3.1-8B-Instruct",
            &set
        ));
    }

    #[test]
    fn test_parse_ramalama_store_empty_and_invalid() {
        let (set, count) = parse_ramalama_store(b"[]").expect("empty array parses");
        assert_eq!(count, 0);
        assert!(set.is_empty());
        assert!(parse_ramalama_store(b"not json").is_none());
    }

    #[test]
    fn test_normalize_ramalama_host_with_scheme() {
        assert_eq!(
            normalize_ramalama_host("http://myhost:8080"),
            Some("http://myhost:8080".to_string())
        );
    }

    #[test]
    fn test_normalize_ramalama_host_without_scheme() {
        assert_eq!(
            normalize_ramalama_host("myhost:8080"),
            Some("http://myhost:8080".to_string())
        );
    }

    #[test]
    fn test_normalize_ramalama_host_rejects_unsupported_scheme() {
        assert_eq!(normalize_ramalama_host("ftp://myhost:8080"), None);
    }

    #[test]
    fn test_normalize_ramalama_host_empty() {
        assert_eq!(normalize_ramalama_host(""), None);
        assert_eq!(normalize_ramalama_host("  "), None);
    }

    #[test]
    fn test_docker_model_runner_host_filtering() {
        // Test the DOCKER_MODEL_RUNNER_HOST filtering logic without mutating the
        // process environment. is_docker_desktop_running() applies
        // `!v.trim().is_empty()` to the env var value.
        fn host_is_set(val: Option<&str>) -> bool {
            val.map(|v| !v.trim().is_empty()).unwrap_or(false)
        }

        // Non-empty value should count as set
        assert!(host_is_set(Some("localhost:12434")));
        // Empty string should NOT count
        assert!(!host_is_set(Some("")));
        // Whitespace-only should NOT count
        assert!(!host_is_set(Some("   ")));
        // Missing env var should NOT count
        assert!(!host_is_set(None));
    }

    #[test]
    fn test_ollama_build_installed_set_skips_cloud_models() {
        let parse = |name: &str, size: u64| OllamaModel {
            name: name.to_string(),
            size,
        };
        let models = vec![
            parse("qwen3-coder:480b-cloud", 0), // cloud: -cloud suffix + size 0
            parse("gpt-oss:120b-cloud", 0),     // cloud
            parse("llama3.1:8b-instruct-q4_K_M", 4_700_000_000), // local
        ];

        let (set, count) = build_installed_set(models);

        // Only the local model is counted and inserted.
        assert_eq!(count, 1, "cloud models must not count as installed");
        assert!(set.contains("llama3.1:8b-instruct-q4_k_m"));
        assert!(set.contains("llama3.1")); // family stem of the local model

        // The cloud family stem must NOT leak in — that was the #619 false positive.
        assert!(
            !set.contains("qwen3-coder"),
            "cloud family stem must not mark unrelated models installed"
        );
        assert!(!set.contains("gpt-oss"));
        assert!(!set.contains("qwen3-coder:480b-cloud"));
    }

    #[test]
    fn test_ollama_is_cloud_detection() {
        let cloud = OllamaModel {
            name: "qwen3-coder:480b-cloud".to_string(),
            size: 0,
        };
        assert!(cloud.is_cloud());

        // A local model with a real on-disk size is not cloud.
        let local = OllamaModel {
            name: "llama3.1:8b".to_string(),
            size: 4_700_000_000,
        };
        assert!(!local.is_cloud());

        // Defensive: a zero-size entry is treated as not-local even without the suffix.
        let zero = OllamaModel {
            name: "mystery:latest".to_string(),
            size: 0,
        };
        assert!(zero.is_cloud());
    }
}
