//! "Printer Status & Calibration" page.
//!
//! Three responsibilities:
//!
//!   1. List every Zebra printer the agent sees (USB + configured
//!      network), with a per-printer Calibrate button that asks the
//!      agent to send `~JC` ZPL via D-Bus.
//!
//!   2. Centralise the "Refresh USB printer permissions" pkexec-
//!      elevated udev fix-up — moved here from the Status page in
//!      v0.3.3 because this is the page a user lands on when a
//!      printer reports a permission error.
//!
//!   3. Show an empty-state action row when no printers are visible,
//!      with a hint about plugging USB or registering a network
//!      printer via the CLI.

use crate::dbus::{AgentClient, PrinterEntry};
use crate::dims::{full_subtitle, query_printer_dims, status_badge, PrinterDims};
use crate::window::ZbplWindow;

/// How long to wait after sending `~JC` before re-querying the
/// printer's dimensions. Typical media calibration is 5-10 seconds;
/// 8 is a sane upper bound that catches longer label stocks too.
/// Picked slightly conservatively because the printer is more
/// likely to be busy than truly stuck — a too-eager refresh hits
/// EBUSY and returns the *old* dims, which is what we're trying to
/// avoid in the first place.
const CALIBRATE_REFRESH_DELAY_SECS: u32 = 8;

/// Bag of widget refs we keep per printer row so the async
/// SGD-fill can mutate the row after it's been added to the
/// PreferencesGroup. Each ref points into widgets we built in
/// `build_printer_row` — they're live as long as the row is.
#[derive(Debug)]
pub struct PrinterRowRefs {
    pub container: gtk::Widget,
    pub subtitle: gtk::Label,
    pub icon_box: gtk::Box,
    pub spinner: gtk::Spinner,
    pub uid: String,
}
use adw::prelude::*;
use adw::subclass::prelude::*;
use gtk::{glib, CompositeTemplate};
use std::cell::{Cell, OnceCell, RefCell};
use std::rc::Rc;
use std::thread;

mod imp {
    use super::*;

    #[derive(Debug, Default, CompositeTemplate)]
    #[template(resource = "/com/printplatformpro/BrowserAgent/ui/printers_page.ui")]
    pub struct ZbplPrintersPage {
        // Single Connected-printers group + single empty state.
        // Lives OUTSIDE the per-transport ViewStack so every printer
        // (USB, network, Bluetooth) shows together regardless of
        // which sub-tab the user has selected. The row's prefix
        // carries two icons — the printer symbol + a transport
        // glyph — so the user can tell USB / network / BT apart
        // without flipping tabs.
        #[template_child]
        pub printers_group: TemplateChild<adw::PreferencesGroup>,
        #[template_child]
        pub empty_group: TemplateChild<adw::PreferencesGroup>,
        #[template_child]
        pub fix_permissions_button: TemplateChild<gtk::Button>,
        #[template_child]
        pub detect_button: TemplateChild<gtk::Button>,
        #[template_child]
        pub add_network_button: TemplateChild<gtk::Button>,
        #[template_child]
        pub pair_bluetooth_button: TemplateChild<gtk::Button>,
        #[template_child]
        pub refresh_status_button: TemplateChild<gtk::Button>,

        pub agent: OnceCell<Rc<AgentClient>>,
        // Rows we've added to the Connected-printers group on the
        // previous refresh. Tracked here (rather than walked via
        // first_child) because PreferencesGroup's internal layout
        // wraps a GtkBox containing a ListBox — first_child returns
        // the box, and remove() on that errors with "non-child".
        pub printer_rows: RefCell<Vec<super::PrinterRowRefs>>,
        // Monotonically increasing token; every refresh() bumps it
        // and stores the new value. Worker threads compare their
        // captured generation to this on each result — mismatched
        // ones drop their results silently so we never apply stale
        // SGD data to freshly-built rows.
        pub generation: Cell<u64>,
    }

    /// Bag of widget refs we keep per printer row so the async
    /// SGD-fill can mutate the row after it's been added to the
    /// PreferencesGroup. Each ref points into widgets we built in
    /// `build_printer_row` — they're live as long as the row is.

    #[glib::object_subclass]
    impl ObjectSubclass for ZbplPrintersPage {
        const NAME: &'static str = "ZbplPrintersPage";
        type Type = super::ZbplPrintersPage;
        type ParentType = adw::Bin;

        fn class_init(klass: &mut Self::Class) {
            klass.bind_template();
        }
        fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
            obj.init_template();
        }
    }

    impl ObjectImpl for ZbplPrintersPage {}
    impl WidgetImpl for ZbplPrintersPage {}
    impl BinImpl for ZbplPrintersPage {}
}

glib::wrapper! {
    pub struct ZbplPrintersPage(ObjectSubclass<imp::ZbplPrintersPage>)
        @extends gtk::Widget, adw::Bin,
        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
}

impl ZbplPrintersPage {
    pub fn bind_agent(&self, agent: Rc<AgentClient>) {
        let imp = self.imp();
        imp.agent.set(agent).ok();

        // "Refresh USB printer permissions" — pkexec-elevated udev
        // refresh + setfacl. Same flow as `sudo zebra-browser-print-
        // linux --fix-perms`. Standard GNOME pattern (Disks /
        // Settings / Software all use pkexec the same way).
        let this = self.clone();
        imp.fix_permissions_button
            .connect_clicked(move |btn| {
                btn.set_sensitive(false);
                let toast_target = this
                    .root()
                    .and_then(|r| r.downcast::<ZbplWindow>().ok());
                // Run the agent's own --fix-perms when available
                // (does udev refresh + setfacl in one shot). Falls
                // back to inline udev commands so pkexec still works
                // even on a system with the binary at a non-default
                // path.
                // Invoke the agent binary directly (not via `sh -c`)
                // when it's installed, so the shipped polkit rule
                // — which keys on `program ==
                // /usr/bin/pppro-browser-linux` — matches and
                // grants admin users a no-prompt allow. Without the
                // direct invoke, `program` would be `/usr/bin/sh`
                // and the rule wouldn't match.
                let agent_binary = which_agent_binary();
                let result = match &agent_binary {
                    Some(path) => std::process::Command::new("pkexec")
                        .arg(path)
                        .arg("--fix-perms")
                        .output(),
                    None => std::process::Command::new("pkexec")
                        .arg("sh")
                        .arg("-c")
                        .arg(
                            "udevadm control --reload-rules && \
                             udevadm trigger --subsystem-match=usb --action=add --attr-match=idVendor=0a5f && \
                             udevadm trigger --subsystem-match=usbmisc --action=add",
                        )
                        .output(),
                };
                btn.set_sensitive(true);
                match result {
                    Ok(out) if out.status.success() => {
                        if let Some(w) = toast_target.as_ref() {
                            w.toast("Printer permissions refreshed. Try printing again.");
                        }
                        this.refresh();
                    }
                    Ok(out) => {
                        // pkexec exit codes (see pkexec(1)):
                        //   126 = authorisation could not be obtained
                        //         (user dismissed the prompt)
                        //   127 = authentication failed / pkexec not
                        //         usable
                        //   other = the inner command's own exit code
                        let code = out.status.code().unwrap_or(-1);
                        let stderr = String::from_utf8_lossy(&out.stderr);
                        tracing::warn!("pkexec exit={code} stderr={stderr:?}");
                        // Always include the first line of stderr if
                        // pkexec produced one — it's the only signal
                        // we get when the prompt looks fine to the
                        // user but pkexec still rejects it (e.g. no
                        // polkit agent registered for this session,
                        // policy lockdown, or fingerprint timeout).
                        let stderr_line: String = stderr.trim().lines().next().unwrap_or("").chars().take(200).collect();
                        let msg = match code {
                            126 => {
                                if stderr_line.is_empty() {
                                    "Permission refresh cancelled. Click Refresh again and authenticate when prompted.".to_string()
                                } else {
                                    format!("Permission refresh failed: {stderr_line}")
                                }
                            }
                            127 => {
                                // Two distinct conditions both return 127:
                                //   - "pkexec must be setuid root" → the UI
                                //     was spawned under a NoNewPrivileges
                                //     process tree (typically a stale agent
                                //     from before we removed NNP).
                                //   - Standard PAM auth failure.
                                // The stderr token disambiguates them so
                                // the operator gets actionable wording.
                                if stderr_line.contains("setuid") {
                                    "Permission refresh blocked: the agent inherited a no-setuid policy. Quit + restart the tray icon, then try again.".to_string()
                                } else {
                                    "Authentication failed. Try Refresh again with the correct password.".to_string()
                                }
                            }
                            _ => {
                                // Surface the underlying tool's message
                                // (truncated) so the user has something
                                // to act on.
                                let trimmed = stderr.trim();
                                if trimmed.is_empty() {
                                    format!("Refresh failed (exit {code}). See journalctl --user -u pppro-browser-linux for details.")
                                } else {
                                    let snippet: String = trimmed.lines().next().unwrap_or("").chars().take(160).collect();
                                    format!("Refresh failed: {snippet}")
                                }
                            }
                        };
                        if let Some(w) = toast_target.as_ref() {
                            w.toast(&msg);
                        }
                    }
                    Err(e) => {
                        tracing::error!("spawn pkexec: {e:?}");
                        if let Some(w) = toast_target.as_ref() {
                            w.toast(&format!("Could not run pkexec: {e}"));
                        }
                    }
                }
            });

        // "Detect printers" — refreshes the list AND runs local
        // user-space diagnostics (lsusb + /dev/usb/lp* listing +
        // writability). Result is shown in a dialog so the user
        // can copy-to-clipboard for support emails. No privilege
        // escalation needed.
        let this = self.clone();
        imp.detect_button.connect_clicked(move |btn| {
            btn.set_sensitive(false);
            this.refresh();
            let report = run_diagnostics();
            this.show_diagnostics(&report);
            btn.set_sensitive(true);
        });

        // "Add network printer…" — opens an AdwAlertDialog with
        // name/host/port entries, calls AddNetworkPrinter on the
        // agent, and refreshes the printer list so the new entry
        // appears. Mirrors the macOS Settings app's AddNetworkPrinterSheet
        // flow. The agent already supports this via `--add-printer
        // NAME=HOST[:PORT]` on the CLI; this just exposes the same
        // operation to the GUI.
        let this = self.clone();
        imp.add_network_button.connect_clicked(move |btn| {
            btn.set_sensitive(false);
            this.show_add_network_dialog();
            btn.set_sensitive(true);
        });

        // "Pair Bluetooth…" — opens the host desktop's Bluetooth
        // settings panel. We deliberately don't run an in-app pair
        // flow (PIN entry + org.bluez.Agent1 registration would be
        // its own can of worms); the system panel does that securely
        // and we pick the printer up via BlueZ on the next Refresh.
        // Matches the macOS Settings app pattern, which opens System
        // Settings → Bluetooth.
        let this = self.clone();
        imp.pair_bluetooth_button.connect_clicked(move |btn| {
            btn.set_sensitive(false);
            let toast_target = this
                .root()
                .and_then(|r| r.downcast::<ZbplWindow>().ok());
            match open_bluetooth_settings() {
                Ok(()) => {
                    if let Some(w) = toast_target.as_ref() {
                        w.toast("Opened your system's Bluetooth settings");
                    }
                }
                Err(e) => {
                    tracing::warn!("open bluetooth settings: {e}");
                    if let Some(w) = toast_target.as_ref() {
                        w.toast(
                            "Couldn't open Bluetooth settings — open it from your system menu, then pair the printer there.",
                        );
                    }
                }
            }
            btn.set_sensitive(true);
        });

        // Header-suffix "Refresh" button — re-queries SGD dims +
        // status for every printer. Use after Calibrate to pick up
        // the new label length, after clearing a fault to confirm
        // the printer's recovered, or any time the rows look stale.
        let this = self.clone();
        imp.refresh_status_button.connect_clicked(move |btn| {
            btn.set_sensitive(false);
            this.refresh();
            btn.set_sensitive(true);
        });

        self.refresh();
    }

