// pages/gnome_extensions.rs — Browse and manage GNOME Shell extensions

use gtk4::prelude::*;
use gtk4::{
    glib, Align, Box as GBox, Button, ComboBoxText, Entry, FlowBox, FlowBoxChild, Label,
    ListBox, Orientation, ScrolledWindow, SelectionMode, Spinner, Stack, StackSwitcher, Widget,
};
use libadwaita::prelude::MessageDialogExt;
use libadwaita::{Carousel, CarouselIndicatorDots, HeaderBar, NavigationPage, NavigationView, ToolbarView};
use serde::Deserialize;
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::rc::Rc;
use std::sync::{mpsc, Arc};
use std::time::Duration;

const EGO_BASE: &str = "https://extensions.gnome.org";
const EXTENSIONS_PER_PAGE: usize = 24;

#[derive(Debug, Clone, Default, Deserialize)]
struct ExtensionSummary {
    uuid: String,
    name: String,
    #[serde(default)]
    creator: String,
    #[serde(default)]
    description: String,
    #[serde(default)]
    downloads: u64,
    #[serde(default)]
    url: Option<String>,
    #[serde(default)]
    link: Option<String>,
    #[serde(default)]
    screenshot: Option<String>,
    #[serde(default)]
    icon: Option<String>,
    #[serde(default)]
    shell_version_map: HashMap<String, ExtensionVersion>,
}

#[derive(Debug, Clone, Default, Deserialize)]
struct ExtensionVersion {
    #[serde(default)]
    version: u32,
}

#[derive(Debug, Clone, Default, Deserialize)]
struct ExtensionInfo {
    #[serde(default)]
    download_url: String,
    #[serde(default)]
    icon: Option<String>,
}

#[derive(Debug, Deserialize)]
struct ExtensionQuery {
    extensions: Vec<ExtensionSummary>,
    #[serde(default)]
    numpages: u32,
    #[serde(default)]
    total: u64,
}

#[derive(Debug, Clone, Default)]
struct InstalledExtension {
    uuid: String,
    name: String,
    icon_url: Option<String>,
    location: ExtensionLocation,
    enabled: bool,
    has_prefs: bool,
    package_name: String,
    removable: bool,
    settings_launcher: Option<String>,
    settings_schema: String,
}

#[derive(Debug, Clone, Default)]
struct ShellExtensionInfo {
    enabled: Option<bool>,
    has_prefs: bool,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum ExtensionLocation {
    #[default]
    User,
    System,
}

pub fn build(nav: Arc<NavigationView>) -> Widget {
    let scroll = ScrolledWindow::builder()
        .hscrollbar_policy(gtk4::PolicyType::Never)
        .vscrollbar_policy(gtk4::PolicyType::Automatic)
        .vexpand(true)
        .build();

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

    main.append(&Label::builder()
        .label("GNOME Extensions")
        .halign(Align::Start)
        .css_classes(vec!["title-1".to_string()])
        .build());
    main.append(&Label::builder()
        .label("Browse extensions.gnome.org and manage installed GNOME Shell extensions.")
        .halign(Align::Start)
        .wrap(true)
        .css_classes(vec!["body".to_string(), "dim-label".to_string()])
        .build());

    let shell_version = detect_shell_version();
    main.append(&Label::builder()
        .label(&format!("Shell version: {}", shell_version.as_deref().unwrap_or("unknown")))
        .halign(Align::Start)
        .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
        .build());

    let tabs = Stack::builder()
        .hexpand(true)
        .vexpand(false)
        .transition_type(gtk4::StackTransitionType::Crossfade)
        .build();
    let switcher = StackSwitcher::builder()
        .stack(&tabs)
        .halign(Align::Start)
        .build();
    main.append(&switcher);

    let browse_page = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(16)
        .build();
    let installed_page = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(16)
        .build();

    let search_row = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(8)
        .build();
    let search_entry = Entry::builder()
        .placeholder_text("Search GNOME extensions")
        .hexpand(true)
        .build();
    let search_btn = Button::builder()
        .label("Search")
        .css_classes(vec!["suggested-action".to_string()])
        .build();
    search_row.append(&search_entry);
    search_row.append(&search_btn);
    browse_page.append(&search_row);

    installed_page.append(&Label::builder()
        .label("Installed Extensions")
        .halign(Align::Start)
        .css_classes(vec!["heading".to_string()])
        .build());
    let installed_list = ListBox::builder()
        .selection_mode(SelectionMode::None)
        .css_classes(vec!["boxed-list".to_string()])
        .build();
    installed_page.append(&installed_list);

    browse_page.append(&Label::builder()
        .label("Browse")
        .halign(Align::Start)
        .css_classes(vec!["heading".to_string()])
        .build());
    let browse_flow = build_app_grid_flow();
    browse_page.append(&browse_flow);

    let spinner = Spinner::builder()
        .spinning(true)
        .halign(Align::Center)
        .build();
    browse_page.append(&spinner);

    let page_row = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(8)
        .halign(Align::Center)
        .build();
    let prev_btn = Button::builder().label("Previous").sensitive(false).build();
    let page_label = Label::builder()
        .label("Page 1")
        .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
        .build();
    let next_btn = Button::builder().label("Next").sensitive(false).build();
    page_row.append(&prev_btn);
    page_row.append(&page_label);
    page_row.append(&next_btn);
    browse_page.append(&page_row);

    tabs.add_titled(&browse_page, Some("browse"), "Browse");
    tabs.add_titled(&installed_page, Some("installed"), "Installed");
    tabs.set_visible_child_name("browse");
    main.append(&tabs);

    let current_page = Rc::new(Cell::new(1u32));
    let current_query = Rc::new(RefCell::new(String::new()));

    refresh_installed(&installed_list, Arc::clone(&nav));
    search_extensions(
        &browse_flow,
        &installed_list,
        spinner.clone(),
        Arc::clone(&nav),
        "",
        shell_version.clone(),
        1,
        &page_label,
        &prev_btn,
        &next_btn,
    );

    {
        let browse_flow = browse_flow.clone();
        let spinner = spinner.clone();
        let nav = Arc::clone(&nav);
        let shell_version = shell_version.clone();
        let search_entry = search_entry.clone();
        let current_page = Rc::clone(&current_page);
        let current_query = Rc::clone(&current_query);
        let page_label = page_label.clone();
        let prev_btn = prev_btn.clone();
        let next_btn = next_btn.clone();
        let installed_list = installed_list.clone();
        search_btn.connect_clicked(move |_| {
            current_page.set(1);
            current_query.replace(search_entry.text().to_string());
            search_extensions(
                &browse_flow,
                &installed_list,
                spinner.clone(),
                Arc::clone(&nav),
                &current_query.borrow(),
                shell_version.clone(),
                1,
                &page_label,
                &prev_btn,
                &next_btn,
            );
        });
    }
    {
        let browse_flow = browse_flow.clone();
        let spinner = spinner.clone();
        let nav = Arc::clone(&nav);
        let shell_version = shell_version.clone();
        let current_page = Rc::clone(&current_page);
        let current_query = Rc::clone(&current_query);
        let page_label = page_label.clone();
        let prev_btn = prev_btn.clone();
        let next_btn = next_btn.clone();
        let installed_list = installed_list.clone();
        search_entry.connect_activate(move |entry| {
            current_page.set(1);
            current_query.replace(entry.text().to_string());
            search_extensions(
                &browse_flow,
                &installed_list,
                spinner.clone(),
                Arc::clone(&nav),
                &current_query.borrow(),
                shell_version.clone(),
                1,
                &page_label,
                &prev_btn,
                &next_btn,
            );
        });
    }
    {
        let browse_flow = browse_flow.clone();
        let spinner = spinner.clone();
        let nav = Arc::clone(&nav);
        let shell_version = shell_version.clone();
        let current_page = Rc::clone(&current_page);
        let current_query = Rc::clone(&current_query);
        let page_label = page_label.clone();
        let prev_btn_c = prev_btn.clone();
        let next_btn = next_btn.clone();
        let installed_list = installed_list.clone();
        prev_btn.connect_clicked(move |_| {
            let page = current_page.get().saturating_sub(1).max(1);
            current_page.set(page);
            search_extensions(
                &browse_flow,
                &installed_list,
                spinner.clone(),
                Arc::clone(&nav),
                &current_query.borrow(),
                shell_version.clone(),
                page,
                &page_label,
                &prev_btn_c,
                &next_btn,
            );
        });
    }
    {
        let browse_flow = browse_flow.clone();
        let spinner = spinner.clone();
        let nav = Arc::clone(&nav);
        let shell_version = shell_version.clone();
        let current_page = Rc::clone(&current_page);
        let current_query = Rc::clone(&current_query);
        let page_label = page_label.clone();
        let prev_btn = prev_btn.clone();
        let next_btn_c = next_btn.clone();
        let installed_list = installed_list.clone();
        next_btn.connect_clicked(move |_| {
            let page = current_page.get().saturating_add(1);
            current_page.set(page);
            search_extensions(
                &browse_flow,
                &installed_list,
                spinner.clone(),
                Arc::clone(&nav),
                &current_query.borrow(),
                shell_version.clone(),
                page,
                &page_label,
                &prev_btn,
                &next_btn_c,
            );
        });
    }

    scroll.upcast()
}

fn build_app_grid_flow() -> FlowBox {
    FlowBox::builder()
        .selection_mode(SelectionMode::None)
        .min_children_per_line(3)
        .max_children_per_line(8)
        .column_spacing(12)
        .row_spacing(12)
        .homogeneous(true)
        .build()
}

fn refresh_installed(list: &ListBox, nav: Arc<NavigationView>) {
    clear_list(list);
    let list_c = list.clone();
    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(list_installed_extensions());
    });
    glib::timeout_add_local(Duration::from_millis(80), move || {
        match rx.try_recv() {
            Ok(items) => {
                clear_list(&list_c);
                if items.is_empty() {
                    list_c.append(&build_empty_state_row("No extensions installed", "Use Browse to install one."));
                } else {
                    for item in items {
                        list_c.append(&build_installed_card(item, list_c.clone(), Arc::clone(&nav)));
                    }
                }
                glib::ControlFlow::Break
            }
            Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
            Err(_) => glib::ControlFlow::Break,
        }
    });
}

