use std::collections::BTreeMap;
use sysinfo::System;

/// The acceleration backend for inference speed estimation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum GpuBackend {
    Cuda,
    Metal,
    Rocm,
    Vulkan, // AMD/other GPUs without ROCm (e.g. Windows AMD, older AMD)
    Sycl,   // Intel oneAPI
    CpuArm,
    CpuX86,
    Ascend,
}

impl GpuBackend {
    pub fn label(&self) -> &'static str {
        match self {
            GpuBackend::Cuda => "CUDA",
            GpuBackend::Metal => "Metal",
            GpuBackend::Rocm => "ROCm",
            GpuBackend::Vulkan => "Vulkan",
            GpuBackend::Sycl => "SYCL",
            GpuBackend::CpuArm => "CPU (ARM)",
            GpuBackend::CpuX86 => "CPU (x86)",
            GpuBackend::Ascend => "NPU (Ascend)",
        }
    }
}

/// Information about a single detected GPU.
#[derive(Debug, Clone, serde::Serialize)]
pub struct GpuInfo {
    pub name: String,
    pub vram_gb: Option<f64>,
    pub backend: GpuBackend,
    pub count: u32, // >1 for same-model multi-GPU (e.g. 2x RTX 4090)
    pub unified_memory: bool,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct SystemSpecs {
    pub total_ram_gb: f64,
    pub available_ram_gb: f64,
    pub total_cpu_cores: usize,
    pub cpu_name: String,
    pub has_gpu: bool,
    pub gpu_vram_gb: Option<f64>,
    /// Total VRAM across all same-model GPUs (e.g., 48GB for 2x RTX 3090).
    /// For multi-GPU inference backends (llama.cpp, vLLM), models can be split
    /// across cards, so we use total VRAM for fit scoring.
    pub total_gpu_vram_gb: Option<f64>,
    /// On Apple Silicon (unified memory), how much of the shared pool the GPU
    /// may actually wire — from Metal's `recommendedMaxWorkingSetSize`. macOS
    /// caps this well below total RAM. `None` on other platforms or when the
    /// query is unavailable. Distinct from `gpu_vram_gb`, which for unified
    /// memory reports the *total* pool.
    pub gpu_available_gb: Option<f64>,
    pub gpu_name: Option<String>,
    pub gpu_count: u32,
    pub unified_memory: bool,
    pub backend: GpuBackend,
    /// All detected GPUs (may span different vendors/backends).
    pub gpus: Vec<GpuInfo>,
    /// True when running in multi-node cluster mode (e.g. DGX Spark cluster).
    pub cluster_mode: bool,
    /// Number of nodes in the cluster (0 or 1 = single machine).
    pub cluster_node_count: u32,
}

impl SystemSpecs {
    pub fn detect() -> Self {
        let mut sys = System::new_all();
        sys.refresh_all();

        let total_ram_bytes = sys.total_memory();
        let available_ram_bytes = sys.available_memory();
        let total_ram_gb = total_ram_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
        let available_ram_gb = if available_ram_bytes == 0 && total_ram_bytes > 0 {
            // sysinfo may fail to report available memory on some platforms
            // (e.g. macOS Tahoe / newer macOS versions). Try fallbacks.
            Self::available_ram_fallback(&sys, total_ram_bytes, total_ram_gb)
        } else {
            available_ram_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
        };

        let total_cpu_cores = sys.cpus().len();
        let cpu_name = Self::detect_cpu_name(&sys);

        // A BIOS GPU UMA carveout hides the carved-out portion from the OS
        // view of RAM: a 32 GB Hawk Point machine with an 8 GB frame buffer
        // reports ~24 GB (issue #810, Windows) and a 128 GB Strix Halo with
        // 96 GB given to the GPU reports ~31 GB (issue #964, Linux). Recover
        // the installed total for AMD APUs so the unified pool is graded
        // against the memory that is actually there.
        let total_ram_gb = Self::apu_total_ram_gb(&cpu_name, total_ram_gb);

        let gpus = Self::detect_all_gpus(total_ram_gb, &cpu_name);

        // Primary GPU = the one with the most VRAM (best for inference).
        // Per-card display values come from the primary; the fit-scoring pool
        // and GPU count are aggregated across every detected GPU so that
        // multi-GPU systems (including mixed models, e.g. RX 7600 + R9700)
        // contribute their full combined VRAM, not just the primary's.
        let primary = gpus.first();
        let has_gpu = !gpus.is_empty();
        let gpu_vram_gb = primary.and_then(|g| g.vram_gb);
        let gpu_name = primary.map(|g| g.name.clone());
        let unified_memory = primary.map(|g| g.unified_memory).unwrap_or(false);
        // Total VRAM = sum of per-card VRAM * count across all GPUs (for
        // multi-GPU tensor splitting). Unified-memory GPUs report the shared
        // system pool as their VRAM; with a single such GPU this is correct.
        let total_gpu_vram_gb = {
            let sum: f64 = gpus
                .iter()
                .filter_map(|g| g.vram_gb.map(|vram| vram * g.count as f64))
                .sum();
            if sum > 0.0 { Some(sum) } else { None }
        };
        let gpu_count: u32 = gpus.iter().map(|g| g.count).sum();

        let cpu_backend =
            if cfg!(target_arch = "aarch64") || cpu_name.to_lowercase().contains("apple") {
                GpuBackend::CpuArm
            } else {
                GpuBackend::CpuX86
            };
        let backend = primary.map(|g| g.backend).unwrap_or(cpu_backend);

        // Only Apple Silicon reports unified memory *and* runs Metal, so the
        // GPU-available query is meaningful only there. Other unified-memory
        // paths (AMD APUs, NVIDIA Grace) fall through to `None` because the
        // query is macOS-only.
        let gpu_available_gb = if unified_memory {
            detect_gpu_available_gb()
        } else {
            None
        };

        SystemSpecs {
            total_ram_gb,
            available_ram_gb,
            total_cpu_cores,
            cpu_name,
            has_gpu,
            gpu_vram_gb,
            total_gpu_vram_gb,
            gpu_available_gb,
            gpu_name,
            gpu_count,
            unified_memory,
            backend,
            gpus,
            cluster_mode: false,
            cluster_node_count: 0,
        }
    }

    /// Detect all GPUs across all vendors. Returns a Vec sorted by VRAM descending
    /// (best GPU first). Unlike the old cascade, this does NOT short-circuit:
    /// a system with both NVIDIA and AMD GPUs will report both.
    fn detect_all_gpus(total_ram_gb: f64, cpu_name: &str) -> Vec<GpuInfo> {
        let mut gpus = Vec::new();

        // NVIDIA GPUs via nvidia-smi, with sysfs fallback for Linux/toolbox setups
        let nvidia = Self::detect_nvidia_gpus();
        if nvidia.is_empty() {
            if let Some(nvidia_sysfs) = Self::detect_nvidia_gpu_sysfs_info() {
                gpus.push(nvidia_sysfs);
            }
        } else {
            gpus.extend(nvidia);
        }

        // AMD GPUs via rocm-smi or sysfs
        let amd_rocm = Self::detect_amd_gpu_rocm_info();
        if amd_rocm.is_empty() {
            gpus.extend(Self::detect_amd_gpu_sysfs_info());
        } else {
            gpus.extend(amd_rocm);
        }

        // Windows WMI (catches GPUs not found by vendor-specific tools)
        for wmi_gpu in Self::detect_gpu_windows_info() {
            // Skip if we already found a GPU with the same name from a vendor tool
            let dominated = gpus.iter().any(|existing| {
                let existing_lower = existing.name.to_lowercase();
                let wmi_lower = wmi_gpu.name.to_lowercase();
                existing_lower.contains(&wmi_lower) || wmi_lower.contains(&existing_lower)
            });
            if !dominated {
                gpus.push(wmi_gpu);
            }
        }

        // AMD unified memory APUs (e.g. Ryzen AI MAX series) share the full
        // system RAM between CPU and GPU, like Apple Silicon. Per-card probes
        // only ever see a slice of that pool (WMI AdapterRAM is a 32-bit
        // field capped at ~4 GB; rocm-smi and sysfs report the BIOS UMA
        // carveout), so the whole pool is substituted. `total_ram_gb` already
        // has the carveout folded back in (see `apu_total_ram_gb`), so a
        // 128 GB Strix Halo grades against ~128 GB whether the BIOS gives the
        // GPU 512 MB or 96 GB (issues #810, #964).
        Self::apply_amd_unified_apu_override(&mut gpus, cpu_name, total_ram_gb);

        // NVIDIA Grace / DGX Spark unified memory SoCs (e.g. GB10, GB20).
        // These share the full system RAM between CPU and GPU, like Apple Silicon.
        // nvidia-smi may report 0 VRAM or a small dedicated portion, so we
        // override with total system RAM and flag as unified memory.
        // Inside Docker the friendly name may be missing; we also match by PCI
        // device ID (e.g. "Device [10de:2e12]").
        let is_nvidia_unified = gpus.iter().any(|g| is_nvidia_unified_memory_gpu(&g.name));
        if is_nvidia_unified {
            for gpu in &mut gpus {
                if is_nvidia_unified_memory_gpu(&gpu.name) {
                    gpu.unified_memory = true;
                    gpu.vram_gb = Some(total_ram_gb);
                }
            }
        }

        // Intel GPUs (integrated or discrete Arc) via lspci/sysfs
        let intel_gpus = Self::detect_intel_gpus(total_ram_gb);
        if !intel_gpus.is_empty() {
            let already_found = gpus.iter().any(|g| g.name.to_lowercase().contains("intel"));
            if !already_found {
                gpus.extend(intel_gpus);
            }
        }

        // Intel macOS machines expose Intel and AMD GPUs through Metal, but
        // not through Linux ROCm/sysfs or NVIDIA-specific tools. Read
        // system_profiler so older MacBook Pros report their discrete Radeon.
        for mac_gpu in Self::detect_macos_metal_gpus() {
            let dominated = gpus
                .iter()
                .any(|existing| Self::is_same_gpu_name(&existing.name, &mac_gpu.name));
            if !dominated {
                gpus.push(mac_gpu);
            }
        }

        // Apple Silicon (unified memory)
        if let Some(vram) = Self::detect_apple_gpu(total_ram_gb) {
            let name = if cpu_name.to_lowercase().contains("apple") {
                cpu_name.to_string()
            } else {
                "Apple Silicon".to_string()
            };
            gpus.push(GpuInfo {
                name,
                vram_gb: Some(vram),
                backend: GpuBackend::Metal,
                count: 1,
                unified_memory: true,
            });
        }

        // Ascend NPUs via npu-smi
        let ascend = Self::detect_ascend_npus();
        if !ascend.is_empty() {
            gpus.extend(ascend);
        }

        // Vulkan fallback (e.g. Android/Termux with Turnip). Earlier sources
        // keep priority because they provide better names and VRAM values.
        gpus = Self::merge_gpu_sources(gpus, Self::detect_vulkan_gpu_info());

        // When both discrete and integrated GPUs are present, drop the
        // integrated GPUs so the discrete GPU becomes primary. This applies
        // globally, not just to the Windows WMI path, to handle cases where
        // an iGPU is detected via Vulkan or APU detection alongside a dGPU.
        // Keep macOS Metal iGPUs visible because Activity Monitor and
        // llama.cpp's Metal device list can expose both built-in GPUs.
        if !cfg!(target_os = "macos") {
            gpus = Self::prefer_discrete_gpus(gpus);
        }

        // Sort by VRAM descending so the best GPU is primary
        gpus.sort_by(|a, b| {
            let va = a.vram_gb.unwrap_or(0.0);
            let vb = b.vram_gb.unwrap_or(0.0);
            vb.partial_cmp(&va).unwrap_or(std::cmp::Ordering::Equal)
        });

        gpus
    }

    /// Pure half of the Ryzen AI MAX unified-memory override: mark the APU's
    /// Radeon as a unified-memory device whose pool is `apu_pool_gb`, or
    /// synthesize one when no probe found it. Split from `detect_all_gpus`
    /// so the issue-#964 fixture (rocm-smi reporting a 96 GB carveout that
    /// this override used to replace with the ~31 GB leftover RAM) can be
    /// regression-tested without shelling out.
    fn apply_amd_unified_apu_override(gpus: &mut Vec<GpuInfo>, cpu_name: &str, apu_pool_gb: f64) {
        if !is_amd_unified_memory_apu(cpu_name) {
            return;
        }
        let amd_idx = gpus.iter().position(|g| {
            let lower = g.name.to_lowercase();
            lower.contains("amd") || lower.contains("radeon")
        });
        if let Some(idx) = amd_idx {
            gpus[idx].unified_memory = true;
            gpus[idx].vram_gb = Some(apu_pool_gb);
            // When detection could only produce a generic name (e.g. rocm-smi
            // reported "N/A"), use the APU model instead — it names the iGPU
            // (e.g. "AMD Ryzen AI MAX+ 395 w/ Radeon 8060S"), giving a stable
            // hardware identity for the leaderboard.
            if is_generic_amd_gpu_name(&gpus[idx].name) {
                gpus[idx].name = format!("{cpu_name} (integrated)");
            }
        } else {
            // No AMD GPU found via other methods; create one.
            gpus.push(GpuInfo {
                name: format!("{cpu_name} (integrated)"),
                vram_gb: Some(apu_pool_gb),
                backend: GpuBackend::Vulkan,
                count: 1,
                unified_memory: true,
            });
        }
    }

    /// Detect NVIDIA GPUs via nvidia-smi. Returns one GpuInfo per unique model,
    /// with count and per-card VRAM for same-model multi-GPU setups.
    ///
    /// First tries querying `addressing_mode` to detect unified memory (Tegra/Grace
    /// Blackwell platforms). Falls back to the standard 2-column query if the field
    /// is unavailable on older nvidia-smi versions.
    fn detect_nvidia_gpus() -> Vec<GpuInfo> {
        // Try the extended query first (addressing_mode,memory.total,name).
        // On NVIDIA Tegra / Grace Blackwell, addressing_mode returns "ATS"
        // (Address Translation Services) which signals unified CPU+GPU memory.
        if let Some(gpus) = Self::try_nvidia_smi_with_addressing_mode() {
            return gpus;
        }

        // Fallback: standard 2-column query for older nvidia-smi versions
        let output = match std::process::Command::new("nvidia-smi")
            .arg("--query-gpu=memory.total,name")
            .arg("--format=csv,noheader,nounits")
            .output()
        {
            Ok(o) if o.status.success() => o,
            _ => return Vec::new(),
        };

        let text = match String::from_utf8(output.stdout) {
            Ok(t) => t,
            Err(_) => return Vec::new(),
        };

        Self::parse_nvidia_smi_list(&text)
    }

    /// Try nvidia-smi with `addressing_mode` column. Returns `None` if the
    /// query fails (e.g. older driver that doesn't support the field), so the
    /// caller can fall back to the standard query.
    fn try_nvidia_smi_with_addressing_mode() -> Option<Vec<GpuInfo>> {
        let output = std::process::Command::new("nvidia-smi")
            .arg("--query-gpu=addressing_mode,memory.total,name")
            .arg("--format=csv,noheader,nounits")
            .output()
            .ok()?;

        if !output.status.success() {
            return None;
        }

        let text = String::from_utf8(output.stdout).ok()?;
        Some(Self::parse_nvidia_smi_extended(&text))
    }

    /// Parse `nvidia-smi --query-gpu=addressing_mode,memory.total,name`.
    /// Detects unified memory when addressing_mode is "ATS" and VRAM is
    /// unavailable — common on NVIDIA Tegra / Grace Blackwell (DGX Spark).
    /// Falls back to system RAM via /proc/meminfo as the unified memory pool.
    fn parse_nvidia_smi_extended(text: &str) -> Vec<GpuInfo> {
        // Track per-model: (count, per_card_vram_mb, is_unified)
        let mut grouped: BTreeMap<String, (u32, f64, bool)> = BTreeMap::new();
        let total_ram_gb = read_proc_meminfo_total_gb();

        for line in text.lines() {
            let line = line.trim();
            if line.is_empty() {
                continue;
            }
            let parts: Vec<&str> = line.splitn(3, ',').collect();
            if parts.len() < 3 {
                continue;
            }

            let addr_mode = parts[0].trim();
            let is_unified = addr_mode.eq_ignore_ascii_case("ATS");

            let name = parts[2].trim().to_string();
            let name = if name.is_empty() {
                "NVIDIA GPU".to_string()
            } else {
                name
            };

            let parsed_vram_mb = parts[1].trim().parse::<f64>().unwrap_or(0.0);

            let vram_mb = if parsed_vram_mb > 0.0 {
                parsed_vram_mb
            } else if is_unified {
                // Unified memory: use total system RAM as the shared pool
                total_ram_gb.unwrap_or(0.0) * 1024.0
            } else {
                estimate_vram_from_name(&name) * 1024.0
            };

            let entry = grouped.entry(name).or_insert((0, 0.0, false));
            entry.0 += 1;
            if vram_mb > entry.1 {
                entry.1 = vram_mb;
            }
            if is_unified {
                entry.2 = true;
            }
        }

        if grouped.is_empty() {
            return Vec::new();
        }

        grouped
            .into_iter()
            .map(|(name, (count, per_card_vram_mb, is_unified))| GpuInfo {
                name,
                vram_gb: if per_card_vram_mb > 0.0 {
                    Some(per_card_vram_mb / 1024.0)
                } else {
                    None
                },
                backend: GpuBackend::Cuda,
                count,
                unified_memory: is_unified,
            })
            .collect()
    }

    /// Parse `nvidia-smi --query-gpu=memory.total,name --format=csv,noheader,nounits`.
    /// Groups same-model cards and keeps per-card VRAM (never sums across cards).
    fn parse_nvidia_smi_list(text: &str) -> Vec<GpuInfo> {
        let mut grouped: BTreeMap<String, (u32, f64)> = BTreeMap::new();

        for line in text.lines() {
            let line = line.trim();
            if line.is_empty() {
                continue;
            }
            let parts: Vec<&str> = line.splitn(2, ',').collect();

            let name = parts
                .get(1)
                .map(|s| s.trim())
                .filter(|s| !s.is_empty())
                .unwrap_or("NVIDIA GPU")
                .to_string();

            let parsed_vram_mb = parts
                .first()
                .and_then(|s| s.trim().parse::<f64>().ok())
                .unwrap_or(0.0);
            let vram_mb = if parsed_vram_mb > 0.0 {
                parsed_vram_mb
            } else {
                estimate_vram_from_name(&name) * 1024.0
            };

            let entry = grouped.entry(name).or_insert((0, 0.0));
            entry.0 += 1;
            if vram_mb > entry.1 {
                entry.1 = vram_mb;
            }
        }

        if grouped.is_empty() {
            return Vec::new();
        }

        grouped
            .into_iter()
            .map(|(name, (count, per_card_vram_mb))| GpuInfo {
                name,
                vram_gb: if per_card_vram_mb > 0.0 {
                    Some(per_card_vram_mb / 1024.0)
                } else {
                    None
                },
                backend: GpuBackend::Cuda,
                count,
                unified_memory: false,
            })
            .collect()
    }

    /// Detect NVIDIA GPUs via Linux sysfs when nvidia-smi is unavailable.
    /// This is common in containerized environments (e.g. Toolbx) and
    /// Nouveau-based systems.
    fn detect_nvidia_gpu_sysfs_info() -> Option<GpuInfo> {
        if !cfg!(target_os = "linux") {
            return None;
        }

        let entries = std::fs::read_dir("/sys/class/drm").ok()?;
        let mut gpu_count: u32 = 0;
        let mut total_vram_bytes: u64 = 0;
        let mut slot_hints: Vec<String> = Vec::new();
        let mut backend = GpuBackend::Vulkan;

        for entry in entries.flatten() {
            let card_path = entry.path();
            let fname = card_path.file_name()?.to_str()?.to_string();
            // Only look at cardN entries, not connectors (cardN-DP-1, etc.)
            if !fname.starts_with("card") || fname.contains('-') {
                continue;
            }

            let device_path = card_path.join("device");
            let vendor_path = device_path.join("vendor");
            let Ok(vendor) = std::fs::read_to_string(&vendor_path) else {
                continue;
            };
            if vendor.trim() != "0x10de" {
                continue;
            }

            gpu_count += 1;

            if let Ok(vram_str) = std::fs::read_to_string(device_path.join("mem_info_vram_total"))
                && let Ok(vram_bytes) = vram_str.trim().parse::<u64>()
                && vram_bytes > 0
            {
                // Track the maximum per-card VRAM instead of summing across all cards.
                total_vram_bytes = total_vram_bytes.max(vram_bytes);
            }

            if let Ok(uevent) = std::fs::read_to_string(device_path.join("uevent")) {
                for line in uevent.lines() {
                    if let Some(slot) = line.strip_prefix("PCI_SLOT_NAME=") {
                        slot_hints.push(slot.to_string());
                    } else if let Some(driver) = line.strip_prefix("DRIVER=")
                        && driver.eq_ignore_ascii_case("nvidia")
                    {
                        backend = GpuBackend::Cuda;
                    }
                }
            }
        }

        if gpu_count == 0 {
            return None;
        }

        let name = Self::get_nvidia_gpu_name_lspci(&slot_hints)
            .unwrap_or_else(|| "NVIDIA GPU".to_string());

        let mut vram_gb = if total_vram_bytes > 0 {
            Some(total_vram_bytes as f64 / (1024.0 * 1024.0 * 1024.0))
        } else {
            None
        };

        if vram_gb.is_none() {
            let est = estimate_vram_from_name(&name);
            if est > 0.0 {
                vram_gb = Some(est);
            }
        }

        let unified_memory = is_nvidia_unified_memory_gpu(&name);

        Some(GpuInfo {
            name,
            vram_gb,
            backend,
            count: gpu_count,
            unified_memory,
        })
    }

    /// Detect AMD GPUs via rocm-smi (available on Linux with ROCm installed).
    /// Parses per-card VRAM and GPU name from rocm-smi output, returning one
    /// `GpuInfo` per distinct GPU model (like `detect_nvidia_gpus`).
    fn detect_amd_gpu_rocm_info() -> Vec<GpuInfo> {
        let vram_output = match std::process::Command::new("rocm-smi")
            .arg("--showmeminfo")
            .arg("vram")
            .output()
        {
            Ok(o) if o.status.success() => o,
            _ => return Vec::new(),
        };
        let vram_text = match String::from_utf8(vram_output.stdout) {
            Ok(t) => t,
            Err(_) => return Vec::new(),
        };

        let product_text = std::process::Command::new("rocm-smi")
            .arg("--showproductname")
            .output()
            .ok()
            .filter(|o| o.status.success())
            .and_then(|o| String::from_utf8(o.stdout).ok());

        Self::parse_rocm_smi_output(&vram_text, product_text.as_deref())
    }

    /// Parse per-GPU VRAM totals (bytes) from `rocm-smi --showmeminfo vram`.
    ///
    /// Handles both output formats:
    /// * Block (ROCm 5.x / 6.x default): one line per field, e.g.
    ///   `GPU[0] : VRAM Total Memory (B): 8589934592`. The total line is
    ///   matched by containing "total" and not "used".
    /// * Tabular (newer rocm-smi): a header row followed by one row per
    ///   device, e.g.
    ///   `Device  Node  VRAM Total Memory (B)   VRAM Total Used Memory (B)`
    ///   `0       2     34342961152             16893`.
    ///   Device rows begin with the integer device index; the Total column
    ///   precedes the Used column, so the first VRAM-sized number on the row
    ///   is the total.
    fn parse_rocm_vram_bytes(vram_text: &str) -> Vec<u64> {
        let mut out: Vec<u64> = Vec::new();

        // Block format.
        for line in vram_text.lines() {
            let lower = line.to_lowercase();
            if lower.contains("total")
                && !lower.contains("used")
                && let Some(val) = line
                    .split_whitespace()
                    .filter_map(|w| w.parse::<u64>().ok())
                    .next_back()
                && val > 0
            {
                out.push(val);
            }
        }
        if !out.is_empty() {
            return out;
        }

        // Tabular format fallback. A device row starts with the integer device
        // index; pick the first number large enough to be a VRAM total (>= 64
        // MB), which skips the device/node index columns and lands on the
        // Total column before the Used column.
        const MIN_VRAM_BYTES: u64 = 64 * 1024 * 1024; // 64 MB
        for line in vram_text.lines() {
            let mut tokens = line.split_whitespace();
            match tokens.next() {
                Some(first) if first.parse::<u32>().is_ok() => {}
                _ => continue, // not a device data row
            }
            if let Some(total) = line
                .split_whitespace()
                .filter_map(|w| w.parse::<u64>().ok())
                .find(|&v| v >= MIN_VRAM_BYTES)
            {
                out.push(total);
            }
        }
        out
    }

    /// Parse per-GPU product names from `rocm-smi --showproductname`.
    ///
    /// Handles both the block format (`GPU[0] : Card Series: AMD Radeon RX
    /// 7600`) and the tabular format where `Card Series` is a column header
    /// and each device row carries its model in that column. Returns one
    /// name per device, in device order.
    fn parse_rocm_product_names(text: &str) -> Vec<String> {
        // Block format: name is after the last colon on a "Card Series" line,
        // e.g. "GPU[0] : Card Series: AMD Radeon RX 7600". The colon guard
        // avoids matching a tabular "Card Series" column header (no colon).
        let mut block: Vec<String> = Vec::new();
        let mut gfx_versions: Vec<String> = Vec::new();
        for line in text.lines() {
            let lower = line.to_lowercase();
            if lower.contains("card series")
                && line.contains(':')
                && let Some(raw) = line.rsplit(':').next().map(|n| n.trim().to_string())
                && !raw.is_empty()
            {
                // rocm-smi reports "N/A" when it cannot read the marketing name
                // (e.g. libdrm_amdgpu.so missing on Strix Halo APUs). Keep the
                // slot aligned but record it as a generic AMD GPU so callers can
                // fall back to a real name (lspci / the APU model) downstream.
                block.push(if is_placeholder_gpu_name(&raw) {
                    "AMD GPU".to_string()
                } else {
                    raw
                });
            } else if lower.contains("gfx version")
                && line.contains(':')
                && let Some(gfx) = line.rsplit(':').next().map(|g| g.trim().to_string())
                && !gfx.is_empty()
            {
                gfx_versions.push(gfx);
            }
        }
        if !block.is_empty() {
            // Disambiguate generic series names with the GFX version when
            // available: some accelerators (e.g. Instinct MI50/MI60) report
            // `Card Series: AMD Radeon Graphics`, which would otherwise be
            // indistinguishable from — and grouped with — an APU iGPU that
            // reports the same generic name (issue #638).
            if gfx_versions.len() == block.len() {
                for (name, gfx) in block.iter_mut().zip(&gfx_versions) {
                    if Self::is_integrated_gpu_name(name) {
                        *name = format!("{name} ({gfx})");
                    }
                }
            }
            return block;
        }

        // Tabular format: slice the "Card Series" column out of each device
        // row using the header column offsets. The column runs from the start
        // of "Card Series" to the start of the next known column header.
        let mut out: Vec<String> = Vec::new();
        let Some(header) = text
            .lines()
            .find(|l| l.to_lowercase().contains("card series"))
        else {
            return out;
        };
        let header_lower = header.to_lowercase();
        let Some(start) = header_lower.find("card series") else {
            return out;
        };
        let end = ["card model", "card vendor", "card sku", "card partition"]
            .iter()
            .filter_map(|h| header_lower.find(h))
            .filter(|&i| i > start)
            .min()
            .unwrap_or(header.len());

        for line in text.lines() {
            // Device rows start with the integer device index.
            match line.split_whitespace().next() {
                Some(first) if first.parse::<u32>().is_ok() => {}
                _ => continue,
            }
            if line.len() <= start {
                out.push("AMD GPU".to_string());
                continue;
            }
            let slice_end = end.min(line.len());
            let name = line[start..slice_end].trim().to_string();
            out.push(if name.is_empty() || is_placeholder_gpu_name(&name) {
                "AMD GPU".to_string()
            } else {
                name
            });
        }
        out
    }

