// pages/updates.rs — System and Flatpak updates page

use gtk4::prelude::*;
use gtk4::{
    glib, Align, Box as GBox, Button, Label, Orientation, ProgressBar,
    ScrolledWindow, Spinner, Widget,
};
use libadwaita::prelude::*;
use libadwaita::NavigationView;
use std::cell::{Cell, RefCell};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::rc::Rc;
use std::sync::{mpsc, Arc};
use std::time::Duration;

// ── Progress message type for package/flatpak upgrade channels ────────────────

enum ProgressMsg {
    Progress(f64),
    Done(bool),
}

const GNOME_EXTENSIONS_BASE: &str = "https://extensions.gnome.org";

fn is_gnome_session() -> bool {
    let current_desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().to_ascii_lowercase();
    let session = std::env::var("DESKTOP_SESSION").unwrap_or_default().to_ascii_lowercase();
    let session_type = std::env::var("GDMSESSION").unwrap_or_default().to_ascii_lowercase();

    current_desktop.split(':').any(|part| part == "gnome")
        || session.contains("gnome")
        || session_type.contains("gnome")
        || std::env::var_os("GNOME_DESKTOP_SESSION_ID").is_some()
}

// ── Parse [N/M] from dnf5 transaction output, e.g. "[2/4] Upgrading file…" ──

fn parse_dnf_progress(line: &str) -> Option<f64> {
    let trimmed = line.trim();
    if let Some(bracket_start) = trimmed.find('[') {
        if let Some(bracket_end) = trimmed[bracket_start..].find(']') {
            let inner = &trimmed[bracket_start + 1..bracket_start + bracket_end];
            if let Some(slash) = inner.find('/') {
                let n: f64 = inner[..slash].trim().parse().ok()?;
                let total: f64 = inner[slash + 1..].trim().parse().ok()?;
                if total > 0.0 {
                    return Some((n / total).min(1.0));
                }
            }
        }
    }
    None
}

// ── Parse bootc/skopeo layer progress from journal lines ──────────────────────
// Handles: "layers[3/50]", "[3/50]", "3/50 layers", "Fetching 3 of 50", etc.

fn parse_bootc_progress(line: &str) -> Option<f64> {
    // [N/M] bracket format (also covers "layers[N/M]")
    if let Some(pct) = parse_dnf_progress(line) {
        return Some(pct);
    }
    // "N of M" — "Fetching 3 of 50", "3 of 50 layers"
    if let Some(of_pos) = line.find(" of ") {
        let n_str = line[..of_pos].trim().split_whitespace().last().unwrap_or("");
        let m_str = line[of_pos + 4..].trim().split_whitespace().next().unwrap_or("");
        if let (Ok(n), Ok(m)) = (n_str.parse::<f64>(), m_str.parse::<f64>()) {
            if m > 0.0 { return Some((n / m).min(1.0)); }
        }
    }
    // Raw "N/M" token near "layer" — "3/50 layers", "Pulling layer 3/50"
    if line.to_ascii_lowercase().contains("layer") {
        for token in line.split_whitespace() {
            let tok = token.trim_matches(|c: char| !c.is_ascii_digit() && c != '/');
            if let Some(slash) = tok.find('/') {
                if let (Ok(n), Ok(m)) = (tok[..slash].parse::<f64>(), tok[slash + 1..].parse::<f64>()) {
                    if m > 0.0 { return Some((n / m).min(1.0)); }
                }
            }
        }
    }
    None
}

use rakuos_flatpak::FlatpakUpdate;
use rakuos_updates::UpdateInfo;
use rakuos_webapps::WebAppUpdate;

#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
struct GnomeExtensionUpdate {
    uuid: String,
    name: String,
    installed_version: u32,
    available_version: u32,
    shell_version: String,
    icon_url: String,
}

#[derive(Debug, serde::Deserialize)]
struct RemoteGnomeExtensionInfo {
    #[serde(default)]
    version: serde_json::Value,
    #[serde(default)]
    name: Option<String>,
    #[serde(default)]
    icon: Option<String>,
    #[serde(default)]
    download_url: Option<String>,
}

// ── Shared operation state ────────────────────────────────────────────────────

#[derive(Clone, Default)]
struct UpdateState {
    active:  Rc<RefCell<String>>,
    running: Rc<RefCell<bool>>,
}

impl UpdateState {
    fn is_running(&self) -> bool { *self.running.borrow() }
    fn start(&self, tag: &str) {
        *self.running.borrow_mut() = true;
        *self.active.borrow_mut() = tag.to_string();
    }
    fn stop(&self) {
        *self.running.borrow_mut() = false;
        *self.active.borrow_mut() = String::new();
    }
}

// ── Page entry point ──────────────────────────────────────────────────────────

pub fn build(_nav: Arc<NavigationView>) -> Widget {
    let root = GBox::builder()
        .orientation(Orientation::Vertical)
        .build();

    // ── Top action bar ─────────────────────────────────────────────────────
    let toolbar = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(8)
        .margin_top(10)
        .margin_bottom(10)
        .margin_start(16)
        .margin_end(16)
        .build();

    let status_lbl = Label::builder()
        .label("Updates")
        .halign(Align::Start)
        .hexpand(true)
        .css_classes(vec!["heading".to_string()])
        .build();

    let busy = Spinner::builder()
        .spinning(false)
        .visible(false)
        .build();

    let check_btn = Button::builder()
        .label("Check for Updates")
        .build();

    let update_all_btn = Button::builder()
        .label("Update All")
        .css_classes(vec!["suggested-action".to_string()])
        .sensitive(false)
        .visible(false)
        .build();

    let reboot_btn = Button::builder()
        .label("Reboot Now")
        .css_classes(vec!["destructive-action".to_string()])
        .visible(false)
        .build();

    toolbar.append(&status_lbl);
    toolbar.append(&busy);
    toolbar.append(&check_btn);
    toolbar.append(&update_all_btn);
    toolbar.append(&reboot_btn);
    root.append(&toolbar);
    root.append(&gtk4::Separator::new(Orientation::Horizontal));

    // ── Scrollable content ─────────────────────────────────────────────────
    let scroll = ScrolledWindow::builder()
        .hscrollbar_policy(gtk4::PolicyType::Never)
        .vscrollbar_policy(gtk4::PolicyType::Automatic)
        .vexpand(true)
        .build();

    let content = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(16)
        .margin_top(16)
        .margin_bottom(24)
        .margin_start(16)
        .margin_end(16)
        .build();
    scroll.set_child(Some(&content));
    root.append(&scroll);

    if !load_cached_updates(
        &content,
        &status_lbl,
        &busy,
        &update_all_btn,
        &reboot_btn,
    ) {
        show_waiting_for_cache(
            &content,
            &status_lbl,
            &busy,
            &update_all_btn,
            &reboot_btn,
        );
    }

    // ── Reconnect to an in-progress bootc op that survived a UI restart ───────
    {
        let content_rc  = content.clone();
        let status_rc   = status_lbl.clone();
        let busy_rc     = busy.clone();
        let upd_rc      = update_all_btn.clone();
        let reboot_rc   = reboot_btn.clone();

        // Fire once after a short delay so the page finishes rendering first.
        glib::timeout_add_local(Duration::from_millis(600), move || {
            let (tx, rx) = mpsc::channel::<bool>();
            std::thread::spawn(move || {
                let _ = tx.send(rakuos_updates::is_bootc_op_active());
            });
            // Poll until the check thread answers, then act.
            glib::timeout_add_local(Duration::from_millis(100), {
                let content_c = content_rc.clone();
                let status_c  = status_rc.clone();
                let busy_c    = busy_rc.clone();
                let upd_c     = upd_rc.clone();
                let reboot_c  = reboot_rc.clone();
                move || match rx.try_recv() {
                    Ok(true) => {
                        // Already busy? Don't double-enter.
                        if busy_c.is_spinning() { return glib::ControlFlow::Break; }
                        enter_bootc_streaming_mode(
                            &content_c, &status_c, &busy_c, &upd_c, &reboot_c,
                        );
                        glib::ControlFlow::Break
                    }
                    Ok(false) => glib::ControlFlow::Break,
                    Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
                    Err(_) => glib::ControlFlow::Break,
                }
            });
            glib::ControlFlow::Break
        });
    }

    // ── Daemon trigger watcher: daemon signals us to run an enriched check ────
    {
        let content_t  = content.clone();
        let status_t   = status_lbl.clone();
        let busy_t     = busy.clone();
        let upd_all_t  = update_all_btn.clone();
        let reboot_t   = reboot_btn.clone();
        let trigger_path = std::path::PathBuf::from(
            std::env::temp_dir().join("rakuos-software-check-requested"));

        glib::timeout_add_seconds_local(10, move || {
            if trigger_path.exists() {
                let _ = std::fs::remove_file(&trigger_path);
                if !busy_t.is_spinning() {
                    while let Some(child) = content_t.first_child() {
                        content_t.remove(&child);
                    }
                    upd_all_t.set_visible(false);
                    upd_all_t.set_sensitive(false);
                    reboot_t.set_visible(false);
                    status_t.set_label("Checking for updates…");
                    busy_t.set_spinning(true);
                    busy_t.set_visible(true);
                    let sp = Spinner::builder()
                        .spinning(true)
                        .halign(Align::Center)
                        .margin_top(48)
                        .build();
                    content_t.append(&sp);
                    load_updates(
                        content_t.clone(),
                        sp,
                        status_t.clone(),
                        busy_t.clone(),
                        upd_all_t.clone(),
                        reboot_t.clone(),
                    );
                }
            }
            glib::ControlFlow::Continue
        });
    }

    // ── Daemon cache watcher: refresh when daemon writes new update data ───
    {
        let cache_path = daemon_cache_path();
        // Seed with current mtime so we only refresh on changes after startup
        let last_mtime = Rc::new(RefCell::new(
            std::fs::metadata(&cache_path)
                .and_then(|m| m.modified())
                .ok(),
        ));
        let content_d  = content.clone();
        let status_d   = status_lbl.clone();
        let busy_d     = busy.clone();
        let upd_all_d  = update_all_btn.clone();
        let reboot_d   = reboot_btn.clone();

        glib::timeout_add_seconds_local(5, move || {
            let current = std::fs::metadata(&cache_path)
                .and_then(|m| m.modified())
                .ok();
            if current != *last_mtime.borrow() {
                *last_mtime.borrow_mut() = current;
                // Only refresh if no update is already running (buttons not busy)
                if !busy_d.is_spinning() {
                    while let Some(child) = content_d.first_child() {
                        content_d.remove(&child);
                    }
                    upd_all_d.set_visible(false);
                    upd_all_d.set_sensitive(false);
                    reboot_d.set_visible(false);
                    if !load_cached_updates(
                        &content_d,
                        &status_d,
                        &busy_d,
                        &upd_all_d,
                        &reboot_d,
                    ) {
                        show_waiting_for_cache(
                            &content_d,
                            &status_d,
                            &busy_d,
                            &upd_all_d,
                            &reboot_d,
                        );
                    }
                }
            }
            glib::ControlFlow::Continue
        });
    }

    // ── Check / Refresh button ─────────────────────────────────────────────
    {
        let content_c = content.clone();
        let status_c  = status_lbl.clone();
        let busy_c    = busy.clone();
        let upd_all_c = update_all_btn.clone();
        let reboot_c  = reboot_btn.clone();

        check_btn.connect_clicked(move |btn| {
            btn.set_sensitive(false);
            while let Some(child) = content_c.first_child() {
                content_c.remove(&child);
            }
            upd_all_c.set_visible(false);
            upd_all_c.set_sensitive(false);
            reboot_c.set_visible(false);
            status_c.set_label("Checking for updates…");
            busy_c.set_spinning(true);
            busy_c.set_visible(true);

            let sp = Spinner::builder()
                .spinning(true)
                .halign(Align::Center)
                .margin_top(48)
                .build();
            content_c.append(&sp);

            load_updates(
                content_c.clone(),
                sp,
                status_c.clone(),
                busy_c.clone(),
                upd_all_c.clone(),
                reboot_c.clone(),
            );

            let btn_c = btn.clone();
            glib::timeout_add_local_once(Duration::from_millis(1500), move || {
                btn_c.set_sensitive(true);
            });
        });
    }

    reboot_btn.connect_clicked(|_| {
        rakuos_updates::schedule_reboot();
    });

    root.upcast()
}