fn refresh_installed_after_delay(list: ListBox, nav: Arc<NavigationView>) {
    glib::timeout_add_local_once(Duration::from_millis(900), move || {
        refresh_installed(&list, Arc::clone(&nav));
    });
}

fn search_extensions(
    flow: &FlowBox,
    installed_list: &ListBox,
    spinner: Spinner,
    nav: Arc<NavigationView>,
    query: &str,
    shell_version: Option<String>,
    page: u32,
    page_label: &Label,
    prev_btn: &Button,
    next_btn: &Button,
) {
    clear_flow(flow);
    spinner.set_visible(true);
    spinner.set_spinning(true);
    prev_btn.set_sensitive(false);
    next_btn.set_sensitive(false);

    let flow_c = flow.clone();
    let installed_list_c = installed_list.clone();
    let spinner_c = spinner.clone();
    let page_label_c = page_label.clone();
    let prev_btn_c = prev_btn.clone();
    let next_btn_c = next_btn.clone();
    let query = query.to_string();
    let shell_version_for_cards = shell_version.clone();
    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(query_compatible_extensions(&query, shell_version.as_deref(), page));
    });
    glib::timeout_add_local(Duration::from_millis(80), move || {
        match rx.try_recv() {
            Ok(Ok(result)) => {
                spinner_c.set_spinning(false);
                spinner_c.set_visible(false);
                clear_flow(&flow_c);
                let pages = result.numpages.max(1);
                page_label_c.set_label(&format!("Page {} of {} - {} extensions", page, pages, result.total));
                prev_btn_c.set_sensitive(page > 1);
                next_btn_c.set_sensitive(page < pages);
                if result.extensions.is_empty() {
                    flow_c.insert(&empty_card("No extensions found", "Try another search term."), -1);
                } else {
                    for item in result.extensions {
                        let child = FlowBoxChild::new();
                        child.set_size_request(120, -1);
                        child.set_halign(Align::Start);
                        child.set_valign(Align::Start);
                        child.set_child(Some(&build_browse_card(
                            item,
                            Arc::clone(&nav),
                            installed_list_c.clone(),
                            shell_version_for_cards.clone(),
                        )));
                        child.set_focusable(false);
                        flow_c.insert(&child, -1);
                    }
                }
                glib::ControlFlow::Break
            }
            Ok(Err(err)) => {
                spinner_c.set_spinning(false);
                spinner_c.set_visible(false);
                clear_flow(&flow_c);
                page_label_c.set_label("Could not load extensions");
                flow_c.insert(&empty_card("Could not load extensions", &err), -1);
                glib::ControlFlow::Break
            }
            Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
            Err(_) => glib::ControlFlow::Break,
        }
    });
}

