//! "Test Prints" page — picks a printer, picks a dpi, fires demo
//! ZPL templates from `crate::demos` at it via `PrintZpl`.
//!
//! Composition responsibilities:
//!
//!   1. Render one selectable ActionRow per printer (radio-grouped),
//!      synced with `ListPrinters` from the agent on refresh().
//!   2. Resolve the active dpi: either auto-detect via the agent's
//!      `GetPrinterDpi` (which talks SGD to the printer) or use the
//!      user's manual override (203 / 300).
//!   3. Render one ActionRow per demo from `demos::ALL`. Click → push
//!      ZPL to `agent.print_zpl(uid, ...)`. Toast on result.

use crate::dbus::{AgentClient, PrinterEntry};
use crate::demos;
use crate::dims::{full_subtitle, query_printer_dims, PrinterDims};
use crate::window::ZbplWindow;
use adw::prelude::*;
use adw::subclass::prelude::*;
use gtk::{glib, CompositeTemplate};
use std::cell::{Cell, OnceCell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use std::thread;

/// Per-row widget refs the async-fill needs to mutate after the row
/// has been added to the PreferencesGroup.
#[derive(Debug)]
pub struct TestPrintsRowRefs {
    pub row: adw::ActionRow,
    pub radio: gtk::CheckButton,
    pub spinner: gtk::Spinner,
    pub uid: String,
}

mod imp {
    use super::*;

    #[derive(Debug, Default, CompositeTemplate)]
    #[template(resource = "/com/printplatformpro/BrowserAgent/ui/test_prints_page.ui")]
    pub struct ZbplTestPrintsPage {
        #[template_child]
        pub printers_group: TemplateChild<adw::PreferencesGroup>,
        #[template_child]
        pub dpi_row: TemplateChild<adw::ComboRow>,
        #[template_child]
        pub demos_group: TemplateChild<adw::PreferencesGroup>,

        pub agent: OnceCell<Rc<AgentClient>>,
        // Mirror of the printer rows currently rendered; same order
        // as the ActionRows in printers_group so we can resolve the
        // selected index back to a UID.
        pub printers: RefCell<Vec<PrinterEntry>>,
        // Per-UID SGD-queried dims, populated on every refresh().
        // Stored as a map (not a parallel Vec) so re-ordering of
        // the printer list doesn't shift dim indices out from
        // under us.
        pub printer_dims: RefCell<HashMap<String, PrinterDims>>,
        // Per-printer ActionRow + its radio CheckButton, tracked so
        // refresh() can clear the old rows and rebuild without
        // walking PreferencesGroup's internal layout. The
        // CheckButton is used to read which row is selected, and
        // the per-row Spinner lets us hide it once SGD answers.
        pub printer_rows: RefCell<Vec<TestPrintsRowRefs>>,
        // Monotonic refresh token; mismatched workers drop their
        // results so a stale answer doesn't clobber fresh rows.
        pub generation: Cell<u64>,
        // Optional fallback row shown when zero printers are present.
        // Kept separately so the printer-list rebuild doesn't
        // accidentally remove it.
        pub empty_row: RefCell<Option<adw::ActionRow>>,
        // Index into `printers` that is currently selected. usize::MAX
        // sentinel = nothing selected.
        pub selected_index: Cell<usize>,
        // Demo rows we've added to demos_group, tracked so refresh()
        // can clear and rebuild without walking PreferencesGroup's
        // internal layout (same gotcha as printers.rs).
        pub demo_rows: RefCell<Vec<adw::ActionRow>>,
        // True once we've populated the demo list at least once.
        // Cheap idempotency guard.
        pub demos_populated: Cell<bool>,
    }

    #[glib::object_subclass]
    impl ObjectSubclass for ZbplTestPrintsPage {
        const NAME: &'static str = "ZbplTestPrintsPage";
        type Type = super::ZbplTestPrintsPage;
        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 ZbplTestPrintsPage {}
    impl WidgetImpl for ZbplTestPrintsPage {}
    impl BinImpl for ZbplTestPrintsPage {}
}

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

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

        // Re-render the dpi indicator subtitle when the user toggles
        // between auto / 203 / 300.
        let this = self.clone();
        imp.dpi_row.connect_selected_notify(move |_row| {
            this.refresh_dpi_subtitle();
        });

        self.populate_demos();
        self.refresh();
    }

    /// Rebuild the printer rows from `ListPrinters`. Called on bind
    /// and again whenever something elsewhere in the app changes
    /// the printer set (e.g. Refresh on the Printers tab, network
    /// printer added via CLI).
    ///
    /// Rows render IMMEDIATELY with placeholder subtitles (serial
    /// only) + a per-row spinner. SGD queries run on a worker
    /// thread and stream back via async-channel so the window
    /// stays responsive while dims/status load.
    pub fn refresh(&self) {
        let imp = self.imp();
        let Some(agent) = imp.agent.get().cloned() else { return };

        let printers = match agent.list_printers() {
            Ok(v) => v,
            Err(e) => {
                tracing::warn!("test_prints list_printers: {e:?}");
                Vec::new()
            }
        };

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

        // Preserve selection across refreshes (e.g. user clicks
        // Refresh on the Printers tab).
        let prev_uid = self.selected_uid();

        // Tear down the old printer rows + empty row.
        {
            let mut rows = imp.printer_rows.borrow_mut();
            for refs in rows.drain(..) {
                imp.printers_group.remove(&refs.row);
            }
        }
        if let Some(row) = imp.empty_row.borrow_mut().take() {
            imp.printers_group.remove(&row);
        }

        if printers.is_empty() {
            let row = adw::ActionRow::new();
            row.set_title("No printers connected");
            row.set_subtitle("Plug a Zebra USB printer in, or register a network printer via the CLI.");
            let icon = gtk::Image::from_icon_name("printer-symbolic");
            icon.set_pixel_size(20);
            row.add_prefix(&icon);
            imp.printers_group.add(&row);
            *imp.empty_row.borrow_mut() = Some(row);

            imp.printers.replace(Vec::new());
            imp.printer_dims.replace(HashMap::new());
            imp.selected_index.set(usize::MAX);
            self.refresh_dpi_subtitle();
            return;
        }

        // Reset the dims cache — every row will be re-populated by
        // the worker. Leaving stale entries would let `selected_dpi`
        // return a value for a printer whose row hasn't filled yet.
        imp.printer_dims.replace(HashMap::new());

        // Build a fresh row per printer with placeholder content
        // (serial line + spinning indicator). The async fill below
        // will fold in the dims/status lines as they arrive.
        let mut new_rows: Vec<TestPrintsRowRefs> = Vec::with_capacity(printers.len());
        let mut first_radio: Option<gtk::CheckButton> = None;
        for (idx, p) in printers.iter().enumerate() {
            let row = adw::ActionRow::new();
            row.set_title(&p.name);
            row.set_subtitle(&full_subtitle(p, None));

            // Default prefix icon — async fill will swap in the
            // error/warning variants once status is known.
            let icon = gtk::Image::from_icon_name("printer-symbolic");
            icon.set_pixel_size(20);
            row.add_prefix(&icon);

            // Inline spinner shown until SGD answers. add_suffix
            // adds widgets to the trailing-end box, so positioning
            // matters: we want spinner first, then radio. They'll
            // appear in that order left-to-right in the trailing box.
            let spinner = gtk::Spinner::new();
            spinner.set_valign(gtk::Align::Center);
            spinner.start();
            row.add_suffix(&spinner);

            let radio = gtk::CheckButton::new();
            radio.set_valign(gtk::Align::Center);
            if let Some(first) = first_radio.as_ref() {
                radio.set_group(Some(first));
            }
            row.add_suffix(&radio);
            row.set_activatable_widget(Some(&radio));

            // Selection change handler — only fire on transition to
            // active, since groups always fire one toggle-on and one
            // toggle-off per click.
            let this = self.clone();
            radio.connect_toggled(move |b| {
                if b.is_active() {
                    this.on_row_selected(idx);
                }
            });

            imp.printers_group.add(&row);
            if first_radio.is_none() {
                first_radio = Some(radio.clone());
            }
            new_rows.push(TestPrintsRowRefs {
                row,
                radio,
                spinner,
                uid: p.uid.clone(),
            });
        }

        // Choose which row to activate: prior selection if still
        // present, else the first row.
        let restore_idx = prev_uid
            .as_deref()
            .and_then(|uid| printers.iter().position(|p| p.uid == uid))
            .unwrap_or(0);

        let uids: Vec<String> = printers.iter().map(|p| p.uid.clone()).collect();
        imp.printers.replace(printers);
        imp.printer_rows.replace(new_rows);
        // Setting the radio fires the toggled handler, which calls
        // on_row_selected → subtitle refresh.
        if let Some(refs) = imp.printer_rows.borrow().get(restore_idx) {
            refs.radio.set_active(true);
        }

        // Kick off the off-thread SGD fill. Results stream back via
        // async-channel and the main-loop drain updates each row.
        self.spawn_dims_worker(uids, gen);
    }

    /// Background fill: query SGD for each UID on a worker thread,
    /// stream results back via async-channel, route each to its row.
    fn spawn_dims_worker(&self, uids: Vec<String>, gen: u64) {
        let (tx, rx) = async_channel::unbounded::<(String, PrinterDims)>();

        thread::spawn(move || {
            let agent = match AgentClient::connect() {
                Ok(a) => a,
                Err(e) => {
                    tracing::warn!("test_prints worker: connect: {e:#}");
                    return;
                }
            };
            for uid in uids {
                let dims = query_printer_dims(&agent, &uid);
                if tx.send_blocking((uid, dims)).is_err() {
                    return;
                }
            }
        });

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

    /// Fold a freshly-fetched PrinterDims into the matching row:
    /// rewrite subtitle, stop the spinner, refresh dpi indicator if
    /// the row is the currently-selected one.
    fn apply_dims_to_row(&self, uid: String, dims: PrinterDims) {
        let imp = self.imp();

        // Update the dims cache so selected_dpi() / send_demo() see
        // the new value.
        imp.printer_dims.borrow_mut().insert(uid.clone(), dims);

        // Find the matching row + the matching PrinterEntry.
        let rows = imp.printer_rows.borrow();
        let Some(refs) = rows.iter().find(|r| r.uid == uid) else { return };
        let printers = imp.printers.borrow();
        let Some(p) = printers.iter().find(|p| p.uid == uid) else { return };

        // Rebuild subtitle text.
        let cache = imp.printer_dims.borrow();
        let updated = cache.get(&uid);
        refs.row.set_subtitle(&full_subtitle(p, updated));
        refs.spinner.stop();
        refs.spinner.set_visible(false);

        // If this is the selected row, refresh the dpi-indicator
        // line below the printer list.
        let sel = imp.selected_index.get();
        if sel != usize::MAX && printers.get(sel).map(|p| p.uid.as_str()) == Some(&uid) {
            drop(cache);
            drop(printers);
            drop(rows);
            self.refresh_dpi_subtitle();
        }
    }

    /// Called when the user clicks a printer row's radio. We don't
    /// requery dims here — every row already has them rendered from
    /// the up-front pass in refresh(). All we need to do is record
    /// the new index and refresh the dpi-row subtitle (which depends
    /// on whichever printer is currently selected).
    fn on_row_selected(&self, idx: usize) {
        self.imp().selected_index.set(idx);
        self.refresh_dpi_subtitle();
    }

    /// Return the cached dpi for the currently-selected printer, or
    /// 0 if nothing is selected / SGD failed. We only need the dpi
    /// number from the cached dims at most call-sites, not the whole
    /// struct, so this avoids cloning the inner Vec<String> lists.
    fn selected_dpi(&self) -> u32 {
        self.selected_uid()
            .and_then(|uid| {
                self.imp()
                    .printer_dims
                    .borrow()
                    .get(&uid)
                    .map(|d| d.dpi)
            })
            .unwrap_or(0)
    }

    fn populate_demos(&self) {
        let imp = self.imp();
        if imp.demos_populated.get() {
            return;
        }
        imp.demos_populated.set(true);

        // Demos used to live in a 1000-line Rust file; now we
        // delegate to the agent's HTTP catalogue so any spec tweak
        // only has to land in `internal/demos/`. If the agent isn't
        // up the list is empty + a single explainer row appears.
        let demos = match demos::fetch_all() {
            Ok(d) => d,
            Err(e) => {
                tracing::warn!("demos::fetch_all: {e:?}");
                let row = adw::ActionRow::new();
                row.set_title("Demos unavailable");
                row.set_subtitle(
                    "Agent isn't reachable at http://127.0.0.1:9100/__demos. \
                     Open Settings → Status → Restart agent.",
                );
                imp.demos_group.add(&row);
                imp.demo_rows.borrow_mut().push(row);
                return;
            }
        };

        for demo in demos {
            let row = adw::ActionRow::new();
            row.set_title(&demo.title);
            row.set_subtitle(&format!(
                "{}  \u{00b7}  {}",
                demo.label_size, demo.description
            ));

            let icon = gtk::Image::from_icon_name("document-print-symbolic");
            icon.set_pixel_size(20);
            row.add_prefix(&icon);

            let send_btn = gtk::Button::with_label("Send");
            send_btn.set_valign(gtk::Align::Center);
            send_btn.add_css_class("suggested-action");

            let demo_clone = demo.clone();
            let this = self.clone();
            send_btn.connect_clicked(move |btn| {
                btn.set_sensitive(false);
                this.send_demo(&demo_clone);
                btn.set_sensitive(true);
            });

            row.add_suffix(&send_btn);
            row.set_activatable_widget(Some(&send_btn));

            imp.demos_group.add(&row);
            imp.demo_rows.borrow_mut().push(row);
        }
    }

    fn send_demo(&self, demo: &demos::Demo) {
        let imp = self.imp();
        let Some(agent) = imp.agent.get().cloned() else { return };
        let toast_target = self.root().and_then(|r| r.downcast::<ZbplWindow>().ok());

        let Some(uid) = self.selected_uid() else {
            if let Some(w) = toast_target.as_ref() {
                w.toast("Pick a printer first.");
            }
            return;
        };

        let dpi = self.active_dpi();
        // ZPL is built by the agent now — fetch over HTTP so the
        // Mac/Windows/Linux flavours all share one renderer.
        let zpl = match demos::fetch_zpl(&demo.id, dpi) {
            Ok(z) => z,
            Err(e) => {
                tracing::warn!("demos::fetch_zpl({}, {dpi}): {e:?}", demo.id);
                if let Some(w) = toast_target.as_ref() {
                    w.toast(&format!("Could not load demo: {e}"));
                }
                return;
            }
        };
        let bytes = zpl.into_bytes();

        match agent.print_zpl(&uid, &bytes) {
            Ok(()) => {
                if let Some(w) = toast_target.as_ref() {
                    w.toast(&format!("Sent “{}” at {} dpi", demo.title, dpi));
                }
            }
            Err(e) => {
                let raw = format!("{e:#}");
                tracing::warn!("print_zpl({uid}, {}): {raw}", demo.id);
                let msg = if raw.to_ascii_lowercase().contains("permission denied")
                    || raw.to_ascii_lowercase().contains("eacces")
                {
                    "Send failed: permission denied. Click Refresh on the Printers tab.".to_string()
                } else {
                    format!("Send failed: {raw}")
                };
                if let Some(w) = toast_target.as_ref() {
                    w.toast(&msg);
                }
            }
        }
    }

    fn selected_uid(&self) -> Option<String> {
        let imp = self.imp();
        let idx = imp.selected_index.get();
        if idx == usize::MAX {
            return None;
        }
        imp.printers.borrow().get(idx).map(|p| p.uid.clone())
    }

    fn refresh_dpi_subtitle(&self) {
        let imp = self.imp();
        let detected = self.selected_dpi();
        let subtitle = match imp.dpi_row.selected() {
            0 => {
                if detected > 0 {
                    format!("Auto-detected: {detected} dpi")
                } else if self.selected_uid().is_some() {
                    "Auto-detect failed; pick a manual override below.".to_string()
                } else {
                    "Detected from the printer".to_string()
                }
            }
            1 => "Manual: 203 dpi".to_string(),
            2 => "Manual: 300 dpi".to_string(),
            _ => String::new(),
        };
        imp.dpi_row.set_subtitle(&subtitle);
    }

    /// Currently-effective dpi, used to render demo ZPL. Falls back
    /// to 203 if auto-detect failed and the user hasn't picked an
    /// override.
    fn active_dpi(&self) -> u32 {
        let imp = self.imp();
        match imp.dpi_row.selected() {
            1 => 203,
            2 => 300,
            _ => {
                let d = self.selected_dpi();
                if d > 0 {
                    d
                } else {
                    203
                }
            }
        }
    }
}


