// rakuos-updates — System update management via bootc
// Mirrors src/backend/updates.py

use anyhow::{Result, anyhow};
use reqwest::header::{ACCEPT, AUTHORIZATION, WWW_AUTHENTICATE};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;

// ── Data types ────────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SystemStatus {
    pub image: String,
    pub version: String,
    pub digest: String,
    pub timestamp: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateInfo {
    pub available: bool,
    /// True when a staged image is waiting for reboot (digest differs from booted).
    /// When set, `available` is false and the UI should show a reboot prompt.
    pub reboot_required: bool,
    pub latest_version: String,
    pub latest_digest: String,
    pub current_version: String,
    pub current_digest: String,
    pub new_version: String,
    pub new_tag: String,
    pub repo_url: String,
    pub update_type: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OverlayStatus {
    pub package_count: usize,
    pub packages: Vec<String>,
    pub has_digest: bool,
    pub is_dirty: bool,
}

#[derive(Debug, Clone)]
struct ImageChannelInfo {
    original_repo_url: String,
    repo_url: String,
    registry_host: String,
    repo_path: String,
    channel_tag: String,
    current_date_tag: String,
}

// ── Public API ────────────────────────────────────────────────────────────────

/// Get current bootc image status.
pub fn get_system_status() -> SystemStatus {
    match bootc_status_json() {
        Ok(data) => {
            let booted = &data["status"]["booted"];
            let image = &booted["image"];
            let image_ref = &image["image"];
            SystemStatus {
                image: image_ref["image"].as_str().unwrap_or("").to_string(),
                version: image["version"].as_str().unwrap_or("").to_string(),
                digest: image["imageDigest"].as_str().unwrap_or("").to_string(),
                timestamp: image["timestamp"].as_str().unwrap_or("").to_string(),
                error: None,
            }
        }
        Err(e) => SystemStatus {
            error: Some(e.to_string()),
            ..Default::default()
        },
    }
}

/// Run `bootc upgrade --check` and return true if bootc reports actual image changes.
/// Used when tag names differ but may point to identical content (e.g. fresh ISO install).
async fn bootc_check_has_changes() -> bool {
    let out = tokio::process::Command::new("sudo")
        .args(["bootc", "upgrade", "--check"])
        .output()
        .await;
    match out {
        Ok(output) => {
            let combined = String::from_utf8_lossy(&output.stdout).to_string()
                + &String::from_utf8_lossy(&output.stderr);
            !combined.contains("No changes")
        }
        Err(_) => true, // assume update available if bootc check fails
    }
}

/// Check the current OCI registry for a newer image using version annotation + date tag comparison.
pub async fn check_for_update() -> UpdateInfo {
    let status = get_system_status();
    if let Some(e) = status.error {
        return UpdateInfo { error: Some(e), ..Default::default() };
    }

    let channel = match parse_image_channel_info(&status.image, &status.version) {
        Ok(info) => info,
        Err(e) => {
            return UpdateInfo {
                error: Some(e),
                current_version: status.version.clone(),
                current_digest: status.digest.clone(),
                ..Default::default()
            };
        }
    };

    match get_latest_version_annotation(&channel.registry_host, &channel.repo_path, &channel.channel_tag).await {
        Ok((version_annotation, latest_digest)) => {
            let newest_tag_clean = extract_date_tag(&version_annotation, &channel.channel_tag);

            if newest_tag_clean.is_empty() {
                return UpdateInfo {
                    error: Some(format!("Could not parse version annotation: {}", version_annotation)),
                    current_version: status.version.clone(),
                    current_digest: status.digest.clone(),
                    ..Default::default()
                };
            }

            let update_available = if channel.current_date_tag.chars().all(|c| c.is_ascii_digit())
                && channel.current_date_tag.len() == 8
            {
                // Numeric date comparison — unambiguously newer if date increased.
                newest_tag_clean.parse::<u64>().unwrap_or(0)
                    > channel.current_date_tag.parse::<u64>().unwrap_or(0)
            } else if !latest_digest.is_empty() && !status.digest.is_empty() {
                // No date suffix on current tag (fresh ISO install / rebase).
                // Compare digests directly — same digest means same image regardless of tag name.
                latest_digest != status.digest
            } else {
                // No digest available — fall back to bootc upgrade --check.
                bootc_check_has_changes().await
            };

            // True when the booted image is on any legacy registry (GitLab or GHCR).
            // Force a switch to Quay regardless of whether there's also a newer build.
            let needs_migration = channel.original_repo_url != channel.repo_url;

            let target_tag = if needs_migration {
                channel.channel_tag.clone()
            } else {
                format!("{}.{}", channel.channel_tag, newest_tag_clean)
            };

            UpdateInfo {
                available: needs_migration || update_available,
                reboot_required: false,
                current_version: status.version.clone(),
                current_digest: status.digest.clone(),
                latest_version: version_annotation,
                latest_digest,
                new_version: if needs_migration {
                    channel.channel_tag.clone()
                } else if channel.channel_tag == "latest" {
                    format!("latest.{}", newest_tag_clean)
                } else {
                    format!("{}.{}", channel.channel_tag, newest_tag_clean)
                },
                new_tag: target_tag,
                repo_url: channel.repo_url,
                update_type: if needs_migration { "migrate".to_string() } else { "update".to_string() },
                error: None,
            }
        }
        Err(e) => UpdateInfo {
            current_version: status.version,
            current_digest: status.digest,
            error: Some(e.to_string()),
            ..Default::default()
        },
    }
}

/// Returns true if a bootc upgrade or switch service is currently running under systemd.
/// Use this on startup to offer reattachment to an in-progress operation.
pub fn is_bootc_op_active() -> bool {
    for unit in &["rakuos-bootc-upgrade.service", "rakuos-bootc-switch.service"] {
        if Command::new("systemctl")
            .args(["is-active", "--quiet", unit])
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
        {
            return true;
        }
    }
    false
}

/// Stream output from `bootc upgrade`. Yields log lines then "__done__<code>".
pub fn upgrade_stream() -> impl Iterator<Item = String> {
    run_detached_stream("rakuos-bootc-upgrade", None)
}

/// Stream output from `bootc upgrade --check` (dry-run).
/// Not detached — check is fast and doesn't need to survive UI restarts.
pub fn check_stream() -> impl Iterator<Item = String> {
    run_stream_owned(vec!["sudo".into(), "bootc".into(), "upgrade".into(), "--check".into()])
}

/// Stream output from a bootc image operation.
/// Both migrate and update delegate to rakuos-update upgrade-image so the bash
/// script's Quay availability check runs at upgrade time, not just check time.
pub fn upgrade_image_stream(_update_type: &str, _repo_url: &str, _new_tag: &str) -> impl Iterator<Item = String> {
    run_stream_owned(vec![
        "sudo".into(),
        "/usr/libexec/rakuos/rakuos-update".into(),
        "upgrade-image".into(),
    ])
}

/// Stream output from overlay package upgrade via rakuos-update.
pub fn upgrade_packages_stream() -> impl Iterator<Item = String> {
    run_stream_owned(vec!["sudo".into(), "/usr/libexec/rakuos/rakuos-update".into(), "upgrade".into()])
}

/// Stream output from upgrading a single overlay package by name.
pub fn upgrade_single_package_stream(name: &str) -> impl Iterator<Item = String> {
    run_stream_owned(vec![
        "sudo".into(),
        "/usr/libexec/rakuos/rakuos-update".into(),
        "upgrade-package".into(),
        name.to_string(),
    ])
}

/// Stream output from `bootc rollback` followed by a soft overlay reset.
/// Both run via pkexec so the user is prompted for auth once per command.
/// The overlay soft-reset ensures the overlay is rebuilt from the packages
/// list on next boot, preventing stale packages after an image rollback.
pub fn rollback_stream() -> impl Iterator<Item = String> {
    let rollback = run_stream_owned(vec![
        "pkexec".into(), "bootc".into(), "rollback".into(),
    ]);
    let soft_reset = run_stream_owned(vec![
        "pkexec".into(),
        "/usr/libexec/rakuos/rakuos-reset-overlay".into(),
        "--soft".into(),
    ]);
    rollback.chain(soft_reset)
}

/// Check for overlay package updates using `rakuos-update check`.
/// Returns a Vec of raw JSON values, one per updatable package.
pub fn check_packages_script() -> Vec<serde_json::Value> {
    let out = Command::new("/usr/libexec/rakuos/rakuos-update")
        .arg("check")
        .output();
    let Ok(out) = out else { return Vec::new() };
    let data: serde_json::Value = serde_json::from_slice(&out.stdout)
        .unwrap_or_default();
    data["updates"].as_array().cloned().unwrap_or_default()
}

/// Check for a system image update using the `rakuos-update check-image` script.
/// Returns (update_available, raw_json_value).
/// This matches what the daemon uses and produces the same cache format.
pub fn check_image_script() -> (bool, serde_json::Value) {
    // Before checking for new updates, see if bootc already has a staged image
    // waiting for reboot (digest differs from booted).  If so, no need to hit
    // GHCR — just tell the UI to prompt for a reboot.
    if let Ok(status_out) = Command::new("bootc")
        .args(["status", "--json"])
        .output()
    {
        if let Ok(status) = serde_json::from_slice::<serde_json::Value>(&status_out.stdout) {
            let booted_digest = status["status"]["booted"]["image"]["imageDigest"]
                .as_str().unwrap_or("");
            let staged_digest = status["status"]["staged"]["image"]["imageDigest"]
                .as_str().unwrap_or("");
            if !staged_digest.is_empty()
                && !booted_digest.is_empty()
                && staged_digest != booted_digest
            {
                // Use the image tag (e.g. stable.20250101) rather than the OCI version
                // annotation which may contain the Fedora version number instead.
                let booted_ref = status["status"]["booted"]["image"]["image"]["image"]
                    .as_str().unwrap_or("");
                let booted_display = booted_ref
                    .rsplit_once(':')
                    .map(|(_, tag)| tag.to_string())
                    .filter(|t| !t.is_empty())
                    .unwrap_or_else(|| {
                        status["status"]["booted"]["image"]["version"]
                            .as_str().unwrap_or("").to_string()
                    });
                let staged_ref = status["status"]["staged"]["image"]["image"]["image"]
                    .as_str().unwrap_or("");
                let staged_display = staged_ref
                    .rsplit_once(':')
                    .map(|(_, tag)| tag.to_string())
                    .filter(|t| !t.is_empty())
                    .unwrap_or_else(|| {
                        status["status"]["staged"]["image"]["version"]
                            .as_str().unwrap_or("").to_string()
                    });
                return (false, serde_json::json!({
                    "update":          false,
                    "reboot_required": true,
                    "booted":          booted_display,
                    "available":       staged_display,
                }));
            }
        }
    }

    // No pending staged image — check the registry/bootc for available updates.
    let out = Command::new("/usr/libexec/rakuos/rakuos-update")
        .arg("check-image")
        .output();
    let Ok(out) = out else {
        return (false, serde_json::json!({"error": "failed to run check-image"}));
    };
    let data: serde_json::Value = serde_json::from_slice(&out.stdout)
        .unwrap_or(serde_json::json!({}));
    let available = data["update"].as_bool().unwrap_or_else(|| out.status.success());
    (available, data)
}

// ── Kernel arguments ──────────────────────────────────────────────────────────

/// Read the kernel args set for the "rakuos-user" source from BLS entries.
/// Returns the options string, or empty if none are set.
pub fn get_user_kernel_args() -> String {
    let entries_dir = std::path::Path::new("/boot/loader/entries");
    if let Ok(dir) = std::fs::read_dir(entries_dir) {
        for entry in dir.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("conf") {
                continue;
            }
            if let Ok(content) = std::fs::read_to_string(&path) {
                for line in content.lines() {
                    if let Some(value) = line.strip_prefix("x-options-source-rakuos-user=") {
                        return value.trim().to_string();
                    }
                }
            }
        }
    }
    String::new()
}