fn build_installed_card(ext: InstalledExtension, list: ListBox, nav: Arc<NavigationView>) -> Widget {
    let row = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(12)
        .margin_top(10)
        .margin_bottom(10)
        .margin_start(12)
        .margin_end(12)
        .build();
    row.append(&extension_icon(ext.icon_url.as_deref(), 48, &ext.name));

    let info = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(3)
        .valign(Align::Center)
        .hexpand(true)
        .build();
    info.append(&Label::builder()
        .label(&ext.name)
        .halign(Align::Start)
        .wrap(true)
        .css_classes(vec!["heading".to_string()])
        .build());
    info.append(&Label::builder()
        .label(&format!("{} · {}", extension_location_label(&ext), if ext.enabled { "Enabled" } else { "Disabled" }))
        .halign(Align::Start)
        .wrap(true)
        .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
        .build());
    row.append(&info);
    row.append(&Label::builder()
        .label("›")
        .valign(Align::Center)
        .css_classes(vec!["dim-label".to_string(), "title-3".to_string()])
        .build());

    let button = Button::new();
    button.set_hexpand(true);
    button.add_css_class("flat");
    button.set_child(Some(&row));
    {
        let ext = ext.clone();
        let list = list.clone();
        let nav = Arc::clone(&nav);
        button.connect_clicked(move |_| {
            push_installed_extension_detail(&nav, ext.clone(), list.clone());
        });
    }

    let list_row = gtk4::ListBoxRow::new();
    list_row.set_child(Some(&button));
    list_row.set_activatable(false);
    list_row.upcast()
}

fn build_browse_card(
    ext: ExtensionSummary,
    nav: Arc<NavigationView>,
    installed_list: ListBox,
    shell_version: Option<String>,
) -> Widget {
    let card = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(6)
        .width_request(120)
        .css_classes(vec!["card".to_string()])
        .halign(Align::Start)
        .valign(Align::Start)
        .hexpand(false)
        .vexpand(false)
        .margin_top(4)
        .margin_bottom(4)
        .build();

    let icon = extension_icon(ext.icon.as_deref(), 56, &ext.name);
    icon.set_halign(Align::Center);
    icon.set_margin_top(10);
    card.append(&icon);

    card.append(&Label::builder()
        .label(&ext.name)
        .halign(Align::Center)
        .wrap(false)
        .ellipsize(gtk4::pango::EllipsizeMode::End)
        .max_width_chars(14)
        .css_classes(vec!["caption-heading".to_string()])
        .margin_start(8)
        .margin_end(8)
        .margin_bottom(10)
        .build());

    let btn = Button::new();
    btn.set_width_request(120);
    btn.set_size_request(120, -1);
    btn.set_hexpand(false);
    btn.set_vexpand(false);
    btn.set_halign(Align::Start);
    btn.set_valign(Align::Start);
    btn.set_child(Some(&card));
    btn.add_css_class("flat");
    btn.connect_clicked(move |_| {
        push_extension_detail(&nav, ext.clone(), installed_list.clone(), shell_version.clone());
    });

    btn.upcast()
}

fn push_installed_extension_detail(nav: &Arc<NavigationView>, ext: InstalledExtension, installed_list: ListBox) {
    let toolbar = ToolbarView::new();
    toolbar.add_top_bar(&HeaderBar::new());

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

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

    let hero_row = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(20)
        .build();
    let icon = extension_icon(ext.icon_url.as_deref(), 96, &ext.name);
    icon.set_valign(Align::Start);
    hero_row.append(&icon);

    let meta_box = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(4)
        .hexpand(true)
        .build();
    meta_box.append(&Label::builder()
        .label(&ext.name)
        .halign(Align::Start)
        .wrap(true)
        .css_classes(vec!["title-1".to_string()])
        .build());
    let status_label = Label::builder()
        .label(&format!(
            "{} · {}",
            extension_location_label(&ext),
            if ext.enabled { "Enabled" } else { "Disabled" }
        ))
        .halign(Align::Start)
        .wrap(true)
        .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
        .build();
    meta_box.append(&status_label);
    meta_box.append(&Label::builder()
        .label(&ext.uuid)
        .halign(Align::Start)
        .wrap(true)
        .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
        .build());

    if ext.location == ExtensionLocation::System && !ext.removable {
        meta_box.append(&Label::builder()
            .label("Managed by the base OS or a package not in packages.list; remove is disabled.")
            .halign(Align::Start)
            .wrap(true)
            .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
            .build());
    }

    let btn_col = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(8)
        .valign(Align::Start)
        .build();
    let toggle_btn = Button::builder()
        .label(if ext.enabled { "Disable" } else { "Enable" })
        .css_classes(vec!["pill".to_string()])
        .build();
    let prefs_btn = Button::builder()
        .label("Settings")
        .sensitive(ext.has_prefs || ext.settings_launcher.is_some() || !ext.settings_schema.is_empty())
        .css_classes(vec!["pill".to_string()])
        .build();
    let remove_btn = Button::builder()
        .label("Remove")
        .sensitive(ext.removable)
        .css_classes(vec!["destructive-action".to_string(), "pill".to_string()])
        .build();
    btn_col.append(&toggle_btn);
    btn_col.append(&prefs_btn);
    btn_col.append(&remove_btn);

    hero_row.append(&meta_box);
    hero_row.append(&btn_col);
    main.append(&hero_row);

    main.append(&Label::builder()
        .label("Management")
        .halign(Align::Start)
        .css_classes(vec!["title-3".to_string()])
        .build());
    main.append(&Label::builder()
        .label("Enable or disable the extension immediately. Settings open the extension's own preferences window when it provides one; otherwise RakuOS Software renders its GSettings schema here.")
        .halign(Align::Start)
        .wrap(true)
        .css_classes(vec!["dim-label".to_string()])
        .build());

    {
        let uuid = ext.uuid.clone();
        let ext_for_state = ext.clone();
        let list = installed_list.clone();
        let nav = Arc::clone(nav);
        let status_label = status_label.clone();
        let toggle_ref = toggle_btn.clone();
        toggle_btn.connect_clicked(move |btn| {
            btn.set_sensitive(false);
            let enable = btn.label().as_deref() == Some("Enable");
            let uuid = uuid.clone();
            let (tx, rx) = mpsc::channel();
            std::thread::spawn(move || {
                let ok = set_extension_enabled(&uuid, enable).is_ok();
                let _ = tx.send((ok, enable));
            });

            let list = list.clone();
            let nav = Arc::clone(&nav);
            let status_label = status_label.clone();
            let toggle_ref = toggle_ref.clone();
            let ext_for_state = ext_for_state.clone();
            glib::timeout_add_local(Duration::from_millis(80), move || {
                match rx.try_recv() {
                    Ok((ok, enabled)) => {
                        if ok {
                            toggle_ref.set_label(if enabled { "Disable" } else { "Enable" });
                            status_label.set_label(&format!(
                                "{} · {}",
                                extension_location_label(&ext_for_state),
                                if enabled { "Enabled" } else { "Disabled" }
                            ));
                            refresh_installed(&list, Arc::clone(&nav));
                        } else {
                            toggle_ref.set_label("Retry");
                        }
                        toggle_ref.set_sensitive(true);
                        glib::ControlFlow::Break
                    }
                    Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
                    Err(_) => glib::ControlFlow::Break,
                }
            });
        });
    }

    {
        let ext = ext.clone();
        let nav = Arc::clone(nav);
        prefs_btn.connect_clicked(move |_| {
            if launch_extension_prefs(&ext.uuid).is_ok() {
                return;
            }
            if let Some(launcher) = &ext.settings_launcher {
                let _ = Command::new("sh").arg("-c").arg(launcher).spawn();
            } else {
                push_extension_settings(&nav, &ext);
            }
        });
    }

    {
        let ext = ext.clone();
        let list = installed_list.clone();
        let nav = Arc::clone(nav);
        remove_btn.connect_clicked(move |btn| {
            btn.set_sensitive(false);
            btn.set_label("Removing...");
            let ext = ext.clone();
            let (tx, rx) = mpsc::channel();
            std::thread::spawn(move || {
                let ok = uninstall_extension(&ext).is_ok();
                let _ = tx.send(ok);
            });

            let list = list.clone();
            let nav = Arc::clone(&nav);
            let btn = btn.clone();
            glib::timeout_add_local(Duration::from_millis(80), move || {
                match rx.try_recv() {
                    Ok(ok) => {
                        if ok {
                            refresh_installed(&list, Arc::clone(&nav));
                            nav.pop();
                        } else {
                            btn.set_label("Retry");
                            btn.set_sensitive(true);
                        }
                        glib::ControlFlow::Break
                    }
                    Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
                    Err(_) => glib::ControlFlow::Break,
                }
            });
        });
    }

    let content = toolbar;
    content.set_content(Some(&scroll));
    let nav_page = NavigationPage::builder()
        .title(&ext.name)
        .child(&content)
        .build();
    nav.push(&nav_page);
}

