use adw::prelude::*;
use std::sync::OnceLock;

use crate::i18n::i18n;

/// Ensures the shared letter-icon CSS provider is registered on the default
/// display exactly once.  The provider defines `.letter-icon-a` through
/// `.letter-icon-z` (and `.letter-icon-other`) with distinct hue-based
/// background/foreground colors so that individual `build_letter_icon` calls
/// never need to create their own CssProvider.
fn ensure_letter_icon_css() {
    static REGISTERED: OnceLock<bool> = OnceLock::new();
    REGISTERED.get_or_init(|| {
        let provider = gtk::CssProvider::new();
        provider.load_from_string(&generate_letter_icon_css());
        if let Some(display) = gtk::gdk::Display::default() {
            gtk::style_context_add_provider_for_display(
                &display,
                &provider,
                gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
            );
        }
        true
    });
}

/// Generate CSS rules for `.letter-icon-a` through `.letter-icon-z` and a
/// `.letter-icon-other` fallback.  Each letter gets a unique hue evenly
/// distributed around the color wheel.  Lightness is adjusted per hue range
/// to guarantee WCAG AAA 7:1 contrast ratio with the white foreground text.
/// Yellow/green hues (50-170) are inherently lighter, so they use darker
/// lightness values.
fn generate_letter_icon_css() -> String {
    let mut css = String::with_capacity(4096);
    for i in 0u32..26 {
        let letter = (b'a' + i as u8) as char;
        let hue = (i * 360) / 26;
        // Yellow/green hues have high luminance; darken them more for contrast
        let lightness = if (50..170).contains(&hue) { 30 } else { 38 };
        css.push_str(&format!(
            "label.letter-icon-{letter} {{ \
                background: hsl({hue}, 65%, {lightness}%); \
                color: hsl({hue}, 100%, 97%); \
                border-radius: 50%; \
                font-weight: 700; \
            }}\n"
        ));
    }
    // Fallback for non-alphabetic first characters
    css.push_str(
        "label.letter-icon-other { \
            background: hsl(0, 0%, 35%); \
            color: hsl(0, 0%, 97%); \
            border-radius: 50%; \
            font-weight: 700; \
        }\n"
    );
    css
}

/// Set the pointer (hand) cursor on a widget, so it looks clickable on hover.
pub fn set_pointer_cursor(widget: &impl IsA<gtk::Widget>) {
    widget.as_ref().set_cursor_from_name(Some("pointer"));
}

/// Recursively walk a widget tree and set pointer cursor on all interactive elements.
/// Call this on a view's root container after building it to cover buttons, switches,
/// toggle buttons, activatable rows, and other clickable widgets.
pub fn apply_pointer_cursors(widget: &impl IsA<gtk::Widget>) {
    let w = widget.as_ref();

    let is_interactive = w.is::<gtk::Button>()
        || w.is::<gtk::ToggleButton>()
        || w.is::<adw::SplitButton>()
        || w.is::<gtk::Switch>()
        || w.is::<gtk::CheckButton>()
        || w.is::<gtk::DropDown>()
        || w.is::<gtk::Scale>()
        || w.has_css_class("activatable");

    if is_interactive {
        w.set_cursor_from_name(Some("pointer"));
    }

    let mut child = w.first_child();
    while let Some(c) = child {
        apply_pointer_cursors(&c);
        child = c.next_sibling();
    }
}

/// Create a status badge pill label with the given text and style class.
/// Style classes: "success", "warning", "error", "info", "neutral"
pub fn status_badge(text: &str, style_class: &str) -> gtk::Label {
    let label = gtk::Label::new(Some(text));
    label.add_css_class("status-badge");
    label.add_css_class(style_class);
    label.set_accessible_role(gtk::AccessibleRole::Status);
    label
}

/// Create a status badge with a symbolic icon prefix for accessibility.
/// The badge contains both an icon and text so information is not color-dependent.
pub fn status_badge_with_icon(icon_name: &str, text: &str, style_class: &str) -> gtk::Box {
    let hbox = gtk::Box::builder()
        .orientation(gtk::Orientation::Horizontal)
        .spacing(4)
        .accessible_role(gtk::AccessibleRole::Status)
        .build();
    hbox.add_css_class("status-badge-with-icon");
    hbox.add_css_class(style_class);
    hbox.update_property(&[gtk::accessible::Property::Label(text)]);

    let icon = gtk::Image::from_icon_name(icon_name);
    icon.set_pixel_size(12);
    icon.set_accessible_role(gtk::AccessibleRole::Presentation);
    hbox.append(&icon);

    let label = gtk::Label::new(Some(text));
    hbox.append(&label);

    hbox
}

