//! Logs page — merged ZPL + agent runtime timeline.
//!
//! Mirrors the macOS Settings app's Logs tab so an operator with
//! installs on both platforms sees the same thing. The agent already
//! captures both streams server-side; this view tails both via the
//! agent's `/__logs/zpl?limit=N` and `/__logs/agent?limit=N` HTTP
//! endpoints (loopback HTTP on 127.0.0.1:9100), merges them
//! client-side, and renders newest-at-top.
//!
//! Threading: HTTP fetches run on a one-shot worker thread (via
//! `std::thread::spawn` + sync `ureq`). Results travel back over an
//! `async_channel` that the GTK main loop drains via
//! `glib::spawn_future_local`. Matches the blocking-zbus + worker-
//! thread pattern used elsewhere in this crate; avoids dragging
//! tokio in.
//!
//! Polling: a `glib::source::timeout_add_local` fires every 3s and
//! kicks a fresh fetch. We don't gate by tab visibility — the
//! loopback HTTP cost is sub-millisecond and the merged JSON is a
//! few KB, so always-on polling keeps the implementation simple.
//! If perf becomes an issue we can observe the parent ViewStack's
//! `notify::visible-child-name` and pause when invisible.

use crate::dbus::AgentClient;
use crate::window::ZbplWindow;
use adw::prelude::*;
use adw::subclass::prelude::*;
use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc};
use gtk::{gio, glib, CompositeTemplate};
use serde::Deserialize;
use std::cell::{Cell, OnceCell, RefCell};
use std::rc::Rc;
use std::thread;
use std::time::Duration;

/// Where to talk to the agent. Loopback HTTP only — no need for the
/// HTTPS port the browser uses; this is an internal Settings UI.
const AGENT_BASE_URL: &str = "http://127.0.0.1:9100";
/// Refresh cadence. Matches the macOS Logs tab.
const POLL_INTERVAL: Duration = Duration::from_secs(3);
/// Per-stream entry caps. Mac uses the same numbers.
const ZPL_LIMIT: usize = 500;
const AGENT_LIMIT: usize = 1000;
/// Mac-line parsing is fixed-width: `YYYY/MM/DD HH:MM:SS.SSSSSS `.
/// Anything shorter than 27 chars can't carry a timestamp.
const AGENT_LINE_PREFIX_LEN: usize = 27;

// -------------------------------------------------------------------
// JSON models — match the shapes emitted by internal/server/logs_route.go
// -------------------------------------------------------------------

#[derive(Debug, Deserialize)]
struct ZplLogResponse {
    #[serde(default)]
    path: String,
    #[serde(default)]
    entries: Vec<ZplLogEntry>,
    #[serde(default)]
    error: Option<String>,
}

#[derive(Debug, Deserialize, Clone)]
pub struct ZplLogEntry {
    ts: String,
    src: String,
    uid: String,
    #[serde(default)]
    origin: String,
    #[serde(default)]
    bytes: i64,
    #[serde(default)]
    zpl: String,
    #[serde(default)]
    truncated: bool,
    #[serde(default)]
    error: String,
}

#[derive(Debug, Deserialize)]
struct AgentLogResponse {
    #[serde(default)]
    path: String,
    #[serde(default)]
    entries: Vec<String>,
    #[serde(default)]
    note: Option<String>,
    #[serde(default)]
    error: Option<String>,
}

// -------------------------------------------------------------------
// Unified merged entry — agnostic of source, sortable by timestamp.
// -------------------------------------------------------------------

#[derive(Debug, Clone)]
pub enum UnifiedEntry {
    Zpl {
        ts: DateTime<Utc>,
        entry: ZplLogEntry,
    },
    Agent {
        ts: DateTime<Utc>,
        text: String,
    },
}

impl UnifiedEntry {
    fn timestamp(&self) -> DateTime<Utc> {
        match self {
            Self::Zpl { ts, .. } => *ts,
            Self::Agent { ts, .. } => *ts,
        }
    }