    /// Parse rocm-smi `--showmeminfo vram` and `--showproductname` output
    /// into one `GpuInfo` per distinct GPU model. Identical models are
    /// grouped with a `count` field, like `parse_nvidia_smi_list`.
    fn parse_rocm_smi_output(vram_text: &str, product_text: Option<&str>) -> Vec<GpuInfo> {
        let per_gpu_vram_bytes = Self::parse_rocm_vram_bytes(vram_text);
        let per_gpu_names = product_text
            .map(Self::parse_rocm_product_names)
            .unwrap_or_default();

        // Filter out integrated GPUs (iGPUs) that have very little VRAM.
        // rocm-smi reports all GPU agents including iGPUs on APUs like
        // Ryzen 9800X3D, which would otherwise inflate the GPU count.
        // Discrete GPUs have > 2 GB VRAM; iGPUs typically show <= 2 GB.
        const IGPU_VRAM_THRESHOLD: u64 = 2 * 1024 * 1024 * 1024; // 2 GB
        let has_discrete = per_gpu_vram_bytes.iter().any(|&v| v > IGPU_VRAM_THRESHOLD);

        // Pair each GPU index with its name and VRAM, filtering iGPUs when
        // discrete GPUs are present.
        let gpu_count = per_gpu_vram_bytes.len().max(per_gpu_names.len());
        let mut grouped: std::collections::BTreeMap<String, (u32, u64)> =
            std::collections::BTreeMap::new();

        for i in 0..gpu_count {
            let vram = per_gpu_vram_bytes.get(i).copied().unwrap_or(0);
            if has_discrete && vram <= IGPU_VRAM_THRESHOLD {
                continue; // skip iGPU
            }
            let name = per_gpu_names
                .get(i)
                .cloned()
                .unwrap_or_else(|| "AMD GPU".to_string());
            let entry = grouped.entry(name).or_insert((0, 0));
            entry.0 += 1;
            if vram > entry.1 {
                entry.1 = vram;
            }
        }

        if grouped.is_empty() {
            return Vec::new();
        }

        grouped
            .into_iter()
            .map(|(name, (count, vram_bytes))| {
                let vram_gb = if vram_bytes > 0 {
                    Some(vram_bytes as f64 / (1024.0 * 1024.0 * 1024.0))
                } else {
                    let est = estimate_vram_from_name(&name);
                    if est > 0.0 { Some(est) } else { None }
                };
                GpuInfo {
                    name,
                    vram_gb,
                    backend: GpuBackend::Rocm,
                    count,
                    unified_memory: false,
                }
            })
            .collect()
    }

    /// Detect AMD GPUs via sysfs on Linux (works without ROCm installed).
    /// AMD vendor ID is 0x1002. Enumerates every `cardN` entry in
    /// `/sys/class/drm`, groups identical models with a `count` (like the
    /// ROCm and NVIDIA paths), and returns one `GpuInfo` per distinct model
    /// so multi-GPU setups are reported in full.
    fn detect_amd_gpu_sysfs_info() -> Vec<GpuInfo> {
        if !cfg!(target_os = "linux") {
            return Vec::new();
        }

        Self::detect_amd_gpu_sysfs_info_from_root(
            std::path::Path::new("/sys/class/drm"),
            |_, slot_hints| Self::get_amd_gpu_name_lspci(slot_hints),
        )
    }

    /// Scan a sysfs-like DRM root for AMD GPUs. Extracted from
    /// `detect_amd_gpu_sysfs_info` so tests can supply a fixture directory
    /// and a stub name resolver without touching the host `/sys` tree or
    /// `lspci`. No `cfg!(target_os = "linux")` guard here — the caller
    /// (production wrapper) owns the platform check so that cross-platform
    /// CI can exercise the fixture tests.
    fn detect_amd_gpu_sysfs_info_from_root(
        drm_root: &std::path::Path,
        resolve_name: impl FnMut(&str, &[String]) -> Option<String>,
    ) -> Vec<GpuInfo> {
        let cards = Self::scan_amd_sysfs_cards(drm_root, resolve_name)
            .into_iter()
            .map(|(name, vram_gb)| {
                // If sysfs gave no VRAM, try to estimate from the name.
                let vram_gb = vram_gb.or_else(|| {
                    let estimated = estimate_vram_from_name(&name);
                    (estimated > 0.0).then_some(estimated)
                });
                (name, vram_gb)
            })
            .collect();
        Self::group_and_filter_amd_sysfs_cards(cards)
    }

    /// Enumerate the AMD (vendor 0x1002) `cardN` entries under a DRM root as
    /// `(name, measured VRAM)` pairs. VRAM comes only from
    /// `mem_info_vram_total`, never from a name-based estimate, so a `Some`
    /// here is a hardware reading; the carveout arithmetic in
    /// [`Self::apu_total_ram_gb`] relies on that.
    fn scan_amd_sysfs_cards(
        drm_root: &std::path::Path,
        mut resolve_name: impl FnMut(&str, &[String]) -> Option<String>,
    ) -> Vec<(String, Option<f64>)> {
        let entries = match std::fs::read_dir(drm_root) {
            Ok(e) => e,
            Err(_) => return Vec::new(),
        };

        let mut cards: Vec<(String, Option<f64>)> = Vec::new();

        for entry in entries.flatten() {
            let card_path = entry.path();
            let fname = match card_path.file_name().and_then(|f| f.to_str()) {
                Some(f) => f.to_string(),
                None => continue,
            };
            // Only look at cardN entries, not cardN-DP-1 etc.
            if !fname.starts_with("card") || fname.contains('-') {
                continue;
            }

            let device_path = card_path.join("device");
            let vendor_path = device_path.join("vendor");
            match std::fs::read_to_string(&vendor_path) {
                Ok(vendor) if vendor.trim() == "0x1002" => {}
                _ => continue,
            }

            // Found an AMD GPU. Try to read VRAM.
            let mut vram_gb: Option<f64> = None;
            let vram_path = device_path.join("mem_info_vram_total");
            if let Ok(vram_str) = std::fs::read_to_string(&vram_path)
                && let Ok(vram_bytes) = vram_str.trim().parse::<u64>()
                && vram_bytes > 0
            {
                vram_gb = Some(vram_bytes as f64 / (1024.0 * 1024.0 * 1024.0));
            }

            // Resolve this card's PCI slot so lspci yields a per-card name.
            let mut slot_hints: Vec<String> = Vec::new();
            if let Ok(uevent) = std::fs::read_to_string(device_path.join("uevent")) {
                for line in uevent.lines() {
                    if let Some(slot) = line.strip_prefix("PCI_SLOT_NAME=") {
                        slot_hints.push(slot.to_string());
                    }
                }
            }

            // Resolve GPU name via the injected resolver.
            let name = resolve_name(&fname, &slot_hints).unwrap_or_else(|| "AMD GPU".to_string());

            cards.push((name, vram_gb));
        }

        cards
    }

    /// Group sysfs AMD cards by model name and drop integrated GPUs when a
    /// discrete card is present. Pure so the #303/#638 multi-GPU
    /// configurations can be regression-tested without a live sysfs.
    fn group_and_filter_amd_sysfs_cards(cards: Vec<(String, Option<f64>)>) -> Vec<GpuInfo> {
        // Group identical models, tracking count and max per-card VRAM.
        let mut grouped: BTreeMap<String, (u32, Option<f64>)> = BTreeMap::new();
        for (name, vram_gb) in cards {
            let entry = grouped.entry(name).or_insert((0, None));
            entry.0 += 1;
            match (entry.1, vram_gb) {
                (Some(existing), Some(new)) if new > existing => entry.1 = Some(new),
                (None, Some(_)) => entry.1 = vram_gb,
                _ => {}
            }
        }

        // Filter out integrated GPUs when discrete GPUs are present. A card
        // whose VRAM could not be read (None) gets the benefit of the doubt:
        // only a *known* small VRAM (<= 2 GB) marks a card as
        // integrated-class. Requiring Some(vram) here silently dropped
        // discrete cards with an unreadable mem_info_vram_total and a name
        // missing from the VRAM estimate table.
        let has_discrete = grouped.iter().any(|(name, (_, vram))| {
            !Self::is_integrated_gpu_name(name) && vram.unwrap_or(0.0) > 2.0
        });
        if has_discrete {
            grouped.retain(|name, (_, vram)| {
                !Self::is_integrated_gpu_name(name) && vram.is_none_or(|v| v > 2.0)
            });
        }

        grouped
            .into_iter()
            .map(|(name, (count, vram_gb))| GpuInfo {
                name,
                // AMD GPU without ROCm — Vulkan is the most likely backend
                vram_gb,
                backend: GpuBackend::Vulkan,
                count,
                unified_memory: false,
            })
            .collect()
    }

    /// Extract AMD GPU name from lspci output.
    fn get_amd_gpu_name_lspci(slot_hints: &[String]) -> Option<String> {
        let text = Self::lspci_output()?;

        // First pass: match exact slot (e.g. "0000:01:00.0"), if available.
        for slot in slot_hints {
            for line in text.lines() {
                let lower = line.to_lowercase();
                if line.starts_with(slot)
                    && (lower.contains("vga") || lower.contains("3d") || lower.contains("display"))
                    && (lower.contains("amd") || lower.contains("ati"))
                    && let Some(model) = Self::extract_model_from_lspci_line(line)
                {
                    return Some(model);
                }
            }
        }

        // Fallback: any AMD/ATI display controller line. Headless/secondary
        // cards (e.g. Instinct MI50s, #638) enumerate as "Display controller",
        // not "VGA compatible controller", so match all three classes just
        // like the slot-hint pass above.
        for line in text.lines() {
            let lower = line.to_lowercase();
            if (lower.contains("vga") || lower.contains("3d") || lower.contains("display"))
                && (lower.contains("amd") || lower.contains("ati"))
                && let Some(model) = Self::extract_model_from_lspci_line(line)
            {
                return Some(model);
            }
        }
        None
    }

    /// Resolve NVIDIA GPU name from lspci, optionally prioritizing specific
    /// PCI slots discovered from sysfs.
    fn get_nvidia_gpu_name_lspci(slot_hints: &[String]) -> Option<String> {
        let text = Self::lspci_output()?;

        // First pass: match exact slot (e.g. "0000:01:00.0"), if available.
        for slot in slot_hints {
            for line in text.lines() {
                let lower = line.to_lowercase();
                if line.starts_with(slot)
                    && (lower.contains("vga") || lower.contains("3d") || lower.contains("display"))
                    && lower.contains("nvidia")
                    && let Some(model) = Self::extract_model_from_lspci_line(line)
                {
                    return Some(model);
                }
            }
        }

        // Fallback: any NVIDIA display controller line.
        for line in text.lines() {
            let lower = line.to_lowercase();
            if (lower.contains("vga") || lower.contains("3d") || lower.contains("display"))
                && lower.contains("nvidia")
                && let Some(model) = Self::extract_model_from_lspci_line(line)
            {
                return Some(model);
            }
        }

        None
    }

    /// Read lspci output, with host fallback for containerized environments.
    fn lspci_output() -> Option<String> {
        let local = std::process::Command::new("lspci")
            .arg("-nnD")
            .output()
            .ok()
            .filter(|o| o.status.success())
            .and_then(|o| String::from_utf8(o.stdout).ok());

        if local.is_some() {
            return local;
        }

        std::process::Command::new("flatpak-spawn")
            .args(["--host", "lspci", "-nnD"])
            .output()
            .ok()
            .filter(|o| o.status.success())
            .and_then(|o| String::from_utf8(o.stdout).ok())
    }

    /// Extract a likely model name from an lspci line.
    /// Prefers human-readable bracketed tokens (e.g. "[GeForce RTX 2060]").
    fn extract_model_from_lspci_line(line: &str) -> Option<String> {
        let mut best: Option<String> = None;
        let mut rest = line;

        while let Some(start) = rest.find('[') {
            let after = &rest[start + 1..];
            let Some(end) = after.find(']') else { break };
            let token = after[..end].trim();
            let usable = !token.is_empty()
                && !token.contains(':')
                && !token.chars().all(|c| c.is_ascii_digit());

            if usable
                && best
                    .as_ref()
                    .map(|current| token.len() > current.len())
                    .unwrap_or(true)
            {
                best = Some(token.to_string());
            }

            rest = &after[end + 1..];
        }

        if best.is_some() {
            return best;
        }

        // Fallback: text after the first ": " separator.
        line.split_once(": ")
            .map(|(_, right)| right.trim().to_string())
            .filter(|s| !s.is_empty())
    }

    /// Detect GPUs on Windows via WMI (Win32_VideoController).
    /// Returns all discrete GPUs found (AMD, NVIDIA, Intel, etc.).
    /// When both discrete and integrated GPUs are present, the integrated
    /// GPUs are filtered out so the discrete GPU is selected as primary.
    fn detect_gpu_windows_info() -> Vec<GpuInfo> {
        if !cfg!(target_os = "windows") {
            return Vec::new();
        }

        // Use PowerShell to query WMI — more reliable than wmic (deprecated)
        if let Ok(output) = std::process::Command::new("powershell")
            .arg("-NoProfile")
            .arg("-Command")
            .arg("Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM | ForEach-Object { $_.Name + '|' + $_.AdapterRAM }")
            .output()
            && output.status.success()
        {
            // Lossy decode: PowerShell 5.1 emits the console OEM codepage, and
            // a strict decode would throw away every adapter over one ® in a
            // single name.
            let text = String::from_utf8_lossy(&output.stdout);
            let gpus = Self::parse_windows_gpu_list(&text);
            if !gpus.is_empty() {
                let gpus = Self::apply_registry_vram(gpus, &detect_windows_registry_vram());
                return Self::prefer_discrete_gpus(gpus);
            }
        }

        // Fallback to wmic for older Windows
        let gpus = Self::detect_gpu_windows_wmic_list();
        let gpus = Self::apply_registry_vram(gpus, &detect_windows_registry_vram());
        Self::prefer_discrete_gpus(gpus)
    }

    /// Override WMI-derived VRAM with the 64-bit value the display driver
    /// publishes in the registry (issue #830).
    ///
    /// `Win32_VideoController.AdapterRAM` is a `uint32`, so any card with 4 GB
    /// or more reports ~4293918720 bytes. `resolve_wmi_vram` then falls back to
    /// guessing from the product name, and cards missing from that table land
    /// on a generic vendor default — an AMD Radeon AI PRO R9700 (32 GB) was
    /// reported as 8 GB. `HardwareInformation.qwMemorySize` is a REG_QWORD
    /// written by the driver itself, so it is exact for any adapter size.
    ///
    /// Entries are matched to adapters by driver description, which mirrors
    /// `Win32_VideoController.Name` on every mainstream WDDM driver. Adapters
    /// with no registry match keep whatever VRAM detection already produced.
    fn apply_registry_vram(gpus: Vec<GpuInfo>, registry: &[(String, u64)]) -> Vec<GpuInfo> {
        if registry.is_empty() {
            return gpus;
        }
        gpus.into_iter()
            .map(|mut gpu| {
                if let Some(bytes) = Self::match_registry_vram(&gpu.name, registry) {
                    gpu.vram_gb = Some(bytes as f64 / (1024.0 * 1024.0 * 1024.0));
                }
                gpu
            })
            .collect()
    }

    /// Find the registry VRAM entry belonging to `name`.
    ///
    /// An exact driver-description match is preferred; `DriverDesc` is what
    /// populates `Win32_VideoController.Name`, so it normally hits. Failing
    /// that, a containment match is accepted only when exactly one entry
    /// matches — an ambiguous partial (a "RTX 4090" adapter against both
    /// "RTX 4090" and "RTX 4090 Ti" entries) must not silently bind to the
    /// wrong card's VRAM.
    fn match_registry_vram(name: &str, registry: &[(String, u64)]) -> Option<u64> {
        let target = normalize_gpu_name_for_match(name);
        if target.is_empty() {
            return None;
        }
        if let Some((_, bytes)) = registry
            .iter()
            .find(|(desc, _)| normalize_gpu_name_for_match(desc) == target)
        {
            return Some(*bytes);
        }
        let mut partial = registry.iter().filter(|(desc, _)| {
            let desc = normalize_gpu_name_for_match(desc);
            !desc.is_empty() && (desc.contains(&target) || target.contains(&desc))
        });
        match (partial.next(), partial.next()) {
            (Some((_, bytes)), None) => Some(*bytes),
            _ => None,
        }
    }

    /// Parse `DriverDesc|bytes` lines from the display-class registry dump.
    ///
    /// Implausibly small values are dropped rather than trusted. The WDK
    /// documents `HardwareInformation.MemorySize` as megabytes while WMI maps
    /// the same value to a bytes-typed field, and real drivers write bytes; a
    /// driver that followed the WDK to the letter would report "8192" for an
    /// 8 GB card. Anything under 64 MiB is therefore treated as unusable, so a
    /// mis-typed value falls back to the existing estimate instead of
    /// overwriting a good one with a few kilobytes.
    fn parse_windows_registry_vram(text: &str) -> Vec<(String, u64)> {
        // Smallest value that can plausibly be a byte count for a real
        // adapter. Below this the units are wrong, not the card.
        const MIN_PLAUSIBLE_VRAM_BYTES: u64 = 64 * 1024 * 1024;

        let mut entries = Vec::new();
        for line in text.lines() {
            let line = line.trim();
            if line.is_empty() {
                continue;
            }
            let Some((desc, bytes)) = line.split_once('|') else {
                continue;
            };
            let desc = desc.trim();
            let Ok(bytes) = bytes.trim().parse::<u64>() else {
                continue;
            };
            if desc.is_empty() || bytes < MIN_PLAUSIBLE_VRAM_BYTES {
                continue;
            }
            entries.push((desc.to_string(), bytes));
        }
        entries
    }

    /// RAM figure for AMD APUs with a BIOS UMA carveout (issues #810, #964).
    ///
    /// The OS view of RAM excludes the carveout, so a 32 GB machine with an
    /// 8 GB frame buffer reports ~24 GB, and a 128 GB Strix Halo with 96 GB
    /// given to the GPU reports ~31 GB. For AMD APUs, recover the installed
    /// total:
    ///
    /// * Windows: installed DIMM capacity (`Win32_PhysicalMemory`, read from
    ///   SMBIOS and unaffected by the carveout), preferred when the gap is
    ///   carveout-sized.
    /// * Linux: the carveout itself, added back. amdgpu exposes it verbatim
    ///   as the integrated GPU's `mem_info_vram_total`; nothing in sysfs
    ///   reports the installed total directly.
    ///
    /// Everything else keeps the sysinfo figure, as does any failure of the
    /// platform query. `available_ram_gb` is untouched either way, so fit
    /// grading against currently-free RAM is unaffected.
    fn apu_total_ram_gb(cpu_name: &str, sysinfo_total_gb: f64) -> f64 {
        if !is_amd_apu(cpu_name) {
            return sysinfo_total_gb;
        }
        if cfg!(target_os = "windows") {
            return match detect_windows_physical_total_ram_gb() {
                Some(physical) => apply_ram_carveout_override(sysinfo_total_gb, physical),
                None => sysinfo_total_gb,
            };
        }
        match Self::linux_apu_uma_carveout_gb() {
            Some(carveout) => sysinfo_total_gb + carveout,
            None => sysinfo_total_gb,
        }
    }

    /// The BIOS UMA carveout of the APU's integrated GPU on Linux, read from
    /// amdgpu's `mem_info_vram_total`. `None` off Linux, without sysfs, or
    /// when the integrated card cannot be singled out (see
    /// [`Self::apu_uma_carveout_from_cards`]).
    fn linux_apu_uma_carveout_gb() -> Option<f64> {
        if !cfg!(target_os = "linux") {
            return None;
        }
        let cards =
            Self::scan_amd_sysfs_cards(std::path::Path::new("/sys/class/drm"), |_, hints| {
                Self::get_amd_gpu_name_lspci(hints)
            });
        Self::apu_uma_carveout_from_cards(&cards)
    }

    /// Pick the integrated GPU's measured VRAM out of the AMD cards sysfs
    /// exposes; on an APU that is the firmware carveout. Exactly one
    /// integrated-class card with a readable `mem_info_vram_total` is
    /// required: a lone discrete card (iGPU disabled in BIOS) or two
    /// integrated-looking cards yield nothing rather than a guess, since the
    /// figure is added straight onto the RAM total.
    fn apu_uma_carveout_from_cards(cards: &[(String, Option<f64>)]) -> Option<f64> {
        let mut igpu_vram = cards
            .iter()
            .filter(|(name, _)| Self::is_integrated_gpu_name(name))
            .filter_map(|(_, vram_gb)| *vram_gb);
        let carveout = igpu_vram.next()?;
        if igpu_vram.next().is_some() {
            return None;
        }
        Some(carveout)
    }

    /// Fallback Windows GPU detection via wmic (works on older systems).
    fn detect_gpu_windows_wmic_list() -> Vec<GpuInfo> {
        let output = match std::process::Command::new("wmic")
            .arg("path")
            .arg("win32_VideoController")
            .arg("get")
            .arg("Name,AdapterRAM")
            .arg("/format:csv")
            .output()
        {
            Ok(o) if o.status.success() => o,
            _ => return Vec::new(),
        };

        let text = match String::from_utf8(output.stdout) {
            Ok(t) => t,
            Err(_) => return Vec::new(),
        };

        let mut gpus = Vec::new();
        // CSV format: Node,AdapterRAM,Name
        for line in text.lines().skip(1) {
            let line = line.trim();
            if line.is_empty() {
                continue;
            }
            let parts: Vec<&str> = line.split(',').collect();
            if parts.len() >= 3 {
                let raw_vram: u64 = parts[1].trim().parse().unwrap_or(0);
                let name = parts[2..].join(",").trim().to_string();
                if let Some(gpu) = Self::windows_gpu_from_wmi(name, raw_vram) {
                    gpus.push(gpu);
                }
            }
        }
        gpus
    }

    /// Parse all GPU entries from PowerShell output (Name|AdapterRAM per line).
    fn parse_windows_gpu_list(text: &str) -> Vec<GpuInfo> {
        let mut gpus = Vec::new();
        for line in text.lines() {
            let line = line.trim();
            if line.is_empty() {
                continue;
            }
            let Some((name, raw_vram)) = line.split_once('|') else {
                continue;
            };
            let Ok(raw_vram) = raw_vram.trim().parse::<u64>() else {
                continue;
            };
            let name = name.trim().to_string();

            if let Some(gpu) = Self::windows_gpu_from_wmi(name, raw_vram) {
                gpus.push(gpu);
            }
        }
        gpus
    }

    /// Convert one Win32_VideoController row into an inference-capable GPU.
    fn windows_gpu_from_wmi(name: String, raw_vram: u64) -> Option<GpuInfo> {
        let lower = name.to_lowercase();
        if lower.is_empty()
            || lower.contains("microsoft")
            || lower.contains("basic")
            || lower.contains("virtual")
            || Self::is_legacy_intel_igpu_name(&name)
        {
            return None;
        }

        Some(GpuInfo {
            backend: Self::infer_gpu_backend(&name),
            vram_gb: Self::resolve_wmi_vram(raw_vram, &name),
            name,
            count: 1,
            unified_memory: false,
        })
    }

    /// Intel HD / UHD Graphics has no useful LLM compute: it predates the
    /// integrated generations current oneAPI runtimes target, and even with
    /// SYCL configured it runs slower than the CPU beside it (a UHD 630 box
    /// in issue #964 ran every Ollama load at 100% CPU). Its "VRAM" is a
    /// shared-memory aperture (WMI AdapterRAM) or the whole RAM pool echoed
    /// back (lspci / Vulkan), so exposing it as a GPU double-counts system
    /// RAM and routes fits through GPU mode (issues #840, #964). Iris Xe and
    /// Arc integrated GPUs are kept: they are real SYCL / Vulkan targets with
    /// a unified pool (issue #609).
    fn is_legacy_intel_igpu_name(name: &str) -> bool {
        let lower = name.to_lowercase();
        lower.contains("intel") && lower.contains("hd graphics")
    }

    /// When both discrete and integrated GPUs are detected on Windows,
    /// drop the integrated GPUs so the discrete GPU becomes primary.
    /// If only integrated GPUs are present, keep them all (iGPU-only systems).
    fn prefer_discrete_gpus(gpus: Vec<GpuInfo>) -> Vec<GpuInfo> {
        let discrete: Vec<GpuInfo> = gpus
            .iter()
            .filter(|g| !Self::is_integrated_gpu(&g.name, g.vram_gb))
            .cloned()
            .collect();

        if discrete.is_empty() {
            // No discrete GPUs found; keep everything (iGPU-only system).
            gpus
        } else {
            discrete
        }
    }

    /// VRAM-aware integrated-GPU check.
    ///
    /// Intel iGPU product names (UHD/HD/Iris) are conclusive, but the AMD
    /// "Radeon Graphics" pattern is ambiguous: datacenter accelerators like
    /// the Instinct MI50/MI60 report the generic `Card Series: AMD Radeon
    /// Graphics` through rocm-smi on some firmware. No true iGPU has this
    /// much *dedicated* VRAM, so a large-VRAM AMD-generic device is treated
    /// as discrete rather than dropped (issue #638).
    fn is_integrated_gpu(name: &str, vram_gb: Option<f64>) -> bool {
        const AMD_GENERIC_DISCRETE_VRAM_GB: f64 = 8.0;
        if !Self::is_integrated_gpu_name(name) {
            return false;
        }
        // A named AMD mobile iGPU (Radeon 610M…890M) is conclusively
        // integrated. With BIOS UMA carveouts of 8 GB+ now reported correctly
        // (issue #810), these would otherwise trip the large-VRAM "generic
        // name is really an Instinct" escape hatch below (issue #638) and be
        // promoted to discrete.
        if Self::is_amd_mobile_igpu_name(name) {
            return true;
        }
        let lower = name.to_lowercase();
        let amd_generic = lower.contains("radeon") && !lower.contains("(integrated)");
        !(amd_generic && vram_gb.unwrap_or(0.0) >= AMD_GENERIC_DISCRETE_VRAM_GB)
    }