fn push_extension_detail(
    nav: &Arc<NavigationView>,
    ext: ExtensionSummary,
    installed_list: ListBox,
    shell_version: Option<String>,
) {
    let toolbar = ToolbarView::new();
    toolbar.add_top_bar(&HeaderBar::new());

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

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

    let hero_row = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(20)
        .build();

    let icon = extension_icon(ext.icon.as_deref(), 96, &ext.name);
    icon.set_valign(Align::Start);
    hero_row.append(&icon);

    let meta_box = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(4)
        .hexpand(true)
        .build();
    meta_box.append(&Label::builder()
        .label(&ext.name)
        .halign(Align::Start)
        .wrap(true)
        .css_classes(vec!["title-1".to_string()])
        .build());
    meta_box.append(&Label::builder()
        .label(&format!("by {} - {} downloads", blank(&ext.creator, "unknown"), ext.downloads))
        .halign(Align::Start)
        .wrap(true)
        .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
        .build());
    meta_box.append(&Label::builder()
        .label(blank(&ext.description, "No description was provided for this extension."))
        .halign(Align::Start)
        .wrap(true)
        .build());

    let btn_col = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(8)
        .valign(Align::Start)
        .build();
    let version_combo = ComboBoxText::new();
    populate_version_combo(&version_combo, &ext, shell_version.as_deref());
    btn_col.append(&version_combo);
    let install_btn = Button::builder()
        .label("Install")
        .css_classes(vec!["suggested-action".to_string(), "pill".to_string()])
        .build();
    let website_btn = Button::builder()
        .label("Website")
        .css_classes(vec!["pill".to_string()])
        .build();
    btn_col.append(&install_btn);
    btn_col.append(&website_btn);

    hero_row.append(&meta_box);
    hero_row.append(&btn_col);
    main.append(&hero_row);

    if let Some(screenshot) = ext.screenshot.as_deref().filter(|s| !s.is_empty()) {
        main.append(&Label::builder()
            .label("Screenshots")
            .halign(Align::Start)
            .css_classes(vec!["title-3".to_string()])
            .build());
        let carousel = Carousel::builder()
            .height_request(300)
            .hexpand(true)
            .build();
        let dots = CarouselIndicatorDots::builder()
            .carousel(&carousel)
            .halign(Align::Center)
            .build();
        load_extension_screenshots(&carousel, &[absolute_ego_url(screenshot)]);
        main.append(&carousel);
        main.append(&dots);
    }

    let url = extension_url(&ext);
    website_btn.connect_clicked(move |_| {
        let _ = Command::new("xdg-open").arg(&url).spawn();
    });
    {
        let uuid = ext.uuid.clone();
        let installed_list = installed_list.clone();
        let nav = Arc::clone(nav);
        install_btn.connect_clicked(move |btn| {
            let uuid = uuid.clone();
            let btn = btn.clone();
            let installed_list = installed_list.clone();
            let nav = Arc::clone(&nav);
            let selected_shell = version_combo
                .active_id()
                .map(|id| id.to_string())
                .or_else(|| shell_version.clone());
            let current_shell = detect_shell_version();
            let unsupported_version = selected_shell
                .as_deref()
                .zip(current_shell.as_deref())
                .is_some_and(|(selected, current)| selected != current);

            if unsupported_version {
                show_unsupported_extension_prompt(
                    btn,
                    uuid,
                    selected_shell,
                    current_shell.unwrap_or_else(|| "unknown".to_string()),
                    installed_list,
                    nav,
                );
            } else {
                start_extension_install(btn, uuid, selected_shell, installed_list, nav);
            }
        });
    }

    let content = toolbar;
    content.set_content(Some(&scroll));
    let nav_page = NavigationPage::builder()
        .title(&ext.name)
        .child(&content)
        .build();
    nav.push(&nav_page);
}

fn show_unsupported_extension_prompt(
    btn: Button,
    uuid: String,
    selected_shell: Option<String>,
    current_shell: String,
    installed_list: ListBox,
    nav: Arc<NavigationView>,
) {
    let selected_label = selected_shell.as_deref().unwrap_or("unknown");
    let body = format!(
        "This extension version targets GNOME Shell {}, but this system is running GNOME Shell {}. \
Installing it requires enabling unsupported GNOME Shell extensions. The extension may break or behave unexpectedly.",
        selected_label,
        current_shell
    );

    if let Some(root) = btn.root() {
        if let Ok(win) = root.downcast::<gtk4::Window>() {
            present_unsupported_extension_dialog(
                libadwaita::MessageDialog::builder()
                    .transient_for(&win)
                    .heading("Install unsupported extension?")
                    .body(&body)
                    .build(),
                btn,
                uuid,
                selected_shell,
                installed_list,
                nav,
            );
            return;
        }
    }

    present_unsupported_extension_dialog(
        libadwaita::MessageDialog::builder()
            .heading("Install unsupported extension?")
            .body(&body)
            .build(),
        btn,
        uuid,
        selected_shell,
        installed_list,
        nav,
    );
}

