//! Cross-platform hardware detection (RAM, GPU) used to decide which local
//! models can run on this machine. No heavy deps — shells out to OS tools.

use std::io::Read;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

/// Max time any single hardware-probe subprocess may run before we give up and
/// kill it. A hung `nvidia-smi` on a misconfigured box must never wedge startup.
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);

#[derive(Debug, Clone, Default)]
pub struct Hardware {
    pub total_ram_gb: f64,
    /// Memory available for new processes right now.
    pub free_ram_gb: f64,
    /// VRAM for discrete NVIDIA GPUs; on Apple Silicon RAM is unified memory.
    pub gpu: Option<String>,
    pub gpu_vram_gb: Option<f64>,
    pub apple_silicon: bool,
    pub os: &'static str,
}

impl Hardware {
    /// Effective memory budget for local inference: unified memory on Apple
    /// Silicon, max(RAM, VRAM-weighted) elsewhere — RAM is what Ollama needs.
    pub fn budget_gb(&self) -> f64 {
        self.total_ram_gb
    }

    /// True when the model fits in total RAM but free memory is tight right now.
    pub fn tight_now(&self, min_ram_gb: f64) -> bool {
        self.free_ram_gb > 0.0 && min_ram_gb <= self.budget_gb() && min_ram_gb > self.free_ram_gb + 1.0
    }

    pub fn summary(&self) -> String {
        let mut s = if self.free_ram_gb > 0.0 {
            format!("{:.0} GB RAM ({:.0} GB free)", self.total_ram_gb, self.free_ram_gb)
        } else {
            format!("{:.0} GB RAM", self.total_ram_gb)
        };
        if self.apple_silicon {
            s.push_str(" (Apple Silicon unified memory)");
        }
        if let Some(gpu) = &self.gpu {
            s.push_str(&format!(" • GPU: {gpu}"));
            if let Some(v) = self.gpu_vram_gb {
                s.push_str(&format!(" {v:.0} GB VRAM"));
            }
        }
        format!("{s} • {}", self.os)
    }
}

fn run(cmd: &str, args: &[&str]) -> Option<String> {
    run_timeout(cmd, args, PROBE_TIMEOUT)
}

/// Run a probe command, killing it if it outlives `timeout`. These probes emit
/// tiny output, so reading stdout after exit can't deadlock on a full pipe.
fn run_timeout(cmd: &str, args: &[&str], timeout: Duration) -> Option<String> {
    let mut child = Command::new(cmd)
        .args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .ok()?;

    let start = Instant::now();
    loop {
        match child.try_wait() {
            Ok(Some(status)) => {
                if !status.success() {
                    return None;
                }
                let mut s = String::new();
                child.stdout.take()?.read_to_string(&mut s).ok()?;
                return Some(s.trim().to_string());
            }
            Ok(None) => {
                if start.elapsed() >= timeout {
                    let _ = child.kill();
                    let _ = child.wait();
                    return None;
                }
                std::thread::sleep(Duration::from_millis(40));
            }
            Err(_) => return None,
        }
    }
}