/// Create a badge showing integration status.
pub fn integration_badge(integrated: bool) -> gtk::Label {
    if integrated {
        status_badge("Integrated", "success")
    } else {
        status_badge("Not integrated", "neutral")
    }
}

/// Create an icon-only button with proper accessible label and minimum target size.
/// Every icon-only button in the app should use this factory to ensure AAA compliance.
pub fn accessible_icon_button(icon_name: &str, accessible_label: &str, tooltip: &str) -> gtk::Button {
    let btn = gtk::Button::builder()
        .icon_name(icon_name)
        .tooltip_text(tooltip)
        .valign(gtk::Align::Center)
        .build();
    btn.add_css_class("flat");
    btn.add_css_class("accessible-icon-btn");
    btn.update_property(&[gtk::accessible::Property::Label(accessible_label)]);
    btn
}

/// Create a decorative suffix icon (e.g. arrows, checkmarks) with an accessible label.
/// Use this for "go-next-symbolic" and similar icons appended to rows.
pub fn accessible_suffix_icon(icon_name: &str, accessible_label: &str) -> gtk::Image {
    let img = gtk::Image::from_icon_name(icon_name);
    img.set_pixel_size(16);
    img.set_accessible_role(gtk::AccessibleRole::Img);
    img.update_property(&[gtk::accessible::Property::Label(accessible_label)]);
    img
}

/// Announce the result of an async operation to screen readers.
/// Uses Alert role for errors and Status role for success.
pub fn announce_result(container: &impl gtk::prelude::IsA<gtk::Widget>, success: bool, message: &str) {
    let role = if success {
        gtk::AccessibleRole::Status
    } else {
        gtk::AccessibleRole::Alert
    };
    let label = gtk::Label::builder()
        .label(message)
        .visible(false)
        .accessible_role(role)
        .build();
    label.update_property(&[gtk::accessible::Property::Label(message)]);

    let target_box = find_ancestor_box(container.upcast_ref::<gtk::Widget>());

    if let Some(box_widget) = target_box {
        box_widget.append(&label);
        label.set_visible(true);
        let label_clone = label.clone();
        let box_clone = box_widget.clone();
        glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || {
            box_clone.remove(&label_clone);
        });
    }
}

/// Create a toast for informational messages (short-lived, normal priority).
pub fn info_toast(message: &str) -> adw::Toast {
    adw::Toast::builder()
        .title(message)
        .timeout(4)
        .build()
}

/// Create a toast for error/failure messages (longer display, high priority
/// so it jumps ahead of queued info toasts).
pub fn error_toast(message: &str) -> adw::Toast {
    adw::Toast::builder()
        .title(message)
        .timeout(7)
        .priority(adw::ToastPriority::High)
        .build()
}

/// Format bytes into a human-readable string.
pub fn format_size(bytes: i64) -> String {
    humansize::format_size(bytes as u64, humansize::BINARY)
}

/// Build an app icon widget with letter-circle fallback.
/// If the icon_path exists and is loadable, show the real icon.
/// Otherwise, generate a colored circle with the first letter of the app name.
/// All returned widgets have an accessible label set to app_name for screen readers.
pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk::Widget {
    // Try to load from explicit path
    if let Some(icon_path) = icon_path {
        let path = std::path::Path::new(icon_path);
        if path.exists() {
            if let Ok(texture) = gtk::gdk::Texture::from_filename(path) {
                let image = gtk::Image::builder()
                    .pixel_size(pixel_size)
                    .build();
                image.set_paintable(Some(&texture));
                image.update_property(&[gtk::accessible::Property::Label(app_name)]);
                return image.upcast();
            }
        }
    }

    // Try cached catalog icon
    let cache_dir = crate::core::catalog::icon_cache_dir();
    let sanitized = crate::core::catalog::sanitize_filename(app_name);
    let cached_path = cache_dir.join(format!("{}.png", sanitized));
    if cached_path.exists() {
        if let Ok(texture) = gtk::gdk::Texture::from_filename(&cached_path) {
            let image = gtk::Image::builder()
                .pixel_size(pixel_size)
                .build();
            image.set_paintable(Some(&texture));
            image.update_property(&[gtk::accessible::Property::Label(app_name)]);
            return image.upcast();
        }
    }

    // Letter-circle fallback (label text already visible, add accessible label)
    let icon = build_letter_icon(app_name, pixel_size);
    icon.update_property(&[gtk::accessible::Property::Label(app_name)]);
    icon
}