    /// Pop a dialog with the diagnostic report. Plain monospace text
    /// (with copy-to-clipboard) so users can paste it into a support
    /// email if anything's wrong. Offers a "Refresh permissions"
    /// shortcut when that's the diagnosis.
    fn show_diagnostics(&self, report: &Diagnostics) {
        let dialog = adw::AlertDialog::new(
            Some(if report.ready {
                "Printer ready"
            } else if report.found_zebra {
                "Permission needed"
            } else {
                "No Zebra printer detected"
            }),
            Some(&report.summary),
        );

        let body_box = gtk::Box::new(gtk::Orientation::Vertical, 12);
        body_box.set_margin_top(12);

        let text_view = gtk::TextView::new();
        text_view.set_editable(false);
        text_view.set_monospace(true);
        text_view.set_wrap_mode(gtk::WrapMode::WordChar);
        text_view.buffer().set_text(&report.detail);
        let scroller = gtk::ScrolledWindow::new();
        scroller.set_min_content_height(220);
        scroller.set_min_content_width(420);
        scroller.set_child(Some(&text_view));
        scroller.set_has_frame(true);
        body_box.append(&scroller);

        let copy_btn = gtk::Button::with_label("Copy report");
        copy_btn.set_halign(gtk::Align::End);
        copy_btn.add_css_class("flat");
        let detail_for_copy = report.detail.clone();
        let parent_for_toast = self
            .root()
            .and_then(|r| r.downcast::<ZbplWindow>().ok());
        copy_btn.connect_clicked(move |_b| {
            if let Some(display) = gtk::gdk::Display::default() {
                display.clipboard().set_text(&detail_for_copy);
                if let Some(w) = parent_for_toast.as_ref() {
                    w.toast("Report copied to clipboard");
                }
            }
        });
        body_box.append(&copy_btn);

        dialog.set_extra_child(Some(&body_box));
        dialog.add_response("close", "Close");
        if !report.ready && report.fix_with_refresh {
            dialog.add_response("fix", "Refresh permissions");
            dialog.set_response_appearance("fix", adw::ResponseAppearance::Suggested);
            dialog.set_default_response(Some("fix"));
        }
        dialog.set_close_response("close");

        let this = self.clone();
        dialog.connect_response(None, move |dlg, response| {
            if response == "fix" {
                // Programmatically click the existing Refresh
                // button so we don't duplicate the pkexec wiring.
                this.imp().fix_permissions_button.emit_clicked();
            }
            dlg.close();
        });

        if let Some(window) = self.root().and_then(|r| r.downcast::<ZbplWindow>().ok()) {
            dialog.present(Some(&window));
        }
    }

    pub fn refresh(&self) {
        let imp = self.imp();
        if imp.agent.get().is_none() {
            return;
        }

        let printers = match imp.agent.get().unwrap().list_printers() {
            Ok(v) => v,
            Err(e) => {
                tracing::warn!("list_printers: {e:?}");
                imp.empty_group.set_visible(true);
                imp.printers_group.set_visible(false);
                return;
            }
        };

        // Bump the generation so any in-flight worker from the
        // previous refresh drops its results when they finally
        // arrive — see `spawn_dims_worker`.
        let gen = imp.generation.get().wrapping_add(1);
        imp.generation.set(gen);

        // Drop the rows we added on the previous refresh.
        {
            let mut rows = imp.printer_rows.borrow_mut();
            for r in rows.drain(..) {
                imp.printers_group.remove(&r.container);
            }
        }

        if printers.is_empty() {
            imp.empty_group.set_visible(true);
            imp.printers_group.set_visible(false);
            return;
        }

        imp.empty_group.set_visible(false);
        imp.printers_group.set_visible(true);

        // Build every row with the placeholder (no dims line, no
        // status icon, spinner spinning). The window can paint as
        // soon as this loop finishes — SGD I/O happens off-thread.
        let uids: Vec<String> = printers.iter().map(|p| p.uid.clone()).collect();
        {
            let mut rows = imp.printer_rows.borrow_mut();
            for p in &printers {
                let refs = self.build_printer_row(p, None);
                imp.printers_group.add(&refs.container);
                rows.push(refs);
            }
        }

        // Kick off the background fill. Each result comes back on
        // an async-channel which is drained on the GTK main loop
        // and routed to `apply_dims_to_row` for the matching UID.
        self.spawn_dims_worker(uids, gen);
    }

    /// Spawn a worker thread that queries SGD for every UID and
    /// streams results back. Generation tag lets us discard results
    /// from a worker whose refresh() has been superseded.
    fn spawn_dims_worker(&self, uids: Vec<String>, gen: u64) {
        let (tx, rx) = async_channel::unbounded::<(String, PrinterDims)>();

        // Worker thread: own AgentClient so we don't have to share
        // the Rc<AgentClient> across threads. zbus session connects
        // are pooled, so the second connect is cheap.
        thread::spawn(move || {
            let agent = match AgentClient::connect() {
                Ok(a) => a,
                Err(e) => {
                    tracing::warn!("worker: connect: {e:#}");
                    return;
                }
            };
            for uid in uids {
                let dims = query_printer_dims(&agent, &uid);
                if tx.send_blocking((uid, dims)).is_err() {
                    // Receiver dropped — refresh superseded us.
                    return;
                }
            }
        });

        // Drain results on the main loop. We capture self by
        // weak-ref-equivalent (clone of the GObject, which is
        // ref-counted) and skip results when the generation has
        // moved past us.
        let this = self.clone();
        glib::spawn_future_local(async move {
            while let Ok((uid, dims)) = rx.recv().await {
                if this.imp().generation.get() != gen {
                    continue;
                }
                this.apply_dims_to_row(&uid, &dims);
            }
        });
    }