// ── Data loading ──────────────────────────────────────────────────────────────

fn daemon_cache_path() -> std::path::PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
    std::path::PathBuf::from(home).join(".cache/rakuos/daemon-update-cache.json")
}

fn clear_update_content(content: &GBox, update_all_btn: &Button, reboot_btn: &Button) {
    while let Some(child) = content.first_child() {
        content.remove(&child);
    }
    update_all_btn.set_visible(false);
    update_all_btn.set_sensitive(false);
    reboot_btn.set_visible(false);
}

/// Enter streaming mode for a bootc upgrade that is already running under systemd.
/// Called on page load when `is_bootc_op_active()` returns true.
fn enter_bootc_streaming_mode(
    content:        &GBox,
    status_lbl:     &Label,
    busy:           &Spinner,
    update_all_btn: &Button,
    reboot_btn:     &Button,
) {
    clear_update_content(content, update_all_btn, reboot_btn);
    update_all_btn.set_visible(false);
    busy.set_spinning(true);
    busy.set_visible(true);
    status_lbl.set_label("System update in progress — reconnected…");

    // Log output box
    let log_lbl = Label::builder()
        .label("System update in progress…")
        .halign(Align::Start)
        .wrap(true)
        .css_classes(vec!["caption".to_string()])
        .margin_top(8)
        .build();
    content.append(&log_lbl);

    let progress = ProgressBar::builder()
        .margin_top(8)
        .margin_start(0)
        .margin_end(0)
        .build();
    content.append(&progress);

    let (tx, rx) = mpsc::channel::<String>();
    std::thread::spawn(move || {
        for line in rakuos_updates::upgrade_stream() {
            let _ = tx.send(line);
        }
    });

    let log_c     = log_lbl.clone();
    let prog_c    = progress.clone();
    let busy_c    = busy.clone();
    let status_c  = status_lbl.clone();
    let reboot_c  = reboot_btn.clone();

    glib::timeout_add_local(Duration::from_millis(100), move || {
        let mut last_progress: Option<f64> = None;
        loop {
            match rx.try_recv() {
                Ok(line) => {
                    if let Some(code) = line.strip_prefix("__done__") {
                        prog_c.set_fraction(1.0);
                        prog_c.set_visible(false);
                        busy_c.set_spinning(false);
                        busy_c.set_visible(false);
                        if code.trim() == "0" {
                            status_c.set_label("System update staged — reboot to apply");
                            log_c.set_label("Update complete. Reboot to apply.");
                            reboot_c.set_visible(true);
                        } else {
                            status_c.set_label("System update failed");
                            log_c.set_label("Update failed.");
                        }
                        return glib::ControlFlow::Break;
                    }
                    log_c.set_label(&line);
                    if let Some(pct) = parse_bootc_progress(&line) {
                        last_progress = Some(pct);
                    }
                }
                Err(mpsc::TryRecvError::Empty) => break,
                Err(_) => return glib::ControlFlow::Break,
            }
        }
        match last_progress {
            Some(pct) => prog_c.set_fraction(pct),
            None => {
                let cur = prog_c.fraction();
                if cur < 0.95 { prog_c.set_fraction((cur + 0.005).min(0.95)); }
            }
        }
        glib::ControlFlow::Continue
    });
}

fn show_waiting_for_cache(
    content:        &GBox,
    status_lbl:     &Label,
    busy:           &Spinner,
    update_all_btn: &Button,
    reboot_btn:     &Button,
) {
    clear_update_content(content, update_all_btn, reboot_btn);
    busy.set_spinning(false);
    busy.set_visible(false);
    status_lbl.set_label("Waiting for update check");
    let sp = libadwaita::StatusPage::builder()
        .title("Waiting for Updates")
        .description("The background update service will publish results here. Use Check for Updates to run a manual check now.")
        .icon_name("software-update-available-symbolic")
        .build();
    content.append(&sp);
}

fn load_cached_updates(
    content:        &GBox,
    status_lbl:     &Label,
    busy:           &Spinner,
    update_all_btn: &Button,
    reboot_btn:     &Button,
) -> bool {
    let Ok(raw) = std::fs::read_to_string(daemon_cache_path()) else {
        return false;
    };
    let Ok(cache) = serde_json::from_str::<serde_json::Value>(&raw) else {
        return false;
    };

    clear_update_content(content, update_all_btn, reboot_btn);

    let packages = cache["packages"].as_array().cloned().unwrap_or_default();
    let flatpaks: Vec<FlatpakUpdate> =
        serde_json::from_value(cache["flatpak"].clone()).unwrap_or_default();
    let webapps: Vec<WebAppUpdate> =
        serde_json::from_value(cache["webapps"].clone()).unwrap_or_default();
    let extensions: Vec<GnomeExtensionUpdate> = if is_gnome_session() {
        serde_json::from_value(cache["gnome_extensions"].clone()).unwrap_or_default()
    } else {
        Vec::new()
    };
    let image_json = cache["image_info"].clone();
    let image_available = cache["image_available"]
        .as_bool()
        .or_else(|| image_json["update"].as_bool())
        .unwrap_or(false);
    let reboot_required = cache["reboot_required"]
        .as_bool()
        .or_else(|| image_json["reboot_required"].as_bool())
        .unwrap_or(false);
    let system = UpdateInfo {
        available:        image_available,
        reboot_required,
        current_version:  image_json["booted"].as_str().unwrap_or("").to_string(),
        new_version:      image_json["available"].as_str().unwrap_or("").to_string(),
        new_tag:          image_json["available"].as_str().unwrap_or("").to_string(),
        repo_url:         image_json["repo"].as_str().unwrap_or("").to_string(),
        update_type:      image_json["type"].as_str().unwrap_or("hotfix").to_string(),
        ..Default::default()
    };

    render_update_results(
        content,
        status_lbl,
        busy,
        update_all_btn,
        reboot_btn,
        system,
        packages,
        flatpaks,
        webapps,
        extensions,
    );
    true
}