/// Build a colored circle with the first letter of the name as a fallback icon.
///
/// The color CSS classes (`.letter-icon-a` .. `.letter-icon-z`) are registered
/// once via a shared CssProvider.  This function only needs to pick the right
/// class and set per-widget sizing, avoiding a new provider per icon.
fn build_letter_icon(name: &str, size: i32) -> gtk::Widget {
    // Ensure the shared CSS for all 26 letter classes is loaded
    ensure_letter_icon_css();

    let first_char = name
        .chars()
        .find(|c| c.is_alphanumeric())
        .unwrap_or('?');
    let letter_upper = first_char.to_uppercase().next().unwrap_or('?');

    // Determine the CSS class: letter-icon-a .. letter-icon-z, or letter-icon-other
    let css_class = if first_char.is_ascii_alphabetic() {
        format!("letter-icon-{}", first_char.to_ascii_lowercase())
    } else {
        "letter-icon-other".to_string()
    };

    // Font size scales with the icon (40% of the circle diameter).
    let font_size_pt = size * 4 / 10;

    let label = gtk::Label::builder()
        .use_markup(true)
        .halign(gtk::Align::Center)
        .valign(gtk::Align::Center)
        .width_request(size)
        .height_request(size)
        .build();

    // Use Pango markup to set the font size without a per-widget CssProvider.
    let markup = format!(
        "<span size='{}pt'>{}</span>",
        font_size_pt,
        glib::markup_escape_text(&letter_upper.to_string()),
    );
    label.set_markup(&markup);

    label.add_css_class(&css_class);

    label.upcast()
}

/// Create a copy-to-clipboard button that shows a toast on success.
pub fn copy_button(text_to_copy: &str, toast_overlay: Option<&adw::ToastOverlay>) -> gtk::Button {
    let btn = gtk::Button::builder()
        .icon_name("edit-copy-symbolic")
        .tooltip_text("Copy to clipboard")
        .valign(gtk::Align::Center)
        .build();
    btn.add_css_class("flat");
    btn.update_property(&[gtk::accessible::Property::Label("Copy to clipboard")]);

    let text = text_to_copy.to_string();
    let toast = toast_overlay.cloned();
    btn.connect_clicked(move |button| {
        let clipboard = button.display().clipboard();
        clipboard.set_text(&text);
        if let Some(ref overlay) = toast {
            overlay.add_toast(info_toast(&i18n("Copied to clipboard")));
        }
    });
    btn
}