    /// Update one printer row in place with freshly-fetched dims:
    /// rewrite the subtitle, swap the badge icon, stop the spinner.
    fn apply_dims_to_row(&self, uid: &str, dims: &PrinterDims) {
        let imp = self.imp();
        let rows = imp.printer_rows.borrow();
        let Some(refs) = rows.iter().find(|r| r.uid == uid) else { return };

        // Look up the matching PrinterEntry for the subtitle's base
        // serial line. The printer set is small so a linear scan is
        // fine — we'd save microseconds by caching the entry on the
        // row, which isn't worth the extra plumbing.
        let agent = match imp.agent.get() {
            Some(a) => a.clone(),
            None => return,
        };
        let printers = agent.list_printers().unwrap_or_default();
        let Some(p) = printers.iter().find(|p| p.uid == uid) else { return };

        refs.subtitle.set_text(&full_subtitle(p, Some(dims)));
        refs.spinner.stop();
        refs.spinner.set_visible(false);

        // Replace the badge slot's contents. The badge slot is the
        // second child of icon_box (added during build); we walk
        // children and remove non-printer ones, then conditionally
        // add the new badge.
        while let Some(child) = refs.icon_box.last_child() {
            if child.downcast_ref::<gtk::Image>()
                .and_then(|img| img.icon_name())
                .map(|n| n == "printer-symbolic")
                .unwrap_or(false)
            {
                break;
            }
            refs.icon_box.remove(&child);
        }
        if let Some((badge_name, badge_class)) = status_badge(dims) {
            let badge = gtk::Image::from_icon_name(badge_name);
            badge.set_pixel_size(14);
            badge.add_css_class(badge_class);
            refs.icon_box.append(&badge);
        }
    }

    fn build_printer_row(&self, p: &PrinterEntry, dims: Option<&PrinterDims>) -> PrinterRowRefs {
        // Subtitle: serial line always, dims + status added only
        // when SGD has already answered. `full_subtitle` handles
        // both paths.
        let subtitle = full_subtitle(p, dims);

        // Primary printer glyph (always shown) plus an optional
        // badge slot below it for error / warning state. Build the
        // badge inline when dims are already known; otherwise leave
        // the slot empty — apply_dims_to_row will populate it when
        // the worker thread answers.
        let icon = gtk::Image::from_icon_name("printer-symbolic");
        icon.set_pixel_size(24);
        icon.set_valign(gtk::Align::Start);
        icon.set_margin_top(2);

        let icon_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
        icon_box.set_valign(gtk::Align::Start);
        icon_box.append(&icon);
        if let Some(d) = dims {
            if let Some((badge_name, badge_class)) = status_badge(d) {
                let badge = gtk::Image::from_icon_name(badge_name);
                badge.set_pixel_size(14);
                badge.add_css_class(badge_class);
                icon_box.append(&badge);
            }
        }

        // Title and subtitle as plain labels — gives us full control
        // over the row's vertical flow so the action buttons can sit
        // below the text block instead of being crammed in next to it.
        let title_lbl = gtk::Label::new(Some(&p.name));
        title_lbl.set_halign(gtk::Align::Start);
        title_lbl.add_css_class("heading");

        let subtitle_lbl = gtk::Label::new(Some(&subtitle));
        subtitle_lbl.set_halign(gtk::Align::Start);
        subtitle_lbl.set_xalign(0.0);
        subtitle_lbl.set_wrap(true);
        subtitle_lbl.set_wrap_mode(gtk::pango::WrapMode::WordChar);
        subtitle_lbl.add_css_class("dim-label");
        subtitle_lbl.add_css_class("caption");

        // Spinner shown at the right edge of the title bar while we
        // wait for the SGD round-trip. apply_dims_to_row stops +
        // hides it when the printer answers.
        let spinner = gtk::Spinner::new();
        spinner.set_valign(gtk::Align::Center);
        if dims.is_none() {
            spinner.start();
            spinner.set_visible(true);
        } else {
            spinner.set_visible(false);
        }

        let text_box = gtk::Box::new(gtk::Orientation::Vertical, 2);
        text_box.set_hexpand(true);
        text_box.set_valign(gtk::Align::Center);
        text_box.append(&title_lbl);
        text_box.append(&subtitle_lbl);

        // Transport glyph — the second prefix icon. Lets the user
        // distinguish USB / network / Bluetooth at a glance now that
        // all three transports share one Connected-printers list
        // (the per-transport sub-tabs only carry setup affordances).
        // Sits as a sibling to icon_box so apply_dims_to_row's
        // last_child cleanup of the status badge still works on the
        // existing vertical printer-icon stack untouched.
        let (transport_icon_name, transport_tooltip) = if p.is_network() {
            ("network-wired-symbolic", "Network printer")
        } else if p.is_bluetooth() {
            ("bluetooth-symbolic", "Bluetooth printer")
        } else {
            ("drive-removable-media-symbolic", "USB printer")
        };
        let transport_icon = gtk::Image::from_icon_name(transport_icon_name);
        transport_icon.set_pixel_size(18);
        transport_icon.set_valign(gtk::Align::Start);
        transport_icon.set_margin_top(4);
        transport_icon.add_css_class("dim-label");
        transport_icon.set_tooltip_text(Some(transport_tooltip));

        let header = gtk::Box::new(gtk::Orientation::Horizontal, 12);
        header.append(&icon_box);
        header.append(&transport_icon);
        header.append(&text_box);
        header.append(&spinner);

        // Per-printer Calibrate button. Sends ~JC via the agent's
        // D-Bus Calibrate method (which writes the ZPL to the
        // device). Disabled while the request is in flight.
        let calibrate_btn = gtk::Button::with_label("Calibrate");
        calibrate_btn.set_valign(gtk::Align::Center);
        // Per-row action buttons use the default GTK4 button size
        // (compact) rather than libadwaita's `pill` style — matches
        // the label app's printers view and keeps the row visually
        // light when 4-5 buttons share one line.
        let agent = self.imp().agent.get().cloned();
        let uid = p.uid.clone();
        let printer_name = p.name.clone();
        let this = self.clone();
        calibrate_btn.connect_clicked(move |btn| {
            let Some(agent) = agent.clone() else { return };
            btn.set_sensitive(false);
            let toast_target = this.root().and_then(|r| r.downcast::<ZbplWindow>().ok());
            let result = agent.calibrate(&uid);
            btn.set_sensitive(true);
            match (toast_target.as_ref(), &result) {
                (Some(w), Ok(())) => {
                    w.toast(&format!("Calibrating {}. Refreshing dims in a few seconds…", printer_name));
                    // Calibration runs async on the printer (~5-15s
                    // depending on media). Schedule a delayed full
                    // refresh so the row picks up the new label
                    // length once the printer's finished. Using
                    // timeout_seconds (single-shot) so we don't
                    // keep polling forever.
                    // Refresh ALL pages, not just this one — the Test
                    // prints picker has its own dims cache that goes
                    // stale otherwise (user calibrates here, switches
                    // to Test prints, sees the OLD label length and
                    // prints onto the wrong media size).
                    let this_refresh = this.clone();
                    glib::timeout_add_seconds_local_once(
                        CALIBRATE_REFRESH_DELAY_SECS,
                        move || {
                            refresh_all_pages(&this_refresh);
                        },
                    );
                }
                (Some(w), Err(e)) => {
                    // anyhow's default Display prints only the
                    // topmost context ("Calibrate(<uid>)"). The
                    // alternate (`{:#}`) walks the chain so the
                    // actual D-Bus error message — including the
                    // agent's "permission denied" — surfaces.
                    let raw = format!("{e:#}");
                    tracing::warn!("calibrate({uid}): {raw}");
                    let msg = if looks_like_permission_error(&raw) {
                        "Calibrate failed: permission denied on /dev/usb/lp*. Click Refresh above to fix.".to_string()
                    } else {
                        format!("Calibrate failed: {raw}")
                    };
                    w.toast(&msg);
                }
                (None, Err(e)) => {
                    tracing::warn!("calibrate({uid}): {e:?}");
                }
                (None, Ok(())) => {}
            }
        });

        // Per-printer "Offset…" button. Opens a small AlertDialog
        // with two spinners (X/Y dots ±200). Saving fires
        // SetPrintOffset on the agent, which persists to JSON and
        // applies on every subsequent /write or PrintZpl.
        let offset_btn = gtk::Button::with_label("Offset…");
        offset_btn.set_valign(gtk::Align::Center);
        // (compact default button style — see calibrate_btn note above)
        let offset_btn_uid = p.uid.clone();
        let offset_btn_name = p.name.clone();
        // dpi used only for the "≈ mm" hint in the offset dialog.
        // 203 is the right default for cheap-end ZD-series printers,
        // which is most of the fleet; the actual dot count sent to
        // the agent is dpi-independent anyway.
        let offset_btn_dpi = dims.map(|d| d.dpi).filter(|d| *d > 0).unwrap_or(203);
        let this_offset = self.clone();
        offset_btn.connect_clicked(move |_b| {
            this_offset.show_offset_dialog(&offset_btn_uid, &offset_btn_name, offset_btn_dpi);
        });

        // Per-printer "Quality…" button. Opens a small dialog with
        // two spinners — Speed (IPS) and Darkness (0-30) — and
        // writes the result back via SetPrintQuality so the printer
        // persists it to NV memory. Lower speed + higher darkness =
        // sharper barcodes; higher speed = more throughput.
        let quality_btn = gtk::Button::with_label("Quality\u{2026}");
        quality_btn.set_valign(gtk::Align::Center);
        // (compact default button style)
        let quality_uid = p.uid.clone();
        let quality_name = p.name.clone();
        let this_quality = self.clone();
        quality_btn.connect_clicked(move |_b| {
            this_quality.show_quality_dialog(&quality_uid, &quality_name);
        });

        // Per-printer "Diagnostics" button. Asks the agent to walk
        // a whole battery of ZPL diagnostic queries (~HI, ~HS,
        // ~HQES, ^HH, SGD getvars …) against this printer and
        // dumps the concatenated report in a copyable dialog. Used
        // for support tickets and to verify printer config.
        let diag_btn = gtk::Button::with_label("Diagnostics");
        diag_btn.set_valign(gtk::Align::Center);
        // (compact default button style)
        let diag_uid = p.uid.clone();
        let diag_name = p.name.clone();
        let this_diag = self.clone();
        diag_btn.connect_clicked(move |btn| {
            btn.set_sensitive(false);
            this_diag.show_zpl_diagnostics(&diag_uid, &diag_name);
            btn.set_sensitive(true);
        });
        // Button row sits beneath the title/subtitle, right-aligned
        // and pill-styled to match the existing tab actions. Easier
        // to read than cramming both buttons next to the dim subtitle
        // text, especially as the subtitle now wraps over three
        // lines (serial + dims + status).
        let btn_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
        btn_row.set_halign(gtk::Align::End);
        btn_row.set_margin_top(4);
        btn_row.append(&diag_btn);
        btn_row.append(&quality_btn);
        btn_row.append(&offset_btn);
        btn_row.append(&calibrate_btn);

        // Remove — always present as the trailing "✕" icon so every
        // row has identical chrome, but only active for network
        // printers. USB + Bluetooth are plug-and-play (next refresh
        // re-finds the device), so there's nothing meaningful to
        // "remove"; greying the button preserves layout symmetry
        // without offering an action that wouldn't do anything.
        // Network printers persist in network-printers.json and
        // need an explicit RemoveNetworkPrinter D-Bus call.
        let remove_btn = gtk::Button::from_icon_name("window-close-symbolic");
        remove_btn.set_valign(gtk::Align::Center);
        remove_btn.add_css_class("flat");
        if p.is_network() {
            remove_btn.add_css_class("destructive-action");
            remove_btn.set_tooltip_text(Some("Remove this network printer"));
            let remove_uid = p.uid.clone();
            let remove_name = p.name.clone();
            let this_remove = self.clone();
            remove_btn.connect_clicked(move |_b| {
                this_remove.confirm_remove_network_printer(&remove_uid, &remove_name);
            });
        } else {
            remove_btn.set_sensitive(false);
            remove_btn.set_tooltip_text(Some(
                "USB and Bluetooth printers can't be removed from here — unplug or unpair to disconnect.",
            ));
        }
        btn_row.append(&remove_btn);

        let outer = gtk::Box::new(gtk::Orientation::Vertical, 8);
        outer.set_margin_top(12);
        outer.set_margin_bottom(12);
        outer.set_margin_start(12);
        outer.set_margin_end(12);
        outer.append(&header);
        outer.append(&btn_row);

        PrinterRowRefs {
            container: outer.upcast::<gtk::Widget>(),
            subtitle: subtitle_lbl,
            icon_box,
            spinner,
            uid: p.uid.clone(),
        }
    }