    /// Lower-cased searchable blob — every field a user might
    /// reasonably type into the filter, flattened.
    fn search_text(&self) -> String {
        match self {
            Self::Zpl { entry, .. } => format!(
                "{} {} {} {} {}",
                entry.uid, entry.src, entry.origin, entry.zpl, entry.error
            )
            .to_lowercase(),
            Self::Agent { text, .. } => text.to_lowercase(),
        }
    }

    /// Diff-friendly plain text used for clipboard + save export.
    /// Designed to match the Mac version's output so operators
    /// pasting from either platform feed the same grep into a
    /// pipeline.
    fn plain_text(&self) -> String {
        let ts = self
            .timestamp()
            .with_timezone(&Local)
            .format("%Y-%m-%d %H:%M:%S%.3f");
        match self {
            Self::Zpl { entry, .. } => {
                let origin = if entry.origin.is_empty() {
                    String::new()
                } else {
                    format!(" origin={}", entry.origin)
                };
                let truncated = if entry.truncated {
                    " (truncated to 16KB)"
                } else {
                    ""
                };
                let err = if entry.error.is_empty() {
                    String::new()
                } else {
                    format!(" ERROR={}", entry.error)
                };
                let zpl = entry.zpl.replace('\n', " ");
                format!(
                    "{} [ZPL  ] uid={} src={} bytes={}{}{}{}\n    {}",
                    ts, entry.uid, entry.src, entry.bytes, origin, truncated, err, zpl
                )
            }
            Self::Agent { text, .. } => format!("{} [AGENT] {}", ts, text),
        }
    }
}

/// Result of one fetch round, posted from the worker thread to the
/// main loop via `async_channel`.
#[derive(Debug)]
struct FetchResult {
    zpl_path: String,
    agent_path: String,
    agent_note: Option<String>,
    merged: Vec<UnifiedEntry>,
    error: Option<String>,
}

// -------------------------------------------------------------------
// GObject subclass + template wiring
// -------------------------------------------------------------------

mod imp {
    use super::*;

    #[derive(Debug, Default, CompositeTemplate)]
    #[template(resource = "/com/printplatformpro/BrowserAgent/ui/logs_page.ui")]
    pub struct ZbplLogsPage {
        #[template_child]
        pub search_entry: TemplateChild<gtk::SearchEntry>,
        #[template_child]
        pub refresh_button: TemplateChild<gtk::Button>,
        #[template_child]
        pub copy_button: TemplateChild<gtk::Button>,
        #[template_child]
        pub save_button: TemplateChild<gtk::Button>,
        #[template_child]
        pub count_label: TemplateChild<gtk::Label>,
        #[template_child]
        pub list_box: TemplateChild<gtk::ListBox>,

        /// AgentClient lives here just for parity with sibling
        /// pages. The Logs tab itself talks HTTP, not D-Bus.
        pub agent: OnceCell<Rc<AgentClient>>,

        /// Merged + sorted entries (oldest→newest). Rendered
        /// reversed so newest sits at the top of the list.
        pub entries: RefCell<Vec<UnifiedEntry>>,

        /// Most recent search query (lower-cased). Empty = no filter.
        pub search_text: RefCell<String>,

        /// Last known paths from the agent — used to source the
        /// "Save full log" download streams.
        pub zpl_path: RefCell<String>,
        pub agent_path: RefCell<String>,

        /// Hint from the agent when /agent isn't backed by a file
        /// (older installs without ZBPL_LOG_PATH set). Shown as
        /// part of the status line so the user understands why no
        /// agent rows appear.
        pub agent_note: RefCell<Option<String>>,

        /// Last error from the polling worker, surfaced in the
        /// status line so an unreachable agent doesn't look like
        /// "no entries".
        pub last_error: RefCell<Option<String>>,

        /// Cleared in dispose() so the timeout closure stops
        /// firing once the page is destroyed.
        pub poll_source: RefCell<Option<glib::SourceId>>,

        /// Set while a worker fetch is in flight so the 3s tick
        /// doesn't pile up overlapping requests if the agent
        /// stalls.
        pub fetch_in_flight: Cell<bool>,
    }

