//! Preferences dialog on the cross-client category map (the Apple 2026-07 settings
//! revamp): General / Display / Input / Audio / Controllers pages — Display owns
//! everything about the picture — with per-field captions in each row's subtitle,
//! dynamic where the meaning depends on the selection (touch mode). Written back to
//! disk when the dialog closes. About stays in the primary menu (GNOME convention)
//! rather than as a page.

use crate::trust::Settings;
use adw::prelude::*;
use pf_client_core::trust::StatsVerbosity;
use std::cell::{Cell, RefCell};
use std::rc::Rc;

/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
const RESOLUTIONS: &[(u32, u32)] = &[
    (0, 0),
    (1280, 720),
    (1920, 1080),
    (2560, 1440),
    (3840, 2160),
];
/// `0` = the monitor's native refresh, resolved at connect.
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
/// Render-scale multipliers (persisted as f64; mirrors [`punktfunk_core::render_scale::PRESETS`]).
/// `1.0` = Native. Applied at connect and each match-window resize.
const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];

/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)".
fn render_scale_label(scale: f64) -> String {
    if scale == 1.0 {
        "Native".to_string()
    } else if scale > 1.0 {
        format!("{scale}× (supersample)")
    } else {
        format!("{scale}×")
    }
}
const GAMEPADS: &[&str] = &[
    "auto",
    "xbox360",
    "dualsense",
    "xboxone",
    "dualshock4",
    "steamdeck",
];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
/// HEVC when either side can't do it.
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1", "pyrowave"];
const CODEC_LABELS: &[&str] = &[
    "Automatic",
    "HEVC (H.265)",
    "H.264 (AVC)",
    "AV1",
    "PyroWave (wired LAN)",
];
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
/// Touch-input model values (persisted) paired with their display labels below — the
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"];
/// The SELECTED touch mode explained — the caption swaps with the choice (the Apple
/// revamp's dynamic-caption idiom) instead of narrating all three modes at once.
/// Combo-row captions must stay ONE line (~66 chars at the default dialog width): a
/// wrapped subtitle's natural width crushes the selected-value label into an ellipsis.
const TOUCH_MODE_CAPTIONS: &[&str] = &[
    "Drives the cursor like a laptop trackpad — tap to click",
    "The cursor jumps to your finger — a tap clicks there",
    "Real multi-touch reaches the host — for touch-native apps",
];
/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as
/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream.
const MOUSE_MODES: &[&str] = &["capture", "desktop"];
const MOUSE_MODE_LABELS: &[&str] = &["Capture (games)", "Desktop (absolute)"];
const MOUSE_MODE_CAPTIONS: &[&str] = &[
    "Pointer locks to the stream — relative motion, best for games",
    "Pointer moves freely in and out — best for remote desktop work",
];

/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
const APP_LICENSE: &str = concat!(
    "Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n",
    "================================ MIT ================================\n\n",
    include_str!("../../../LICENSE-MIT"),
    "\n\n=============================== Apache-2.0 ===============================\n\n",
    include_str!("../../../LICENSE-APACHE"),
);
/// Third-party software notices for the linked Rust crates (generated by
/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog).
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");

/// Show the About dialog (app license + the third-party-software Legal section) — reached
/// from the primary menu (app.rs `win.about`).
pub fn show_about(parent: &impl IsA<gtk::Widget>) {
    let about = adw::AboutDialog::builder()
        .application_name("Punktfunk")
        .developer_name("unom")
        .version(env!("CARGO_PKG_VERSION"))
        .website("https://git.unom.io/unom/punktfunk")
        .license_type(gtk::License::Custom)
        .license(APP_LICENSE)
        .build();
    // The native (FFmpeg/GTK/PipeWire/SDL3) components are dynamically linked under their own
    // (LGPL/Zlib/MIT) licenses; the Rust crate notices are the substantive attribution set.
    about.add_legal_section(
        "Third-party software (Rust crates)",
        None,
        gtk::License::Custom,
        Some(THIRD_PARTY_NOTICES),
    );
    about.add_legal_section(
        "Third-party software (system libraries)",
        None,
        gtk::License::Custom,
        Some(
            "This application dynamically links system libraries under their own licenses, \
             including FFmpeg (LGPL v2.1+), GTK 4 and libadwaita (LGPL v2.1+), PipeWire (MIT), \
             and SDL 3 (Zlib). Their full license texts are available from each project.",
        ),
    );
    about.present(Some(parent));
}

/// True inside a gamescope session (Steam game mode on the Deck / Bazzite): GTK popovers
/// are xdg_popups, which gamescope never maps for nested apps — a ComboRow's dropdown
/// flashes the row but no list ever appears. Selection UI must stay inside the toplevel.
fn gamescope_session() -> bool {
    std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope"))
        || std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok()
}