    /// Pull a full ZPL diagnostic report from the agent for this
    /// printer and present it in a scrollable, copyable dialog.
    /// Runs synchronously; the round-trip can take ~1-2 seconds
    /// because the agent issues a dozen+ ZPL queries serially.
    fn show_zpl_diagnostics(&self, uid: &str, name: &str) {
        let Some(agent) = self.imp().agent.get().cloned() else { return };
        let toast_target = self.root().and_then(|r| r.downcast::<ZbplWindow>().ok());
        let report = match agent.get_diagnostics(uid) {
            Ok(r) => r,
            Err(e) => {
                let raw = format!("{e:#}");
                tracing::warn!("get_diagnostics({uid}): {raw}");
                if let Some(w) = toast_target.as_ref() {
                    w.toast(&format!("Diagnostics failed: {raw}"));
                }
                return;
            }
        };

        let dialog = adw::AlertDialog::new(
            Some(&format!("Diagnostics · {name}")),
            Some("Output of every available ZPL status / configuration query. \
                  Use Copy to paste into a support ticket."),
        );

        let body = gtk::Box::new(gtk::Orientation::Vertical, 12);
        body.set_margin_top(12);

        // Scrollable monospace TextView — same pattern as the
        // existing Detect dialog so the two reports feel consistent.
        let text_view = gtk::TextView::new();
        text_view.set_editable(false);
        text_view.set_monospace(true);
        text_view.set_wrap_mode(gtk::WrapMode::WordChar);
        text_view.buffer().set_text(&report);
        let scroller = gtk::ScrolledWindow::new();
        scroller.set_min_content_height(420);
        scroller.set_min_content_width(640);
        scroller.set_child(Some(&text_view));
        scroller.set_has_frame(true);
        body.append(&scroller);

        let copy_btn = gtk::Button::with_label("Copy report");
        copy_btn.set_halign(gtk::Align::End);
        copy_btn.add_css_class("flat");
        let report_for_copy = report.clone();
        let parent_for_toast = self.root().and_then(|r| r.downcast::<ZbplWindow>().ok());
        copy_btn.connect_clicked(move |_b| {
            if let Some(display) = gtk::gdk::Display::default() {
                display.clipboard().set_text(&report_for_copy);
                if let Some(w) = parent_for_toast.as_ref() {
                    w.toast("Diagnostics copied to clipboard");
                }
            }
        });
        body.append(&copy_btn);

        dialog.set_extra_child(Some(&body));
        dialog.add_response("close", "Close");
        dialog.set_close_response("close");

        if let Some(window) = self.root().and_then(|r| r.downcast::<ZbplWindow>().ok()) {
            dialog.present(Some(&window));
        }
    }

    /// Open the per-printer "Print quality" dialog. Loads the
    /// current speed + darkness from the printer on open; saves the
    /// new values via SetPrintQuality which writes them through to
    /// the printer's NV memory (~SD + ^PR + ^JUS).
    fn show_quality_dialog(&self, uid: &str, name: &str) {
        let Some(agent) = self.imp().agent.get().cloned() else { return };
        let toast_target = self.root().and_then(|r| r.downcast::<ZbplWindow>().ok());

        // Best-effort read of current values; if the printer doesn't
        // answer (older firmware), seed the dialog with sane defaults
        // (4 IPS, 15 darkness) so the user still has somewhere to start.
        let (cur_speed, cur_dark) = match agent.get_print_quality(uid) {
            Ok((s, d)) => (
                if s == 0 { 4 } else { s as i32 },
                if d == 0 { 15 } else { d as i32 },
            ),
            Err(e) => {
                tracing::warn!("get_print_quality({uid}): {e:#}");
                (4, 15)
            }
        };

        let dialog = adw::AlertDialog::new(
            Some(&format!("Print quality \u{00b7} {name}")),
            Some("Lower speed and higher darkness sharpen barcodes and small text. \
                  Higher speed prints more labels per minute but can wash out fine detail. \
                  Settings persist across reboots — applied directly on USB/raw printers, \
                  or injected per job on IPP-only printers (e.g. Zebra ZSB)."),
        );

        let body = gtk::Box::new(gtk::Orientation::Vertical, 12);
        body.set_margin_top(12);
        body.set_margin_bottom(4);

        // Speed: 1-14 IPS. ZD220/230 cap at 6, ZD420 at 8, industrial
        // ZT at 14 — the printer silently clamps if you go above its
        // own ceiling, so a generous range is fine here.
        let (speed_row, speed_spin) = make_quality_row(
            "Speed (inches/sec)",
            cur_speed,
            1.0,
            14.0,
            "Higher = faster prints.",
        );

        // Darkness: 0-30 universally.
        let (dark_row, dark_spin) = make_quality_row(
            "Darkness",
            cur_dark,
            0.0,
            30.0,
            "Higher = darker dots. 10-15 is a sane mid-range start.",
        );

        // Preset row — one-click recipes for the four common
        // operating points. Buttons only populate the spinners;
        // the user still has to click Save to write to the printer.
        // Order is fastest → most-precise so the dialog reads left
        // to right as a quality dial.
        let preset_row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
        preset_row.set_halign(gtk::Align::Start);
        preset_row.set_margin_top(2);
        let preset_lbl = gtk::Label::new(Some("Presets:"));
        preset_lbl.add_css_class("dim-label");
        preset_lbl.add_css_class("caption");
        preset_row.append(&preset_lbl);
        for (label, preset_speed, preset_dark) in [
            ("Speed", 14u8, 15u8),
            ("Default", 6, 20),
            ("Quality", 1, 30),
            ("Barcodes", 4, 20),
        ] {
            let btn = gtk::Button::with_label(label);
            btn.add_css_class("flat");
            let s_clone = speed_spin.clone();
            let d_clone = dark_spin.clone();
            btn.connect_clicked(move |_| {
                s_clone.set_value(preset_speed as f64);
                d_clone.set_value(preset_dark as f64);
            });
            preset_row.append(&btn);
        }

        body.append(&preset_row);
        body.append(&speed_row);
        body.append(&dark_row);

        dialog.set_extra_child(Some(&body));
        dialog.add_response("cancel", "Cancel");
        dialog.add_response("save", "Save");
        dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested);
        dialog.set_default_response(Some("save"));
        dialog.set_close_response("cancel");