fn load_updates(
    content:        GBox,
    spinner:        Spinner,
    status_lbl:     Label,
    busy:           Spinner,
    update_all_btn: Button,
    reboot_btn:     Button,
) {
    type CheckResult = (
        UpdateInfo,
        Vec<serde_json::Value>,
        Vec<FlatpakUpdate>,
        Vec<WebAppUpdate>,
        Vec<GnomeExtensionUpdate>,
    );
    let (tx, rx) = mpsc::channel::<CheckResult>();

    std::thread::spawn(move || {
        // Run all three checks: packages, flatpak, image
        let packages = rakuos_updates::enrich_package_updates(
            rakuos_updates::check_packages_script(),
        );
        let flatpaks = rakuos_flatpak::get_all_updates();
        let webapps = rakuos_webapps::get_updates();
        let extensions = if is_gnome_session() {
            check_gnome_extension_updates()
        } else {
            Vec::new()
        };
        let status = rakuos_updates::get_system_status();
        let (image_available, image_json) = rakuos_updates::check_image_script();
        let reboot_required = image_json["reboot_required"].as_bool().unwrap_or(false);
        // Current version: prefer the booted image tag (e.g. "staging.20260531") from
        // system status — matches what the system page shows. Fall back to check-image JSON.
        let current_ver = {
            let tag = status.image.rsplit_once(':').map(|(_, t)| t).unwrap_or("").to_string();
            if !tag.is_empty() {
                tag
            } else {
                image_json["current_version"]
                    .as_str()
                    .unwrap_or_else(|| image_json["booted"].as_str().unwrap_or(""))
                    .to_string()
            }
        };
        let system = UpdateInfo {
            available:        image_available,
            reboot_required,
            current_version:  current_ver,
            new_version:      image_json["available_version"]
                .as_str()
                .filter(|s| !s.is_empty())
                .unwrap_or_else(|| image_json["available"].as_str().unwrap_or(""))
                .to_string(),
            new_tag:          image_json["available"].as_str().unwrap_or("").to_string(),
            repo_url:         image_json["repo"].as_str().unwrap_or("").to_string(),
            update_type:      image_json["type"].as_str().unwrap_or("hotfix").to_string(),
            ..Default::default()
        };

        // Write daemon cache in the same format the daemon uses
        let total = (if system.available { 1 } else { 0 })
            + packages.len()
            + flatpaks.len()
            + webapps.len()
            + extensions.len();
        let cache = serde_json::json!({
            "total":           total,
            "packages":        packages,
            "flatpak":         serde_json::to_value(&flatpaks).unwrap_or_default(),
            "webapps":         serde_json::to_value(&webapps).unwrap_or_default(),
            "gnome_extensions": serde_json::to_value(&extensions).unwrap_or_default(),
            "appimages":       [],
            "image_available": system.available,
            "reboot_required": reboot_required,
            "image_info":      image_json,
        });
        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
        let cache_path = std::path::PathBuf::from(home)
            .join(".cache/rakuos/daemon-update-cache.json");
        if let Some(parent) = cache_path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let _ = std::fs::write(&cache_path, serde_json::to_string_pretty(&cache).unwrap_or_default());

        let _ = tx.send((system, packages, flatpaks, webapps, extensions));
    });

    glib::timeout_add_local(Duration::from_millis(80), move || {
        match rx.try_recv() {
            Ok((system, packages, flatpaks, webapps, extensions)) => {
                spinner.set_spinning(false);
                spinner.set_visible(false);
                render_update_results(
                    &content,
                    &status_lbl,
                    &busy,
                    &update_all_btn,
                    &reboot_btn,
                    system,
                    packages,
                    flatpaks,
                    webapps,
                    extensions,
                );
                glib::ControlFlow::Break
            }
            Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
            Err(_) => glib::ControlFlow::Break,
        }
    });
}

fn render_update_results(
    content:        &GBox,
    status_lbl:     &Label,
    busy:           &Spinner,
    update_all_btn: &Button,
    reboot_btn:     &Button,
    system:         UpdateInfo,
    packages:       Vec<serde_json::Value>,
    flatpaks:       Vec<FlatpakUpdate>,
    webapps:        Vec<WebAppUpdate>,
    extensions:     Vec<GnomeExtensionUpdate>,
) {
    busy.set_spinning(false);
    busy.set_visible(false);

    let apps:     Vec<FlatpakUpdate> = flatpaks.iter().filter(|f| !f.runtime).cloned().collect();
    let runtimes: Vec<FlatpakUpdate> = flatpaks.iter().filter(|f|  f.runtime).cloned().collect();

    // Split RPM packages by gui field — gui=true go to Applications, gui=false to Overlay Dependencies
    let gui_pkgs: Vec<serde_json::Value> = packages.iter()
        .filter(|p| p["gui"].as_bool().unwrap_or(false))
        .cloned().collect();
    let dep_pkgs: Vec<serde_json::Value> = packages.iter()
        .filter(|p| !p["gui"].as_bool().unwrap_or(false))
        .cloned().collect();

    let has_image      = system.available;
    let reboot_pending = system.reboot_required;
    let has_gui_pkgs   = !gui_pkgs.is_empty();
    let has_dep_pkgs   = !dep_pkgs.is_empty();
    let has_pkgs       = !packages.is_empty();
    let has_apps       = !apps.is_empty();
    let has_runtimes   = !runtimes.is_empty();
    let has_webapps    = !webapps.is_empty();
    let has_extensions = !extensions.is_empty();

    // If a staged image is waiting for reboot, show reboot button immediately.
    if reboot_pending {
        status_lbl.set_label("System update staged — reboot to apply");
        reboot_btn.set_visible(true);
    }

    if !has_image
        && !reboot_pending
        && !has_pkgs
        && !has_apps
        && !has_runtimes
        && !has_webapps
        && !has_extensions
    {
        status_lbl.set_label("Your system is up to date");
        let sp = libadwaita::StatusPage::builder()
            .title("Up to Date")
            .description("Your system and apps are up to date")
            .icon_name("emblem-default-symbolic")
            .build();
        content.append(&sp);
        return;
    }

    let count = (if has_image { 1 } else { 0 })
        + packages.len()
        + flatpaks.len()
        + webapps.len()
        + extensions.len();
    status_lbl.set_label(&format!(
        "{} update{} available",
        count,
        if count == 1 { "" } else { "s" }
    ));

    update_all_btn.set_visible(true);
    update_all_btn.set_sensitive(true);

    let state = UpdateState::default();

    // OS Image card
    if has_image {
        content.append(&build_image_card(
            &system,
            state.clone(),
            reboot_btn.clone(),
            status_lbl.clone(),
            update_all_btn.clone(),
        ));
    }

    // Applications: gui RPM packages + non-runtime flatpaks
    if has_gui_pkgs || has_apps {
        content.append(&build_apps_section(&gui_pkgs, &apps, state.clone(), status_lbl.clone()));
    }

    if has_webapps {
        content.append(&build_webapps_section(&webapps, state.clone(), status_lbl.clone()));
    }

    if has_extensions {
        content.append(&build_gnome_extensions_section(
            &extensions,
            state.clone(),
            status_lbl.clone(),
        ));
    }

    // Overlay Dependencies: non-gui RPM packages
    if has_dep_pkgs {
        content.append(&build_packages_section("Overlay Dependencies", &dep_pkgs, state.clone(), status_lbl.clone()));
    }

    // Runtimes / Add-ons section
    if has_runtimes {
        content.append(&build_flatpak_section("Runtimes / Add-ons", &runtimes, state.clone()));
    }

    // Wire Update All
    let sys_repo  = system.repo_url.clone();
    let sys_tag   = system.new_tag.clone();
    let sys_type  = system.update_type.clone();
    let do_image  = system.available;
    let do_pkgs   = has_pkgs;  // upgrade_packages_stream handles all overlay pkgs
    let all_fps   = flatpaks.clone();
    let all_webapps = webapps.clone();
    let all_extensions = extensions.clone();
    let state_a   = state.clone();
    let status_a  = status_lbl.clone();
    let upd_btn   = update_all_btn.clone();
    let reboot_a  = reboot_btn.clone();

    update_all_btn.connect_clicked(move |btn| {
        if state_a.is_running() { return; }
        state_a.start("all");
        btn.set_sensitive(false);
        btn.set_label("Updating…");
        status_a.set_label("Updating…");

        let repo   = sys_repo.clone();
        let tag    = sys_tag.clone();
        let image_type = sys_type.clone();
        let fps    = all_fps.clone();
        let webapps = all_webapps.clone();
        let extensions = all_extensions.clone();
        let do_img = do_image;
        let do_pkg = do_pkgs;
        let (tx2, rx2) = mpsc::channel::<bool>();

        std::thread::spawn(move || {
            if do_img {
                let _: Vec<_> = rakuos_updates::upgrade_image_stream(&image_type, &repo, &tag).collect();
            }
            if do_pkg {
                let _: Vec<_> = rakuos_updates::upgrade_packages_stream().collect();
            }
            for fp in &fps {
                let id = fp.app_id.clone();
                let _: Vec<_> = rakuos_flatpak::update_single_stream(&id).collect();
            }
            for webapp in &webapps {
                let _ = rakuos_webapps::update(&webapp.id);
            }
            for extension in &extensions {
                let _ = update_gnome_extension(&extension.uuid, &extension.shell_version);
            }
            let _ = tx2.send(do_img);
        });

        let state_r  = state_a.clone();
        let btn_r    = upd_btn.clone();
        let status_r = status_a.clone();
        let reboot_r = reboot_a.clone();

        glib::timeout_add_local(Duration::from_millis(100), move || {
            match rx2.try_recv() {
                Ok(did_image) => {
                    state_r.stop();
                    btn_r.set_sensitive(false);
                    btn_r.set_label("Done");
                    if did_image {
                        status_r.set_label("System update staged — reboot to apply");
                        reboot_r.set_visible(true);
                    } else {
                        status_r.set_label("Updates complete");
                    }
                    glib::ControlFlow::Break
                }
                Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
                Err(_) => { state_r.stop(); glib::ControlFlow::Break }
            }
        });
    });
}

// ── OS Image update card ───────────────────────────────────────────────────────