/// Show a detailed crash dialog when an AppImage fails to start.
/// Includes a plain-text explanation, the full error output in a copyable text view,
/// and a button to copy the full error to clipboard.
pub fn show_crash_dialog(
    parent: &impl gtk::prelude::IsA<gtk::Widget>,
    app_name: &str,
    exit_code: Option<i32>,
    stderr: &str,
) {
    let explanation = crash_explanation(stderr);

    let exit_str = exit_code
        .map(|c| c.to_string())
        .unwrap_or_else(|| "unknown".to_string());

    // Build the full text that gets copied
    let full_error = format!(
        "App: {}\nExit code: {}\n\n{}\n\nError output:\n{}",
        app_name,
        exit_str,
        explanation,
        stderr.trim(),
    );

    // Use adw::Dialog (not AlertDialog) for full size control
    let dialog = adw::Dialog::builder()
        .title(&format!("{} failed to start", app_name))
        .content_width(900)
        .content_height(550)
        .build();

    let toolbar = adw::ToolbarView::new();
    let header = adw::HeaderBar::new();
    toolbar.add_top_bar(&header);

    let content = gtk::Box::builder()
        .orientation(gtk::Orientation::Vertical)
        .spacing(12)
        .margin_top(16)
        .margin_bottom(16)
        .margin_start(24)
        .margin_end(24)
        .build();

    // Explanation
    let explanation_label = gtk::Label::builder()
        .label(&format!("{}\n\nExit code: {}", explanation, exit_str))
        .wrap(true)
        .xalign(0.0)
        .accessible_role(gtk::AccessibleRole::Alert)
        .build();
    content.append(&explanation_label);

    // Error output
    if !stderr.trim().is_empty() {
        let heading = gtk::Label::builder()
            .label("Error output:")
            .xalign(0.0)
            .accessible_role(gtk::AccessibleRole::Heading)
            .build();
        heading.add_css_class("heading");
        heading.update_property(&[gtk::accessible::Property::Level(2)]);
        content.append(&heading);

        let text_view = gtk::TextView::builder()
            .editable(false)
            .cursor_visible(false)
            .monospace(true)
            .wrap_mode(gtk::WrapMode::WordChar)
            .top_margin(8)
            .bottom_margin(8)
            .left_margin(8)
            .right_margin(8)
            .build();
        text_view.buffer().set_text(stderr.trim());
        text_view.add_css_class("card");
        text_view.update_property(&[gtk::accessible::Property::Label("Error output")]);

        let scrolled = gtk::ScrolledWindow::builder()
            .child(&text_view)
            .vexpand(true)
            .build();
        content.append(&scrolled);

        let copy_btn = gtk::Button::builder()
            .label("Copy to clipboard")
            .halign(gtk::Align::Start)
            .build();
        copy_btn.add_css_class("pill");
        let full_error_copy = full_error.clone();
        let content_for_copy = content.clone();
        copy_btn.connect_clicked(move |btn| {
            let clipboard = btn.display().clipboard();
            clipboard.set_text(&full_error_copy);
            btn.set_label("Copied!");
            btn.set_sensitive(false);
            announce(&content_for_copy, "Copied to clipboard");
        });
        content.append(&copy_btn);
    }

    toolbar.set_content(Some(&content));
    dialog.set_child(Some(&toolbar));
    dialog.present(Some(parent));
}

/// Generate a plain-text explanation of why an app crashed based on stderr patterns.
fn crash_explanation(stderr: &str) -> String {
    if stderr.contains("Could not find the Qt platform plugin") || stderr.contains("qt.qpa.plugin") {
        return "This app needs a display system plugin that is not installed. \
                Try installing the Qt platform packages for your system.".to_string();
    }
    if stderr.contains("cannot open shared object file") {
        if let Some(pos) = stderr.find("cannot open shared object file") {
            let before = &stderr[..pos];
            if let Some(start) = before.rfind(": ") {
                let lib = before[start + 2..].trim();
                if !lib.is_empty() {
                    return format!(
                        "This app is missing a component it needs to run \
                         (similar to a missing DLL on Windows). \
                         The missing component is: {}",
                        lib,
                    );
                }
            }
        }
        return "This app is missing a component it needs to run \
                (similar to a missing DLL on Windows).".to_string();
    }
    if stderr.contains("Segmentation fault") || stderr.contains("SIGSEGV") {
        return "This app crashed immediately. This is usually a bug in the \
                app itself, not something you can fix.".to_string();
    }
    if stderr.contains("Permission denied") {
        return "This app does not have permission to run. Driftwood usually \
                fixes this automatically - try removing and re-adding the app.".to_string();
    }
    if stderr.contains("fatal IO error") || stderr.contains("display connection") {
        return "This app could not connect to your display. If you are using \
                a remote session or container, this may not work.".to_string();
    }
    if stderr.contains("FATAL:") || stderr.contains("Aborted") {
        return "This app encountered an error during startup. The error details \
                below may help identify the cause.".to_string();
    }
    if stderr.contains("Failed to initialize") {
        return "The app couldn't set itself up properly. It may need additional \
                system components to run.".to_string();
    }
    "The app exited immediately after starting. The error details below \
     may help identify the cause.".to_string()
}