    /// Heuristic: returns true when the GPU name matches known integrated GPU
    /// patterns on Windows (Intel UHD/HD/Iris, AMD Radeon Graphics without a
    /// discrete model number like RX).
    fn is_integrated_gpu_name(name: &str) -> bool {
        let lower = name.to_lowercase();

        // Explicitly tagged as integrated (e.g. from APU detection path)
        if lower.contains("(integrated)") {
            return true;
        }

        // Named AMD mobile iGPUs (Radeon 610M…890M) are conclusive even
        // without the "Graphics" suffix some drivers omit.
        if Self::is_amd_mobile_igpu_name(name) {
            return true;
        }

        // Intel integrated: UHD, HD Graphics, Iris (but NOT Intel Arc discrete)
        if lower.contains("intel") {
            return lower.contains("uhd")
                || lower.contains("hd graphics")
                || (lower.contains("iris") && !lower.contains("arc"));
        }

        // AMD integrated: "Radeon Graphics" or "Radeon(TM) Graphics" without
        // a discrete series identifier (RX, PRO, Vega 56/64, VII, W-series).
        if lower.contains("radeon") && lower.contains("graphics") {
            let has_discrete_tag = lower.contains("rx ")
                || lower.contains("pro ")
                || lower.contains("vega")
                || lower.contains(" vii")
                || lower.contains(" w");
            return !has_discrete_tag;
        }

        false
    }

    /// True for AMD's mobile-iGPU naming: "Radeon" plus a bare three-digit
    /// model number suffixed with M ("Radeon 780M Graphics", "Radeon 890M").
    /// No discrete card uses that shape — discrete mobile parts carry an RX
    /// prefix ("Radeon RX 7900M") — so the pattern is conclusive regardless
    /// of reported VRAM.
    fn is_amd_mobile_igpu_name(name: &str) -> bool {
        let lower = name.to_lowercase();
        if !lower.contains("radeon") || lower.contains("rx") {
            return false;
        }
        lower
            .split(|c: char| !c.is_ascii_alphanumeric())
            .any(|tok| {
                tok.len() == 4 && tok.ends_with('m') && tok[..3].chars().all(|c| c.is_ascii_digit())
            })
    }

    /// WMI AdapterRAM is a 32-bit field, capped at ~4 GB.
    /// If reported value is suspiciously low, estimate from GPU name.
    fn resolve_wmi_vram(raw_bytes: u64, name: &str) -> Option<f64> {
        let mut vram_gb = raw_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
        if vram_gb < 0.1 || (vram_gb <= 4.1 && estimate_vram_from_name(name) > 4.1) {
            let estimated = estimate_vram_from_name(name);
            if estimated > 0.0 {
                vram_gb = estimated;
            }
        }
        if vram_gb > 0.0 { Some(vram_gb) } else { None }
    }

    /// Infer the most likely inference backend from a GPU name string.
    fn infer_gpu_backend(name: &str) -> GpuBackend {
        let lower = name.to_lowercase();
        if lower.contains("nvidia")
            || lower.contains("geforce")
            || lower.contains("quadro")
            || lower.contains("tesla")
            || lower.contains("rtx")
        {
            GpuBackend::Cuda
        } else if lower.contains("amd") || lower.contains("radeon") || lower.contains("ati") {
            // On Windows, Vulkan is the primary inference path for AMD GPUs
            // (ROCm support on Windows is limited)
            GpuBackend::Vulkan
        } else if lower.contains("intel") || lower.contains("arc") {
            GpuBackend::Sycl
        } else {
            GpuBackend::Vulkan
        }
    }

    /// Detect Intel GPUs (integrated or discrete Arc) via lspci, with a sysfs
    /// vendor-ID fallback when lspci is unavailable.
    ///
    /// Dedicated VRAM for discrete Arc cards is read from sysfs per PCI
    /// address (issue #609): the `xe` driver exposes per-tile
    /// `tileN/physical_vram_size_bytes` and i915 exposes
    /// `drm/cardN/lmem_total_bytes` (`mem_info_vram_total` is amdgpu-only).
    /// Integrated GPUs (always at PCI address 00:02.0 on Intel platforms)
    /// share system RAM and are reported as unified-memory devices with the
    /// full RAM pool, matching the AMD APU and Apple Silicon conventions.
    /// Legacy HD / UHD iGPUs are skipped entirely (see
    /// [`Self::is_legacy_intel_igpu_name`]).
    fn detect_intel_gpus(total_ram_gb: f64) -> Vec<GpuInfo> {
        if let Some(text) = Self::lspci_output() {
            let gpus = Self::parse_intel_gpus_from_lspci(
                &text,
                total_ram_gb,
                Self::intel_dgpu_vram_gb_from_sysfs,
            );
            if !gpus.is_empty() {
                return gpus;
            }
        }

        // Fallback: lspci unavailable — sysfs vendor ID at least tells us an
        // Intel GPU exists, but not whether it's integrated or discrete.
        if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
            for entry in entries.flatten() {
                let card_path = entry.path();
                let fname = match card_path.file_name().and_then(|f| f.to_str()) {
                    Some(f) => f,
                    None => continue,
                };
                if !fname.starts_with("card") || fname.contains('-') {
                    continue;
                }
                if let Ok(vendor) = std::fs::read_to_string(card_path.join("device/vendor"))
                    && vendor.trim() == "0x8086"
                {
                    // Dedicated VRAM (if any) identifies the card as discrete.
                    let vram_gb = Self::intel_dgpu_vram_gb_from_pci_dir(&card_path.join("device"));
                    return vec![GpuInfo {
                        name: "Intel Graphics".to_string(),
                        vram_gb,
                        backend: GpuBackend::Sycl,
                        count: 1,
                        unified_memory: false,
                    }];
                }
            }
        }

        Vec::new()
    }

    /// Classify Intel display controllers from `lspci -nnD` output.
    /// Separated from [`Self::detect_intel_gpus`] so real lspci captures can
    /// be used as regression fixtures; `dgpu_vram_gb` maps a discrete card's
    /// PCI address to its dedicated VRAM (sysfs in production, a fixture in
    /// tests).
    fn parse_intel_gpus_from_lspci(
        text: &str,
        total_ram_gb: f64,
        dgpu_vram_gb: impl Fn(&str) -> Option<f64>,
    ) -> Vec<GpuInfo> {
        let mut gpus = Vec::new();
        for line in text.lines() {
            let lower = line.to_lowercase();
            let is_display = lower.contains("vga compatible")
                || lower.contains("3d controller")
                || lower.contains("display controller");
            if !is_display || !line.contains("[8086:") {
                continue;
            }
            let name = Self::intel_name_from_lspci_line(line);
            if Self::is_legacy_intel_igpu_name(&name) {
                continue;
            }
            // Intel iGPUs live at PCI 00:02.0 on the root complex; discrete
            // cards enumerate behind a bridge on a nonzero bus.
            let addr = line.split_whitespace().next().unwrap_or("");
            let integrated = addr.ends_with(":00:02.0") || addr == "00:02.0";
            if integrated {
                gpus.push(GpuInfo {
                    name: format!("{name} (integrated)"),
                    vram_gb: Some(total_ram_gb),
                    backend: GpuBackend::Sycl,
                    count: 1,
                    unified_memory: true,
                });
            } else {
                gpus.push(GpuInfo {
                    name,
                    vram_gb: dgpu_vram_gb(addr),
                    backend: GpuBackend::Sycl,
                    count: 1,
                    unified_memory: false,
                });
            }
        }
        gpus
    }

    /// Dedicated VRAM of a discrete Intel GPU, from sysfs by PCI address
    /// (domain-qualified, as printed by `lspci -nnD`, e.g. "0000:03:00.0").
    fn intel_dgpu_vram_gb_from_sysfs(pci_addr: &str) -> Option<f64> {
        Self::intel_dgpu_vram_gb_from_pci_dir(
            &std::path::Path::new("/sys/bus/pci/devices").join(pci_addr),
        )
    }

    /// Read a discrete Intel GPU's dedicated VRAM from its sysfs PCI device
    /// directory. The `xe` driver exposes one `tileN/physical_vram_size_bytes`
    /// per tile (summed here for multi-tile cards); i915 exposes a single
    /// `drm/cardN/lmem_total_bytes`. Returns `None` for iGPUs (neither file
    /// exists) or when the values are unreadable.
    fn intel_dgpu_vram_gb_from_pci_dir(dev_dir: &std::path::Path) -> Option<f64> {
        let mut total_bytes: u64 = 0;

        if let Ok(entries) = std::fs::read_dir(dev_dir) {
            for entry in entries.flatten() {
                let fname = entry.file_name();
                let fname = fname.to_string_lossy();
                if fname.starts_with("tile")
                    && let Ok(text) =
                        std::fs::read_to_string(entry.path().join("physical_vram_size_bytes"))
                    && let Ok(bytes) = text.trim().parse::<u64>()
                {
                    total_bytes += bytes;
                }
            }
        }

        if total_bytes == 0
            && let Ok(entries) = std::fs::read_dir(dev_dir.join("drm"))
        {
            for entry in entries.flatten() {
                let fname = entry.file_name();
                let fname = fname.to_string_lossy();
                if fname.starts_with("card")
                    && !fname.contains('-')
                    && let Ok(text) = std::fs::read_to_string(entry.path().join("lmem_total_bytes"))
                    && let Ok(bytes) = text.trim().parse::<u64>()
                {
                    total_bytes = bytes;
                    break;
                }
            }
        }

        (total_bytes > 0).then(|| total_bytes as f64 / 1_073_741_824.0)
    }

    /// Extract a readable GPU name from an Intel lspci line, e.g.
    /// `"... Intel Corporation Core Ultra 200V Series Processors Arc Graphics
    /// 130V/140V GPU [8086:64a0] (rev 04)"` → `"Intel Arc Graphics 130V/140V"`.
    fn intel_name_from_lspci_line(line: &str) -> String {
        let after = line
            .split_once("Intel Corporation")
            .map(|(_, r)| r)
            .unwrap_or(line);
        let cleaned = after.split(" [8086:").next().unwrap_or(after).trim();
        let mut name = if let Some(idx) = cleaned.find("Arc") {
            // Codename lines bracket the marketing name: "DG2 [Arc A770]".
            format!("Intel {}", cleaned[idx..].trim_end_matches(']'))
        } else if cleaned.is_empty() {
            "Intel Graphics".to_string()
        } else {
            format!("Intel {cleaned}")
        };
        if let Some(stripped) = name.strip_suffix(" GPU") {
            name = stripped.to_string();
        }
        name
    }

    /// Detect Apple Silicon GPU via system_profiler.
    /// Returns total system RAM as VRAM since memory is unified.
    /// The unified memory pool capacity is the total RAM -- it doesn't
    /// fluctuate with current usage the way available RAM does.
    fn detect_apple_gpu(total_ram_gb: f64) -> Option<f64> {
        // system_profiler only exists on macOS
        let output = std::process::Command::new("system_profiler")
            .arg("SPDisplaysDataType")
            .output()
            .ok()?;

        if !output.status.success() {
            return None;
        }

        let text = String::from_utf8(output.stdout).ok()?;

        // Apple Silicon GPUs show "Apple M1/M2/M3/M4" in the chipset line.
        // Discrete AMD/Intel GPUs on older Macs won't match.
        let is_apple_gpu = text.lines().any(|line| {
            let lower = line.to_lowercase();
            lower.contains("apple m") || lower.contains("apple gpu")
        });

        if is_apple_gpu {
            // Unified memory: GPU and CPU share the same RAM pool.
            // Report total RAM as the VRAM capacity.
            Some(total_ram_gb)
        } else {
            None
        }
    }

    /// Detect macOS Metal GPUs from system_profiler.
    ///
    /// This covers Intel Macs with built-in Intel graphics and discrete AMD
    /// Radeon GPUs. Apple Silicon is intentionally skipped because it is
    /// handled by `detect_apple_gpu` as unified memory.
    fn detect_macos_metal_gpus() -> Vec<GpuInfo> {
        if !cfg!(target_os = "macos") {
            return Vec::new();
        }

        let output = std::process::Command::new("system_profiler")
            .args(["SPDisplaysDataType", "-json"])
            .output();
        let Ok(output) = output else {
            return Vec::new();
        };
        if !output.status.success() {
            return Vec::new();
        }

        Self::parse_macos_metal_gpus_from_system_profiler_json(&output.stdout)
    }

    fn parse_macos_metal_gpus_from_system_profiler_json(data: &[u8]) -> Vec<GpuInfo> {
        let Ok(json) = serde_json::from_slice::<serde_json::Value>(data) else {
            return Vec::new();
        };
        let Some(displays) = json.get("SPDisplaysDataType").and_then(|v| v.as_array()) else {
            return Vec::new();
        };
        displays
            .iter()
            .filter_map(|entry| {
                let name = entry
                    .get("sppci_model")
                    .or_else(|| entry.get("_name"))
                    .and_then(|v| v.as_str())?
                    .trim()
                    .to_string();
                let lower = name.to_lowercase();
                if lower.contains("apple m") || lower.contains("apple gpu") {
                    return None;
                }

                let metal = entry
                    .get("spdisplays_mtlgpufamilysupport")
                    .and_then(|v| v.as_str())
                    .map(|s| !s.trim().is_empty())
                    .unwrap_or(false);
                if !metal {
                    return None;
                }

                let vram_gb = entry
                    .get("spdisplays_vram")
                    .or_else(|| entry.get("_spdisplays_vram"))
                    .or_else(|| entry.get("spdisplays_vram_shared"))
                    .and_then(|v| v.as_str())
                    .and_then(parse_memory_size);

                Some(GpuInfo {
                    name,
                    vram_gb,
                    backend: GpuBackend::Metal,
                    count: 1,
                    unified_memory: false,
                })
            })
            .collect()
    }

    fn has_command(command: &str) -> bool {
        let Some(path_var) = std::env::var_os("PATH") else {
            return false;
        };

        for path in std::env::split_paths(&path_var) {
            let candidate = path.join(command);
            if candidate.is_file() {
                return true;
            }

            #[cfg(target_os = "windows")]
            for ext in [".exe", ".cmd", ".bat", ".com"] {
                let candidate = path.join(format!("{command}{ext}"));
                if candidate.is_file() {
                    return true;
                }
            }
        }

        false
    }

    /// Detect GPUs via Vulkan. This is especially useful on Android/Termux,
    /// where vendor-specific Linux utilities may be unavailable.
    fn detect_vulkan_gpu_info() -> Vec<GpuInfo> {
        if !Self::has_command("vulkaninfo") {
            return Vec::new();
        }

        let output = match std::process::Command::new("vulkaninfo")
            .arg("--summary")
            .output()
        {
            Ok(o) if o.status.success() => o,
            _ => match std::process::Command::new("vulkaninfo").output() {
                Ok(o) if o.status.success() => o,
                _ => return Vec::new(),
            },
        };

        let text = String::from_utf8_lossy(&output.stdout);
        Self::parse_vulkan_gpus(&text)
    }

    fn parse_vulkan_gpus(text: &str) -> Vec<GpuInfo> {
        let mut grouped: BTreeMap<String, u32> = BTreeMap::new();

        for name in Self::parse_vulkan_device_names(text) {
            if Self::is_software_vulkan_device(&name) || Self::is_legacy_intel_igpu_name(&name) {
                continue;
            }
            *grouped.entry(name).or_insert(0) += 1;
        }

        grouped
            .into_iter()
            .map(|(name, count)| GpuInfo {
                backend: GpuBackend::Vulkan,
                count,
                name,
                unified_memory: false,
                vram_gb: None,
            })
            .collect()
    }

    fn merge_gpu_sources(mut primary: Vec<GpuInfo>, fallback: Vec<GpuInfo>) -> Vec<GpuInfo> {
        for candidate in fallback {
            let duplicate = primary
                .iter_mut()
                .find(|gpu| Self::is_same_gpu_name(&gpu.name, &candidate.name));
            match duplicate {
                Some(existing) => {
                    if !existing.unified_memory
                        && existing.vram_gb.unwrap_or(0.0) == 0.0
                        && candidate.vram_gb.unwrap_or(0.0) > 0.0
                    {
                        existing.vram_gb = candidate.vram_gb;
                    }
                }
                None => primary.push(candidate),
            }
        }

        primary.sort_by(|a, b| {
            let a_vram = a.vram_gb.unwrap_or(0.0);
            let b_vram = b.vram_gb.unwrap_or(0.0);
            b_vram
                .partial_cmp(&a_vram)
                .unwrap_or(std::cmp::Ordering::Equal)
        });
        primary
    }

    fn is_same_gpu_name(existing_name: &str, candidate_name: &str) -> bool {
        if Self::normalize_gpu_name_for_dedupe(existing_name)
            == Self::normalize_gpu_name_for_dedupe(candidate_name)
        {
            return true;
        }

        // ROCm reports AMD GPUs using a generic family name that lists multiple
        // model variants separated by "/" (e.g. "Radeon RX 7700S/7600/7600S/7600M
        // XT/PRO W7600"), while Vulkan/RADV reports the specific model with a
        // driver codename suffix (e.g. "AMD Radeon RX 7600 XT (RADV NAVI33)").
        // These refer to the same physical GPU but never match via exact
        // normalization, so we do a secondary check: if both names contain "amd"
        // or "radeon" and share at least one 3-5 digit model number, treat them
        // as the same device.
        let e_lower = existing_name.to_lowercase();
        let c_lower = candidate_name.to_lowercase();
        let is_amd = |s: &str| s.contains("radeon") || s.starts_with("amd ") || s.contains(" amd ");
        if is_amd(&e_lower) && is_amd(&c_lower) {
            let e_nums = Self::extract_gpu_model_numbers(&e_lower);
            let c_nums = Self::extract_gpu_model_numbers(&c_lower);
            if !e_nums.is_empty() && e_nums.iter().any(|n| c_nums.contains(n)) {
                return true;
            }
        }

        // Intel: lspci reports platform names ("Intel Arc Graphics 130V/140V
        // (integrated)") while Mesa/Vulkan reports codenames ("Intel(R)
        // Arc(tm) Graphics (LNL)"). Same-model-number matches (A770 vs
        // "Arc A770") are the same device; an integrated entry also matches
        // a Vulkan Intel device with no model number of its own, since a
        // platform has at most one Intel iGPU.
        let is_intel = |s: &str| s.contains("intel");
        if is_intel(&e_lower) && is_intel(&c_lower) {
            let e_nums = Self::extract_gpu_model_numbers(&e_lower);
            let c_nums = Self::extract_gpu_model_numbers(&c_lower);
            if !e_nums.is_empty() && e_nums.iter().any(|n| c_nums.contains(n)) {
                return true;
            }
            // Arc Pro cards use 2-digit model numbers ("Pro B70") that the
            // 3-5 digit extractor drops; compare full letter-prefixed model
            // tokens (a770, b580, b70) as well.
            let e_toks = Self::extract_arc_model_tokens(&e_lower);
            let c_toks = Self::extract_arc_model_tokens(&c_lower);
            if !e_toks.is_empty() && e_toks.iter().any(|t| c_toks.contains(t)) {
                return true;
            }
            // An integrated entry matches a Vulkan Intel device only when the
            // latter has no model identifier at all — a two-digit Arc Pro
            // model ("B70") is a dGPU, not the platform iGPU.
            if (e_lower.contains("(integrated)") && c_nums.is_empty() && c_toks.is_empty())
                || (c_lower.contains("(integrated)") && e_nums.is_empty() && e_toks.is_empty())
            {
                return true;
            }
        }

        false
    }

    /// Extract Intel Arc model tokens — a series letter (A/B/C/D) followed by
    /// 2-4 digits, e.g. "a770", "b580", "b70" — from a lowercased GPU name.
    fn extract_arc_model_tokens(name: &str) -> Vec<String> {
        name.split(|c: char| !c.is_ascii_alphanumeric())
            .filter(|tok| {
                (3..=5).contains(&tok.len())
                    && matches!(tok.as_bytes()[0], b'a' | b'b' | b'c' | b'd')
                    && tok.as_bytes()[1..].iter().all(u8::is_ascii_digit)
            })
            .map(str::to_string)
            .collect()
    }

    /// Extract 3-5 digit numeric tokens from a GPU name (e.g. "7600", "6800").
    /// Used to compare AMD family names from ROCm against specific model names
    /// from Vulkan/RADV for deduplication.
    fn extract_gpu_model_numbers(name: &str) -> Vec<String> {
        let mut numbers = Vec::new();
        let mut current = String::new();
        for c in name.chars() {
            if c.is_ascii_digit() {
                current.push(c);
            } else {
                if current.len() >= 3 && current.len() <= 5 {
                    numbers.push(current.clone());
                }
                current.clear();
            }
        }
        if current.len() >= 3 && current.len() <= 5 {
            numbers.push(current);
        }
        numbers
    }

    fn normalize_gpu_name_for_dedupe(name: &str) -> String {
        let mut normalized = String::with_capacity(name.len());
        let mut last_was_separator = true;

        for ch in name.chars().flat_map(char::to_lowercase) {
            if ch.is_alphanumeric() {
                normalized.push(ch);
                last_was_separator = false;
            } else if !last_was_separator {
                normalized.push(' ');
                last_was_separator = true;
            }
        }

        normalized.trim().to_string()
    }

    fn parse_vulkan_device_names(text: &str) -> Vec<String> {
        let mut names = Vec::new();

        for line in text.lines() {
            let trimmed = line.trim();
            if trimmed.is_empty() {
                continue;
            }

            if let Some((key, value)) = trimmed.split_once('=')
                && key.trim().eq_ignore_ascii_case("deviceName")
            {
                let name = value.trim();
                if !name.is_empty() {
                    names.push(name.to_string());
                }
                continue;
            }

            if let Some(rest) = trimmed.strip_prefix("GPU id")
                && let Some(start) = rest.find('(')
                && let Some(end) = rest.rfind(')')
                && end > start + 1
            {
                let name = rest[start + 1..end].trim();
                if !name.is_empty() {
                    names.push(name.to_string());
                }
            }
        }

        names
    }

    fn is_software_vulkan_device(name: &str) -> bool {
        let lower = name.to_lowercase();
        // Software rasterizers / CPU emulation
        if lower.contains("llvmpipe")
            || lower.contains("lavapipe")
            || lower.contains("swiftshader")
            || lower.contains("software rasterizer")
        {
            return true;
        }
        // CPU compute devices exposed as Vulkan by Mesa/RADV.
        // These appear when ROCm or Mesa exposes the CPU's compute
        // engine as a Vulkan device (e.g. "AMD Ryzen 7 9800X3D
        // 8-Core Processor (RADV RAPHAEL_MENDOCINO)").  CPUs are
        // not inference GPUs and should never be scored as one.
        if lower.contains("core processor") {
            return true;
        }
        false
    }

    /// Detect Ascend NPUs via npu-smi. Returns a vector of NPU info.
    fn detect_ascend_npus() -> Vec<GpuInfo> {
        // 1. Get the list of IDs
        let list_output = match std::process::Command::new("npu-smi")
            .args(["info", "-l"])
            .output()
        {
            Ok(o) if o.status.success() => o,
            _ => return Vec::new(),
        };

        let list_stdout = String::from_utf8_lossy(&list_output.stdout);

        // Extracting IDs: ["0", "1", "2"...]
        let ids: Vec<String> = list_stdout
            .lines()
            .filter(|line| line.contains("NPU ID"))
            .filter_map(|line| line.split(':').next_back())
            .map(|s| s.trim().to_string())
            .collect();

        if ids.is_empty() {
            return Vec::new();
        }

        let mut npu_infos: Vec<GpuInfo> = Vec::new();
        let npu_name = "Ascend NPU";

        // 2. Loop through NPUs
        for id in &ids {
            let mem_output = std::process::Command::new("npu-smi")
                .args(["info", "-t", "memory", "-i", id])
                .output();

            if let Ok(o) = mem_output {
                let s = String::from_utf8_lossy(&o.stdout);

                // Parse HBM Capacity (e.g., from "HBM Capacity(MB) : 65536")
                let mem = s
                    .lines()
                    .find(|l| l.contains("HBM Capacity"))
                    .and_then(|l| l.split(':').next_back())
                    .and_then(|v| v.split_whitespace().next())
                    .and_then(|num| num.parse::<u64>().ok())
                    .unwrap_or(0);

                let npu_info = GpuInfo {
                    name: npu_name.to_string(),
                    vram_gb: Some((mem as f64) / 1024.0),
                    backend: GpuBackend::Ascend,
                    count: 1,
                    unified_memory: false,
                };
                npu_infos.push(npu_info);
            }
        }

        npu_infos
    }

    /// Fallback for available RAM when sysinfo returns 0.
    /// Tries total - used first, then macOS vm_stat parsing.
    fn available_ram_fallback(sys: &System, total_bytes: u64, total_gb: f64) -> f64 {
        // Try total - used from sysinfo (may also use vm_statistics64 internally)
        let used = sys.used_memory();
        if used > 0 && used < total_bytes {
            return (total_bytes - used) as f64 / (1024.0 * 1024.0 * 1024.0);
        }

        // macOS fallback: parse vm_stat output
        if let Some(avail) = Self::available_ram_from_vm_stat() {
            return avail;
        }

        // Last resort: assume 80% of total is available (conservative)
        total_gb * 0.8
    }

    /// Parse macOS `vm_stat` to compute available memory.
    /// Available ≈ (free + inactive + purgeable) * page_size
    fn available_ram_from_vm_stat() -> Option<f64> {
        let output = std::process::Command::new("vm_stat").output().ok()?;
        if !output.status.success() {
            return None;
        }
        let text = String::from_utf8(output.stdout).ok()?;

        // First line: "Mach Virtual Memory Statistics: (page size of NNNNN bytes)"
        let page_size: u64 = text
            .lines()
            .next()
            .and_then(|line| {
                line.split("page size of ")
                    .nth(1)?
                    .split(' ')
                    .next()?
                    .parse()
                    .ok()
            })
            .unwrap_or(16384); // Apple Silicon default is 16 KB pages

        let mut free: u64 = 0;
        let mut inactive: u64 = 0;
        let mut purgeable: u64 = 0;

        for line in text.lines() {
            if let Some(val) = Self::parse_vm_stat_line(line, "Pages free") {
                free = val;
            } else if let Some(val) = Self::parse_vm_stat_line(line, "Pages inactive") {
                inactive = val;
            } else if let Some(val) = Self::parse_vm_stat_line(line, "Pages purgeable") {
                purgeable = val;
            }
        }

        let available_bytes = (free + inactive + purgeable) * page_size;
        if available_bytes > 0 {
            Some(available_bytes as f64 / (1024.0 * 1024.0 * 1024.0))
        } else {
            None
        }
    }

    /// Parse a single vm_stat line like "Pages free:    123456."
    fn parse_vm_stat_line(line: &str, key: &str) -> Option<u64> {
        if !line.starts_with(key) {
            return None;
        }
        line.split(':')
            .nth(1)?
            .trim()
            .trim_end_matches('.')
            .parse()
            .ok()
    }

    fn detect_cpu_name(sys: &System) -> String {
        if let Some(cpu_name) = sys
            .cpus()
            .iter()
            .map(|cpu| cpu.brand().trim())
            .find(|brand| !brand.is_empty() && !brand.eq_ignore_ascii_case("unknown"))
        {
            return cpu_name.to_string();
        }

        if let Some(cpu_name) = Self::read_cpu_name_from_proc_cpuinfo() {
            return cpu_name;
        }

        if let Some(cpu_name) = Self::read_android_soc_name() {
            return cpu_name;
        }

        "Unknown CPU".to_string()
    }