/// Read the current kernel command line from /proc/cmdline.
pub fn get_current_cmdline() -> String {
    std::fs::read_to_string("/proc/cmdline")
        .unwrap_or_default()
        .trim()
        .to_string()
}

/// Stage kernel args for the "rakuos-user" source via bootc.
/// Pass an empty string to remove all user-set args (drops the source).
/// Streams log lines and ends with "__done__<exit_code>".
pub fn set_kernel_args_stream(options: &str) -> impl Iterator<Item = String> {
    let mut cmd = vec![
        "pkexec".to_string(),
        "bootc".to_string(),
        "loader-entries".to_string(),
        "set-options-for-source".to_string(),
        "--source".to_string(),
        "rakuos-user".to_string(),
    ];
    if !options.trim().is_empty() {
        cmd.push("--options".to_string());
        cmd.push(options.trim().to_string());
    }
    run_stream_owned(cmd)
}

/// Returns true if rakuos-updater.timer is enabled.
pub fn is_bootc_autoupdate_enabled() -> bool {
    Command::new("systemctl")
        .args(["is-enabled", "--quiet", "rakuos-updater.timer"])
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

/// Enable or disable rakuos-updater.timer via pkexec. Returns (success, message).
pub fn set_bootc_autoupdate(enable: bool) -> (bool, String) {
    let action = if enable { "enable" } else { "disable" };
    match Command::new("pkexec")
        .args(["systemctl", action, "--now", "rakuos-updater.timer"])
        .output()
    {
        Ok(o) if o.status.success() => (true, String::new()),
        Ok(o) => (false, String::from_utf8_lossy(&o.stderr).trim().to_string()),
        Err(e) => (false, e.to_string()),
    }
}

/// Schedule a system reboot. Returns (success, error_message).
pub fn schedule_reboot() -> (bool, String) {
    match Command::new("systemctl").arg("reboot").output() {
        Ok(o) if o.status.success() => (true, String::new()),
        Ok(o) => (false, String::from_utf8_lossy(&o.stderr).trim().to_string()),
        Err(e) => (false, e.to_string()),
    }
}

/// Get overlay package count and dirty/digest state.
pub fn get_overlay_status() -> OverlayStatus {
    let packages_list = Path::new("/var/lib/rakuos/packages.list");
    let rpm_list      = Path::new("/var/lib/rakuos/packages-rpm.list");
    let state_file    = Path::new("/var/lib/rakuos/overlay.state");
    let dirty_file    = Path::new("/var/lib/rakuos/overlay.dirty");

    let read_list = |p: &Path| -> Vec<String> {
        if !p.exists() { return Vec::new(); }
        std::fs::read_to_string(p)
            .unwrap_or_default()
            .lines()
            .map(|l| l.trim().to_string())
            .filter(|l| !l.is_empty())
            .collect()
    };

    let mut packages = read_list(packages_list);
    packages.extend(read_list(rpm_list));
    // Sort A-Z (case-insensitive), deduplicate
    packages.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
    packages.dedup();

    OverlayStatus {
        package_count: packages.len(),
        packages,
        has_digest: state_file.exists(),
        is_dirty: dirty_file.exists(),
    }
}

/// Stream output from a bootc upgrade via the detached systemd service.
pub fn pkexec_upgrade_stream() -> impl Iterator<Item = String> {
    run_detached_stream("rakuos-bootc-upgrade", None)
}

/// Stream output from `bootc switch <target>` via the detached systemd service.
pub fn pkexec_switch_stream(target: &str) -> impl Iterator<Item = String> {
    run_detached_stream("rakuos-bootc-switch", Some(target.to_string()))
}

/// Run a full overlay reset and then switch to a new image target.
/// This is used by the system page image switch flow so the overlay is reset
/// before staging the new image.
pub fn pkexec_reset_overlay_and_switch_stream(target: &str) -> impl Iterator<Item = String> {
    use std::sync::mpsc;

    let target = target.to_string();
    let (tx, rx) = mpsc::channel::<String>();

    std::thread::spawn(move || {
        let _ = tx.send("Resetting overlay before image switch…".to_string());

        let mut reset_exit = 1i32;
        for line in reset_overlay_stream("full") {
            if let Some(code) = line.strip_prefix("__done__") {
                reset_exit = code.trim().parse().unwrap_or(1);
            } else {
                let _ = tx.send(line);
            }
        }

        if reset_exit != 0 {
            let _ = tx.send(format!("__done__{}", reset_exit));
            return;
        }

        let _ = tx.send("Overlay reset complete. Switching image…".to_string());

        let mut switch_exit = 1i32;
        for line in pkexec_switch_stream(&target) {
            if let Some(code) = line.strip_prefix("__done__") {
                switch_exit = code.trim().parse().unwrap_or(1);
            } else {
                let _ = tx.send(line);
            }
        }

        let _ = tx.send(format!("__done__{}", switch_exit));
    });

    rx.into_iter()
}

/// Run overlay reset via pkexec and stream output.
/// mode: "soft" → --soft (preserves packages.list), "full" → --confirm (wipes everything).
pub fn reset_overlay_stream(mode: &str) -> impl Iterator<Item = String> {
    let flag = if mode == "soft" { "--soft" } else { "--confirm" };
    run_stream_owned(vec![
        "pkexec".into(),
        "/usr/libexec/rakuos/rakuos-reset-overlay".into(),
        flag.into(),
    ])
}

// ── Internals ─────────────────────────────────────────────────────────────────

/// Run a bootc operation as a detached systemd service so it survives UI restarts.
///
/// `unit`   — bare unit name without `.service` (e.g. `"rakuos-bootc-upgrade"`)
/// `switch_target` — for `rakuos-bootc-switch`, the OCI target URL; `None` otherwise.
///
/// Yields log lines then `"__done__<code>"` when the service finishes.
/// If the service is already active (UI reconnect after crash), skips the start step
/// and reattaches to the existing journal stream.
fn run_detached_stream(unit: &str, switch_target: Option<String>) -> impl Iterator<Item = String> {
    use std::sync::mpsc;
    use std::time::Duration;

    let (tx, rx) = mpsc::channel::<String>();
    let unit = unit.to_string();

    std::thread::spawn(move || {
        let unit_service = format!("{}.service", unit);

        let already_active = Command::new("systemctl")
            .args(["is-active", "--quiet", &unit_service])
            .status()
            .map(|s| s.success())
            .unwrap_or(false);

        if !already_active {
            // Reset any previous failed/inactive-but-present state.
            let _ = Command::new("sudo")
                .args(["systemctl", "reset-failed", &unit_service])
                .status();

            // For the switch service, write the target into the env file first.
            if let Some(ref target) = switch_target {
                let _ = std::fs::create_dir_all("/run/rakuos");
                let env_line = format!("BOOTC_SWITCH_TARGET={}\n", target);
                // tee writes as root via sudoers entry so /run/rakuos is accessible.
                let mut tee = match Command::new("sudo")
                    .args(["tee", "/run/rakuos/bootc-switch-target"])
                    .stdin(std::process::Stdio::piped())
                    .stdout(std::process::Stdio::null())
                    .spawn()
                {
                    Ok(c) => c,
                    Err(e) => {
                        let _ = tx.send(format!("Error writing switch target: {e}"));
                        let _ = tx.send("__done__1".to_string());
                        return;
                    }
                };
                if let Some(stdin) = tee.stdin.take() {
                    use std::io::Write;
                    let mut stdin = stdin;
                    let _ = stdin.write_all(env_line.as_bytes());
                }
                let _ = tee.wait();
            }

            // Start the systemd service.
            let status = Command::new("sudo")
                .args(["systemctl", "start", &unit_service])
                .status();

            match status {
                Ok(s) if s.success() => {}
                Ok(_) | Err(_) => {
                    let _ = tx.send(format!("Failed to start {unit_service}"));
                    let _ = tx.send("__done__1".to_string());
                    return;
                }
            }
        }

        // Stream the journal for this unit (from the current boot, follow mode).
        let mut journal = match Command::new("journalctl")
            .args([
                "-u", &unit_service,
                "--follow",
                "--output=cat",
                "--no-pager",
                "-n", "all",
            ])
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::null())
            .spawn()
        {
            Ok(c) => c,
            Err(e) => {
                let _ = tx.send(format!("Error reading journal: {e}"));
                let _ = tx.send("__done__1".to_string());
                return;
            }
        };

        let stdout = journal.stdout.take().unwrap();
        let tx2 = tx.clone();
        let reader = std::thread::spawn(move || drain_reader(stdout, tx2, false));

        // Poll until the service process has actually finished.
        // We check SubState rather than is-active because both service units use
        // RemainAfterExit=yes, which keeps ActiveState=active after the process
        // exits — so is-active never returns false and the old loop spun forever.
        // SubState transitions: "running" while executing, then "exited" (success)
        // or "failed" (non-zero exit), regardless of RemainAfterExit.
        loop {
            std::thread::sleep(Duration::from_millis(500));
            let sub = Command::new("systemctl")
                .args(["show", &unit_service, "-p", "SubState", "--value"])
                .output()
                .ok()
                .and_then(|o| String::from_utf8(o.stdout).ok())
                .map(|s| s.trim().to_string())
                .unwrap_or_default();
            if sub != "running" && sub != "start" { break; }
        }

        // Give journalctl a moment to flush the last lines before we kill it.
        std::thread::sleep(Duration::from_millis(400));
        let _ = journal.kill();
        reader.join().ok();

        // Read exit code from systemd before resetting.
        let code: i32 = Command::new("systemctl")
            .args(["show", &unit_service, "-p", "ExecMainStatus", "--value"])
            .output()
            .ok()
            .and_then(|o| String::from_utf8(o.stdout).ok())
            .and_then(|s| s.trim().parse().ok())
            .unwrap_or(1);

        // Clean up so the unit can be started again next time.
        let _ = Command::new("sudo")
            .args(["systemctl", "reset-failed", &unit_service])
            .status();

        let _ = tx.send(format!("__done__{}", code));
    });

    rx.into_iter()
}