    #[glib::object_subclass]
    impl ObjectSubclass for ZbplLogsPage {
        const NAME: &'static str = "ZbplLogsPage";
        type Type = super::ZbplLogsPage;
        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 ZbplLogsPage {
        fn dispose(&self) {
            // Cancel the 3-second polling timer so it stops trying
            // to fetch after the page widget has been torn down
            // (which would otherwise leak the worker channel +
            // closure until the GLib main context cleans them up).
            if let Some(id) = self.poll_source.borrow_mut().take() {
                id.remove();
            }
        }
    }
    impl WidgetImpl for ZbplLogsPage {}
    impl BinImpl for ZbplLogsPage {}
}

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

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

        // Search → re-render filtered list. The fetch worker keeps
        // pushing new merged entries; this just changes the slice
        // of `entries` that gets rendered.
        let this = self.clone();
        imp.search_entry.connect_search_changed(move |entry| {
            *this.imp().search_text.borrow_mut() = entry.text().trim().to_lowercase();
            this.rebuild_visible_rows();
        });

        // Refresh button — kick an immediate fetch on top of the
        // running 3s tick.
        let this = self.clone();
        imp.refresh_button.connect_clicked(move |_| {
            this.spawn_fetch();
        });

        // Copy filtered log to clipboard.
        let this = self.clone();
        imp.copy_button.connect_clicked(move |_| {
            this.copy_to_clipboard();
        });

        // Save full log via gtk::FileDialog (modern GTK4 replacement
        // for the deprecated FileChooserNative).
        let this = self.clone();
        imp.save_button.connect_clicked(move |_| {
            this.save_full_log();
        });

        // Spin up the main-loop channel drain + the periodic timer.
        // Both are torn down by `dispose()`.
        self.start_polling();

