rustpm 0.2.6

A fast, friendly APT frontend with kernel, desktop, and sources management
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::sync::mpsc;

use super::manager::trigger_grub_update;

#[derive(Debug, Clone, PartialEq)]
pub enum ReleaseType {
    Mainline,
    Stable,
    Longterm,
    Eol,
    Unknown,
}

impl ReleaseType {
    fn from_str(s: &str) -> Self {
        match s {
            "mainline" => Self::Mainline,
            "stable"   => Self::Stable,
            "longterm" => Self::Longterm,
            "EOL" | "eol" => Self::Eol,
            _ => Self::Unknown,
        }
    }

    pub fn label(&self) -> &'static str {
        match self {
            Self::Mainline => "mainline",
            Self::Stable   => "stable",
            Self::Longterm => "longterm",
            Self::Eol      => "EOL",
            Self::Unknown  => "unknown",
        }
    }
}

#[derive(Debug, Clone)]
pub struct VanillaRelease {
    pub version: String,
    pub release_type: ReleaseType,
    pub released: Option<String>,
    pub eol: Option<String>,
    pub changelog_url: String,
    pub installed: bool,
}

#[derive(Deserialize)]
struct KernelReleasesJson {
    releases: Vec<KernelReleaseJson>,
}

#[derive(Deserialize)]
struct KernelReleaseJson {
    version: String,
    moniker: String,
    released: Option<KernelDate>,
    eol: Option<serde_json::Value>,
    changelog: Option<String>,
}

#[derive(Deserialize)]
struct KernelDate {
    isodate: String,
}

fn dpkg_installed_vanilla_versions() -> HashSet<String> {
    let stdout = Command::new("dpkg-query")
        .args(["-f", "${Package}\\t${db:Status-Abbrev}\\n", "-W", "linux-image-*-generic"])
        .output()
        .map(|o| o.stdout)
        .unwrap_or_default();

    String::from_utf8_lossy(&stdout)
        .lines()
        .filter_map(|line| {
            let mut parts = line.splitn(2, '\t');
            let pkg = parts.next()?;
            let status = parts.next().unwrap_or("").trim();
            if !status.starts_with("ii") {
                return None;
            }
            // pkg is like "linux-image-6.14.3-1-generic"
            let rest = pkg.strip_prefix("linux-image-")?;
            let without_generic = rest.strip_suffix("-generic")?;
            // strip trailing build number if present (all-digit last segment)
            let ver = if let Some((base, suffix)) = without_generic.rsplit_once('-') {
                if suffix.chars().all(|c| c.is_ascii_digit()) { base } else { without_generic }
            } else {
                without_generic
            };
            Some(ver.to_string())
        })
        .collect()
}

pub fn fetch_releases() -> Result<Vec<VanillaRelease>> {
    let out = Command::new("curl")
        .args(["-fsSL", "--max-time", "15", "https://www.kernel.org/releases.json"])
        .output()
        .context("curl not found — install curl to fetch kernel releases")?;

    if !out.status.success() {
        bail!("failed to reach kernel.org");
    }

    let json: KernelReleasesJson = serde_json::from_slice(&out.stdout)
        .context("failed to parse kernel.org JSON")?;

    let installed = dpkg_installed_vanilla_versions();

    let releases = json.releases.into_iter().map(|r| {
        let released = r.released.map(|d| d.isodate);
        let eol = r.eol.as_ref().and_then(|v| v.as_str().map(|s| s.to_string()));
        let base_version = r.version.split('-').next().unwrap_or(&r.version).to_string();
        let is_installed = installed.contains(&base_version);

        VanillaRelease {
            version: r.version,
            release_type: ReleaseType::from_str(&r.moniker),
            released,
            eol,
            changelog_url: r.changelog.unwrap_or_default(),
            installed: is_installed,
        }
    }).collect();

    Ok(releases)
}

fn download_file(url: &str, dest: &PathBuf, tx: &mpsc::Sender<String>) -> Result<()> {
    let _ = tx.send(format!("Downloading {}", url));

    let status = Command::new("curl")
        .args(["-fsSL", "--max-time", "120", "-o", dest.to_str().unwrap_or(""), url])
        .status()
        .context("curl not found — install curl to download kernel packages")?;

    if !status.success() {
        bail!("failed to download {}", url);
    }

    let _ = tx.send(format!("  Saved to {}", dest.display()));
    Ok(())
}