fn build_image_card(
    system:     &UpdateInfo,
    state:      UpdateState,
    reboot_btn: Button,
    status_lbl: Label,
    upd_all:    Button,
) -> Widget {
    let card = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(0)
        .css_classes(vec!["card".to_string()])
        .build();

    // Header
    let header = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(8)
        .margin_top(12)
        .margin_bottom(8)
        .margin_start(12)
        .margin_end(12)
        .build();
    header.append(
        &Label::builder()
            .label("Operating System")
            .halign(Align::Start)
            .hexpand(true)
            .css_classes(vec!["heading".to_string()])
            .build(),
    );
    header.append(
        &Label::builder()
            .label("1 update")
            .halign(Align::End)
            .css_classes(vec!["dim-label".to_string(), "caption".to_string()])
            .build(),
    );
    card.append(&header);
    card.append(&gtk4::Separator::new(Orientation::Horizontal));

    // Item row
    let item_row = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(12)
        .margin_top(10)
        .margin_bottom(10)
        .margin_start(12)
        .margin_end(12)
        .build();

    item_row.append(
        &gtk4::Image::builder()
            .icon_name("system-software-update-symbolic")
            .pixel_size(36)
            .build(),
    );

    let info = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(2)
        .valign(Align::Center)
        .hexpand(true)
        .build();
    info.append(
        &Label::builder()
            .label("RakuOS")
            .halign(Align::Start)
            .css_classes(vec!["heading".to_string()])
            .build(),
    );
    let version_text = if !system.current_version.is_empty() && !system.new_version.is_empty() {
        format!("{} → {}", system.current_version, system.new_version)
    } else if !system.new_version.is_empty() {
        format!("→ {}", system.new_version)
    } else {
        "Update available".to_string()
    };
    info.append(
        &Label::builder()
            .label(&version_text)
            .halign(Align::Start)
            .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
            .build(),
    );
    item_row.append(&info);

    let cur_date = system.current_version.rsplit_once('.').map(|(_, d)| d).unwrap_or("");
    let new_date = system.new_version.rsplit_once('.').map(|(_, d)| d).unwrap_or("");
    let is_hotfix = !cur_date.is_empty() && cur_date == new_date;
    let btn_label = if system.update_type == "migrate" {
        "Migrate"
    } else if is_hotfix {
        "Apply Hotfix"
    } else {
        "Update"
    };
    let update_btn = Button::builder()
        .label(btn_label)
        .valign(Align::Center)
        .build();
    item_row.append(&update_btn);
    card.append(&item_row);

    let progress = ProgressBar::builder()
        .margin_start(12)
        .margin_end(12)
        .margin_bottom(4)
        .visible(false)
        .build();
    card.append(&progress);

    let done_lbl = Label::builder()
        .halign(Align::Center)
        .margin_bottom(8)
        .visible(false)
        .css_classes(vec!["caption".to_string()])
        .build();
    card.append(&done_lbl);

    // Wire button
    let repo     = system.repo_url.clone();
    let tag      = system.new_tag.clone();
    let image_type = system.update_type.clone();
    let prog_c   = progress.clone();
    let done_c   = done_lbl.clone();
    let btn_ref  = update_btn.clone();
    let state_c  = state.clone();
    let reboot_c = reboot_btn.clone();
    let status_c = status_lbl.clone();
    let upd_c    = upd_all.clone();

    update_btn.connect_clicked(move |btn| {
        if state_c.is_running() { return; }
        state_c.start("image");
        btn.set_sensitive(false);
        btn.set_label("Updating…");
        prog_c.set_fraction(0.02);
        prog_c.set_visible(true);

        let repo_t = repo.clone();
        let tag_t  = tag.clone();
        let type_t = image_type.clone();
        let (tx, rx) = mpsc::channel::<ProgressMsg>();
        std::thread::spawn(move || {
            for line in rakuos_updates::upgrade_image_stream(&type_t, &repo_t, &tag_t) {
                if let Some(code) = line.strip_prefix("__done__") {
                    let _ = tx.send(ProgressMsg::Done(code.trim() == "0"));
                    return;
                }
                if let Some(pct) = parse_bootc_progress(&line) {
                    let _ = tx.send(ProgressMsg::Progress(pct));
                }
            }
            let _ = tx.send(ProgressMsg::Done(false));
        });

        let state_r  = state_c.clone();
        let prog_r   = prog_c.clone();
        let done_r   = done_c.clone();
        let btn_r    = btn_ref.clone();
        let reboot_r = reboot_c.clone();
        let status_r = status_c.clone();
        let upd_r    = upd_c.clone();

        glib::timeout_add_local(Duration::from_millis(100), move || {
            let mut got_real = false;
            let mut done_result: Option<bool> = None;
            loop {
                match rx.try_recv() {
                    Ok(ProgressMsg::Progress(pct)) => { prog_r.set_fraction(pct); got_real = true; }
                    Ok(ProgressMsg::Done(ok))      => { done_result = Some(ok); break; }
                    Err(_)                         => break,
                }
            }
            if !got_real && done_result.is_none() {
                let cur = prog_r.fraction();
                if cur < 0.95 { prog_r.set_fraction((cur + 0.005).min(0.95)); }
            }
            if let Some(ok) = done_result {
                prog_r.set_fraction(1.0);
                state_r.stop();
                prog_r.set_visible(false);
                if ok {
                    done_r.set_label("System update staged — reboot to apply");
                    done_r.set_visible(true);
                    btn_r.set_label("Done");
                    reboot_r.set_visible(true);
                    upd_r.set_visible(false);
                    status_r.set_label("System update staged — reboot to apply");
                } else {
                    done_r.set_label("Update failed");
                    done_r.set_visible(true);
                    btn_r.set_label("Retry");
                    btn_r.set_sensitive(true);
                }
                return glib::ControlFlow::Break;
            }
            glib::ControlFlow::Continue
        });
    });

    card.upcast()
}

// ── Package updates section card ─────────────────────────────────────────────

fn build_packages_section(title: &str, packages: &[serde_json::Value], state: UpdateState, status_lbl: Label) -> Widget {
    let card = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(0)
        .css_classes(vec!["card".to_string()])
        .build();

    // Header
    let header = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(8)
        .margin_top(12).margin_bottom(8)
        .margin_start(12).margin_end(12)
        .build();
    header.append(&Label::builder()
        .label(title)
        .halign(Align::Start).hexpand(true)
        .css_classes(vec!["heading".to_string()])
        .build());
    let n = packages.len();
    header.append(&Label::builder()
        .label(&format!("{} update{}", n, if n == 1 { "" } else { "s" }))
        .halign(Align::End)
        .css_classes(vec!["dim-label".to_string(), "caption".to_string()])
        .build());

    let update_all_btn = Button::builder()
        .label("Update All")
        .valign(Align::Center)
        .css_classes(vec!["suggested-action".to_string()])
        .build();
    header.append(&update_all_btn);
    card.append(&header);
    card.append(&gtk4::Separator::new(Orientation::Horizontal));

    let progress = ProgressBar::builder()
        .margin_start(12).margin_end(12)
        .margin_bottom(4)
        .visible(false)
        .build();
    let done_lbl = Label::builder()
        .halign(Align::Center).margin_bottom(8)
        .visible(false)
        .css_classes(vec!["caption".to_string()])
        .build();

    // Per-row remaining counter: when it hits 0, the whole card hides.
    let remaining = Rc::new(Cell::new(packages.len()));

    // Rows
    for pkg in packages {
        let name = pkg["name"].as_str().unwrap_or("").to_string();
        let display_name = pkg["display_name"].as_str().unwrap_or(&name).to_string();
        let cur  = pkg["current_version"].as_str().unwrap_or("").to_string();
        let new  = pkg["version"].as_str().unwrap_or("").to_string();

        let icon_path = pkg["icon_path"].as_str().unwrap_or("").to_string();
        let icon_url  = pkg["icon_url"].as_str().unwrap_or("").to_string();

        let pkg_row = GBox::builder()
            .orientation(Orientation::Vertical)
            .spacing(4)
            .build();

        let row = GBox::builder()
            .orientation(Orientation::Horizontal)
            .spacing(12)
            .margin_top(8).margin_bottom(8)
            .margin_start(12).margin_end(12)
            .build();
        row.append(&super::icon_helper::load_app_icon(&icon_path, &icon_url, 28, &display_name));

        let info = GBox::builder()
            .orientation(Orientation::Vertical)
            .spacing(2).valign(Align::Center).hexpand(true)
            .build();
        info.append(&Label::builder().label(&display_name).halign(Align::Start)
            .css_classes(vec!["heading".to_string()]).build());
        if !cur.is_empty() && !new.is_empty() {
            info.append(&Label::builder()
                .label(&format!("{} → {}", cur, new))
                .halign(Align::Start)
                .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
                .build());
        }
        row.append(&info);

        let upd_btn = Button::builder()
            .label("Update")
            .valign(Align::Center)
            .build();
        row.append(&upd_btn);
        pkg_row.append(&row);

        let row_prog = ProgressBar::builder()
            .margin_start(12).margin_end(12)
            .visible(false)
            .build();
        pkg_row.append(&row_prog);
        card.append(&pkg_row);
        card.append(&gtk4::Separator::new(Orientation::Horizontal));

        // Wire per-row Update button — ProgressMsg channel with real+fake progress
        let pkg_name  = name.clone();
        let state_p   = state.clone();
        let prog_p    = row_prog.clone();
        let btn_p     = upd_btn.clone();
        let row_p     = pkg_row.clone();
        let rem_p     = remaining.clone();
        let card_p    = card.clone();

        upd_btn.connect_clicked(move |btn| {
            if state_p.is_running() { return; }
            state_p.start(&pkg_name);
            btn.set_sensitive(false);
            btn.set_label("Updating…");
            prog_p.set_fraction(0.02);  // start at 2% so bar is immediately visible
            prog_p.set_visible(true);

            let name_t = pkg_name.clone();
            let (tx, rx) = mpsc::channel::<ProgressMsg>();
            std::thread::spawn(move || {
                for line in rakuos_updates::upgrade_single_package_stream(&name_t) {
                    if let Some(code) = line.strip_prefix("__done__") {
                        let _ = tx.send(ProgressMsg::Done(code.trim() == "0"));
                        return;
                    }
                    if let Some(pct) = parse_dnf_progress(&line) {
                        let _ = tx.send(ProgressMsg::Progress(pct));
                    }
                }
                let _ = tx.send(ProgressMsg::Done(false));
            });

            let state_r = state_p.clone();
            let prog_r  = prog_p.clone();
            let btn_r   = btn_p.clone();
            let row_r   = row_p.clone();
            let rem_r   = rem_p.clone();
            let card_r  = card_p.clone();

            glib::timeout_add_local(Duration::from_millis(100), move || {
                let mut got_real = false;
                let mut done_result: Option<bool> = None;
                loop {
                    match rx.try_recv() {
                        Ok(ProgressMsg::Progress(pct)) => { prog_r.set_fraction(pct); got_real = true; }
                        Ok(ProgressMsg::Done(ok))      => { done_result = Some(ok); break; }
                        Err(_)                         => break,
                    }
                }
                if !got_real && done_result.is_none() {
                    // Fake-advance toward 95% so something is always visibly moving
                    let cur = prog_r.fraction();
                    if cur < 0.95 { prog_r.set_fraction((cur + 0.01).min(0.95)); }
                }
                if let Some(ok) = done_result {
                    prog_r.set_fraction(1.0);
                    state_r.stop();
                    prog_r.set_visible(false);
                    if ok {
                        row_r.set_visible(false);
                        let rem = rem_r.get().saturating_sub(1);
                        rem_r.set(rem);
                        if rem == 0 { card_r.set_visible(false); }
                    } else {
                        btn_r.set_sensitive(true);
                        btn_r.set_label("Retry");
                    }
                    return glib::ControlFlow::Break;
                }
                glib::ControlFlow::Continue
            });
        });
    }

    card.append(&progress);
    card.append(&done_lbl);

    // Wire Update All
    let state_c  = state.clone();
    let prog_c   = progress.clone();
    let done_c   = done_lbl.clone();
    let btn_c    = update_all_btn.clone();
    let status_c = status_lbl.clone();
    let card_c   = card.clone();

    update_all_btn.connect_clicked(move |btn| {
        if state_c.is_running() { return; }
        state_c.start("packages");
        btn.set_sensitive(false);
        btn.set_label("Updating…");
        prog_c.set_fraction(0.0);
        prog_c.set_visible(true);

        let (tx, rx) = mpsc::channel::<ProgressMsg>();
        std::thread::spawn(move || {
            for line in rakuos_updates::upgrade_packages_stream() {
                if let Some(code) = line.strip_prefix("__done__") {
                    let ok = code.trim() == "0";
                    let _ = tx.send(ProgressMsg::Done(ok));
                    return;
                }
                if let Some(pct) = parse_dnf_progress(&line) {
                    let _ = tx.send(ProgressMsg::Progress(pct));
                }
            }
            let _ = tx.send(ProgressMsg::Done(false));
        });

        let state_r  = state_c.clone();
        let prog_r   = prog_c.clone();
        let done_r   = done_c.clone();
        let btn_r    = btn_c.clone();
        let status_r = status_c.clone();
        let card_r   = card_c.clone();

        glib::timeout_add_local(Duration::from_millis(100), move || {
            let mut done_result: Option<bool> = None;
            loop {
                match rx.try_recv() {
                    Ok(ProgressMsg::Progress(pct)) => { prog_r.set_fraction(pct); }
                    Ok(ProgressMsg::Done(ok))      => { done_result = Some(ok); break; }
                    Err(_)                         => break,
                }
            }
            if let Some(ok) = done_result {
                state_r.stop();
                prog_r.set_visible(false);
                if ok {
                    card_r.set_visible(false);
                    status_r.set_label("Packages updated");
                } else {
                    done_r.set_label("Update failed");
                    done_r.set_visible(true);
                    btn_r.set_label("Retry");
                    btn_r.set_sensitive(true);
                }
                glib::ControlFlow::Break
            } else {
                glib::ControlFlow::Continue
            }
        });
    });

    card.upcast()
}