type ChangedFn = Rc<RefCell<Option<Box<dyn Fn(u32)>>>>;

/// A titled single-choice preference row. On a desktop this is a stock popover
/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable
/// row that pushes an in-window selection subpage onto the preferences dialog instead.
struct ChoiceRow {
    row: adw::PreferencesRow,
    selected: Rc<Cell<u32>>,
    /// Fires on user changes only — [`connect_changed`](Self::connect_changed) is installed
    /// after seeding, so programmatic `set_selected` during setup never fires it.
    changed: ChangedFn,
    /// Subpage mode only: the current value rendered as the row's suffix.
    value_label: Option<gtk::Label>,
    options: Rc<Vec<String>>,
}

impl ChoiceRow {
    /// `inline` = subpage mode (gamescope): computed once per dialog via
    /// [`gamescope_session`] and passed in so tests can drive both modes directly.
    fn new(
        dialog: &adw::PreferencesDialog,
        inline: bool,
        title: &str,
        subtitle: &str,
        options: &[&str],
    ) -> ChoiceRow {
        let options: Rc<Vec<String>> = Rc::new(options.iter().map(|s| s.to_string()).collect());
        let selected = Rc::new(Cell::new(0u32));
        let changed: ChangedFn = Rc::new(RefCell::new(None));

        if !inline {
            let row = adw::ComboRow::builder()
                .title(title)
                .subtitle(subtitle)
                .model(&gtk::StringList::new(
                    &options.iter().map(String::as_str).collect::<Vec<_>>(),
                ))
                .build();
            let (sel, chg) = (selected.clone(), changed.clone());
            row.connect_selected_notify(move |r| {
                if sel.replace(r.selected()) != r.selected() {
                    if let Some(f) = chg.borrow().as_ref() {
                        f(r.selected());
                    }
                }
            });
            return ChoiceRow {
                row: row.upcast(),
                selected,
                changed,
                value_label: None,
                options,
            };
        }

        let value = gtk::Label::builder().css_classes(["dim-label"]).build();
        let row = adw::ActionRow::builder()
            .title(title)
            .subtitle(subtitle)
            .activatable(true)
            .build();
        row.add_suffix(&value);
        row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
        {
            let dialog = dialog.downgrade();
            let (options, sel, chg, value) = (
                options.clone(),
                selected.clone(),
                changed.clone(),
                value.clone(),
            );
            let title = title.to_string();
            row.connect_activated(move |_| {
                let Some(dialog) = dialog.upgrade() else {
                    return;
                };
                let list = gtk::ListBox::builder()
                    .selection_mode(gtk::SelectionMode::None)
                    .css_classes(["boxed-list"])
                    .build();
                for (i, opt) in options.iter().enumerate() {
                    let check = gtk::Image::from_icon_name("object-select-symbolic");
                    check.set_visible(i as u32 == sel.get());
                    let opt_row = adw::ActionRow::builder()
                        .title(opt)
                        .use_markup(false)
                        .activatable(true)
                        .build();
                    opt_row.add_suffix(&check);
                    let idx = i as u32;
                    let dlg = dialog.downgrade();
                    let (sel, chg, value, label) =
                        (sel.clone(), chg.clone(), value.clone(), opt.clone());
                    opt_row.connect_activated(move |_| {
                        let user_change = sel.replace(idx) != idx;
                        value.set_text(&label);
                        if user_change {
                            if let Some(f) = chg.borrow().as_ref() {
                                f(idx);
                            }
                        }
                        if let Some(d) = dlg.upgrade() {
                            d.pop_subpage();
                        }
                    });
                    list.append(&opt_row);
                }
                let clamp = adw::Clamp::builder()
                    .child(&list)
                    .margin_top(24)
                    .margin_bottom(24)
                    .margin_start(12)
                    .margin_end(12)
                    .build();
                let scroll = gtk::ScrolledWindow::builder()
                    .hscrollbar_policy(gtk::PolicyType::Never)
                    .child(&clamp)
                    .build();
                let view = adw::ToolbarView::new();
                view.add_top_bar(&adw::HeaderBar::new());
                view.set_content(Some(&scroll));
                dialog.push_subpage(&adw::NavigationPage::new(&view, &title));
            });
        }
        let cr = ChoiceRow {
            row: row.upcast(),
            selected,
            changed,
            value_label: Some(value),
            options,
        };
        cr.sync_value();
        cr
    }