pub fn detect() -> Hardware {
    let mut hw = Hardware { os: std::env::consts::OS, ..Default::default() };

    #[cfg(target_os = "macos")]
    {
        if let Some(bytes) = run("sysctl", &["-n", "hw.memsize"]).and_then(|s| s.parse::<u64>().ok()) {
            hw.total_ram_gb = bytes as f64 / 1_073_741_824.0;
        }
        if let Some(brand) = run("sysctl", &["-n", "machdep.cpu.brand_string"]) {
            if brand.contains("Apple") {
                hw.apple_silicon = true;
                hw.gpu = Some(brand);
            }
        }
        // Free = free + inactive + speculative pages (reclaimable on demand).
        if let Some(vm) = run("vm_stat", &[]) {
            let page_size: f64 = vm
                .lines()
                .next()
                .and_then(|l| l.split("page size of ").nth(1))
                .and_then(|s| s.split_whitespace().next())
                .and_then(|v| v.parse().ok())
                .unwrap_or(16384.0);
            let pages = |key: &str| -> f64 {
                vm.lines()
                    .find(|l| l.starts_with(key))
                    .and_then(|l| l.rsplit_once(' '))
                    .map(|(_, v)| v.trim_end_matches('.').trim())
                    .and_then(|v| v.parse::<f64>().ok())
                    .unwrap_or(0.0)
            };
            let free_pages =
                pages("Pages free:") + pages("Pages inactive:") + pages("Pages speculative:");
            hw.free_ram_gb = free_pages * page_size / 1_073_741_824.0;
        }
    }

    #[cfg(target_os = "linux")]
    {
        if let Ok(meminfo) = std::fs::read_to_string("/proc/meminfo") {
            let kb = |key: &str| -> Option<u64> {
                meminfo
                    .lines()
                    .find(|l| l.starts_with(key))
                    .and_then(|l| l.split_whitespace().nth(1))
                    .and_then(|v| v.parse().ok())
            };
            if let Some(total) = kb("MemTotal:") {
                hw.total_ram_gb = total as f64 / 1_048_576.0;
            }
            if let Some(avail) = kb("MemAvailable:") {
                hw.free_ram_gb = avail as f64 / 1_048_576.0;
            }
        }
    }

    #[cfg(target_os = "windows")]
    {
        if let Some(out) = run(
            "powershell",
            &["-NoProfile", "-Command", "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory"],
        ) {
            if let Ok(bytes) = out.trim().parse::<u64>() {
                hw.total_ram_gb = bytes as f64 / 1_073_741_824.0;
            }
        }
        if let Some(out) = run(
            "powershell",
            &["-NoProfile", "-Command", "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory"],
        ) {
            if let Ok(kb) = out.trim().parse::<u64>() {
                hw.free_ram_gb = kb as f64 / 1_048_576.0;
            }
        }
    }

    // NVIDIA discrete GPU (any OS with nvidia-smi on PATH).
    if let Some(out) = run(
        "nvidia-smi",
        &["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
    ) {
        if let Some(line) = out.lines().next() {
            let mut parts = line.split(',').map(str::trim);
            let name = parts.next().unwrap_or("NVIDIA GPU").to_string();
            let vram_mb: f64 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0.0);
            hw.gpu = Some(name);
            hw.gpu_vram_gb = Some(vram_mb / 1024.0);
        }
    }

    if hw.total_ram_gb == 0.0 {
        hw.total_ram_gb = 8.0; // conservative fallback
    }
    hw
}

/// A locally runnable open-source model served through Ollama.
pub struct LocalModel {
    pub tag: &'static str,
    pub family: &'static str,
    pub download_gb: f64,
    pub min_ram_gb: f64,
    pub tool_calling: bool,
    /// Rough capability rank (higher = better) used to sort suggestions.
    pub quality: u8,
    pub note: &'static str,
}