        // Initial fetch so the tab isn't blank for 3s on first
        // open.
        self.spawn_fetch();
    }

    /// Page-level refresh hook called by ZbplWindow::refresh_all.
    /// Just kick an out-of-band fetch; the periodic timer keeps
    /// running independently.
    pub fn refresh(&self) {
        self.spawn_fetch();
    }

    // --------------------------------------------------------------
    // Polling lifecycle
    // --------------------------------------------------------------

    fn start_polling(&self) {
        let imp = self.imp();

        // Main-loop channel: worker thread → main loop. Unbounded
        // because we only ever push one result per fetch and the
        // main loop drains promptly.
        let (tx, rx) = async_channel::unbounded::<FetchResult>();

        // Stash the sender so spawn_fetch() can clone it.
        imp.agent.get(); // touch — silence unused warning if compiler complains
        // We store tx in a RefCell on `self` so spawn_fetch can pull
        // it back out. Adding it to `imp` requires a field; do that
        // via a thread-local mini-cache keyed by `self.as_ptr()` —
        // too much rigmarole. Simpler: stash on self via the
        // existing OnceCell pattern.
        SENDER.with(|cell| {
            // SAFETY: GTK main loop is single-threaded. SENDER is a
            // thread-local. Each ZbplLogsPage instance gets its own
            // sender; we key by the underlying GObject pointer so a
            // theoretical multi-instance case (we only ever have one)
            // wouldn't clobber.
            cell.borrow_mut()
                .insert(self.as_ptr() as usize, tx.clone());
        });

        // Drain the channel from the main loop. spawn_future_local
        // runs the future on the default main context (which is the
        // GTK thread).
        let this = self.clone();
        glib::spawn_future_local(async move {
            while let Ok(result) = rx.recv().await {
                this.apply_result(result);
            }
        });

        // 3-second tick.
        let this = self.clone();
        let id = glib::timeout_add_local(POLL_INTERVAL, move || {
            this.spawn_fetch();
            glib::ControlFlow::Continue
        });
        imp.poll_source.borrow_mut().replace(id);
    }

    fn spawn_fetch(&self) {
        let imp = self.imp();
        if imp.fetch_in_flight.get() {
            return;
        }
        let Some(tx) = SENDER.with(|cell| {
            cell.borrow().get(&(self.as_ptr() as usize)).cloned()
        }) else {
            // start_polling() not run yet — bind_agent should have
            // wired this up; nothing to do.
            return;
        };
        imp.fetch_in_flight.set(true);
        thread::spawn(move || {
            let result = fetch_both_streams();
            // Send the result; ignore send-errors (means the
            // receiver was dropped, i.e. page is being torn down).
            // The main-loop drain in start_polling will call
            // apply_result, which clears fetch_in_flight. If the
            // page is gone the flag is moot anyway.
            let _ = tx.send_blocking(result);
        });
    }

    // --------------------------------------------------------------
    // Apply a fetched result to the model + view
    // --------------------------------------------------------------

    fn apply_result(&self, result: FetchResult) {
        let imp = self.imp();
        // Clear the in-flight gate so the next 3-second tick is
        // allowed to fire. apply_result runs on the GTK main loop
        // (via spawn_future_local on the channel), so this is the
        // safe place to touch !Send state.
        imp.fetch_in_flight.set(false);
        *imp.zpl_path.borrow_mut() = result.zpl_path;
        *imp.agent_path.borrow_mut() = result.agent_path;
        *imp.agent_note.borrow_mut() = result.agent_note;
        *imp.last_error.borrow_mut() = result.error;
        *imp.entries.borrow_mut() = result.merged;
        self.rebuild_visible_rows();
    }

    fn rebuild_visible_rows(&self) {
        let imp = self.imp();
        let entries = imp.entries.borrow();
        let query = imp.search_text.borrow();

        // Clear the list. remove_all() is GTK 4.12+ which we pin in
        // Cargo features.
        imp.list_box.remove_all();

        let filtered: Vec<&UnifiedEntry> = if query.is_empty() {
            entries.iter().collect()
        } else {
            entries
                .iter()
                .filter(|e| e.search_text().contains(query.as_str()))
                .collect()
        };

        // Render newest-first.
        for entry in filtered.iter().rev() {
            let row = match entry {
                UnifiedEntry::Zpl { entry, .. } => build_zpl_row(entry),
                UnifiedEntry::Agent { ts, text } => build_agent_row(*ts, text),
            };
            imp.list_box.append(&row);
        }

        // Status line.
        let total = entries.len();
        let shown = filtered.len();
        let status = if let Some(err) = imp.last_error.borrow().as_ref() {
            format!("Couldn't load logs: {err}")
        } else if total == 0 {
            "No log entries yet — try the Test Prints tab or restart the agent.".to_string()
        } else if query.is_empty() {
            let note_suffix = imp
                .agent_note
                .borrow()
                .as_ref()
                .map(|n| format!(" · {n}"))
                .unwrap_or_default();
            format!("Showing {total} entries{note_suffix}")
        } else {
            format!("Showing {shown} of {total} entries")
        };
        imp.count_label.set_text(&status);

        // Disable copy/save when nothing to act on.
        imp.copy_button.set_sensitive(shown > 0);
        imp.save_button.set_sensitive(total > 0);
    }

    // --------------------------------------------------------------
    // Clipboard + save
    // --------------------------------------------------------------

    fn copy_to_clipboard(&self) {
        let imp = self.imp();
        let entries = imp.entries.borrow();
        let query = imp.search_text.borrow();
        let text: String = entries
            .iter()
            .filter(|e| query.is_empty() || e.search_text().contains(query.as_str()))
            .map(|e| e.plain_text())
            .collect::<Vec<_>>()
            .join("\n");
        if text.is_empty() {
            return;
        }
        let clipboard = self.clipboard();
        clipboard.set_text(&text);
        if let Some(window) = self.root().and_then(|r| r.downcast::<ZbplWindow>().ok()) {
            window.toast("Log copied to clipboard");
        }
    }

    fn save_full_log(&self) {
        let imp = self.imp();
        let text: String = imp
            .entries
            .borrow()
            .iter()
            .map(|e| e.plain_text())
            .collect::<Vec<_>>()
            .join("\n");
        if text.is_empty() {
            return;
        }
        let ts = Local::now().format("%Y%m%d-%H%M%S");
        let suggested = format!("pppro-merged-log-{ts}.txt");

        let dialog = gtk::FileDialog::builder()
            .title("Save merged log")
            .initial_name(&suggested)
            .modal(true)
            .build();

        let parent_window = self.root().and_then(|r| r.downcast::<gtk::Window>().ok());
        let this = self.clone();
        dialog.save(
            parent_window.as_ref(),
            None::<&gio::Cancellable>,
            move |result| {
                let Ok(file) = result else { return };
                let Some(path) = file.path() else { return };
                if let Err(e) = std::fs::write(&path, &text) {
                    tracing::error!("save merged log: {e}");
                    if let Some(w) = this.root().and_then(|r| r.downcast::<ZbplWindow>().ok())
                    {
                        w.toast(&format!("Couldn't save log: {e}"));
                    }
                    return;
                }
                if let Some(w) = this.root().and_then(|r| r.downcast::<ZbplWindow>().ok()) {
                    w.toast(&format!(
                        "Saved {} entries",
                        this.imp().entries.borrow().len()
                    ));
                }
            },
        );
    }
}