// ── Applications section: gui RPM packages + non-runtime flatpaks ────────────

fn build_apps_section(
    gui_pkgs: &[serde_json::Value],
    apps: &[FlatpakUpdate],
    state: UpdateState,
    status_lbl: Label,
) -> Widget {
    let card = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(0)
        .css_classes(vec!["card".to_string()])
        .build();

    let total_count = gui_pkgs.len() + apps.len();

    let header = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(8)
        .margin_top(12).margin_bottom(8)
        .margin_start(12).margin_end(12)
        .build();
    header.append(&Label::builder()
        .label("Applications")
        .halign(Align::Start).hexpand(true)
        .css_classes(vec!["heading".to_string()])
        .build());
    header.append(&Label::builder()
        .label(&format!("{} update{}", total_count, if total_count == 1 { "" } else { "s" }))
        .halign(Align::End)
        .css_classes(vec!["dim-label".to_string(), "caption".to_string()])
        .build());

    let update_all_btn = Button::builder()
        .label("Update All")
        .valign(Align::Center)
        .css_classes(vec!["suggested-action".to_string()])
        .build();
    header.append(&update_all_btn);
    card.append(&header);
    card.append(&gtk4::Separator::new(Orientation::Horizontal));

    // Shared remaining counter: when it hits 0, hide the whole card
    let remaining = Rc::new(Cell::new(total_count));

    // ── RPM rows ──────────────────────────────────────────────────────────────
    for pkg in gui_pkgs {
        let name = pkg["name"].as_str().unwrap_or("").to_string();
        let display_name = pkg["display_name"].as_str().unwrap_or(&name).to_string();
        let cur  = pkg["current_version"].as_str().unwrap_or("").to_string();
        let new  = pkg["version"].as_str().unwrap_or("").to_string();
        let icon_path = pkg["icon_path"].as_str().unwrap_or("").to_string();
        let icon_url  = pkg["icon_url"].as_str().unwrap_or("").to_string();

        let pkg_row = GBox::builder()
            .orientation(Orientation::Vertical)
            .spacing(4)
            .build();

        let row = GBox::builder()
            .orientation(Orientation::Horizontal)
            .spacing(12)
            .margin_top(8).margin_bottom(8)
            .margin_start(12).margin_end(12)
            .build();
        row.append(&super::icon_helper::load_app_icon(&icon_path, &icon_url, 28, &display_name));

        let info = GBox::builder()
            .orientation(Orientation::Vertical)
            .spacing(2).valign(Align::Center).hexpand(true)
            .build();
        info.append(&Label::builder().label(&display_name).halign(Align::Start)
            .css_classes(vec!["heading".to_string()]).build());
        if !cur.is_empty() && !new.is_empty() {
            info.append(&Label::builder()
                .label(&format!("{} → {}", cur, new))
                .halign(Align::Start)
                .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
                .build());
        }
        row.append(&info);

        let upd_btn = Button::builder().label("Update").valign(Align::Center).build();
        row.append(&upd_btn);
        pkg_row.append(&row);

        let row_prog = ProgressBar::builder()
            .margin_start(12).margin_end(12)
            .visible(false)
            .build();
        pkg_row.append(&row_prog);
        card.append(&pkg_row);
        card.append(&gtk4::Separator::new(Orientation::Horizontal));

        let pkg_name = name.clone();
        let state_p  = state.clone();
        let prog_p   = row_prog.clone();
        let btn_p    = upd_btn.clone();
        let row_p    = pkg_row.clone();
        let rem_p    = remaining.clone();
        let card_p   = card.clone();

        upd_btn.connect_clicked(move |btn| {
            if state_p.is_running() { return; }
            state_p.start(&pkg_name);
            btn.set_sensitive(false);
            btn.set_label("Updating…");
            prog_p.set_fraction(0.02);
            prog_p.set_visible(true);

            let name_t = pkg_name.clone();
            let (tx, rx) = mpsc::channel::<ProgressMsg>();
            std::thread::spawn(move || {
                for line in rakuos_updates::upgrade_single_package_stream(&name_t) {
                    if let Some(code) = line.strip_prefix("__done__") {
                        let _ = tx.send(ProgressMsg::Done(code.trim() == "0"));
                        return;
                    }
                    if let Some(pct) = parse_dnf_progress(&line) {
                        let _ = tx.send(ProgressMsg::Progress(pct));
                    }
                }
                let _ = tx.send(ProgressMsg::Done(false));
            });

            let state_r = state_p.clone();
            let prog_r  = prog_p.clone();
            let btn_r   = btn_p.clone();
            let row_r   = row_p.clone();
            let rem_r   = rem_p.clone();
            let card_r  = card_p.clone();

            glib::timeout_add_local(Duration::from_millis(100), move || {
                let mut got_real = false;
                let mut done_result: Option<bool> = None;
                loop {
                    match rx.try_recv() {
                        Ok(ProgressMsg::Progress(pct)) => { prog_r.set_fraction(pct); got_real = true; }
                        Ok(ProgressMsg::Done(ok))      => { done_result = Some(ok); break; }
                        Err(_)                         => break,
                    }
                }
                if !got_real && done_result.is_none() {
                    let cur = prog_r.fraction();
                    if cur < 0.95 { prog_r.set_fraction((cur + 0.01).min(0.95)); }
                }
                if let Some(ok) = done_result {
                    prog_r.set_fraction(1.0);
                    state_r.stop();
                    prog_r.set_visible(false);
                    if ok {
                        row_r.set_visible(false);
                        let rem = rem_r.get().saturating_sub(1);
                        rem_r.set(rem);
                        if rem == 0 { card_r.set_visible(false); }
                    } else {
                        btn_r.set_sensitive(true);
                        btn_r.set_label("Retry");
                    }
                    return glib::ControlFlow::Break;
                }
                glib::ControlFlow::Continue
            });
        });
    }

    // ── Flatpak app rows ──────────────────────────────────────────────────────
    let fp_count = apps.len();
    for (i, fp) in apps.iter().enumerate() {
        card.append(&build_flatpak_row(fp, state.clone(), remaining.clone(), card.clone()));
        if i + 1 < fp_count {
            card.append(&gtk4::Separator::new(Orientation::Horizontal));
        }
    }

    // ── Update All: upgrade each item individually ────────────────────────────
    let rpm_names: Vec<String> = gui_pkgs.iter()
        .filter_map(|p| p["name"].as_str().map(|s| s.to_string()))
        .collect();
    let fps_all  = apps.to_vec();
    let state_a  = state.clone();
    let btn_a    = update_all_btn.clone();
    let card_a   = card.clone();
    let status_a = status_lbl.clone();

    update_all_btn.connect_clicked(move |btn| {
        if state_a.is_running() { return; }
        state_a.start("apps-all");
        btn.set_sensitive(false);
        btn.set_label("Updating…");
        status_a.set_label("Updating applications…");

        let names = rpm_names.clone();
        let fps   = fps_all.clone();
        let (tx, rx) = mpsc::channel::<bool>();
        std::thread::spawn(move || {
            let mut all_ok = true;
            for name in &names {
                let ok = rakuos_updates::upgrade_single_package_stream(name)
                    .any(|l| l.starts_with("__done__0"));
                if !ok { all_ok = false; }
            }
            for fp in &fps {
                let ok = if fp.needs_install {
                    rakuos_flatpak::install_ref_stream(&fp.install_remote, &fp.app_id, &fp.version)
                        .any(|l| l.starts_with("__done__0"))
                } else {
                    rakuos_flatpak::update_single_stream(&fp.app_id)
                        .any(|l| l.starts_with("__done__0"))
                };
                if !ok { all_ok = false; }
            }
            let _ = tx.send(all_ok);
        });

        let state_r  = state_a.clone();
        let btn_r    = btn_a.clone();
        let card_r   = card_a.clone();
        let status_r = status_a.clone();
        glib::timeout_add_local(Duration::from_millis(100), move || {
            match rx.try_recv() {
                Ok(ok) => {
                    state_r.stop();
                    if ok {
                        card_r.set_visible(false);
                        status_r.set_label("Applications updated");
                    } else {
                        btn_r.set_label("Retry");
                        btn_r.set_sensitive(true);
                    }
                    glib::ControlFlow::Break
                }
                Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
                Err(_) => { state_r.stop(); glib::ControlFlow::Break }
            }
        });
    });

    card.upcast()
}