fn present_unsupported_extension_dialog(
    dialog: libadwaita::MessageDialog,
    btn: Button,
    uuid: String,
    selected_shell: Option<String>,
    installed_list: ListBox,
    nav: Arc<NavigationView>,
) {
    dialog.add_response("cancel", "Cancel");
    dialog.add_response("continue", "Continue");
    dialog.set_response_appearance("continue", libadwaita::ResponseAppearance::Suggested);
    dialog.set_default_response(Some("cancel"));
    dialog.set_close_response("cancel");

    dialog.connect_response(None, move |_dialog, response| {
        if response != "continue" {
            return;
        }
        if enable_unsupported_extension_validation_override() {
            start_extension_install(
                btn.clone(),
                uuid.clone(),
                selected_shell.clone(),
                installed_list.clone(),
                Arc::clone(&nav),
            );
        } else {
            btn.set_label("Failed");
            btn.set_sensitive(true);
        }
    });
    dialog.present();
}

fn start_extension_install(
    btn: Button,
    uuid: String,
    selected_shell: Option<String>,
    installed_list: ListBox,
    nav: Arc<NavigationView>,
) {
    btn.set_sensitive(false);
    btn.set_label("Installing...");
    let btn_c = btn.clone();
    let installed_list_c = installed_list.clone();
    let nav_c = Arc::clone(&nav);
    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        let ok = install_extension_with_shell(&uuid, selected_shell.as_deref()).is_ok();
        let _ = tx.send(ok);
    });
    glib::timeout_add_local(Duration::from_millis(80), move || {
        match rx.try_recv() {
            Ok(ok) => {
                btn_c.set_label(if ok { "Installed" } else { "Failed" });
                btn_c.set_sensitive(!ok);
                if ok {
                    refresh_installed(&installed_list_c, Arc::clone(&nav_c));
                    refresh_installed_after_delay(installed_list_c.clone(), Arc::clone(&nav_c));
                }
                glib::ControlFlow::Break
            }
            Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
            Err(_) => glib::ControlFlow::Break,
        }
    });
}

fn push_extension_settings(nav: &Arc<NavigationView>, ext: &InstalledExtension) {
    let toolbar = ToolbarView::new();
    toolbar.add_top_bar(&HeaderBar::new());

    let scroll = ScrolledWindow::builder()
        .hscrollbar_policy(gtk4::PolicyType::Never)
        .vscrollbar_policy(gtk4::PolicyType::Automatic)
        .vexpand(true)
        .build();
    let main = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(12)
        .margin_top(24)
        .margin_bottom(32)
        .margin_start(24)
        .margin_end(24)
        .build();
    scroll.set_child(Some(&main));

    main.append(&Label::builder()
        .label(&format!("{} Settings", ext.name))
        .halign(Align::Start)
        .wrap(true)
        .css_classes(vec!["title-1".to_string()])
        .build());

    if ext.settings_schema.is_empty() {
        main.append(&Label::builder()
            .label("This extension does not advertise a preferences window or a GSettings schema.")
            .halign(Align::Start)
            .wrap(true)
            .css_classes(vec!["dim-label".to_string()])
            .build());
    } else {
        main.append(&Label::builder()
            .label(&format!("Schema: {}", ext.settings_schema))
            .halign(Align::Start)
            .wrap(true)
            .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
            .build());
        for (key, value) in read_gsettings_values(&ext.settings_schema) {
            main.append(&settings_row(&ext.settings_schema, &key, &value));
        }
    }

    let content = toolbar;
    content.set_content(Some(&scroll));
    let nav_page = NavigationPage::builder()
        .title(&format!("{} Settings", ext.name))
        .child(&content)
        .build();
    nav.push(&nav_page);
}

fn settings_row(schema: &str, key: &str, value: &str) -> Widget {
    let row = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(6)
        .margin_top(10)
        .margin_bottom(10)
        .margin_start(12)
        .margin_end(12)
        .css_classes(vec!["card".to_string()])
        .build();
    row.append(&Label::builder()
        .label(key)
        .halign(Align::Start)
        .css_classes(vec!["heading".to_string()])
        .build());
    let edit_row = GBox::builder()
        .orientation(Orientation::Horizontal)
        .spacing(8)
        .build();
    let entry = Entry::builder()
        .text(value)
        .hexpand(true)
        .build();
    let save_btn = Button::builder()
        .label("Save")
        .css_classes(vec!["suggested-action".to_string()])
        .build();
    edit_row.append(&entry);
    edit_row.append(&save_btn);
    row.append(&edit_row);
    {
        let schema = schema.to_string();
        let key = key.to_string();
        let entry = entry.clone();
        save_btn.connect_clicked(move |btn| {
            btn.set_sensitive(false);
            let ok = Command::new("gsettings")
                .args(["set", &schema, &key, &entry.text()])
                .status()
                .map(|status| status.success())
                .unwrap_or(false);
            btn.set_label(if ok { "Saved" } else { "Failed" });
            btn.set_sensitive(true);
        });
    }
    row.upcast()
}

fn populate_version_combo(combo: &ComboBoxText, ext: &ExtensionSummary, current_shell: Option<&str>) {
    let mut shells = ext
        .shell_version_map
        .keys()
        .filter(|shell| is_supported_shell_version(shell))
        .cloned()
        .collect::<Vec<_>>();
    shells.sort_by(|a, b| shell_sort_key(b).cmp(&shell_sort_key(a)));
    if shells.is_empty() {
        if let Some(shell) = current_shell {
            combo.append(Some(shell), &format!("GNOME {}", shell));
            combo.set_active_id(Some(shell));
        }
        combo.set_visible(current_shell.is_some());
        return;
    }
    for shell in &shells {
        let version = ext.shell_version_map.get(shell).map(|v| v.version).unwrap_or_default();
        let label = if version > 0 {
            format!("GNOME {} - extension v{}", shell, version)
        } else {
            format!("GNOME {}", shell)
        };
        combo.append(Some(shell), &label);
    }
    let active = current_shell
        .filter(|shell| ext.shell_version_map.contains_key(*shell))
        .or_else(|| shells.first().map(String::as_str));
    if let Some(active) = active {
        combo.set_active_id(Some(active));
    }
}