        let uid_owned = uid.to_string();
        let name_owned = name.to_string();
        let agent_clone = agent.clone();
        let this = self.clone();
        dialog.connect_response(None, move |dlg, response| {
            if response != "save" {
                dlg.close();
                return;
            }
            let s = speed_spin.value_as_int().clamp(1, 14) as u8;
            let d = dark_spin.value_as_int().clamp(0, 30) as u8;
            let toast_target = this.root().and_then(|r| r.downcast::<ZbplWindow>().ok());
            match agent_clone.set_print_quality(&uid_owned, s, d) {
                Ok(()) => {
                    if let Some(w) = toast_target.as_ref() {
                        w.toast(&format!(
                            "{name_owned}: speed {s} IPS, darkness {d} (saved)"
                        ));
                    }
                }
                Err(e) => {
                    tracing::warn!("set_print_quality({uid_owned}): {e:#}");
                    if let Some(w) = toast_target.as_ref() {
                        w.toast(&format!("Could not save quality: {e:#}"));
                    }
                }
            }
            dlg.close();
        });
        let _ = toast_target; // silence unused warning when dialog not shown

        if let Some(window) = self.root().and_then(|r| r.downcast::<ZbplWindow>().ok()) {
            dialog.present(Some(&window));
        }
    }

    /// Open the per-printer "Print offset" adjustment dialog. Loads
    /// the current offset from the agent on open; saves the new
    /// value via SetPrintOffset and toasts on success. dpi is used
    /// only for the "≈ X.X mm" hint next to each spinner so the
    /// user can think in physical units; the value sent to the agent
    /// is always in dots.
    fn show_offset_dialog(&self, uid: &str, name: &str, dpi: u32) {
        let Some(agent) = self.imp().agent.get().cloned() else { return };
        let (init_x, init_y) = agent.get_print_offset(uid).unwrap_or((0, 0));

        let dialog = adw::AlertDialog::new(
            Some(&format!("Print offset · {name}")),
            Some("Shift every print on this printer by the given dots. \
                  Use small values (±10 dots ≈ ±1.2 mm at 203 dpi) to nudge prints \
                  that land slightly off the label."),
        );

        let body = gtk::Box::new(gtk::Orientation::Vertical, 12);
        body.set_margin_top(12);
        body.set_margin_bottom(4);

        // X spinner.
        let (x_row, x_spin) = make_offset_row("Horizontal (X)", init_x, dpi);
        body.append(&x_row);
        // Y spinner.
        let (y_row, y_spin) = make_offset_row("Vertical (Y)", init_y, dpi);
        body.append(&y_row);

        dialog.set_extra_child(Some(&body));
        dialog.add_response("cancel", "Cancel");
        dialog.add_response("reset", "Reset to 0");
        dialog.add_response("save", "Save");
        dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested);
        dialog.set_default_response(Some("save"));
        dialog.set_close_response("cancel");

        let uid_owned = uid.to_string();
        let name_owned = name.to_string();
        let agent_clone = agent.clone();
        let this = self.clone();
        dialog.connect_response(None, move |dlg, response| {
            let toast_target = this.root().and_then(|r| r.downcast::<ZbplWindow>().ok());
            let (x, y) = match response {
                "save" => (x_spin.value_as_int(), y_spin.value_as_int()),
                "reset" => (0, 0),
                _ => {
                    dlg.close();
                    return;
                }
            };
            match agent_clone.set_print_offset(&uid_owned, x, y) {
                Ok(()) => {
                    if let Some(w) = toast_target.as_ref() {
                        let msg = if x == 0 && y == 0 {
                            format!("Offset cleared for {name_owned}")
                        } else {
                            format!("Offset for {name_owned}: {x:+}, {y:+} dots")
                        };
                        w.toast(&msg);
                    }
                    this.refresh();
                }
                Err(e) => {
                    tracing::warn!("set_print_offset({uid_owned}): {e:#}");
                    if let Some(w) = toast_target.as_ref() {
                        w.toast(&format!("Could not save offset: {e:#}"));
                    }
                }
            }
            dlg.close();
        });

        if let Some(window) = self.root().and_then(|r| r.downcast::<ZbplWindow>().ok()) {
            dialog.present(Some(&window));
        }
    }

    /// Open the "Add network printer" dialog. Two sections:
    ///
    ///   1. **Discover** — optional subnet entry + "Scan network"
    ///      button. Scan runs on a worker thread (8s max), results
    ///      stream into a ListBox below. Click a result → populates
    ///      the manual fields with the discovered printer's name +
    ///      host + port.
    ///
    ///   2. **Manual** — Name/Host/Port fields users always have as
    ///      a fallback for printers that don't show up in scan (e.g.
    ///      across-segment routing, firewalled hosts).
    ///
    /// Mirrors the macOS Settings app's AddNetworkPrinterSheet plus
    /// the smart-scan affordance the Linux UI previously lacked.
    fn show_add_network_dialog(&self) {
        let Some(agent) = self.imp().agent.get().cloned() else {
            return;
        };

        let dialog = adw::AlertDialog::new(
            Some("Add a network printer"),
            Some(
                "Scan the network for Zebra printers, or enter one manually \
                 below. Discovered printers click into the fields so you can \
                 rename before adding.",
            ),
        );
        // AdwDialog::content_width is the *preferred* width — the
        // dialog still grows when content (here: the results list)
        // demands more, which produced the "opens skinny, balloons
        // after Scan" reflow. Forcing a hard size_request on the
        // body Box prevents both directions: GTK respects width_request
        // as a floor AND we cap horizontal expansion below so rows
        // can never widen the dialog. 580 leaves room for the EntryRow
        // title + standalone Scan button + result rows without truncation,
        // and fits comfortably inside the 1024-wide main window.
        let body = gtk::Box::new(gtk::Orientation::Vertical, 12);
        body.set_margin_top(12);
        body.set_margin_bottom(4);
        body.set_size_request(580, -1);
        body.set_hexpand(false);

        // ---------- Discover section ----------------------------------
        let discover_group = adw::PreferencesGroup::new();
        discover_group.set_title("Discover");
        discover_group.set_description(Some(
            "Click Scan to look for Zebra printers on your LAN. \
             For a printer on a different segment, type any IP from \
             that segment — we'll sweep its /24.",
        ));

        // Extra-subnet input. Field accepts any of:
        //   - A printer's IP (10.20.30.98) — most user-friendly
        //   - The /24 base (10.20.30.0)
        //   - CIDR (10.20.30.0/24)
        // Agent's parseSubnet handles all three. Title prioritises the
        // "type an IP" form because that's what the user already
        // knows; earlier wording with a CIDR example confused users
        // into typing just the "/24" suffix.
        let subnet_row = adw::EntryRow::new();
        subnet_row.set_title("Printer IP on another segment (optional)");
        subnet_row.set_input_purpose(gtk::InputPurpose::Url);
        discover_group.add(&subnet_row);

        // Scan button gets its own row below the entry — was a
        // suffix on the EntryRow but the inline-suffix layout
        // squeezed the title down to "Printer IP (..." on every
        // reasonable dialog width. Standalone button has room for
        // the full label + the EntryRow title can breathe.
        let scan_btn = gtk::Button::with_label("Scan network");
        scan_btn.add_css_class("suggested-action");
        scan_btn.set_halign(gtk::Align::End);
        scan_btn.set_margin_top(4);

        // Status label — "scanning…" / "found N" / "no results yet".
        let status_lbl = gtk::Label::new(Some("Click Scan to look for printers."));
        status_lbl.set_xalign(0.0);
        status_lbl.set_halign(gtk::Align::Start);
        status_lbl.add_css_class("caption");
        status_lbl.add_css_class("dim-label");
        status_lbl.set_margin_top(4);
        status_lbl.set_margin_start(4);

        // Results list. Bounded height with a scroll wrapper so a noisy
        // network doesn't blow out the dialog vertically.
        let results_box = gtk::ListBox::new();
        results_box.set_selection_mode(gtk::SelectionMode::None);
        results_box.add_css_class("boxed-list");

        let results_scroll = gtk::ScrolledWindow::new();
        results_scroll.set_min_content_height(120);
        results_scroll.set_max_content_height(220);
        // Cap the scroll's max content width so a long row label
        // can't widen the dialog. Pair with hexpand(false) so it
        // doesn't fill any extra horizontal space either.
        results_scroll.set_max_content_width(540);
        results_scroll.set_propagate_natural_height(true);
        results_scroll.set_hscrollbar_policy(gtk::PolicyType::Never);
        results_scroll.set_hexpand(false);
        results_scroll.set_child(Some(&results_box));

        let discover_holder = gtk::Box::new(gtk::Orientation::Vertical, 6);
        discover_holder.append(&discover_group);
        discover_holder.append(&scan_btn);
        discover_holder.append(&status_lbl);
        discover_holder.append(&results_scroll);
        body.append(&discover_holder);

        // ---------- Manual entry section ------------------------------
        let manual_group = adw::PreferencesGroup::new();
        manual_group.set_title("Enter manually");

        let name_row = adw::EntryRow::new();
        name_row.set_title("Name");
        name_row.set_text("Warehouse ZD421");

        let host_row = adw::EntryRow::new();
        host_row.set_title("Host or IP");
        host_row.set_input_purpose(gtk::InputPurpose::Url);

        // Port defaults to 9100; agent also accepts 0 → "use default"
        // but we keep the visible default at 9100 for clarity.
        let port_adj = gtk::Adjustment::new(9100.0, 1.0, 65535.0, 1.0, 100.0, 0.0);
        let port_row = adw::SpinRow::new(Some(&port_adj), 1.0, 0);
        port_row.set_title("Port");
        port_row.set_subtitle("Zebra raw socket. 9100 unless changed in printer setup.");

        manual_group.add(&name_row);
        manual_group.add(&host_row);
        manual_group.add(&port_row);
        body.append(&manual_group);

        dialog.set_extra_child(Some(&body));
        dialog.add_response("cancel", "Cancel");
        dialog.add_response("add", "Add");
        dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested);
        dialog.set_default_response(Some("add"));
        dialog.set_close_response("cancel");

        // ---------- Scan plumbing -------------------------------------
        // Worker thread does the (blocking, up-to-8s) D-Bus call.
        // Result travels back over async_channel which the GTK main
        // loop drains via spawn_future_local. Same pattern as the Logs
        // tab (and same threading reason — blocking-zbus + no tokio).
        let scan_in_flight = std::rc::Rc::new(std::cell::Cell::new(false));

        // Capture the entries-to-fill by clone so the row click handler
        // (built inside spawn_future_local) can write to them. GTK
        // widget handles are GObject refcounts under the hood — cheap
        // to clone.
        let agent_for_scan = agent.clone();
        let scan_btn_clone = scan_btn.clone();
        let subnet_row_clone = subnet_row.clone();
        let status_lbl_clone = status_lbl.clone();
        let results_box_clone = results_box.clone();
        let name_row_for_fill = name_row.clone();
        let host_row_for_fill = host_row.clone();
        let port_row_for_fill = port_row.clone();
        let in_flight = scan_in_flight.clone();
        scan_btn.connect_clicked(move |_btn| {
            if in_flight.get() {
                return;
            }
            in_flight.set(true);
            scan_btn_clone.set_sensitive(false);
            status_lbl_clone.set_text("Scanning…");
            results_box_clone.remove_all();

            let extra_text = subnet_row_clone.text().trim().to_string();
            let extras: Vec<String> = if extra_text.is_empty() {
                Vec::new()
            } else {
                vec![extra_text]
            };

            let (tx, rx) = async_channel::bounded::<Result<Vec<crate::dbus::DiscoveredPrinter>, String>>(1);
            // Clone the AgentClient OUT of the Rc — the underlying
            // zbus::blocking::Connection is Send+Clone, so the worker
            // thread can take an owned copy without needing Arc.
            let agent_thread = (*agent_for_scan).clone();
            std::thread::spawn(move || {
                let result = agent_thread
                    .scan_network_printers(&extras)
                    .map_err(|e| format!("{e:#}"));
                let _ = tx.send_blocking(result);
            });

            let scan_btn_main = scan_btn_clone.clone();
            let status_main = status_lbl_clone.clone();
            let results_main = results_box_clone.clone();
            let name_fill = name_row_for_fill.clone();
            let host_fill = host_row_for_fill.clone();
            let port_fill = port_row_for_fill.clone();
            let in_flight_main = in_flight.clone();
            glib::spawn_future_local(async move {
                let result = rx.recv().await.unwrap_or_else(|e| Err(e.to_string()));
                match result {
                    Ok(found) => {
                        if found.is_empty() {
                            // Most common cause on a multi-LAN site is
                            // the printer being on a different /24 from
                            // the laptop. Spell it out instead of just
                            // "no results".
                            status_main.set_text(
                                "No Zebra printers on this LAN. If your printer is on a \
                                 different subnet (e.g. 10.20.30.0/24), type it in the \
                                 \"Other subnet\" field above and click Scan again.",
                            );
                        } else {
                            status_main.set_text(&format!(
                                "Found {} printer{}. Click one to fill the fields below.",
                                found.len(),
                                if found.len() == 1 { "" } else { "s" }
                            ));
                            for fr in found {
                                let row = build_discovered_row(
                                    &fr,
                                    name_fill.clone(),
                                    host_fill.clone(),
                                    port_fill.clone(),
                                );
                                results_main.append(&row);
                            }
                        }
                    }
                    Err(e) => {
                        tracing::warn!("scan_network_printers: {e}");
                        status_main.set_text(&format!("Scan failed: {e}"));
                    }
                }
                scan_btn_main.set_sensitive(true);
                in_flight_main.set(false);
            });
        });

        // ---------- Add response --------------------------------------
        let agent_clone = agent.clone();
        let this = self.clone();
        dialog.connect_response(None, move |dlg, response| {
            if response != "add" {
                dlg.close();
                return;
            }
            let name = name_row.text().to_string();
            let host = host_row.text().trim().to_string();
            let port = (port_row.value() as i64).clamp(1, 65535) as u16;
            let toast_target = this.root().and_then(|r| r.downcast::<ZbplWindow>().ok());
            if host.is_empty() {
                if let Some(w) = toast_target.as_ref() {
                    w.toast("Host is required — pick a discovered printer or enter one.");
                }
                return;
            }
            match agent_clone.add_network_printer(&name, &host, port) {
                Ok(uid) => {
                    tracing::info!("AddNetworkPrinter → {uid}");
                    if let Some(w) = toast_target.as_ref() {
                        let display = if name.trim().is_empty() {
                            host.clone()
                        } else {
                            name.clone()
                        };
                        w.toast(&format!("Added {display} ({host}:{port})"));
                    }
                    // Bubble through ZbplWindow.refresh_all so the
                    // Test Prints tab's printer picker + every other
                    // page that lists printers (Logs / About) sees
                    // the new entry without the user having to flip
                    // away and back.
                    refresh_all_pages(&this);
                    dlg.close();
                }
                Err(e) => {
                    tracing::warn!("add_network_printer: {e:#}");
                    if let Some(w) = toast_target.as_ref() {
                        w.toast(&format!("Could not add printer: {e:#}"));
                    }
                }
            }
        });

        if let Some(window) = self.root().and_then(|r| r.downcast::<ZbplWindow>().ok()) {
            dialog.present(Some(&window));
        }
    }

    /// Confirm-then-remove a network printer. AdwAlertDialog with
    /// a destructive Remove response and a Cancel default.
    fn confirm_remove_network_printer(&self, uid: &str, name: &str) {
        let Some(agent) = self.imp().agent.get().cloned() else {
            return;
        };

        let dialog = adw::AlertDialog::new(
            Some(&format!("Remove {name}?")),
            Some(
                "This removes the printer from the agent's configured list. \
                 The printer itself is untouched — you can add it back any \
                 time using its host/IP.",
            ),
        );
        dialog.add_response("cancel", "Cancel");
        dialog.add_response("remove", "Remove");
        dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
        dialog.set_default_response(Some("cancel"));
        dialog.set_close_response("cancel");

        let uid_owned = uid.to_string();
        let name_owned = name.to_string();
        let agent_clone = agent.clone();
        let this = self.clone();
        dialog.connect_response(None, move |dlg, response| {
            if response != "remove" {
                dlg.close();
                return;
            }
            let toast_target = this.root().and_then(|r| r.downcast::<ZbplWindow>().ok());
            match agent_clone.remove_network_printer(&uid_owned) {
                Ok(()) => {
                    if let Some(w) = toast_target.as_ref() {
                        w.toast(&format!("Removed {name_owned}"));
                    }
                    // Same cross-page refresh as the add path —
                    // Test Prints needs to drop the removed entry
                    // from its printer picker.
                    refresh_all_pages(&this);
                }
                Err(e) => {
                    tracing::warn!("remove_network_printer({uid_owned}): {e:#}");
                    if let Some(w) = toast_target.as_ref() {
                        w.toast(&format!("Could not remove: {e:#}"));
                    }
                }
            }
            dlg.close();
        });

        if let Some(window) = self.root().and_then(|r| r.downcast::<ZbplWindow>().ok()) {
            dialog.present(Some(&window));
        }
    }
}