fn build_webapps_section(
    webapps: &[WebAppUpdate],
    state: UpdateState,
    status_lbl: Label,
) -> Widget {
    let card = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(0)
        .css_classes(vec!["card".to_string()])
        .build();

    let header = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(8)
        .margin_top(12).margin_bottom(8)
        .margin_start(12).margin_end(12)
        .build();
    header.append(&Label::builder()
        .label("Web Apps")
        .halign(Align::Start).hexpand(true)
        .css_classes(vec!["heading".to_string()])
        .build());
    header.append(&Label::builder()
        .label(&format!("{} update{}", webapps.len(), if webapps.len() == 1 { "" } else { "s" }))
        .halign(Align::End)
        .css_classes(vec!["dim-label".to_string(), "caption".to_string()])
        .build());

    let update_all_btn = Button::builder()
        .label("Update All")
        .valign(Align::Center)
        .css_classes(vec!["suggested-action".to_string()])
        .build();
    header.append(&update_all_btn);
    card.append(&header);
    card.append(&gtk4::Separator::new(Orientation::Horizontal));

    let remaining = Rc::new(Cell::new(webapps.len()));

    for app in webapps {
        let app_row = GBox::builder()
            .orientation(Orientation::Vertical)
            .spacing(4)
            .build();

        let row = GBox::builder()
            .orientation(Orientation::Horizontal)
            .spacing(12)
            .margin_top(8).margin_bottom(8)
            .margin_start(12).margin_end(12)
            .build();
        row.append(&super::icon_helper::load_app_icon(&app.icon_path, &app.icon_url, 28, &app.name));

        let info = GBox::builder()
            .orientation(Orientation::Vertical)
            .spacing(2).valign(Align::Center).hexpand(true)
            .build();
        info.append(&Label::builder()
            .label(&app.name)
            .halign(Align::Start)
            .css_classes(vec!["heading".to_string()])
            .build());
        let detail = if !app.current_last_updated.is_empty() && !app.new_last_updated.is_empty() {
            format!("Catalog metadata updated: {} → {}", app.current_last_updated, app.new_last_updated)
        } else if !app.summary.is_empty() {
            app.summary.clone()
        } else {
            "Updated web app metadata available".to_string()
        };
        info.append(&Label::builder()
            .label(&detail)
            .halign(Align::Start)
            .wrap(true)
            .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
            .build());
        row.append(&info);

        let upd_btn = Button::builder()
            .label("Update")
            .valign(Align::Center)
            .build();
        row.append(&upd_btn);
        app_row.append(&row);

        let row_prog = ProgressBar::builder()
            .margin_start(12).margin_end(12)
            .visible(false)
            .build();
        app_row.append(&row_prog);
        card.append(&app_row);
        card.append(&gtk4::Separator::new(Orientation::Horizontal));

        let app_id = app.id.clone();
        let state_p = state.clone();
        let prog_p = row_prog.clone();
        let btn_p = upd_btn.clone();
        let row_p = app_row.clone();
        let rem_p = remaining.clone();
        let card_p = card.clone();

        upd_btn.connect_clicked(move |btn| {
            if state_p.is_running() { return; }
            state_p.start(&app_id);
            btn.set_sensitive(false);
            btn.set_label("Updating…");
            prog_p.set_fraction(0.15);
            prog_p.set_visible(true);

            let id_t = app_id.clone();
            let (tx, rx) = mpsc::channel::<bool>();
            std::thread::spawn(move || {
                let (ok, _) = rakuos_webapps::update(&id_t);
                let _ = tx.send(ok);
            });

            let state_r = state_p.clone();
            let prog_r = prog_p.clone();
            let btn_r = btn_p.clone();
            let row_r = row_p.clone();
            let rem_r = rem_p.clone();
            let card_r = card_p.clone();

            glib::timeout_add_local(Duration::from_millis(100), move || {
                match rx.try_recv() {
                    Ok(ok) => {
                        state_r.stop();
                        prog_r.set_visible(false);
                        if ok {
                            row_r.set_visible(false);
                            let rem = rem_r.get().saturating_sub(1);
                            rem_r.set(rem);
                            if rem == 0 { card_r.set_visible(false); }
                        } else {
                            btn_r.set_label("Retry");
                            btn_r.set_sensitive(true);
                        }
                        glib::ControlFlow::Break
                    }
                    Err(mpsc::TryRecvError::Empty) => {
                        let cur = prog_r.fraction();
                        if cur < 0.95 { prog_r.set_fraction((cur + 0.02).min(0.95)); }
                        glib::ControlFlow::Continue
                    }
                    Err(_) => {
                        state_r.stop();
                        prog_r.set_visible(false);
                        btn_r.set_label("Retry");
                        btn_r.set_sensitive(true);
                        glib::ControlFlow::Break
                    }
                }
            });
        });
    }

    let ids: Vec<String> = webapps.iter().map(|w| w.id.clone()).collect();
    let state_a = state.clone();
    let btn_a = update_all_btn.clone();
    let card_a = card.clone();
    let status_a = status_lbl.clone();

    update_all_btn.connect_clicked(move |btn| {
        if state_a.is_running() { return; }
        state_a.start("webapps-all");
        btn.set_sensitive(false);
        btn.set_label("Updating…");
        status_a.set_label("Updating web apps…");

        let ids_t = ids.clone();
        let (tx, rx) = mpsc::channel::<bool>();
        std::thread::spawn(move || {
            let mut all_ok = true;
            for id in &ids_t {
                let (ok, _) = rakuos_webapps::update(id);
                if !ok { all_ok = false; }
            }
            let _ = tx.send(all_ok);
        });

        let state_r = state_a.clone();
        let btn_r = btn_a.clone();
        let card_r = card_a.clone();
        let status_r = status_a.clone();
        glib::timeout_add_local(Duration::from_millis(100), move || {
            match rx.try_recv() {
                Ok(ok) => {
                    state_r.stop();
                    if ok {
                        card_r.set_visible(false);
                        status_r.set_label("Web apps updated");
                    } else {
                        status_r.set_label("Some web app updates failed");
                        btn_r.set_label("Retry");
                        btn_r.set_sensitive(true);
                    }
                    glib::ControlFlow::Break
                }
                Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
                Err(_) => {
                    state_r.stop();
                    btn_r.set_label("Retry");
                    btn_r.set_sensitive(true);
                    glib::ControlFlow::Break
                }
            }
        });
    });

    card.upcast()
}

