// 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::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),
}

// ── 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
}

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

// ── 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("Checking for updates…")
        .halign(Align::Start)
        .hexpand(true)
        .css_classes(vec!["heading".to_string()])
        .build();

    let busy = Spinner::builder()
        .spinning(true)
        .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);

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

    load_updates(
        content.clone(),
        init_spinner,
        status_lbl.clone(),
        busy.clone(),
        update_all_btn.clone(),
        reboot_btn.clone(),
    );

    // ── 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 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");
        // 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(30, 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);
                    status_d.set_label("Checking for updates…");
                    busy_d.set_spinning(true);
                    busy_d.set_visible(true);
                    let sp = Spinner::builder()
                        .spinning(true)
                        .halign(Align::Center)
                        .margin_top(48)
                        .build();
                    content_d.append(&sp);
                    load_updates(
                        content_d.clone(),
                        sp,
                        status_d.clone(),
                        busy_d.clone(),
                        upd_all_d.clone(),
                        reboot_d.clone(),
                    );
                }
            }
            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 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>);
    let (tx, rx) = mpsc::channel::<CheckResult>();

    std::thread::spawn(move || {
        // Run all three checks: packages, flatpak, image
        let raw_packages = rakuos_updates::check_packages_script();
        // Enrich package entries with AppStream display metadata (same source as installed page)
        let appstream = rakuos_appstream::get_appstream();
        // Build pkg_name → (icon_path, icon_url, display_name). Prefer entries with
        // a local icon and a friendly title, regardless of whether it came from
        // native AppStream or borrowed Flatpak metadata.
        let mut pkg_meta: std::collections::HashMap<String, (String, String, String)> =
            std::collections::HashMap::new();
        for a in appstream.values().filter(|a| !a.package_name.is_empty()) {
            let friendly_name = if !a.name.is_empty() && a.name != a.package_name {
                a.name.clone()
            } else {
                String::new()
            };
            let entry = pkg_meta.entry(a.package_name.clone()).or_insert_with(|| {
                (a.icon_path.clone(), a.icon_url.clone(), friendly_name.clone())
            });
            // Upgrade to better display data if the stored entry has nothing
            if entry.0.is_empty() && entry.1.is_empty() {
                entry.0 = a.icon_path.clone();
                entry.1 = a.icon_url.clone();
            } else if entry.0.is_empty() && !a.icon_path.is_empty() {
                entry.0 = a.icon_path.clone();
            }
            if entry.2.is_empty() && !friendly_name.is_empty() {
                entry.2 = friendly_name;
            }
        }
        let packages: Vec<serde_json::Value> = raw_packages.into_iter().map(|mut v| {
            if let Some(name) = v["name"].as_str().map(|s| s.to_string()) {
                if let Some((ip, iu, display_name)) = pkg_meta.get(&name) {
                    if !ip.is_empty() { v["icon_path"] = ip.clone().into(); }
                    if !iu.is_empty() { v["icon_url"]  = iu.clone().into(); }
                    if !display_name.is_empty() {
                        v["display_name"] = display_name.clone().into();
                    }
                }
            }
            v
        }).collect();
        drop(appstream);
        let flatpaks = rakuos_flatpak::get_all_updates();
        let webapps = rakuos_webapps::get_updates();
        let (image_available, image_json) = rakuos_updates::check_image_script();
        let reboot_required = 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(),
            ..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();
        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(),
            "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));
    });

    glib::timeout_add_local(Duration::from_millis(80), move || {
        match rx.try_recv() {
            Ok((system, packages, flatpaks, webapps)) => {
                spinner.set_spinning(false);
                spinner.set_visible(false);
                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();

                // 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 {
                    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);
                } else {
                    let count = (if has_image { 1 } else { 0 }) + packages.len() + flatpaks.len() + webapps.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()));
                    }

                    // 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 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 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 fps    = all_fps.clone();
                        let webapps = all_webapps.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("switch", &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);
                            }
                            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 }
                            }
                        });
                    });
                }
                glib::ControlFlow::Break
            }
            Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
            Err(_) => 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 update_btn = Button::builder()
        .label("Update")
        .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 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_visible(true);

        let repo_t = repo.clone();
        let tag_t  = tag.clone();
        let (tx, rx) = mpsc::channel::<bool>();
        std::thread::spawn(move || {
            let mut ok = false;
            for line in rakuos_updates::upgrade_image_stream("switch", &repo_t, &tag_t) {
                if let Some(code) = line.strip_prefix("__done__") {
                    ok = code.trim() == "0";
                }
            }
            let _ = tx.send(ok);
        });

        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 || {
            prog_r.pulse();
            match rx.try_recv() {
                Ok(ok) => {
                    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);
                    }
                    glib::ControlFlow::Break
                }
                Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
                Err(_) => {
                    state_r.stop();
                    prog_r.set_visible(false);
                    done_r.set_label("Update failed");
                    done_r.set_visible(true);
                    btn_r.set_label("Retry");
                    btn_r.set_sensitive(true);
                    glib::ControlFlow::Break
                }
            }
        });
    });

    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()
}

// ── 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 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 {
                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()
}