/// Build one labelled row for the quality dialog: a title, a
/// SpinButton with bounds, and an explanatory hint underneath. The
/// quality knobs are unit-less (IPS for speed, abstract 0-30 for
/// darkness) so no live conversion hint is needed — the static hint
/// suffices.
fn make_quality_row(
    label: &str,
    initial: i32,
    min: f64,
    max: f64,
    hint: &str,
) -> (gtk::Box, gtk::SpinButton) {
    let row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
    let col = gtk::Box::new(gtk::Orientation::Vertical, 2);
    col.set_hexpand(true);

    let title = gtk::Label::new(Some(label));
    title.set_halign(gtk::Align::Start);
    col.append(&title);

    let hint_lbl = gtk::Label::new(Some(hint));
    hint_lbl.set_halign(gtk::Align::Start);
    hint_lbl.set_xalign(0.0);
    hint_lbl.add_css_class("dim-label");
    hint_lbl.add_css_class("caption");
    col.append(&hint_lbl);

    row.append(&col);

    let adjustment = gtk::Adjustment::new(initial as f64, min, max, 1.0, 5.0, 0.0);
    let spin = gtk::SpinButton::new(Some(&adjustment), 1.0, 0);
    spin.set_numeric(true);
    spin.set_value(initial as f64);
    spin.set_valign(gtk::Align::Center);
    row.append(&spin);

    (row, spin)
}