    fn read_cpu_name_from_proc_cpuinfo() -> Option<String> {
        #[cfg(target_os = "linux")]
        {
            let text = std::fs::read_to_string("/proc/cpuinfo").ok()?;
            Self::parse_cpu_name_from_cpuinfo(&text)
        }

        #[cfg(not(target_os = "linux"))]
        {
            None
        }
    }

    fn parse_cpu_name_from_cpuinfo(text: &str) -> Option<String> {
        for key in ["model name", "hardware", "processor", "cpu model", "model"] {
            for line in text.lines() {
                let Some((lhs, rhs)) = line.split_once(':') else {
                    continue;
                };
                if lhs.trim().eq_ignore_ascii_case(key) {
                    let candidate = rhs.trim();
                    if !candidate.is_empty() && !candidate.eq_ignore_ascii_case("unknown") {
                        return Some(candidate.to_string());
                    }
                }
            }
        }

        None
    }

    fn read_android_soc_name() -> Option<String> {
        #[cfg(target_os = "linux")]
        {
            let output = std::process::Command::new("getprop")
                .arg("ro.soc.model")
                .output()
                .ok()?;
            if !output.status.success() {
                return None;
            }

            let model = String::from_utf8(output.stdout).ok()?;
            let model = model.trim();
            if model.is_empty() {
                return None;
            }

            Some(model.to_string())
        }

        #[cfg(not(target_os = "linux"))]
        {
            None
        }
    }

    /// Override the primary GPU's VRAM with a user-specified value (in GB).
    /// This is used by the `--memory` CLI flag when GPU autodetection fails.
    /// If no GPU was detected, this creates a synthetic GPU entry.
    pub fn with_gpu_memory_override(mut self, vram_gb: f64) -> Self {
        if self.gpus.is_empty() {
            // No GPU was detected; create a synthetic one.
            let backend = if cfg!(target_arch = "aarch64")
                || self.cpu_name.to_lowercase().contains("apple")
            {
                GpuBackend::Metal
            } else {
                GpuBackend::Cuda
            };
            self.gpus.push(GpuInfo {
                name: "User-specified GPU".to_string(),
                vram_gb: Some(vram_gb),
                backend,
                count: 1,
                unified_memory: false,
            });
            self.has_gpu = true;
            self.gpu_vram_gb = Some(vram_gb);
            self.total_gpu_vram_gb = Some(vram_gb);
            self.gpu_name = Some("User-specified GPU".to_string());
            self.gpu_count = 1;
            self.backend = backend;
        } else {
            // Override the primary (first) GPU's VRAM.
            self.gpus[0].vram_gb = Some(vram_gb);
            self.gpu_vram_gb = Some(vram_gb);
            // Update total VRAM: per-card VRAM * count.
            let count = self.gpus[0].count;
            self.total_gpu_vram_gb = Some(vram_gb * count as f64);
            self.has_gpu = true;
        }
        // The detected GPU-available cap describes the real host, not the
        // simulated one; clear it rather than report a stale figure.
        self.gpu_available_gb = None;
        self
    }

    /// Override total and available system RAM with a user-specified value (in GB).
    /// Sets available RAM to 90% of the override to model typical system usage.
    /// On unified-memory systems (Apple Silicon), this also updates GPU VRAM
    /// to stay consistent — use `--memory` after `--ram` to override VRAM separately.
    pub fn with_ram_override(mut self, ram_gb: f64) -> Self {
        self.total_ram_gb = ram_gb;
        self.available_ram_gb = ram_gb * 0.9;
        // The detected GPU-available cap describes the real host, not the
        // simulated one; clear it rather than report a stale figure.
        self.gpu_available_gb = None;
        if self.unified_memory {
            self.gpu_vram_gb = Some(ram_gb);
            self.total_gpu_vram_gb = Some(ram_gb);
            for gpu in &mut self.gpus {
                if gpu.unified_memory {
                    gpu.vram_gb = Some(ram_gb);
                }
            }
        }
        self
    }

    /// Override the detected CPU core count with a user-specified value.
    pub fn with_cpu_core_override(mut self, cores: usize) -> Self {
        self.total_cpu_cores = cores;
        self
    }

    /// Apply a hardware profile's memory capacity (see [`crate::hwprofile`]).
    ///
    /// `unified_memory` is set before the RAM override so that a unified
    /// profile makes VRAM track `total_ram_gb` — the two describe one pool, and
    /// a profile that disagreed with itself would score models against a
    /// machine that cannot exist.
    ///
    /// A unified profile applied to a host with no detected GPU synthesizes
    /// one, exactly as `--memory` does: without it every model would fall to
    /// the CPU-only path and the profile could never reproduce the machine it
    /// names. Profiles carry no VRAM figure of their own, so a non-unified
    /// profile leaves the detected VRAM alone.
    pub fn with_profile_capacity(mut self, total_ram_gb: f64, unified_memory: bool) -> Self {
        if unified_memory && self.gpus.is_empty() {
            self = self.with_gpu_memory_override(total_ram_gb);
        }
        self.unified_memory = unified_memory;
        for gpu in &mut self.gpus {
            gpu.unified_memory = unified_memory;
        }
        self.with_ram_override(total_ram_gb)
    }

    pub fn display(&self) {
        println!("\n=== System Specifications ===");
        println!("CPU: {} ({} cores)", self.cpu_name, self.total_cpu_cores);
        println!("Total RAM: {:.2} GB", self.total_ram_gb);
        println!("Available RAM: {:.2} GB", self.available_ram_gb);
        if let Some(bw) = measured_ram_bandwidth_gbps() {
            println!("RAM Bandwidth: ~{bw:.0} GB/s (measured)");
        }
        println!("Backend: {}", self.backend.label());

        if self.gpus.is_empty() {
            println!("GPU: Not detected");
        } else {
            for (i, gpu) in self.gpus.iter().enumerate() {
                let prefix = if self.gpus.len() > 1 {
                    format!("GPU {}: ", i + 1)
                } else {
                    "GPU: ".to_string()
                };
                if gpu.unified_memory {
                    println!(
                        "{}{}",
                        prefix,
                        format_unified_memory_line(
                            &gpu.name,
                            gpu.vram_gb.unwrap_or(0.0),
                            self.gpu_available_gb,
                            gpu.backend.label(),
                        )
                    );
                } else {
                    match gpu.vram_gb {
                        Some(vram) if vram > 0.0 => {
                            if gpu.count > 1 {
                                let total_vram = vram * gpu.count as f64;
                                println!(
                                    "{}{} x{} ({:.2} GB VRAM each = {:.0} GB total, {})",
                                    prefix,
                                    gpu.name,
                                    gpu.count,
                                    vram,
                                    total_vram,
                                    gpu.backend.label()
                                );
                            } else {
                                println!(
                                    "{}{} ({:.2} GB VRAM, {})",
                                    prefix,
                                    gpu.name,
                                    vram,
                                    gpu.backend.label()
                                );
                            }
                        }
                        Some(_) => println!(
                            "{}{} (shared system memory, {})",
                            prefix,
                            gpu.name,
                            gpu.backend.label()
                        ),
                        None => println!(
                            "{}{} (VRAM unknown, {})",
                            prefix,
                            gpu.name,
                            gpu.backend.label()
                        ),
                    }
                }
            }
        }
        println!();
    }
}

/// Format the unified-memory GPU line. When the GPU-available figure is known
/// (Apple Silicon), it is shown alongside the total shared pool; otherwise the
/// line falls back to reporting the shared pool alone. The GPU-available
/// figure is clamped to the shared pool: Metal passes `iogpu.wired_limit_mb`
/// through verbatim, so a sysctl set above physical RAM would otherwise be
/// reported as-is.
pub(crate) fn format_unified_memory_line(
    name: &str,
    shared_gb: f64,
    gpu_available_gb: Option<f64>,
    backend_label: &str,
) -> String {
    match gpu_available_gb {
        Some(available) => {
            let available = available.min(shared_gb);
            format!(
                "{name} (unified memory, {available:.2} GB GPU-available of {shared_gb:.2} GB shared, {backend_label})"
            )
        }
        None => format!("{name} (unified memory, {shared_gb:.2} GB shared, {backend_label})"),
    }
}

/// Query how much unified memory the GPU may wire on Apple Silicon.
///
/// Metal's `recommendedMaxWorkingSetSize` reflects the kernel's wired limit,
/// including a manual `sysctl iogpu.wired_limit_mb` override — verified on an
/// M3 (16 GB): raising the sysctl to 14000 MiB made Metal report exactly
/// 14000 MiB — so no separate sysctl read is needed. Returns `None` when no
/// default Metal device is available. Reported in base-2 GB to match the rest
/// of the display.
#[cfg(target_os = "macos")]
pub(crate) fn detect_gpu_available_gb() -> Option<f64> {
    use objc2_metal::{MTLCreateSystemDefaultDevice, MTLDevice};
    let device = MTLCreateSystemDefaultDevice()?;
    Some(device.recommendedMaxWorkingSetSize() as f64 / (1024.0 * 1024.0 * 1024.0))
}

/// Non-macOS platforms have no Metal working-set concept.
#[cfg(not(target_os = "macos"))]
pub(crate) fn detect_gpu_available_gb() -> Option<f64> {
    None
}

/// Parse a human-readable memory size string into gigabytes.
/// Accepts formats: "32G", "32g", "32GB", "32gb", "32000M", "32000m", "32000MB", etc.
/// Returns `None` if the input is malformed.
pub fn parse_memory_size(s: &str) -> Option<f64> {
    let s = s.trim();
    if s.is_empty() {
        return None;
    }

    // Split into numeric part and suffix
    let num_end = s
        .find(|c: char| !c.is_ascii_digit() && c != '.')
        .unwrap_or(s.len());
    let (num_str, suffix) = s.split_at(num_end);
    let value: f64 = num_str.parse().ok()?;
    if value < 0.0 {
        return None;
    }

    let suffix = suffix.trim().to_lowercase();
    match suffix.as_str() {
        "g" | "gb" | "gib" | "" => Some(value),     // already in GB
        "m" | "mb" | "mib" => Some(value / 1024.0), // MB → GB
        "t" | "tb" | "tib" => Some(value * 1024.0), // TB → GB
        _ => None,
    }
}

pub fn is_running_in_wsl() -> bool {
    static IS_WSL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
    *IS_WSL.get_or_init(detect_running_in_wsl)
}

fn detect_running_in_wsl() -> bool {
    if !cfg!(target_os = "linux") {
        return false;
    }

    if std::env::var_os("WSL_INTEROP").is_some() || std::env::var_os("WSL_DISTRO_NAME").is_some() {
        return true;
    }

    ["/proc/sys/kernel/osrelease", "/proc/version"]
        .iter()
        .any(|path| {
            std::fs::read_to_string(path)
                .map(|text| text.to_ascii_lowercase().contains("microsoft"))
                .unwrap_or(false)
        })
}

/// Check if the CPU name indicates an AMD APU with unified memory architecture.
/// These APUs share the full system RAM between CPU and GPU (like Apple Silicon).
/// Currently covers:
///  - Ryzen AI MAX / MAX+ (Strix Halo): up to 128 GB unified.
///  - Ryzen AI 9 / 7 / 5 (Strix Point, Krackan Point): configurable shared
///    memory, users can allocate most of system RAM to GPU via BIOS.
/// All Ryzen AI APUs have integrated Radeon GPUs that share system memory.
/// Placeholder GPU-name tokens that mean detection failed to read a real
/// marketing name (rocm-smi prints "N/A" when it can't load libdrm). These
/// must never be used as an actual GPU identity.
fn is_placeholder_gpu_name(name: &str) -> bool {
    let lower = name.trim().to_lowercase();
    matches!(
        lower.as_str(),
        "" | "n/a" | "na" | "n-a" | "n\\a" | "unknown" | "none" | "null" | "-"
    )
}

/// Whether a GPU name is too generic to identify the specific model, so a more
/// descriptive fallback (e.g. the APU model string) should be preferred.
fn is_generic_amd_gpu_name(name: &str) -> bool {
    let lower = name.trim().to_lowercase();
    matches!(
        lower.as_str(),
        "amd gpu" | "amd/ati" | "radeon graphics" | "amd radeon graphics"
    )
}

fn is_amd_unified_memory_apu(cpu_name: &str) -> bool {
    let lower = cpu_name.to_lowercase();
    // Only "Ryzen AI MAX" / "Ryzen AI MAX+" APUs have a large unified memory
    // pool shared between CPU and GPU (similar to Apple Silicon).
    // Regular Ryzen AI chips (e.g. HX 370, HX 365) have a standard small iGPU
    // and should NOT be treated as unified-memory systems.
    // Examples that match:
    //   "AMD Ryzen AI MAX+ 395 w/ Radeon 8060S"
    //   "AMD Ryzen AI MAX 390"
    if lower.contains("ryzen ai max") {
        return true;
    }
    false
}

/// True for any AMD APU whose iGPU shares system RAM — the population that can
/// carry a BIOS UMA carveout. Windows bakes the iGPU into the CPU brand string
/// ("AMD Ryzen 7 8845HS w/ Radeon 780M Graphics"), so Ryzen + Radeon in the
/// CPU name is the reliable signal; desktop CPUs without an iGPU carveout
/// ("AMD Ryzen 9 7950X") don't carry it.
fn is_amd_apu(cpu_name: &str) -> bool {
    let lower = cpu_name.to_lowercase();
    lower.contains("ryzen") && lower.contains("radeon")
}

/// Minimum gap between installed DIMM capacity and the OS view of RAM before
/// the physical figure is preferred. Ordinary firmware/reserved overhead is
/// well under 1 GB; BIOS UMA carveout options start at 1 GB.
const UMA_CARVEOUT_MIN_GAP_GB: f64 = 1.0;

/// Pure decision half of the issue-#810 RAM fix: prefer the physical DIMM
/// total only when it exceeds the sysinfo figure by a clear carveout-sized
/// margin, so machines without a carveout keep the OS view (which already
/// nets out normal reserved memory).
fn apply_ram_carveout_override(sysinfo_total_gb: f64, physical_total_gb: f64) -> f64 {
    if physical_total_gb - sysinfo_total_gb >= UMA_CARVEOUT_MIN_GAP_GB {
        physical_total_gb
    } else {
        sysinfo_total_gb
    }
}