/// Format a timestamp string as a human-readable relative time.
/// Accepts RFC3339, "YYYY-MM-DD HH:MM:SS", or ISO8601 formats.
pub fn relative_time(timestamp: &str) -> String {
    let parsed = chrono::DateTime::parse_from_rfc3339(timestamp)
        .map(|dt| dt.with_timezone(&chrono::Utc))
        .or_else(|_| {
            chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S")
                .map(|ndt| ndt.and_utc())
        });

    let dt = match parsed {
        Ok(dt) => dt,
        Err(_) => return timestamp.to_string(),
    };

    let now = chrono::Utc::now();
    let duration = now.signed_duration_since(dt);

    if duration.num_seconds() < 0 {
        return "Just now".to_string();
    }

    if duration.num_seconds() < 60 {
        "Just now".to_string()
    } else if duration.num_minutes() < 60 {
        let m = duration.num_minutes();
        format!("{} min ago", m)
    } else if duration.num_hours() < 24 {
        let h = duration.num_hours();
        format!("{} hr ago", h)
    } else if duration.num_days() == 1 {
        "Yesterday".to_string()
    } else if duration.num_days() < 7 {
        format!("{} days ago", duration.num_days())
    } else if duration.num_weeks() == 1 {
        "Last week".to_string()
    } else if duration.num_days() < 30 {
        format!("{} weeks ago", duration.num_weeks())
    } else if duration.num_days() < 60 {
        "Last month".to_string()
    } else {
        format!("{} months ago", duration.num_days() / 30)
    }
}

/// Format a count with K/M suffixes for readability.
pub fn format_count(n: i64) -> String {
    if n >= 1_000_000 {
        format!("{:.1}M", n as f64 / 1_000_000.0)
    } else if n >= 1_000 {
        format!("{:.1}K", n as f64 / 1_000.0)
    } else {
        n.to_string()
    }
}

/// Walk up the widget tree from the given widget to find the nearest Box
/// (directly or as a visible child of a Stack). This fixes announce/announce_result
/// silently failing when called on non-Box containers like FlowBox.
fn find_ancestor_box(widget: &gtk::Widget) -> Option<gtk::Box> {
    let mut current: Option<gtk::Widget> = Some(widget.clone());
    loop {
        match current {
            Some(ref w) => {
                if let Some(b) = w.dynamic_cast_ref::<gtk::Box>() {
                    return Some(b.clone());
                }
                if let Some(s) = w.dynamic_cast_ref::<gtk::Stack>() {
                    if let Some(child) = s.visible_child() {
                        if let Ok(b) = child.downcast::<gtk::Box>() {
                            return Some(b);
                        }
                    }
                }
                current = w.parent();
            }
            None => return None,
        }
    }
}

/// Create a screen-reader live region announcement.
/// Inserts a hidden label with AccessibleRole::Alert into the given container,
/// which causes AT-SPI to announce the text to screen readers.
/// The label auto-removes after a short delay.
pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, text: &str) {
    let label = gtk::Label::builder()
        .label(text)
        .visible(false)
        .accessible_role(gtk::AccessibleRole::Alert)
        .build();
    label.update_property(&[gtk::accessible::Property::Label(text)]);

    let target_box = find_ancestor_box(container.upcast_ref::<gtk::Widget>());

    if let Some(box_widget) = target_box {
        box_widget.append(&label);
        label.set_visible(true);
        let label_clone = label.clone();
        let box_clone = box_widget.clone();
        glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || {
            box_clone.remove(&label_clone);
        });
    }
}