fn shell_sort_key(shell: &str) -> Vec<u32> {
    shell
        .split('.')
        .map(|part| part.parse::<u32>().unwrap_or_default())
        .collect()
}

fn has_supported_shell_version(ext: &ExtensionSummary) -> bool {
    ext.shell_version_map
        .keys()
        .any(|shell| is_supported_shell_version(shell))
}

fn is_supported_shell_version(shell: &str) -> bool {
    shell
        .split('.')
        .next()
        .and_then(|major| major.parse::<u32>().ok())
        .is_some_and(|major| major >= 40)
}

fn extension_icon(icon_url: Option<&str>, size: i32, name: &str) -> Widget {
    if let Some(icon_url) = icon_url.filter(|url| !url.is_empty()) {
        let picture = gtk4::Picture::builder()
            .width_request(size)
            .height_request(size)
            .halign(Align::Center)
            .valign(Align::Center)
            .can_shrink(true)
            .tooltip_text(name)
            .build();
        picture.set_size_request(size, size);
        load_remote_picture_sized(&picture, &absolute_ego_url(icon_url), size);
        picture.upcast()
    } else {
        let picture = gtk4::Picture::builder()
            .width_request(size)
            .height_request(size)
            .halign(Align::Center)
            .valign(Align::Center)
            .can_shrink(true)
            .tooltip_text(name)
            .build();
        picture.set_size_request(size, size);
        load_remote_picture_sized(&picture, &format!("{}/static/images/plugin.png", EGO_BASE), size);
        picture.upcast()
    }
}

fn load_extension_screenshots(carousel: &Carousel, urls: &[String]) {
    for url in urls.iter().take(4) {
        let picture = gtk4::Picture::builder()
            .hexpand(true)
            .can_shrink(true)
            .height_request(280)
            .build();

        load_remote_picture(&picture, url);
        carousel.append(&picture);
    }
}

fn load_remote_picture(picture: &gtk4::Picture, url: &str) {
    load_remote_picture_inner(picture, url, None);
}

fn load_remote_picture_sized(picture: &gtk4::Picture, url: &str, size: i32) {
    load_remote_picture_inner(picture, url, Some(size));
}

fn load_remote_picture_inner(picture: &gtk4::Picture, url: &str, max_size: Option<i32>) {
    let url_c = url.to_string();
    let pic_c = picture.clone();
    let (tx, rx) = mpsc::channel::<Vec<u8>>();
    std::thread::spawn(move || {
        if let Ok(out) = Command::new("curl")
            .args(["-sL", "--max-time", "15", &url_c])
            .output()
        {
            if out.status.success() {
                let _ = tx.send(out.stdout);
            }
        }
    });

    glib::timeout_add_local(Duration::from_millis(80), move || {
        match rx.try_recv() {
            Ok(bytes) => {
                let loader = gtk4::gdk_pixbuf::PixbufLoader::new();
                if loader.write(&bytes).is_ok() {
                    let _ = loader.close();
                    if let Some(pb) = loader.pixbuf() {
                        let scaled = max_size
                            .and_then(|size| {
                                let width = pb.width().max(1);
                                let height = pb.height().max(1);
                                let ratio = (size as f64 / width as f64)
                                    .min(size as f64 / height as f64);
                                let new_width = (width as f64 * ratio).round().max(1.0) as i32;
                                let new_height = (height as f64 * ratio).round().max(1.0) as i32;
                                pb.scale_simple(new_width, new_height, gtk4::gdk_pixbuf::InterpType::Bilinear)
                            })
                            .unwrap_or(pb);
                        let texture = gtk4::gdk::Texture::for_pixbuf(&scaled);
                        pic_c.set_paintable(Some(&texture));
                    }
                }
                glib::ControlFlow::Break
            }
            Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
            Err(_) => glib::ControlFlow::Break,
        }
    });
}

fn card_box() -> GBox {
    GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(8)
        .width_request(320)
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .css_classes(vec!["card".to_string()])
        .build()
}

fn wrap_child(widget: Widget) -> Widget {
    let child = FlowBoxChild::new();
    child.set_hexpand(true);
    child.set_child(Some(&widget));
    child.upcast()
}

fn empty_card(title: &str, description: &str) -> Widget {
    let card = card_box();
    card.append(&Label::builder()
        .label(title)
        .halign(Align::Start)
        .css_classes(vec!["heading".to_string()])
        .build());
    card.append(&Label::builder()
        .label(description)
        .halign(Align::Start)
        .wrap(true)
        .css_classes(vec!["dim-label".to_string()])
        .build());
    wrap_child(card.upcast())
}

fn build_empty_state_row(title: &str, description: &str) -> Widget {
    let row = GBox::builder()
        .orientation(Orientation::Vertical)
        .spacing(4)
        .margin_top(12)
        .margin_bottom(12)
        .margin_start(12)
        .margin_end(12)
        .build();
    row.append(&Label::builder()
        .label(title)
        .halign(Align::Start)
        .css_classes(vec!["heading".to_string()])
        .build());
    row.append(&Label::builder()
        .label(description)
        .halign(Align::Start)
        .wrap(true)
        .css_classes(vec!["dim-label".to_string()])
        .build());
    let list_row = gtk4::ListBoxRow::new();
    list_row.set_child(Some(&row));
    list_row.set_activatable(false);
    list_row.upcast()
}

fn extension_location_label(ext: &InstalledExtension) -> String {
    match ext.location {
        ExtensionLocation::User => "User extension".to_string(),
        ExtensionLocation::System if ext.package_name.is_empty() => "System extension".to_string(),
        ExtensionLocation::System => format!("System extension from {}", ext.package_name),
    }
}

fn clear_flow(flow: &FlowBox) {
    while let Some(child) = flow.first_child() {
        flow.remove(&child);
    }
}

fn clear_list(list: &ListBox) {
    while let Some(child) = list.first_child() {
        list.remove(&child);
    }
}

fn query_extensions(query: &str, shell_version: Option<&str>, page: u32) -> Result<ExtensionQuery, String> {
    let search = query.trim();
    let mut url = format!(
        "{}/extension-query/?search={}&page={}",
        EGO_BASE,
        url_escape(search),
        page.max(1)
    );
    if let Some(shell) = shell_version {
        url.push_str("&shell_version=");
        url.push_str(&url_escape(shell));
    }
    let response: ExtensionQuery = 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())?;
    Ok(response)
}