// -------------------------------------------------------------------
// SENDER: per-page channel sender, keyed by GObject pointer.
//
// async_channel::Sender isn't Rc-cloneable into a Cell on the imp
// struct without giving up Send (which we need for thread::spawn).
// Storing it in a thread-local map (GTK is single-threaded so this
// is fine) keeps the imp::ZbplLogsPage struct trivially Default-able
// and avoids leaking the worker channel into the GObject schema.
// -------------------------------------------------------------------

thread_local! {
    static SENDER: RefCell<std::collections::HashMap<usize, async_channel::Sender<FetchResult>>>
        = RefCell::new(std::collections::HashMap::new());
}

// -------------------------------------------------------------------
// HTTP — runs on worker thread, returns a single merged result.
// -------------------------------------------------------------------

fn fetch_both_streams() -> FetchResult {
    let zpl_url = format!("{AGENT_BASE_URL}/__logs/zpl?limit={ZPL_LIMIT}");
    let agent_url = format!("{AGENT_BASE_URL}/__logs/agent?limit={AGENT_LIMIT}");

    let zpl_res: Result<ZplLogResponse, String> = ureq::get(&zpl_url)
        .timeout(Duration::from_secs(5))
        .call()
        .map_err(|e| e.to_string())
        .and_then(|r| r.into_json::<ZplLogResponse>().map_err(|e| e.to_string()));

    let agent_res: Result<AgentLogResponse, String> = ureq::get(&agent_url)
        .timeout(Duration::from_secs(5))
        .call()
        .map_err(|e| e.to_string())
        .and_then(|r| r.into_json::<AgentLogResponse>().map_err(|e| e.to_string()));

    // If both fail, surface the agent-side error (more likely to be
    // user-actionable: "agent not running"). If only one fails the
    // other still populates.
    let mut error: Option<String> = None;
    let (zpl_path, mut merged) = match zpl_res {
        Ok(r) => {
            if let Some(e) = r.error {
                error = Some(e);
                (r.path, Vec::new())
            } else {
                let merged: Vec<UnifiedEntry> = r
                    .entries
                    .into_iter()
                    .filter_map(|e| {
                        let ts = parse_zpl_ts(&e.ts)?;
                        Some(UnifiedEntry::Zpl { ts, entry: e })
                    })
                    .collect();
                (r.path, merged)
            }
        }
        Err(e) => {
            error = Some(e);
            (String::new(), Vec::new())
        }
    };

    let (agent_path, agent_note) = match agent_res {
        Ok(r) => {
            if let Some(e) = r.error {
                if error.is_none() {
                    error = Some(e);
                }
                (r.path, r.note)
            } else {
                for line in r.entries {
                    let ts = parse_agent_ts(&line).unwrap_or_else(Utc::now);
                    let text = strip_agent_ts_prefix(&line).unwrap_or(line);
                    merged.push(UnifiedEntry::Agent { ts, text });
                }
                (r.path, r.note)
            }
        }
        Err(e) => {
            if error.is_none() {
                error = Some(e);
            }
            (String::new(), None)
        }
    };

    merged.sort_by_key(UnifiedEntry::timestamp);

    FetchResult {
        zpl_path,
        agent_path,
        agent_note,
        merged,
        error,
    }
}