/// Build a GTK widget tree from markdown text using pulldown-cmark.
/// Returns a vertical Box containing formatted labels for each block element.
pub fn build_markdown_view(markdown: &str) -> gtk::Box {
    use pulldown_cmark::{Event, Tag, TagEnd, Options, Parser};

    let container = gtk::Box::builder()
        .orientation(gtk::Orientation::Vertical)
        .spacing(8)
        .build();

    let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
    let parser = Parser::new_ext(markdown, options);

    // Accumulate inline Pango markup, flush as labels on block boundaries
    let mut markup = String::new();
    let mut in_heading: Option<u8> = None;
    let mut in_code_block = false;
    let mut code_block_text = String::new();
    let mut list_depth: u32 = 0;
    let mut list_item_open = false;

    let flush_label = |container: &gtk::Box, markup: &mut String, heading: Option<u8>| {
        let text = markup.trim().to_string();
        if text.is_empty() {
            markup.clear();
            return;
        }
        let label = gtk::Label::builder()
            .use_markup(true)
            .wrap(true)
            .wrap_mode(gtk::pango::WrapMode::WordChar)
            .xalign(0.0)
            .halign(gtk::Align::Fill)
            .selectable(true)
            .build();

        label.set_markup(&text);

        match heading {
            Some(1) => { label.add_css_class("title-1"); label.set_margin_top(12); }
            Some(2) => { label.add_css_class("title-2"); label.set_margin_top(10); }
            Some(3) => { label.add_css_class("title-3"); label.set_margin_top(8); }
            Some(4..=6) => { label.add_css_class("title-4"); label.set_margin_top(6); }
            _ => {}
        }
        if let Some(level) = heading {
            label.set_accessible_role(gtk::AccessibleRole::Heading);
            label.update_property(&[gtk::accessible::Property::Level(level as i32)]);
        }
        container.append(&label);
        markup.clear();
    };

    for event in parser {
        match event {
            Event::Start(Tag::Heading { level, .. }) => {
                flush_label(&container, &mut markup, None);
                in_heading = Some(level as u8);
            }
            Event::End(TagEnd::Heading(_)) => {
                let level = in_heading.take();
                flush_label(&container, &mut markup, level);
            }
            Event::Start(Tag::Paragraph) => {}
            Event::End(TagEnd::Paragraph) => {
                if !in_code_block {
                    flush_label(&container, &mut markup, None);
                }
            }
            Event::Start(Tag::CodeBlock(_)) => {
                flush_label(&container, &mut markup, None);
                in_code_block = true;
                code_block_text.clear();
            }
            Event::End(TagEnd::CodeBlock) => {
                in_code_block = false;
                let code_label = gtk::Label::builder()
                    .use_markup(true)
                    .wrap(true)
                    .wrap_mode(gtk::pango::WrapMode::WordChar)
                    .xalign(0.0)
                    .halign(gtk::Align::Fill)
                    .selectable(true)
                    .css_classes(["monospace", "card"])
                    .margin_start(8)
                    .margin_end(8)
                    .build();
                // Escape for Pango markup inside the <tt> tag
                let escaped = glib::markup_escape_text(&code_block_text);
                code_label.set_markup(&format!("<tt>{}</tt>", escaped));
                code_label.update_property(&[gtk::accessible::Property::Description("Code block")]);
                container.append(&code_label);
                code_block_text.clear();
            }
            Event::Start(Tag::Strong) => markup.push_str("<b>"),
            Event::End(TagEnd::Strong) => markup.push_str("</b>"),
            Event::Start(Tag::Emphasis) => markup.push_str("<i>"),
            Event::End(TagEnd::Emphasis) => markup.push_str("</i>"),
            Event::Start(Tag::Strikethrough) => markup.push_str("<s>"),
            Event::End(TagEnd::Strikethrough) => markup.push_str("</s>"),
            Event::Start(Tag::Link { dest_url, .. }) => {
                markup.push_str(&format!("<a href=\"{}\">", glib::markup_escape_text(&dest_url)));
            }
            Event::End(TagEnd::Link) => markup.push_str("</a>"),
            Event::Start(Tag::List(_)) => {
                flush_label(&container, &mut markup, None);
                list_depth += 1;
            }
            Event::End(TagEnd::List(_)) => {
                flush_label(&container, &mut markup, None);
                list_depth = list_depth.saturating_sub(1);
            }
            Event::Start(Tag::Item) => {
                list_item_open = true;
                let indent = "  ".repeat(list_depth.saturating_sub(1) as usize);
                markup.push_str(&format!("{}  \u{2022} ", indent));
            }
            Event::End(TagEnd::Item) => {
                list_item_open = false;
                flush_label(&container, &mut markup, None);
            }
            Event::Code(code) => {
                markup.push_str(&format!("<tt>{}</tt>", glib::markup_escape_text(&code)));
            }
            Event::Text(text) => {
                if in_code_block {
                    code_block_text.push_str(&text);
                } else {
                    markup.push_str(&glib::markup_escape_text(&text));
                }
            }
            Event::SoftBreak => {
                if in_code_block {
                    code_block_text.push('\n');
                } else if list_item_open {
                    markup.push(' ');
                } else {
                    markup.push(' ');
                }
            }
            Event::HardBreak => {
                if in_code_block {
                    code_block_text.push('\n');
                } else {
                    markup.push('\n');
                }
            }
            Event::Rule => {
                flush_label(&container, &mut markup, None);
                let sep = gtk::Separator::new(gtk::Orientation::Horizontal);
                sep.set_margin_top(8);
                sep.set_margin_bottom(8);
                container.append(&sep);
            }
            // Skip images, HTML, footnotes, etc.
            _ => {}
        }
    }

    // Flush any remaining text
    flush_label(&container, &mut markup, None);

    container
}