fn query_compatible_extensions(query: &str, shell_version: Option<&str>, page: u32) -> Result<ExtensionQuery, String> {
    let target_page = page.max(1);
    let needed_end = target_page as usize * EXTENSIONS_PER_PAGE;
    let needed_start = needed_end.saturating_sub(EXTENSIONS_PER_PAGE);
    let mut compatible = Vec::new();
    let mut api_page = 1u32;

    loop {
        let result = query_extensions(query, shell_version, api_page)?;
        let api_pages = result.numpages.max(1);
        compatible.extend(result.extensions.into_iter().filter(has_supported_shell_version));
        if compatible.len() >= needed_end || api_page >= api_pages {
            let exhausted = api_page >= api_pages;
            let total = compatible.len() as u64;
            let pages = if exhausted {
                ((total as usize + EXTENSIONS_PER_PAGE.saturating_sub(1)) / EXTENSIONS_PER_PAGE)
                    .max(1) as u32
            } else {
                target_page.saturating_add(1)
            };
            let extensions = compatible
                .into_iter()
                .skip(needed_start)
                .take(EXTENSIONS_PER_PAGE)
                .collect::<Vec<_>>();

            return Ok(ExtensionQuery {
                extensions,
                numpages: pages,
                total,
            });
        }
        api_page = api_page.saturating_add(1);
    }
}