/// Curated local catalog: Qwen, DeepSeek, OpenAI gpt-oss, Google Gemma,
/// Mistral. The agent harness needs tool calling — models without it are
/// flagged.
pub const LOCAL_MODELS: &[LocalModel] = &[
    LocalModel { tag: "qwen3:0.6b", family: "Qwen", download_gb: 0.5, min_ram_gb: 2.0, tool_calling: true, quality: 35, note: "tiny, fast smoke tests" },
    LocalModel { tag: "qwen3:1.7b", family: "Qwen", download_gb: 1.4, min_ram_gb: 4.0, tool_calling: true, quality: 48, note: "small + capable" },
    LocalModel { tag: "qwen3:4b", family: "Qwen", download_gb: 2.6, min_ram_gb: 6.0, tool_calling: true, quality: 65, note: "best small all-rounder" },
    LocalModel { tag: "qwen3:8b", family: "Qwen", download_gb: 5.2, min_ram_gb: 10.0, tool_calling: true, quality: 71, note: "mid-size; benchmarked flaky on agentic follow-through" },
    LocalModel { tag: "qwen3:14b", family: "Qwen", download_gb: 9.3, min_ram_gb: 16.0, tool_calling: true, quality: 80, note: "strong reasoning" },
    LocalModel { tag: "qwen3:30b-a3b", family: "Qwen", download_gb: 19.0, min_ram_gb: 32.0, tool_calling: true, quality: 88, note: "MoE, fast for its size" },
    LocalModel { tag: "qwen2.5-coder:3b", family: "Qwen Coder", download_gb: 1.9, min_ram_gb: 4.0, tool_calling: true, quality: 55, note: "small coding model" },
    LocalModel { tag: "qwen2.5-coder:7b", family: "Qwen Coder", download_gb: 4.7, min_ram_gb: 10.0, tool_calling: true, quality: 70, note: "solid local coder" },
    LocalModel { tag: "qwen2.5-coder:14b", family: "Qwen Coder", download_gb: 9.0, min_ram_gb: 16.0, tool_calling: true, quality: 78, note: "stronger coder" },
    LocalModel { tag: "qwen2.5-coder:32b", family: "Qwen Coder", download_gb: 20.0, min_ram_gb: 32.0, tool_calling: true, quality: 90, note: "strong local coder" },
    LocalModel { tag: "qwen3-coder:30b", family: "Qwen Coder", download_gb: 19.0, min_ram_gb: 32.0, tool_calling: true, quality: 91, note: "★ best local agentic coder (RL-trained on SWE-Bench)" },
    LocalModel { tag: "deepseek-r1:1.5b", family: "DeepSeek", download_gb: 1.1, min_ram_gb: 3.0, tool_calling: false, quality: 30, note: "reasoning distill (no tools)" },
    LocalModel { tag: "deepseek-r1:8b", family: "DeepSeek", download_gb: 5.2, min_ram_gb: 10.0, tool_calling: false, quality: 58, note: "reasoning distill (no tools)" },
    LocalModel { tag: "deepseek-r1:14b", family: "DeepSeek", download_gb: 9.0, min_ram_gb: 16.0, tool_calling: false, quality: 66, note: "reasoning distill (no tools)" },
    LocalModel { tag: "deepseek-r1:32b", family: "DeepSeek", download_gb: 20.0, min_ram_gb: 32.0, tool_calling: false, quality: 76, note: "reasoning distill (no tools)" },
    LocalModel { tag: "gpt-oss:20b", family: "OpenAI", download_gb: 14.0, min_ram_gb: 16.0, tool_calling: true, quality: 86, note: "★ recommended — benchmark winner 3/4 (frontend/backend/xlsx), fast tool calls" },
    LocalModel { tag: "hf.co/unsloth/ERNIE-4.5-21B-A3B-Thinking-GGUF:Q4_K_M", family: "Baidu", download_gb: 12.7, min_ram_gb: 16.0, tool_calling: false, quality: 55, note: "ERNIE 4.5 thinking specialist — benchmarked 0/4 agentic (overthinks); chat/reasoning only" },
    LocalModel { tag: "gpt-oss:120b", family: "OpenAI", download_gb: 65.0, min_ram_gb: 80.0, tool_calling: true, quality: 95, note: "OpenAI open-weight, large" },
    LocalModel { tag: "gemma3:1b", family: "Google", download_gb: 0.8, min_ram_gb: 2.0, tool_calling: false, quality: 25, note: "Gemini family (no tools)" },
    LocalModel { tag: "gemma3:4b", family: "Google", download_gb: 3.3, min_ram_gb: 6.0, tool_calling: false, quality: 42, note: "Gemini family (no tools)" },
    LocalModel { tag: "gemma3:12b", family: "Google", download_gb: 8.1, min_ram_gb: 14.0, tool_calling: false, quality: 56, note: "Gemini family (no tools)" },
    LocalModel { tag: "gemma3:27b", family: "Google", download_gb: 17.0, min_ram_gb: 28.0, tool_calling: false, quality: 68, note: "Gemini family (no tools)" },
    LocalModel { tag: "mistral:7b", family: "Mistral", download_gb: 4.4, min_ram_gb: 8.0, tool_calling: true, quality: 52, note: "classic 7B" },
    LocalModel { tag: "devstral:24b", family: "Mistral", download_gb: 14.0, min_ram_gb: 24.0, tool_calling: true, quality: 84, note: "agentic coding model" },
];

pub fn find_local(tag: &str) -> Option<&'static LocalModel> {
    LOCAL_MODELS.iter().find(|m| m.tag.eq_ignore_ascii_case(tag))
}

/// Best tool-calling local model that fits this machine — the agent harness
/// needs tool calls, so non-tool models are never recommended.
pub fn recommended_local(hw: &Hardware) -> Option<&'static LocalModel> {
    LOCAL_MODELS
        .iter()
        .filter(|m| m.tool_calling && m.min_ram_gb <= hw.budget_gb())
        .max_by_key(|m| m.quality)
}

/// Models that only make sense in the cloud (too big for any laptop, or
/// proprietary). These require an OpenRouter API key or Lucid Cloud login.
pub struct CloudModel {
    pub alias: &'static str,
    pub id: &'static str,
    pub family: &'static str,
    /// Descriptive metadata kept in the catalog (proprietary vs. open weights).
    #[allow(dead_code)]
    pub proprietary: bool,
    /// Rough capability rank (higher = better) used to sort suggestions.
    pub quality: u8,
    /// Marks the default OpenRouter pick shown as "★ Lucid's choice".
    pub recommended: bool,
    /// FREE while in preview (no token cost) — surfaced in the setup screen.
    pub free: bool,
}