/// Query total installed physical RAM on Windows by summing DIMM capacities
/// from WMI `Win32_PhysicalMemory`. Unlike `sysinfo::System::total_memory()`
/// or `Win32_ComputerSystem.TotalPhysicalMemory`, this reads directly from
/// SMBIOS and is unaffected by BIOS-level GPU UMA carveouts.
///
/// On AMD Ryzen AI MAX / MAX+ systems where users configure e.g. 96 GB as GPU
/// UMA in BIOS, the OS only sees the remaining ~32 GB as system RAM, causing
/// `sysinfo` to report 32 GB. `Win32_PhysicalMemory.Capacity` correctly sums
/// all installed DIMMs (e.g. 128 GB) regardless of that carveout.
///
/// Returns `None` when not on Windows, PowerShell is unavailable, or the
/// query fails; callers fall back to the sysinfo value.
fn detect_windows_physical_total_ram_gb() -> Option<f64> {
    if !cfg!(target_os = "windows") {
        return None;
    }
    let output = std::process::Command::new("powershell")
        .args([
            "-NoProfile",
            "-Command",
            "(Get-CimInstance Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum",
        ])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let text = String::from_utf8(output.stdout).ok()?;
    let bytes: u64 = text.trim().parse().ok()?;
    if bytes == 0 {
        return None;
    }
    Some(bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}

/// PowerShell one-liner that dumps `DriverDesc|<bytes>` for every display
/// adapter, reading the driver-published 64-bit VRAM size from the registry.
///
/// Modern WDDM drivers (Windows 10 and later, all three vendors) write
/// `HardwareInformation.qwMemorySize` — a REG_QWORD in bytes. Microsoft never
/// documented it, so its absence on older Windows or an exotic driver is
/// expected rather than a fault; the query then falls back to the documented
/// `HardwareInformation.MemorySize`, which may be a REG_BINARY blob or a
/// REG_DWORD. Both widths are decoded here because PowerShell is the only
/// layer that still knows the registry type. REG_DWORD values above 2 GiB
/// arrive as a negative `Int32` and are folded back into range — without that,
/// the fallback would be dead over exactly the 2-4 GB span it exists to cover.
///
/// Shared with `llmfit doctor` so diagnostic reports carry the same evidence
/// detection uses.
pub const WINDOWS_REGISTRY_VRAM_PS_COMMAND: &str = r"Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}\*' -ErrorAction SilentlyContinue | ForEach-Object { $d = $_.DriverDesc; $m = $_.'HardwareInformation.qwMemorySize'; if ($null -eq $m) { $m = $_.'HardwareInformation.MemorySize' }; if ($m -is [byte[]]) { if ($m.Length -ge 8) { $m = [System.BitConverter]::ToUInt64($m, 0) } elseif ($m.Length -ge 4) { $m = [System.BitConverter]::ToUInt32($m, 0) } else { $m = $null } } elseif ($m -is [int] -and $m -lt 0) { $m = [long]$m + 4294967296 }; if ($d -and $m) { $d + '|' + $m } }";

/// Normalize a GPU product name for cross-source comparison: lowercase, with
/// trademark decoration and punctuation reduced to whitespace, then whitespace
/// collapsed. WMI and the registry disagree on these decorations for the same
/// adapter ("AMD Radeon(TM) Graphics" vs "AMD Radeon Graphics"), and neither
/// string contains the other, so matching has to see through them.
///
/// The textual markers are expanded first, because stripping punctuation alone
/// would turn "(TM)" into a literal "tm" glued to the preceding word. Every
/// remaining non-alphanumeric character is then dropped, which also covers the
/// symbol forms: a ™ or ® arrives from PowerShell 5.1 in the console OEM
/// codepage, so a lossy decode leaves U+FFFD rather than the character itself,
/// and no amount of marker matching would remove it.
fn normalize_gpu_name_for_match(name: &str) -> String {
    let expanded = name
        .to_lowercase()
        .replace("(tm)", " ")
        .replace("(r)", " ")
        .replace("(c)", " ");
    expanded
        .chars()
        .map(|c| if c.is_alphanumeric() { c } else { ' ' })
        .collect::<String>()
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}

/// Read each display adapter's true VRAM size from the Windows registry.
///
/// Returns `(DriverDesc, bytes)` pairs, or an empty vector when not on
/// Windows, when PowerShell is unavailable, or when no driver publishes a
/// memory size — callers then keep their existing estimate.
fn detect_windows_registry_vram() -> Vec<(String, u64)> {
    if !cfg!(target_os = "windows") {
        return Vec::new();
    }
    let Ok(output) = std::process::Command::new("powershell")
        .args(["-NoProfile", "-Command", WINDOWS_REGISTRY_VRAM_PS_COMMAND])
        .output()
    else {
        return Vec::new();
    };
    if !output.status.success() {
        return Vec::new();
    }
    // Windows PowerShell 5.1 writes stdout in the console OEM codepage, so a
    // single non-ASCII byte in any adapter name (® and ™ do appear in INFs)
    // would make a strict decode discard every entry, not just that line.
    let text = String::from_utf8_lossy(&output.stdout);
    SystemSpecs::parse_windows_registry_vram(&text)
}

/// Read total system RAM from /proc/meminfo (Linux only).
/// Used as the unified memory pool on NVIDIA Tegra / Grace Blackwell platforms
/// where nvidia-smi cannot report dedicated VRAM.
fn read_proc_meminfo_total_gb() -> Option<f64> {
    let text = std::fs::read_to_string("/proc/meminfo").ok()?;
    for line in text.lines() {
        if let Some(rest) = line.strip_prefix("MemTotal:") {
            let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
            return Some(kb as f64 / (1024.0 * 1024.0));
        }
    }
    None
}

/// Effective system RAM bandwidth in GB/s, measured once per process with a
/// short multithreaded memcpy sweep (~100 ms total) and cached.
///
/// This is *achievable* streaming bandwidth (STREAM-copy convention: bytes
/// read + bytes written per pass), which is what MoE-offload expert streaming
/// actually sees — typically 60–80% of the spec-sheet peak. Returns `None`
/// if the measurement fails or produces an implausible value; callers should
/// fall back to a conservative constant.
pub fn measured_ram_bandwidth_gbps() -> Option<f64> {
    static MEASURED: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
    *MEASURED.get_or_init(measure_ram_bandwidth_gbps)
}

fn measure_ram_bandwidth_gbps() -> Option<f64> {
    use std::time::{Duration, Instant};

    // Per-thread working set (2 × 32 MiB) must comfortably exceed L3 so we
    // measure DRAM, not cache. A single thread rarely saturates multi-channel
    // memory controllers, so spread the sweep across up to 8 cores.
    const BUF_BYTES: usize = 32 * 1024 * 1024;
    const MEASURE_WINDOW: Duration = Duration::from_millis(80);

    let threads = std::thread::available_parallelism()
        .map(|n| n.get().min(8))
        .unwrap_or(4);

    let barrier = std::sync::Barrier::new(threads);
    let per_thread_gbps: Vec<f64> = std::thread::scope(|scope| {
        let barrier = &barrier;
        let handles: Vec<_> = (0..threads)
            .map(|_| {
                scope.spawn(move || {
                    let src = vec![1u8; BUF_BYTES];
                    let mut dst = vec![0u8; BUF_BYTES];
                    // Warmup pass faults pages in before the timed window.
                    dst.copy_from_slice(&src);
                    std::hint::black_box(&mut dst);
                    barrier.wait();
                    let start = Instant::now();
                    let mut passes = 0u64;
                    while start.elapsed() < MEASURE_WINDOW {
                        dst.copy_from_slice(&src);
                        std::hint::black_box(&mut dst);
                        passes += 1;
                    }
                    let secs = start.elapsed().as_secs_f64();
                    (passes as f64) * (2 * BUF_BYTES) as f64 / secs / 1e9
                })
            })
            .collect();
        handles.into_iter().filter_map(|h| h.join().ok()).collect()
    });

    if per_thread_gbps.len() != threads {
        return None;
    }
    let total: f64 = per_thread_gbps.iter().sum();
    // Sanity band: below 2 GB/s means the measurement was starved (heavy
    // contention, throttled VM); above 4000 GB/s means we measured cache.
    (2.0..=4000.0).contains(&total).then_some(total)
}

/// True for a mobile (laptop) SKU.
///
/// NVIDIA suffixes these adapters with "Laptop GPU"; earlier generations use
/// "Max-Q" or "Mobile". The model-number tables in this module describe
/// desktop cards, and the mobile part that shares a number is a different
/// piece of silicon: an RTX 5070 Laptop GPU carries a 128-bit bus and 8 GB
/// against the desktop card's 192-bit and 12 GB. Matching on the number
/// alone is therefore wrong for every one of them (#919).
fn is_mobile_gpu_name(lower: &str) -> bool {
    lower.contains("laptop") || lower.contains("mobile") || lower.contains("max-q")
}

/// Estimate GPU memory bandwidth in GB/s from the GPU model name.
///
/// Token generation in LLM inference is memory-bandwidth-bound (each token
/// requires reading the full model weights once). Using per-GPU bandwidth
/// produces significantly more accurate tok/s estimates than a single
/// constant for all CUDA/ROCm/Metal devices.
///
/// References:
///  - kipply, "Transformer Inference Arithmetic" (2022)
///  - ggerganov, llama.cpp Apple Silicon benchmarks (Discussion #4167)
///  - Google, "Efficiently Scaling Transformer Inference" (arXiv:2211.05102)
///  - ggerganov, llama.cpp NVIDIA T4 benchmarks (Discussion #4225)
///
/// Returns `None` when the GPU is not recognized, and for mobile SKUs, whose
/// model number does not identify their memory subsystem; callers should fall
/// back to the existing fixed-constant approach.
pub fn gpu_memory_bandwidth_gbps(name: &str) -> Option<f64> {
    let lower = name.to_lowercase();

    // A mobile part shares its model number with a desktop card but not the
    // memory bus behind it: an RTX 5070 Laptop GPU is 128-bit where the
    // desktop 5070 is 192-bit, so the chain below would hand back 672 GB/s
    // for a card that cannot physically exceed ~512. The overestimate runs
    // to 1.8x on the 30/40/50 mobile lines and propagates into every tok/s
    // figure. Fall back to the fixed constant instead of guessing (#919).
    if is_mobile_gpu_name(&lower) {
        return None;
    }

    // ── NVIDIA Consumer (GeForce) ──────────────────────────────────
    // RTX 50 series (Blackwell)
    if lower.contains("5090") {
        return Some(1792.0);
    }
    if lower.contains("5080") {
        return Some(960.0);
    }
    if lower.contains("5070 ti") {
        return Some(896.0);
    }
    if lower.contains("5070") {
        return Some(672.0);
    }
    if lower.contains("5060 ti") {
        return Some(448.0);
    }
    if lower.contains("5060") {
        return Some(256.0);
    }

    // RTX 40 series (Ada Lovelace)
    if lower.contains("4090") {
        return Some(1008.0);
    }
    if lower.contains("4080 super") {
        return Some(736.0);
    }
    if lower.contains("4080") {
        return Some(717.0);
    }
    if lower.contains("4070 ti super") {
        return Some(672.0);
    }
    if lower.contains("4070 ti") {
        return Some(504.0);
    }
    if lower.contains("4070 super") {
        return Some(504.0);
    }
    if lower.contains("4070") {
        return Some(504.0);
    }
    if lower.contains("4060 ti") {
        return Some(288.0);
    }
    if lower.contains("4060") {
        return Some(272.0);
    }

    // RTX 30 series (Ampere)
    if lower.contains("3090 ti") {
        return Some(1008.0);
    }
    if lower.contains("3090") {
        return Some(936.0);
    }
    if lower.contains("3080 ti") {
        return Some(912.0);
    }
    if lower.contains("3080") {
        return Some(760.0);
    }
    if lower.contains("3070 ti") {
        return Some(608.0);
    }
    if lower.contains("3070") {
        return Some(448.0);
    }
    if lower.contains("3060 ti") {
        return Some(448.0);
    }
    if lower.contains("3060") {
        return Some(360.0);
    }

    // RTX 20 series (Turing)
    if lower.contains("2080 ti") {
        return Some(616.0);
    }
    if lower.contains("2080 super") {
        return Some(496.0);
    }
    if lower.contains("2080") {
        return Some(448.0);
    }
    if lower.contains("2070 super") {
        return Some(448.0);
    }
    if lower.contains("2070") {
        return Some(448.0);
    }
    if lower.contains("2060 super") {
        return Some(448.0);
    }
    if lower.contains("2060") {
        return Some(336.0);
    }

    // GTX 16 series (Turing, no RT cores)
    if lower.contains("1660 ti") {
        return Some(288.0);
    }
    if lower.contains("1660 super") {
        return Some(336.0);
    }
    if lower.contains("1660") {
        return Some(192.0);
    }
    if lower.contains("1650 super") {
        return Some(192.0);
    }
    if lower.contains("1650") {
        return Some(128.0);
    }

    // ── NVIDIA Data Center / Professional ──────────────────────────
    if lower.contains("h100 sxm") {
        return Some(3350.0);
    }
    if lower.contains("h100") {
        return Some(2039.0);
    } // PCIe
    if lower.contains("h200") {
        return Some(4800.0);
    }
    if lower.contains("a100 sxm") {
        return Some(2039.0);
    }
    if lower.contains("a100") {
        return Some(1555.0);
    } // PCIe 40GB
    if lower.contains("l40s") {
        return Some(864.0);
    }
    if lower.contains("l40") {
        return Some(864.0);
    }
    if lower.contains("l4") {
        return Some(300.0);
    }
    if lower.contains("a10g") {
        return Some(600.0);
    }
    if lower.contains("a10") {
        return Some(600.0);
    }
    if lower.contains("t4") {
        return Some(320.0);
    }
    if lower.contains("v100 sxm") {
        return Some(900.0);
    }
    if lower.contains("v100") {
        return Some(897.0);
    }
    if lower.contains("a6000") {
        return Some(768.0);
    }
    if lower.contains("a5000") {
        return Some(768.0);
    }
    if lower.contains("a4000") {
        return Some(448.0);
    }

    // ── AMD unified-memory APUs (Strix Halo) ───────────────────────
    // Ryzen AI MAX / MAX+ (Radeon 8050S/8060S): 256-bit LPDDR5X-8000.
    // Names vary by detection path: lspci ("Strix Halo [Radeon ...]"),
    // marketing ("Radeon 8060S"), or the cpu-derived fallback
    // ("AMD Ryzen AI MAX+ 395 w/ Radeon 8060S (integrated)").
    if lower.contains("8060s")
        || lower.contains("8050s")
        || lower.contains("strix halo")
        || lower.contains("ryzen ai max")
    {
        return Some(256.0);
    }

    // ── AMD Discrete (RDNA) ────────────────────────────────────────
    // RX 9000 series (RDNA 4)
    if lower.contains("9070 xt") {
        return Some(624.0);
    }
    if lower.contains("9070") {
        return Some(488.0);
    }

    // RX 7000 series (RDNA 3)
    if lower.contains("7900 xtx") {
        return Some(960.0);
    }
    if lower.contains("7900 xt") {
        return Some(800.0);
    }
    if lower.contains("7900 gre") {
        return Some(576.0);
    }
    if lower.contains("7800 xt") {
        return Some(624.0);
    }
    if lower.contains("7700 xt") {
        return Some(432.0);
    }
    if lower.contains("7600") {
        return Some(288.0);
    }

    // RX 6000 series (RDNA 2)
    if lower.contains("6950 xt") {
        return Some(576.0);
    }
    if lower.contains("6900 xt") {
        return Some(512.0);
    }
    if lower.contains("6800 xt") {
        return Some(512.0);
    }
    if lower.contains("6800") {
        return Some(512.0);
    }
    if lower.contains("6700 xt") {
        return Some(384.0);
    }
    if lower.contains("6600 xt") {
        return Some(256.0);
    }
    if lower.contains("6600") {
        return Some(224.0);
    }

    // AMD data center (CDNA)
    if lower.contains("mi300x") {
        return Some(5300.0);
    }
    if lower.contains("mi300") {
        return Some(5300.0);
    }
    if lower.contains("mi250x") {
        return Some(3277.0);
    }
    if lower.contains("mi250") {
        return Some(3277.0);
    }
    if lower.contains("mi210") {
        return Some(1638.0);
    }
    if lower.contains("mi100") {
        return Some(1229.0);
    }

    // ── Apple Silicon (unified memory bandwidth) ───────────────────
    if lower.contains("m5 max") {
        return Some(614.0);
    }
    if lower.contains("m5 pro") {
        return Some(307.0);
    }
    if lower.contains("m5") {
        return Some(153.6);
    }
    if lower.contains("m4 ultra") {
        return Some(819.0);
    }
    if lower.contains("m4 max") {
        return Some(546.0);
    }
    if lower.contains("m4 pro") {
        return Some(273.0);
    }
    if lower.contains("m4") {
        return Some(120.0);
    }
    if lower.contains("m3 ultra") {
        return Some(800.0);
    }
    if lower.contains("m3 max") {
        return Some(400.0);
    }
    if lower.contains("m3 pro") {
        return Some(150.0);
    }
    if lower.contains("m3") {
        return Some(100.0);
    }
    if lower.contains("m2 ultra") {
        return Some(800.0);
    }
    if lower.contains("m2 max") {
        return Some(400.0);
    }
    if lower.contains("m2 pro") {
        return Some(200.0);
    }
    if lower.contains("m2") {
        return Some(100.0);
    }
    if lower.contains("m1 ultra") {
        return Some(800.0);
    }
    if lower.contains("m1 max") {
        return Some(400.0);
    }
    if lower.contains("m1 pro") {
        return Some(200.0);
    }
    if lower.contains("m1") {
        return Some(68.0);
    }

    None
}

/// Returns the NVIDIA compute capability (major, minor) for a known GPU name.
/// Used to determine compatibility with quantization formats that require
/// specific hardware features (e.g. AWQ requires Turing+ / cc >= 7.5).
///
/// Returns `None` for non-NVIDIA GPUs or unrecognized models.
pub fn gpu_compute_capability(name: &str) -> Option<(u8, u8)> {
    let lower = name.to_lowercase();

    // ── Blackwell (RTX 50xx, B100/B200) ──────────────────────────
    if lower.contains("5090")
        || lower.contains("5080")
        || lower.contains("5070")
        || lower.contains("5060")
        || lower.contains("b200")
        || lower.contains("b100")
        || lower.contains("gb200")
        || lower.contains("gb100")
    {
        return Some((10, 0));
    }

    // ── Hopper (H100, H200) ─────────────────────────────────────
    if lower.contains("h100") || lower.contains("h200") {
        return Some((9, 0));
    }

    // ── Ada Lovelace (RTX 40xx, L4, L40/L40S) ──────────────────
    if lower.contains("4090")
        || lower.contains("4080")
        || lower.contains("4070")
        || lower.contains("4060")
        || lower.contains("l40")
        || lower.contains("l4")
    {
        return Some((8, 9));
    }

    // ── Ampere (RTX 30xx consumer = 8.6, A100/A10/A6000 = 8.0) ─
    if lower.contains("a100") {
        return Some((8, 0));
    }
    if lower.contains("3090")
        || lower.contains("3080")
        || lower.contains("3070")
        || lower.contains("3060")
        || lower.contains("a10")
        || lower.contains("a6000")
        || lower.contains("a5000")
        || lower.contains("a4000")
        || lower.contains("a2000")
        || lower.contains("a16")
    {
        return Some((8, 6));
    }

    // ── Turing (RTX 20xx, GTX 16xx, T4) ─────────────────────────
    if lower.contains("2080")
        || lower.contains("2070")
        || lower.contains("2060")
        || lower.contains("1660")
        || lower.contains("1650")
        || lower.contains("t4")
    {
        return Some((7, 5));
    }

    // ── Volta (V100, Titan V) ───────────────────────────────────
    if lower.contains("v100") || lower.contains("titan v") {
        return Some((7, 0));
    }

    // ── Pascal (P100, GTX 10xx, Titan X Pascal) ─────────────────
    if lower.contains("p100")
        || lower.contains("1080")
        || lower.contains("1070")
        || lower.contains("1060")
        || lower.contains("1050")
        || lower.contains("p40")
        || lower.contains("p4")
    {
        return Some((6, 1));
    }

    None
}

/// Minimum NVIDIA compute capability required by a quantization format
/// when running under vLLM. Based on vLLM's documented hardware support:
/// <https://docs.vllm.ai/en/latest/features/quantization/#supported-hardware>
///
/// Returns `None` for quantization formats that have no known CC restriction
/// (e.g. GGUF quants which run through llama.cpp, not vLLM).
pub fn quant_min_compute_capability(quantization: &str) -> Option<(u8, u8)> {
    match quantization {
        // AWQ requires Turing+ (int4 tensor-core kernels)
        "AWQ-4bit" | "AWQ-8bit" => Some((7, 5)),
        // GPTQ Marlin kernels require Turing+
        "GPTQ-Int4" | "GPTQ-Int8" => Some((7, 5)),
        _ => None,
    }
}

/// Check if a GPU name (including PCI device IDs from lspci) indicates an
/// NVIDIA unified memory SoC (Grace Blackwell / DGX Spark / GB-series).
/// Inside Docker, nvidia-smi may report the raw PCI device ID instead of the
/// friendly model name, e.g. "NVIDIA Corporation Device [10de:2e12] (rev a1)"
/// instead of "NVIDIA GB10".
fn is_nvidia_unified_memory_gpu(name: &str) -> bool {
    let lower = name.to_lowercase();
    // Friendly model names
    if lower.contains("gb10") || lower.contains("gb20") {
        return true;
    }
    // PCI device IDs (hex) — these are the known GB-series SoCs.
    // 10de:2e12 = GB10 (DGX Spark / Project DIGITS)
    if lower.contains("2e12") {
        return true;
    }
    // Jetson / older Tegra SoCs (Orin, Xavier, Nano, ...) run the legacy
    // nvgpu/gk20a driver stack, which reports `addressing_mode` as "N/A"
    // rather than "ATS" (that field is only populated on newer Grace/Thor
    // chips). nvidia-smi always suffixes these iGPUs' name with "(nvgpu)",
    // so use that as a reliable unified-memory signal independent of
    // addressing_mode.
    if lower.contains("nvgpu") {
        return true;
    }
    false
}

/// Fallback VRAM estimation from GPU model name.
/// Used when nvidia-smi or other tools report 0 VRAM.
fn estimate_vram_from_name(name: &str) -> f64 {
    let lower = name.to_lowercase();
    // Same trap as in `gpu_memory_bandwidth_gbps`: the table below is keyed
    // on the model number, which a mobile part shares with a desktop card
    // that has a different memory configuration (RTX 5070: 12 GB desktop,
    // 8 GB laptop). Skip it and take the generic per-family estimate (#919).
    if is_mobile_gpu_name(&lower) {
        return estimate_vram_generic(&lower);
    }
    // NVIDIA RTX 50 series
    if lower.contains("5090") {
        return 32.0;
    }
    if lower.contains("5080") {
        return 16.0;
    }
    if lower.contains("5070 ti") {
        return 16.0;
    }
    if lower.contains("5070") {
        return 12.0;
    }
    if lower.contains("5060 ti") {
        return 16.0;
    }
    if lower.contains("5060") {
        return 8.0;
    }
    // NVIDIA RTX 40 series
    if lower.contains("4090") {
        return 24.0;
    }
    if lower.contains("4080") {
        return 16.0;
    }
    if lower.contains("4070 ti") {
        return 12.0;
    }
    if lower.contains("4070") {
        return 12.0;
    }
    if lower.contains("4060 ti") {
        return 16.0;
    }
    if lower.contains("4060") {
        return 8.0;
    }
    // NVIDIA RTX 30 series
    if lower.contains("3090") {
        return 24.0;
    }
    if lower.contains("3080 ti") {
        return 12.0;
    }
    if lower.contains("3080") {
        return 10.0;
    }
    if lower.contains("3070") {
        return 8.0;
    }
    if lower.contains("3060 ti") {
        return 8.0;
    }
    if lower.contains("3060") {
        return 12.0;
    }
    // Data center / professional
    if lower.contains("h100") {
        return 80.0;
    }
    if lower.contains("a100") {
        return 80.0;
    }
    if lower.contains("l40") {
        return 48.0;
    }
    // NVIDIA RTX professional (Ampere) — must be checked before the broad "a10" match
    if lower.contains("a6000") {
        return 48.0;
    }
    if lower.contains("a5500") {
        return 24.0;
    }
    if lower.contains("a5000") {
        return 24.0;
    }
    if lower.contains("a4500") {
        return 20.0;
    }
    if lower.contains("a4000") {
        return 16.0;
    }
    if lower.contains("a2000") {
        return 12.0;
    }
    if lower.contains("a10") {
        return 24.0;
    }
    if lower.contains("t4") {
        return 16.0;
    }
    // NVIDIA Grace / DGX Spark unified memory SoCs.
    // Also match PCI device ID 2e12 (GB10) for Docker/container environments
    // where lspci shows "Device [10de:2e12]" instead of the friendly name.
    if lower.contains("gb10") || lower.contains("2e12") {
        return 128.0;
    }
    if lower.contains("gb20") {
        return 128.0;
    }
    // AMD Radeon AI PRO (RDNA 4 workstation). Checked before the RX 9000
    // series: without an entry here "R9700" misses every model check and
    // lands on the generic 8 GB Radeon fallback (issue #830).
    if lower.contains("r9700") {
        return 32.0;
    }
    // AMD RX 9000 series (RDNA 4)
    if lower.contains("9070 xt") {
        return 16.0;
    }
    if lower.contains("9070") {
        return 12.0;
    }
    if lower.contains("9060 xt") {
        return 16.0;
    }
    if lower.contains("9060") {
        return 8.0;
    }
    // AMD RX 7000 series
    if lower.contains("7900 xtx") {
        return 24.0;
    }
    if lower.contains("7900") {
        return 20.0;
    }
    if lower.contains("7800") {
        return 16.0;
    }
    if lower.contains("7700") {
        return 12.0;
    }
    if lower.contains("7600") {
        return 8.0;
    }
    // AMD RX 6000 series
    if lower.contains("6950") {
        return 16.0;
    }
    if lower.contains("6900") {
        return 16.0;
    }
    if lower.contains("6800") {
        return 16.0;
    }
    if lower.contains("6750") {
        return 12.0;
    }
    if lower.contains("6700") {
        return 12.0;
    }
    if lower.contains("6650") {
        return 8.0;
    }
    if lower.contains("6600") {
        return 8.0;
    }
    if lower.contains("6500") {
        return 4.0;
    }
    // AMD RX 5000 series
    if lower.contains("5700 xt") {
        return 8.0;
    }
    if lower.contains("5700") {
        return 8.0;
    }
    if lower.contains("5600") {
        return 6.0;
    }
    if lower.contains("5500") {
        return 4.0;
    }
    // AMD Radeon 8000 series (Ryzen AI MAX / Strix Halo integrated)
    // These are unified memory APUs; VRAM = system RAM in practice,
    // but this fallback gives a reasonable discrete estimate for name-only detection.
    if lower.contains("8060s") {
        return 32.0;
    }
    if lower.contains("8050s") {
        return 24.0;
    }
    if lower.contains("8060") && !lower.contains("8060s") {
        return 16.0;
    }
    if lower.contains("8050") && !lower.contains("8050s") {
        return 12.0;
    }
    // AMD Radeon 800M series (Ryzen AI 9 / Strix Point integrated)
    if lower.contains("890m") {
        return 16.0;
    }
    if lower.contains("880m") {
        return 12.0;
    }
    if lower.contains("870m") {
        return 8.0;
    }
    if lower.contains("860m") {
        return 8.0;
    }

    estimate_vram_generic(&lower)
}

/// VRAM estimate that does not depend on the model number: the integrated-GPU
/// carve-out and the per-family defaults. Used for GPUs missing from the table
/// above, and for mobile SKUs, where a model-number match would describe the
/// desktop card instead.
fn estimate_vram_generic(lower: &str) -> f64 {
    // Integrated GPUs (APU iGPUs) — must check before generic fallbacks
    // APU names like "AMD Radeon(TM) Graphics" or "Radeon Graphics" without
    // a discrete model number (RX/HD/R5/R7/R9) have very limited dedicated VRAM.
    if (lower.contains("radeon") || lower.contains("amd"))
        && !lower.contains("rx ")
        && !lower.contains("hd ")
        && !lower.contains(" r5 ")
        && !lower.contains(" r7 ")
        && !lower.contains(" r9 ")
        && !lower.contains("8060")
        && !lower.contains("8050")
        && (lower.contains("graphics") || lower.contains("igpu"))
    {
        return 0.5;
    }

    // Generic fallbacks
    if lower.contains("rtx") {
        return 8.0;
    }
    if lower.contains("gtx") {
        return 4.0;
    }
    if lower.contains("rx ") || lower.contains("radeon") {
        return 8.0;
    }
    0.0
}

#[cfg(test)]
mod tests {
    use super::{GpuBackend, SystemSpecs};

    // Regression for #303 (wezm): Granite Ridge iGPU ("Radeon Graphics",
    // 2 GB UMA carve-out) enumerated alongside an RX 9060 XT. The iGPU must
    // be filtered out and the discrete card kept.
    #[test]
    fn test_amd_sysfs_igpu_filtered_when_discrete_present() {
        let gpus = SystemSpecs::group_and_filter_amd_sysfs_cards(vec![
            ("Radeon Graphics".to_string(), Some(2.0)),
            ("Radeon RX 9060 XT".to_string(), Some(16.0)),
        ]);
        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].name, "Radeon RX 9060 XT");
        assert_eq!(gpus[0].vram_gb, Some(16.0));
        assert!(!SystemSpecs::is_integrated_gpu_name("Radeon RX 9060 XT"));
    }

    // A discrete card whose mem_info_vram_total is unreadable (None) and
    // whose name isn't in the VRAM estimate table must not be silently
    // dropped when another discrete card is present.
    #[test]
    fn test_amd_sysfs_vramless_discrete_card_kept() {
        let gpus = SystemSpecs::group_and_filter_amd_sysfs_cards(vec![
            ("Radeon RX 7900 XTX".to_string(), Some(24.0)),
            ("Radeon Pro W7800X Duo".to_string(), None),
        ]);
        assert_eq!(gpus.len(), 2, "VRAM-less discrete card was dropped");
    }

    // Without any discrete card, the iGPU must survive the filter.
    #[test]
    fn test_amd_sysfs_igpu_kept_when_alone() {
        let gpus = SystemSpecs::group_and_filter_amd_sysfs_cards(vec![(
            "Radeon Graphics".to_string(),
            Some(2.0),
        )]);
        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].name, "Radeon Graphics");
    }

    // lspci line for the RX 9060 XT (Navi 44) as seen in #303.
    #[test]
    fn test_extract_model_navi44_lspci_line() {
        let line = "0000:03:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Navi 44 [Radeon RX 9060 XT] [1002:7590]";
        assert_eq!(
            SystemSpecs::extract_model_from_lspci_line(line).as_deref(),
            Some("Radeon RX 9060 XT")
        );
    }

    // -- Fixture-backed AMD sysfs scanner tests (#880) ------------------------

    fn sysfs_fixture(name: &str) -> std::path::PathBuf {
        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("tests")
            .join("fixtures")
            .join("hardware")
            .join("sysfs")
            .join(name)
    }

    // The mixed fixture has card0 (1 GiB iGPU), card1 (24 GiB dGPU), and
    // card2 (NVIDIA vendor 0x10de). The scanner must skip non-AMD cards,
    // filter the iGPU when a discrete card is present, and return only the
    // dGPU with its VRAM and card count.
    #[test]
    fn amd_sysfs_fixture_filters_igpu_when_discrete_gpu_present() {
        let root = sysfs_fixture("mixed");
        let gpus = SystemSpecs::detect_amd_gpu_sysfs_info_from_root(&root, |card, _| match card {
            "card0" => Some("AMD Radeon 780M Graphics".to_string()),
            "card1" => Some("AMD Radeon RX 7900 XTX".to_string()),
            _ => None,
        });

        assert_eq!(gpus.len(), 1);
        let gpu = &gpus[0];
        assert_eq!(gpu.name, "AMD Radeon RX 7900 XTX");
        assert_eq!(gpu.vram_gb, Some(24.0));
        assert_eq!(gpu.count, 1);
        assert_eq!(gpu.backend, GpuBackend::Vulkan);
    }

    // When mem_info_vram_total is absent the scanner must not panic and must
    // leave vram_gb as None (estimate_vram_from_name returns 0.0 for a
    // generic fixture name).
    #[test]
    fn amd_sysfs_fixture_missing_vram_has_defined_fallback() {
        let root = sysfs_fixture("missing-vram");
        let gpus = SystemSpecs::detect_amd_gpu_sysfs_info_from_root(&root, |_, _| {
            Some("AMD Fixture GPU".to_string())
        });

        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].name, "AMD Fixture GPU");
        assert_eq!(gpus[0].vram_gb, None);
    }

    // When mem_info_vram_total contains unparseable text the scanner must
    // not panic and must leave vram_gb as None.
    #[test]
    fn amd_sysfs_fixture_malformed_vram_has_defined_fallback() {
        let root = sysfs_fixture("malformed-vram");
        let gpus = SystemSpecs::detect_amd_gpu_sysfs_info_from_root(&root, |_, _| {
            Some("AMD Fixture GPU".to_string())
        });

        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].name, "AMD Fixture GPU");
        assert_eq!(gpus[0].vram_gb, None);
    }

    #[test]
    fn test_measured_ram_bandwidth_plausible_and_cached() {
        // May legitimately be None on a starved CI runner; when it measures,
        // the value must sit in the sanity band and be stable across calls.
        if let Some(bw) = super::measured_ram_bandwidth_gbps() {
            assert!((2.0..=4000.0).contains(&bw), "implausible bandwidth: {bw}");
            assert_eq!(super::measured_ram_bandwidth_gbps(), Some(bw));
        }
    }

    #[test]
    fn test_parse_nvidia_smi_does_not_sum_multi_gpu_vram() {
        let text = include_str!("../tests/fixtures/hardware/nvidia/standard-identical.csv");
        let gpus = SystemSpecs::parse_nvidia_smi_list(text);

        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].count, 2);
        let vram = gpus[0]
            .vram_gb
            .expect("VRAM should be parsed for RTX 4090 entries");
        // 24564 MiB ~= 23.99 GiB; must stay single-card VRAM, not 2x summed.
        assert!(vram > 23.0 && vram < 25.0, "unexpected VRAM value: {vram}");
    }

    #[test]
    fn test_parse_nvidia_smi_keeps_distinct_models() {
        let text = include_str!("../tests/fixtures/hardware/nvidia/standard-distinct.csv");
        let gpus = SystemSpecs::parse_nvidia_smi_list(text);

        assert_eq!(gpus.len(), 2);
        assert!(gpus.iter().any(|g| g.name.contains("4090") && g.count == 1));
        assert!(gpus.iter().any(|g| g.name.contains("4080") && g.count == 1));
    }

    #[test]
    fn test_parse_nvidia_smi_gb10_gets_vram_estimate() {
        // DGX Spark reports GB10 with 0 VRAM from nvidia-smi
        let text = "0, NVIDIA GB10\n";
        let gpus = SystemSpecs::parse_nvidia_smi_list(text);

        assert_eq!(gpus.len(), 1);
        assert!(gpus[0].name.contains("GB10"));
        // estimate_vram_from_name should kick in and return 128GB
        let vram = gpus[0].vram_gb.expect("GB10 should have estimated VRAM");
        assert!(vram > 100.0, "GB10 VRAM should be ~128GB, got {vram}");
    }

    #[test]
    fn test_estimate_vram_gb10() {
        assert_eq!(super::estimate_vram_from_name("NVIDIA GB10"), 128.0);
        assert_eq!(super::estimate_vram_from_name("NVIDIA GB20"), 128.0);
    }

    #[test]
    fn test_estimate_vram_rtx_professional() {
        assert_eq!(super::estimate_vram_from_name("NVIDIA RTX A6000"), 48.0);
        assert_eq!(super::estimate_vram_from_name("NVIDIA RTX A5500"), 24.0);
        assert_eq!(super::estimate_vram_from_name("NVIDIA RTX A5000"), 24.0);
        assert_eq!(super::estimate_vram_from_name("NVIDIA RTX A4500"), 20.0);
        assert_eq!(super::estimate_vram_from_name("NVIDIA RTX A4000"), 16.0);
        assert_eq!(super::estimate_vram_from_name("NVIDIA RTX A2000"), 12.0);
    }

    #[test]
    fn test_parse_extended_discrete_gpu_not_unified() {
        // Discrete GPU: addressing_mode is "None", VRAM is reported normally
        let text = "None, 24564, NVIDIA GeForce RTX 4090\n";
        let gpus = SystemSpecs::parse_nvidia_smi_extended(text);

        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].name, "NVIDIA GeForce RTX 4090");
        assert!(
            !gpus[0].unified_memory,
            "discrete GPU should not be unified"
        );
        let vram = gpus[0].vram_gb.expect("VRAM should be present");
        assert!(vram > 23.0 && vram < 25.0, "unexpected VRAM: {vram}");
    }

    #[test]
    fn test_parse_extended_tegra_unified_memory() {
        let text = include_str!("../tests/fixtures/hardware/nvidia/extended-unified.csv");
        let gpus = SystemSpecs::parse_nvidia_smi_extended(text);

        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].name, "NVIDIA Thor");
        assert!(gpus[0].unified_memory, "ATS should set unified_memory=true");
        // VRAM comes from /proc/meminfo; if unavailable, it's None
        // (on Linux test machines it will be Some, on macOS CI it will be None)
    }

    #[test]
    fn test_parse_nvidia_smi_single_and_empty_fixtures() {
        let single = include_str!("../tests/fixtures/hardware/nvidia/standard-single.csv");
        let gpus = SystemSpecs::parse_nvidia_smi_list(single);
        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].name, "NVIDIA GeForce RTX 4090");
        assert_eq!(gpus[0].count, 1);
        assert_eq!(gpus[0].backend, super::GpuBackend::Cuda);
        assert!(!gpus[0].unified_memory);

        let empty = include_str!("../tests/fixtures/hardware/nvidia/empty.txt");
        assert!(SystemSpecs::parse_nvidia_smi_list(empty).is_empty());
        assert!(SystemSpecs::parse_nvidia_smi_extended(empty).is_empty());
    }

    #[test]
    fn test_parse_extended_jetson_orin_unified_memory() {
        // Jetson Orin: addressing_mode is "N/A" (legacy nvgpu/gk20a stack, not
        // ATS), and memory.total is also "N/A" since nvidia-smi can't query
        // dedicated VRAM for the on-chip GPU.
        let text = "N/A, [N/A], Orin (nvgpu)\n";
        let gpus = SystemSpecs::parse_nvidia_smi_extended(text);

        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].name, "Orin (nvgpu)");
        assert!(
            super::is_nvidia_unified_memory_gpu(&gpus[0].name),
            "\"(nvgpu)\" name should be recognized as unified memory"
        );
    }

    #[test]
    fn test_is_nvidia_unified_memory_gpu_jetson_names() {
        assert!(super::is_nvidia_unified_memory_gpu("Orin (nvgpu)"));
        assert!(super::is_nvidia_unified_memory_gpu("Xavier (nvgpu)"));
        assert!(!super::is_nvidia_unified_memory_gpu(
            "NVIDIA GeForce RTX 4090"
        ));
    }

    #[test]
    fn test_parse_extended_multi_gpu_discrete() {
        // Two discrete GPUs, no unified memory
        let text = "None, 24564, NVIDIA GeForce RTX 4090\nNone, 24564, NVIDIA GeForce RTX 4090\n";
        let gpus = SystemSpecs::parse_nvidia_smi_extended(text);

        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].count, 2);
        assert!(!gpus[0].unified_memory);
    }

    #[test]
    fn test_gpu_bandwidth_known_gpus() {
        // Spot-check a few well-known GPUs
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce RTX 4090"),
            Some(1008.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce RTX 3060"),
            Some(360.0)
        );
        assert_eq!(super::gpu_memory_bandwidth_gbps("Tesla T4"), Some(320.0));
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA H100 SXM"),
            Some(3350.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA A100"),
            Some(1555.0)
        );
    }

    #[test]
    fn test_gpu_bandwidth_apple_silicon() {
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("Apple M1 Max"),
            Some(400.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("Apple M4 Pro"),
            Some(273.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("Apple M5 Max"),
            Some(614.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("Apple M5 Pro"),
            Some(307.0)
        );
        assert_eq!(super::gpu_memory_bandwidth_gbps("Apple M5"), Some(153.6));
    }

    #[test]
    fn test_gpu_bandwidth_unknown_returns_none() {
        assert_eq!(super::gpu_memory_bandwidth_gbps("Some Random GPU"), None);
        assert_eq!(super::gpu_memory_bandwidth_gbps(""), None);
    }

    #[test]
    fn test_gpu_bandwidth_amd() {
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("AMD Radeon RX 7900 XTX"),
            Some(960.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("AMD Instinct MI300X"),
            Some(5300.0)
        );
    }

    #[test]
    fn test_parse_cpu_name_from_cpuinfo_prefers_model_name() {
        let cpuinfo = "\
processor   : 0
model name  : Qualcomm Kryo 680
Hardware    : Qualcomm Technologies, Inc SM8350
";
        assert_eq!(
            SystemSpecs::parse_cpu_name_from_cpuinfo(cpuinfo),
            Some("Qualcomm Kryo 680".to_string())
        );
    }

    #[test]
    fn test_parse_cpu_name_from_cpuinfo_uses_hardware_fallback() {
        let cpuinfo = "\
processor   : 0
Hardware    : Qualcomm Technologies, Inc SM8650
";
        assert_eq!(
            SystemSpecs::parse_cpu_name_from_cpuinfo(cpuinfo),
            Some("Qualcomm Technologies, Inc SM8650".to_string())
        );
    }

    #[test]
    fn test_parse_vulkan_device_names_from_summary_output() {
        let text = "\
GPU0:
deviceName         = Adreno (TM) 740
GPU1:
deviceName         = llvmpipe (LLVM 17.0.0, 256 bits)
";
        let names = SystemSpecs::parse_vulkan_device_names(text);
        assert_eq!(
            names,
            vec![
                "Adreno (TM) 740".to_string(),
                "llvmpipe (LLVM 17.0.0, 256 bits)".to_string()
            ]
        );
    }

    #[test]
    fn test_parse_vulkan_device_names_from_gpu_id_lines() {
        let text = "\
GPU id = 0 (Adreno (TM) 740)
GPU id = 1 (NVIDIA GeForce RTX 4090)
";
        let names = SystemSpecs::parse_vulkan_device_names(text);
        assert_eq!(
            names,
            vec![
                "Adreno (TM) 740".to_string(),
                "NVIDIA GeForce RTX 4090".to_string()
            ]
        );
    }

    #[test]
    fn test_is_software_vulkan_device() {
        assert!(SystemSpecs::is_software_vulkan_device(
            "llvmpipe (LLVM 17.0.0, 256 bits)"
        ));
        assert!(SystemSpecs::is_software_vulkan_device("SwiftShader Device"));
        assert!(!SystemSpecs::is_software_vulkan_device("Adreno (TM) 740"));
        // CPU compute devices exposed by Mesa/RADV must be filtered out
        assert!(SystemSpecs::is_software_vulkan_device(
            "AMD Ryzen 7 9800X3D 8-Core Processor (RADV RAPHAEL_MENDOCINO)"
        ));
        assert!(SystemSpecs::is_software_vulkan_device(
            "AMD Ryzen 5 7600X 6-Core Processor (RADV RAPHAEL)"
        ));
        // Real discrete GPUs must still pass through
        assert!(!SystemSpecs::is_software_vulkan_device(
            "AMD Radeon RX 7900 XTX (RADV NAVI31)"
        ));
    }

    #[test]
    fn test_is_same_gpu_name_uses_normalized_exact_match() {
        assert!(SystemSpecs::is_same_gpu_name(
            "NVIDIA-GeForce RTX 4090",
            "nvidia geforce rtx 4090"
        ));
        assert!(!SystemSpecs::is_same_gpu_name("RTX", "RTX 4090"));
    }

    #[test]
    fn test_is_same_gpu_name_amd_rocm_vs_vulkan_radv() {
        // ROCm reports a family name listing multiple variants; RADV reports the
        // specific model with a driver codename.  They should be treated as the
        // same physical GPU.
        assert!(SystemSpecs::is_same_gpu_name(
            "Radeon RX 7700S/7600/7600S/7600M XT/PRO W7600",
            "AMD Radeon RX 7600 XT (RADV NAVI33)"
        ));
        // A 7700 XT via RADV should also match the same ROCm family name.
        assert!(SystemSpecs::is_same_gpu_name(
            "Radeon RX 7700S/7600/7600S/7600M XT/PRO W7600",
            "AMD Radeon RX 7700 XT (RADV NAVI33)"
        ));
        // Non-AMD GPUs must not be affected.
        assert!(!SystemSpecs::is_same_gpu_name(
            "NVIDIA GeForce RTX 3060",
            "AMD Radeon RX 6600"
        ));
        // Different AMD model numbers must not match.
        assert!(!SystemSpecs::is_same_gpu_name(
            "AMD Radeon RX 6600",
            "AMD Radeon RX 7900 XTX (RADV NAVI31)"
        ));
    }

    #[test]
    fn test_extract_gpu_model_numbers() {
        assert_eq!(
            SystemSpecs::extract_gpu_model_numbers("radeon rx 7700s 7600 7600s 7600m xt pro w7600"),
            vec!["7700", "7600", "7600", "7600", "7600"]
        );
        assert_eq!(
            SystemSpecs::extract_gpu_model_numbers("amd radeon rx 7600 xt radv navi33"),
            vec!["7600"]
        );
        // Numbers shorter than 3 or longer than 5 digits are ignored.
        assert!(SystemSpecs::extract_gpu_model_numbers("rx 42 xt").is_empty());
    }

    #[test]
    fn test_normalize_gpu_name_for_dedupe() {
        assert_eq!(
            SystemSpecs::normalize_gpu_name_for_dedupe(" Adreno (TM) 740 "),
            "adreno tm 740"
        );
    }

    // ── GpuBackend::label ────────────────────────────────────────────

    #[test]
    fn test_gpu_backend_labels() {
        assert_eq!(super::GpuBackend::Cuda.label(), "CUDA");
        assert_eq!(super::GpuBackend::Metal.label(), "Metal");
        assert_eq!(super::GpuBackend::Rocm.label(), "ROCm");
        assert_eq!(super::GpuBackend::Vulkan.label(), "Vulkan");
        assert_eq!(super::GpuBackend::Sycl.label(), "SYCL");
        assert_eq!(super::GpuBackend::CpuArm.label(), "CPU (ARM)");
        assert_eq!(super::GpuBackend::CpuX86.label(), "CPU (x86)");
        assert_eq!(super::GpuBackend::Ascend.label(), "NPU (Ascend)");
    }

    // ── parse_memory_size ────────────────────────────────────────────

    #[test]
    fn test_parse_memory_size_gb() {
        assert_eq!(super::parse_memory_size("32G"), Some(32.0));
        assert_eq!(super::parse_memory_size("32GB"), Some(32.0));
        assert_eq!(super::parse_memory_size("32GiB"), Some(32.0));
        assert_eq!(super::parse_memory_size("24g"), Some(24.0));
        assert_eq!(super::parse_memory_size("24gb"), Some(24.0));
    }

    #[test]
    fn test_parse_memory_size_mb() {
        let result = super::parse_memory_size("16384M").unwrap();
        assert!((result - 16.0).abs() < 0.01);
        let result = super::parse_memory_size("8192MB").unwrap();
        assert!((result - 8.0).abs() < 0.01);
    }

    #[test]
    fn test_parse_memory_size_tb() {
        let result = super::parse_memory_size("1T").unwrap();
        assert!((result - 1024.0).abs() < 0.01);
        let result = super::parse_memory_size("2TB").unwrap();
        assert!((result - 2048.0).abs() < 0.01);
    }

    #[test]
    fn test_parse_memory_size_bare_number() {
        assert_eq!(super::parse_memory_size("16"), Some(16.0));
    }

    #[test]
    fn test_parse_memory_size_whitespace() {
        assert_eq!(super::parse_memory_size("  32G  "), Some(32.0));
    }

    #[test]
    fn test_parse_memory_size_empty() {
        assert_eq!(super::parse_memory_size(""), None);
        assert_eq!(super::parse_memory_size("  "), None);
    }

    #[test]
    fn test_parse_memory_size_invalid_suffix() {
        assert_eq!(super::parse_memory_size("32X"), None);
        assert_eq!(super::parse_memory_size("32KB"), None);
    }

    #[test]
    fn test_parse_memory_size_fractional() {
        assert_eq!(super::parse_memory_size("16.5G"), Some(16.5));
    }

    // ── with_gpu_memory_override ─────────────────────────────────────

    fn make_specs_no_gpu() -> SystemSpecs {
        SystemSpecs {
            total_ram_gb: 32.0,
            available_ram_gb: 24.0,
            total_cpu_cores: 8,
            cpu_name: "Test CPU".to_string(),
            has_gpu: false,
            gpu_vram_gb: None,
            total_gpu_vram_gb: None,
            gpu_available_gb: None,
            gpu_name: None,
            gpu_count: 0,
            unified_memory: false,
            backend: super::GpuBackend::CpuX86,
            gpus: vec![],
            cluster_mode: false,
            cluster_node_count: 0,
        }
    }

    fn make_specs_with_gpu() -> SystemSpecs {
        SystemSpecs {
            total_ram_gb: 32.0,
            available_ram_gb: 24.0,
            total_cpu_cores: 8,
            cpu_name: "Test CPU".to_string(),
            has_gpu: true,
            gpu_vram_gb: Some(8.0),
            total_gpu_vram_gb: Some(8.0),
            gpu_available_gb: None,
            gpu_name: Some("NVIDIA RTX 3070".to_string()),
            gpu_count: 1,
            unified_memory: false,
            backend: super::GpuBackend::Cuda,
            gpus: vec![super::GpuInfo {
                name: "NVIDIA RTX 3070".to_string(),
                vram_gb: Some(8.0),
                backend: super::GpuBackend::Cuda,
                count: 1,
                unified_memory: false,
            }],
            cluster_mode: false,
            cluster_node_count: 0,
        }
    }

    #[test]
    fn test_gpu_override_creates_synthetic_gpu_when_none() {
        let specs = make_specs_no_gpu().with_gpu_memory_override(24.0);
        assert!(specs.has_gpu);
        assert_eq!(specs.gpu_vram_gb, Some(24.0));
        assert_eq!(specs.total_gpu_vram_gb, Some(24.0));
        assert_eq!(specs.gpu_count, 1);
        assert_eq!(specs.gpus.len(), 1);
        assert_eq!(specs.gpus[0].name, "User-specified GPU");
    }

    #[test]
    fn test_gpu_override_updates_existing_gpu() {
        let specs = make_specs_with_gpu().with_gpu_memory_override(24.0);
        assert_eq!(specs.gpu_vram_gb, Some(24.0));
        assert_eq!(specs.total_gpu_vram_gb, Some(24.0));
        assert_eq!(specs.gpus[0].vram_gb, Some(24.0));
        assert_eq!(specs.gpus[0].name, "NVIDIA RTX 3070");
    }

    #[test]
    fn test_gpu_override_multi_gpu_scales_total() {
        let mut specs = make_specs_with_gpu();
        specs.gpus[0].count = 2;
        let specs = specs.with_gpu_memory_override(24.0);
        assert_eq!(specs.gpu_vram_gb, Some(24.0));
        assert_eq!(specs.total_gpu_vram_gb, Some(48.0));
    }

    #[test]
    fn test_gpu_override_clears_gpu_available() {
        let mut specs = make_specs_with_gpu();
        specs.gpu_available_gb = Some(11.84);
        let specs = specs.with_gpu_memory_override(24.0);
        assert_eq!(specs.gpu_available_gb, None);
    }

    #[test]
    fn test_gpu_override_synthetic_gpu_clears_gpu_available() {
        let mut specs = make_specs_no_gpu();
        specs.gpu_available_gb = Some(11.84);
        let specs = specs.with_gpu_memory_override(24.0);
        assert_eq!(specs.gpu_available_gb, None);
    }

    // ── format_unified_memory_line ───────────────────────────────────

    #[test]
    fn test_unified_line_shows_gpu_available_when_known() {
        let line = super::format_unified_memory_line("Apple M3", 16.0, Some(11.84), "Metal");
        assert_eq!(
            line,
            "Apple M3 (unified memory, 11.84 GB GPU-available of 16.00 GB shared, Metal)"
        );
    }

    #[test]
    fn test_unified_line_clamps_gpu_available_to_shared_pool() {
        // An iogpu.wired_limit_mb set above physical RAM passes through Metal
        // verbatim; the display must bound it by the shared pool.
        let line = super::format_unified_memory_line("Apple M3", 16.0, Some(976.56), "Metal");
        assert_eq!(
            line,
            "Apple M3 (unified memory, 16.00 GB GPU-available of 16.00 GB shared, Metal)"
        );
    }

    #[test]
    fn test_unified_line_falls_back_when_unknown() {
        let line = super::format_unified_memory_line("Apple M3", 16.0, None, "Metal");
        assert_eq!(line, "Apple M3 (unified memory, 16.00 GB shared, Metal)");
    }

    // ── is_amd_unified_memory_apu ────────────────────────────────────

    #[test]
    fn test_amd_unified_memory_apu_detection() {
        // Only Ryzen AI MAX / MAX+ have true unified memory
        assert!(super::is_amd_unified_memory_apu(
            "AMD Ryzen AI MAX+ 395 w/ Radeon 8060S"
        ));
        assert!(super::is_amd_unified_memory_apu("AMD Ryzen AI MAX 390"));
        // Regular Ryzen AI chips are NOT unified memory APUs
        assert!(!super::is_amd_unified_memory_apu(
            "AMD Ryzen AI 9 HX 370 w/ Radeon 890M"
        ));
        assert!(!super::is_amd_unified_memory_apu("AMD Ryzen AI 7 350"));
        assert!(!super::is_amd_unified_memory_apu("AMD Ryzen 9 7950X"));
        assert!(!super::is_amd_unified_memory_apu("Intel Core i9-14900K"));
    }

    // ── detect_windows_physical_total_ram_gb ─────────────────────────

    #[test]
    #[cfg(not(target_os = "windows"))]
    fn test_windows_physical_total_ram_returns_none_on_non_windows() {
        // On Linux/macOS the function must return None (it is Windows-only).
        assert!(super::detect_windows_physical_total_ram_gb().is_none());
    }

    // ── issue #810: BIOS UMA carveouts on non-MAX AMD APUs ───────────

    #[test]
    fn test_is_amd_apu_matches_igpu_brand_strings() {
        // Any Ryzen with an iGPU baked into the brand string qualifies.
        assert!(super::is_amd_apu(
            "AMD Ryzen 7 8845HS w/ Radeon 780M Graphics"
        ));
        assert!(super::is_amd_apu("AMD Ryzen AI 9 HX 370 w/ Radeon 890M"));
        assert!(super::is_amd_apu("AMD Ryzen AI MAX+ 395 w/ Radeon 8060S"));
        // Desktop parts without an iGPU carveout, and non-AMD, do not.
        assert!(!super::is_amd_apu("AMD Ryzen 9 7950X"));
        assert!(!super::is_amd_apu("Intel Core i9-14900K"));
        assert!(!super::is_amd_apu("Apple M3 Pro"));
    }

    #[test]
    fn test_ram_carveout_override_prefers_physical_on_real_gap() {
        // Fixture from issue #810: 32 GB installed, 8 GB UMA carveout,
        // sysinfo sees 23.82 GB. The physical figure must win.
        let got = super::apply_ram_carveout_override(23.824_016_571_044_922, 32.0);
        assert!((got - 32.0).abs() < f64::EPSILON);
    }

    #[test]
    fn test_ram_carveout_override_keeps_sysinfo_on_normal_overhead() {
        // ~0.3 GB firmware reservation is not a carveout; keep the OS view.
        let got = super::apply_ram_carveout_override(31.7, 32.0);
        assert!((got - 31.7).abs() < f64::EPSILON);
        // A physical read *below* the OS view (bad WMI data) must never win.
        let got = super::apply_ram_carveout_override(32.0, 16.0);
        assert!((got - 32.0).abs() < f64::EPSILON);
    }

    // ── issue #964: BIOS UMA carveouts on Linux (Strix Halo) ─────────

    // rocm-smi output from issue #964 (idahomst): Asus ProArt PX13, 128 GB,
    // "UMA Frame Buffer Size: 96GB" in BIOS. Some builds print the
    // low-power warning on stdout ahead of the table.
    const STRIX_HALO_ROCM_VRAM: &str = "\
============================ ROCm System Management Interface ============================
WARNING: AMD GPU device(s) is/are in a low-power state. Check power control/runtime_status

================================== Memory Usage (Bytes) ==================================
GPU[0]          : VRAM Total Memory (B): 103079215104
GPU[0]          : VRAM Total Used Memory (B): 933543936
==========================================================================================
================================== End of ROCm SMI Log ===================================
";
    const STRIX_HALO_ROCM_PRODUCT: &str = "\
============================ ROCm System Management Interface ============================
====================================== Product Info ======================================
GPU[0]          : Card Series:          AMD Radeon 8060S Graphics
GPU[0]          : Card Model:           0x1586
GPU[0]          : Card Vendor:          Advanced Micro Devices, Inc. [AMD/ATI]
GPU[0]          : Card SKU:             STRXLGEN
GPU[0]          : Subsystem ID:         0x1714
GPU[0]          : Device Rev:           0xc1
GPU[0]          : Node ID:              1
GPU[0]          : GUID:                 51717
GPU[0]          : GFX Version:          gfx1151
==========================================================================================
================================== End of ROCm SMI Log ===================================
";
    const STRIX_HALO_CPU: &str = "AMD RYZEN AI MAX+ 395 w/ Radeon 8060S";
    const STRIX_HALO_LEFTOVER_RAM_GB: f64 = 30.977_878_570_556_64;

    #[test]
    fn test_parse_rocm_smi_strix_halo_reports_carveout() {
        let gpus =
            SystemSpecs::parse_rocm_smi_output(STRIX_HALO_ROCM_VRAM, Some(STRIX_HALO_ROCM_PRODUCT));
        assert_eq!(gpus.len(), 1, "{gpus:?}");
        // The gfx suffix is only ever added by the product parser, which is
        // how the doctor dump proves rocm-smi succeeded on that box.
        assert_eq!(gpus[0].name, "AMD Radeon 8060S Graphics (gfx1151)");
        let vram_gb = gpus[0].vram_gb.expect("vram");
        assert!((vram_gb - 96.0).abs() < 1e-9, "{vram_gb}");
    }

    // The doctor dump in #964 showed gpu_vram_gb = 30.98: the override took
    // the rocm-smi card above and replaced its 96 GB with the leftover
    // sysinfo RAM. With the carveout folded back into the RAM total, the
    // pool it substitutes is the installed ~128 GB.
    #[test]
    fn test_unified_apu_override_grades_against_installed_pool() {
        let mut gpus =
            SystemSpecs::parse_rocm_smi_output(STRIX_HALO_ROCM_VRAM, Some(STRIX_HALO_ROCM_PRODUCT));
        let pool = STRIX_HALO_LEFTOVER_RAM_GB + 96.0;
        SystemSpecs::apply_amd_unified_apu_override(&mut gpus, STRIX_HALO_CPU, pool);
        assert_eq!(gpus.len(), 1, "{gpus:?}");
        assert!(gpus[0].unified_memory);
        assert_eq!(gpus[0].vram_gb, Some(pool));
        assert!(
            pool > 126.0,
            "pool must be the installed total, not leftover RAM: {pool}"
        );
        assert_eq!(gpus[0].name, "AMD Radeon 8060S Graphics (gfx1151)");
    }

    #[test]
    fn test_unified_apu_override_synthesizes_and_renames() {
        // No probe found the iGPU: one is created from the CPU model.
        let mut gpus = Vec::new();
        SystemSpecs::apply_amd_unified_apu_override(&mut gpus, STRIX_HALO_CPU, 128.0);
        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].name, format!("{STRIX_HALO_CPU} (integrated)"));
        assert!(gpus[0].unified_memory);
        assert_eq!(gpus[0].vram_gb, Some(128.0));

        // A generic probe name ("N/A" from rocm-smi) is replaced by the APU
        // model, a specific one is kept.
        let mut gpus = vec![super::GpuInfo {
            name: "AMD GPU".to_string(),
            vram_gb: Some(0.5),
            backend: GpuBackend::Rocm,
            count: 1,
            unified_memory: false,
        }];
        SystemSpecs::apply_amd_unified_apu_override(&mut gpus, STRIX_HALO_CPU, 128.0);
        assert_eq!(gpus[0].name, format!("{STRIX_HALO_CPU} (integrated)"));
        assert_eq!(gpus[0].vram_gb, Some(128.0));

        // Not a Ryzen AI MAX: untouched.
        let mut gpus = Vec::new();
        SystemSpecs::apply_amd_unified_apu_override(
            &mut gpus,
            "AMD Ryzen 7 8845HS w/ Radeon 780M Graphics",
            32.0,
        );
        assert!(gpus.is_empty());
    }

    // The lspci line from the #964 dump must classify as integrated, since
    // that is how the sysfs scan singles out the APU card.
    #[test]
    fn test_strix_halo_lspci_line_is_integrated_class() {
        let line = "c4:00.0 Display controller [0380]: Advanced Micro Devices, Inc. [AMD/ATI] Strix Halo [Radeon Graphics / Radeon 8050S Graphics / Radeon 8060S Graphics] [1002:1586] (rev c1)";
        let name = SystemSpecs::extract_model_from_lspci_line(line).expect("model name");
        assert!(SystemSpecs::is_integrated_gpu_name(&name), "{name}");
    }

    #[test]
    fn test_apu_carveout_requires_one_integrated_measured_card() {
        let pick = SystemSpecs::apu_uma_carveout_from_cards;
        let card = |name: &str, vram: Option<f64>| (name.to_string(), vram);

        // APU + AMD dGPU laptop: the 780M's carveout, not the dGPU's VRAM.
        assert_eq!(
            pick(&[
                card("AMD Radeon 780M Graphics", Some(8.0)),
                card("AMD Radeon RX 7900 XTX", Some(24.0)),
            ]),
            Some(8.0)
        );
        // iGPU disabled in BIOS: the lone card is discrete, its VRAM is not RAM.
        assert_eq!(
            pick(&[card("Radeon RX 7700 XT / 7800 XT", Some(16.0))]),
            None
        );
        // Unreadable mem_info_vram_total: nothing to add.
        assert_eq!(pick(&[card("Radeon Graphics", None)]), None);
        // Two integrated-looking cards: ambiguous, so no guess.
        assert_eq!(
            pick(&[
                card("Radeon Graphics", Some(2.0)),
                card("Radeon Graphics", Some(0.5)),
            ]),
            None
        );
        assert_eq!(pick(&[]), None);
    }

    // sysfs fixture built from the #964 dump: card1 vendor=0x1002
    // device=0x1586 driver=amdgpu mem_info_vram_total=103079215104. Added to
    // the leftover 30.98 GB this recovers the installed 128 GB.
    #[test]
    fn amd_sysfs_fixture_strix_halo_carveout_recovers_installed_ram() {
        let root = sysfs_fixture("strix-halo");
        let cards = SystemSpecs::scan_amd_sysfs_cards(&root, |card, _| {
            assert_eq!(card, "card1");
            Some("Radeon Graphics / Radeon 8050S Graphics / Radeon 8060S Graphics".to_string())
        });
        let carveout = SystemSpecs::apu_uma_carveout_from_cards(&cards).expect("carveout");
        assert!((carveout - 96.0).abs() < 1e-9, "{carveout}");
        let total = STRIX_HALO_LEFTOVER_RAM_GB + carveout;
        assert!((126.9..=128.0).contains(&total), "{total}");
    }

    // The measured scan must not fall back to the name-based VRAM estimate:
    // a 8060S without a readable carveout would otherwise "measure" 32 GB.
    #[test]
    fn amd_sysfs_scan_never_estimates_vram() {
        let root = sysfs_fixture("missing-vram");
        let cards = SystemSpecs::scan_amd_sysfs_cards(&root, |_, _| {
            Some("AMD Radeon 8060S Graphics".to_string())
        });
        assert_eq!(cards, vec![("AMD Radeon 8060S Graphics".to_string(), None)]);
        assert_eq!(SystemSpecs::apu_uma_carveout_from_cards(&cards), None);
    }

    #[test]
    fn test_amd_mobile_igpu_name() {
        assert!(super::SystemSpecs::is_amd_mobile_igpu_name(
            "AMD Radeon 780M Graphics"
        ));
        assert!(super::SystemSpecs::is_amd_mobile_igpu_name(
            "AMD Radeon(TM) 890M"
        ));
        // Discrete mobile parts carry the RX prefix.
        assert!(!super::SystemSpecs::is_amd_mobile_igpu_name(
            "AMD Radeon RX 7900M"
        ));
        // Generic and datacenter names are not the mobile-iGPU shape.
        assert!(!super::SystemSpecs::is_amd_mobile_igpu_name(
            "AMD Radeon Graphics"
        ));
        assert!(!super::SystemSpecs::is_amd_mobile_igpu_name(
            "AMD Instinct MI50"
        ));
    }

    #[test]
    fn test_mobile_igpu_with_large_carveout_stays_integrated() {
        // With the registry fix, a 780M can legitimately report 8 GB+, which
        // used to trip the issue-#638 "generic AMD name with big VRAM is an
        // Instinct" escape hatch and promote it to discrete.
        assert!(super::SystemSpecs::is_integrated_gpu(
            "AMD Radeon 780M Graphics",
            Some(8.25)
        ));
        // The #638 behaviour itself must survive: a generic-named card with
        // datacenter-sized VRAM is still treated as discrete.
        assert!(!super::SystemSpecs::is_integrated_gpu(
            "AMD Radeon Graphics",
            Some(32.0)
        ));
    }

    // ── bandwidth: RTX 20 series ─────────────────────────────────────

    #[test]
    fn test_bandwidth_rtx_20_series() {
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce RTX 2080 Ti"),
            Some(616.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce RTX 2060"),
            Some(336.0)
        );
    }

    // ── bandwidth: GTX 16 series ─────────────────────────────────────

    #[test]
    fn test_bandwidth_gtx_16_series() {
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce GTX 1660 Ti"),
            Some(288.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce GTX 1650"),
            Some(128.0)
        );
    }

    // ── bandwidth: RTX 50 series ─────────────────────────────────────

    #[test]
    fn test_bandwidth_rtx_50_series() {
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce RTX 5090"),
            Some(1792.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce RTX 5080"),
            Some(960.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce RTX 5070 Ti"),
            Some(896.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce RTX 5070"),
            Some(672.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce RTX 5060 Ti"),
            Some(448.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce RTX 5060"),
            Some(256.0)
        );
    }

    // ── bandwidth: mobile SKUs (#919) ────────────────────────

    // A "Laptop GPU" name must not inherit its desktop namesake's figure.
    // The desktop RTX 5070 reaches 672 GB/s over a 192-bit bus; the laptop
    // part has 128 bits, on which 672 GB/s would need 42 Gbps memory. No
    // mobile table is kept, so these fall back to the fixed constant.
    #[test]
    fn test_bandwidth_mobile_skus_are_unrecognized() {
        for name in [
            "NVIDIA GeForce RTX 5070 Laptop GPU",
            "NVIDIA GeForce RTX 5070 Ti Laptop GPU",
            "NVIDIA GeForce RTX 4090 Laptop GPU",
            "NVIDIA GeForce RTX 3080 Ti Laptop GPU",
            "NVIDIA GeForce RTX 2070 with Max-Q Design",
            "NVIDIA GeForce GTX 1660 Ti Mobile",
        ] {
            assert_eq!(
                super::gpu_memory_bandwidth_gbps(name),
                None,
                "{name} took the desktop card's bandwidth"
            );
        }
    }

    // The guard is keyed on the mobile marker, not on the model number, so
    // the desktop cards keep their entries.
    #[test]
    fn test_bandwidth_desktop_names_unaffected_by_mobile_guard() {
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce RTX 5070"),
            Some(672.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA GeForce RTX 4090"),
            Some(1008.0)
        );
        // "Max" in an Apple part is not the "Max-Q" marker.
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("Apple M3 Max"),
            Some(400.0)
        );
    }

    // ── vram: mobile SKUs (#919) ───────────────────────────────

    // Same failure mode in the VRAM fallback: the RTX 5070 Laptop GPU ships
    // 8 GB (12 GB on the 2026 refresh), not the desktop card's 12. It must
    // degrade to the generic per-family estimate rather than 0, so callers
    // that only accept a positive estimate still get one.
    #[test]
    fn test_vram_mobile_skus_use_generic_estimate() {
        assert_eq!(
            super::estimate_vram_from_name("NVIDIA GeForce RTX 5070 Laptop GPU"),
            8.0
        );
        assert_eq!(
            super::estimate_vram_from_name("NVIDIA GeForce RTX 4090 Laptop GPU"),
            8.0
        );
        assert_eq!(
            super::estimate_vram_from_name("NVIDIA GeForce GTX 1650 Mobile"),
            4.0
        );
        // Desktop names keep their exact figures.
        assert_eq!(
            super::estimate_vram_from_name("NVIDIA GeForce RTX 5070"),
            12.0
        );
        assert_eq!(
            super::estimate_vram_from_name("NVIDIA GeForce RTX 4090"),
            24.0
        );
    }

    // ── bandwidth: AMD RX 6000 series ────────────────────────────────

    #[test]
    fn test_bandwidth_amd_rx_6000() {
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("AMD Radeon RX 6950 XT"),
            Some(576.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("AMD Radeon RX 6700 XT"),
            Some(384.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("AMD Radeon RX 6600"),
            Some(224.0)
        );
    }

    // ── bandwidth: NVIDIA professional ───────────────────────────────

    #[test]
    fn test_bandwidth_nvidia_professional() {
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA RTX A6000"),
            Some(768.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("NVIDIA RTX A4000"),
            Some(448.0)
        );
        assert_eq!(super::gpu_memory_bandwidth_gbps("NVIDIA L40S"), Some(864.0));
        assert_eq!(super::gpu_memory_bandwidth_gbps("NVIDIA L4"), Some(300.0));
    }

    // ── bandwidth: Apple Silicon all variants ────────────────────────

    #[test]
    fn test_bandwidth_apple_silicon_all() {
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("Apple M4 Ultra"),
            Some(819.0)
        );
        assert_eq!(super::gpu_memory_bandwidth_gbps("Apple M4"), Some(120.0));
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("Apple M3 Ultra"),
            Some(800.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("Apple M3 Max"),
            Some(400.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("Apple M3 Pro"),
            Some(150.0)
        );
        assert_eq!(super::gpu_memory_bandwidth_gbps("Apple M3"), Some(100.0));
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("Apple M1 Pro"),
            Some(200.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("Apple M1 Ultra"),
            Some(800.0)
        );
    }

    // ── bandwidth: AMD CDNA ──────────────────────────────────────────

    #[test]
    fn test_bandwidth_amd_cdna() {
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("AMD Instinct MI250X"),
            Some(3277.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("AMD Instinct MI210"),
            Some(1638.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("AMD Instinct MI100"),
            Some(1229.0)
        );
    }

    // ── bandwidth: AMD RDNA 4 ────────────────────────────────────────

    #[test]
    fn test_bandwidth_amd_rdna4() {
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("AMD Radeon RX 9070 XT"),
            Some(624.0)
        );
        assert_eq!(
            super::gpu_memory_bandwidth_gbps("AMD Radeon RX 9070"),
            Some(488.0)
        );
    }

    // ── compute capability tests ──────────────────────────────────────

    #[test]
    fn test_compute_capability_nvidia_generations() {
        // Pascal
        assert_eq!(super::gpu_compute_capability("Tesla P100"), Some((6, 1)));
        // Volta
        assert_eq!(
            super::gpu_compute_capability("Tesla V100-PCIE-16GB"),
            Some((7, 0))
        );
        // Turing
        assert_eq!(super::gpu_compute_capability("Tesla T4"), Some((7, 5)));
        assert_eq!(
            super::gpu_compute_capability("NVIDIA GeForce RTX 2080 Ti"),
            Some((7, 5))
        );
        assert_eq!(
            super::gpu_compute_capability("NVIDIA GeForce GTX 1660 Ti"),
            Some((7, 5))
        );
        // Ampere
        assert_eq!(super::gpu_compute_capability("NVIDIA A100"), Some((8, 0)));
        assert_eq!(
            super::gpu_compute_capability("NVIDIA GeForce RTX 3090"),
            Some((8, 6))
        );
        // Ada Lovelace
        assert_eq!(
            super::gpu_compute_capability("NVIDIA GeForce RTX 4090"),
            Some((8, 9))
        );
        assert_eq!(super::gpu_compute_capability("NVIDIA L40S"), Some((8, 9)));
        // Hopper
        assert_eq!(
            super::gpu_compute_capability("NVIDIA H100 SXM"),
            Some((9, 0))
        );
        // Blackwell
        assert_eq!(
            super::gpu_compute_capability("NVIDIA GeForce RTX 5090"),
            Some((10, 0))
        );
    }

    #[test]
    fn test_compute_capability_unknown_returns_none() {
        assert_eq!(super::gpu_compute_capability("Some Random GPU"), None);
        assert_eq!(super::gpu_compute_capability("Apple M4 Max"), None);
        assert_eq!(
            super::gpu_compute_capability("AMD Radeon RX 7900 XTX"),
            None
        );
    }

    #[test]
    fn test_is_integrated_gpu_name() {
        // Intel integrated
        assert!(SystemSpecs::is_integrated_gpu_name(
            "Intel(R) UHD Graphics 770"
        ));
        assert!(SystemSpecs::is_integrated_gpu_name(
            "Intel(R) HD Graphics 630"
        ));
        assert!(SystemSpecs::is_integrated_gpu_name(
            "Intel(R) Iris(R) Xe Graphics"
        ));
        assert!(SystemSpecs::is_integrated_gpu_name(
            "Intel(R) Iris(R) Plus Graphics"
        ));
        // Intel discrete should NOT match
        assert!(!SystemSpecs::is_integrated_gpu_name(
            "Intel(R) Arc(TM) A770"
        ));
        assert!(!SystemSpecs::is_integrated_gpu_name(
            "Intel(R) Arc(TM) B580"
        ));
        // Explicit "(integrated)" tag from APU detection
        assert!(SystemSpecs::is_integrated_gpu_name(
            "AMD Ryzen AI 9 HX 370 w/ Radeon 890M (integrated)"
        ));
    }

    #[test]
    fn test_is_integrated_gpu_name_amd() {
        // AMD integrated (generic "Radeon Graphics" with no RX/PRO)
        assert!(SystemSpecs::is_integrated_gpu_name(
            "AMD Radeon(TM) Graphics"
        ));
        assert!(SystemSpecs::is_integrated_gpu_name("AMD Radeon Graphics"));
        // AMD discrete should NOT match
        assert!(!SystemSpecs::is_integrated_gpu_name(
            "AMD Radeon RX 7900 XTX"
        ));
        assert!(!SystemSpecs::is_integrated_gpu_name("AMD Radeon Pro W7900"));
    }

    #[test]
    fn test_is_integrated_gpu_name_nvidia() {
        // NVIDIA GPUs are never integrated in the traditional sense
        assert!(!SystemSpecs::is_integrated_gpu_name(
            "NVIDIA GeForce RTX 4090"
        ));
        assert!(!SystemSpecs::is_integrated_gpu_name(
            "NVIDIA GeForce GTX 1650"
        ));
    }

    #[test]
    fn test_prefer_discrete_gpus_filters_integrated() {
        use super::GpuBackend;
        let gpus = vec![
            super::GpuInfo {
                name: "Intel(R) UHD Graphics 770".to_string(),
                vram_gb: Some(8.0),
                backend: GpuBackend::Vulkan,
                count: 1,
                unified_memory: false,
            },
            super::GpuInfo {
                name: "NVIDIA GeForce RTX 4090".to_string(),
                vram_gb: Some(4.0), // WMI 32-bit cap may report low value
                backend: GpuBackend::Cuda,
                count: 1,
                unified_memory: false,
            },
        ];
        let result = SystemSpecs::prefer_discrete_gpus(gpus);
        assert_eq!(result.len(), 1);
        assert!(result[0].name.contains("RTX 4090"));
    }

    #[test]
    fn test_prefer_discrete_gpus_keeps_igpu_only() {
        use super::GpuBackend;
        let gpus = vec![super::GpuInfo {
            name: "Intel(R) UHD Graphics 770".to_string(),
            vram_gb: Some(2.0),
            backend: GpuBackend::Vulkan,
            count: 1,
            unified_memory: false,
        }];
        let result = SystemSpecs::prefer_discrete_gpus(gpus);
        assert_eq!(result.len(), 1);
        assert!(result[0].name.contains("UHD"));
    }

    #[test]
    fn test_parse_macos_metal_gpus_from_system_profiler_json() {
        let json = include_bytes!("../tests/fixtures/hardware/macos/intel-amd.json");

        let gpus = SystemSpecs::parse_macos_metal_gpus_from_system_profiler_json(json);

        assert_eq!(gpus.len(), 2);
        assert_eq!(gpus[0].name, "Intel HD Graphics 630");
        assert_eq!(gpus[0].backend, super::GpuBackend::Metal);
        assert_eq!(gpus[0].vram_gb, Some(1.5));
        assert!(!gpus[0].unified_memory);
        assert_eq!(gpus[1].name, "Radeon Pro 560");
        assert_eq!(gpus[1].backend, super::GpuBackend::Metal);
        assert_eq!(gpus[1].vram_gb, Some(4.0));
        assert!(!gpus[1].unified_memory);
    }

    #[test]
    fn test_parse_macos_metal_gpu_edge_fixtures() {
        let apple = include_bytes!("../tests/fixtures/hardware/macos/apple-silicon.json");
        assert!(SystemSpecs::parse_macos_metal_gpus_from_system_profiler_json(apple).is_empty());

        let missing_metal = include_bytes!("../tests/fixtures/hardware/macos/missing-metal.json");
        assert!(
            SystemSpecs::parse_macos_metal_gpus_from_system_profiler_json(missing_metal).is_empty()
        );

        let missing_vram = include_bytes!("../tests/fixtures/hardware/macos/missing-vram.json");
        let gpus = SystemSpecs::parse_macos_metal_gpus_from_system_profiler_json(missing_vram);
        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].name, "Radeon Pro 560");
        assert_eq!(gpus[0].vram_gb, None);
        assert_eq!(gpus[0].backend, super::GpuBackend::Metal);

        for invalid in [
            include_bytes!("../tests/fixtures/hardware/macos/invalid.json").as_slice(),
            include_bytes!("../tests/fixtures/hardware/macos/missing-array.json").as_slice(),
        ] {
            assert!(
                SystemSpecs::parse_macos_metal_gpus_from_system_profiler_json(invalid).is_empty()
            );
        }
    }

    #[test]
    fn test_quant_min_compute_capability() {
        assert_eq!(
            super::quant_min_compute_capability("AWQ-4bit"),
            Some((7, 5))
        );
        assert_eq!(
            super::quant_min_compute_capability("AWQ-8bit"),
            Some((7, 5))
        );
        assert_eq!(
            super::quant_min_compute_capability("GPTQ-Int4"),
            Some((7, 5))
        );
        assert_eq!(
            super::quant_min_compute_capability("GPTQ-Int8"),
            Some((7, 5))
        );
        // GGUF quants have no CC restriction
        assert_eq!(super::quant_min_compute_capability("Q4_K_M"), None);
        assert_eq!(super::quant_min_compute_capability("Q8_0"), None);
    }

    #[test]
    fn test_ram_override_updates_ram_values() {
        let specs = SystemSpecs {
            total_ram_gb: 32.0,
            available_ram_gb: 24.0,
            total_cpu_cores: 8,
            cpu_name: "Test CPU".to_string(),
            has_gpu: true,
            gpu_vram_gb: Some(16.0),
            total_gpu_vram_gb: Some(16.0),
            gpu_available_gb: None,
            gpu_name: Some("Test GPU".to_string()),
            gpu_count: 1,
            unified_memory: false,
            backend: super::GpuBackend::Cuda,
            gpus: vec![super::GpuInfo {
                name: "Test GPU".to_string(),
                vram_gb: Some(16.0),
                backend: super::GpuBackend::Cuda,
                count: 1,
                unified_memory: false,
            }],
            cluster_mode: false,
            cluster_node_count: 0,
        };

        let overridden = specs.with_ram_override(128.0);
        assert_eq!(overridden.total_ram_gb, 128.0);
        assert!((overridden.available_ram_gb - 115.2).abs() < 0.01);
        // Discrete GPU VRAM unchanged
        assert_eq!(overridden.gpu_vram_gb, Some(16.0));
        assert_eq!(overridden.total_gpu_vram_gb, Some(16.0));
    }

    #[test]
    fn test_ram_override_unified_memory_updates_gpu() {
        let specs = SystemSpecs {
            total_ram_gb: 36.0,
            available_ram_gb: 30.0,
            total_cpu_cores: 10,
            cpu_name: "Apple M2 Max".to_string(),
            has_gpu: true,
            gpu_vram_gb: Some(36.0),
            total_gpu_vram_gb: Some(36.0),
            gpu_available_gb: Some(27.0),
            gpu_name: Some("Apple M2 Max".to_string()),
            gpu_count: 1,
            unified_memory: true,
            backend: super::GpuBackend::Metal,
            gpus: vec![super::GpuInfo {
                name: "Apple M2 Max".to_string(),
                vram_gb: Some(36.0),
                backend: super::GpuBackend::Metal,
                count: 1,
                unified_memory: true,
            }],
            cluster_mode: false,
            cluster_node_count: 0,
        };

        let overridden = specs.with_ram_override(96.0);
        assert_eq!(overridden.total_ram_gb, 96.0);
        assert_eq!(overridden.gpu_vram_gb, Some(96.0));
        assert_eq!(overridden.total_gpu_vram_gb, Some(96.0));
        assert_eq!(overridden.gpus[0].vram_gb, Some(96.0));
        assert_eq!(overridden.gpu_available_gb, None);
    }

    #[test]
    fn test_cpu_core_override() {
        let specs = SystemSpecs {
            total_ram_gb: 32.0,
            available_ram_gb: 24.0,
            total_cpu_cores: 8,
            cpu_name: "Test CPU".to_string(),
            has_gpu: false,
            gpu_vram_gb: None,
            total_gpu_vram_gb: None,
            gpu_available_gb: None,
            gpu_name: None,
            gpu_count: 0,
            unified_memory: false,
            backend: super::GpuBackend::CpuX86,
            gpus: vec![],
            cluster_mode: false,
            cluster_node_count: 0,
        };

        let overridden = specs.with_cpu_core_override(64);
        assert_eq!(overridden.total_cpu_cores, 64);
        // Other fields unchanged
        assert_eq!(overridden.total_ram_gb, 32.0);
        assert_eq!(overridden.available_ram_gb, 24.0);
        assert!(!overridden.has_gpu);
    }

    #[test]
    fn test_parse_rocm_smi_two_different_gpus() {
        let vram_text = include_str!("../tests/fixtures/hardware/rocm/block-vram.txt");
        let product_text = include_str!("../tests/fixtures/hardware/rocm/block-product.txt");

        let gpus = SystemSpecs::parse_rocm_smi_output(vram_text, Some(product_text));

        assert_eq!(gpus.len(), 2, "should detect two distinct GPUs");
        assert!(
            gpus.iter()
                .any(|g| g.name.contains("RX 7600") && g.count == 1),
            "should find RX 7600"
        );
        assert!(
            gpus.iter()
                .any(|g| g.name.contains("R9700") && g.count == 1),
            "should find R9700"
        );

        let rx7600 = gpus.iter().find(|g| g.name.contains("RX 7600")).unwrap();
        let r9700 = gpus.iter().find(|g| g.name.contains("R9700")).unwrap();
        // RX 7600 ~8 GB, R9700 ~32 GB
        assert!(rx7600.vram_gb.unwrap() > 7.0 && rx7600.vram_gb.unwrap() < 9.0);
        assert!(r9700.vram_gb.unwrap() > 31.0 && r9700.vram_gb.unwrap() < 33.0);
    }

    #[test]
    fn test_parse_rocm_smi_identical_gpus_grouped() {
        let vram_text = "\
GPU[0]          : VRAM Total Memory (B): 34208743424
GPU[0]          : VRAM Total Used Memory (B): 100000
GPU[1]          : VRAM Total Memory (B): 34208743424
GPU[1]          : VRAM Total Used Memory (B): 200000";

        let product_text = "\
GPU[0]          : Card Series:          AMD Radeon AI PRO R9700
GPU[1]          : Card Series:          AMD Radeon AI PRO R9700";

        let gpus = SystemSpecs::parse_rocm_smi_output(vram_text, Some(product_text));

        assert_eq!(gpus.len(), 1, "identical GPUs should be grouped");
        assert_eq!(gpus[0].count, 2);
        assert!(gpus[0].name.contains("R9700"));
    }

    #[test]
    fn test_parse_rocm_smi_igpu_filtered() {
        let vram_text = include_str!("../tests/fixtures/hardware/rocm/mixed-vram.txt");
        let product_text = include_str!("../tests/fixtures/hardware/rocm/mixed-product.txt");

        let gpus = SystemSpecs::parse_rocm_smi_output(vram_text, Some(product_text));

        assert_eq!(gpus.len(), 1, "iGPU should be filtered out");
        assert!(gpus[0].name.contains("R9700"));
    }

    #[test]
    fn test_parse_rocm_smi_no_product_text() {
        let vram_text = include_str!("../tests/fixtures/hardware/rocm/block-vram.txt");

        let gpus = SystemSpecs::parse_rocm_smi_output(vram_text, None);

        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].name, "AMD GPU");
        assert_eq!(gpus[0].count, 2);
        assert!(gpus[0].vram_gb.unwrap() > 31.0);
    }

    // Strix Halo (AMD Ryzen AI MAX+) with libdrm_amdgpu.so missing: rocm-smi
    // reports `Card Series: N/A` for the marketing name. That "N/A" must not
    // become the GPU identity — it falls back to a generic "AMD GPU" so the
    // APU-unify step (and, ultimately, the leaderboard) can name it properly.
    #[test]
    fn test_parse_rocm_smi_na_product_name_falls_back() {
        let vram_text = "\
GPU[0]          : VRAM Total Memory (B): 68719476736
GPU[0]          : VRAM Total Used Memory (B): 53637746688";
        let product_text = "\
GPU[0]          : Card Series:            N/A
GPU[0]          : Card Model:             0x1586
GPU[0]          : Card SKU:               STRXLGEN
GPU[0]          : GFX Version:            gfx1151";

        let gpus = SystemSpecs::parse_rocm_smi_output(vram_text, Some(product_text));

        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].name, "AMD GPU", "N/A must not be used as a name");
        assert!(gpus[0].vram_gb.unwrap() > 63.0);
    }

    #[test]
    fn test_is_placeholder_and_generic_amd_gpu_name() {
        assert!(super::is_placeholder_gpu_name("N/A"));
        assert!(super::is_placeholder_gpu_name(" n/a "));
        assert!(super::is_placeholder_gpu_name("unknown"));
        assert!(!super::is_placeholder_gpu_name("Radeon 8060S"));
        assert!(super::is_generic_amd_gpu_name("AMD GPU"));
        assert!(super::is_generic_amd_gpu_name("AMD/ATI"));
        assert!(super::is_generic_amd_gpu_name("Radeon Graphics"));
        assert!(!super::is_generic_amd_gpu_name("AMD Radeon RX 7900 XTX"));
    }

    // Newer rocm-smi emits a tabular layout instead of one line per field.
    // Models the dual Instinct MI50 setup from issue #638 (both cards share
    // the same product name and 32 GB VRAM), which the block-only parser
    // collapsed to a single card.
    #[test]
    fn test_parse_rocm_smi_tabular_identical_gpus() {
        let vram_text = include_str!("../tests/fixtures/hardware/rocm/tabular-vram.txt");
        let product_text = include_str!("../tests/fixtures/hardware/rocm/tabular-product.txt");

        let gpus = SystemSpecs::parse_rocm_smi_output(vram_text, Some(product_text));

        assert_eq!(gpus.len(), 1, "identical tabular GPUs should be grouped");
        assert_eq!(gpus[0].count, 2, "both MI50s should be detected");
        assert!(gpus[0].name.contains("MI50"), "name: {}", gpus[0].name);
        assert!(gpus[0].vram_gb.unwrap() > 31.0 && gpus[0].vram_gb.unwrap() < 33.0);
    }

    #[test]
    fn test_parse_rocm_smi_tabular_two_different_gpus() {
        let vram_text = "\
Device  Node  VRAM Total Memory (B)   VRAM Total Used Memory (B)
0       2     8573157376              60448768
1       1     34208743424             33732509696";

        let product_text = "\
Device  Card Series              Card Model
0       AMD Radeon RX 7600       0x7480
1       AMD Radeon AI PRO R9700  0x7551";

        let gpus = SystemSpecs::parse_rocm_smi_output(vram_text, Some(product_text));

        assert_eq!(gpus.len(), 2, "should detect two distinct tabular GPUs");
        let rx7600 = gpus.iter().find(|g| g.name.contains("RX 7600")).unwrap();
        let r9700 = gpus.iter().find(|g| g.name.contains("R9700")).unwrap();
        assert_eq!(rx7600.count, 1);
        assert_eq!(r9700.count, 1);
        assert!(rx7600.vram_gb.unwrap() > 7.0 && rx7600.vram_gb.unwrap() < 9.0);
        assert!(r9700.vram_gb.unwrap() > 31.0 && r9700.vram_gb.unwrap() < 33.0);
    }

    // Tabular VRAM without parseable product names still yields the right
    // count (names fall back to "AMD GPU" but cards are not lost).
    #[test]
    fn test_parse_rocm_smi_tabular_vram_no_names() {
        let vram_text = include_str!("../tests/fixtures/hardware/rocm/tabular-vram.txt");

        let gpus = SystemSpecs::parse_rocm_smi_output(vram_text, None);

        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].count, 2);
        assert_eq!(gpus[0].name, "AMD GPU");
    }

    #[test]
    fn test_parse_rocm_smi_malformed_fixture() {
        let malformed = include_str!("../tests/fixtures/hardware/rocm/malformed.txt");
        assert!(SystemSpecs::parse_rocm_smi_output(malformed, Some(malformed)).is_empty());
    }

    // Regression for issue #638 (keyz182): verbatim rocm-smi block output
    // from a mixed system — a 32 GB MI50 that reports the generic
    // `Card Series: AMD Radeon Graphics`, a 16 GB MI50 with the proper
    // Instinct name, and a 512 MB Cezanne iGPU. The generic-named 32 GB
    // card must survive both the iGPU VRAM filter and prefer_discrete_gpus,
    // and must not be grouped with the iGPU that shares its generic name.
    #[test]
    fn test_parse_rocm_smi_mixed_mi50s_generic_name_and_igpu() {
        let vram_text = "\
============================ ROCm System Management Interface ============================
================================== Memory Usage (Bytes) ==================================
GPU[0]\t\t: VRAM Total Memory (B): 34342961152
GPU[0]\t\t: VRAM Total Used Memory (B): 25227759616
GPU[1]\t\t: VRAM Total Memory (B): 17163091968
GPU[1]\t\t: VRAM Total Used Memory (B): 7695077376
GPU[2]\t\t: VRAM Total Memory (B): 536870912
GPU[2]\t\t: VRAM Total Used Memory (B): 18165760
==========================================================================================
================================== End of ROCm SMI Log ===================================";

        let product_text = "\
============================ ROCm System Management Interface ============================
====================================== Product Info ======================================
GPU[0]\t\t: Card Series: \t\tAMD Radeon Graphics
GPU[0]\t\t: Card Model: \t\t0x66a0
GPU[0]\t\t: Card Vendor: \t\tAdvanced Micro Devices, Inc. [AMD/ATI]
GPU[0]\t\t: Card SKU: \t\tD1640200
GPU[0]\t\t: Subsystem ID: \t0x081e
GPU[0]\t\t: Device Rev: \t\t0x00
GPU[0]\t\t: Node ID: \t\t1
GPU[0]\t\t: GUID: \t\t45854
GPU[0]\t\t: GFX Version: \t\tgfx906
GPU[1]\t\t: Card Series: \t\tAMD Instinct MI60 / MI50
GPU[1]\t\t: Card Model: \t\t0x66a1
GPU[1]\t\t: Card Vendor: \t\tAdvanced Micro Devices, Inc. [AMD/ATI]
GPU[1]\t\t: Card SKU: \t\tD1631400
GPU[1]\t\t: Subsystem ID: \t0x0834
GPU[1]\t\t: Device Rev: \t\t0x02
GPU[1]\t\t: Node ID: \t\t2
GPU[1]\t\t: GUID: \t\t28640
GPU[1]\t\t: GFX Version: \t\tgfx906
GPU[2]\t\t: Card Series: \t\tAMD Radeon Graphics
GPU[2]\t\t: Card Model: \t\t0x1638
GPU[2]\t\t: Card Vendor: \t\tAdvanced Micro Devices, Inc. [AMD/ATI]
GPU[2]\t\t: Card SKU: \t\tCEZANNE
GPU[2]\t\t: Subsystem ID: \t0x1636
GPU[2]\t\t: Device Rev: \t\t0xc8
GPU[2]\t\t: Node ID: \t\t3
GPU[2]\t\t: GUID: \t\t48746
GPU[2]\t\t: GFX Version: \t\tgfx90c
==========================================================================================
================================== End of ROCm SMI Log ===================================";

        let gpus = SystemSpecs::parse_rocm_smi_output(vram_text, Some(product_text));

        assert_eq!(
            gpus.len(),
            2,
            "both MI50s must be detected, iGPU excluded: {gpus:?}"
        );
        let big = gpus
            .iter()
            .find(|g| g.vram_gb.unwrap_or(0.0) > 30.0)
            .expect("32 GB MI50 missing");
        let small = gpus
            .iter()
            .find(|g| {
                let v = g.vram_gb.unwrap_or(0.0);
                v > 15.0 && v < 17.0
            })
            .expect("16 GB MI50 missing");
        // Generic name disambiguated with the GFX version.
        assert_eq!(big.name, "AMD Radeon Graphics (gfx906)");
        assert!(small.name.contains("MI60 / MI50"));

        // The generic-named 32 GB accelerator must survive the global
        // discrete-preference filter alongside the properly named card.
        let filtered = SystemSpecs::prefer_discrete_gpus(gpus);
        assert_eq!(
            filtered.len(),
            2,
            "prefer_discrete_gpus must not drop a 32 GB accelerator: {filtered:?}"
        );
    }

    #[test]
    fn test_mixed_gpu_fixtures_deduplicate_and_sort_sources() {
        let rocm_vram = include_str!("../tests/fixtures/hardware/mixed/rocm-vram.txt");
        let rocm_product = include_str!("../tests/fixtures/hardware/mixed/rocm-product.txt");
        let vulkan = include_str!("../tests/fixtures/hardware/mixed/vulkan-summary.txt");

        let rocm = SystemSpecs::parse_rocm_smi_output(rocm_vram, Some(rocm_product));
        let vulkan = SystemSpecs::parse_vulkan_gpus(vulkan);
        let gpus = SystemSpecs::merge_gpu_sources(rocm, vulkan);

        assert_eq!(
            gpus.len(),
            3,
            "duplicate AMD reports were not merged: {gpus:?}"
        );
        assert_eq!(gpus[0].name, "AMD Radeon RX 7900 XTX");
        assert_eq!(gpus[0].vram_gb, Some(24.0));
        assert_eq!(gpus[0].count, 1);
        assert_eq!(
            gpus.iter()
                .filter(|gpu| gpu.name.contains("7900 XTX"))
                .count(),
            1,
            "the ROCm and Vulkan reports must merge once"
        );
        assert!(
            gpus.iter()
                .any(|gpu| gpu.name.contains("7800 XT") && gpu.count == 1),
            "a distinct AMD GPU must remain present"
        );
        assert!(
            gpus.iter()
                .any(|gpu| gpu.name == "NVIDIA GeForce RTX 4070" && gpu.count == 1),
            "a distinct NVIDIA GPU must remain present"
        );
    }

    #[test]
    fn test_invalid_gpu_fixture_does_not_remove_valid_source() {
        let rocm_vram = include_str!("../tests/fixtures/hardware/mixed/rocm-vram.txt");
        let rocm_product = include_str!("../tests/fixtures/hardware/mixed/rocm-product.txt");
        let malformed = include_str!("../tests/fixtures/hardware/mixed/vulkan-malformed.txt");

        let rocm = SystemSpecs::parse_rocm_smi_output(rocm_vram, Some(rocm_product));
        let malformed = SystemSpecs::parse_vulkan_gpus(malformed);
        let gpus = SystemSpecs::merge_gpu_sources(rocm, malformed);

        assert_eq!(gpus.len(), 1);
        assert_eq!(gpus[0].name, "AMD Radeon RX 7900 XTX");
        assert_eq!(gpus[0].vram_gb, Some(24.0));
    }

    // Real `lspci -nnD` line from a Lunar Lake laptop (Core Ultra 7 258V,
    // Arc 140V iGPU): must classify as integrated/unified with the RAM pool,
    // not a 0-VRAM discrete device (issue #609 family).
    #[test]
    fn test_parse_intel_igpu_from_lspci_lunar_lake() {
        let text = "0000:00:02.0 VGA compatible controller [0300]: Intel Corporation Core Ultra 200V Series Processors Arc Graphics 130V/140V GPU [8086:64a0] (rev 04)";
        let gpus = SystemSpecs::parse_intel_gpus_from_lspci(text, 32.0, |_| None);
        assert_eq!(gpus.len(), 1, "{gpus:?}");
        assert_eq!(gpus[0].name, "Intel Arc Graphics 130V/140V (integrated)");
        assert!(gpus[0].unified_memory);
        assert_eq!(gpus[0].vram_gb, Some(32.0));
    }

    // Discrete Arc cards enumerate behind a bridge (nonzero bus). Their
    // dedicated VRAM comes from the sysfs lookup keyed by PCI address; when
    // the lookup has nothing (e.g. driver not bound), VRAM stays None but the
    // card must still be detected and named (issue #609).
    #[test]
    fn test_parse_intel_dgpu_from_lspci() {
        let a770 = "0000:03:00.0 VGA compatible controller [0300]: Intel Corporation DG2 [Arc A770] [8086:56a0] (rev 08)";
        let gpus = SystemSpecs::parse_intel_gpus_from_lspci(a770, 32.0, |_| None);
        assert_eq!(gpus.len(), 1, "{gpus:?}");
        assert_eq!(gpus[0].name, "Intel Arc A770");
        assert!(!gpus[0].unified_memory);
        assert_eq!(gpus[0].vram_gb, None);

        let b70 = "0000:03:00.0 VGA compatible controller [0300]: Intel Corporation Battlemage G21 [Arc Pro B70] [8086:e211]";
        let gpus = SystemSpecs::parse_intel_gpus_from_lspci(b70, 32.0, |addr| {
            assert_eq!(addr, "0000:03:00.0");
            Some(24.0)
        });
        assert_eq!(gpus.len(), 1, "{gpus:?}");
        assert_eq!(gpus[0].name, "Intel Arc Pro B70");
        assert!(!gpus[0].unified_memory);
        assert_eq!(gpus[0].vram_gb, Some(24.0));
    }

    // Dual-card setup from issue #609: each card gets its own entry with
    // per-address VRAM, so total_gpu_vram_gb aggregation sees both.
    #[test]
    fn test_parse_intel_dual_dgpu_from_lspci() {
        let text = "\
0000:03:00.0 VGA compatible controller [0300]: Intel Corporation Battlemage G21 [Arc Pro B70] [8086:e211]
0000:04:00.0 VGA compatible controller [0300]: Intel Corporation Battlemage G21 [Arc Pro B70] [8086:e211]";
        let gpus = SystemSpecs::parse_intel_gpus_from_lspci(text, 32.0, |_| Some(24.0));
        assert_eq!(gpus.len(), 2, "{gpus:?}");
        for gpu in &gpus {
            assert_eq!(gpu.name, "Intel Arc Pro B70");
            assert_eq!(gpu.vram_gb, Some(24.0));
            assert!(!gpu.unified_memory);
        }
    }

    #[test]
    fn test_parse_intel_igpu_and_dgpu_together() {
        let text = "\
0000:00:02.0 VGA compatible controller [0300]: Intel Corporation Alder Lake-P GT2 [Iris Xe Graphics] [8086:46a6] (rev 0c)
0000:03:00.0 VGA compatible controller [0300]: Intel Corporation DG2 [Arc A770] [8086:56a0] (rev 08)";
        let gpus = SystemSpecs::parse_intel_gpus_from_lspci(text, 64.0, |_| Some(16.0));
        assert_eq!(gpus.len(), 2, "{gpus:?}");
        assert!(gpus[0].unified_memory && gpus[0].name.contains("(integrated)"));
        assert_eq!(gpus[0].vram_gb, Some(64.0), "iGPU shares the RAM pool");
        assert_eq!(gpus[1].name, "Intel Arc A770");
        assert!(!gpus[1].unified_memory);
        assert_eq!(gpus[1].vram_gb, Some(16.0));
    }

    // ── issue #964: legacy Intel iGPUs are not inference GPUs ────────

    // Fixture from issue #964 (sean-abbott): a Coffee Lake UHD 630 at
    // 00:02.0 on a CPU-only box was reported as a 46.88 GB unified-memory
    // SYCL GPU — the whole RAM pool echoed back as VRAM — while every
    // Ollama load ran at 100% CPU. It must not be a GPU at all.
    #[test]
    fn test_parse_intel_lspci_skips_legacy_uhd_igpu() {
        let text = "0000:00:02.0 VGA compatible controller [0300]: Intel Corporation CoffeeLake-S GT2 [UHD Graphics 630] [8086:3e92]";
        let gpus = SystemSpecs::parse_intel_gpus_from_lspci(text, 46.879_173_278_808_594, |_| None);
        assert!(gpus.is_empty(), "{gpus:?}");

        // Alongside a discrete card the legacy iGPU is dropped and the card
        // is kept, as on Windows (#840).
        let text = "\
0000:00:02.0 VGA compatible controller [0300]: Intel Corporation Raptor Lake-S UHD Graphics [8086:a780] (rev 04)
0000:03:00.0 VGA compatible controller [0300]: Intel Corporation DG2 [Arc A770] [8086:56a0] (rev 08)";
        let gpus = SystemSpecs::parse_intel_gpus_from_lspci(text, 64.0, |_| Some(16.0));
        assert_eq!(gpus.len(), 1, "{gpus:?}");
        assert_eq!(gpus[0].name, "Intel Arc A770");
    }

    // The Vulkan fallback runs after the lspci path and would otherwise
    // re-add the same iGPU under its Mesa name.
    #[test]
    fn test_parse_vulkan_gpus_skips_legacy_intel_igpu() {
        let text = "GPU0:\n\tdeviceName = Intel(R) UHD Graphics 630 (CFL GT2)\n";
        assert!(SystemSpecs::parse_vulkan_gpus(text).is_empty());

        let text = "GPU0:\n\tdeviceName = Intel(R) Arc(tm) Graphics (LNL)\n";
        assert_eq!(SystemSpecs::parse_vulkan_gpus(text).len(), 1);
    }

    #[test]
    fn test_is_legacy_intel_igpu_name() {
        let legacy = SystemSpecs::is_legacy_intel_igpu_name;
        assert!(legacy("Intel(R) HD Graphics 4000"));
        assert!(legacy("Intel CoffeeLake-S GT2 [UHD Graphics 630]"));
        assert!(legacy("Intel(R) UHD Graphics 770"));
        // Capable integrated and discrete parts are untouched.
        assert!(!legacy("Intel Alder Lake-P GT2 [Iris Xe Graphics]"));
        assert!(!legacy("Intel Arc Graphics 130V/140V"));
        assert!(!legacy("Intel Arc A770"));
        assert!(!legacy("AMD Radeon Graphics"));
    }

    // xe driver sysfs layout: per-tile physical_vram_size_bytes under the PCI
    // device directory. i915 layout: drm/cardN/lmem_total_bytes. Both must
    // yield the card's dedicated VRAM; an iGPU-like tree (neither file) must
    // yield None.
    #[test]
    fn test_intel_dgpu_vram_from_sysfs_layouts() {
        let root = std::env::temp_dir().join(format!(
            "llmfit-test-intel-sysfs-{}-{:?}",
            std::process::id(),
            std::thread::current().id()
        ));

        // Directory names avoid PCI-address colons — invalid in Windows
        // filenames, and the function only cares about the tree layout.
        // xe: two tiles of 12 GiB each → 24 GiB total.
        let xe_dev = root.join("xe-dev");
        std::fs::create_dir_all(xe_dev.join("tile0")).unwrap();
        std::fs::create_dir_all(xe_dev.join("tile1")).unwrap();
        let tile_bytes = (12u64 * 1024 * 1024 * 1024).to_string();
        std::fs::write(xe_dev.join("tile0/physical_vram_size_bytes"), &tile_bytes).unwrap();
        std::fs::write(xe_dev.join("tile1/physical_vram_size_bytes"), &tile_bytes).unwrap();
        assert_eq!(
            SystemSpecs::intel_dgpu_vram_gb_from_pci_dir(&xe_dev),
            Some(24.0)
        );

        // i915 discrete: lmem_total_bytes under the DRM card node.
        let i915_dev = root.join("i915-dev");
        std::fs::create_dir_all(i915_dev.join("drm/card1")).unwrap();
        std::fs::write(
            i915_dev.join("drm/card1/lmem_total_bytes"),
            (16u64 * 1024 * 1024 * 1024).to_string(),
        )
        .unwrap();
        assert_eq!(
            SystemSpecs::intel_dgpu_vram_gb_from_pci_dir(&i915_dev),
            Some(16.0)
        );

        // iGPU: DRM card node exists but no VRAM files anywhere.
        let igpu_dev = root.join("igpu-dev");
        std::fs::create_dir_all(igpu_dev.join("drm/card0")).unwrap();
        assert_eq!(
            SystemSpecs::intel_dgpu_vram_gb_from_pci_dir(&igpu_dev),
            None
        );

        std::fs::remove_dir_all(&root).ok();
    }

    // Mesa/Vulkan reports Intel devices by codename ("(LNL)") — must dedupe
    // against the lspci-derived integrated entry, but a discrete Arc with a
    // model number must NOT be swallowed by the iGPU entry.
    #[test]
    fn test_is_same_gpu_name_intel_igpu_vs_vulkan_codename() {
        assert!(SystemSpecs::is_same_gpu_name(
            "Intel Arc Graphics 130V/140V (integrated)",
            "Intel(R) Arc(tm) Graphics (LNL)"
        ));
        assert!(!SystemSpecs::is_same_gpu_name(
            "Intel Arc Graphics 130V/140V (integrated)",
            "Intel(R) Arc(tm) A770 Graphics"
        ));
        assert!(SystemSpecs::is_same_gpu_name(
            "Intel Arc A770",
            "Intel(R) Arc(tm) A770 Graphics"
        ));
    }

    // Arc Pro cards have 2-digit model numbers ("B70") that the 3-5 digit
    // extractor drops — the lspci name and the Vulkan/Level Zero name must
    // still dedupe via letter-prefixed model tokens (issue #609).
    #[test]
    fn test_is_same_gpu_name_intel_arc_pro_two_digit_model() {
        assert!(SystemSpecs::is_same_gpu_name(
            "Intel Arc Pro B70",
            "Intel(R) Arc(TM) Pro B70 Graphics"
        ));
        assert!(!SystemSpecs::is_same_gpu_name(
            "Intel Arc Pro B70",
            "Intel(R) Arc(TM) Pro B60 Graphics"
        ));
        // A dGPU with a model token must not be swallowed by an iGPU entry.
        assert!(!SystemSpecs::is_same_gpu_name(
            "Intel Arc Graphics 130V/140V (integrated)",
            "Intel(R) Arc(TM) Pro B70 Graphics"
        ));
    }

    #[test]
    fn test_prefer_discrete_gpus_drops_small_generic_radeon_keeps_large() {
        use super::GpuBackend;
        let mk = |name: &str, vram: f64| super::GpuInfo {
            name: name.to_string(),
            vram_gb: Some(vram),
            backend: GpuBackend::Rocm,
            count: 1,
            unified_memory: false,
        };
        let gpus = vec![
            mk("AMD Radeon Graphics", 32.0), // mislabeled MI50-class accelerator
            mk("AMD Radeon(TM) Graphics", 0.5), // true APU iGPU
            mk("AMD Instinct MI60 / MI50", 16.0),
        ];
        let result = SystemSpecs::prefer_discrete_gpus(gpus);
        assert_eq!(result.len(), 2, "{result:?}");
        assert!(result.iter().any(|g| g.vram_gb == Some(32.0)));
        assert!(result.iter().any(|g| g.name.contains("Instinct")));
    }

    // ── Windows VRAM: WMI 32-bit cap and registry override (#830) ─────

    // Anonymous PowerShell fixture covers direct WMI values, an integrated
    // GPU, the 32-bit AdapterRAM cap, and malformed rows without executing WMI.
    #[test]
    fn test_parse_windows_gpu_fixture() {
        let text = include_str!("../tests/fixtures/hardware/windows/win32-video-controller.txt");
        let gpus = SystemSpecs::parse_windows_gpu_list(text);
        let names = gpus.iter().map(|gpu| gpu.name.as_str()).collect::<Vec<_>>();

        assert_eq!(
            names,
            vec![
                "NVIDIA GeForce RTX 4070",
                "AMD Radeon 780M Graphics",
                "AMD Radeon AI PRO R9700",
                "AMD Radeon PRO V710",
            ],
            "malformed rows must be ignored: {gpus:?}"
        );

        let rtx4070 = &gpus[0];
        assert_eq!(rtx4070.backend, super::GpuBackend::Cuda);
        assert_eq!(rtx4070.vram_gb, Some(12.0));

        let integrated = &gpus[1];
        assert_eq!(integrated.backend, super::GpuBackend::Vulkan);
        assert_eq!(integrated.vram_gb, Some(2.0));
        assert!(SystemSpecs::is_integrated_gpu(
            &integrated.name,
            integrated.vram_gb
        ));

        let capped = &gpus[2];
        assert_eq!(capped.backend, super::GpuBackend::Vulkan);
        assert_eq!(capped.vram_gb, Some(32.0));
        assert!(!SystemSpecs::is_integrated_gpu(
            &capped.name,
            capped.vram_gb
        ));
    }

    // Regression for #840: Win32_VideoController reports its shared-memory
    // aperture as AdapterRAM, but Intel HD Graphics predates the supported
    // oneAPI integrated-GPU generations. It must not create a standalone SYCL
    // memory pool and route model fits through GPU mode.
    #[test]
    fn test_parse_windows_gpu_list_skips_legacy_intel_hd_graphics() {
        let legacy_only =
            SystemSpecs::parse_windows_gpu_list("Intel(R) HD Graphics 4000|2158112768\n");
        assert!(legacy_only.is_empty(), "{legacy_only:?}");

        let gpus = SystemSpecs::parse_windows_gpu_list(
            "Intel(R) HD Graphics 4000|2158112768\n\
             NVIDIA GeForce RTX 4090|4293918720\n",
        );

        assert_eq!(
            gpus.len(),
            1,
            "legacy Intel iGPU was treated as usable: {gpus:?}"
        );
        assert_eq!(gpus[0].name, "NVIDIA GeForce RTX 4090");
        assert_eq!(gpus[0].backend, super::GpuBackend::Cuda);
    }

    #[test]
    fn test_estimate_vram_radeon_ai_pro_r9700() {
        assert_eq!(
            super::estimate_vram_from_name("AMD Radeon AI PRO R9700"),
            32.0
        );
        // The RDNA 4 consumer parts must keep their own sizes.
        assert_eq!(
            super::estimate_vram_from_name("AMD Radeon RX 9070 XT"),
            16.0
        );
        assert_eq!(
            super::estimate_vram_from_name("AMD Radeon RX 9060 XT"),
            16.0
        );
    }

    #[test]
    fn test_parse_windows_registry_vram_fixture() {
        let text = include_str!("../tests/fixtures/hardware/windows/registry-vram.txt");
        let entries = SystemSpecs::parse_windows_registry_vram(text);

        assert_eq!(
            entries,
            vec![
                ("AMD Radeon PRO V710".to_string(), 30064771072),
                ("NVIDIA GeForce RTX 4070".to_string(), 12884901888),
            ]
        );
    }

    // The WDK documents HardwareInformation.MemorySize in megabytes while WMI
    // treats the same value as bytes. A driver that writes megabytes must not
    // be allowed to overwrite a sane estimate with a few kilobytes, so values
    // below the plausibility floor are dropped and the estimate survives.
    #[test]
    fn test_parse_windows_registry_vram_rejects_megabyte_units() {
        let entries = SystemSpecs::parse_windows_registry_vram("Some Adapter|8192\n");
        assert!(entries.is_empty(), "{entries:?}");

        let gpus = SystemSpecs::parse_windows_gpu_list("AMD Radeon AI PRO R9700|4293918720\n");
        let gpus = SystemSpecs::apply_registry_vram(gpus, &entries);
        assert_eq!(
            gpus[0].vram_gb,
            Some(32.0),
            "a bogus value overwrote a good one"
        );
    }

    // The registry value is driver-published and 64-bit wide, so it wins over
    // both the capped AdapterRAM and the name-table estimate.
    #[test]
    fn test_registry_vram_overrides_wmi_estimate() {
        let gpus = SystemSpecs::parse_windows_gpu_list("AMD Radeon AI PRO R9700|4293918720\n");
        let registry = vec![("AMD Radeon AI PRO R9700".to_string(), 34359738368u64)];
        let gpus = SystemSpecs::apply_registry_vram(gpus, &registry);
        assert_eq!(gpus[0].vram_gb, Some(32.0));
    }

    // A card the name table does not know at all still gets the right size
    // once the registry fixture is applied.
    #[test]
    fn test_registry_vram_rescues_unknown_card() {
        let wmi = include_str!("../tests/fixtures/hardware/windows/win32-video-controller.txt");
        let registry = include_str!("../tests/fixtures/hardware/windows/registry-vram.txt");
        let gpus = SystemSpecs::parse_windows_gpu_list(wmi);
        let before = gpus
            .iter()
            .find(|gpu| gpu.name == "AMD Radeon PRO V710")
            .expect("fixture GPU should be parsed");
        assert_eq!(before.vram_gb, Some(8.0), "expected the generic guess");

        let registry = SystemSpecs::parse_windows_registry_vram(registry);
        let gpus = SystemSpecs::apply_registry_vram(gpus, &registry);
        let after = gpus
            .iter()
            .find(|gpu| gpu.name == "AMD Radeon PRO V710")
            .expect("fixture GPU should remain present");
        assert_eq!(after.vram_gb, Some(28.0));
    }

    // Drivers that expose the adapter under a slightly different description
    // in WMI than in the registry must still match.
    #[test]
    fn test_registry_vram_matches_on_substring() {
        let gpus = SystemSpecs::parse_windows_gpu_list("AMD Radeon AI PRO R9700|4293918720\n");
        let registry = vec![("Radeon AI PRO R9700".to_string(), 34359738368u64)];
        let gpus = SystemSpecs::apply_registry_vram(gpus, &registry);
        assert_eq!(gpus[0].vram_gb, Some(32.0));
    }

    // An unrelated registry entry must not be applied to the wrong adapter,
    // and an empty dump must leave detection untouched.
    #[test]
    fn test_registry_vram_leaves_unmatched_gpus_alone() {
        let gpus = SystemSpecs::parse_windows_gpu_list("NVIDIA GeForce RTX 4090|4293918720\n");
        assert_eq!(gpus[0].vram_gb, Some(24.0));

        let registry = vec![("AMD Radeon AI PRO R9700".to_string(), 34359738368u64)];
        let matched = SystemSpecs::apply_registry_vram(gpus.clone(), &registry);
        assert_eq!(matched[0].vram_gb, Some(24.0), "matched the wrong adapter");

        let untouched = SystemSpecs::apply_registry_vram(gpus, &[]);
        assert_eq!(untouched[0].vram_gb, Some(24.0));
    }

    // Multi-GPU hosts must pair each adapter with its own registry entry.
    #[test]
    fn test_registry_vram_multi_gpu_pairing() {
        let gpus = SystemSpecs::parse_windows_gpu_list(
            "AMD Radeon AI PRO R9700|4293918720\nNVIDIA GeForce RTX 4090|4293918720\n",
        );
        let registry = vec![
            ("NVIDIA GeForce RTX 4090".to_string(), 25769803776u64),
            ("AMD Radeon AI PRO R9700".to_string(), 34359738368u64),
        ];
        let gpus = SystemSpecs::apply_registry_vram(gpus, &registry);
        assert_eq!(gpus[0].vram_gb, Some(32.0));
        assert_eq!(gpus[1].vram_gb, Some(24.0));
    }

    // An adapter name that partially matches two registry entries is
    // ambiguous; binding it to either one would be a guess, so detection
    // keeps its own estimate instead.
    #[test]
    fn test_registry_vram_ignores_ambiguous_partial_match() {
        let registry = vec![
            ("NVIDIA GeForce RTX 4090 Ti".to_string(), 25769803776u64),
            (
                "NVIDIA GeForce RTX 4090 Laptop GPU".to_string(),
                17179869184,
            ),
        ];
        assert_eq!(
            SystemSpecs::match_registry_vram("NVIDIA GeForce RTX 4090", &registry),
            None
        );
        // With only one of them present the partial match is unambiguous.
        assert_eq!(
            SystemSpecs::match_registry_vram("NVIDIA GeForce RTX 4090", &registry[..1]),
            Some(25769803776)
        );
    }

    // Exact matching must win over a partial that would otherwise be
    // ambiguous, so a real 4090 alongside a 4090 Ti still resolves.
    #[test]
    fn test_registry_vram_exact_match_beats_partial() {
        let registry = vec![
            ("NVIDIA GeForce RTX 4090 Ti".to_string(), 34359738368u64),
            ("NVIDIA GeForce RTX 4090".to_string(), 25769803776),
        ];
        assert_eq!(
            SystemSpecs::match_registry_vram("NVIDIA GeForce RTX 4090", &registry),
            Some(25769803776)
        );
    }

    // WMI and the registry decorate the same adapter differently; neither
    // string contains the other, so normalization has to bridge them.
    #[test]
    fn test_registry_vram_matches_across_trademark_markers() {
        let registry = vec![("AMD Radeon Graphics".to_string(), 2147483648u64)];
        assert_eq!(
            SystemSpecs::match_registry_vram("AMD Radeon(TM)  Graphics", &registry),
            Some(2147483648)
        );
    }

    // The symbol forms of the same decoration. PowerShell 5.1 writes stdout in
    // the console OEM codepage, so a ™ or ® survives the lossy decode as
    // U+FFFD; matching must see through the replacement character too, or the
    // registry override is unreachable for those adapters.
    #[test]
    fn test_registry_vram_matches_across_symbol_and_mangled_markers() {
        let registry = vec![("AMD Radeon Graphics".to_string(), 2147483648u64)];
        for name in [
            "AMD Radeon\u{2122} Graphics",
            "AMD Radeon\u{00ae} Graphics",
            "AMD Radeon\u{fffd} Graphics",
            "AMD  Radeon Graphics",
        ] {
            assert_eq!(
                SystemSpecs::match_registry_vram(name, &registry),
                Some(2147483648),
                "failed to match {name:?}"
            );
        }
    }

    // Stripping punctuation must not fuse "(TM)" into the preceding word,
    // which would leave "radeontm graphics" and break the match it exists
    // to make.
    #[test]
    fn test_normalize_gpu_name_expands_markers_before_stripping() {
        assert_eq!(
            super::normalize_gpu_name_for_match("AMD Radeon(TM) Graphics"),
            "amd radeon graphics"
        );
        assert_eq!(
            super::normalize_gpu_name_for_match("Intel(R) Arc(TM) A770 Graphics"),
            "intel arc a770 graphics"
        );
        // Model identity must survive normalization.
        assert_eq!(
            super::normalize_gpu_name_for_match("AMD Radeon AI PRO R9700"),
            "amd radeon ai pro r9700"
        );
    }

    #[test]
    #[cfg(not(target_os = "windows"))]
    fn test_registry_vram_query_returns_empty_off_windows() {
        assert!(super::detect_windows_registry_vram().is_empty());
    }
}