fn get_extension_info(uuid: &str, shell_version: Option<&str>) -> Result<ExtensionInfo, String> {
    let mut url = format!("{}/extension-info/?uuid={}", EGO_BASE, url_escape(uuid));
    if let Some(shell) = shell_version {
        url.push_str("&shell_version=");
        url.push_str(&url_escape(shell));
    }
    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 extension_icon_url_for_uuid(uuid: &str) -> Option<String> {
    get_extension_info(uuid, None)
        .ok()
        .and_then(|info| info.icon)
        .filter(|icon| !icon.is_empty())
}

fn enable_unsupported_extension_validation_override() -> bool {
    Command::new("gsettings")
        .args([
            "set",
            "org.gnome.shell",
            "disable-extension-version-validation",
            "true",
        ])
        .status()
        .map(|status| status.success())
        .unwrap_or(false)
}

fn install_extension_with_shell(uuid: &str, shell_version: Option<&str>) -> Result<(), String> {
    let current_shell = detect_shell_version();
    if shell_version.is_none() || shell_version == current_shell.as_deref() {
        if install_extension_via_shell(uuid).is_ok() {
            return Ok(());
        }
    }

    install_extension_from_zip(uuid, shell_version)
}

fn install_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_extension_from_zip(uuid: &str, shell_version: Option<&str>) -> Result<(), String> {
    let info = get_extension_info(uuid, shell_version)?;
    let download_url = if info.download_url.starts_with("http") {
        info.download_url
    } else {
        format!("{}{}", EGO_BASE, info.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_extension_dir(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 _ = run_gnome_extensions("enable", uuid);
    Ok(())
}

fn list_installed_extensions() -> Vec<InstalledExtension> {
    let enabled = command_lines(Command::new("gnome-extensions").arg("list").arg("--enabled"))
        .into_iter()
        .collect::<HashSet<_>>();
    let package_list = read_package_list();
    let mut items = Vec::new();

    let user_base = user_extensions_base();
    collect_extension_dirs(&user_base, ExtensionLocation::User, &enabled, &package_list, &mut items);
    collect_extension_dirs(
        Path::new("/usr/share/gnome-shell/extensions"),
        ExtensionLocation::System,
        &enabled,
        &package_list,
        &mut items,
    );
    items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
    items
}

fn collect_extension_dirs(
    base: &Path,
    location: ExtensionLocation,
    enabled: &HashSet<String>,
    package_list: &HashSet<String>,
    items: &mut Vec<InstalledExtension>,
) {
    let Ok(read_dir) = fs::read_dir(base) else { return; };
    for entry in read_dir.flatten() {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let uuid = entry.file_name().to_string_lossy().to_string();
        let metadata = read_extension_metadata(&path);
        let name = metadata
            .as_ref()
            .and_then(|json| json["name"].as_str().or_else(|| json["uuid"].as_str()))
            .map(|s| s.to_string())
            .unwrap_or_else(|| uuid.clone());
        let settings_schema = metadata
            .as_ref()
            .and_then(|json| json["settings-schema"].as_str())
            .unwrap_or_default()
            .to_string();
        let settings_launcher = find_settings_launcher(&path);
        let shell_info = get_shell_extension_info(&uuid);
        let icon_url = extension_icon_url_for_uuid(&uuid)
            .or_else(|| metadata
                .as_ref()
                .and_then(|json| json["icon"].as_str())
                .map(|s| s.to_string()));
        let package_name = if location == ExtensionLocation::System {
            owning_package(&path).unwrap_or_default()
        } else {
            String::new()
        };
        let removable = location == ExtensionLocation::User
            || (!package_name.is_empty() && package_list.contains(&package_name));
        items.push(InstalledExtension {
            uuid: uuid.clone(),
            name,
            icon_url,
            location,
            enabled: shell_info
                .as_ref()
                .and_then(|info| info.enabled)
                .unwrap_or_else(|| enabled.contains(&uuid)),
            has_prefs: shell_info.as_ref().is_some_and(|info| info.has_prefs),
            package_name,
            removable,
            settings_launcher,
            settings_schema,
        });
    }
}

fn uninstall_extension(ext: &InstalledExtension) -> Result<(), String> {
    match ext.location {
        ExtensionLocation::User => {
            let _ = uninstall_extension_via_shell(&ext.uuid);
            let dir = user_extension_dir(&ext.uuid);
            if dir.exists() {
                fs::remove_dir_all(dir).map_err(|e| e.to_string())?;
            }
            Ok(())
        }
        ExtensionLocation::System if ext.removable && !ext.package_name.is_empty() => {
            let status = Command::new("pkexec")
                .arg("/usr/libexec/rakuos/rakuos-remove")
                .arg(&ext.package_name)
                .status()
                .map_err(|e| e.to_string())?;
            if status.success() {
                Ok(())
            } else {
                Err("rakuos-remove failed".to_string())
            }
        }
        ExtensionLocation::System => Err("system extension is not removable".to_string()),
    }
}

fn set_extension_enabled(uuid: &str, enabled: bool) -> Result<(), String> {
    call_shell_boolean_method(
        if enabled { "EnableExtension" } else { "DisableExtension" },
        uuid,
    )
    .or_else(|_| run_gnome_extensions(if enabled { "enable" } else { "disable" }, uuid))
}

fn uninstall_extension_via_shell(uuid: &str) -> Result<(), String> {
    call_shell_boolean_method("UninstallExtension", uuid)
}

fn launch_extension_prefs(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.LaunchExtensionPrefs",
            uuid,
        ])
        .status()
        .map_err(|e| e.to_string())?;
    if status.success() {
        Ok(())
    } else {
        Err("LaunchExtensionPrefs failed".to_string())
    }
}

fn call_shell_boolean_method(method: &str, uuid: &str) -> Result<(), String> {
    let output = Command::new("gdbus")
        .args([
            "call",
            "--session",
            "--dest",
            "org.gnome.Shell.Extensions",
            "--object-path",
            "/org/gnome/Shell/Extensions",
            "--method",
            &format!("org.gnome.Shell.Extensions.{}", method),
            uuid,
        ])
        .output()
        .map_err(|e| e.to_string())?;
    if !output.status.success() {
        return Err(format!("{} failed", method));
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    if stdout.contains("true") || stdout.trim().is_empty() {
        Ok(())
    } else {
        Err(format!("{} returned false", method))
    }
}

fn get_shell_extension_info(uuid: &str) -> Option<ShellExtensionInfo> {
    let output = Command::new("gdbus")
        .args([
            "call",
            "--session",
            "--dest",
            "org.gnome.Shell.Extensions",
            "--object-path",
            "/org/gnome/Shell/Extensions",
            "--method",
            "org.gnome.Shell.Extensions.GetExtensionInfo",
            uuid,
        ])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let text = String::from_utf8_lossy(&output.stdout);
    Some(ShellExtensionInfo {
        enabled: variant_bool_field(&text, "enabled"),
        has_prefs: variant_bool_field(&text, "hasPrefs").unwrap_or(false),
    })
}

fn variant_bool_field(text: &str, key: &str) -> Option<bool> {
    let key_pos = text.find(&format!("'{}'", key)).or_else(|| text.find(&format!("\"{}\"", key)))?;
    let rest = &text[key_pos..];
    if rest.find("true").is_some_and(|true_pos| {
        rest.find("false").map_or(true, |false_pos| true_pos < false_pos)
    }) {
        Some(true)
    } else if rest.contains("false") {
        Some(false)
    } else {
        None
    }
}

fn run_gnome_extensions(action: &str, uuid: &str) -> Result<(), String> {
    let status = Command::new("gnome-extensions")
        .arg(action)
        .arg(uuid)
        .status()
        .map_err(|e| e.to_string())?;
    if status.success() {
        Ok(())
    } else {
        Err(format!("gnome-extensions {} failed", action))
    }
}

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().map(|c| c.is_ascii_digit()).unwrap_or(false))
        .map(|part| part.split('.').next().unwrap_or(part).to_string())
}

fn command_lines(command: &mut Command) -> Vec<String> {
    command.output().ok()
        .map(|out| String::from_utf8_lossy(&out.stdout)
            .lines()
            .map(|line| line.trim().to_string())
            .filter(|line| !line.is_empty())
            .collect())
        .unwrap_or_default()
}

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 user_extension_dir(uuid: &str) -> PathBuf {
    user_extensions_base().join(uuid)
}

fn read_extension_metadata(path: &Path) -> Option<serde_json::Value> {
    let raw = fs::read_to_string(path.join("metadata.json")).ok()?;
    serde_json::from_str::<serde_json::Value>(&raw).ok()
}

fn find_settings_launcher(path: &Path) -> Option<String> {
    let read_dir = fs::read_dir(path).ok()?;
    for entry in read_dir.flatten() {
        let file_path = entry.path();
        if file_path.extension().and_then(|s| s.to_str()) != Some("desktop") {
            continue;
        }
        let raw = fs::read_to_string(&file_path).ok()?;
        let name_matches = raw
            .lines()
            .any(|line| {
                let lower = line.to_lowercase();
                lower.starts_with("name=")
                    && (lower.contains("setting")
                        || lower.contains("preference")
                        || lower.contains("config"))
            });
        if !name_matches {
            continue;
        }
        if let Some(exec) = raw
            .lines()
            .find_map(|line| line.strip_prefix("Exec=").map(str::trim))
            .filter(|line| !line.is_empty())
        {
            return Some(strip_desktop_exec_codes(exec));
        }
    }
    None
}

fn strip_desktop_exec_codes(exec: &str) -> String {
    exec.split_whitespace()
        .filter(|part| {
            !matches!(
                *part,
                "%f" | "%F" | "%u" | "%U" | "%d" | "%D" | "%n" | "%N" | "%i" | "%c" | "%k" | "%v" | "%m"
            )
        })
        .collect::<Vec<_>>()
        .join(" ")
}

fn read_gsettings_values(schema: &str) -> Vec<(String, String)> {
    let out = Command::new("gsettings")
        .args(["list-recursively", schema])
        .output()
        .ok();
    let Some(out) = out else { return Vec::new(); };
    if !out.status.success() {
        return Vec::new();
    }
    String::from_utf8_lossy(&out.stdout)
        .lines()
        .filter_map(|line| {
            let mut parts = line.splitn(3, ' ');
            let _schema = parts.next()?;
            let key = parts.next()?.to_string();
            let value = parts.next().unwrap_or_default().to_string();
            Some((key, value))
        })
        .collect()
}

fn extension_url(ext: &ExtensionSummary) -> String {
    if let Some(url) = ext.url.as_deref().filter(|url| !url.is_empty()) {
        absolute_ego_url(url)
    } else if let Some(link) = ext.link.as_deref().filter(|link| !link.is_empty()) {
        absolute_ego_url(link)
    } else {
        format!("{}/extension/{}/", EGO_BASE, ext.uuid)
    }
}

fn absolute_ego_url(path_or_url: &str) -> String {
    if path_or_url.starts_with("http") {
        path_or_url.to_string()
    } else if path_or_url.starts_with('/') {
        format!("{}{}", EGO_BASE, path_or_url)
    } else {
        format!("{}/{}", EGO_BASE, path_or_url)
    }
}

fn owning_package(path: &Path) -> Option<String> {
    let out = Command::new("rpm")
        .arg("-qf")
        .arg(path)
        .arg("--qf")
        .arg("%{NAME}")
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
    (!name.is_empty()).then_some(name)
}

fn read_package_list() -> HashSet<String> {
    fs::read_to_string("/var/lib/rakuos/packages.list")
        .unwrap_or_default()
        .lines()
        .map(|line| line.trim().to_string())
        .filter(|line| !line.is_empty())
        .collect()
}

fn url_escape(input: &str) -> String {
    input.bytes().fold(String::new(), |mut out, b| {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
            b' ' => out.push('+'),
            _ => out.push_str(&format!("%{:02X}", b)),
        }
        out
    })
}

fn blank<'a>(value: &'a str, fallback: &'a str) -> &'a str {
    if value.trim().is_empty() { fallback } else { value }
}