pub const CLOUD_MODELS: &[CloudModel] = &[
    // FREE while in preview — Lucid's default pick until the free tier ends
    // (then flip `recommended` back to kimi-k2.7-code below).
    CloudModel { alias: "hy3", id: "tencent/hy3:free", family: "Tencent", proprietary: false, quality: 97, recommended: true, free: true },
    // Open-source frontier (OpenRouter, bring-your-own key).
    CloudModel { alias: "kimi-k2.7-code", id: "moonshotai/kimi-k2.7-code", family: "Kimi", proprietary: false, quality: 98, recommended: false, free: false },
    CloudModel { alias: "kimi-k2.7", id: "moonshotai/kimi-k2.7", family: "Kimi", proprietary: false, quality: 96, recommended: false, free: false },
    CloudModel { alias: "deepseek-v4-pro", id: "deepseek/deepseek-v4-pro", family: "DeepSeek", proprietary: false, quality: 97, recommended: false, free: false },
    CloudModel { alias: "qwen3.7-max", id: "qwen/qwen3.7-max", family: "Qwen", proprietary: false, quality: 95, recommended: false, free: false },
    CloudModel { alias: "kimi-k2.6", id: "moonshotai/kimi-k2.6", family: "Kimi", proprietary: false, quality: 93, recommended: false, free: false },
    CloudModel { alias: "kimi-k2.5", id: "moonshotai/kimi-k2.5", family: "Kimi", proprietary: false, quality: 90, recommended: false, free: false },
    CloudModel { alias: "qwen3-coder", id: "qwen/qwen3-coder", family: "Qwen", proprietary: false, quality: 91, recommended: false, free: false },
    CloudModel { alias: "deepseek", id: "deepseek/deepseek-chat-v3.1", family: "DeepSeek", proprietary: false, quality: 86, recommended: false, free: false },
    CloudModel { alias: "glm", id: "z-ai/glm-5.1", family: "Zhipu", proprietary: false, quality: 89, recommended: false, free: false },
    CloudModel { alias: "minimax", id: "minimax/minimax-m2.7", family: "MiniMax", proprietary: false, quality: 85, recommended: false, free: false },
    CloudModel { alias: "mistral-large", id: "mistralai/mistral-large-2411", family: "Mistral", proprietary: false, quality: 80, recommended: false, free: false },
    // Proprietary (OpenRouter key required).
    CloudModel { alias: "gpt-5.1-codex", id: "openai/gpt-5.1-codex", family: "OpenAI", proprietary: true, quality: 98, recommended: false, free: false },
    CloudModel { alias: "gpt-5.1", id: "openai/gpt-5.1", family: "OpenAI", proprietary: true, quality: 95, recommended: false, free: false },
    CloudModel { alias: "claude-sonnet", id: "anthropic/claude-sonnet-4.5", family: "Anthropic", proprietary: true, quality: 96, recommended: false, free: false },
    CloudModel { alias: "claude-opus", id: "anthropic/claude-opus-4.1", family: "Anthropic", proprietary: true, quality: 99, recommended: false, free: false },
    CloudModel { alias: "grok", id: "x-ai/grok-4", family: "xAI", proprietary: true, quality: 94, recommended: false, free: false },
    CloudModel { alias: "grok-code", id: "x-ai/grok-code-fast-1", family: "xAI", proprietary: true, quality: 86, recommended: false, free: false },
    CloudModel { alias: "gemini", id: "google/gemini-2.5-pro", family: "Google", proprietary: true, quality: 93, recommended: false, free: false },
];

pub fn find_cloud(name: &str) -> Option<&'static CloudModel> {
    CLOUD_MODELS
        .iter()
        .find(|m| m.alias.eq_ignore_ascii_case(name) || m.id.eq_ignore_ascii_case(name))
}

/// The default OpenRouter pick ("Lucid's choice").
pub fn recommended_cloud() -> &'static CloudModel {
    CLOUD_MODELS
        .iter()
        .find(|m| m.recommended)
        .unwrap_or(&CLOUD_MODELS[0])
}

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

    #[test]
    fn detects_some_ram() {
        let hw = detect();
        assert!(hw.total_ram_gb >= 1.0, "ram: {}", hw.total_ram_gb);
    }

    #[test]
    fn catalog_lookups() {
        assert!(find_local("qwen3:4b").is_some());
        assert!(find_local("QWEN3:4B").is_some());
        assert!(find_cloud("kimi-k2.5").is_some());
        assert!(find_cloud("anthropic/claude-sonnet-4.5").is_some());
        assert!(find_cloud("claude-sonnet").map(|m| m.proprietary).unwrap_or(false));
    }

    // POSIX-only: relies on `sleep`/`echo`. The probe timeout is what keeps a
    // hung `nvidia-smi` from wedging first-run startup.
    #[cfg(unix)]
    #[test]
    fn run_timeout_kills_slow_command() {
        let start = Instant::now();
        let out = run_timeout("sleep", &["30"], Duration::from_millis(300));
        assert!(out.is_none(), "a command past the timeout must return None");
        assert!(
            start.elapsed() < Duration::from_secs(3),
            "slow command should have been killed promptly, took {:?}",
            start.elapsed()
        );
    }

    #[cfg(unix)]
    #[test]
    fn run_timeout_returns_fast_output() {
        assert_eq!(run_timeout("echo", &["hello-hw"], Duration::from_secs(2)).as_deref(), Some("hello-hw"));
    }
}