fn bootc_status_json() -> Result<serde_json::Value> {
    let out = Command::new("bootc")
        .args(["status", "--json"])
        .output()?;

    if out.status.success() {
        return Ok(serde_json::from_slice(&out.stdout)?);
    }

    let sudo_out = Command::new("sudo")
        .args(["bootc", "status", "--json"])
        .output()?;

    if sudo_out.status.success() {
        return Ok(serde_json::from_slice(&sudo_out.stdout)?);
    }

    let stderr = String::from_utf8_lossy(&sudo_out.stderr).trim().to_string();
    Err(anyhow!(
        "failed to read bootc status: {}",
        if stderr.is_empty() { "unknown error" } else { &stderr }
    ))
}

/// Fetch version annotation for a repo from the current OCI registry. Returns (version, digest).
async fn get_latest_version_annotation(
    registry_host: &str,
    repo_path: &str,
    channel_tag: &str,
) -> Result<(String, String)> {
    let url = format!("https://{}/v2/{}/manifests/{}", registry_host, repo_path, channel_tag);
    let client = reqwest::Client::new();
    let token = get_registry_token(&client, registry_host, repo_path, channel_tag).await?;
    let mut manifest_req = client
        .get(&url)
        .header(ACCEPT, "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json");
    if let Some(token) = token.as_deref() {
        manifest_req = manifest_req.header(AUTHORIZATION, format!("Bearer {}", token));
    }
    let resp = manifest_req
        .send().await?
        .error_for_status()?
        .json::<serde_json::Value>().await?;

    // Try manifest-level annotations first
    let mut version = resp["annotations"]
        .get("org.opencontainers.image.version")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();

    // Fallback: check config blob labels
    if version.is_empty() {
        if let Some(config_digest) = resp["config"]["digest"].as_str() {
            let blob_url = format!("https://{}/v2/{}/blobs/{}", registry_host, repo_path, config_digest);
            let mut blob_req = client.get(&blob_url);
            if let Some(token) = token.as_deref() {
                blob_req = blob_req.header(AUTHORIZATION, format!("Bearer {}", token));
            }
            if let Ok(blob) = blob_req
                .send().await?
                .error_for_status()?
                .json::<serde_json::Value>().await
            {
                version = blob["config"]["Labels"]
                    .get("org.opencontainers.image.version")
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
            }
        }
    }

    let digest = resp["config"]["digest"]
        .as_str()
        .unwrap_or("")
        .to_string();

    Ok((version, digest))
}