    /// Subpage mode: reflect the current selection in the row's suffix label.
    fn sync_value(&self) {
        if let Some(l) = &self.value_label {
            let i = self.selected.get() as usize;
            l.set_text(self.options.get(i).map(String::as_str).unwrap_or(""));
        }
    }

    fn widget(&self) -> &adw::PreferencesRow {
        &self.row
    }

    fn selected(&self) -> u32 {
        self.selected.get()
    }

    fn set_selected(&self, i: u32) {
        if let Some(combo) = self.row.downcast_ref::<adw::ComboRow>() {
            combo.set_selected(i); // the notify handler syncs the cell
        } else {
            self.selected.set(i);
            self.sync_value();
        }
    }

    fn connect_changed(&self, f: impl Fn(u32) + 'static) {
        *self.changed.borrow_mut() = Some(Box::new(f));
    }
}

/// Update a row's caption after construction — the dynamic-caption hook (touch mode,
/// resolution, codec). Both ChoiceRow shapes carry their subtitle on [`adw::ActionRow`]
/// ([`adw::ComboRow`] derives from it), so one downcast covers desktop and gamescope mode.
fn set_row_subtitle(row: &adw::PreferencesRow, text: &str) {
    if let Some(r) = row.downcast_ref::<adw::ActionRow>() {
        r.set_subtitle(text);
    }
}

/// The SELECTED resolution choice explained (row index: 0 = Native, 1 = Match window,
/// 2.. = explicit sizes) — one line each, see the caption-width note on
/// [`TOUCH_MODE_CAPTIONS`].
fn resolution_caption(i: u32) -> &'static str {
    match i {
        0 => "The native mode of this monitor, resolved at connect",
        1 => "Follows the stream window — resizes renegotiate the host output",
        _ => "The host drives a virtual output at exactly this size",
    }
}

/// The SELECTED codec explained: the PyroWave entry is the one that needs its trade-off
/// spelled out; everything else shares the soft-preference line.
fn codec_caption(i: u32) -> &'static str {
    if CODECS.get(i as usize) == Some(&"pyrowave") {
        "Wavelet codec for wired LAN — minimal latency, lots of bandwidth"
    } else {
        "A preference — the host falls back if it can't encode it"
    }
}

/// A settings category page for the dialog's view switcher.
fn page(title: &str, icon: &str) -> adw::PreferencesPage {
    adw::PreferencesPage::builder()
        // The name addresses the page programmatically (`set_visible_page_name` — the
        // screenshot harness's page knob); the title is what the view switcher shows.
        .name(title.to_lowercase())
        .title(title)
        .icon_name(icon)
        .build()
}

/// Startup device probes for the pickers — filled by the app shell in the background
/// (GPUs via `punktfunk-session --list-adapters`, audio endpoints via the PipeWire
/// registry); any list may still be empty when the dialog opens, which simply hides
/// that picker.
#[derive(Default)]
pub struct DeviceProbes {
    pub adapters: Vec<String>,
    pub speakers: Vec<pf_client_core::audio::AudioDevice>,
    pub mics: Vec<pf_client_core::audio::AudioDevice>,
}

/// A titled group of rows; `description` (may be empty) is the one form-level note —
/// per-field explanations belong in row subtitles, not here.
fn group(title: &str, description: &str) -> adw::PreferencesGroup {
    let g = adw::PreferencesGroup::builder().title(title).build();
    if !description.is_empty() {
        g.set_description(Some(description));
    }
    g
}