// -------------------------------------------------------------------
// Timestamp parsing
// -------------------------------------------------------------------

/// Parse the agent's RFC3339-with-fractional-seconds timestamps.
fn parse_zpl_ts(s: &str) -> Option<DateTime<Utc>> {
    DateTime::parse_from_rfc3339(s)
        .ok()
        .map(|d| d.with_timezone(&Utc))
}

/// Parse Go's default `log.Printf` line shape:
/// `2026/05/17 10:00:02.174518 the rest of the message`
fn parse_agent_ts(line: &str) -> Option<DateTime<Utc>> {
    if line.len() < AGENT_LINE_PREFIX_LEN {
        return None;
    }
    let prefix = &line[..AGENT_LINE_PREFIX_LEN - 1]; // strip trailing space
    let naive = NaiveDateTime::parse_from_str(prefix, "%Y/%m/%d %H:%M:%S%.f").ok()?;
    // Agent writes in the local timezone. Convert via chrono::Local
    // → Utc so all sortable timestamps share a clock.
    Local
        .from_local_datetime(&naive)
        .single()
        .map(|d| d.with_timezone(&Utc))
}

/// Strip the fixed-width timestamp prefix from an agent log line.
fn strip_agent_ts_prefix(line: &str) -> Option<String> {
    if line.len() > AGENT_LINE_PREFIX_LEN {
        Some(line[AGENT_LINE_PREFIX_LEN..].to_string())
    } else {
        None
    }
}

// -------------------------------------------------------------------
// Row builders — one per source variant.
// -------------------------------------------------------------------

// Build a single-line ZPL row. Summary line lives INSIDE the
// Expander's label widget so the whole row is clickable — the
// disclosure triangle sits at the start and the full payload
// expands inline below. Dense / monospace / one-line — designed
// to scan a 1500-row stream the way a journalctl reader would.
fn build_zpl_row(entry: &ZplLogEntry) -> gtk::ListBoxRow {
    let row = gtk::ListBoxRow::new();
    row.set_selectable(false);
    row.set_activatable(false);

    // Summary line — all fixed columns, mono-spaced for vertical
    // scanning. preview is the first non-empty line of the ZPL,
    // truncated to fit; ellipsize handles the rest.
    let summary = gtk::Box::new(gtk::Orientation::Horizontal, 8);
    summary.set_hexpand(true);

    let ts_label = gtk::Label::new(Some(&format_time_only_str(&entry.ts)));
    ts_label.add_css_class("monospace");
    ts_label.add_css_class("dim-label");
    ts_label.add_css_class("caption");
    summary.append(&ts_label);

    summary.append(&pill("ZPL", "accent"));

    let src_label = gtk::Label::new(Some(&entry.src.to_uppercase()));
    src_label.add_css_class("monospace");
    src_label.add_css_class("dim-label");
    src_label.add_css_class("caption");
    summary.append(&src_label);

    let uid_label = gtk::Label::new(Some(&entry.uid));
    uid_label.add_css_class("monospace");
    uid_label.add_css_class("caption");
    summary.append(&uid_label);

    let bytes_label = gtk::Label::new(Some(&format!("{}B", entry.bytes)));
    bytes_label.add_css_class("monospace");
    bytes_label.add_css_class("dim-label");
    bytes_label.add_css_class("caption");
    summary.append(&bytes_label);

    if !entry.error.is_empty() {
        summary.append(&pill("ERR", "error"));
    }

    let preview = entry
        .zpl
        .lines()
        .find(|l| !l.is_empty())
        .map(|l| l.chars().take(120).collect::<String>())
        .unwrap_or_default();
    let preview_label = gtk::Label::new(Some(&preview));
    preview_label.add_css_class("monospace");
    preview_label.add_css_class("dim-label");
    preview_label.add_css_class("caption");
    preview_label.set_xalign(0.0);
    preview_label.set_hexpand(true);
    preview_label.set_ellipsize(gtk::pango::EllipsizeMode::End);
    summary.append(&preview_label);

    // Expanded body — full ZPL + origin + error detail when present.
    let body = gtk::Box::builder()
        .orientation(gtk::Orientation::Vertical)
        .spacing(4)
        .margin_top(4)
        .margin_start(28)
        .margin_end(6)
        .build();

    if !entry.origin.is_empty() {
        let origin = gtk::Label::new(Some(&entry.origin));
        origin.add_css_class("caption");
        origin.add_css_class("dim-label");
        origin.add_css_class("monospace");
        origin.set_xalign(0.0);
        body.append(&origin);
    }

    if !entry.error.is_empty() {
        let err = gtk::Label::new(Some(&entry.error));
        err.add_css_class("caption");
        err.add_css_class("error");
        err.add_css_class("monospace");
        err.set_xalign(0.0);
        err.set_wrap(true);
        body.append(&err);
    }

    let scrolled = gtk::ScrolledWindow::builder()
        .height_request(200)
        .hscrollbar_policy(gtk::PolicyType::Automatic)
        .build();
    let payload = gtk::Label::builder()
        .label(&entry.zpl)
        .xalign(0.0)
        .yalign(0.0)
        .selectable(true)
        .wrap(false)
        .margin_top(4)
        .margin_bottom(4)
        .margin_start(4)
        .margin_end(4)
        .build();
    payload.add_css_class("monospace");
    payload.add_css_class("caption");
    scrolled.set_child(Some(&payload));
    body.append(&scrolled);

    if entry.truncated {
        let trunc = gtk::Label::new(Some("Truncated to 16 KB"));
        trunc.add_css_class("caption");
        trunc.add_css_class("dim-label");
        trunc.set_xalign(0.0);
        body.append(&trunc);
    }

    let expander = gtk::Expander::new(None);
    expander.set_label_widget(Some(&summary));
    expander.set_child(Some(&body));
    expander.set_margin_top(2);
    expander.set_margin_bottom(2);
    expander.set_margin_start(8);
    expander.set_margin_end(8);

    row.set_child(Some(&expander));
    row
}