fn parse_image_channel_info(image_full: &str, version: &str) -> std::result::Result<ImageChannelInfo, String> {
    if image_full.is_empty() {
        return Err("Could not detect booted image".to_string());
    }

    let (original_repo_url, current_image_tag) = image_full
        .rsplit_once(':')
        .unwrap_or((image_full, ""));
    if original_repo_url.is_empty() || current_image_tag.is_empty() {
        return Err("Could not parse booted image reference".to_string());
    }

    let repo_url = canonicalize_repo_url(original_repo_url);

    let (registry_host, repo_path) = repo_url
        .split_once('/')
        .ok_or_else(|| format!("Could not parse registry repo path from: {}", repo_url))?;
    if registry_host.is_empty() || repo_path.is_empty() {
        return Err(format!("Could not parse registry repo path from: {}", repo_url));
    }

    let trimmed_version = version.trim().to_string();
    let (channel_tag, current_date_tag) = if current_image_tag.len() == 8
        && current_image_tag.chars().all(|c| c.is_ascii_digit())
    {
        ("latest".to_string(), current_image_tag.to_string())
    } else if let Some((channel, date)) = current_image_tag.rsplit_once('.') {
        if date.len() == 8 && date.chars().all(|c| c.is_ascii_digit()) {
            (normalize_channel_tag(channel), date.to_string())
        } else {
            (
                normalize_channel_tag(current_image_tag),
                extract_date_tag(&trimmed_version, current_image_tag),
            )
        }
    } else {
        (
            normalize_channel_tag(current_image_tag),
            extract_date_tag(&trimmed_version, current_image_tag),
        )
    };

    Ok(ImageChannelInfo {
        original_repo_url: original_repo_url.to_string(),
        repo_url: repo_url.clone(),
        registry_host: registry_host.to_string(),
        repo_path: repo_path.to_string(),
        channel_tag,
        current_date_tag,
    })
}