fn find_deb_urls(version: &str, tx: &mpsc::Sender<String>) -> Result<Vec<String>> {
    let base_url = format!("https://kernel.ubuntu.com/mainline/v{}/amd64/", version);
    let _ = tx.send(format!("Fetching package list from {}", base_url));

    let out = Command::new("curl")
        .args(["-fsSL", "--max-time", "15", &base_url])
        .output()
        .context("curl not found")?;

    if !out.status.success() {
        bail!("failed to fetch {}", base_url);
    }

    let body = String::from_utf8_lossy(&out.stdout);
    let mut urls: Vec<String> = Vec::new();
    let mut remaining: &str = &body;

    while let Some(pos) = remaining.find("href=\"") {
        remaining = &remaining[pos + 6..];
        if let Some(end) = remaining.find('"') {
            let href = &remaining[..end];
            if href.ends_with(".deb") {
                let url = if href.starts_with("http") {
                    href.to_string()
                } else {
                    format!("{}{}", base_url, href)
                };
                let fname = url.split('/').last().unwrap_or("");
                if !fname.contains("_all.deb") || fname.contains("linux-headers-") {
                    urls.push(url);
                }
            }
            remaining = &remaining[end + 1..];
        }
    }

    urls.sort();
    urls.dedup();

    if urls.is_empty() {
        bail!("No .deb packages found for kernel {} at {}", version, base_url);
    }

    Ok(urls)
}

pub fn install_vanilla(version: &str, tx: &mpsc::Sender<String>) -> Result<()> {
    let tmp_dir = PathBuf::from(format!("/tmp/rustpm-kernel-{}", version));
    fs::create_dir_all(&tmp_dir)?;

    let deb_urls = find_deb_urls(version, tx)?;

    let mut deb_paths: Vec<PathBuf> = Vec::new();
    for url in &deb_urls {
        let filename = url.split('/').last().unwrap_or("package.deb");
        let dest = tmp_dir.join(filename);
        download_file(url, &dest, tx)?;
        deb_paths.push(dest);
    }

    let _ = tx.send("Installing downloaded packages with dpkg...".into());

    let path_strs: Vec<&str> = deb_paths.iter().map(|p| p.to_str().unwrap_or("")).collect();
    let mut args = vec!["--install"];
    args.extend_from_slice(&path_strs);

    let status = Command::new("dpkg").args(&args).status()?;
    if !status.success() {
        bail!("dpkg install failed — run 'sudo apt-get install -f' to fix broken deps");
    }

    trigger_grub_update(tx)?;
    let _ = fs::remove_dir_all(&tmp_dir);
    let _ = tx.send(format!("Vanilla kernel {} installed successfully.", version));
    Ok(())
}

pub fn remove_vanilla(version: &str, tx: &mpsc::Sender<String>) -> Result<()> {
    let _ = tx.send(format!("Removing vanilla kernel {}...", version));

    let out = Command::new("dpkg-query")
        .args(["-f", "${Package}\\t${db:Status-Abbrev}\\n", "-W", &format!("linux-*{}*", version)])
        .output()?;

    let pkgs: Vec<String> = String::from_utf8_lossy(&out.stdout)
        .lines()
        .filter_map(|line| {
            let mut parts = line.splitn(2, '\t');
            let pkg = parts.next()?.to_string();
            let status = parts.next().unwrap_or("").trim();
            if status.starts_with("ii") { Some(pkg) } else { None }
        })
        .collect();

    if pkgs.is_empty() {
        bail!("No installed packages found for vanilla kernel {}", version);
    }

    let pkg_refs: Vec<&str> = pkgs.iter().map(|s| s.as_str()).collect();
    let mut args = vec!["remove", "-y"];
    args.extend_from_slice(&pkg_refs);

    let status = Command::new("apt-get").args(&args).status()?;
    if !status.success() {
        bail!("Failed to remove vanilla kernel {}", version);
    }

    trigger_grub_update(tx)?;
    Ok(())
}