/// Build one labelled row for the offset dialog: a title, a
/// SpinButton seeded with `initial` dots, and a small mm hint that
/// updates as the spinner changes. Returns the wrapper Box plus the
/// SpinButton so the caller can read `.value_as_int()` on save.
fn make_offset_row(label: &str, initial: i32, dpi: u32) -> (gtk::Box, gtk::SpinButton) {
    let row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
    let title = gtk::Label::new(Some(label));
    title.set_halign(gtk::Align::Start);
    title.set_width_chars(16);
    row.append(&title);

    // ±200 dots is plenty: 200 / 203 dpi ≈ 25mm = 1 inch. Anything
    // larger would suggest the calibration itself is wrong, not the
    // print position.
    let adjustment = gtk::Adjustment::new(initial as f64, -200.0, 200.0, 1.0, 10.0, 0.0);
    let spin = gtk::SpinButton::new(Some(&adjustment), 1.0, 0);
    spin.set_numeric(true);
    spin.set_value(initial as f64);
    row.append(&spin);

    let hint = gtk::Label::new(None);
    hint.set_halign(gtk::Align::Start);
    hint.add_css_class("dim-label");
    update_mm_hint(&hint, initial, dpi);
    row.append(&hint);

    // Live-update the "≈ X.X mm" hint as the user spins.
    let hint_clone = hint.clone();
    spin.connect_value_changed(move |s| {
        update_mm_hint(&hint_clone, s.value_as_int(), dpi);
    });

    (row, spin)
}

fn update_mm_hint(label: &gtk::Label, dots: i32, dpi: u32) {
    if dpi == 0 {
        label.set_text("");
        return;
    }
    let mm = dots as f32 * 25.4 / dpi as f32;
    label.set_text(&format!("≈ {mm:+.2} mm"));
}

fn which_agent_binary() -> Option<String> {
    // Prefer the agent installed alongside the UI; fall back to
    // PATH. Both .rpm and .deb install to /usr/bin.
    for path in ["/usr/bin/pppro-browser-linux", "/usr/local/bin/pppro-browser-linux"] {
        if std::path::Path::new(path).exists() {
            return Some(path.to_string());
        }
    }
    None
}

/// Heuristic match against the chained error string from a D-Bus
/// Calibrate call. Catches both POSIX EACCES wording and the agent's
/// own "permission denied" log line, plus the path pattern, so a
/// reworded error in the agent doesn't silently drop the
/// Refresh-button hint.
fn looks_like_permission_error(s: &str) -> bool {
    let lower = s.to_ascii_lowercase();
    lower.contains("permission denied")
        || lower.contains("eacces")
        || (lower.contains("/dev/usb/lp") && lower.contains("denied"))
}

/// What the Detect button reports back to the user.
struct Diagnostics {
    /// True iff a Zebra USB device is visible AND has a writable
    /// /dev/usb/lpN node. The agent will be able to print.
    ready: bool,
    /// True iff any Zebra device showed up in lsusb. Distinguishes
    /// "no printer plugged in" from "printer plugged but unauthorised".
    found_zebra: bool,
    /// True iff the suggested fix is to run --fix-perms (i.e. device
    /// is visible but ACL not yet applied).
    fix_with_refresh: bool,
    /// One-line headline shown as the dialog body's primary message.
    summary: String,
    /// Multi-line text dump shown in the scrollable region. Format
    /// is "human-readable + greppable" so a support engineer
    /// pasting this into an email can act on it.
    detail: String,
}

/// Runs three local checks — `lsusb`, `ls /dev/usb/lp*`, and a
/// per-device writability probe. No privilege escalation; everything
/// is readable by a normal desktop user.
fn run_diagnostics() -> Diagnostics {
    use std::path::Path;
    use std::process::Command;

    let mut detail = String::new();
    let mut found_zebra = false;
    let mut writable_lp = Vec::<String>::new();
    let mut unwritable_lp = Vec::<String>::new();

    detail.push_str("=== USB enumeration (lsusb) ===\n");
    match Command::new("lsusb").output() {
        Ok(out) if out.status.success() => {
            let text = String::from_utf8_lossy(&out.stdout);
            let zebra_lines: Vec<&str> = text
                .lines()
                .filter(|l| l.contains("0a5f:") || l.to_lowercase().contains("zebra"))
                .collect();
            if zebra_lines.is_empty() {
                detail.push_str("(no Zebra Technologies devices found)\n");
            } else {
                found_zebra = true;
                for l in &zebra_lines {
                    detail.push_str(l);
                    detail.push('\n');
                }
            }
        }
        Ok(out) => {
            detail.push_str(&format!(
                "lsusb exited with status {:?} — install `usbutils` if missing\n",
                out.status
            ));
        }
        Err(e) => {
            detail.push_str(&format!("lsusb not found: {} — install `usbutils`\n", e));
        }
    }

    detail.push_str("\n=== Kernel device nodes (/dev/usb/lp*) ===\n");
    let lp_paths: Vec<String> = (0..16)
        .map(|i| format!("/dev/usb/lp{i}"))
        .filter(|p| Path::new(p).exists())
        .collect();
    if lp_paths.is_empty() {
        detail.push_str("(none — kernel hasn't created a usblp device for this printer)\n");
        detail.push_str("Possible causes:\n");
        detail.push_str("  - Printer not powered on / cable not seated\n");
        detail.push_str("  - USBGuard or similar policy blocking the device\n");
        detail.push_str("  - usblp kernel module missing (try `sudo modprobe usblp`)\n");
    } else {
        for path in &lp_paths {
            // Permission check: can we open it for write? `access(2)`
            // via std::fs::File::options is the cleanest way.
            let writable = std::fs::OpenOptions::new()
                .write(true)
                .open(path)
                .is_ok();
            if writable {
                writable_lp.push(path.clone());
                detail.push_str(&format!("{path}  WRITABLE\n"));
            } else {
                unwritable_lp.push(path.clone());
                detail.push_str(&format!("{path}  permission denied\n"));
            }
        }
    }

    detail.push_str("\n=== Sysfs vendor IDs ===\n");
    if let Ok(entries) = std::fs::read_dir("/sys/class/usbmisc") {
        for e in entries.flatten() {
            let name = e.file_name().to_string_lossy().into_owned();
            // Walk up the device tree to find idVendor; sysfs lays
            // it out as /sys/class/usbmisc/lpN -> .../device.../idVendor
            if let Ok(real) = e.path().canonicalize() {
                let mut p = real.clone();
                for _ in 0..6 {
                    let v = p.join("idVendor");
                    if v.exists() {
                        if let Ok(s) = std::fs::read_to_string(&v) {
                            detail.push_str(&format!("{name}: idVendor={}", s));
                        }
                        break;
                    }
                    if !p.pop() {
                        break;
                    }
                }
            }
        }
    }

    let ready = !writable_lp.is_empty() && found_zebra;
    let fix_with_refresh = found_zebra && writable_lp.is_empty() && !unwritable_lp.is_empty();

    let summary = if ready {
        format!(
            "Found {} Zebra USB printer(s) and the agent has write access. \
             You can ignore any \"no driver found\" message from your OS settings — \
             Print Platform Pro talks to the printer directly.",
            writable_lp.len()
        )
    } else if fix_with_refresh {
        "A Zebra printer is plugged in, but the agent doesn't have write \
         permission on its USB device yet. Click \"Refresh permissions\" — \
         you'll be asked for your admin password once."
            .to_string()
    } else if found_zebra && writable_lp.is_empty() && unwritable_lp.is_empty() {
        "A Zebra device is visible on the USB bus but the kernel hasn't \
         created /dev/usb/lpN for it. This usually means the printer is \
         in a non-printing mode — try powering it off and on, or check \
         that the cable is fully seated."
            .to_string()
    } else {
        "No Zebra printer detected on USB. Check the cable and that the \
         printer is powered on; then click Detect again."
            .to_string()
    };

    Diagnostics {
        ready,
        found_zebra,
        fix_with_refresh,
        summary,
        detail,
    }
}