fn normalize_channel_tag(tag: &str) -> String {
    tag.trim().to_ascii_lowercase()
}

fn extract_date_tag(version_annotation: &str, channel_tag: &str) -> String {
    let trimmed = version_annotation.trim();
    if let Some(stripped) = trimmed.strip_prefix(&format!("{}.", channel_tag)) {
        if let Some(date) = stripped
            .split(|c: char| !c.is_ascii_digit())
            .find(|part| part.len() == 8 && part.chars().all(|c| c.is_ascii_digit()))
        {
            return date.to_string();
        }
    }
    if trimmed.len() == 8 && trimmed.chars().all(|c| c.is_ascii_digit()) {
        return trimmed.to_string();
    }
    if let Some(date) = trimmed
        .split(|c: char| !c.is_ascii_digit())
        .find(|part| part.len() == 8 && part.chars().all(|c| c.is_ascii_digit()))
    {
        return date.to_string();
    }
    String::new()
}

async fn get_registry_token(
    client: &reqwest::Client,
    registry_host: &str,
    repo_path: &str,
    channel_tag: &str,
) -> Result<Option<String>> {
    let manifest_url = format!("https://{}/v2/{}/manifests/{}", registry_host, repo_path, channel_tag);
    let resp = client
        .get(&manifest_url)
        .header(ACCEPT, "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json")
        .send()
        .await?;

    if resp.status().is_success() {
        return Ok(None);
    }

    if resp.status() != reqwest::StatusCode::UNAUTHORIZED {
        return Err(anyhow!("Unexpected registry response from {}: HTTP {}", manifest_url, resp.status()));
    }

    let challenge = resp
        .headers()
        .get(WWW_AUTHENTICATE)
        .and_then(|value| value.to_str().ok())
        .ok_or_else(|| anyhow!("Registry did not provide an authentication challenge"))?;

    let realm = parse_auth_challenge_value(challenge, "realm")
        .ok_or_else(|| anyhow!("Could not parse registry auth realm"))?;
    let service = parse_auth_challenge_value(challenge, "service");
    let scope = parse_auth_challenge_value(challenge, "scope")
        .unwrap_or_else(|| format!("repository:{}:pull", repo_path));

    let mut token_req = client.get(&realm).query(&[("scope", scope.as_str())]);
    if let Some(service) = service.as_deref() {
        token_req = token_req.query(&[("service", service)]);
    }

    let token_resp = token_req
        .send()
        .await?
        .error_for_status()?
        .json::<serde_json::Value>()
        .await?;

    let token = token_resp["token"]
        .as_str()
        .or_else(|| token_resp["access_token"].as_str())
        .unwrap_or("")
        .to_string();

    if token.is_empty() {
        return Err(anyhow!("Failed to get registry token for {}", repo_path));
    }

    Ok(Some(token))
}