// Agent log rows are single-line, no expander needed — the agent
// log lines are already short and self-contained. Text is
// selectable so a long line can be copied, ellipsized if it
// overflows the available width.
fn build_agent_row(ts: DateTime<Utc>, text: &str) -> gtk::ListBoxRow {
    let row = gtk::ListBoxRow::new();
    row.set_selectable(false);
    row.set_activatable(false);

    let outer = gtk::Box::builder()
        .orientation(gtk::Orientation::Horizontal)
        .spacing(8)
        .margin_top(2)
        .margin_bottom(2)
        .margin_start(36) // align with expander-content indent
        .margin_end(14)
        .build();

    let time_label = gtk::Label::new(Some(&format_time_only_local(ts)));
    time_label.add_css_class("monospace");
    time_label.add_css_class("dim-label");
    time_label.add_css_class("caption");
    outer.append(&time_label);

    outer.append(&pill("AGENT", "success"));

    let text_label = gtk::Label::new(Some(text));
    text_label.add_css_class("monospace");
    text_label.add_css_class("caption");
    text_label.set_xalign(0.0);
    text_label.set_hexpand(true);
    text_label.set_selectable(true);
    text_label.set_ellipsize(gtk::pango::EllipsizeMode::End);
    outer.append(&text_label);

    row.set_child(Some(&outer));
    row
}

/// Capsule-styled label — uses libadwaita semantic colour classes
/// (`accent`, `success`, `error`) so it themes correctly in both
/// light + dark modes without us hand-rolling a CSS provider.
fn pill(text: &str, color_class: &str) -> gtk::Label {
    let label = gtk::Label::new(Some(text));
    label.add_css_class("caption-heading");
    label.add_css_class(color_class);
    label.add_css_class("pill");
    label
}

fn format_time_only_str(rfc3339: &str) -> String {
    parse_zpl_ts(rfc3339)
        .map(format_time_only_local)
        .unwrap_or_else(|| rfc3339.to_string())
}

fn format_time_only_local(ts: DateTime<Utc>) -> String {
    ts.with_timezone(&Local).format("%H:%M:%S").to_string()
}