fn build_gnome_extensions_section(
    extensions: &[GnomeExtensionUpdate],
    state: UpdateState,
    status_lbl: Label,
) -> Widget {
    let card = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(0)
        .css_classes(vec!["card".to_string()])
        .build();

    let header = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(8)
        .margin_top(12).margin_bottom(8)
        .margin_start(12).margin_end(12)
        .build();
    header.append(&Label::builder()
        .label("GNOME Extensions")
        .halign(Align::Start).hexpand(true)
        .css_classes(vec!["heading".to_string()])
        .build());
    header.append(&Label::builder()
        .label(&format!("{} update{}", extensions.len(), if extensions.len() == 1 { "" } else { "s" }))
        .halign(Align::End)
        .css_classes(vec!["dim-label".to_string(), "caption".to_string()])
        .build());

    let update_all_btn = Button::builder()
        .label("Update All")
        .valign(Align::Center)
        .css_classes(vec!["suggested-action".to_string()])
        .build();
    header.append(&update_all_btn);
    card.append(&header);
    card.append(&gtk4::Separator::new(Orientation::Horizontal));

    let remaining = Rc::new(Cell::new(extensions.len()));

    for extension in extensions {
        let ext_row = GBox::builder()
            .orientation(Orientation::Vertical)
            .spacing(4)
            .build();

        let row = GBox::builder()
            .orientation(Orientation::Horizontal)
            .spacing(12)
            .margin_top(8).margin_bottom(8)
            .margin_start(12).margin_end(12)
            .build();
        row.append(&super::icon_helper::load_app_icon(
            "",
            &extension.icon_url,
            28,
            &extension.name,
        ));

        let info = GBox::builder()
            .orientation(Orientation::Vertical)
            .spacing(2).valign(Align::Center).hexpand(true)
            .build();
        info.append(&Label::builder()
            .label(&extension.name)
            .halign(Align::Start)
            .css_classes(vec!["heading".to_string()])
            .build());
        info.append(&Label::builder()
            .label(&format!(
                "GNOME Shell {}: v{} → v{}",
                extension.shell_version,
                extension.installed_version,
                extension.available_version
            ))
            .halign(Align::Start)
            .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
            .build());
        row.append(&info);

        let upd_btn = Button::builder()
            .label("Update")
            .valign(Align::Center)
            .build();
        row.append(&upd_btn);
        ext_row.append(&row);

        let row_prog = ProgressBar::builder()
            .margin_start(12).margin_end(12)
            .visible(false)
            .build();
        ext_row.append(&row_prog);
        card.append(&ext_row);
        card.append(&gtk4::Separator::new(Orientation::Horizontal));

        let uuid = extension.uuid.clone();
        let shell_version = extension.shell_version.clone();
        let state_p = state.clone();
        let prog_p = row_prog.clone();
        let btn_p = upd_btn.clone();
        let row_p = ext_row.clone();
        let rem_p = remaining.clone();
        let card_p = card.clone();

        upd_btn.connect_clicked(move |btn| {
            if state_p.is_running() { return; }
            state_p.start(&uuid);
            btn.set_sensitive(false);
            btn.set_label("Updating…");
            prog_p.set_fraction(0.15);
            prog_p.set_visible(true);

            let uuid_t = uuid.clone();
            let shell_t = shell_version.clone();
            let (tx, rx) = mpsc::channel::<bool>();
            std::thread::spawn(move || {
                let ok = update_gnome_extension(&uuid_t, &shell_t);
                let _ = tx.send(ok);
            });

            let state_r = state_p.clone();
            let prog_r = prog_p.clone();
            let btn_r = btn_p.clone();
            let row_r = row_p.clone();
            let rem_r = rem_p.clone();
            let card_r = card_p.clone();

            glib::timeout_add_local(Duration::from_millis(100), move || {
                match rx.try_recv() {
                    Ok(ok) => {
                        state_r.stop();
                        prog_r.set_visible(false);
                        if ok {
                            row_r.set_visible(false);
                            let rem = rem_r.get().saturating_sub(1);
                            rem_r.set(rem);
                            if rem == 0 { card_r.set_visible(false); }
                        } else {
                            btn_r.set_label("Retry");
                            btn_r.set_sensitive(true);
                        }
                        glib::ControlFlow::Break
                    }
                    Err(mpsc::TryRecvError::Empty) => {
                        let cur = prog_r.fraction();
                        if cur < 0.95 { prog_r.set_fraction((cur + 0.02).min(0.95)); }
                        glib::ControlFlow::Continue
                    }
                    Err(_) => {
                        state_r.stop();
                        prog_r.set_visible(false);
                        btn_r.set_label("Retry");
                        btn_r.set_sensitive(true);
                        glib::ControlFlow::Break
                    }
                }
            });
        });
    }

    let updates = extensions.to_vec();
    let state_a = state.clone();
    let btn_a = update_all_btn.clone();
    let card_a = card.clone();
    let status_a = status_lbl.clone();

    update_all_btn.connect_clicked(move |btn| {
        if state_a.is_running() { return; }
        state_a.start("gnome-extensions-all");
        btn.set_sensitive(false);
        btn.set_label("Updating…");
        status_a.set_label("Updating GNOME extensions…");

        let updates_t = updates.clone();
        let (tx, rx) = mpsc::channel::<bool>();
        std::thread::spawn(move || {
            let mut all_ok = true;
            for extension in &updates_t {
                if !update_gnome_extension(&extension.uuid, &extension.shell_version) {
                    all_ok = false;
                }
            }
            let _ = tx.send(all_ok);
        });

        let state_r = state_a.clone();
        let btn_r = btn_a.clone();
        let card_r = card_a.clone();
        let status_r = status_a.clone();
        glib::timeout_add_local(Duration::from_millis(100), move || {
            match rx.try_recv() {
                Ok(ok) => {
                    state_r.stop();
                    if ok {
                        card_r.set_visible(false);
                        status_r.set_label("GNOME extensions updated");
                    } else {
                        status_r.set_label("Some GNOME extension updates failed");
                        btn_r.set_label("Retry");
                        btn_r.set_sensitive(true);
                    }
                    glib::ControlFlow::Break
                }
                Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
                Err(_) => {
                    state_r.stop();
                    btn_r.set_label("Retry");
                    btn_r.set_sensitive(true);
                    glib::ControlFlow::Break
                }
            }
        });
    });

    card.upcast()
}

// ── Flatpak section card (apps or runtimes) ───────────────────────────────────

fn build_flatpak_section(title: &str, flatpaks: &[FlatpakUpdate], state: UpdateState) -> Widget {
    let card = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(0)
        .css_classes(vec!["card".to_string()])
        .build();

    // Section header
    let header = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(8)
        .margin_top(12)
        .margin_bottom(8)
        .margin_start(12)
        .margin_end(12)
        .build();

    header.append(
        &Label::builder()
            .label(title)
            .halign(Align::Start)
            .hexpand(true)
            .css_classes(vec!["heading".to_string()])
            .build(),
    );
    header.append(
        &Label::builder()
            .label(&format!(
                "{} update{}",
                flatpaks.len(),
                if flatpaks.len() == 1 { "" } else { "s" }
            ))
            .halign(Align::End)
            .css_classes(vec!["dim-label".to_string(), "caption".to_string()])
            .build(),
    );

    let sec_upd_btn = Button::builder()
        .label("Update All")
        .valign(Align::Center)
        .build();
    header.append(&sec_upd_btn);
    card.append(&header);
    card.append(&gtk4::Separator::new(Orientation::Horizontal));

    // Items — remaining counter hides the card when all rows complete individually
    let remaining = Rc::new(Cell::new(flatpaks.len()));
    let count = flatpaks.len();
    for (i, fp) in flatpaks.iter().enumerate() {
        card.append(&build_flatpak_row(fp, state.clone(), remaining.clone(), card.clone()));
        if i + 1 < count {
            card.append(&gtk4::Separator::new(Orientation::Horizontal));
        }
    }

    // Wire section Update All
    let fps_all  = flatpaks.to_vec();
    let state_sa = state.clone();
    let btn_sa   = sec_upd_btn.clone();
    let card_sa  = card.clone();

    sec_upd_btn.connect_clicked(move |btn| {
        if state_sa.is_running() { return; }
        state_sa.start("flatpak-all");
        btn.set_sensitive(false);
        btn.set_label("Updating…");

        let fps = fps_all.clone();
        let (tx, rx) = mpsc::channel::<bool>();
        std::thread::spawn(move || {
            let mut all_ok = true;
            for fp in &fps {
                let ok = if fp.needs_install {
                    rakuos_flatpak::install_ref_stream(&fp.install_remote, &fp.app_id, &fp.version)
                        .any(|l| l.starts_with("__done__0"))
                } else {
                    rakuos_flatpak::update_single_stream(&fp.app_id)
                        .any(|l| l.starts_with("__done__0"))
                };
                if !ok { all_ok = false; }
            }
            let _ = tx.send(all_ok);
        });

        let state_r = state_sa.clone();
        let btn_r   = btn_sa.clone();
        let card_r  = card_sa.clone();
        glib::timeout_add_local(Duration::from_millis(100), move || {
            match rx.try_recv() {
                Ok(ok) => {
                    state_r.stop();
                    if ok {
                        card_r.set_visible(false);
                    } else {
                        btn_r.set_label("Retry");
                        btn_r.set_sensitive(true);
                    }
                    glib::ControlFlow::Break
                }
                Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
                Err(_) => { state_r.stop(); glib::ControlFlow::Break }
            }
        });
    });

    card.upcast()
}

