rustpm 0.2.1

A fast, friendly APT frontend with kernel, desktop, and sources management
use anyhow::{bail, Context, Result};
use chrono::NaiveDate;
use regex::Regex;
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<NaiveDate>,
    pub eol: Option<NaiveDate>,
    pub changelog_url: String,
    pub installed: bool,
}

// Matches the kernel.org JSON schema
#[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();

    let re = Regex::new(r"linux-image-(\d+\.\d+\.\d+)-\d+-generic").unwrap();
    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;
            }
            re.captures(pkg).map(|c| c[1].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: Vec<VanillaRelease> = json
        .releases
        .into_iter()
        .map(|r| {
            let released = r
                .released
                .as_ref()
                .and_then(|d| NaiveDate::parse_from_str(&d.isodate, "%Y-%m-%d").ok());

            let eol = r.eol.as_ref().and_then(|v| {
                v.as_str()
                    .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
            });

            // Extract base version (strip -rc suffix for installed check)
            let base_version = r.version.split('-').next().unwrap_or(&r.version).to_string();
            let is_installed = installed.contains(&base_version);

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

    Ok(releases)
}

/// Download a file from url to dest, sending status messages via tx.
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(())
}

/// Scrape Ubuntu mainline index page for .deb hrefs matching a version.
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 re = Regex::new(r#"href="([^"]*\.deb)""#).unwrap();
    let mut urls: Vec<String> = re
        .captures_iter(&body)
        .map(|c| {
            let href = &c[1];
            if href.starts_with("http") {
                href.to_string()
            } else {
                format!("{}{}", base_url, href)
            }
        })
        .filter(|url| {
            let fname = url.split('/').last().unwrap_or("");
            // Skip all-architecture headers meta package, keep amd64 packages
            !fname.contains("_all.deb") || fname.contains("linux-headers-")
        })
        .collect();

    // Deduplicate
    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)?;

    // Cleanup
    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));

    // Find packages matching this 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(())
}