fn parse_auth_challenge_value(header: &str, key: &str) -> Option<String> {
    let pattern = format!(r#"{}=""#, key);
    let start = header.find(&pattern)? + pattern.len();
    let rest = &header[start..];
    let end = rest.find('"')?;
    Some(rest[..end].to_string())
}

fn canonicalize_repo_url(repo_url: &str) -> String {
    match repo_url {
        "ghcr.io/rakuos/rakuos-base" | "registry.gitlab.com/rakuos/images/rakuos-base" => {
            "quay.io/rakuos/rakuos-base".to_string()
        }
        "ghcr.io/rakuos/rakuos-base-nvidia" | "registry.gitlab.com/rakuos/images/rakuos-base/nvidia" => {
            "quay.io/rakuos/rakuos-base-nvidia".to_string()
        }
        "ghcr.io/rakuos/rakuos-kde" | "registry.gitlab.com/rakuos/images/rakuos-kde" => {
            "quay.io/rakuos/rakuos-kde".to_string()
        }
        "ghcr.io/rakuos/rakuos-kde-nvidia" | "registry.gitlab.com/rakuos/images/rakuos-kde/nvidia" => {
            "quay.io/rakuos/rakuos-kde-nvidia".to_string()
        }
        "ghcr.io/rakuos/rakuos-gnome" | "registry.gitlab.com/rakuos/images/rakuos-gnome" => {
            "quay.io/rakuos/rakuos-gnome".to_string()
        }
        "ghcr.io/rakuos/rakuos-gnome-nvidia" | "registry.gitlab.com/rakuos/images/rakuos-gnome/nvidia" => {
            "quay.io/rakuos/rakuos-gnome-nvidia".to_string()
        }
        "ghcr.io/rakuos/rakuos-cosmic" | "registry.gitlab.com/rakuos/images/rakuos-cosmic" => {
            "quay.io/rakuos/rakuos-cosmic".to_string()
        }
        "ghcr.io/rakuos/rakuos-cosmic-nvidia" | "registry.gitlab.com/rakuos/images/rakuos-cosmic/nvidia" => {
            "quay.io/rakuos/rakuos-cosmic-nvidia".to_string()
        }
        _ => repo_url.to_string(),
    }
}

/// Strip ANSI/VT100 escape sequences from raw bytes so PTY output is clean text.
fn strip_ansi(input: &[u8]) -> Vec<u8> {
    let mut out = Vec::with_capacity(input.len());
    let mut i = 0;
    while i < input.len() {
        if input[i] == b'\x1b' {
            i += 1;
            if i >= input.len() { break; }
            match input[i] {
                b'[' => {
                    // CSI sequence: ESC [ <params> <letter>
                    i += 1;
                    while i < input.len() && !input[i].is_ascii_alphabetic() { i += 1; }
                    if i < input.len() { i += 1; }
                }
                b']' => {
                    // OSC sequence: ESC ] ... BEL  or  ESC ] ... ESC backslash
                    i += 1;
                    while i < input.len() {
                        if input[i] == b'\x07' { i += 1; break; }
                        if input[i] == b'\x1b' && i + 1 < input.len() && input[i + 1] == b'\\' {
                            i += 2; break;
                        }
                        i += 1;
                    }
                }
                _ => { i += 1; }
            }
        } else {
            out.push(input[i]);
            i += 1;
        }
    }
    out
}

/// Drain `reader`, stripping ANSI codes and splitting on `\r` or `\n`.
/// Handles EIO (errno 5) as normal EOF so PTY master reads terminate cleanly.
fn drain_reader<R: std::io::Read>(reader: R, tx: std::sync::mpsc::Sender<String>, strip: bool) {
    use std::io::Read;
    let mut reader = reader;
    let mut buf = [0u8; 4096];
    let mut pending = String::new();

    loop {
        match reader.read(&mut buf) {
            Ok(0) => break,
            Ok(n) => {
                let bytes = if strip { strip_ansi(&buf[..n]) } else { buf[..n].to_vec() };
                pending.push_str(&String::from_utf8_lossy(&bytes));
                let mut start = 0;
                for (i, b) in pending.bytes().enumerate() {
                    if b == b'\n' || b == b'\r' {
                        let seg = pending[start..i].trim();
                        if !seg.is_empty() {
                            let _ = tx.send(seg.to_string());
                        }
                        start = i + 1;
                    }
                }
                pending = pending[start..].to_string();
            }
            Err(e) if e.raw_os_error() == Some(libc::EIO) => break, // PTY slave closed
            Err(_) => break,
        }
    }

    let seg = pending.trim();
    if !seg.is_empty() {
        let _ = tx.send(seg.to_string());
    }
}

/// Spawn `cmd` inside a PTY so the child process sees a real terminal (isatty → true).
/// Falls back to paired pipes if PTY allocation fails.
fn run_stream_owned(cmd: Vec<String>) -> impl Iterator<Item = String> {
    use std::os::unix::io::FromRawFd;
    use std::process::Stdio;
    use std::sync::mpsc;

    let (tx, rx) = mpsc::channel::<String>();

    std::thread::spawn(move || {
        // ── Try PTY ──────────────────────────────────────────────────────────
        let mut master_raw: libc::c_int = -1;
        let mut slave_raw: libc::c_int = -1;
        let pty_ok = unsafe {
            libc::openpty(
                &mut master_raw,
                &mut slave_raw,
                std::ptr::null_mut(),
                std::ptr::null_mut(),
                std::ptr::null_mut(),
            ) == 0
        };

        if pty_ok {
            // Dup slave for stdout and stderr; stdin gets the original fd.
            let slave_out = unsafe { libc::dup(slave_raw) };
            let slave_err = unsafe { libc::dup(slave_raw) };

            if slave_out >= 0 && slave_err >= 0 {
                use std::os::unix::process::CommandExt;
                let mut builder = Command::new(&cmd[0]);
                builder.args(&cmd[1..]);
                unsafe {
                    builder
                        .stdin(Stdio::from_raw_fd(slave_raw))
                        .stdout(Stdio::from_raw_fd(slave_out))
                        .stderr(Stdio::from_raw_fd(slave_err))
                        // New session so PTY slave becomes the controlling terminal.
                        .pre_exec(|| {
                            libc::setsid();
                            Ok(())
                        });
                }

                match builder.spawn() {
                    Ok(mut child) => {
                        // Close slave fds in the parent — the child has its own copies.
                        // If we leave them open the PTY master never gets EIO when the
                        // child exits, so drain_reader blocks forever.
                        unsafe {
                            libc::close(slave_raw);
                            libc::close(slave_out);
                            libc::close(slave_err);
                        }
                        let master = unsafe { std::fs::File::from_raw_fd(master_raw) };
                        let tx2 = tx.clone();
                        let reader = std::thread::spawn(move || drain_reader(master, tx2, true));
                        let code = child.wait().map(|s| s.code().unwrap_or(1)).unwrap_or(1);
                        reader.join().ok();
                        let _ = tx.send(format!("__done__{}", code));
                        return;
                    }
                    Err(e) => {
                        unsafe { libc::close(master_raw); }
                        let _ = tx.send(format!("Error: {e}"));
                        let _ = tx.send("__done__1".to_string());
                        return;
                    }
                }
            } else {
                unsafe {
                    if slave_out >= 0 { libc::close(slave_out); }
                    if slave_err >= 0 { libc::close(slave_err); }
                    libc::close(slave_raw);
                    libc::close(master_raw);
                }
            }
        }

        // ── Pipe fallback (PTY unavailable) ──────────────────────────────────
        let mut child = match Command::new(&cmd[0])
            .args(&cmd[1..])
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
        {
            Ok(c) => c,
            Err(e) => {
                let _ = tx.send(format!("Error: {e}"));
                let _ = tx.send("__done__1".to_string());
                return;
            }
        };

        let stdout = child.stdout.take().unwrap();
        let stderr = child.stderr.take().unwrap();
        let tx_out = tx.clone();
        let t1 = std::thread::spawn(move || drain_reader(stdout, tx_out, false));
        let tx_err = tx.clone();
        let t2 = std::thread::spawn(move || drain_reader(stderr, tx_err, false));
        t1.join().ok();
        t2.join().ok();
        let code = child.wait().map(|s| s.code().unwrap_or(1)).unwrap_or(1);
        let _ = tx.send(format!("__done__{}", code));
    });

    rx.into_iter()
}

// ── AppStream enrichment for package update lists ─────────────────────────────

/// Enrich a list of raw package update JSON objects (from `check_packages_script`)
/// with icon_path, icon_url, and display_name sourced from the AppStream catalog
/// and the installed-packages list — the same sources used by the app catalog page.
pub fn enrich_package_updates(raw: Vec<serde_json::Value>) -> Vec<serde_json::Value> {
    let appstream = rakuos_appstream::get_appstream();
    let mut meta: HashMap<String, (String, String, String)> = HashMap::new();

    // Seed from installed-packages list — already has fully resolved icons/names.
    for app in rakuos_packages::get_installed().unwrap_or_default() {
        insert_pkg_meta(&mut meta, &app.package_name, &app.icon_path, &app.icon_url, &app.name);
        insert_pkg_meta(&mut meta, &app.id, &app.icon_path, &app.icon_url, &app.name);
    }

    // Supplement/override with the full AppStream catalog keyed by package_name + id.
    for app in appstream.values().filter(|a| !a.package_name.is_empty()) {
        let friendly = if !app.name.is_empty() && app.name != app.package_name {
            app.name.clone()
        } else {
            String::new()
        };
        insert_pkg_meta(&mut meta, &app.package_name, &app.icon_path, &app.icon_url, &friendly);
        insert_pkg_meta(&mut meta, &app.id, &app.icon_path, &app.icon_url, &friendly);
    }

    raw.into_iter()
        .map(|mut update| {
            if let Some(name) = update["name"].as_str().map(ToString::to_string) {
                if let Some((icon_path, icon_url, display_name)) = meta.get(&name) {
                    if !icon_path.is_empty() {
                        update["icon_path"] = icon_path.clone().into();
                    }
                    if !icon_url.is_empty() {
                        update["icon_url"] = icon_url.clone().into();
                    }
                    if !display_name.is_empty() {
                        update["display_name"] = display_name.clone().into();
                    }
                }
            }
            update
        })
        .collect()
}

fn insert_pkg_meta(
    map: &mut HashMap<String, (String, String, String)>,
    key: &str,
    icon_path: &str,
    icon_url: &str,
    display_name: &str,
) {
    if key.is_empty() {
        return;
    }
    let entry = map
        .entry(key.to_string())
        .or_insert_with(|| (icon_path.to_string(), icon_url.to_string(), display_name.to_string()));
    if entry.0.is_empty() && entry.1.is_empty() {
        entry.0 = icon_path.to_string();
        entry.1 = icon_url.to_string();
    } else if entry.0.is_empty() && !icon_path.is_empty() {
        entry.0 = icon_path.to_string();
    }
    if !display_name.is_empty() && display_name != key {
        if entry.2.is_empty() || entry.2 == key {
            entry.2 = display_name.to_string();
        }
    }
}