fn build_flatpak_row(fp: &FlatpakUpdate, state: UpdateState, remaining: Rc<Cell<usize>>, card: GBox) -> Widget {
    let row = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(4)
        .margin_top(8)
        .margin_bottom(8)
        .margin_start(12)
        .margin_end(12)
        .build();

    let item = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(10)
        .build();

    // Icon — use the resolved path from AppStream (same source as installed page)
    let icon = super::icon_helper::load_app_icon(&fp.icon_path, &fp.icon_url, 36, &fp.name);
    item.append(&icon);

    let info = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(2)
        .valign(Align::Center)
        .hexpand(true)
        .build();
    info.append(
        &Label::builder()
            .label(&fp.name)
            .halign(Align::Start)
            .css_classes(vec!["heading".to_string()])
            .build(),
    );

    let badge_row = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(6)
        .build();

    if !fp.version.is_empty() {
        let ver_text = if !fp.current_version.is_empty() && fp.current_version != fp.version {
            format!("{} → {}", fp.current_version, fp.version)
        } else {
            fp.version.clone()
        };
        badge_row.append(
            &Label::builder()
                .label(&ver_text)
                .halign(Align::Start)
                .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
                .build(),
        );
    }

    let badge_text = if fp.runtime { "Runtime" } else { "Flatpak" };
    badge_row.append(
        &Label::builder()
            .label(badge_text)
            .halign(Align::Start)
            .css_classes(vec!["tag".to_string()])
            .build(),
    );

    info.append(&badge_row);
    item.append(&info);

    let btn_label = if fp.needs_install { "Install" } else { "Update" };
    let update_btn = Button::builder()
        .label(btn_label)
        .valign(Align::Center)
        .build();
    item.append(&update_btn);
    row.append(&item);

    let progress = ProgressBar::builder()
        .visible(false)
        .build();
    row.append(&progress);

    // Wire individual Update/Install button — ProgressMsg channel, real+fake 0-100% bar
    let app_id        = fp.app_id.clone();
    let fp_version    = fp.version.clone();
    let fp_remote     = fp.install_remote.clone();
    let needs_install = fp.needs_install;
    let is_runtime    = fp.runtime;
    let prog_c  = progress.clone();
    let btn_ref = update_btn.clone();
    let state_c = state.clone();
    let row_c   = row.clone();
    let rem_c   = remaining.clone();
    let card_c  = card.clone();

    update_btn.connect_clicked(move |btn| {
        if state_c.is_running() { return; }
        state_c.start(&app_id);
        btn.set_sensitive(false);
        btn.set_label(if needs_install { "Installing…" } else { "Updating…" });
        prog_c.set_fraction(0.02);  // start at 2% — always a bar, never a spinner
        prog_c.set_visible(true);

        let id      = app_id.clone();
        let version = fp_version.clone();
        let remote  = fp_remote.clone();
        let (tx, rx) = mpsc::channel::<ProgressMsg>();
        std::thread::spawn(move || {
            let iter: Box<dyn Iterator<Item = String>> = if needs_install {
                Box::new(rakuos_flatpak::install_ref_stream(&remote, &id, &version))
            } else if is_runtime {
                Box::new(rakuos_flatpak::update_runtime_stream(&id, &version))
            } else {
                Box::new(rakuos_flatpak::update_single_stream(&id))
            };
            for line in iter {
                if let Some(code) = line.strip_prefix("__done__") {
                    let _ = tx.send(ProgressMsg::Done(code.trim() == "0"));
                    return;
                }
                // "[N/M]" step progress or "> XX%" sub-step percentage
                if let Some(pct) = parse_dnf_progress(&line) {
                    let _ = tx.send(ProgressMsg::Progress(pct));
                } else {
                    let t = line.trim();
                    if let Some(rest) = t.strip_prefix('>') {
                        let pct_str = rest.trim().trim_end_matches('%');
                        if let Ok(p) = pct_str.parse::<f64>() {
                            let _ = tx.send(ProgressMsg::Progress(p / 100.0));
                        }
                    }
                }
            }
            let _ = tx.send(ProgressMsg::Done(false));
        });

        let state_r = state_c.clone();
        let prog_r  = prog_c.clone();
        let btn_r   = btn_ref.clone();
        let row_r   = row_c.clone();
        let rem_r   = rem_c.clone();
        let card_r  = card_c.clone();

        glib::timeout_add_local(Duration::from_millis(100), move || {
            let mut got_real = false;
            let mut done_result: Option<bool> = None;
            loop {
                match rx.try_recv() {
                    Ok(ProgressMsg::Progress(pct)) => { prog_r.set_fraction(pct); got_real = true; }
                    Ok(ProgressMsg::Done(ok))      => { done_result = Some(ok); break; }
                    Err(_)                         => break,
                }
            }
            if !got_real && done_result.is_none() {
                // Fake-advance: bar always moves so users know something is happening
                let cur = prog_r.fraction();
                if cur < 0.95 { prog_r.set_fraction((cur + 0.01).min(0.95)); }
            }
            if let Some(ok) = done_result {
                prog_r.set_fraction(1.0);
                state_r.stop();
                prog_r.set_visible(false);
                if ok {
                    row_r.set_visible(false);
                    let rem = rem_r.get().saturating_sub(1);
                    rem_r.set(rem);
                    if rem == 0 { card_r.set_visible(false); }
                } else {
                    btn_r.set_label("Retry");
                    btn_r.set_sensitive(true);
                }
                return glib::ControlFlow::Break;
            }
            glib::ControlFlow::Continue
        });
    });

    row.upcast()
}

fn check_gnome_extension_updates() -> Vec<GnomeExtensionUpdate> {
    let Some(shell_version) = detect_shell_version() else {
        return Vec::new();
    };
    let base = user_extensions_base();
    let Ok(entries) = fs::read_dir(base) else {
        return Vec::new();
    };

    let mut updates = Vec::new();
    for entry in entries.flatten() {
        let dir = entry.path();
        if !dir.is_dir() {
            continue;
        }
        let metadata_path = dir.join("metadata.json");
        let Ok(raw) = fs::read_to_string(metadata_path) else {
            continue;
        };
        let Ok(metadata) = serde_json::from_str::<serde_json::Value>(&raw) else {
            continue;
        };
        let uuid = metadata["uuid"]
            .as_str()
            .map(str::to_string)
            .or_else(|| dir.file_name().and_then(|n| n.to_str()).map(str::to_string))
            .unwrap_or_default();
        if uuid.is_empty() {
            continue;
        }
        let installed_version = json_u32(&metadata["version"]).unwrap_or(0);
        let installed_name = metadata["name"]
            .as_str()
            .unwrap_or(&uuid)
            .to_string();

        let Ok(remote) = get_remote_gnome_extension_info(&uuid, &shell_version) else {
            continue;
        };
        let Some(available_version) = json_u32(&remote.version) else {
            continue;
        };
        if available_version <= installed_version {
            continue;
        }

        let icon_url = remote.icon
            .filter(|s| !s.is_empty())
            .map(|s| absolute_gnome_extension_url(&s))
            .unwrap_or_else(|| format!("{}/static/images/plugin.png", GNOME_EXTENSIONS_BASE));

        updates.push(GnomeExtensionUpdate {
            uuid,
            name: remote.name.filter(|s| !s.is_empty()).unwrap_or(installed_name),
            installed_version,
            available_version,
            shell_version: shell_version.clone(),
            icon_url,
        });
    }

    updates.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
    updates
}

fn update_gnome_extension(uuid: &str, shell_version: &str) -> bool {
    install_gnome_extension_via_shell(uuid)
        .or_else(|_| install_gnome_extension_from_zip(uuid, shell_version))
        .is_ok()
}

fn install_gnome_extension_via_shell(uuid: &str) -> Result<(), String> {
    let status = Command::new("gdbus")
        .args([
            "call",
            "--session",
            "--dest",
            "org.gnome.Shell.Extensions",
            "--object-path",
            "/org/gnome/Shell/Extensions",
            "--method",
            "org.gnome.Shell.Extensions.InstallRemoteExtension",
            uuid,
        ])
        .status()
        .map_err(|e| e.to_string())?;
    if status.success() {
        Ok(())
    } else {
        Err("GNOME Shell remote install failed".to_string())
    }
}

fn install_gnome_extension_from_zip(uuid: &str, shell_version: &str) -> Result<(), String> {
    let info = get_remote_gnome_extension_info(uuid, shell_version)?;
    let Some(download_url) = info.download_url else {
        return Err("GNOME extension download URL missing".to_string());
    };
    let download_url = absolute_gnome_extension_url(&download_url);
    let bytes = reqwest::blocking::get(&download_url)
        .map_err(|e| e.to_string())?
        .error_for_status()
        .map_err(|e| e.to_string())?
        .bytes()
        .map_err(|e| e.to_string())?;

    let tmp_zip = std::env::temp_dir().join(format!("{}.shell-extension.zip", uuid));
    fs::write(&tmp_zip, &bytes).map_err(|e| e.to_string())?;

    let ext_dir = user_extensions_base().join(uuid);
    if ext_dir.exists() {
        fs::remove_dir_all(&ext_dir).map_err(|e| e.to_string())?;
    }
    fs::create_dir_all(&ext_dir).map_err(|e| e.to_string())?;

    let status = Command::new("unzip")
        .arg("-q")
        .arg("-o")
        .arg(&tmp_zip)
        .arg("-d")
        .arg(&ext_dir)
        .status()
        .map_err(|e| e.to_string())?;
    let _ = fs::remove_file(&tmp_zip);
    if !status.success() {
        return Err("unzip failed".to_string());
    }

    let _ = Command::new("gnome-extensions").args(["enable", uuid]).status();
    Ok(())
}

fn get_remote_gnome_extension_info(
    uuid: &str,
    shell_version: &str,
) -> Result<RemoteGnomeExtensionInfo, String> {
    let url = format!(
        "{}/extension-info/?uuid={}&shell_version={}",
        GNOME_EXTENSIONS_BASE,
        url_escape(uuid),
        url_escape(shell_version)
    );
    reqwest::blocking::get(&url)
        .map_err(|e| e.to_string())?
        .error_for_status()
        .map_err(|e| e.to_string())?
        .json()
        .map_err(|e| e.to_string())
}

fn detect_shell_version() -> Option<String> {
    let out = Command::new("gnome-shell").arg("--version").output().ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    text.split_whitespace()
        .find(|part| part.chars().next().is_some_and(|c| c.is_ascii_digit()))
        .map(|s| s.to_string())
}

fn user_extensions_base() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
    PathBuf::from(home).join(".local/share/gnome-shell/extensions")
}

fn absolute_gnome_extension_url(url: &str) -> String {
    if url.starts_with("http") {
        url.to_string()
    } else {
        format!("{}{}", GNOME_EXTENSIONS_BASE, url)
    }
}

fn url_escape(input: &str) -> String {
    input
        .replace('%', "%25")
        .replace(' ', "%20")
        .replace('@', "%40")
        .replace('+', "%2B")
        .replace('/', "%2F")
}

fn json_u32(value: &serde_json::Value) -> Option<u32> {
    value
        .as_u64()
        .and_then(|v| u32::try_from(v).ok())
        .or_else(|| value.as_str().and_then(|s| s.parse::<u32>().ok()))
}