/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
/// there so the library toggle takes effect without a nav round-trip). `probes` is the
/// shell's startup device probe (`AppModel::probes`) — may still be empty. Returns the
/// presented dialog so the screenshot harness can select a page; callers ignore it.
pub fn show(
    parent: &impl IsA<gtk::Widget>,
    settings: Rc<RefCell<Settings>>,
    gamepads: &crate::gamepad::GamepadService,
    probes: &DeviceProbes,
    on_closed: impl Fn() + 'static,
) -> adw::PreferencesDialog {
    // The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
    // subpage onto it.
    let dialog = adw::PreferencesDialog::new();
    dialog.set_title("Preferences");
    dialog.set_search_enabled(true);
    // Wide enough that the category switcher sits in the HEADER BAR (the tabbed look the
    // Apple/Windows clients have): AdwPreferencesDialog moves it to a bottom bar below a
    // breakpoint of 110pt × page count (≈ 733 px for our five pages). In a window that
    // can't give the dialog this width it still collapses to the bottom bar on its own.
    dialog.set_content_width(830);
    let inline = gamescope_session();

    // ---- Display: Resolution ----
    // The D1 tri-state: Native, Match window (a virtual index 1, stored as the
    // `match_window` flag), then the explicit sizes.
    let res_names: Vec<String> = std::iter::once("Native display".to_string())
        .chain(std::iter::once("Match window".to_string()))
        .chain(
            RESOLUTIONS
                .iter()
                .skip(1)
                .map(|&(w, h)| format!("{w} × {h}")),
        )
        .collect();
    let res_row = ChoiceRow::new(
        &dialog,
        inline,
        "Resolution",
        resolution_caption(0),
        &res_names.iter().map(String::as_str).collect::<Vec<_>>(),
    );
    {
        let w = res_row.widget().clone();
        res_row.connect_changed(move |i| set_row_subtitle(&w, resolution_caption(i)));
    }
    let hz_names: Vec<String> = REFRESH
        .iter()
        .map(|&r| {
            if r == 0 {
                "Native".to_string()
            } else {
                format!("{r} Hz")
            }
        })
        .collect();
    let hz_row = ChoiceRow::new(
        &dialog,
        inline,
        "Refresh rate",
        "Native follows the monitor the window is on",
        &hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
    );

    // ---- Display: Quality ----
    let scale_names: Vec<String> = RENDER_SCALES
        .iter()
        .map(|&s| render_scale_label(s))
        .collect();
    let scale_row = ChoiceRow::new(
        &dialog,
        inline,
        "Render scale",
        "Above 1× supersamples for sharpness; below is lighter on the host",
        &scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
    );
    let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
    bitrate_row.set_title("Bitrate");
    bitrate_row
        .set_subtitle("Mbit/s · 0 = host default · a host card's menu has a network speed test");
    let codec_row = ChoiceRow::new(
        &dialog,
        inline,
        "Video codec",
        codec_caption(0),
        CODEC_LABELS,
    );
    {
        let w = codec_row.widget().clone();
        codec_row.connect_changed(move |i| set_row_subtitle(&w, codec_caption(i)));
    }
    let hdr_row = adw::SwitchRow::builder()
        .title("10-bit HDR")
        .subtitle(
            "Advertise 10-bit HDR10 so the host upgrades HDR content — shown in HDR where \
             the display supports it, tone-mapped otherwise",
        )
        .build();
    let decoder_row = ChoiceRow::new(
        &dialog,
        inline,
        "Video decoder",
        "Automatic picks the best hardware decode, then software",
        &["Automatic", "Vulkan Video", "VAAPI", "Software"],
    );
    // GPU picker (multi-GPU boxes): the adapter name feeds the session's device pick
    // via `Settings::adapter` → PUNKTFUNK_VK_ADAPTER. Hidden when there's nothing to
    // pick; a saved adapter that's gone (eGPU unplugged) keeps a revertable entry.
    let saved_adapter = settings.borrow().adapter.clone();
    let mut gpu_names = vec!["Automatic".to_string()];
    let mut gpu_keys: Vec<String> = vec![String::new()];
    for a in &probes.adapters {
        gpu_names.push(a.clone());
        gpu_keys.push(a.clone());
    }
    if !saved_adapter.is_empty() && !gpu_keys.contains(&saved_adapter) {
        gpu_names.push(format!("{saved_adapter} (not detected)"));
        gpu_keys.push(saved_adapter.clone());
    }
    let gpu_row = (gpu_keys.len() > 1).then(|| {
        let row = ChoiceRow::new(
            &dialog,
            inline,
            "GPU",
            "Decodes and presents the stream",
            &gpu_names.iter().map(String::as_str).collect::<Vec<_>>(),
        );
        let i = gpu_keys
            .iter()
            .position(|k| k == &saved_adapter)
            .unwrap_or(0);
        row.set_selected(i as u32);
        row
    });

    // ---- Display: Host output ----
    let compositor_row = ChoiceRow::new(
        &dialog,
        inline,
        "Host compositor",
        "Advisory — the host falls back to auto-detect when unavailable",
        &[
            "Automatic",
            "KWin",
            "wlroots (Sway/Hyprland)",
            "Mutter (GNOME)",
            "gamescope",
        ],
    );

    // ---- General ----
    let fullscreen_row = adw::SwitchRow::builder()
        .title("Start streams in fullscreen")
        .subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
        .build();
    let wake_row = adw::SwitchRow::builder()
        .title("Auto-wake on connect")
        .subtitle(
            "Sends Wake-on-LAN to an offline saved host and waits for it to boot — turn \
             off if hosts behind a VPN look offline when they aren't",
        )
        .build();
    let stats_row = ChoiceRow::new(
        &dialog,
        inline,
        "Statistics overlay",
        "Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
        &["Off", "Compact", "Normal", "Detailed"],
    );
    let library_row = adw::SwitchRow::builder()
        .title("Show game library")
        .subtitle(
            "Adds “Browse library…” to paired hosts — list their Steam and custom games \
             and launch one directly. No extra host setup",
        )
        .build();

    // ---- Input ----
    let touch_row = ChoiceRow::new(
        &dialog,
        inline,
        "Touch input",
        TOUCH_MODE_CAPTIONS[0],
        TOUCH_MODE_LABELS,
    );
    // Dynamic caption: describe the SELECTED mode, not all three at once.
    {
        let w = touch_row.widget().clone();
        touch_row.connect_changed(move |i| {
            let i = (i as usize).min(TOUCH_MODE_CAPTIONS.len() - 1);
            set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]);
        });
    }
    let mouse_row = ChoiceRow::new(
        &dialog,
        inline,
        "Mouse input",
        MOUSE_MODE_CAPTIONS[0],
        MOUSE_MODE_LABELS,
    );
    {
        let w = mouse_row.widget().clone();
        mouse_row.connect_changed(move |i| {
            let i = (i as usize).min(MOUSE_MODE_CAPTIONS.len() - 1);
            set_row_subtitle(&w, MOUSE_MODE_CAPTIONS[i]);
        });
    }
    let inhibit_row = adw::SwitchRow::builder()
        .title("Capture system shortcuts")
        .subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
        .build();
    let invert_row = adw::SwitchRow::builder()
        .title("Invert scroll direction")
        .subtitle("Reverses the wheel and trackpad scroll direction sent to the host")
        .build();

    // ---- Audio ----
    let surround_row = ChoiceRow::new(
        &dialog,
        inline,
        "Audio channels",
        "Stereo or surround — the host downmixes if its output has fewer",
        &["Stereo", "5.1 Surround", "7.1 Surround"],
    );
    let mic_row = adw::SwitchRow::builder()
        .title("Stream microphone")
        .subtitle("Sends your microphone to the host's virtual mic")
        .build();
    // Endpoint pickers (from the PipeWire probe): visible labels are descriptions, the
    // stored value is the node name. Hidden when the probe found nothing; a saved
    // device that's gone keeps a revertable "(not detected)" entry, like the GPU row.
    let dev_row = |saved: String,
                   devs: &[pf_client_core::audio::AudioDevice],
                   title: &str,
                   subtitle: &str| {
        let mut names = vec!["System default".to_string()];
        let mut keys = vec![String::new()];
        for d in devs {
            names.push(d.description.clone());
            keys.push(d.name.clone());
        }
        if !saved.is_empty() && !keys.contains(&saved) {
            names.push(format!("{saved} (not detected)"));
            keys.push(saved.clone());
        }
        let row = (keys.len() > 1).then(|| {
            let row = ChoiceRow::new(
                &dialog,
                inline,
                title,
                subtitle,
                &names.iter().map(String::as_str).collect::<Vec<_>>(),
            );
            row.set_selected(keys.iter().position(|k| k == &saved).unwrap_or(0) as u32);
            row
        });
        (row, keys)
    };
    let (speaker_row, speaker_keys) = dev_row(
        settings.borrow().speaker_device.clone(),
        &probes.speakers,
        "Speaker",
        "Host audio plays here — System default follows the desktop",
    );
    let (micdev_row, micdev_keys) = dev_row(
        settings.borrow().mic_device.clone(),
        &probes.mics,
        "Microphone",
        "The input that feeds the host's virtual mic",
    );
    // The device pick only matters while the mic streams at all.
    if let Some(r) = &micdev_row {
        let w = r.widget().clone();
        w.set_sensitive(mic_row.is_active());
        mic_row.connect_active_notify(move |m| w.set_sensitive(m.is_active()));
    }

    // ---- Controllers ----
    // Controller forwarding: Automatic forwards EVERY real controller, each as its own pad
    // (Steam's virtual pad skipped); pinning one restricts the session to that single
    // controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
    // so it survives restarts — and disconnects: an offline pinned pad keeps its entry here
    // instead of silently snapping back to Automatic.
    let pads = gamepads.pads();
    let saved_pin = settings.borrow().forward_pad.clone();
    let mut pad_names = vec!["Automatic (all controllers)".to_string()];
    let mut pad_keys: Vec<String> = Vec::new();
    for p in &pads {
        let kind = p.kind_label();
        pad_names.push(if kind.is_empty() {
            p.name.clone()
        } else {
            format!("{} · {kind}", p.name)
        });
        pad_keys.push(p.key.clone());
    }
    if !saved_pin.is_empty() && !pad_keys.contains(&saved_pin) {
        let name = saved_pin
            .splitn(3, ':')
            .nth(2)
            .unwrap_or("Saved controller");
        pad_names.push(format!("{name} (not connected)"));
        pad_keys.push(saved_pin.clone());
    }
    let forward_row = ChoiceRow::new(
        &dialog,
        inline,
        "Forwarded controller",
        if pads.is_empty() {
            "No controllers detected"
        } else {
            "Every pad is its own player — pick one to force single-player"
        },
        &pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
    );
    let pinned_i = pad_keys
        .iter()
        .position(|k| k == &saved_pin)
        .map_or(0, |i| i + 1);
    forward_row.set_selected(pinned_i as u32);
    // The dialog-local choice, written into Settings on close (reading the service back
    // would race its worker thread applying the Pin message).
    let chosen_pin: Rc<RefCell<String>> = Rc::new(RefCell::new(saved_pin));
    {
        let svc = gamepads.clone();
        let keys = pad_keys.clone();
        let chosen = chosen_pin.clone();
        forward_row.connect_changed(move |sel| {
            let key = if sel == 0 {
                None
            } else {
                keys.get(sel as usize - 1).cloned()
            };
            *chosen.borrow_mut() = key.clone().unwrap_or_default();
            svc.set_pinned(key);
        });
    }
    let pad_row = ChoiceRow::new(
        &dialog,
        inline,
        "Gamepad type",
        "The virtual pad on the host — Automatic matches your controller",
        &[
            "Automatic",
            "Xbox 360",
            "DualSense",
            "Xbox One",
            "DualShock 4",
            "Steam Deck",
        ],
    );

    // ---- Seed from the current settings ----
    {
        let s = settings.borrow();
        let res_i = if s.match_window {
            1
        } else {
            RESOLUTIONS
                .iter()
                .position(|&(w, h)| w == s.width && h == s.height)
                .map(|i| if i == 0 { 0 } else { i + 1 })
                .unwrap_or(0)
        };
        res_row.set_selected(res_i as u32);
        set_row_subtitle(res_row.widget(), resolution_caption(res_i as u32));
        let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
        hz_row.set_selected(hz_i as u32);
        let scale_i = RENDER_SCALES
            .iter()
            .position(|&x| (x - s.render_scale).abs() < 1e-6)
            .unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
        scale_row.set_selected(scale_i as u32);
        bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
        let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
        pad_row.set_selected(pad_i as u32);
        let touch_i = TOUCH_MODES
            .iter()
            .position(|&t| t == s.touch_mode)
            .unwrap_or(0);
        touch_row.set_selected(touch_i as u32);
        // set_selected never fires the changed hook, so seed the dynamic caption directly.
        set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]);
        let mouse_i = MOUSE_MODES
            .iter()
            .position(|&m| m == s.mouse_mode)
            .unwrap_or(0);
        mouse_row.set_selected(mouse_i as u32);
        set_row_subtitle(mouse_row.widget(), MOUSE_MODE_CAPTIONS[mouse_i]);
        let comp_i = COMPOSITORS
            .iter()
            .position(|&c| c == s.compositor)
            .unwrap_or(0);
        compositor_row.set_selected(comp_i as u32);
        let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
        decoder_row.set_selected(dec_i as u32);
        let stats_i = StatsVerbosity::ALL
            .iter()
            .position(|v| *v == s.stats_verbosity())
            .unwrap_or(0);
        stats_row.set_selected(stats_i as u32);
        fullscreen_row.set_active(s.fullscreen_on_stream);
        wake_row.set_active(s.auto_wake);
        inhibit_row.set_active(s.inhibit_shortcuts);
        invert_row.set_active(s.invert_scroll);
        mic_row.set_active(s.mic_enabled);
        hdr_row.set_active(s.hdr_enabled);
        library_row.set_active(s.library_enabled);
        surround_row.set_selected(match s.audio_channels {
            6 => 1,
            8 => 2,
            _ => 0,
        });
        let codec_i = CODECS.iter().position(|&c| c == s.codec).unwrap_or(0);
        codec_row.set_selected(codec_i as u32);
        set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32));
    }

    // ---- Assemble the category pages (the Apple revamp's map) ----
    let general = page("General", "preferences-system-symbolic");
    let session_group = group("Session", "");
    session_group.add(&fullscreen_row);
    session_group.add(&wake_row);
    let stats_group = group("Statistics", "");
    stats_group.add(stats_row.widget());
    let library_group = group("Library", "");
    library_group.add(&library_row);
    general.add(&session_group);
    general.add(&stats_group);
    general.add(&library_group);

    let display = page("Display", "video-display-symbolic");
    let resolution_group = group("Resolution", "");
    resolution_group.add(res_row.widget());
    resolution_group.add(hz_row.widget());
    let quality_group = group("Quality", "");
    quality_group.add(scale_row.widget());
    quality_group.add(&bitrate_row);
    quality_group.add(codec_row.widget());
    quality_group.add(&hdr_row);
    quality_group.add(decoder_row.widget());
    if let Some(r) = &gpu_row {
        quality_group.add(r.widget());
    }
    // The one form-level note (deliberately not repeated on every row).
    let output_group = group(
        "Host output",
        "Display changes apply from the next session.",
    );
    output_group.add(compositor_row.widget());
    display.add(&resolution_group);
    display.add(&quality_group);
    display.add(&output_group);

    let input = page("Input", "input-keyboard-symbolic");
    let touch_group = group("Touch", "");
    touch_group.add(touch_row.widget());
    // Group titles are Pango markup — the ampersand must be an entity.
    let kbm_group = group("Keyboard &amp; mouse", "");
    kbm_group.add(mouse_row.widget());
    kbm_group.add(&inhibit_row);
    kbm_group.add(&invert_row);
    input.add(&touch_group);
    input.add(&kbm_group);

    let audio = page("Audio", "audio-volume-high-symbolic");
    let audio_group = group("", "Applies from the next session.");
    audio_group.add(surround_row.widget());
    if let Some(r) = &speaker_row {
        audio_group.add(r.widget());
    }
    audio_group.add(&mic_row);
    if let Some(r) = &micdev_row {
        audio_group.add(r.widget());
    }
    audio.add(&audio_group);

    let controllers = page("Controllers", "input-gaming-symbolic");
    let controllers_group = group("", "");
    // The detected-pad list (mirrors the Apple Controllers section): informational rows
    // above the pickers, from the same snapshot that feeds the forwarding picker.
    if pads.is_empty() {
        let none = adw::ActionRow::builder()
            .title("No controllers detected")
            .css_classes(["dim-label"])
            .build();
        controllers_group.add(&none);
    } else {
        for p in &pads {
            let row = adw::ActionRow::builder()
                .title(&p.name)
                .use_markup(false)
                .build();
            if p.steam_virtual {
                row.set_subtitle(
                    "Steam Input's virtual pad — Automatic skips it while a real pad is connected",
                );
            } else {
                row.set_subtitle(p.kind_label());
            }
            row.add_prefix(&gtk::Image::from_icon_name("input-gaming-symbolic"));
            controllers_group.add(&row);
        }
    }
    controllers_group.add(forward_row.widget());
    controllers_group.add(pad_row.widget());
    controllers.add(&controllers_group);

    dialog.add(&general);
    dialog.add(&display);
    dialog.add(&input);
    dialog.add(&audio);
    dialog.add(&controllers);

    dialog.connect_closed(move |_| {
        let mut s = settings.borrow_mut();
        // Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
        let res_i = (res_row.selected() as usize).min(RESOLUTIONS.len());
        s.match_window = res_i == 1;
        (s.width, s.height) = if res_i <= 1 {
            (0, 0)
        } else {
            RESOLUTIONS[res_i - 1]
        };
        s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
        s.render_scale =
            RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
        s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
        // Keep a stored preference this table doesn't list (e.g. "switchpro" — valid to the
        // session, hand-edited or written by another client): it displays as "Automatic", and
        // writing that back would silently erase it just by opening + closing the dialog.
        // Persist the row only when the user picked a non-Auto entry or the stored value was
        // a listed one to begin with.
        let pad_sel = (pad_row.selected() as usize).min(GAMEPADS.len() - 1);
        if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
            s.gamepad = GAMEPADS[pad_sel].to_string();
        }
        s.touch_mode =
            TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
        s.mouse_mode =
            MOUSE_MODES[(mouse_row.selected() as usize).min(MOUSE_MODES.len() - 1)].to_string();
        s.forward_pad = chosen_pin.borrow().clone();
        s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
            .to_string();
        s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
        if let Some(r) = &gpu_row {
            s.adapter = gpu_keys[(r.selected() as usize).min(gpu_keys.len() - 1)].clone();
        }
        if let Some(r) = &speaker_row {
            s.speaker_device =
                speaker_keys[(r.selected() as usize).min(speaker_keys.len() - 1)].clone();
        }
        if let Some(r) = &micdev_row {
            s.mic_device = micdev_keys[(r.selected() as usize).min(micdev_keys.len() - 1)].clone();
        }
        s.set_stats_verbosity(
            StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
        );
        s.fullscreen_on_stream = fullscreen_row.is_active();
        s.auto_wake = wake_row.is_active();
        s.inhibit_shortcuts = inhibit_row.is_active();
        s.invert_scroll = invert_row.is_active();
        s.mic_enabled = mic_row.is_active();
        s.hdr_enabled = hdr_row.is_active();
        s.audio_channels = match surround_row.selected() {
            1 => 6,
            2 => 8,
            _ => 2,
        };
        s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
        s.library_enabled = library_row.is_active();
        s.save();
        drop(s);
        on_closed();
    });
    dialog.present(Some(parent));
    dialog
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Depth-first search for an [`adw::ActionRow`] with the given title.
    fn find_action_row(root: &gtk::Widget, title: &str) -> Option<adw::ActionRow> {
        if let Some(row) = root.downcast_ref::<adw::ActionRow>() {
            if row.title() == title {
                return Some(row.clone());
            }
        }
        let mut child = root.first_child();
        while let Some(c) = child {
            if let Some(hit) = find_action_row(&c, title) {
                return Some(hit);
            }
            child = c.next_sibling();
        }
        None
    }

    fn pump() {
        let ctx = gtk::glib::MainContext::default();
        while ctx.iteration(false) {}
    }

    /// Both ChoiceRow modes in ONE test (GTK is thread-affine and libtest gives every test
    /// its own thread, so the display tests can't be split). Gamescope mode: activating the
    /// row pushes the in-window selection subpage; activating an option updates the
    /// selection + suffix label, fires the change callback, and pops the subpage. Combo
    /// mode: cell sync + change callback. Needs a display — run manually with
    /// `cargo test -p punktfunk-client-linux -- --ignored` on a session box.
    #[test]
    #[ignore = "needs a Wayland/X display"]
    fn choice_row_modes() {
        assert!(gtk::init().is_ok() && adw::init().is_ok(), "no display");
        let win = adw::Window::new();
        let dialog = adw::PreferencesDialog::new();
        let page = adw::PreferencesPage::new();
        let group = adw::PreferencesGroup::new();
        let row = ChoiceRow::new(&dialog, true, "Resolution", "sub", &["A", "B", "C"]);
        group.add(row.widget());
        page.add(&group);
        dialog.add(&page);
        let fired = Rc::new(Cell::new(u32::MAX));
        {
            let f = fired.clone();
            row.connect_changed(move |i| f.set(i));
        }
        win.present();
        dialog.present(Some(&win));
        pump();

        // Suffix label reflects the seed.
        assert_eq!(row.value_label.as_ref().unwrap().text(), "A");

        // Row activation → subpage with the options list.
        row.widget()
            .downcast_ref::<adw::ActionRow>()
            .unwrap()
            .emit_by_name::<()>("activated", &[]);
        pump();
        let opt_b = find_action_row(dialog.upcast_ref(), "B").expect("subpage option missing");

        // Option activation → state + label + callback, subpage popped.
        opt_b.emit_by_name::<()>("activated", &[]);
        pump();
        assert_eq!(row.selected(), 1);
        assert_eq!(fired.get(), 1);
        assert_eq!(row.value_label.as_ref().unwrap().text(), "B");

        // The dynamic-caption hook drives the same subtitle both row shapes expose.
        set_row_subtitle(row.widget(), "swapped");
        assert_eq!(
            row.widget()
                .downcast_ref::<adw::ActionRow>()
                .unwrap()
                .subtitle()
                .as_deref(),
            Some("swapped")
        );

        // Re-activating shows the check on the new selection (fresh subpage each time).
        row.widget()
            .downcast_ref::<adw::ActionRow>()
            .unwrap()
            .emit_by_name::<()>("activated", &[]);
        pump();
        assert!(find_action_row(dialog.upcast_ref(), "B").is_some());

        // Desktop (ComboRow) mode: cell sync + change callback on selection change.
        let combo = ChoiceRow::new(&dialog, false, "Codec", "", &["X", "Y"]);
        combo.set_selected(1);
        assert_eq!(combo.selected(), 1);
        let combo_fired = Rc::new(Cell::new(u32::MAX));
        {
            let f = combo_fired.clone();
            combo.connect_changed(move |i| f.set(i));
        }
        combo.set_selected(0);
        assert_eq!(combo.selected(), 0);
        assert_eq!(combo_fired.get(), 0);
        // ComboRow derives from ActionRow, so the caption hook reaches it too.
        set_row_subtitle(combo.widget(), "combo caption");
        assert_eq!(
            combo
                .widget()
                .downcast_ref::<adw::ActionRow>()
                .unwrap()
                .subtitle()
                .as_deref(),
            Some("combo caption")
        );
    }
}