/// Render a single discovered-printer row for the Add-Network
/// dialog's results list. Layout: name + host:port + source pill
/// ("probe" or "mdns") + an "Add" arrow icon. Clicking anywhere
/// on the row fills the manual entry fields below the list so the
/// user can rename before committing.
fn build_discovered_row(
    found: &crate::dbus::DiscoveredPrinter,
    name_field: adw::EntryRow,
    host_field: adw::EntryRow,
    port_field: adw::SpinRow,
) -> gtk::ListBoxRow {
    let row = gtk::ListBoxRow::new();
    row.set_selectable(false);
    row.set_activatable(true);

    let outer = gtk::Box::builder()
        .orientation(gtk::Orientation::Horizontal)
        .spacing(12)
        .margin_top(8)
        .margin_bottom(8)
        .margin_start(12)
        .margin_end(12)
        .build();

    // Title + subtitle column.
    let text_col = gtk::Box::new(gtk::Orientation::Vertical, 2);
    text_col.set_hexpand(true);

    let title_label = gtk::Label::new(Some(&found.name));
    title_label.set_xalign(0.0);
    title_label.set_halign(gtk::Align::Start);
    title_label.add_css_class("heading");
    text_col.append(&title_label);

    let is_ipp = found.source == "ipp";
    let mut subtitle = if found.firmware.is_empty() {
        format!("{}:{}", found.host, found.port)
    } else {
        format!("{}:{}  ·  firmware {}", found.host, found.port, found.firmware)
    };
    if is_ipp {
        // IPP printers work, but their feature surface is smaller —
        // the agent can't probe dpi/media-size/status via SGD over
        // IPP (no host-command passthrough). Hint at that in the
        // subtitle so the user understands why the Test Prints row
        // is missing the usual calibrated-dims subtitle.
        subtitle.push_str("  ·  IPP transport (no live status probes)");
    }
    let subtitle_label = gtk::Label::new(Some(&subtitle));
    subtitle_label.set_xalign(0.0);
    subtitle_label.set_halign(gtk::Align::Start);
    subtitle_label.add_css_class("caption");
    subtitle_label.add_css_class("dim-label");
    text_col.append(&subtitle_label);

    outer.append(&text_col);

    // Source pill — "probe" → accent (blue), "mdns" → success
    // (green), "ipp" → warning (orange) to flag that the agent
    // can't yet send jobs to these. Same accent palette used by
    // the Logs tab so the colour vocabulary stays consistent.
    let pill = gtk::Label::new(Some(&found.source.to_uppercase()));
    pill.add_css_class("caption-heading");
    pill.add_css_class("pill");
    pill.add_css_class(match found.source.as_str() {
        "mdns" => "success",
        "ipp" => "warning",
        _ => "accent",
    });
    pill.set_valign(gtk::Align::Center);
    outer.append(&pill);

    // Arrow icon hints "click to use" for every result row.
    let arrow = gtk::Image::from_icon_name("go-next-symbolic");
    arrow.set_pixel_size(16);
    arrow.add_css_class("dim-label");
    outer.append(&arrow);

    row.set_child(Some(&outer));

    if is_ipp {
        // IPP rows have the same click-to-fill behaviour as the
        // raw + mdns paths now that the agent's writeIPP backend
        // ships — the printer ends up in network-printers.json
        // with port 631, and PrintZpl routes through the IPP
        // Print-Job path. Tooltip just explains the transport.
        row.set_tooltip_text(Some(
            "Zebra IPP-only printer (ZSB series). The agent sends ZPL inside an IPP \
             Print-Job request — printing works, but live status / dimensions \
             aren't available because IPP doesn't tunnel SGD host-commands.",
        ));
    }

    // Click-to-fill (raw + mdns + ipp rows): gtk::GestureClick captures the
    // press anywhere on the row (the row itself is activatable but
    // Activate only fires when the user keyboard-tabs to it; gesture
    // covers both mouse + touch + key).
    let name_val = found.name.clone();
    let host_val = found.host.clone();
    let port_val = found.port;
    let click = gtk::GestureClick::new();
    click.connect_pressed(move |_gesture, _n_press, _x, _y| {
        name_field.set_text(&name_val);
        host_field.set_text(&host_val);
        port_field.set_value(port_val as f64);
    });
    row.add_controller(click);

    row
}

/// Bubble a refresh to ZbplWindow so every page that lists printers
/// re-pulls. Used by add/remove network-printer flows on the Printers
/// page — without this the Test Prints tab's printer picker shows
/// the pre-mutation list until the next tab activation.
fn refresh_all_pages(page: &ZbplPrintersPage) {
    if let Some(window) = page.root().and_then(|r| r.downcast::<ZbplWindow>().ok()) {
        window.refresh_all();
    } else {
        // Fallback if we're somehow not yet attached to a window —
        // at least refresh ourselves so the local state is current.
        page.refresh();
    }
}

/// Launch the host desktop's Bluetooth settings panel. Tries the
/// common control-centre invocations in order; succeeds on the first
/// one whose binary spawns cleanly. Each candidate uses
/// `Command::spawn` (not `output`) so we don't block on the
/// long-running panel process — we just want to fire it and return.
///
/// Order rationale:
///   - GNOME `gnome-control-center bluetooth` — dominant Linux
///     desktop, covers Fedora / Ubuntu / Debian default installs.
///   - KDE `kcmshell6 kcm_bluetooth` / `systemsettings5 bluetooth` —
///     Plasma 6 and Plasma 5 respectively.
///   - XFCE / Cinnamon / generic — `blueman-manager` (the de-facto
///     standalone Bluetooth manager bundled on most non-GNOME/KDE
///     desktops).
///   - `gnome-control-center` alone — fall back to opening the
///     control centre with no panel selected; user is one click away.
///
/// Returns Err only when none of the candidates exist on PATH; the
/// caller surfaces that via a toast so the user knows to open the
/// panel manually.
fn open_bluetooth_settings() -> std::io::Result<()> {
    use std::io::ErrorKind;
    use std::process::{Command, Stdio};

    // Each tuple is (program, args). Tried in order; first one that
    // spawns wins.
    let candidates: &[(&str, &[&str])] = &[
        ("gnome-control-center", &["bluetooth"]),
        ("kcmshell6", &["kcm_bluetooth"]),
        ("systemsettings5", &["bluetooth"]),
        ("blueman-manager", &[]),
        ("gnome-control-center", &[]),
    ];
    for (prog, args) in candidates {
        let result = Command::new(prog)
            .args(*args)
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn();
        match result {
            Ok(_child) => return Ok(()),
            Err(e) if e.kind() == ErrorKind::NotFound => continue,
            Err(e) => return Err(e),
        }
    }
    Err(std::io::Error::new(
        ErrorKind::NotFound,
        "no known Bluetooth settings panel found on PATH",
    ))
}
