use crate::config::Config;
use crate::keybindings::{Action, KeybindingMode, Keybindings};
use crate::parser::{Document, HeadingNode, Link, extract_links};
use crate::tui::help_text;
use crate::tui::interactive::{ElementType, InteractiveState};
use crate::tui::kitty_animation::{self, KittyAnimation};
use crate::tui::syntax::SyntaxHighlighter;
use crate::tui::terminal_compat::ColorMode;
use crate::tui::theme::{Theme, ThemeName};
use crossterm::event::{KeyCode, KeyModifiers};
use indexmap::IndexMap;
use ratatui::widgets::{ListState, ScrollbarState};
#[cfg(all(feature = "mermaid", unix))]
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::{Duration, Instant};

/// Special marker for the document overview entry (shows entire file content)
pub const DOCUMENT_OVERVIEW: &str = "(Document)";

/// Result of executing an action
#[derive(Debug)]
pub enum ActionResult {
    /// Continue the main loop
    Continue,
    /// Exit the application
    Quit,
    /// Run an editor on a file, optionally at a specific line
    RunEditor(PathBuf, Option<u32>),
    /// Redraw the screen (terminal.clear())
    Redraw,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Focus {
    Outline,
    Content,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AppMode {
    Normal,
    Interactive,
    LinkFollow,
    Search,
    ThemePicker,
    Help,
    CellEdit,
    ConfirmFileCreate,
    DocSearch,             // In-document search mode (n/N navigation)
    CommandPalette,        // Fuzzy-searchable command palette
    ConfirmSaveWidth,      // Modal confirmation for saving outline width
    ConfirmSaveBeforeQuit, // Prompt to save unsaved changes before quitting
    ConfirmSaveBeforeNav,  // Prompt to save unsaved changes before navigating
    FilePicker,            // File picker modal for switching files
    FileSearch,            // File picker search/filter mode
}

/// Type of pending navigation when user has unsaved changes
#[derive(Debug, Clone)]
pub enum PendingNavigation {
    /// Navigate back in file history
    Back,
    /// Navigate forward in file history
    Forward,
    /// Load a file (relative path, optional anchor)
    LoadFile(PathBuf, Option<String>),
}

/// Available commands in the command palette
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CommandAction {
    SaveWidth,
    SaveFile, // Save pending edits to file (:w)
    Undo,     // Undo last pending edit
    ToggleOutline,
    ToggleHeadingMarkers,
    ToggleHelp,
    ToggleRawSource,
    ToggleMouseCapture,
    JumpToTop,
    JumpToBottom,
    CollapseAll,
    ExpandAll,
    /// Collapse headings at a specific level (parsed from command argument)
    CollapseLevel,
    /// Expand headings at a specific level (parsed from command argument)
    ExpandLevel,
    Quit,
    /// Forward to the regular keybinding action dispatch — lets the palette
    /// cover every command without duplicating handlers
    Dispatch(Action),
}

/// A command in the palette
#[derive(Debug, Clone)]
pub struct PaletteCommand {
    pub name: &'static str,
    pub aliases: &'static [&'static str],
    pub description: &'static str,
    pub action: CommandAction,
}

impl PaletteCommand {
    const fn new(
        name: &'static str,
        aliases: &'static [&'static str],
        description: &'static str,
        action: CommandAction,
    ) -> Self {
        Self {
            name,
            aliases,
            description,
            action,
        }
    }

    /// Check if query matches this command (fuzzy match on name or aliases).
    /// `query_lower` is the caller's pre-lowercased query — avoids re-allocating
    /// per command when filtering the palette.
    pub fn matches(&self, query_lower: &str) -> bool {
        if query_lower.is_empty() {
            return true;
        }

        // Aliases and name are static ASCII — compare case-insensitively without
        // allocating a lowercase copy of each.
        if contains_ignore_ascii_case(self.name, query_lower) {
            return true;
        }
        for alias in self.aliases {
            if starts_with_ignore_ascii_case(alias, query_lower) {
                return true;
            }
        }

        // Fuzzy: every char of query appears in order in name (ASCII fold).
        let mut name_chars = self.name.chars().map(|c| c.to_ascii_lowercase()).peekable();
        for qc in query_lower.chars() {
            loop {
                match name_chars.next() {
                    Some(nc) if nc == qc => break,
                    Some(_) => continue,
                    None => return false,
                }
            }
        }
        true
    }

    /// Calculate match score (higher = better match).
    /// `query_lower` is the caller's pre-lowercased query.
    pub fn match_score(&self, query_lower: &str) -> usize {
        if query_lower.is_empty() {
            return 100;
        }

        for alias in self.aliases {
            if alias.eq_ignore_ascii_case(query_lower) {
                return 1000;
            }
        }
        for alias in self.aliases {
            if starts_with_ignore_ascii_case(alias, query_lower) {
                return 500;
            }
        }
        if starts_with_ignore_ascii_case(self.name, query_lower) {
            return 300;
        }
        if contains_ignore_ascii_case(self.name, query_lower) {
            return 200;
        }
        100
    }
}

fn starts_with_ignore_ascii_case(haystack: &str, needle_lower: &str) -> bool {
    haystack.len() >= needle_lower.len()
        && haystack.as_bytes()[..needle_lower.len()].eq_ignore_ascii_case(needle_lower.as_bytes())
}

fn contains_ignore_ascii_case(haystack: &str, needle_lower: &str) -> bool {
    if needle_lower.is_empty() {
        return true;
    }
    if haystack.len() < needle_lower.len() {
        return false;
    }
    let needle_bytes = needle_lower.as_bytes();
    haystack
        .as_bytes()
        .windows(needle_bytes.len())
        .any(|w| w.eq_ignore_ascii_case(needle_bytes))
}

/// All available commands
pub const PALETTE_COMMANDS: &[PaletteCommand] = &[
    PaletteCommand::new(
        "Save changes",
        &["w", "write", "save"],
        "Save pending edits to file",
        CommandAction::SaveFile,
    ),
    PaletteCommand::new(
        "Undo edit",
        &["u", "undo"],
        "Undo last table cell edit",
        CommandAction::Undo,
    ),
    PaletteCommand::new(
        "Save width to config",
        &["sw", "savewidth"],
        "Save current outline width to config file",
        CommandAction::SaveWidth,
    ),
    PaletteCommand::new(
        "Toggle outline",
        &["outline", "sidebar"],
        "Show/hide the outline sidebar",
        CommandAction::ToggleOutline,
    ),
    PaletteCommand::new(
        "Toggle heading markers",
        &["markers", "hashes"],
        "Show/hide # level markers in outline",
        CommandAction::ToggleHeadingMarkers,
    ),
    PaletteCommand::new(
        "Toggle help",
        &["help", "?"],
        "Show/hide keyboard shortcuts",
        CommandAction::ToggleHelp,
    ),
    PaletteCommand::new(
        "Toggle raw source",
        &["raw", "source"],
        "Switch between rendered and raw markdown",
        CommandAction::ToggleRawSource,
    ),
    PaletteCommand::new(
        "Toggle mouse capture",
        &["mouse", "select", "selection"],
        "Release the mouse so you can select and copy text natively",
        CommandAction::ToggleMouseCapture,
    ),
    PaletteCommand::new(
        "Jump to top",
        &["top", "first", "gg"],
        "Go to first heading",
        CommandAction::JumpToTop,
    ),
    PaletteCommand::new(
        "Jump to bottom",
        &["bottom", "last", "G"],
        "Go to last heading",
        CommandAction::JumpToBottom,
    ),
    PaletteCommand::new(
        "Collapse all",
        &["collapse", "ca"],
        "Collapse all headings (or :collapse N for level N)",
        CommandAction::CollapseAll,
    ),
    PaletteCommand::new(
        "Expand all",
        &["expand", "ea"],
        "Expand all headings (or :expand N for level N)",
        CommandAction::ExpandAll,
    ),
    PaletteCommand::new(
        "Collapse level",
        &[
            "collapse 1",
            "collapse 2",
            "collapse 3",
            "collapse 4",
            "collapse 5",
        ],
        "Collapse headings at specific level",
        CommandAction::CollapseLevel,
    ),
    PaletteCommand::new(
        "Expand level",
        &["expand 1", "expand 2", "expand 3", "expand 4", "expand 5"],
        "Expand headings at specific level",
        CommandAction::ExpandLevel,
    ),
    PaletteCommand::new(
        "Theme picker",
        &["theme", "colors"],
        "Choose a color theme (live preview)",
        CommandAction::Dispatch(Action::ToggleThemePicker),
    ),
    PaletteCommand::new(
        "Search content",
        &["/", "find", "search"],
        "Search the document content",
        CommandAction::Dispatch(Action::EnterDocSearch),
    ),
    PaletteCommand::new(
        "Filter outline",
        &["s", "filter"],
        "Filter outline headings",
        CommandAction::Dispatch(Action::EnterSearchMode),
    ),
    PaletteCommand::new(
        "Interactive mode",
        &["i", "interactive"],
        "Navigate tables, checkboxes, links and code blocks",
        CommandAction::Dispatch(Action::EnterInteractiveMode),
    ),
    PaletteCommand::new(
        "Link mode",
        &["f", "links", "follow"],
        "Follow links in the current section",
        CommandAction::Dispatch(Action::EnterLinkFollowMode),
    ),
    PaletteCommand::new(
        "Open in editor",
        &["e", "edit", "editor"],
        "Open the file in $VISUAL/$EDITOR at the current position",
        CommandAction::Dispatch(Action::OpenInEditor),
    ),
    PaletteCommand::new(
        "Open file picker",
        &["o", "open", "files"],
        "Browse and open markdown files",
        CommandAction::Dispatch(Action::OpenFilePicker),
    ),
    PaletteCommand::new(
        "Set bookmark",
        &["m", "mark", "bookmark"],
        "Bookmark the current position",
        CommandAction::Dispatch(Action::SetBookmark),
    ),
    PaletteCommand::new(
        "Jump to bookmark",
        &["'", "gotomark"],
        "Jump to the bookmarked position",
        CommandAction::Dispatch(Action::JumpToBookmark),
    ),
    PaletteCommand::new(
        "Copy section",
        &["y", "copy", "yank"],
        "Copy current section content to clipboard",
        CommandAction::Dispatch(Action::CopyContent),
    ),
    PaletteCommand::new(
        "Copy anchor link",
        &["anchor"],
        "Copy the current heading's anchor link",
        CommandAction::Dispatch(Action::CopyAnchor),
    ),
    PaletteCommand::new(
        "Toggle TODO filter",
        &["todo", "tasks"],
        "Show only sections containing open tasks",
        CommandAction::Dispatch(Action::ToggleTodoFilter),
    ),
    PaletteCommand::new(
        "Go back",
        &["back", "b"],
        "Go back to the previous file",
        CommandAction::Dispatch(Action::GoBack),
    ),
    PaletteCommand::new(
        "Go forward",
        &["forward"],
        "Go forward in navigation history",
        CommandAction::Dispatch(Action::GoForward),
    ),
    PaletteCommand::new(
        "Quit",
        &["q", "quit", "exit"],
        "Exit treemd",
        CommandAction::Quit,
    ),
];

/// A match found during search
#[derive(Debug, Clone)]
pub struct SearchMatch {
    /// Line number (0-indexed)
    pub line: usize,
    /// Start column (byte offset in line)
    pub col_start: usize,
    /// Length of match in bytes
    pub len: usize,
}

/// In-document search state (`/` then `n`/`N`).
#[derive(Debug, Default)]
pub struct DocSearchState {
    pub query: String,
    pub matches: Vec<SearchMatch>,
    pub current_idx: Option<usize>,
    /// Whether the input prompt is active (cursor visible).
    pub active: bool,
    /// True when search was opened from interactive mode (so it returns there).
    pub from_interactive: bool,
    /// If the current match falls inside a link, this is the index into
    /// `links_in_view`.
    pub selected_link_idx: Option<usize>,
}

/// Command palette state.
#[derive(Debug)]
pub struct CommandPaletteState {
    pub query: String,
    /// Indices into `PALETTE_COMMANDS`, ordered by match score.
    pub filtered: Vec<usize>,
    pub selected: usize,
}

impl Default for CommandPaletteState {
    fn default() -> Self {
        Self {
            query: String::new(),
            filtered: (0..PALETTE_COMMANDS.len()).collect(),
            selected: 0,
        }
    }
}

/// File picker state — files and directories listing, search, selection.
#[derive(Debug, Default)]
pub struct FilePickerState {
    pub files: Vec<PathBuf>,
    pub dirs: Vec<PathBuf>,
    pub filtered_file_indices: Vec<usize>,
    pub filtered_dir_indices: Vec<usize>,
    /// Selected index into the combined dirs+files list.
    pub selected: Option<usize>,
    pub query: String,
    /// Whether the search input is active (cursor visible).
    pub active: bool,
}

/// Link picker state (`f` to open).
#[derive(Debug, Default)]
pub struct LinkPickerState {
    pub filtered_indices: Vec<usize>,
    pub selected: Option<usize>,
    pub query: String,
    pub active: bool,
}

/// Modal image viewer state.
#[derive(Default)]
pub struct ImageModalState {
    pub path: Option<std::path::PathBuf>,
    pub state: Option<ratatui_image::protocol::StatefulProtocol>,
    pub gif_frames: Vec<crate::tui::image_cache::GifFrame>,
    pub frame_protocols: Vec<ratatui_image::protocol::StatefulProtocol>,
    pub frame_index: usize,
    pub last_rendered_frame: Option<usize>,
    pub last_frame_update: Option<Instant>,
    pub animation_paused: bool,
}

/// A high-level edit applied to whichever text-input field is active.
/// Keys are translated to edits in one place so every input field gets the
/// same editing behavior (and modifier chords never insert literal chars).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextInputEdit {
    Insert(char),
    /// Ctrl+U — clear the whole input
    Clear,
    /// Ctrl+W — delete the trailing word
    DeleteWord,
}

/// A pending table cell edit that hasn't been saved to file yet
#[derive(Debug, Clone)]
pub struct PendingEdit {
    /// Source line (0-indexed) where the table's section starts; table
    /// counting for this edit begins at this line
    pub section_start_line: usize,
    /// Which table within the section (0-indexed)
    pub table_index: usize,
    /// Row within the table (0 = header, 1+ = data rows, excludes separator)
    pub row: usize,
    /// Column within the table (0-indexed)
    pub col: usize,
    /// The original value before editing (for undo)
    pub original_value: String,
    /// The new value after editing
    pub new_value: String,
}

pub struct App {
    pub document: Document,
    pub filename: String,
    pub tree: Vec<HeadingNode>,
    pub outline_state: ListState,
    pub outline_scroll_state: ScrollbarState,
    pub focus: Focus,
    pub outline_items: Vec<OutlineItem>,
    pub content_scroll: u16,
    pub content_scroll_state: ScrollbarState,
    pub content_height: usize,
    pub content_viewport_height: u16, // Actual viewport height for scroll calculations
    pub show_help: bool,
    pub help_scroll: u16,
    pub show_search: bool,
    pub outline_search_active: bool, // Whether search input is active (cursor visible)
    pub search_query: String,
    pub highlighter: SyntaxHighlighter,
    pub show_outline: bool,
    pub show_heading_markers: bool, // Show # prefixes in outline sidebar
    /// Whether terminal mouse capture is active. When on, the scroll wheel drives
    /// navigation but the terminal's native click-drag text selection is disabled.
    /// Toggling it off hands the mouse back to the terminal so text can be selected
    /// and copied. Enabled at startup to match `main.rs`'s best-effort capture.
    pub mouse_capture: bool,
    pub outline_width: u16, // Percentage: 20, 30, or 40
    /// Whether the config file had a custom (non-standard) outline width at startup.
    /// Used to protect power users' custom config values from being overwritten.
    /// Standard values are 20, 30, 40; anything else is considered custom.
    config_has_custom_outline_width: bool,
    pub bookmark_position: Option<String>, // Bookmarked heading text (was: outline position)
    collapsed_headings: HashSet<String>,   // Track which headings are collapsed by text
    pub filter_by_todos: bool,             // Filter outline to show only headings with open todos
    pub current_theme: ThemeName,
    pub theme: Theme,
    pub show_theme_picker: bool,
    pub theme_picker_selected: usize,
    pub theme_picker_original: Option<ThemeName>, // Original theme before picker opened (for cancel)
    previous_selection: Option<Option<usize>>,    // Heading index for change detection
    /// True when the cached content_height/scrollbar may be stale and need
    /// recomputation on the next `update_content_metrics`. Set by file
    /// reload, raw-source toggle, etc.
    metrics_dirty: bool,

    // Link following state
    pub mode: AppMode,
    /// Vim-style count prefix for motion commands (e.g., 5j moves down 5)
    pub count_prefix: Option<usize>,
    pub current_file_path: PathBuf, // Path to current file for resolving relative links
    pub file_path_changed: bool,    // Flag to signal file watcher needs update
    pub suppress_file_watch: bool,  // Skip next file watch check (after internal save)
    pub links_in_view: Vec<Link>,   // Links in currently displayed content
    pub link_picker: LinkPickerState,

    // File picker state
    pub file_picker: FilePickerState,
    pub startup_needs_file_picker: bool, // True if started without file arg
    pub file_picker_dir: Option<PathBuf>, // Custom directory for file picker
    pub show_hidden: bool,               // Whether to show hidden (dot) files and directories

    pub file_history: Vec<FileState>,   // Back navigation stack
    pub file_future: Vec<FileState>,    // Forward navigation stack (for undo back)
    pub status_message: Option<String>, // Temporary status message to display
    pub status_message_time: Option<Instant>, // When the status message was set

    // Interactive element navigation
    pub interactive_state: InteractiveState,

    // Cell editing state
    pub cell_edit_value: String,          // Current value being edited
    pub cell_edit_original_value: String, // Original value before editing (for undo)
    pub cell_edit_row: usize,             // Row being edited
    pub cell_edit_col: usize,             // Column being edited

    // Pending edits buffer (for safe editing with explicit save)
    pub pending_edits: Vec<PendingEdit>, // Stack of uncommitted edits
    pub has_unsaved_changes: bool,       // True if pending_edits is non-empty

    // Persistent clipboard for Linux X11 compatibility
    // On Linux, the clipboard instance must stay alive to serve paste requests
    clipboard: Option<arboard::Clipboard>,

    // Configuration persistence
    config: Config,
    color_mode: ColorMode,

    // Pending file to open in external editor (set by link following, consumed by main loop)
    pub pending_editor_file: Option<PathBuf>,

    // Raw source view toggle
    pub show_raw_source: bool,

    // Pending file creation (for confirm dialog)
    pub pending_file_create: Option<PathBuf>,
    pub pending_file_create_message: Option<String>,

    /// In-document search (/ + n/N).
    pub doc_search: DocSearchState,

    /// Command palette (`:`).
    pub command_palette: CommandPaletteState,

    // Customizable keybindings
    pub keybindings: Keybindings,

    // Pending navigation (for confirm save dialog when navigating with unsaved changes)
    pub pending_navigation: Option<PendingNavigation>,

    // Terminal graphics protocol picker (with fallback font size)
    pub picker: Option<ratatui_image::picker::Picker>,

    // Cached image protocols keyed by resolved path (avoids re-decoding from disk every frame)
    /// Decoded image protocols. Bounded LRU-ish cache: insertion-ordered, with
    /// the oldest entries evicted when capacity is reached. Keeping recently
    /// seen images survives navigation between sections so the user doesn't
    /// pay the decode cost on every back-and-forth.
    pub image_protocol_cache: IndexMap<PathBuf, ratatui_image::protocol::StatefulProtocol>,

    // Image modal viewing state (path, current frame, GIF playback).
    pub image_modal: ImageModalState,

    // Native Kitty animation (for flicker-free GIF playback)
    pub kitty_animation: Option<KittyAnimation>,
    pub use_kitty_animation: bool, // Whether to use native Kitty animation

    // Image rendering control (can be disabled via config or CLI)
    pub images_enabled: bool,

    // Mermaid diagram rendering cache: source hash → rendered protocol
    #[cfg(all(feature = "mermaid", unix))]
    pub mermaid_protocol_cache: HashMap<u64, ratatui_image::protocol::StatefulProtocol>,
    #[cfg(all(feature = "mermaid", unix))]
    pub mermaid_render_errors: HashMap<u64, String>,
    #[cfg(all(feature = "mermaid", unix))]
    pub mermaid_last_render_width: u32,
    /// Pixel dimensions (width, height) of each rendered mermaid image, keyed by source hash.
    #[cfg(all(feature = "mermaid", unix))]
    pub mermaid_image_dims: HashMap<u64, (u32, u32)>,
    /// Terminal row count for each rendered mermaid image, derived from pixel height / font height.
    #[cfg(all(feature = "mermaid", unix))]
    pub mermaid_placeholder_rows: HashMap<u64, usize>,
    /// Set when new mermaid dims are stored; triggers a re-index on the next frame.
    #[cfg(all(feature = "mermaid", unix))]
    pub mermaid_needs_reindex: bool,

    // LaTeX detection state
    pub latex_detected: bool,
    pub latex_hint_shown: bool,
}

/// Saved state for file navigation history
#[derive(Debug, Clone)]
pub struct FileState {
    pub path: PathBuf,
    pub document: Document,
    pub filename: String,
    pub selected_heading: Option<String>,
    pub content_scroll: u16,
    pub outline_state_selected: Option<usize>,
}

#[derive(Debug, Clone)]
pub struct OutlineItem {
    pub level: usize,
    pub text: String,
    pub expanded: bool,
    pub has_children: bool, // Track if this heading has children in the tree
    pub heading_index: Option<usize>, // Index into Document::headings
}

impl App {
    pub fn new(
        document: Document,
        filename: String,
        file_path: PathBuf,
        config: Config,
        color_mode: ColorMode,
        images_enabled: bool,
    ) -> Self {
        let tree = document.build_tree();
        let collapsed_headings = HashSet::new();
        let mut outline_items = Self::flatten_tree(&tree, &collapsed_headings);

        // Add document overview entry if there's preamble content or no headings
        let has_preamble = Self::has_preamble_content(&document);
        if has_preamble || document.headings.is_empty() {
            outline_items.insert(
                0,
                OutlineItem {
                    level: 0, // Level 0 for document overview (renders without # prefix)
                    text: DOCUMENT_OVERVIEW.to_string(),
                    expanded: true,
                    has_children: !outline_items.is_empty(),
                    heading_index: None,
                },
            );
        }

        let mut outline_state = ListState::default();
        if !outline_items.is_empty() {
            outline_state.select(Some(0));
        }

        let content_lines = document.content.lines().count();

        // Load theme from config, apply color mode, then apply custom colors
        let current_theme = config.theme_name();
        let theme = Theme::from_name(current_theme)
            .with_color_mode(color_mode, current_theme)
            .with_custom_colors(&config.theme, color_mode);

        // Load sublime color scheme directory
        let code_theme_dir = config.code_theme_dir_path();
        // Load sublime color scheme name (for code highlighting)
        let code_theme = config.ui.code_theme.as_str();

        // Load outline width from config
        let outline_width = config.ui.outline_width;

        // Detect if config has a custom (non-standard) outline width
        // Standard values: 20, 30, 40 - anything else is a custom power-user setting
        let config_has_custom_outline_width =
            outline_width != 20 && outline_width != 30 && outline_width != 40;

        // Load keybindings from config (before config is moved)
        let keybindings = config.keybindings();

        Self {
            document,
            filename,
            tree,
            outline_state,
            outline_scroll_state: ScrollbarState::new(outline_items.len()),
            focus: Focus::Outline,
            outline_items,
            content_scroll: 0,
            content_scroll_state: ScrollbarState::new(content_lines),
            content_height: content_lines,
            content_viewport_height: 20, // Default, will be updated by UI on first render
            show_help: false,
            help_scroll: 0,
            show_search: false,
            outline_search_active: false,
            search_query: String::new(),
            highlighter: SyntaxHighlighter::new(code_theme, code_theme_dir),
            show_outline: true,
            show_heading_markers: config.ui.outline_heading_markers,
            mouse_capture: true,
            outline_width,
            config_has_custom_outline_width,
            bookmark_position: None,
            collapsed_headings,
            filter_by_todos: false,
            current_theme,
            theme,
            show_theme_picker: false,
            theme_picker_selected: 0,
            theme_picker_original: None,
            previous_selection: None,
            metrics_dirty: true,

            // Link following state
            mode: AppMode::Normal,
            count_prefix: None,
            current_file_path: file_path,
            file_path_changed: false,
            suppress_file_watch: false,
            links_in_view: Vec::new(),
            link_picker: LinkPickerState::default(),

            // File picker state
            file_picker: FilePickerState::default(),
            startup_needs_file_picker: false,
            file_picker_dir: None,
            show_hidden: false,

            file_history: Vec::new(),
            file_future: Vec::new(),
            status_message: None,
            status_message_time: None,

            // Interactive element navigation
            interactive_state: InteractiveState::new(),

            // Cell editing state
            cell_edit_value: String::new(),
            cell_edit_original_value: String::new(),
            cell_edit_row: 0,
            cell_edit_col: 0,

            // Pending edits buffer
            pending_edits: Vec::new(),
            has_unsaved_changes: false,

            // Initialize persistent clipboard (None if unavailable)
            clipboard: arboard::Clipboard::new().ok(),

            // Configuration persistence
            config,
            color_mode,

            // Pending editor file
            pending_editor_file: None,

            // Raw source view (off by default)
            show_raw_source: false,

            // Pending file creation (for confirm dialog)
            pending_file_create: None,
            pending_file_create_message: None,

            // Document search state
            doc_search: DocSearchState::default(),

            // Command palette state
            command_palette: CommandPaletteState::default(),

            // Customizable keybindings (loaded from config)
            // Note: keybindings() called before config is moved into struct
            keybindings,

            // Pending navigation (for confirm save dialog)
            pending_navigation: None,

            // Terminal graphics protocol picker with fallback (like figif)
            // Only initialize if images are enabled
            picker: if images_enabled {
                Self::init_picker()
            } else {
                None
            },

            // Cached image protocols (populated after index_elements)
            image_protocol_cache: IndexMap::new(),

            // Image modal viewing state (path, GIF playback, etc.)
            image_modal: ImageModalState::default(),

            // Native Kitty animation
            kitty_animation: None,
            use_kitty_animation: images_enabled && kitty_animation::is_kitty_terminal(),

            // Image rendering control
            images_enabled,

            // Mermaid diagram cache
            #[cfg(all(feature = "mermaid", unix))]
            mermaid_protocol_cache: HashMap::new(),
            #[cfg(all(feature = "mermaid", unix))]
            mermaid_render_errors: HashMap::new(),
            #[cfg(all(feature = "mermaid", unix))]
            mermaid_last_render_width: 0,
            #[cfg(all(feature = "mermaid", unix))]
            mermaid_image_dims: HashMap::new(),
            #[cfg(all(feature = "mermaid", unix))]
            mermaid_placeholder_rows: HashMap::new(),
            #[cfg(all(feature = "mermaid", unix))]
            mermaid_needs_reindex: false,

            // LaTeX detection
            latex_detected: false,
            latex_hint_shown: false,
        }
    }

    /// Initialize graphics protocol picker with stdio protocol detection.
    ///
    /// Ratatui-image's stdio query is authoritative because terminal environment
    /// variables can be stale in tmux or remote sessions. Environment hints are
    /// only used when the query returns no capabilities at all.
    fn init_picker() -> Option<ratatui_image::picker::Picker> {
        use ratatui_image::picker::Picker;

        let mut picker = match Picker::from_query_stdio() {
            Ok(picker) => {
                let font_size = picker.font_size();
                if font_size.width < 4 || font_size.height < 4 {
                    Picker::halfblocks()
                } else {
                    picker
                }
            }
            Err(_) => Picker::halfblocks(),
        };

        if Self::should_use_env_protocol_fallback(&picker)
            && let Some(protocol) = Self::detect_image_protocol_fallback()
        {
            picker.set_protocol_type(protocol);
        }

        Some(picker)
    }

    fn should_use_env_protocol_fallback(picker: &ratatui_image::picker::Picker) -> bool {
        use ratatui_image::picker::ProtocolType;

        picker.protocol_type() == ProtocolType::Halfblocks && picker.capabilities().is_empty()
    }

    /// Detect an image protocol fallback from environment variables.
    ///
    /// Returns None if no specific fallback is detected or if the terminal is
    /// known to report buggy graphics support.
    fn detect_image_protocol_fallback() -> Option<ratatui_image::picker::ProtocolType> {
        use ratatui_image::picker::ProtocolType;

        if std::env::var("KONSOLE_VERSION").is_ok_and(|value| !value.is_empty()) {
            return None;
        }

        if std::env::var("TERM_PROGRAM").is_ok_and(|term_program| {
            term_program.contains("iTerm")
                || term_program.contains("WezTerm")
                || term_program.contains("mintty")
                || term_program.contains("vscode")
                || term_program.contains("Tabby")
                || term_program.contains("Hyper")
                || term_program.contains("rio")
                || term_program.contains("Bobcat")
                || term_program.contains("WarpTerminal")
        }) || std::env::var("WEZTERM_EXECUTABLE").is_ok_and(|value| !value.is_empty())
        {
            return Some(ProtocolType::Iterm2);
        }

        // Kitty graphics protocol
        if std::env::var("KITTY_WINDOW_ID").is_ok_and(|value| !value.is_empty()) {
            return Some(ProtocolType::Kitty);
        }

        // Ghostty uses Kitty protocol
        if let Ok(term) = std::env::var("TERM") {
            if term.contains("ghostty") {
                return Some(ProtocolType::Kitty);
            }
            // Sixel support
            if term.contains("sixel") || term.contains("foot") {
                return Some(ProtocolType::Sixel);
            }
        }

        None
    }

    /// Populate the image protocol cache from indexed interactive elements.
    ///
    /// Decodes any new images and creates stateful protocols, keeping
    /// previously cached images so navigation back and forth doesn't re-decode.
    /// Bounded by `IMAGE_CACHE_LIMIT`; oldest entries are evicted when full.
    pub fn populate_image_cache(&mut self) {
        const IMAGE_CACHE_LIMIT: usize = 32;
        use std::collections::HashSet as ImgSet;

        if self.picker.is_none() || !self.images_enabled {
            return;
        }

        // Collect unique image src strings from indexed elements
        let mut seen = ImgSet::new();
        let srcs: Vec<String> = self
            .interactive_state
            .elements
            .iter()
            .filter_map(|elem| {
                if let ElementType::Image { src, .. } = &elem.element_type {
                    if seen.insert(src.clone()) {
                        Some(src.clone())
                    } else {
                        None
                    }
                } else {
                    None
                }
            })
            .collect();

        // Decode any newly-seen images. Existing cache entries are preserved.
        for src in &srcs {
            let path = match self.resolve_image_path(src) {
                Ok(p) => p,
                Err(_) => continue,
            };

            if self.image_protocol_cache.contains_key(&path) {
                continue;
            }

            let img_data = match crate::tui::image_cache::ImageCache::extract_first_frame(&path) {
                Ok(data) => data,
                Err(_) => continue,
            };

            let picker = match self.picker.as_mut() {
                Some(p) => p,
                None => continue,
            };

            let protocol = picker.new_resize_protocol(img_data);
            self.image_protocol_cache.insert(path, protocol);

            // Evict oldest entries when over capacity (FIFO is a reasonable
            // proxy for LRU here without bringing in a dedicated LRU crate).
            while self.image_protocol_cache.len() > IMAGE_CACHE_LIMIT {
                self.image_protocol_cache.shift_remove_index(0);
            }
        }
    }

    /// Render a mermaid diagram and cache the result as a StatefulProtocol.
    /// Returns true if a cached protocol is available (either fresh or from prior render).
    #[cfg(all(feature = "mermaid", unix))]
    pub fn render_mermaid_if_needed(&mut self, source: &str, width: u16) -> bool {
        use std::hash::{Hash, Hasher};
        let mut hasher = std::collections::hash_map::DefaultHasher::new();
        source.hash(&mut hasher);
        let hash = hasher.finish();

        // Use picker's actual font width for accurate pixel calculation
        let font_width = self.picker.as_ref().map_or(8u16, |p| {
            let font_size = p.font_size();
            if font_size.width < 1 {
                8
            } else {
                font_size.width
            }
        });
        let target_px = (width as u32) * (font_width as u32);

        // Clear caches on render width change (re-rasterize at new size)
        if target_px != self.mermaid_last_render_width {
            self.mermaid_protocol_cache.clear();
            self.mermaid_render_errors.clear();
            self.mermaid_image_dims.clear();
            self.mermaid_placeholder_rows.clear();
            self.mermaid_needs_reindex = false;
            self.mermaid_last_render_width = target_px;
        }

        // Already cached (success or failure)
        if self.mermaid_protocol_cache.contains_key(&hash) {
            return true;
        }
        if self.mermaid_render_errors.contains_key(&hash) {
            return false;
        }
        match crate::tui::mermaid::render_mermaid_to_image(source, target_px) {
            Ok(img) => {
                let dims = (img.width(), img.height());
                if let Some(picker) = self.picker.as_mut() {
                    let font_h = {
                        let font_size = picker.font_size();
                        if font_size.height < 1 {
                            16u32
                        } else {
                            font_size.height as u32
                        }
                    };
                    let rows = dims.1.div_ceil(font_h) as usize;
                    let protocol = picker.new_resize_protocol(img);
                    self.mermaid_protocol_cache.insert(hash, protocol);
                    self.mermaid_image_dims.insert(hash, dims);
                    self.mermaid_placeholder_rows.insert(hash, rows.max(1));
                    self.mermaid_needs_reindex = true;
                    return true;
                }
                false
            }
            Err(e) => {
                self.mermaid_render_errors.insert(hash, e);
                false
            }
        }
    }

    /// Re-index interactive elements using pixel-accurate placeholder rows if new dims arrived.
    ///
    /// Called once per frame (before rendering). On the frame after a mermaid diagram is first
    /// rendered, this replaces the heuristic placeholder sizes with the real image heights so
    /// the blank-line reservation matches the displayed image.
    #[cfg(all(feature = "mermaid", unix))]
    pub fn reindex_mermaid_if_needed(&mut self) {
        if !self.mermaid_needs_reindex {
            return;
        }
        self.mermaid_needs_reindex = false;
        let content_text = self.current_section_content();
        use crate::parser::content::parse_content;
        let blocks = parse_content(&content_text, 0);
        let rows = self.mermaid_placeholder_rows.clone();
        self.interactive_state.index_elements(&blocks, &rows);
    }

    /// Index interactive elements, passing the mermaid placeholder-rows cache when available.
    ///
    /// Centralises the cfg-gated map extraction so callers don't repeat the pattern.
    pub(crate) fn index_interactive_elements(&mut self, blocks: &[crate::parser::output::Block]) {
        #[cfg(all(feature = "mermaid", unix))]
        let rows = self.mermaid_placeholder_rows.clone();
        #[cfg(not(all(feature = "mermaid", unix)))]
        let rows: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
        self.interactive_state.index_elements(blocks, &rows);
    }

    /// Get the hash for a mermaid source string.
    #[cfg(all(feature = "mermaid", unix))]
    pub fn mermaid_source_hash(source: &str) -> u64 {
        use std::hash::{Hash, Hasher};
        let mut hasher = std::collections::hash_map::DefaultHasher::new();
        source.hash(&mut hasher);
        hasher.finish()
    }

    /// Open image modal for a given image path
    pub fn open_image_modal(&mut self, image_src: &str) {
        // Skip if images are disabled
        if !self.images_enabled {
            return;
        }

        // Try to resolve and load the image
        if let Ok(path) = self.resolve_image_path(image_src) {
            // Load all frames (for GIF animation)
            if let Ok(frames) = crate::tui::image_cache::ImageCache::extract_all_frames(&path)
                && !frames.is_empty()
                && let Some(picker) = &mut self.picker
            {
                // Create initial protocol for first frame only.
                // Subsequent frames will be created on-demand during animation
                // to avoid memory overhead of pre-computing all protocols.
                let initial_protocol = picker.new_resize_protocol(frames[0].image.clone());

                self.image_modal.path = Some(path);
                self.image_modal.state = Some(initial_protocol);
                self.image_modal.gif_frames = frames;
                self.image_modal.frame_protocols.clear(); // Not used anymore
                self.image_modal.frame_index = 0;
                self.image_modal.last_rendered_frame = Some(0); // Mark first frame as rendered
                self.image_modal.last_frame_update = Some(Instant::now());
            }
        }
    }

    /// Close the image modal
    pub fn close_image_modal(&mut self) {
        // Delete Kitty animation if active
        self.stop_kitty_animation();

        self.image_modal.path = None;
        self.image_modal.state = None;
        self.image_modal.gif_frames.clear();
        self.image_modal.frame_protocols.clear();
        self.image_modal.frame_index = 0;
        self.image_modal.last_rendered_frame = None;
        self.image_modal.last_frame_update = None;
        self.image_modal.animation_paused = false;
    }

    /// Go to previous frame in GIF animation
    pub fn modal_prev_frame(&mut self) {
        if self.image_modal.gif_frames.is_empty() {
            return;
        }
        // Stop Kitty animation - it doesn't support frame stepping, so fall back to software
        self.stop_kitty_animation();
        // Pause animation when manually stepping
        self.image_modal.animation_paused = true;
        let len = self.image_modal.gif_frames.len();
        self.image_modal.frame_index = (self.image_modal.frame_index + len - 1) % len;
        // Force re-render of the new frame
        self.image_modal.last_rendered_frame = None;
    }

    /// Go to next frame in GIF animation
    pub fn modal_next_frame(&mut self) {
        if self.image_modal.gif_frames.is_empty() {
            return;
        }
        // Stop Kitty animation - it doesn't support frame stepping, so fall back to software
        self.stop_kitty_animation();
        // Pause animation when manually stepping
        self.image_modal.animation_paused = true;
        self.image_modal.frame_index =
            (self.image_modal.frame_index + 1) % self.image_modal.gif_frames.len();
        // Force re-render of the new frame
        self.image_modal.last_rendered_frame = None;
    }

    /// Stop and delete Kitty animation, falling back to software rendering.
    /// Called when manual frame control is needed (stepping).
    fn stop_kitty_animation(&mut self) {
        if let Some(ref anim) = self.kitty_animation {
            let mut stdout = std::io::stdout();
            let _ = kitty_animation::delete_animation(&mut stdout, anim);
        }
        self.kitty_animation = None;
    }

    /// Toggle animation play/pause
    pub fn modal_toggle_animation(&mut self) {
        self.image_modal.animation_paused = !self.image_modal.animation_paused;

        // Control Kitty animation if active
        if let Some(ref anim) = self.kitty_animation {
            let mut stdout = std::io::stdout();
            if self.image_modal.animation_paused {
                let _ = kitty_animation::pause_animation(&mut stdout, anim);
            } else {
                let _ = kitty_animation::resume_animation(&mut stdout, anim);
            }
        }

        if !self.image_modal.animation_paused {
            // Reset the timer when resuming
            self.image_modal.last_frame_update = Some(Instant::now());
        }
    }

    /// Check if image modal is open
    pub fn is_image_modal_open(&self) -> bool {
        self.image_modal.path.is_some()
    }

    /// Start Kitty native animation for GIF playback.
    /// Called from render when we know the exact coordinates.
    /// Returns true if animation was started successfully.
    pub fn start_kitty_animation(&mut self, col: u16, row: u16) -> bool {
        // Only start if:
        // 1. Use Kitty animation is enabled
        // 2. We have multiple frames (GIF)
        // 3. Animation hasn't started yet
        if !self.use_kitty_animation
            || self.image_modal.gif_frames.len() <= 1
            || self.kitty_animation.is_some()
        {
            return false;
        }

        // Prepare frames for Kitty animation
        let frames: Vec<(image::DynamicImage, u32)> = self
            .image_modal
            .gif_frames
            .iter()
            .map(|f| (f.image.clone(), f.delay_ms))
            .collect();

        // Transmit animation to Kitty terminal
        let mut stdout = std::io::stdout();
        match kitty_animation::transmit_animation(&mut stdout, &frames, col, row) {
            Ok(Some(anim)) => {
                self.kitty_animation = Some(anim);
                true
            }
            Ok(None) => false,
            Err(_) => {
                // Fall back to software animation
                self.use_kitty_animation = false;
                false
            }
        }
    }

    /// Check if Kitty animation is active
    pub fn has_kitty_animation(&self) -> bool {
        self.kitty_animation.is_some()
    }

    /// Get time until next GIF frame should be displayed.
    /// Returns None if not animating, Some(Duration) otherwise.
    /// Used by the event loop to optimize poll timeout for smooth animation.
    pub fn time_until_next_frame(&self) -> Option<std::time::Duration> {
        // Kitty handles animation timing internally - no client-side timing needed
        if self.kitty_animation.is_some() {
            return None;
        }

        // Must be in image modal with multiple frames and not paused
        if !self.is_image_modal_open()
            || self.image_modal.gif_frames.len() <= 1
            || self.image_modal.animation_paused
        {
            return None;
        }

        let last_update = self.image_modal.last_frame_update?;
        let current_frame = &self.image_modal.gif_frames[self.image_modal.frame_index];
        let frame_delay = std::time::Duration::from_millis(current_frame.delay_ms as u64);
        let elapsed = last_update.elapsed();

        if elapsed >= frame_delay {
            // Frame is due now - return minimal duration to trigger immediate redraw
            Some(std::time::Duration::from_millis(1))
        } else {
            Some(frame_delay - elapsed)
        }
    }

    /// Update the content viewport height (called by UI when terminal size is known)
    pub fn set_viewport_height(&mut self, height: u16) {
        self.content_viewport_height = height.max(1); // Ensure at least 1 to avoid divide-by-zero
    }

    /// Get the current keybinding mode based on app state
    pub fn current_keybinding_mode(&self) -> KeybindingMode {
        // Check modal states first
        if self.show_help {
            return KeybindingMode::Help;
        }
        if self.show_theme_picker {
            return KeybindingMode::ThemePicker;
        }

        // Then check app mode
        match self.mode {
            AppMode::Normal => {
                if self.show_search && self.outline_search_active {
                    // Active input mode for typing search query
                    KeybindingMode::Search
                } else {
                    // Normal mode (including accepted outline search state)
                    // When show_search=true but outline_search_active=false, we're in
                    // "accepted search" state - user can navigate filtered results with
                    // normal keybindings (j/k, n/N for cycling, s to start new search)
                    KeybindingMode::Normal
                }
            }
            AppMode::Interactive => {
                if self.interactive_state.is_in_table_mode() {
                    KeybindingMode::InteractiveTable
                } else {
                    KeybindingMode::Interactive
                }
            }
            AppMode::LinkFollow => {
                if self.link_picker.active {
                    KeybindingMode::LinkSearch
                } else {
                    KeybindingMode::LinkFollow
                }
            }
            AppMode::Search => KeybindingMode::Search,
            AppMode::ThemePicker => KeybindingMode::ThemePicker,
            AppMode::Help => KeybindingMode::Help,
            AppMode::CellEdit => KeybindingMode::CellEdit,
            AppMode::ConfirmFileCreate
            | AppMode::ConfirmSaveWidth
            | AppMode::ConfirmSaveBeforeQuit
            | AppMode::ConfirmSaveBeforeNav => KeybindingMode::ConfirmDialog,
            AppMode::DocSearch => KeybindingMode::DocSearch,
            AppMode::CommandPalette => KeybindingMode::CommandPalette,
            AppMode::FilePicker => {
                if self.file_picker.active {
                    KeybindingMode::FileSearch
                } else {
                    KeybindingMode::FilePicker
                }
            }
            // FileSearch mode is no longer used - we use FilePicker mode with file_search_active flag
            AppMode::FileSearch => KeybindingMode::FileSearch,
        }
    }

    /// Get the action for a key press in the current mode
    pub fn get_action_for_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
        use crossterm::event::{KeyEvent, KeyEventKind, KeyEventState};

        let mode = self.current_keybinding_mode();
        let event = KeyEvent {
            code,
            modifiers,
            kind: KeyEventKind::Press,
            state: KeyEventState::NONE,
        };

        self.keybindings.dispatch(mode, event)
    }

    /// Execute an action, returning the result type
    ///
    /// Returns:
    /// - `ActionResult::Continue` - continue the main loop
    /// - `ActionResult::Quit` - exit the application
    /// - `ActionResult::RunEditor(PathBuf, Option<u32>)` - run editor on file at optional line
    pub fn execute_action(&mut self, action: Action) -> ActionResult {
        use Action::*;

        match action {
            // === Miscellaneous ===
            Noop => {}
            // === Application ===
            Quit => {
                // If in accepted outline search state, clear search instead of quitting
                if self.show_search {
                    self.search_query.clear();
                    self.filter_outline();
                    self.show_search = false;
                    self.outline_search_active = false;
                } else if self.has_unsaved_changes {
                    // Prompt to save before quitting
                    self.mode = AppMode::ConfirmSaveBeforeQuit;
                } else {
                    return ActionResult::Quit;
                }
            }
            Redraw => {
                return ActionResult::Redraw;
            }

            // === Navigation ===
            Next => {
                let count = self.take_count();
                if self.mode == AppMode::FilePicker {
                    for _ in 0..count {
                        self.next_file();
                    }
                } else {
                    for _ in 0..count {
                        self.next();
                    }
                }
            }
            Previous => {
                let count = self.take_count();
                if self.mode == AppMode::FilePicker {
                    for _ in 0..count {
                        self.previous_file();
                    }
                } else {
                    for _ in 0..count {
                        self.previous();
                    }
                }
            }
            First => {
                self.clear_count();
                self.first();
            }
            Last => {
                self.clear_count();
                self.last();
            }
            PageDown => {
                self.clear_count();
                if self.show_help {
                    self.scroll_help_page_down();
                } else {
                    self.scroll_page_down();
                }
            }
            PageUp => {
                self.clear_count();
                if self.show_help {
                    self.scroll_help_page_up();
                } else {
                    self.scroll_page_up();
                }
            }
            JumpToParent => {
                self.clear_count();
                self.jump_to_parent();
            }

            // === Outline ===
            Expand => self.expand(),
            Collapse => self.collapse(),
            ToggleExpand => self.toggle_expand(),
            ToggleFocus => self.toggle_focus(),
            ToggleFocusBack => self.toggle_focus_back(),
            ToggleOutline => self.toggle_outline(),
            OutlineWidthIncrease => self.cycle_outline_width(true),
            OutlineWidthDecrease => self.cycle_outline_width(false),
            ToggleTodoFilter => self.toggle_todo_filter(),
            ToggleHeadingMarkers => self.toggle_heading_markers(),

            // === Bookmarks ===
            SetBookmark => self.set_bookmark(),
            JumpToBookmark => self.jump_to_bookmark(),

            // === Mode Transitions ===
            EnterInteractiveMode => self.enter_interactive_mode(),
            ExitInteractiveMode => self.exit_interactive_mode(),
            EnterLinkFollowMode => self.enter_link_follow_mode(),
            EnterSearchMode => self.toggle_search(),
            EnterDocSearch => self.enter_doc_search(),
            ToggleSearchMode => self.toggle_search_mode(),
            ExitMode => self.exit_current_mode(),
            OpenCommandPalette => self.open_command_palette(),

            // === Link Navigation ===
            NextLink => self.next_link(),
            PreviousLink => self.previous_link(),
            FollowLink => match self.mode {
                AppMode::LinkFollow => {
                    if let Err(e) = self.follow_selected_link() {
                        self.status_message = Some(format!("✗ Error: {}", e));
                    }
                    self.update_content_metrics();
                }
                AppMode::FilePicker | AppMode::FileSearch => {
                    if let Err(e) = self.select_file_from_picker() {
                        self.status_message = Some(format!("✗ Error: {}", e));
                    }
                    self.update_content_metrics();
                }
                _ => {}
            },
            LinkSearch => match self.mode {
                AppMode::LinkFollow => self.start_link_search(),
                AppMode::FilePicker => {
                    self.file_picker.active = true;
                }
                _ => {}
            },

            // === Interactive Mode ===
            InteractiveNext => {
                let count = self.take_count();
                if self.interactive_state.is_in_table_mode() {
                    // In table mode, move down within table
                    let (rows, cols) = self.get_table_dimensions();
                    for _ in 0..count {
                        self.interactive_state.table_move_down(rows);
                    }
                    self.status_message =
                        Some(self.interactive_state.table_status_text(rows + 1, cols));
                } else {
                    // Normal interactive mode, move to next element
                    for _ in 0..count {
                        self.interactive_state.next();
                    }
                    self.scroll_to_interactive_element(self.content_viewport_height);
                    self.status_message = Some(self.interactive_state.status_text());
                }
            }
            InteractivePrevious => {
                let count = self.take_count();
                if self.interactive_state.is_in_table_mode() {
                    // In table mode, move up within table
                    let (rows, cols) = self.get_table_dimensions();
                    for _ in 0..count {
                        self.interactive_state.table_move_up();
                    }
                    self.status_message =
                        Some(self.interactive_state.table_status_text(rows + 1, cols));
                } else {
                    // Normal interactive mode, move to previous element
                    for _ in 0..count {
                        self.interactive_state.previous();
                    }
                    self.scroll_to_interactive_element(self.content_viewport_height);
                    self.status_message = Some(self.interactive_state.status_text());
                }
            }
            InteractiveActivate => {
                self.clear_count();
                // In table mode, Enter edits the cell; otherwise activate the element
                if self.interactive_state.is_in_table_mode() {
                    if let Err(e) = self.enter_cell_edit_mode() {
                        self.status_message = Some(format!("✗ Error: {}", e));
                    }
                } else if let Err(e) = self.activate_interactive_element() {
                    self.status_message = Some(format!("✗ Error: {}", e));
                }
                self.update_content_metrics();
            }
            InteractiveNextLink => {
                let count = self.take_count();
                for _ in 0..count {
                    self.interactive_state.next();
                }
                self.scroll_to_interactive_element(self.content_viewport_height);
                self.status_message = Some(self.interactive_state.status_text());
            }
            InteractivePreviousLink => {
                let count = self.take_count();
                for _ in 0..count {
                    self.interactive_state.previous();
                }
                self.scroll_to_interactive_element(self.content_viewport_height);
                self.status_message = Some(self.interactive_state.status_text());
            }
            InteractiveLeft => {
                let count = self.take_count();
                for _ in 0..count {
                    self.table_navigate_left();
                }
            }
            InteractiveRight => {
                let count = self.take_count();
                for _ in 0..count {
                    self.table_navigate_right();
                }
            }
            CopyTableCell => {
                if let Err(e) = self.copy_table_cell() {
                    self.set_status_message(&format!("✗ {}", e));
                }
            }
            CopyTableRow => {
                if let Err(e) = self.copy_table_row() {
                    self.set_status_message(&format!("✗ {}", e));
                }
            }
            CopyTableMarkdown => {
                if let Err(e) = self.copy_table_markdown() {
                    self.set_status_message(&format!("✗ {}", e));
                }
            }

            // === View ===
            ToggleRawSource => self.toggle_raw_source(),
            ToggleMouseCapture => self.toggle_mouse_capture(),
            ToggleHelp => self.toggle_help(),
            ToggleThemePicker => self.toggle_theme_picker(),
            ApplyTheme => self.apply_selected_theme(),

            // === Clipboard ===
            CopyContent => self.copy_content(),
            CopyAnchor => self.copy_anchor(),

            // === File Operations ===
            GoBack => {
                // Check if there's anything to go back to
                if self.file_history.is_empty() {
                    return ActionResult::Continue;
                }
                // Check for unsaved changes
                if self.has_unsaved_changes {
                    self.pending_navigation = Some(PendingNavigation::Back);
                    self.mode = AppMode::ConfirmSaveBeforeNav;
                } else if self.go_back().is_ok() {
                    self.update_content_metrics();
                }
            }
            GoForward => {
                // Check if there's anything to go forward to
                if self.file_future.is_empty() {
                    return ActionResult::Continue;
                }
                // Check for unsaved changes
                if self.has_unsaved_changes {
                    self.pending_navigation = Some(PendingNavigation::Forward);
                    self.mode = AppMode::ConfirmSaveBeforeNav;
                } else if self.go_forward().is_ok() {
                    self.update_content_metrics();
                }
            }
            OpenInEditor => {
                let line = if self.mode == AppMode::Interactive {
                    // In interactive mode, jump to the current element's source line
                    self.interactive_element_source_line()
                        .or_else(|| self.selected_heading_source_line())
                } else {
                    self.selected_heading_source_line()
                };
                return ActionResult::RunEditor(self.current_file_path.clone(), line);
            }
            UndoEdit => {
                self.clear_count();
                if let Err(e) = self.undo_last_edit() {
                    self.status_message = Some(format!("✗ Undo failed: {}", e));
                }
            }
            OpenFilePicker => {
                self.enter_file_picker();
            }
            ParentDirectory => {
                self.file_picker_parent_dir();
            }
            ToggleHidden => {
                self.show_hidden = !self.show_hidden;
                self.scan_markdown_files();
            }

            // === Dialog Actions ===
            ConfirmAction => {
                if let Some(result) = self.handle_confirm_action() {
                    return result;
                }
            }
            CancelAction => self.handle_cancel_action(),
            DiscardAndQuit => {
                if let Some(result) = self.handle_discard_and_quit() {
                    return result;
                }
            }
            DiscardAndContinue => {
                self.handle_discard_and_continue();
            }

            // === Jump to Heading by Number ===
            JumpToHeading1 => self.jump_to_heading(0),
            JumpToHeading2 => self.jump_to_heading(1),
            JumpToHeading3 => self.jump_to_heading(2),
            JumpToHeading4 => self.jump_to_heading(3),
            JumpToHeading5 => self.jump_to_heading(4),
            JumpToHeading6 => self.jump_to_heading(5),
            JumpToHeading7 => self.jump_to_heading(6),
            JumpToHeading8 => self.jump_to_heading(7),
            JumpToHeading9 => self.jump_to_heading(8),

            // === Jump to Link by Number ===
            JumpToLink1 => self.jump_to_link(0),
            JumpToLink2 => self.jump_to_link(1),
            JumpToLink3 => self.jump_to_link(2),
            JumpToLink4 => self.jump_to_link(3),
            JumpToLink5 => self.jump_to_link(4),
            JumpToLink6 => self.jump_to_link(5),
            JumpToLink7 => self.jump_to_link(6),
            JumpToLink8 => self.jump_to_link(7),
            JumpToLink9 => self.jump_to_link(8),

            // === Scroll (Content pane) ===
            ScrollDown => {
                let count = self.take_count();
                for _ in 0..count {
                    self.scroll_content_down();
                }
            }
            ScrollUp => {
                let count = self.take_count();
                for _ in 0..count {
                    self.scroll_content_up();
                }
            }

            // === Help Navigation ===
            HelpScrollDown => {
                self.clear_count();
                self.scroll_help_down();
            }
            HelpScrollUp => {
                self.clear_count();
                self.scroll_help_up();
            }

            // === Theme Picker Navigation ===
            ThemePickerNext => self.theme_picker_next(),
            ThemePickerPrevious => self.theme_picker_previous(),

            // === Search Input ===
            SearchBackspace => self.handle_search_backspace(),

            // === Command Palette ===
            CommandPaletteNext => self.command_palette_next(),
            CommandPalettePrev => self.command_palette_prev(),
            CommandPaletteAutocomplete => self.command_palette_autocomplete(),

            // === Doc Search Navigation ===
            NextMatch => self.next_doc_match(),
            PrevMatch => self.prev_doc_match(),
        }

        ActionResult::Continue
    }

    /// Exit the current mode based on app state
    fn exit_current_mode(&mut self) {
        // Close image modal if open
        if self.is_image_modal_open() {
            self.close_image_modal();
            self.status_message = Some("Image modal closed".to_string());
            return;
        }

        // Handle outline search - clear everything
        if self.show_search {
            self.search_query.clear();
            self.filter_outline();
            self.show_search = false;
            self.outline_search_active = false;
            return;
        }

        match self.mode {
            AppMode::Interactive => {
                // If in table mode, exit table mode first (stay in interactive)
                if self.interactive_state.is_in_table_mode() {
                    self.interactive_state.exit_table_mode();
                    self.status_message = Some(self.interactive_state.status_text());
                } else {
                    self.exit_interactive_mode();
                }
            }
            AppMode::LinkFollow => {
                if self.link_picker.active {
                    self.stop_link_search();
                } else if !self.link_picker.query.is_empty() {
                    self.clear_link_search();
                } else {
                    self.exit_link_follow_mode();
                }
            }
            AppMode::Search => {
                self.search_query.clear();
                self.filter_outline();
                self.show_search = false;
            }
            AppMode::DocSearch => {
                if self.doc_search.active {
                    self.cancel_doc_search();
                } else {
                    self.clear_doc_search();
                }
            }
            AppMode::CommandPalette => self.close_command_palette(),
            AppMode::CellEdit => {
                self.mode = AppMode::Interactive;
                self.status_message = Some("Editing cancelled".to_string());
            }
            AppMode::ThemePicker => {
                // Close theme picker (restores original theme)
                self.toggle_theme_picker();
            }
            AppMode::Help => {
                // Close help
                self.show_help = false;
            }
            AppMode::FileSearch => {
                self.file_picker.active = false;
                self.mode = AppMode::FilePicker;
            }
            AppMode::FilePicker => {
                if self.file_picker.active {
                    // Exit search mode, but stay in file picker
                    self.file_picker.active = false;
                    self.file_picker.query.clear();
                } else {
                    // Exit file picker entirely
                    self.mode = AppMode::Normal;
                    self.file_picker.query.clear();
                    self.file_picker.active = false;
                }
            }
            AppMode::Normal
            | AppMode::ConfirmFileCreate
            | AppMode::ConfirmSaveWidth
            | AppMode::ConfirmSaveBeforeQuit
            | AppMode::ConfirmSaveBeforeNav => {
                // In normal mode, show hint for quitting
                self.set_status_message("Press q to quit • : for commands • ? for help");
            }
        }
    }

    /// Handle confirm action based on current mode
    /// Returns Some(ActionResult) if the action should return early (e.g., quit)
    fn handle_confirm_action(&mut self) -> Option<ActionResult> {
        // Handle outline search - accept and keep filtered results visible
        if self.show_search && self.outline_search_active {
            // Check if there are any matches (filtered items with matching text)
            let has_matches = if self.search_query.is_empty() {
                true // Empty query matches everything
            } else {
                // Check if any outline items match the query
                !self.outline_items.is_empty()
            };

            if has_matches {
                // Accept search - deactivate input but keep filter visible
                self.outline_search_active = false;
                // show_search stays true to keep highlights visible
                // User can now navigate with j/k, n/N, or press 's' to start new search
            } else {
                // No matches - show status message and clear search
                self.status_message = Some(format!("Pattern not found: {}", self.search_query));
                self.show_search = false;
                self.outline_search_active = false;
                self.search_query.clear();
                self.filter_outline(); // Restore full outline
            }
            return None;
        }

        match self.mode {
            AppMode::ConfirmFileCreate => {
                if let Err(e) = self.confirm_file_create() {
                    self.status_message = Some(format!("✗ Error: {}", e));
                }
            }
            AppMode::ConfirmSaveWidth => self.confirm_save_outline_width(),
            AppMode::ConfirmSaveBeforeQuit => {
                // Save pending changes and quit
                if let Err(e) = self.save_pending_edits_to_file() {
                    self.status_message = Some(format!("✗ Save failed: {}", e));
                    self.mode = AppMode::Normal;
                } else {
                    return Some(ActionResult::Quit);
                }
            }
            AppMode::ConfirmSaveBeforeNav => {
                // Save pending changes and then navigate
                if let Err(e) = self.save_pending_edits_to_file() {
                    self.status_message = Some(format!("✗ Save failed: {}", e));
                    self.mode = AppMode::Normal;
                    self.pending_navigation = None;
                } else {
                    // Execute the pending navigation
                    self.execute_pending_navigation();
                }
            }
            AppMode::Search => self.show_search = false,
            AppMode::DocSearch => self.accept_doc_search(),
            AppMode::CommandPalette => {
                // Execute command - propagate non-trivial results (Quit,
                // RunEditor, Redraw) to the main loop
                match self.execute_selected_command() {
                    ActionResult::Continue => {}
                    result => return Some(result),
                }
            }
            AppMode::CellEdit => {
                if let Err(e) = self.save_edited_cell() {
                    self.status_message = Some(format!("✗ Error saving: {}", e));
                } else {
                    self.mode = AppMode::Interactive;
                }
            }
            _ => {}
        }
        None
    }

    /// Handle cancel action based on current mode
    fn handle_cancel_action(&mut self) {
        match self.mode {
            AppMode::ConfirmFileCreate => self.cancel_file_create(),
            AppMode::ConfirmSaveWidth => self.cancel_save_width_confirmation(),
            AppMode::ConfirmSaveBeforeQuit => {
                // Cancel quit - go back to normal mode
                self.mode = AppMode::Normal;
                self.status_message = Some("Quit cancelled".to_string());
            }
            AppMode::ConfirmSaveBeforeNav => {
                // Cancel navigation - go back to normal mode
                self.mode = AppMode::Normal;
                self.pending_navigation = None;
                self.status_message = Some("Navigation cancelled".to_string());
            }
            _ => self.exit_current_mode(),
        }
    }

    /// Drop all buffered edits and clear the unsaved-changes flag.
    pub fn discard_pending_edits(&mut self) {
        self.pending_edits.clear();
        self.has_unsaved_changes = false;
    }

    /// Handle discard and quit action (quit without saving)
    fn handle_discard_and_quit(&mut self) -> Option<ActionResult> {
        match self.mode {
            AppMode::ConfirmSaveBeforeQuit => {
                // Discard changes and quit
                self.discard_pending_edits();
                Some(ActionResult::Quit)
            }
            AppMode::ConfirmSaveBeforeNav => {
                // Discard changes and quit (instead of navigating)
                self.discard_pending_edits();
                self.pending_navigation = None;
                Some(ActionResult::Quit)
            }
            _ => None,
        }
    }

    /// Handle discard and continue action (discard changes and proceed with navigation)
    fn handle_discard_and_continue(&mut self) {
        match self.mode {
            AppMode::ConfirmSaveBeforeNav => {
                // Discard changes and navigate
                self.discard_pending_edits();
                self.execute_pending_navigation();
            }
            AppMode::ConfirmSaveBeforeQuit => {
                // In quit dialog, 'd' doesn't make sense - ignore or treat as quit
                // We'll ignore for now, user should use 'q' for quit without saving
            }
            _ => {}
        }
    }

    /// Execute the pending navigation action
    // Suggested guard collapse would call go_back/go_forward inside match guards
    // (side effects in guards) — keep the explicit ifs.
    #[allow(clippy::collapsible_match)]
    fn execute_pending_navigation(&mut self) {
        let nav = self.pending_navigation.take();
        self.mode = AppMode::Normal;

        match nav {
            Some(PendingNavigation::Back) => {
                if self.go_back().is_ok() {
                    self.update_content_metrics();
                }
            }
            Some(PendingNavigation::Forward) => {
                if self.go_forward().is_ok() {
                    self.update_content_metrics();
                }
            }
            Some(PendingNavigation::LoadFile(path, anchor)) => {
                // Call internal load_file_internal that skips the unsaved check
                if let Err(e) = self.load_file_internal(&path, anchor.as_deref()) {
                    self.status_message = Some(format!("✗ {}", e));
                }
            }
            None => {}
        }
    }

    /// Handle backspace in search contexts
    fn handle_search_backspace(&mut self) {
        // Handle outline search - only if active
        if self.show_search && self.outline_search_active {
            self.search_backspace();
            return;
        }

        match self.mode {
            AppMode::Search => self.search_backspace(),
            AppMode::DocSearch => self.doc_search_backspace(),
            AppMode::LinkFollow if self.link_picker.active => self.link_search_pop(),
            AppMode::FilePicker if self.file_picker.active => {
                if self.file_picker.query.is_empty() {
                    // Empty search query + Backspace => navigate to parent directory
                    self.file_picker_parent_dir();
                } else {
                    self.file_search_pop();
                }
            }
            AppMode::CommandPalette => self.command_palette_backspace(),
            AppMode::CellEdit => {
                self.cell_edit_value.pop();
            }
            _ => {}
        }
    }

    /// Navigate table left
    fn table_navigate_left(&mut self) {
        if !self.interactive_state.is_in_table_mode() {
            return;
        }

        let (rows, cols) = self.get_table_dimensions();
        if cols > 0 {
            self.interactive_state.table_move_left();
            self.status_message = Some(self.interactive_state.table_status_text(rows + 1, cols));
        }
    }

    /// Navigate table right
    fn table_navigate_right(&mut self) {
        if !self.interactive_state.is_in_table_mode() {
            return;
        }

        let (rows, cols) = self.get_table_dimensions();
        if cols > 0 {
            self.interactive_state.table_move_right(cols);
            self.status_message = Some(self.interactive_state.table_status_text(rows + 1, cols));
        }
    }

    /// Get table dimensions for current interactive element
    fn get_table_dimensions(&self) -> (usize, usize) {
        if let Some(element) = self.interactive_state.current_element()
            && let crate::tui::interactive::ElementType::Table { rows, cols, .. } =
                &element.element_type
        {
            return (*rows, *cols);
        }
        (0, 0)
    }

    /// Maximum scroll offset: stops when last line is at bottom of viewport
    pub fn max_content_scroll(&self) -> u16 {
        let viewport = self.content_viewport_height as usize;
        self.content_height
            .saturating_sub(viewport)
            .min(u16::MAX as usize) as u16
    }

    /// Scroll content down by one line
    fn scroll_content_down(&mut self) {
        let max_scroll = self.max_content_scroll();
        let new_scroll = self.content_scroll.saturating_add(1);
        if new_scroll <= max_scroll {
            self.content_scroll = new_scroll;
            self.content_scroll_state = self.content_scroll_state.position(new_scroll as usize);
        }
    }

    /// Scroll content up by one line
    fn scroll_content_up(&mut self) {
        let new_scroll = self.content_scroll.saturating_sub(1);
        self.content_scroll = new_scroll;
        self.content_scroll_state = self.content_scroll_state.position(new_scroll as usize);
    }

    /// Jump to link by index in filtered list
    fn jump_to_link(&mut self, idx: usize) {
        if let Some(display_idx) = self
            .link_picker
            .filtered_indices
            .iter()
            .position(|&i| i == idx)
        {
            self.link_picker.selected = Some(display_idx);
        }
    }

    /// Toggle between raw source view and rendered markdown view
    pub fn toggle_raw_source(&mut self) {
        self.show_raw_source = !self.show_raw_source;
        // Raw vs rendered changes the line count of the displayed content.
        self.metrics_dirty = true;
        let msg = if self.show_raw_source {
            "Raw source view enabled"
        } else {
            "Rendered view enabled"
        };
        self.set_status_message(msg);
    }

    /// Set an informational status message that auto-expires after a few
    /// seconds. Messages assigned directly to `status_message` (without a
    /// timestamp) are treated as sticky and dismissed on the next keypress.
    pub fn set_status_message(&mut self, msg: &str) {
        self.status_message = Some(msg.to_string());
        self.status_message_time = Some(Instant::now());
    }

    /// Clear status message if it has expired
    pub fn clear_expired_status_message(&mut self) {
        const STATUS_MESSAGE_TIMEOUT: Duration = Duration::from_secs(3);

        if let Some(time) = self.status_message_time
            && time.elapsed() >= STATUS_MESSAGE_TIMEOUT
        {
            self.status_message = None;
            self.status_message_time = None;
        }
    }

    /// Dismiss a sticky status message (one set without a timestamp, e.g.
    /// errors). Called on the next keypress so errors stay visible until the
    /// user acts, while timed info messages expire on their own.
    pub fn dismiss_sticky_status_message(&mut self) {
        if self.status_message.is_some() && self.status_message_time.is_none() {
            self.status_message = None;
        }
    }

    /// Delete the trailing whitespace-delimited word from a string (Ctrl+W).
    fn delete_last_word(s: &mut String) {
        while s.chars().last().is_some_and(char::is_whitespace) {
            s.pop();
        }
        while s.chars().last().is_some_and(|c| !c.is_whitespace()) {
            s.pop();
        }
    }

    /// Route a text edit to whichever input field is currently active
    /// (outline search, doc search, link search, file search, command
    /// palette, cell edit). Returns true if an active field consumed it.
    pub fn apply_text_input_edit(&mut self, edit: TextInputEdit) -> bool {
        use TextInputEdit::*;

        // Outline search
        if self.show_search && self.outline_search_active {
            match edit {
                Insert(c) => self.search_input(c),
                Clear => {
                    self.search_query.clear();
                    self.filter_outline();
                }
                DeleteWord => {
                    Self::delete_last_word(&mut self.search_query);
                    self.filter_outline();
                }
            }
            return true;
        }

        // Doc search
        if self.mode == AppMode::DocSearch && self.doc_search.active {
            match edit {
                Insert(c) => self.doc_search_input(c),
                Clear => {
                    self.doc_search.query.clear();
                    self.update_doc_search_matches();
                }
                DeleteWord => {
                    Self::delete_last_word(&mut self.doc_search.query);
                    self.update_doc_search_matches();
                }
            }
            return true;
        }

        // Link search
        if self.mode == AppMode::LinkFollow && self.link_picker.active {
            match edit {
                Insert(c) => self.link_search_push(c),
                Clear => {
                    self.link_picker.query.clear();
                    self.update_link_filter();
                }
                DeleteWord => {
                    Self::delete_last_word(&mut self.link_picker.query);
                    self.update_link_filter();
                }
            }
            return true;
        }

        // File search
        if (self.mode == AppMode::FilePicker && self.file_picker.active)
            || self.mode == AppMode::FileSearch
        {
            match edit {
                Insert(c) => self.file_search_push(c),
                Clear => {
                    self.file_picker.query.clear();
                    self.update_file_filter();
                }
                DeleteWord => {
                    Self::delete_last_word(&mut self.file_picker.query);
                    self.update_file_filter();
                }
            }
            return true;
        }

        // Command palette
        if self.mode == AppMode::CommandPalette {
            match edit {
                Insert(c) => self.command_palette_input(c),
                Clear => {
                    self.command_palette.query.clear();
                    self.filter_commands();
                }
                DeleteWord => {
                    Self::delete_last_word(&mut self.command_palette.query);
                    self.filter_commands();
                }
            }
            return true;
        }

        // Cell edit
        if self.mode == AppMode::CellEdit {
            match edit {
                Insert(c) => self.cell_edit_value.push(c),
                Clear => self.cell_edit_value.clear(),
                DeleteWord => Self::delete_last_word(&mut self.cell_edit_value),
            }
            return true;
        }

        false
    }

    /// Accumulate a digit into the vim-style count prefix
    /// Returns true if the digit was handled as a count prefix
    pub fn accumulate_count_digit(&mut self, digit: char) -> bool {
        if let Some(d) = digit.to_digit(10) {
            let current = self.count_prefix.unwrap_or(0);
            // Limit to reasonable count (max 9999)
            let new_count = current
                .saturating_mul(10)
                .saturating_add(d as usize)
                .min(9999);
            self.count_prefix = Some(new_count);
            true
        } else {
            false
        }
    }

    /// Get and consume the count prefix, returning at least 1
    pub fn take_count(&mut self) -> usize {
        self.count_prefix.take().unwrap_or(1)
    }

    /// Clear the count prefix without consuming it
    pub fn clear_count(&mut self) {
        self.count_prefix = None;
    }

    /// Check if there's an active count prefix
    pub fn has_count(&self) -> bool {
        self.count_prefix.is_some()
    }

    /// Check if the document has non-whitespace content before the first heading
    fn has_preamble_content(document: &Document) -> bool {
        if document.headings.is_empty() {
            // No headings at all - entire document is preamble
            return !document.content.trim().is_empty();
        }

        // Check if there's content before the first heading
        let first_heading_offset = document.headings[0].offset;
        if first_heading_offset == 0 {
            return false;
        }

        // Check if there's non-whitespace content before the first heading
        let preamble = &document.content[..first_heading_offset];
        !preamble.trim().is_empty()
    }

    /// Check if a heading's section contains open todos (- [ ])
    fn heading_has_open_todos(&self, heading_index: usize) -> bool {
        if let Some(content) = self.document.extract_section_at_index(heading_index) {
            // Check for unchecked todo pattern: - [ ] or * [ ]
            content.contains("- [ ]") || content.contains("* [ ]")
        } else {
            false
        }
    }

    /// Check if a heading or any of its descendants have open todos
    fn heading_tree_has_open_todos(&self, node: &HeadingNode) -> bool {
        // Check this heading's direct content
        if self.heading_has_open_todos(node.index) {
            return true;
        }
        // Recursively check children
        for child in &node.children {
            if self.heading_tree_has_open_todos(child) {
                return true;
            }
        }
        false
    }

    /// Build a set of heading texts that have open todos (directly or in descendants)
    fn headings_with_open_todos(&self) -> HashSet<String> {
        let mut result = HashSet::new();
        for node in &self.tree {
            self.collect_headings_with_todos(node, &mut result);
        }
        result
    }

    /// Recursively collect headings that should be shown when filtering by todos
    fn collect_headings_with_todos(&self, node: &HeadingNode, result: &mut HashSet<String>) {
        if self.heading_tree_has_open_todos(node) {
            // This heading or a descendant has todos, include it
            result.insert(node.heading.text.clone());
            // Also recursively add children that have todos
            for child in &node.children {
                self.collect_headings_with_todos(child, result);
            }
        }
    }

    /// Rebuild outline items from the tree, optionally adding document overview
    fn rebuild_outline_items(&mut self) {
        let mut items = Self::flatten_tree(&self.tree, &self.collapsed_headings);

        // Apply todo filter if enabled
        if self.filter_by_todos {
            let headings_with_todos = self.headings_with_open_todos();
            items.retain(|item| headings_with_todos.contains(&item.text));
        }

        self.outline_items = items;

        // Add document overview entry if there's preamble content or no headings
        // When filtering by todos, only show overview if it has todos
        let has_preamble = Self::has_preamble_content(&self.document);
        let preamble_has_todos = self.filter_by_todos
            && self
                .document
                .content
                .split_once("\n#")
                .is_some_and(|(preamble, _)| {
                    preamble.contains("- [ ]") || preamble.contains("* [ ]")
                });

        let show_preamble = (!self.filter_by_todos
            && (has_preamble || self.document.headings.is_empty()))
            || (self.filter_by_todos && preamble_has_todos);

        if show_preamble {
            self.outline_items.insert(
                0,
                OutlineItem {
                    level: 0,
                    text: DOCUMENT_OVERVIEW.to_string(),
                    expanded: true,
                    has_children: !self.outline_items.is_empty(),
                    heading_index: None,
                },
            );
        }
    }

    fn flatten_tree(
        tree: &[HeadingNode],
        collapsed_headings: &HashSet<String>,
    ) -> Vec<OutlineItem> {
        let mut items = Vec::new();

        fn flatten_recursive(
            node: &HeadingNode,
            items: &mut Vec<OutlineItem>,
            collapsed_headings: &HashSet<String>,
        ) {
            let is_collapsed = collapsed_headings.contains(&node.heading.text);
            let expanded = !is_collapsed;
            let has_children = !node.children.is_empty();

            items.push(OutlineItem {
                level: node.heading.level,
                text: node.heading.text.clone(),
                expanded,
                has_children,
                heading_index: Some(node.index),
            });

            // Only show children if this node is expanded
            if expanded {
                for child in &node.children {
                    flatten_recursive(child, items, collapsed_headings);
                }
            }
        }

        for node in tree {
            flatten_recursive(node, &mut items, collapsed_headings);
        }

        items
    }

    /// Select an outline item by index, updating both selection and scroll state.
    fn select_outline_index(&mut self, idx: usize) {
        self.outline_state.select(Some(idx));
        self.outline_scroll_state = self.outline_scroll_state.position(idx);
    }

    /// Select a heading by its text. Returns true if found and selected.
    fn select_by_text(&mut self, text: &str) -> bool {
        for (idx, item) in self.outline_items.iter().enumerate() {
            if item.text == text {
                self.select_outline_index(idx);
                return true;
            }
        }
        false
    }

    fn select_by_heading_index(&mut self, heading_index: usize) -> bool {
        for (idx, item) in self.outline_items.iter().enumerate() {
            if item.heading_index == Some(heading_index) {
                self.select_outline_index(idx);
                return true;
            }
        }
        false
    }

    /// Update content height based on current selection and reset scroll if selection changed.
    ///
    /// The expensive line-counting only runs when the selection or the
    /// underlying content has changed (signalled by `metrics_dirty`). The
    /// render loop calls this every frame; without gating, a large section
    /// would be re-scanned 60+ times per second during animations.
    pub fn update_content_metrics(&mut self) {
        let current_selection = self.outline_state.selected().map(|i| {
            self.outline_items
                .get(i)
                .and_then(|item| item.heading_index)
        });
        let selection_changed = current_selection != self.previous_selection;

        if selection_changed {
            // Reset content scroll when selection changes
            self.content_scroll = 0;
            self.previous_selection = current_selection;

            // Reindex interactive elements for the new section
            let content_text = self.current_section_content();

            use crate::parser::content::parse_content;
            let blocks = parse_content(&content_text, 0);
            self.index_interactive_elements(&blocks);
            self.populate_image_cache();
        }

        if selection_changed || self.metrics_dirty {
            let content_text = self.current_section_content();
            let content_lines = content_text.lines().count();
            self.content_height = content_lines;
            self.content_scroll_state =
                ScrollbarState::new(content_lines).position(self.content_scroll as usize);
            self.metrics_dirty = false;
        } else {
            // Cheap path: just keep the scrollbar position in sync with scroll.
            self.content_scroll_state = self
                .content_scroll_state
                .position(self.content_scroll as usize);
        }
    }

    /// Mark content metrics as needing recomputation. Call this whenever the
    /// rendered section's source could have changed (file reload, raw-source
    /// toggle, document edit).
    pub fn mark_metrics_dirty(&mut self) {
        self.metrics_dirty = true;
    }

    pub fn next(&mut self) {
        if self.focus == Focus::Outline {
            let i = match self.outline_state.selected() {
                Some(i) => {
                    if i >= self.outline_items.len().saturating_sub(1) {
                        i
                    } else {
                        i + 1
                    }
                }
                None => 0,
            };
            self.select_outline_index(i);
        } else {
            // Scroll content - stop when last line is at viewport bottom
            self.scroll_content_down();
        }
    }

    pub fn previous(&mut self) {
        if self.focus == Focus::Outline {
            let i = match self.outline_state.selected() {
                Some(i) => i.saturating_sub(1),
                None => 0,
            };
            self.select_outline_index(i);
        } else {
            // Scroll content
            self.scroll_content_up();
        }
    }

    pub fn first(&mut self) {
        if self.mode == AppMode::FilePicker {
            let total = self.file_picker_item_count();
            if total > 0 {
                self.file_picker.selected = Some(0);
            }
        } else if self.focus == Focus::Outline && !self.outline_items.is_empty() {
            self.select_outline_index(0);
        } else {
            self.content_scroll = 0;
            self.content_scroll_state = self.content_scroll_state.position(0);
        }
    }

    pub fn last(&mut self) {
        if self.mode == AppMode::FilePicker {
            let total = self.file_picker_item_count();
            if total > 0 {
                self.file_picker.selected = Some(total - 1);
            }
        } else if self.focus == Focus::Outline && !self.outline_items.is_empty() {
            let last = self.outline_items.len() - 1;
            self.select_outline_index(last);
        } else {
            // Scroll to show the last line at the bottom of the viewport
            let max_scroll = self.max_content_scroll();
            self.content_scroll = max_scroll;
            self.content_scroll_state = self.content_scroll_state.position(max_scroll as usize);
        }
    }

    pub fn jump_to_parent(&mut self) {
        // Works in both Outline and Content focus
        if let Some(current_idx) = self.outline_state.selected()
            && current_idx < self.outline_items.len()
        {
            let current_level = self.outline_items[current_idx].level;

            // Search backwards for a heading with lower level (parent)
            for i in (0..current_idx).rev() {
                if self.outline_items[i].level < current_level {
                    self.select_outline_index(i);
                    return;
                }
            }

            // If no parent found, stay at current position
            // (we're already at a top-level heading or first item)
        }
    }

    pub fn toggle_help(&mut self) {
        self.show_help = !self.show_help;
        if self.show_help {
            self.help_scroll = 0; // Reset scroll when opening help
        }
    }

    pub fn scroll_help_down(&mut self) {
        let new_scroll = self.help_scroll.saturating_add(1);
        let max_scroll = help_text::HELP_LINES.len() as u16;
        if new_scroll < max_scroll {
            self.help_scroll = new_scroll;
        }
    }

    pub fn scroll_help_up(&mut self) {
        self.help_scroll = self.help_scroll.saturating_sub(1);
    }

    /// Scroll help popup down by a page
    pub fn scroll_help_page_down(&mut self) {
        let page_size = self.content_viewport_height.saturating_sub(2).max(1);
        let new_scroll = self.help_scroll.saturating_add(page_size);
        let max_scroll = help_text::HELP_LINES.len() as u16;
        if new_scroll < max_scroll {
            self.help_scroll = new_scroll;
        }
    }

    /// Scroll help popup up by a page
    pub fn scroll_help_page_up(&mut self) {
        let page_size = self.content_viewport_height.saturating_sub(2).max(1);
        self.help_scroll = self.help_scroll.saturating_sub(page_size);
    }

    pub fn toggle_search(&mut self) {
        if self.show_search && self.outline_search_active {
            // If actively typing in search, toggle it off (clear and hide)
            self.show_search = false;
            self.outline_search_active = false;
            self.search_query.clear();
            self.filter_outline();
        } else if self.show_search {
            // In accepted search state (showing filtered results) - start fresh search
            self.search_query.clear();
            self.filter_outline(); // Restore full outline for new search
            self.outline_search_active = true; // Re-enter input mode
        } else {
            // Enter search mode from normal state
            self.show_search = true;
            self.outline_search_active = true;
            self.search_query.clear();
        }
    }

    /// Toggle between outline search and document search, preserving the query.
    /// After search is accepted (Enter pressed), Tab cycles through matches instead.
    pub fn toggle_search_mode(&mut self) {
        if self.show_search {
            // Currently in outline search -> switch to doc search
            let query = self.search_query.clone();
            let was_active = self.outline_search_active;
            self.show_search = false;
            self.outline_search_active = false;
            self.search_query.clear();
            self.filter_outline(); // Reset outline filter

            // Enter doc search with the same query
            self.mode = AppMode::DocSearch;
            self.doc_search.active = was_active; // Preserve active state
            self.doc_search.query = query;
            self.doc_search.matches.clear();
            self.doc_search.current_idx = None;
            self.update_doc_search_matches();
        } else if self.mode == AppMode::DocSearch {
            if self.doc_search.active {
                // Still typing -> switch to outline search
                let query = self.doc_search.query.clone();
                self.mode = AppMode::Normal;
                self.doc_search.active = false;
                self.doc_search.query.clear();
                self.doc_search.matches.clear();
                self.doc_search.current_idx = None;

                // Enter outline search with the same query
                self.show_search = true;
                self.outline_search_active = true; // Keep input active
                self.search_query = query;
                self.filter_outline();
            } else {
                // Search accepted (after Enter) -> cycle through matches
                self.next_doc_match();
            }
        }
    }

    /// Maximum search query length to prevent performance issues
    const MAX_SEARCH_LEN: usize = 256;

    pub fn search_input(&mut self, c: char) {
        // Limit search query length
        if self.search_query.len() >= Self::MAX_SEARCH_LEN {
            return;
        }

        // Filter control characters (except common ones)
        if c.is_control() && c != '\t' {
            return;
        }

        self.search_query.push(c);
        self.filter_outline();
    }

    pub fn search_backspace(&mut self) {
        self.search_query.pop();
        self.filter_outline();
    }

    pub fn filter_outline(&mut self) {
        // Save current selection
        let current_idx = self.selected_heading_index();
        let current_selection = self.selected_heading_text().map(|s| s.to_string());

        if self.search_query.is_empty() {
            // Reset to full tree with overview entry
            self.rebuild_outline_items();
        } else {
            // Filter by search query, but always include overview entry if applicable
            let query_lower = self.search_query.to_lowercase();
            let has_preamble = Self::has_preamble_content(&self.document);

            self.outline_items = Self::flatten_tree(&self.tree, &self.collapsed_headings)
                .into_iter()
                .filter(|item| item.text.to_lowercase().contains(&query_lower))
                .collect();

            // Add overview entry if it matches the search or if document has preamble
            if (has_preamble || self.document.headings.is_empty())
                && DOCUMENT_OVERVIEW.to_lowercase().contains(&query_lower)
            {
                self.outline_items.insert(
                    0,
                    OutlineItem {
                        level: 0,
                        text: DOCUMENT_OVERVIEW.to_string(),
                        expanded: true,
                        has_children: !self.tree.is_empty(),
                        heading_index: None,
                    },
                );
            }
        }

        // Try to restore previous selection, otherwise select first item
        if !self.outline_items.is_empty() {
            let restored = current_idx.is_some_and(|i| self.select_by_heading_index(i))
                || current_selection.is_some_and(|t| self.select_by_text(&t));

            if !restored {
                self.outline_state.select(Some(0));
                self.outline_scroll_state =
                    ScrollbarState::new(self.outline_items.len()).position(0);
            }
        }
    }

    // ========== Document Search Methods ==========

    /// Enter document search mode (activated by / when content is focused or in interactive mode)
    /// If in accepted outline search state, re-enter outline search input instead
    /// If already in accepted doc search state, re-enter doc search input
    pub fn enter_doc_search(&mut self) {
        // If in accepted outline search state (locked-in filter), re-enter outline search input
        if self.show_search && !self.outline_search_active {
            // Re-activate outline search input (keep existing query for editing)
            self.outline_search_active = true;
            return;
        }

        // If already in accepted doc search state, re-enter input mode (keep existing query)
        if self.mode == AppMode::DocSearch && !self.doc_search.active {
            self.doc_search.active = true;
            return;
        }

        // Remember if we came from interactive mode to restore it later
        self.doc_search.from_interactive = self.mode == AppMode::Interactive;
        self.mode = AppMode::DocSearch;
        self.doc_search.active = true;
        self.doc_search.query.clear();
        self.doc_search.matches.clear();
        self.doc_search.current_idx = None;
    }

    /// Add a character to the document search query
    pub fn doc_search_input(&mut self, c: char) {
        // Limit search query length
        if self.doc_search.query.len() >= Self::MAX_SEARCH_LEN {
            return;
        }
        // Filter control characters
        if c.is_control() && c != '\t' {
            return;
        }
        self.doc_search.query.push(c);
        self.update_doc_search_matches();
    }

    /// Remove the last character from the document search query
    pub fn doc_search_backspace(&mut self) {
        self.doc_search.query.pop();
        self.update_doc_search_matches();
    }

    /// Update search matches based on current query (supports fuzzy and exact matching)
    pub fn update_doc_search_matches(&mut self) {
        self.doc_search.matches.clear();

        if self.doc_search.query.is_empty() {
            self.doc_search.current_idx = None;
            return;
        }

        // Get current section content
        let content = self.current_section_content();

        // Convert to plain text using parser (strips links, formatting, etc.)
        // This ensures search matches what's visible when rendered
        let plain_content = turbovault_parser::to_plain_text(&content);
        let query = self.doc_search.query.to_lowercase();

        // Find all exact substring matches (case-insensitive)
        for (line_num, line) in plain_content.lines().enumerate() {
            let line_lower = line.to_lowercase();

            let mut search_start = 0;
            while let Some(pos) = line_lower[search_start..].find(&query) {
                let col_start = search_start + pos;
                self.doc_search.matches.push(SearchMatch {
                    line: line_num,
                    col_start,
                    len: query.len(),
                });
                search_start = col_start + query.len();
            }
        }

        // Select first match if any exist
        self.doc_search.current_idx = if self.doc_search.matches.is_empty() {
            None
        } else {
            Some(0)
        };

        // Scroll to current match
        self.scroll_to_doc_search_match();
    }

    /// Scroll to the current search match and detect if it's inside a link
    fn scroll_to_doc_search_match(&mut self) {
        // Reset link selection
        self.doc_search.selected_link_idx = None;

        if let Some(idx) = self.doc_search.current_idx
            && let Some(m) = self.doc_search.matches.get(idx)
        {
            let match_line = m.line as u16;

            // Scroll to bring match line into view (center it if possible)
            let half_viewport = self.content_viewport_height / 2;
            self.content_scroll = match_line.saturating_sub(half_viewport);
            self.content_scroll = self.content_scroll.min(self.max_content_scroll());
            self.content_scroll_state = self
                .content_scroll_state
                .position(self.content_scroll as usize);

            // Check if this match is inside a link
            self.detect_link_at_search_match(m.line, m.col_start, m.len);
        }
    }

    /// Detect if a search match position overlaps with a link and select it
    fn detect_link_at_search_match(
        &mut self,
        match_line: usize,
        match_col: usize,
        match_len: usize,
    ) {
        use crate::parser::links::extract_links;

        // Get current section content
        let content = self.current_section_content();

        // Convert line/col to byte offset
        let mut byte_offset = 0;
        for (line_num, line) in content.lines().enumerate() {
            if line_num == match_line {
                byte_offset += match_col;
                break;
            }
            byte_offset += line.len() + 1; // +1 for newline
        }

        let match_end = byte_offset + match_len;

        // Extract links and populate links_in_view for potential following
        self.links_in_view = extract_links(&content);
        self.link_picker.filtered_indices = (0..self.links_in_view.len()).collect();

        // Find if match overlaps with any link
        for (idx, link) in self.links_in_view.iter().enumerate() {
            let link_start = link.offset;
            // Estimate link end based on its display text length + some syntax overhead
            // For markdown: [text](url) - we care about the text portion
            // For wikilinks: [[target|text]] - we care about the display text
            let link_end = link_start + link.text.len() + 20; // generous estimate for syntax

            // Check if match overlaps with link region
            if byte_offset < link_end && match_end > link_start {
                self.doc_search.selected_link_idx = Some(idx);
                self.link_picker.selected = Some(idx); // Also set link mode selection
                break;
            }
        }
    }

    /// Accept search and exit search input mode (keep matches for n/N navigation)
    pub fn accept_doc_search(&mut self) {
        self.doc_search.active = false;
        // Keep mode as DocSearch for n/N navigation
        // If no matches, show status message
        if self.doc_search.matches.is_empty() && !self.doc_search.query.is_empty() {
            self.status_message = Some(format!("Pattern not found: {}", self.doc_search.query));
        }
    }

    /// Cancel search and return to previous mode (interactive or normal)
    pub fn cancel_doc_search(&mut self) {
        // Restore interactive mode if that's where we came from
        if self.doc_search.from_interactive {
            self.mode = AppMode::Interactive;
        } else {
            self.mode = AppMode::Normal;
        }
        self.doc_search.active = false;
        self.doc_search.from_interactive = false;
        self.doc_search.query.clear();
        self.doc_search.matches.clear();
        self.doc_search.current_idx = None;
        self.doc_search.selected_link_idx = None;
        // Sync to prevent update_content_metrics() from resetting scroll
        self.sync_previous_selection();
    }

    /// Clear search highlighting and return to previous mode (interactive or normal)
    pub fn clear_doc_search(&mut self) {
        // Restore interactive mode if that's where we came from
        if self.doc_search.from_interactive {
            self.mode = AppMode::Interactive;
        } else {
            self.mode = AppMode::Normal;
        }
        self.doc_search.from_interactive = false;
        self.doc_search.query.clear();
        self.doc_search.matches.clear();
        self.doc_search.current_idx = None;
        self.doc_search.selected_link_idx = None;
        // Sync to prevent update_content_metrics() from resetting scroll
        self.sync_previous_selection();
    }

    /// Navigate to next search match
    /// Handles both doc search matches and accepted outline search navigation
    pub fn next_doc_match(&mut self) {
        // Check if in accepted outline search state (filtered outline visible)
        if self.show_search && !self.outline_search_active && !self.outline_items.is_empty() {
            // Cycle through filtered outline items
            let current = self.outline_state.selected().unwrap_or(0);
            let next = (current + 1) % self.outline_items.len();
            self.select_outline_index(next);
            return;
        }

        if self.doc_search.matches.is_empty() {
            return;
        }

        self.doc_search.current_idx = Some(match self.doc_search.current_idx {
            Some(idx) => (idx + 1) % self.doc_search.matches.len(),
            None => 0,
        });

        self.scroll_to_doc_search_match();
    }

    /// Navigate to previous search match
    /// Handles both doc search matches and accepted outline search navigation
    pub fn prev_doc_match(&mut self) {
        // Check if in accepted outline search state (filtered outline visible)
        if self.show_search && !self.outline_search_active && !self.outline_items.is_empty() {
            // Cycle through filtered outline items
            let current = self.outline_state.selected().unwrap_or(0);
            let len = self.outline_items.len();
            let prev = (current + len - 1) % len;
            self.select_outline_index(prev);
            return;
        }

        if self.doc_search.matches.is_empty() {
            return;
        }

        let len = self.doc_search.matches.len();
        self.doc_search.current_idx = Some(match self.doc_search.current_idx {
            Some(idx) => (idx + len - 1) % len,
            None => len - 1,
        });

        self.scroll_to_doc_search_match();
    }

    /// Get document search status text for status bar
    pub fn doc_search_status(&self) -> String {
        if self.doc_search.matches.is_empty() {
            if self.doc_search.query.is_empty() {
                "Search: ".to_string()
            } else {
                format!("Search: {} (no matches)", self.doc_search.query)
            }
        } else {
            let current = self.doc_search.current_idx.unwrap_or(0) + 1;
            let total = self.doc_search.matches.len();
            let base = format!("Search: {} ({}/{})", self.doc_search.query, current, total);

            // Add link indicator if match is inside a link
            if let Some(link_idx) = self.doc_search.selected_link_idx {
                if let Some(link) = self.links_in_view.get(link_idx) {
                    format!("{} → [{}] (Enter to follow)", base, link.text)
                } else {
                    base
                }
            } else {
                base
            }
        }
    }

    pub fn scroll_page_down(&mut self) {
        if self.focus == Focus::Content {
            let page = self.content_viewport_height.saturating_sub(2).max(1);
            let new_scroll = self.content_scroll.saturating_add(page);
            self.content_scroll = new_scroll.min(self.max_content_scroll());
            self.content_scroll_state = self
                .content_scroll_state
                .position(self.content_scroll as usize);
        }
    }

    pub fn scroll_page_up(&mut self) {
        if self.focus == Focus::Content {
            let page = self.content_viewport_height.saturating_sub(2).max(1);
            self.content_scroll = self.content_scroll.saturating_sub(page);
            self.content_scroll_state = self
                .content_scroll_state
                .position(self.content_scroll as usize);
        }
    }

    /// Scroll page down in interactive mode (bypasses focus check)
    pub fn scroll_page_down_interactive(&mut self) {
        let page = self.content_viewport_height.saturating_sub(2).max(1);
        let new_scroll = self.content_scroll.saturating_add(page);
        self.content_scroll = new_scroll.min(self.max_content_scroll());
        self.content_scroll_state = self
            .content_scroll_state
            .position(self.content_scroll as usize);
    }

    /// Scroll page up in interactive mode (bypasses focus check)
    pub fn scroll_page_up_interactive(&mut self) {
        let page = self.content_viewport_height.saturating_sub(2).max(1);
        self.content_scroll = self.content_scroll.saturating_sub(page);
        self.content_scroll_state = self
            .content_scroll_state
            .position(self.content_scroll as usize);
    }

    /// Auto-scroll to keep the selected interactive element in view
    /// viewport_height: height of the visible content area (in lines)
    pub fn scroll_to_interactive_element(&mut self, viewport_height: u16) {
        if let Some((start_line, end_line)) = self.interactive_state.current_element_line_range() {
            let start = start_line as u16;
            let end = end_line as u16;
            let scroll = self.content_scroll;
            let viewport_end = scroll.saturating_add(viewport_height);

            // Add margin for smoother scrolling - trigger before element goes completely off-screen
            let scroll_margin = 2u16.min(viewport_height / 4);

            // Element is above viewport (or too close to top margin) - scroll up
            if start < scroll.saturating_add(scroll_margin) {
                self.content_scroll = start.saturating_sub(scroll_margin);
            }
            // Element end is below viewport (or within bottom margin) - scroll down
            else if end.saturating_add(scroll_margin) > viewport_end {
                // Position so element's end is near bottom of viewport with margin
                let new_scroll = end
                    .saturating_add(scroll_margin)
                    .saturating_sub(viewport_height);
                self.content_scroll = new_scroll.min(self.max_content_scroll());
            }

            // Update scrollbar state
            self.content_scroll_state = self
                .content_scroll_state
                .position(self.content_scroll as usize);
        }
    }

    pub fn toggle_expand(&mut self) {
        if self.focus == Focus::Outline
            && let Some(i) = self.outline_state.selected()
            && i < self.outline_items.len()
            && self.outline_items[i].has_children
        {
            let heading_text = self.outline_items[i].text.clone();
            let heading_idx = self.outline_items[i].heading_index;

            // Toggle the collapsed state
            if self.collapsed_headings.contains(&heading_text) {
                self.collapsed_headings.remove(&heading_text);
            } else {
                self.collapsed_headings.insert(heading_text.clone());
            }

            // Rebuild the flattened list with overview entry
            self.rebuild_outline_items();

            let restored = heading_idx.is_some_and(|i| self.select_by_heading_index(i))
                || self.select_by_text(&heading_text);
            if !restored {
                let safe_idx = i.min(self.outline_items.len().saturating_sub(1));
                self.outline_state.select(Some(safe_idx));
                self.outline_scroll_state =
                    ScrollbarState::new(self.outline_items.len()).position(safe_idx);
            }
        }
    }

    pub fn expand(&mut self) {
        if self.focus == Focus::Outline
            && let Some(i) = self.outline_state.selected()
            && i < self.outline_items.len()
            && self.outline_items[i].has_children
        {
            let heading_text = self.outline_items[i].text.clone();
            let heading_idx = self.outline_items[i].heading_index;

            // Remove from collapsed set to expand
            self.collapsed_headings.remove(&heading_text);

            // Rebuild the flattened list with overview entry
            self.rebuild_outline_items();

            let restored = heading_idx.is_some_and(|i| self.select_by_heading_index(i))
                || self.select_by_text(&heading_text);
            if !restored {
                // If heading not found (shouldn't happen), clamp to valid index
                let safe_idx = i.min(self.outline_items.len().saturating_sub(1));
                self.outline_state.select(Some(safe_idx));
                self.outline_scroll_state =
                    ScrollbarState::new(self.outline_items.len()).position(safe_idx);
            }
        }
    }

    pub fn collapse(&mut self) {
        if self.focus == Focus::Outline
            && let Some(i) = self.outline_state.selected()
            && i < self.outline_items.len()
        {
            let current_level = self.outline_items[i].level;
            let current_text = self.outline_items[i].text.clone();
            let current_heading_idx = self.outline_items[i].heading_index;

            // If current heading has children, collapse it
            if self.outline_items[i].has_children {
                self.collapsed_headings.insert(current_text.clone());

                // Rebuild the flattened list with overview entry
                self.rebuild_outline_items();

                let restored = current_heading_idx.is_some_and(|i| self.select_by_heading_index(i))
                    || self.select_by_text(&current_text);
                if !restored {
                    let safe_idx = i.min(self.outline_items.len().saturating_sub(1));
                    self.outline_state.select(Some(safe_idx));
                    self.outline_scroll_state =
                        ScrollbarState::new(self.outline_items.len()).position(safe_idx);
                }
            } else {
                // If no children, find parent and collapse it
                // Look backwards for first heading with lower level
                let mut parent_text: Option<String> = None;
                let mut parent_heading_idx: Option<usize> = None;
                for idx in (0..i).rev() {
                    if self.outline_items[idx].level < current_level {
                        // Found parent
                        parent_text = Some(self.outline_items[idx].text.clone());
                        parent_heading_idx = self.outline_items[idx].heading_index;
                        break;
                    }
                }

                if let Some(parent) = parent_text {
                    // Collapse the parent
                    self.collapsed_headings.insert(parent.clone());

                    // Rebuild and move selection to parent
                    self.rebuild_outline_items();

                    let restored = parent_heading_idx
                        .is_some_and(|i| self.select_by_heading_index(i))
                        || self.select_by_text(&parent);
                    if !restored && !self.outline_items.is_empty() {
                        self.outline_state.select(Some(0));
                        self.outline_scroll_state =
                            ScrollbarState::new(self.outline_items.len()).position(0);
                    }
                }
                // No parent found, do nothing
            }
        }
    }

    /// Collapse all headings that have children
    pub fn collapse_all(&mut self) {
        // Collect all heading texts that have children
        let headings_to_collapse: Vec<String> = self
            .tree
            .iter()
            .flat_map(Self::collect_collapsible_headings)
            .collect();

        for text in headings_to_collapse {
            self.collapsed_headings.insert(text);
        }

        // Rebuild outline and preserve selection
        let selected_offset = self.selected_heading_index();
        let selected_text = self.selected_heading_text().map(|s| s.to_string());
        self.rebuild_outline_items();

        // Try to restore selection, or select first item
        let restored = selected_offset.is_some_and(|o| self.select_by_heading_index(o))
            || selected_text.is_some_and(|t| self.select_by_text(&t));
        if !restored && !self.outline_items.is_empty() {
            self.outline_state.select(Some(0));
            self.outline_scroll_state = ScrollbarState::new(self.outline_items.len()).position(0);
        }

        let count = self.collapsed_headings.len();
        self.set_status_message(&format!("Collapsed {} headings", count));
    }

    /// Recursively collect all heading texts that have children
    fn collect_collapsible_headings(node: &HeadingNode) -> Vec<String> {
        let mut result = Vec::new();
        if !node.children.is_empty() {
            result.push(node.heading.text.clone());
            for child in &node.children {
                result.extend(Self::collect_collapsible_headings(child));
            }
        }
        result
    }

    /// Expand all headings
    pub fn expand_all(&mut self) {
        let count = self.collapsed_headings.len();
        self.collapsed_headings.clear();

        // Rebuild outline and preserve selection
        let selected_offset = self.selected_heading_index();
        let selected_text = self.selected_heading_text().map(|s| s.to_string());
        self.rebuild_outline_items();

        if !(selected_offset.is_some_and(|o| self.select_by_heading_index(o)))
            && let Some(text) = selected_text
        {
            self.select_by_text(&text);
        }

        self.set_status_message(&format!("Expanded {} headings", count));
    }

    /// Collapse all headings at a specific level (1-6)
    pub fn collapse_level(&mut self, level: usize) {
        // Collect all headings at the target level that have children
        let headings_to_collapse: Vec<String> = self
            .tree
            .iter()
            .flat_map(|node| Self::collect_headings_at_level_with_children(node, level))
            .collect();

        let count = headings_to_collapse.len();
        for text in headings_to_collapse {
            self.collapsed_headings.insert(text);
        }

        // Rebuild outline and preserve selection
        let selected_offset = self.selected_heading_index();
        let selected_text = self.selected_heading_text().map(|s| s.to_string());
        self.rebuild_outline_items();

        let restored = selected_offset.is_some_and(|o| self.select_by_heading_index(o))
            || selected_text.is_some_and(|t| self.select_by_text(&t));
        if !restored && !self.outline_items.is_empty() {
            self.outline_state.select(Some(0));
            self.outline_scroll_state = ScrollbarState::new(self.outline_items.len()).position(0);
        }

        self.set_status_message(&format!("Collapsed {} h{} headings", count, level));
    }

    /// Recursively collect headings at a specific level that have children
    fn collect_headings_at_level_with_children(
        node: &HeadingNode,
        target_level: usize,
    ) -> Vec<String> {
        let mut result = Vec::new();

        if node.heading.level == target_level && !node.children.is_empty() {
            result.push(node.heading.text.clone());
        }

        // Always recurse to find nested headings at the target level
        for child in &node.children {
            result.extend(Self::collect_headings_at_level_with_children(
                child,
                target_level,
            ));
        }

        result
    }

    /// Expand all headings at a specific level (1-6)
    pub fn expand_level(&mut self, level: usize) {
        let mut count = 0;

        // Find all collapsed headings at the specified level and expand them
        let headings_at_level: Vec<String> = self
            .tree
            .iter()
            .flat_map(|node| self.collect_headings_at_level(node, level))
            .collect();

        for heading_text in headings_at_level {
            if self.collapsed_headings.remove(&heading_text) {
                count += 1;
            }
        }

        // Rebuild outline and preserve selection
        let selected_offset = self.selected_heading_index();
        let selected_text = self.selected_heading_text().map(|s| s.to_string());
        self.rebuild_outline_items();

        if !(selected_offset.is_some_and(|o| self.select_by_heading_index(o)))
            && let Some(text) = selected_text
        {
            self.select_by_text(&text);
        }

        self.set_status_message(&format!("Expanded {} h{} headings", count, level));
    }

    /// Collect heading texts at a specific level
    fn collect_headings_at_level(&self, node: &HeadingNode, target_level: usize) -> Vec<String> {
        let mut result = Vec::new();

        if node.heading.level == target_level {
            result.push(node.heading.text.clone());
        }

        for child in &node.children {
            result.extend(self.collect_headings_at_level(child, target_level));
        }

        result
    }

    pub fn toggle_focus(&mut self) {
        // If in locked-in outline search state, Tab cycles to next filtered item
        if self.show_search && !self.outline_search_active && !self.outline_items.is_empty() {
            let current = self.outline_state.selected().unwrap_or(0);
            let next = (current + 1) % self.outline_items.len();
            self.select_outline_index(next);
            return;
        }

        if self.show_outline {
            self.focus = match self.focus {
                Focus::Outline => Focus::Content,
                Focus::Content => Focus::Outline,
            };
        }
    }

    /// Toggle focus backwards (Shift+Tab) - cycles to previous item when search is locked in
    pub fn toggle_focus_back(&mut self) {
        // If in locked-in outline search state, Shift+Tab cycles to previous filtered item
        if self.show_search && !self.outline_search_active && !self.outline_items.is_empty() {
            let current = self.outline_state.selected().unwrap_or(0);
            let len = self.outline_items.len();
            let prev = (current + len - 1) % len;
            self.select_outline_index(prev);
            return;
        }

        // Same as toggle_focus when not in locked search state
        if self.show_outline {
            self.focus = match self.focus {
                Focus::Outline => Focus::Content,
                Focus::Content => Focus::Outline,
            };
        }
    }

    pub fn toggle_outline(&mut self) {
        self.show_outline = !self.show_outline;
        if !self.show_outline {
            // When hiding outline, switch focus to content
            self.focus = Focus::Content;
        } else {
            // When showing outline, switch focus back to outline
            self.focus = Focus::Outline;
        }
    }

    /// Toggle heading level markers (#, ##, ###) in the outline sidebar
    pub fn toggle_heading_markers(&mut self) {
        self.show_heading_markers = !self.show_heading_markers;
        let state = if self.show_heading_markers {
            "ON"
        } else {
            "OFF"
        };
        self.set_status_message(&format!("Heading markers: {}", state));
    }

    /// Toggle terminal mouse capture.
    ///
    /// With capture on, the scroll wheel drives navigation but the terminal
    /// cannot perform native click-drag text selection. Turning it off releases
    /// the mouse to the terminal so the user can select and copy arbitrary text;
    /// turning it back on restores scroll-wheel navigation. Capture changes are
    /// best-effort — terminals that reject the command simply keep their state.
    pub fn toggle_mouse_capture(&mut self) {
        use crossterm::ExecutableCommand;
        use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
        use std::io::stdout;

        self.mouse_capture = !self.mouse_capture;
        if self.mouse_capture {
            let _ = stdout().execute(EnableMouseCapture);
            self.set_status_message("Mouse capture: ON (scroll navigation)");
        } else {
            let _ = stdout().execute(DisableMouseCapture);
            self.set_status_message("Mouse capture: OFF (drag to select text)");
        }
    }

    /// Toggle filtering outline by open todos
    pub fn toggle_todo_filter(&mut self) {
        self.filter_by_todos = !self.filter_by_todos;

        // Rebuild outline with/without filter
        let selected_offset = self.selected_heading_index();
        let selected_text = self.selected_heading_text().map(|s| s.to_string());
        self.rebuild_outline_items();

        // Try to restore selection
        let restored = selected_offset.is_some_and(|o| self.select_by_heading_index(o))
            || selected_text.is_some_and(|t| self.select_by_text(&t));
        if !restored && !self.outline_items.is_empty() {
            self.outline_state.select(Some(0));
            self.outline_scroll_state = ScrollbarState::new(self.outline_items.len()).position(0);
        }

        // Set status message
        if self.filter_by_todos {
            let count = self.outline_items.len();
            self.set_status_message(&format!(
                "Todo filter ON: {} heading{} with open todos",
                count,
                if count == 1 { "" } else { "s" }
            ));
        } else {
            self.set_status_message("Todo filter OFF: showing all headings");
        }
    }

    /// Cycle outline width between 20%, 30%, and 40%.
    ///
    /// Behavior depends on user's config:
    /// - **New users** (default or standard width in config): Changes are persisted
    ///   to config file for a seamless experience.
    /// - **Power users** (custom width like 25% in config): Changes are session-only
    ///   to protect their carefully crafted config from accidental overwrites.
    ///   They can explicitly save with `S` key.
    ///
    /// This respects the principle that user config should always take precedence.
    pub fn cycle_outline_width(&mut self, increase: bool) {
        if increase {
            self.outline_width = match self.outline_width {
                20 => 30,
                30 => 40,
                40 => 20, // Wrap around
                // For custom widths, snap to nearest standard value going up
                w if w < 25 => 30,
                w if w < 35 => 40,
                _ => 20,
            };
        } else {
            self.outline_width = match self.outline_width {
                40 => 30,
                30 => 20,
                20 => 40, // Wrap around
                // For custom widths, snap to nearest standard value going down
                w if w > 35 => 30,
                w if w > 25 => 20,
                _ => 40,
            };
        }

        // Decide whether to persist based on user's config type
        if self.config_has_custom_outline_width {
            // Power user: protect their custom config value, offer explicit save
            self.set_status_message(&format!("Width: {}% | :w to save", self.outline_width));
        } else {
            // New user or standard config: safe to persist for better UX
            let _ = self.config.set_outline_width(self.outline_width);
            self.set_status_message(&format!("Width: {}%", self.outline_width));
        }
    }

    /// Show confirmation modal for saving outline width.
    /// Called when user presses `S`.
    pub fn show_save_width_confirmation(&mut self) {
        self.mode = AppMode::ConfirmSaveWidth;
    }

    /// Confirm and save outline width to config file.
    pub fn confirm_save_outline_width(&mut self) {
        match self.config.set_outline_width(self.outline_width) {
            Ok(_) => {
                // Update the flag since user explicitly chose to save
                self.config_has_custom_outline_width = self.outline_width != 20
                    && self.outline_width != 30
                    && self.outline_width != 40;
                self.set_status_message(&format!(
                    "✓ Width {}% saved to config",
                    self.outline_width
                ));
            }
            Err(e) => {
                self.set_status_message(&format!("✗ Failed to save: {}", e));
            }
        }
        self.mode = AppMode::Normal;
    }

    /// Cancel the save width confirmation modal.
    pub fn cancel_save_width_confirmation(&mut self) {
        self.mode = AppMode::Normal;
        self.set_status_message("Save cancelled");
    }

    // ========== Command Palette ==========

    /// Open command palette (triggered by `:`)
    pub fn open_command_palette(&mut self) {
        self.mode = AppMode::CommandPalette;
        self.command_palette.query.clear();
        self.command_palette.filtered = (0..PALETTE_COMMANDS.len()).collect();
        self.command_palette.selected = 0;
    }

    /// Add a character to command palette search
    pub fn command_palette_input(&mut self, c: char) {
        if self.command_palette.query.len() < 32 {
            self.command_palette.query.push(c);
            self.filter_commands();
        }
    }

    /// Remove last character from command palette search
    pub fn command_palette_backspace(&mut self) {
        self.command_palette.query.pop();
        self.filter_commands();
    }

    /// Filter commands based on current query
    fn filter_commands(&mut self) {
        // Lowercase once, pass to each command — saves N allocations per
        // keystroke where N = number of palette commands.
        let query_lower = self.command_palette.query.to_lowercase();
        let mut matches: Vec<(usize, usize)> = PALETTE_COMMANDS
            .iter()
            .enumerate()
            .filter(|(_, cmd)| cmd.matches(&query_lower))
            .map(|(idx, cmd)| (idx, cmd.match_score(&query_lower)))
            .collect();

        // Sort by score (highest first)
        matches.sort_by_key(|m| std::cmp::Reverse(m.1));

        self.command_palette.filtered = matches.into_iter().map(|(idx, _)| idx).collect();

        // Reset selection if it's out of bounds
        if self.command_palette.selected >= self.command_palette.filtered.len() {
            self.command_palette.selected = 0;
        }
    }

    /// Move selection down in command palette
    pub fn command_palette_next(&mut self) {
        if !self.command_palette.filtered.is_empty() {
            self.command_palette.selected =
                (self.command_palette.selected + 1) % self.command_palette.filtered.len();
        }
    }

    /// Move selection up in command palette
    pub fn command_palette_prev(&mut self) {
        if !self.command_palette.filtered.is_empty() {
            self.command_palette.selected = if self.command_palette.selected == 0 {
                self.command_palette.filtered.len() - 1
            } else {
                self.command_palette.selected - 1
            };
        }
    }

    /// Autocomplete command palette with selected command's alias
    pub fn command_palette_autocomplete(&mut self) {
        if let Some(&cmd_idx) = self
            .command_palette
            .filtered
            .get(self.command_palette.selected)
        {
            let cmd = &PALETTE_COMMANDS[cmd_idx];
            // Use the first alias (typically the shortest canonical form)
            if let Some(&alias) = cmd.aliases.first() {
                self.command_palette.query = alias.to_string();
                // Re-filter with the new query (will likely still match the same command)
                self.filter_commands();
            }
        }
    }

    /// Close command palette without executing
    pub fn close_command_palette(&mut self) {
        self.mode = AppMode::Normal;
        self.command_palette.query.clear();
    }

    /// Execute selected command and return whether to quit
    pub fn execute_selected_command(&mut self) -> ActionResult {
        if let Some(&cmd_idx) = self
            .command_palette
            .filtered
            .get(self.command_palette.selected)
        {
            let action = PALETTE_COMMANDS[cmd_idx].action;
            let query = self.command_palette.query.clone(); // Capture query for argument parsing
            self.mode = AppMode::Normal;
            self.command_palette.query.clear();
            self.execute_command_action(action, &query)
        } else {
            self.mode = AppMode::Normal;
            ActionResult::Continue
        }
    }

    /// Execute a command action
    fn execute_command_action(&mut self, action: CommandAction, query: &str) -> ActionResult {
        match action {
            CommandAction::SaveWidth => {
                match self.config.set_outline_width(self.outline_width) {
                    Ok(_) => {
                        self.config_has_custom_outline_width = self.outline_width != 20
                            && self.outline_width != 30
                            && self.outline_width != 40;
                        self.set_status_message(&format!(
                            "\u{2713} Width {}% saved to config",
                            self.outline_width
                        ));
                    }
                    Err(e) => {
                        self.set_status_message(&format!("\u{2717} Failed to save: {}", e));
                    }
                }
                ActionResult::Continue
            }
            CommandAction::ToggleOutline => {
                self.toggle_outline();
                ActionResult::Continue
            }
            CommandAction::ToggleHeadingMarkers => {
                self.toggle_heading_markers();
                ActionResult::Continue
            }
            CommandAction::ToggleMouseCapture => {
                self.toggle_mouse_capture();
                ActionResult::Continue
            }
            CommandAction::ToggleHelp => {
                self.toggle_help();
                ActionResult::Continue
            }
            CommandAction::ToggleRawSource => {
                self.toggle_raw_source();
                ActionResult::Continue
            }
            CommandAction::JumpToTop => {
                self.first();
                ActionResult::Continue
            }
            CommandAction::JumpToBottom => {
                self.last();
                ActionResult::Continue
            }
            CommandAction::CollapseAll => {
                self.collapse_all();
                ActionResult::Continue
            }
            CommandAction::ExpandAll => {
                self.expand_all();
                ActionResult::Continue
            }
            CommandAction::CollapseLevel => {
                // Parse level from query (e.g., "collapse 2" -> 2)
                if let Some(level) = Self::parse_level_from_query(query) {
                    self.collapse_level(level);
                } else {
                    self.collapse_all();
                }
                ActionResult::Continue
            }
            CommandAction::ExpandLevel => {
                // Parse level from query (e.g., "expand 2" -> 2)
                if let Some(level) = Self::parse_level_from_query(query) {
                    self.expand_level(level);
                } else {
                    self.expand_all();
                }
                ActionResult::Continue
            }
            CommandAction::SaveFile => {
                if let Err(e) = self.save_pending_edits_to_file() {
                    self.set_status_message(&format!("\u{2717} Save failed: {}", e));
                }
                ActionResult::Continue
            }
            CommandAction::Undo => {
                if let Err(e) = self.undo_last_edit() {
                    self.set_status_message(&format!("\u{2717} Undo failed: {}", e));
                }
                ActionResult::Continue
            }
            CommandAction::Quit => {
                if self.has_unsaved_changes {
                    // Show confirmation dialog instead of quitting immediately
                    self.mode = AppMode::ConfirmSaveBeforeQuit;
                    ActionResult::Continue
                } else {
                    ActionResult::Quit
                }
            }
            // Forward to the regular keybinding action dispatch
            CommandAction::Dispatch(action) => self.execute_action(action),
        }
    }

    /// Parse a level number from a command query like "collapse 2" or "expand 3"
    fn parse_level_from_query(query: &str) -> Option<usize> {
        // Find the last word and try to parse it as a number
        query
            .split_whitespace()
            .last()
            .and_then(|s| s.parse::<usize>().ok())
            .filter(|&n| (1..=6).contains(&n))
    }

    /// Get selected command for display
    pub fn selected_command(&self) -> Option<&'static PaletteCommand> {
        self.command_palette
            .filtered
            .get(self.command_palette.selected)
            .map(|&idx| &PALETTE_COMMANDS[idx])
    }

    pub fn jump_to_heading(&mut self, index: usize) {
        if index < self.outline_items.len() {
            self.select_outline_index(index);
        }
    }

    pub fn set_bookmark(&mut self) {
        // Store bookmark as heading text instead of index
        self.bookmark_position = self.selected_heading_text().map(|s| s.to_string());
    }

    pub fn jump_to_bookmark(&mut self) {
        // Jump to bookmark by finding the heading text
        if let Some(text) = self.bookmark_position.clone() {
            self.select_by_text(&text);
        }
    }

    pub fn selected_heading_text(&self) -> Option<&str> {
        self.outline_state
            .selected()
            .and_then(|i| self.outline_items.get(i))
            .map(|item| item.text.as_str())
    }

    pub fn selected_heading_index(&self) -> Option<usize> {
        self.outline_state
            .selected()
            .and_then(|i| self.outline_items.get(i))
            .and_then(|item| item.heading_index)
    }

    /// Get the content for the currently selected section, or the full document if no heading is selected.
    fn current_section_content(&self) -> String {
        self.selected_heading_index()
            .and_then(|idx| self.document.extract_section_at_index(idx))
            .unwrap_or_else(|| self.document.content.clone())
    }

    /// Get the source line number (1-indexed) for the currently selected heading.
    ///
    /// Returns None if no heading is selected or if the selection is the document overview.
    pub fn selected_heading_source_line(&self) -> Option<u32> {
        let selected_text = self.selected_heading_text()?;

        // Document overview doesn't have a source line
        if selected_text == DOCUMENT_OVERVIEW {
            return Some(1); // Return line 1 for document overview
        }

        let heading = self.document.headings.get(self.selected_heading_index()?)?;
        let offset = heading.offset.min(self.document.content.len());
        let before = &self.document.content[..offset];
        let line = before.chars().filter(|&c| c == '\n').count() + 1;
        Some(line as u32)
    }

    /// Get the source line number (1-indexed) for the current interactive element.
    ///
    /// Computes the source line by adding the element's rendered line offset
    /// to the section's starting line in the source file.
    pub fn interactive_element_source_line(&self) -> Option<u32> {
        let (element_line, _) = self.interactive_state.current_element_line_range()?;

        let selected_text = self.selected_heading_text();
        let is_overview = selected_text.is_none_or(|t| t == DOCUMENT_OVERVIEW);

        if is_overview {
            // Document overview: element lines are relative to full document content
            Some((element_line + 1) as u32)
        } else {
            // Section: element lines are relative to section content (after heading line)
            let heading_line = self.selected_heading_source_line().unwrap_or(1) as usize;
            Some((heading_line + 1 + element_line) as u32)
        }
    }

    /// Sync previous_selection to current selection (prevents spurious scroll resets)
    pub fn sync_previous_selection(&mut self) {
        self.previous_selection = self.outline_state.selected().map(|i| {
            self.outline_items
                .get(i)
                .and_then(|item| item.heading_index)
        });
    }

    pub fn toggle_theme_picker(&mut self) {
        if self.show_theme_picker {
            // Closing picker - restore original theme if set (user pressed Esc)
            if let Some(original) = self.theme_picker_original.take() {
                self.apply_theme_preview(original);
            }
            self.show_theme_picker = false;
        } else {
            // Opening picker - store current theme and set selection
            self.theme_picker_original = Some(self.current_theme);
            self.theme_picker_selected = match self.current_theme {
                ThemeName::OceanDark => 0,
                ThemeName::Nord => 1,
                ThemeName::Dracula => 2,
                ThemeName::Solarized => 3,
                ThemeName::Monokai => 4,
                ThemeName::Gruvbox => 5,
                ThemeName::TokyoNight => 6,
                ThemeName::CatppuccinMocha => 7,
            };
            self.show_theme_picker = true;
        }
    }

    /// Convert theme picker selection index to ThemeName
    fn theme_name_from_index(idx: usize) -> ThemeName {
        match idx {
            0 => ThemeName::OceanDark,
            1 => ThemeName::Nord,
            2 => ThemeName::Dracula,
            3 => ThemeName::Solarized,
            4 => ThemeName::Monokai,
            5 => ThemeName::Gruvbox,
            6 => ThemeName::TokyoNight,
            7 => ThemeName::CatppuccinMocha,
            _ => ThemeName::OceanDark,
        }
    }

    /// Apply a theme preview (doesn't save to config)
    fn apply_theme_preview(&mut self, theme_name: ThemeName) {
        self.current_theme = theme_name;
        self.theme = Theme::from_name(theme_name)
            .with_color_mode(self.color_mode, theme_name)
            .with_custom_colors(&self.config.theme, self.color_mode);
    }

    pub fn theme_picker_next(&mut self) {
        if self.theme_picker_selected < 7 {
            self.theme_picker_selected += 1;
            // Apply theme preview immediately
            let theme_name = Self::theme_name_from_index(self.theme_picker_selected);
            self.apply_theme_preview(theme_name);
        }
    }

    pub fn theme_picker_previous(&mut self) {
        if self.theme_picker_selected > 0 {
            self.theme_picker_selected -= 1;
            // Apply theme preview immediately
            let theme_name = Self::theme_name_from_index(self.theme_picker_selected);
            self.apply_theme_preview(theme_name);
        }
    }

    pub fn apply_selected_theme(&mut self) {
        // Theme is already applied via preview, just save to config and close
        self.theme_picker_original = None; // Clear so toggle doesn't restore
        self.show_theme_picker = false;

        // Save to config (silently ignore errors)
        let _ = self.config.set_theme(self.current_theme);
    }

    /// Get the editor configuration for external file editing
    pub fn editor_config(&self) -> opensesame::EditorConfig {
        self.config.editor.clone()
    }

    pub fn copy_content(&mut self) {
        let content = self.current_section_content();
        if let Some(clipboard) = &mut self.clipboard {
            match clipboard.set_text(content) {
                Ok(_) => {
                    self.status_message = Some("✓ Section copied to clipboard".to_string());
                }
                Err(e) => {
                    self.status_message = Some(format!("✗ Clipboard error: {}", e));
                }
            }
        } else {
            self.status_message = Some("✗ Clipboard not available".to_string());
        }
    }

    pub fn copy_anchor(&mut self) {
        // Copy the anchor link for the currently selected heading
        if let Some(heading_text) = self.selected_heading_text() {
            // Convert heading to anchor format (lowercase, replace spaces with dashes)
            let anchor = Self::heading_to_anchor(heading_text);
            let anchor_link = format!("#{}", anchor);

            // Use persistent clipboard for Linux X11 compatibility
            if let Some(clipboard) = &mut self.clipboard {
                match clipboard.set_text(anchor_link) {
                    Ok(_) => {
                        self.status_message = Some(format!("✓ Anchor link copied: #{}", anchor));
                    }
                    Err(e) => {
                        self.status_message = Some(format!("✗ Clipboard error: {}", e));
                    }
                }
            } else {
                self.status_message = Some("✗ Clipboard not available".to_string());
            }
        } else {
            self.status_message = Some("✗ No heading selected".to_string());
        }
    }

    /// Convert heading text to anchor format using the parser's slugify for consistency
    fn heading_to_anchor(heading: &str) -> String {
        crate::parser::content::slugify(heading)
    }

    /// Enter link follow mode - extract links from current section and highlight them
    pub fn enter_link_follow_mode(&mut self) {
        // Extract content for current section
        let content = self.current_section_content();

        // Extract all links from the content
        self.links_in_view = extract_links(&content);

        // Initialize filtered indices to show all links
        self.link_picker.filtered_indices = (0..self.links_in_view.len()).collect();
        self.link_picker.query.clear();
        self.link_picker.active = false;

        // Always enter mode, even if no links (so user sees "no links" message)
        self.mode = AppMode::LinkFollow;

        // Select first link if any exist
        if !self.link_picker.filtered_indices.is_empty() {
            self.link_picker.selected = Some(0);
        } else {
            self.link_picker.selected = None;
        }
    }

    /// Exit link follow mode and return to normal mode
    pub fn exit_link_follow_mode(&mut self) {
        self.mode = AppMode::Normal;
        self.links_in_view.clear();
        self.link_picker.filtered_indices.clear();
        self.link_picker.selected = None;
        self.link_picker.query.clear();
        self.link_picker.active = false;
        // Don't clear status message here - let it display for a moment
    }

    /// Start link search mode
    pub fn start_link_search(&mut self) {
        if self.mode == AppMode::LinkFollow {
            self.link_picker.active = true;
        }
    }

    /// Stop link search mode (but keep the filter)
    pub fn stop_link_search(&mut self) {
        self.link_picker.active = false;
    }

    /// Clear link search and show all links
    pub fn clear_link_search(&mut self) {
        self.link_picker.query.clear();
        self.link_picker.active = false;
        self.update_link_filter();
    }

    /// Add a character to the link search query
    pub fn link_search_push(&mut self, c: char) {
        self.link_picker.query.push(c);
        self.update_link_filter();
    }

    /// Remove the last character from the link search query
    pub fn link_search_pop(&mut self) {
        self.link_picker.query.pop();
        self.update_link_filter();
    }

    /// Update the filtered link indices based on the search query
    fn update_link_filter(&mut self) {
        let query = self.link_picker.query.to_lowercase();

        if query.is_empty() {
            // Show all links when no search query
            self.link_picker.filtered_indices = (0..self.links_in_view.len()).collect();
        } else {
            // Filter links by text or URL containing the query
            self.link_picker.filtered_indices = self
                .links_in_view
                .iter()
                .enumerate()
                .filter(|(_, link)| {
                    link.text.to_lowercase().contains(&query)
                        || link.target.as_str().to_lowercase().contains(&query)
                })
                .map(|(idx, _)| idx)
                .collect();
        }

        // Update selection to stay within filtered results
        if self.link_picker.filtered_indices.is_empty() {
            self.link_picker.selected = None;
        } else if let Some(idx) = self.link_picker.selected {
            if idx >= self.link_picker.filtered_indices.len() {
                self.link_picker.selected = Some(0);
            }
        } else {
            self.link_picker.selected = Some(0);
        }
    }

    /// Cycle to the next link (Tab in link follow mode)
    pub fn next_link(&mut self) {
        if self.mode == AppMode::LinkFollow && !self.link_picker.filtered_indices.is_empty() {
            self.link_picker.selected = Some(match self.link_picker.selected {
                Some(idx) => {
                    if idx >= self.link_picker.filtered_indices.len() - 1 {
                        0 // Wrap to first
                    } else {
                        idx + 1
                    }
                }
                None => 0,
            });
        }
    }

    /// Cycle to the previous link (Shift+Tab in link follow mode)
    pub fn previous_link(&mut self) {
        if self.mode == AppMode::LinkFollow && !self.link_picker.filtered_indices.is_empty() {
            self.link_picker.selected = Some(match self.link_picker.selected {
                Some(idx) => {
                    if idx == 0 {
                        self.link_picker.filtered_indices.len() - 1 // Wrap to last
                    } else {
                        idx - 1
                    }
                }
                None => 0,
            });
        }
    }

    /// Jump to parent heading while staying in link follow mode
    pub fn jump_to_parent_links(&mut self) {
        if self.mode == AppMode::LinkFollow {
            // First, jump to parent in outline
            if let Some(current_idx) = self.outline_state.selected()
                && current_idx < self.outline_items.len()
            {
                let current_level = self.outline_items[current_idx].level;

                // Search backwards for a heading with lower level (parent)
                for i in (0..current_idx).rev() {
                    if self.outline_items[i].level < current_level {
                        // Jump to parent in outline
                        self.select_outline_index(i);

                        // Now extract links from parent's content
                        let content = self.current_section_content();
                        self.links_in_view = extract_links(&content);

                        // Reset link selection
                        if !self.links_in_view.is_empty() {
                            self.link_picker.selected = Some(0);
                            self.status_message = Some(format!(
                                "✓ Jumped to parent ({} links found)",
                                self.links_in_view.len()
                            ));
                        } else {
                            self.link_picker.selected = None;
                            self.status_message = Some("⚠ Parent has no links".to_string());
                        }

                        return;
                    }
                }

                // If no parent found (already at top-level)
                self.status_message = Some("⚠ Already at top-level heading".to_string());
            }
        }
    }

    // ===== File Picker Methods =====

    /// Check if a path has a markdown file extension
    fn is_markdown_extension(path: &std::path::Path) -> bool {
        path.extension()
            .and_then(|ext| ext.to_str())
            .map(|ext| {
                let ext_lower = ext.to_lowercase();
                ext_lower == "md" || ext_lower == "markdown" || ext_lower == "mdown"
            })
            .unwrap_or(false)
    }

    /// Get the effective file picker directory (custom or cwd)
    pub fn effective_picker_dir(&self) -> PathBuf {
        self.file_picker_dir
            .clone()
            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
    }

    /// Navigate file picker to the given directory, clearing search and resetting selection
    fn navigate_picker_to_dir(&mut self, dir: PathBuf) {
        self.file_picker_dir = Some(dir);
        self.file_picker.query.clear();
        self.scan_markdown_files(); // update_file_filter() resets selected_file_idx
    }

    /// Scan current directory for .md files and subdirectories (non-recursive, alphabetically sorted)
    pub fn scan_markdown_files(&mut self) {
        use std::fs;

        let dir = self.effective_picker_dir();

        let mut files = Vec::new();
        let mut dirs = Vec::new();

        if let Ok(entries) = fs::read_dir(&dir) {
            for entry in entries.filter_map(|e| e.ok()) {
                let ft = entry.file_type().ok();
                let path = entry.path();
                let is_visible = path
                    .file_name()
                    .and_then(|n| n.to_str())
                    .map(|name| self.show_hidden || !name.starts_with('.'))
                    .unwrap_or(false);
                if !is_visible {
                    continue;
                }
                if ft.map(|t| t.is_file()).unwrap_or(false) && Self::is_markdown_extension(&path) {
                    files.push(path);
                } else if ft.map(|t| t.is_dir()).unwrap_or(false) {
                    dirs.push(path);
                }
            }
        }

        files.sort();
        dirs.sort();
        self.file_picker.files = files;
        self.file_picker.dirs = dirs;

        self.update_file_filter();
    }

    /// Update filtered file and directory lists based on search query
    pub fn update_file_filter(&mut self) {
        fn filter_by_name(paths: &[PathBuf], query: &str) -> Vec<usize> {
            paths
                .iter()
                .enumerate()
                .filter(|(_, path)| {
                    path.file_name()
                        .and_then(|n| n.to_str())
                        .map(|name| name.to_lowercase().contains(query))
                        .unwrap_or(false)
                })
                .map(|(idx, _)| idx)
                .collect()
        }

        if self.file_picker.query.is_empty() {
            self.file_picker.filtered_file_indices = (0..self.file_picker.files.len()).collect();
            self.file_picker.filtered_dir_indices = (0..self.file_picker.dirs.len()).collect();
        } else {
            let query_lower = self.file_picker.query.to_lowercase();
            self.file_picker.filtered_file_indices =
                filter_by_name(&self.file_picker.files, &query_lower);
            self.file_picker.filtered_dir_indices =
                filter_by_name(&self.file_picker.dirs, &query_lower);
        }

        // Combined count: files + directories
        let combined_count = self.file_picker.filtered_file_indices.len()
            + self.file_picker.filtered_dir_indices.len();

        // Reset selection if current is out of bounds
        if let Some(sel) = self.file_picker.selected {
            if sel >= combined_count {
                self.file_picker.selected = if combined_count == 0 { None } else { Some(0) };
            }
        } else if combined_count > 0 {
            self.file_picker.selected = Some(0);
        }
    }

    /// Get the total number of items in the file picker (files + dirs)
    pub fn file_picker_item_count(&self) -> usize {
        self.file_picker.filtered_file_indices.len() + self.file_picker.filtered_dir_indices.len()
    }

    /// Push character to file search query
    pub fn file_search_push(&mut self, c: char) {
        self.file_picker.query.push(c);
        self.update_file_filter();
    }

    /// Pop character from file search query
    pub fn file_search_pop(&mut self) {
        self.file_picker.query.pop();
        self.update_file_filter();
    }

    /// Navigate to parent directory in file picker
    pub fn file_picker_parent_dir(&mut self) {
        let current_dir = self.effective_picker_dir();
        if let Some(parent) = current_dir.parent() {
            self.navigate_picker_to_dir(parent.to_path_buf());
        }
    }

    /// Enter file picker mode
    pub fn enter_file_picker(&mut self) {
        self.scan_markdown_files();

        // Highlight current file if present
        if let Some(current_idx) = self
            .file_picker
            .files
            .iter()
            .position(|p| p == &self.current_file_path)
        {
            self.file_picker.selected = Some(current_idx);
        } else if !self.file_picker.filtered_file_indices.is_empty() {
            self.file_picker.selected = Some(0);
        }

        self.mode = AppMode::FilePicker;
    }

    /// Select file from picker and load it (or navigate into directory)
    pub fn select_file_from_picker(&mut self) -> Result<(), String> {
        let selected_display_idx = self.file_picker.selected.ok_or("No file selected")?;
        let file_count = self.file_picker.filtered_file_indices.len();

        // Check if selection is in the directory range
        if selected_display_idx >= file_count {
            let dir_display_idx = selected_display_idx - file_count;
            let real_dir_idx = self
                .file_picker
                .filtered_dir_indices
                .get(dir_display_idx)
                .ok_or("Invalid directory selection")?;
            let dir_path = self.file_picker.dirs[*real_dir_idx].clone();
            self.navigate_picker_to_dir(dir_path);
            return Ok(());
        }

        let real_idx = self
            .file_picker
            .filtered_file_indices
            .get(selected_display_idx)
            .ok_or("Invalid selection")?;
        let file_path = self.file_picker.files[*real_idx].clone();

        // Don't reload if it's already the current file
        if file_path == self.current_file_path {
            self.mode = AppMode::Normal;
            self.file_picker.query.clear();
            self.file_picker.active = false;
            return Ok(());
        }

        // Save current state to history
        let current_state = FileState {
            path: self.current_file_path.clone(),
            document: self.document.clone(),
            filename: self.filename.clone(),
            selected_heading: self.selected_heading_text().map(|s| s.to_string()),
            content_scroll: self.content_scroll,
            outline_state_selected: self.outline_state.selected(),
        };
        self.file_history.push(current_state);
        self.file_future.clear(); // Clear forward history when navigating to new file

        // Load new file
        let content = std::fs::read_to_string(&file_path)
            .map_err(|e| format!("Failed to read file: {}", e))?;
        let document = crate::parser::parse_markdown(&content);
        let filename = file_path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("unknown.md")
            .to_string();

        self.load_document(document, filename, file_path);

        // Exit picker mode
        self.mode = AppMode::Normal;
        self.file_picker.query.clear();
        self.file_picker.active = false;

        Ok(())
    }

    /// Cycle to the next item in file picker (files + dirs)
    pub fn next_file(&mut self) {
        let total = self.file_picker_item_count();
        if self.mode == AppMode::FilePicker && total > 0 {
            self.file_picker.selected = Some(match self.file_picker.selected {
                Some(idx) => {
                    if idx >= total - 1 {
                        0 // Wrap to first
                    } else {
                        idx + 1
                    }
                }
                None => 0,
            });
        }
    }

    /// Cycle to the previous item in file picker (files + dirs)
    pub fn previous_file(&mut self) {
        let total = self.file_picker_item_count();
        if self.mode == AppMode::FilePicker && total > 0 {
            self.file_picker.selected = Some(match self.file_picker.selected {
                Some(idx) => {
                    if idx == 0 {
                        total - 1 // Wrap to last
                    } else {
                        idx - 1
                    }
                }
                None => 0,
            });
        }
    }

    /// Get the currently selected link (from filtered list)
    pub fn get_selected_link(&self) -> Option<&Link> {
        self.link_picker
            .selected
            .and_then(|idx| self.link_picker.filtered_indices.get(idx))
            .and_then(|&real_idx| self.links_in_view.get(real_idx))
    }

    /// Check if frontmatter should be hidden (from config)
    pub fn should_hide_frontmatter(&self) -> bool {
        self.config.content.hide_frontmatter
    }

    /// Check if LaTeX should be hidden (from config)
    pub fn should_hide_latex(&self) -> bool {
        self.config.content.hide_latex
    }

    /// Check if aggressive LaTeX filtering is enabled (from config)
    pub fn should_latex_aggressive(&self) -> bool {
        self.config.content.latex_aggressive
    }

    /// Handle loading a relative file link, resolving markdown extensions and fallbacks.
    ///
    /// Returns `true` if the caller should exit its current mode (link-follow or interactive).
    fn resolve_relative_file_link(
        &mut self,
        path: &PathBuf,
        anchor: &Option<String>,
    ) -> Result<bool, String> {
        let has_md_extension = Self::is_markdown_extension(path);

        let current_dir = self
            .current_file_path
            .parent()
            .ok_or("Cannot determine current directory")?;

        if has_md_extension {
            self.load_file(path, anchor.as_deref())?;
            // Only signal exit if we're not prompting for file creation
            Ok(self.mode != AppMode::ConfirmFileCreate)
        } else {
            // No markdown extension — try .md, then as-is, then prompt to create
            let md_path = PathBuf::from(format!("{}.md", path.display()));
            let absolute_md_path = current_dir.join(&md_path);

            if absolute_md_path.exists() && !absolute_md_path.is_symlink() {
                self.load_file(&md_path, anchor.as_deref())?;
                Ok(true)
            } else {
                let absolute_path = current_dir.join(path);

                if absolute_path.exists() && !absolute_path.is_symlink() {
                    // Non-markdown file — open in editor
                    self.pending_editor_file = Some(absolute_path);
                    Ok(true)
                } else {
                    // File doesn't exist — prompt to create markdown file
                    let relative_path = if path.extension().is_none() {
                        PathBuf::from(format!("{}.md", path.display()))
                    } else {
                        path.clone()
                    };
                    self.load_file(&relative_path, anchor.as_deref())?;
                    Ok(self.mode != AppMode::ConfirmFileCreate)
                }
            }
        }
    }

    /// Follow the currently selected link
    pub fn follow_selected_link(&mut self) -> Result<(), String> {
        let link = match self.get_selected_link() {
            Some(link) => link.clone(),
            None => return Err("No link selected".to_string()),
        };

        match link.target {
            crate::parser::LinkTarget::Anchor(anchor) => {
                // Jump to heading in current document
                self.jump_to_anchor(&anchor)?;
                self.status_message = Some(format!("✓ Jumped to #{}", anchor));
                self.exit_link_follow_mode();
                Ok(())
            }
            crate::parser::LinkTarget::RelativeFile { path, anchor } => {
                let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
                if self.resolve_relative_file_link(&path, &anchor)? {
                    self.status_message = Some(format!("✓ Opened {}", filename));
                    self.exit_link_follow_mode();
                }
                Ok(())
            }
            crate::parser::LinkTarget::WikiLink { target, .. } => {
                // Try to find and load the wikilinked file
                self.load_wikilink(&target)?;
                // Only exit link follow mode if we're not prompting for file creation
                if self.mode != AppMode::ConfirmFileCreate {
                    self.status_message = Some(format!("✓ Opened [[{}]]", target));
                    self.exit_link_follow_mode();
                }
                Ok(())
            }
            crate::parser::LinkTarget::External(url) => {
                // Try to open in default browser
                let open_result = open::that(&url);

                // Also copy to clipboard as backup
                let mut clipboard_success = false;
                if let Ok(mut clipboard) = arboard::Clipboard::new() {
                    clipboard_success = clipboard.set_text(url.clone()).is_ok();
                }

                // Set status message
                self.status_message = match (open_result, clipboard_success) {
                    (Ok(_), true) => Some(format!(
                        "✓ Opened {} in browser (also copied to clipboard)",
                        url
                    )),
                    (Ok(_), false) => Some(format!("✓ Opened {} in browser", url)),
                    (Err(_), true) => Some(format!(
                        "⚠ Could not open browser, URL copied to clipboard: {}",
                        url
                    )),
                    (Err(_), false) => Some(format!("✗ Failed to open URL: {}", url)),
                };

                self.exit_link_follow_mode();
                Ok(())
            }
        }
    }

    /// Jump to a heading by anchor name or heading text.
    ///
    /// Supports two matching strategies (checked per-item, Strategy 1 takes priority):
    /// 1. **Normalized anchor match** - compares `heading_to_anchor(item)` with lowercased anchor.
    ///    Handles markdown links (`#features`, `#mixed-links-test`) and simple wikilinks (`[[#Features]]`).
    /// 2. **Heading text match** - case-insensitive comparison of raw heading text.
    ///    Handles wikilinks preserving spaces (`[[#Mixed Links Test]]`).
    fn jump_to_anchor(&mut self, anchor: &str) -> Result<(), String> {
        let anchor_lower = anchor.to_lowercase();

        for (idx, item) in self.outline_items.iter().enumerate() {
            // Strategy 1: Normalized anchor match
            // The anchor from markdown links is already normalized (lowercase, dashes),
            // so we just lowercase the query and compare with the item's normalized form.
            if Self::heading_to_anchor(&item.text) == anchor_lower {
                self.select_outline_index(idx);
                return Ok(());
            }

            // Strategy 2: Direct heading text match (case-insensitive)
            // Wikilinks like [[#Mixed Links Test]] preserve the original heading text.
            if item.text.eq_ignore_ascii_case(anchor) {
                self.select_outline_index(idx);
                return Ok(());
            }
        }

        Err(format!("Heading '{}' not found", anchor))
    }

    /// Load a file by relative path (checks for unsaved changes first)
    ///
    /// Security: Validates path to prevent directory traversal attacks.
    /// Files must be within the current file's directory or its subdirectories.
    fn load_file(&mut self, relative_path: &PathBuf, anchor: Option<&str>) -> Result<(), String> {
        // Check for unsaved changes before navigating to a different file
        if self.has_unsaved_changes {
            self.pending_navigation = Some(PendingNavigation::LoadFile(
                relative_path.clone(),
                anchor.map(|s| s.to_string()),
            ));
            self.mode = AppMode::ConfirmSaveBeforeNav;
            return Ok(()); // Not an error - we're asking user to confirm
        }

        self.load_file_internal(relative_path, anchor)
    }

    /// Internal file loading - skips unsaved changes check
    ///
    /// Security: Validates path to prevent directory traversal attacks.
    /// Files must be within the current file's directory or its subdirectories.
    fn load_file_internal(
        &mut self,
        relative_path: &PathBuf,
        anchor: Option<&str>,
    ) -> Result<(), String> {
        // Reject absolute paths
        if relative_path.is_absolute() {
            return Err("Absolute paths are not allowed for security reasons".to_string());
        }

        // Reject paths containing .. components (path traversal)
        if relative_path
            .components()
            .any(|c| matches!(c, std::path::Component::ParentDir))
        {
            return Err("Path traversal (..) is not allowed for security reasons".to_string());
        }

        // Resolve path relative to current file
        let current_dir = self
            .current_file_path
            .parent()
            .ok_or("Cannot determine current directory")?;
        let absolute_path = current_dir.join(relative_path);

        // Verify the resolved path is within allowed boundaries
        // (defense in depth - even though we rejected .., canonicalize to be sure)
        if let (Ok(canonical_path), Ok(canonical_base)) =
            (absolute_path.canonicalize(), current_dir.canonicalize())
            && !canonical_path.starts_with(&canonical_base)
        {
            return Err("Path escapes document directory boundary".to_string());
        }

        // Check for symlink (prevent symlink attacks)
        if absolute_path.is_symlink() {
            return Err("Symlinks are not allowed for security reasons".to_string());
        }

        // Check if file exists - if not, prompt to create it
        if !absolute_path.exists() {
            self.pending_file_create = Some(absolute_path.clone());
            self.pending_file_create_message = Some(format!(
                "File '{}' does not exist. Create it?",
                relative_path.display()
            ));
            self.mode = AppMode::ConfirmFileCreate;
            return Ok(()); // Not an error - we're asking user to confirm
        }

        // Parse the new file
        let new_document = crate::parser::parse_file(&absolute_path)
            .map_err(|e| format!("Failed to load file: {}", e))?;

        let new_filename = absolute_path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("unknown")
            .to_string();

        // Save current state to history
        self.save_to_history();

        // Load new document
        self.load_document(new_document, new_filename, absolute_path);

        // Jump to anchor if specified
        if let Some(anchor_name) = anchor {
            let _ = self.jump_to_anchor(anchor_name);
        }

        Ok(())
    }

    /// Find and load a wikilinked file
    ///
    /// Supports formats:
    /// - `[[filename]]` - load file (tries .md, .markdown extensions)
    /// - `[[filename#anchor]]` - load file and jump to anchor
    /// - `[[#anchor]]` - jump to anchor in current document
    /// - `[[path/to/file]]` - load file with path (e.g., `[[diary/notes.md]]`)
    ///
    /// Security: Path traversal (..) and absolute paths are blocked.
    /// The `load_file()` function provides additional security validation.
    fn load_wikilink(&mut self, target: &str) -> Result<(), String> {
        // Handle anchor-only wikilinks (e.g., [[#section]])
        if let Some(anchor) = target.strip_prefix('#') {
            // Jump to heading in current document
            self.jump_to_anchor(anchor)?;
            self.status_message = Some(format!("✓ Jumped to #{}", anchor));
            return Ok(());
        }

        // Split target into file and optional anchor (e.g., "file#section" -> ("file", Some("section")))
        let (file_target, anchor) = if let Some((file, anchor)) = target.split_once('#') {
            (file, Some(anchor))
        } else {
            (target, None)
        };

        // Security: Reject path traversal attempts
        if file_target.contains("..") {
            return Err("WikiLinks cannot contain path traversal (..)".to_string());
        }

        // Security: Reject absolute paths
        if file_target.starts_with('/') {
            return Err("WikiLinks cannot be absolute paths".to_string());
        }

        // Security: Reject Windows absolute paths (drive letters)
        #[cfg(windows)]
        if file_target.len() >= 2 && file_target.chars().nth(1) == Some(':') {
            return Err("WikiLinks cannot be absolute paths".to_string());
        }

        // Normalize backslashes to forward slashes for cross-platform compatibility
        let file_target = file_target.replace('\\', "/");

        // Try to find the file relative to current directory
        let current_dir = self
            .current_file_path
            .parent()
            .ok_or("Cannot determine current directory")?;

        // Check if target already has a markdown extension
        let file_target_lower = file_target.to_lowercase();
        let has_md_extension = file_target_lower.ends_with(".md")
            || file_target_lower.ends_with(".markdown")
            || file_target_lower.ends_with(".mdown");

        // Try various extensions (only add extensions if target doesn't already have one)
        let candidates: Vec<String> = if has_md_extension {
            // Already has markdown extension - just try as-is
            vec![file_target.to_string()]
        } else {
            // Try with various extensions
            vec![
                format!("{}.md", file_target),
                format!("{}.markdown", file_target),
                file_target.to_string(),
            ]
        };

        for candidate in &candidates {
            let path = current_dir.join(candidate);
            // Check for symlinks
            if path.is_symlink() {
                continue; // Skip symlinks for security
            }
            if path.exists() {
                return self.load_file(&PathBuf::from(candidate), anchor);
            }
        }

        // File not found - prompt to create it (default to .md extension if not already present)
        let default_filename = if has_md_extension {
            file_target.to_string()
        } else {
            format!("{}.md", file_target)
        };
        let new_path = current_dir.join(&default_filename);
        self.pending_file_create = Some(new_path);
        self.pending_file_create_message = Some(format!(
            "Wikilink '[[{}]]' not found. Create '{}'?",
            target, default_filename
        ));
        self.mode = AppMode::ConfirmFileCreate;
        Ok(()) // Not an error - we're asking user to confirm
    }

    /// Save current state to history before navigating away
    fn save_to_history(&mut self) {
        let state = FileState {
            path: self.current_file_path.clone(),
            document: self.document.clone(),
            filename: self.filename.clone(),
            selected_heading: self.selected_heading_text().map(|s| s.to_string()),
            content_scroll: self.content_scroll,
            outline_state_selected: self.outline_state.selected(),
        };
        self.file_history.push(state);

        // Clear forward history when navigating to a new file
        self.file_future.clear();
    }

    /// Load a new document and update all related state
    fn load_document(&mut self, document: Document, filename: String, path: PathBuf) {
        // Signal file watcher if path changed
        if self.current_file_path != path {
            self.file_path_changed = true;
        }

        self.document = document;
        self.filename = filename;
        self.current_file_path = path;

        // Rebuild tree and outline (with overview entry if applicable)
        self.tree = self.document.build_tree();
        self.rebuild_outline_items();

        // Reset selection to first item
        let mut outline_state = ListState::default();
        if !self.outline_items.is_empty() {
            outline_state.select(Some(0));
        }
        self.outline_state = outline_state;
        self.outline_scroll_state = ScrollbarState::new(self.outline_items.len());

        // Reset content scroll
        self.content_scroll = 0;
        let content_lines = self.document.content.lines().count();
        self.content_height = content_lines;
        self.content_scroll_state = ScrollbarState::new(content_lines);

        // Clear previous selection tracking
        self.previous_selection = None;
        // Document changed — force a metrics recompute on the next render.
        self.metrics_dirty = true;

        // Index interactive elements (links, images, etc.) even in normal mode
        // This allows inline images to render without entering interactive mode
        let content = self.document.content.clone();
        use crate::parser::content::parse_content;
        let blocks = parse_content(&content, 0);
        self.index_interactive_elements(&blocks);
        self.populate_image_cache();

        // Detect LaTeX content for status hint
        self.latex_detected = content.contains("\\begin{")
            || content.contains("\\end{")
            || content.contains("\\textbf{")
            || content.contains("\\textit{")
            || content.contains("\\usepackage")
            || content.contains("\\documentclass")
            || content.contains("\\newpage")
            || content.contains("\\section{");

        if self.latex_detected && self.should_hide_latex() && !self.latex_hint_shown {
            self.latex_hint_shown = true;
            self.set_status_message("LaTeX detected · filtered via hide_latex in config");
        }
    }

    /// Navigate back in file history
    pub fn go_back(&mut self) -> Result<(), String> {
        let previous_state = self
            .file_history
            .pop()
            .ok_or("No previous file in history")?;

        // Save current state to future stack
        let current_state = FileState {
            path: self.current_file_path.clone(),
            document: self.document.clone(),
            filename: self.filename.clone(),
            selected_heading: self.selected_heading_text().map(|s| s.to_string()),
            content_scroll: self.content_scroll,
            outline_state_selected: self.outline_state.selected(),
        };
        self.file_future.push(current_state);

        // Restore previous state
        self.restore_file_state(previous_state);

        Ok(())
    }

    /// Navigate forward in file history
    pub fn go_forward(&mut self) -> Result<(), String> {
        let next_state = self.file_future.pop().ok_or("No next file in history")?;

        // Save current state to history stack
        let current_state = FileState {
            path: self.current_file_path.clone(),
            document: self.document.clone(),
            filename: self.filename.clone(),
            selected_heading: self.selected_heading_text().map(|s| s.to_string()),
            content_scroll: self.content_scroll,
            outline_state_selected: self.outline_state.selected(),
        };
        self.file_history.push(current_state);

        // Restore next state
        self.restore_file_state(next_state);

        Ok(())
    }

    /// Restore a file state from history
    fn restore_file_state(&mut self, state: FileState) {
        self.load_document(state.document, state.filename, state.path);

        // Restore selection and scroll position
        if let Some(selected_idx) = state.outline_state_selected
            && selected_idx < self.outline_items.len()
        {
            self.select_outline_index(selected_idx);
        }

        self.content_scroll = state.content_scroll;
        self.content_scroll_state = self
            .content_scroll_state
            .position(state.content_scroll as usize);
    }

    /// Reload current file from disk (used after external editing).
    ///
    /// Returns `Ok(false)` when the on-disk content is identical to the
    /// in-memory document (e.g. the watcher echoing back our own save),
    /// in which case nothing is reloaded.
    pub fn reload_current_file(&mut self) -> Result<bool, String> {
        // Save current state to restore after reload
        let current_selection = self.selected_heading_text().map(|s| s.to_string());
        let current_scroll = self.content_scroll;

        // Reload the file
        let content = std::fs::read_to_string(&self.current_file_path)
            .map_err(|e| format!("Failed to reload file: {}", e))?;

        if content == self.document.content {
            return Ok(false);
        }

        let document = crate::parser::parse_markdown(&content);
        let filename = self
            .current_file_path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("file")
            .to_string();

        self.load_document(document, filename, self.current_file_path.clone());

        // Try to restore selection if the heading still exists
        if let Some(heading) = current_selection {
            self.select_by_text(&heading);
        }

        // Restore scroll position (may be adjusted if content changed)
        if (current_scroll as usize) < self.content_height {
            self.content_scroll = current_scroll;
            self.content_scroll_state = self.content_scroll_state.position(current_scroll as usize);
        }

        Ok(true)
    }

    /// Enter interactive mode - build element index and enter mode
    pub fn enter_interactive_mode(&mut self) {
        // Exit raw source view if active (interactive elements aren't visible in raw mode)
        if self.show_raw_source {
            self.show_raw_source = false;
        }

        // Get current section content to index
        let content = self.current_section_content();

        // Parse content into blocks
        use crate::parser::content::parse_content;
        let blocks = parse_content(&content, 0);

        // Index interactive elements
        self.index_interactive_elements(&blocks);
        self.populate_image_cache();

        // Enter interactive mode at current scroll position (preserve user's view)
        self.interactive_state
            .enter_at_scroll_position(self.content_scroll as usize);
        self.mode = AppMode::Interactive;

        // Only scroll if the selected element is not fully visible
        self.scroll_to_interactive_element(self.content_viewport_height);

        // Set status message
        if self.interactive_state.elements.is_empty() {
            self.status_message = Some("⚠ No interactive elements in this section".to_string());
        } else {
            self.status_message = Some(format!(
                "✓ Interactive mode: {} elements found (Tab to cycle)",
                self.interactive_state.elements.len()
            ));
        }
    }

    /// Exit interactive mode and return to normal
    pub fn exit_interactive_mode(&mut self) {
        self.interactive_state.exit();
        self.mode = AppMode::Normal;
        self.status_message = None;
    }

    /// Confirm file creation and open the new file
    pub fn confirm_file_create(&mut self) -> Result<(), String> {
        if let Some(path) = self.pending_file_create.take() {
            // Create parent directories if needed
            if let Some(parent) = path.parent()
                && !parent.exists()
            {
                std::fs::create_dir_all(parent)
                    .map_err(|e| format!("Failed to create directory: {}", e))?;
            }

            // Create the file with default content
            let filename = path
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("untitled");
            let default_content = format!("# {}\n\n", filename);

            std::fs::write(&path, &default_content)
                .map_err(|e| format!("Failed to create file: {}", e))?;

            self.pending_file_create_message = None;
            self.mode = AppMode::Normal;

            // Load the newly created file via its path relative to the
            // current document's directory — using only the file name here
            // would resolve to the wrong directory for wikilinks like
            // [[diary/notes]]
            let current_dir = self
                .current_file_path
                .parent()
                .map(PathBuf::from)
                .unwrap_or_default();
            let relative_path = path
                .strip_prefix(&current_dir)
                .map(PathBuf::from)
                .unwrap_or_else(|_| path.clone());

            self.load_file(&relative_path, None)?;
            self.status_message = Some(format!("✓ Created and opened {}", relative_path.display()));
            self.exit_link_follow_mode();
        }
        Ok(())
    }

    /// Cancel file creation and return to previous mode
    pub fn cancel_file_create(&mut self) {
        self.pending_file_create = None;
        self.pending_file_create_message = None;
        self.mode = AppMode::Normal;
        self.status_message = Some("File creation cancelled".to_string());
    }

    /// Get the currently selected interactive element
    pub fn get_selected_interactive_element(
        &self,
    ) -> Option<&crate::tui::interactive::InteractiveElement> {
        self.interactive_state.current_element()
    }

    /// Activate the currently selected interactive element
    pub fn activate_interactive_element(&mut self) -> Result<(), String> {
        use crate::tui::interactive::ElementType;

        let element = match self.interactive_state.current_element() {
            Some(elem) => elem.clone(),
            None => return Err("No element selected".to_string()),
        };

        match &element.element_type {
            ElementType::Details { .. } => {
                // Toggle details expansion
                self.interactive_state.toggle_details(element.id);

                // Re-index elements since expanded state changed content
                self.reindex_interactive_elements();

                self.status_message = Some("✓ Toggled details".to_string());
                Ok(())
            }
            ElementType::Checkbox {
                checked,
                block_idx,
                item_idx,
                ..
            } => {
                // Toggle checkbox and save to file
                self.toggle_checkbox_and_save(*block_idx, *item_idx, *checked)?;
                Ok(())
            }
            ElementType::Link { link, .. } => {
                // Follow link using existing link follow logic
                self.follow_link_from_interactive(&link.clone())?;
                Ok(())
            }
            ElementType::CodeBlock { content, .. } => {
                // Copy code to clipboard
                self.copy_to_clipboard(content)?;
                self.status_message = Some("✓ Code copied to clipboard".to_string());
                Ok(())
            }
            ElementType::Image { src, alt, .. } => {
                if self.images_enabled {
                    // Open image modal to view the image fullscreen
                    self.open_image_modal(src);
                    self.status_message = Some(format!("📸 Viewing: {} (Esc:Close)", alt));
                } else {
                    self.status_message =
                        Some("Images disabled (use --images or config to enable)".to_string());
                }
                Ok(())
            }
            ElementType::Table { rows, cols, .. } => {
                // Enter table navigation mode
                self.interactive_state.enter_table_mode()?;
                self.status_message =
                    Some(self.interactive_state.table_status_text(rows + 1, *cols));
                Ok(())
            }
        }
    }

    /// Re-index interactive elements after state changes
    pub fn reindex_interactive_elements(&mut self) {
        let content = self.current_section_content();

        use crate::parser::content::parse_content;
        let blocks = parse_content(&content, 0);
        self.index_interactive_elements(&blocks);
        self.populate_image_cache();
    }

    /// Toggle a checkbox and save changes to the file
    fn toggle_checkbox_and_save(
        &mut self,
        block_idx: usize,
        item_idx: usize,
        checked: bool,
    ) -> Result<(), String> {
        use crate::parser::content::parse_content;
        use crate::parser::output::Block;

        let clean = |raw: &str| -> String {
            raw.trim_start()
                .trim_start_matches("[x]")
                .trim_start_matches("[X]")
                .trim_start_matches("[ ]")
                .trim()
                .to_string()
        };

        // Identify the checkbox text and its occurrence index among identical
        // checkboxes (same text, same state) in this section, so duplicates
        // toggle the right line.
        let content = self.current_section_content();
        let blocks = parse_content(&content, 0);

        let target_text = if let Some(Block::List { items, .. }) = blocks.get(block_idx) {
            items.get(item_idx).map(|item| clean(&item.content))
        } else {
            None
        };
        let target_text =
            target_text.ok_or_else(|| "Could not find checkbox content".to_string())?;

        let mut occurrence = 0usize;
        'outer: for (b_i, block) in blocks.iter().enumerate() {
            if let Block::List { items, .. } = block {
                for (i_i, item) in items.iter().enumerate() {
                    if b_i == block_idx && i_i == item_idx {
                        break 'outer;
                    }
                    if item.checked == Some(checked) && clean(&item.content) == target_text {
                        occurrence += 1;
                    }
                }
            }
        }

        // Apply the toggle on the in-memory content (which includes any
        // buffered table edits) so toggling can never drop them.
        let had_pending = self.has_unsaved_changes;
        let line_range = self.current_section_line_range();
        let new_content = crate::tui::edits::toggle_checkbox(
            &self.document.content,
            line_range,
            &target_text,
            checked,
            occurrence,
            crate::parser::utils::strip_markdown_inline,
        )?;

        // Atomic write: write to temp file, then rename (prevents data corruption)
        use std::io::Write;
        let parent_dir = self
            .current_file_path
            .parent()
            .ok_or("Cannot determine parent directory")?;

        let mut temp_file = tempfile::NamedTempFile::new_in(parent_dir)
            .map_err(|e| format!("Failed to create temp file: {}", e))?;

        temp_file
            .write_all(new_content.as_bytes())
            .map_err(|e| format!("Failed to write temp file: {}", e))?;

        temp_file
            .flush()
            .map_err(|e| format!("Failed to flush temp file: {}", e))?;

        // Atomic rename (same filesystem guarantees atomicity)
        temp_file
            .persist(&self.current_file_path)
            .map_err(|e| format!("Failed to save file: {}", e))?;

        // Any buffered table edits were persisted along with the toggle
        if had_pending {
            self.pending_edits.clear();
            self.has_unsaved_changes = false;
        }

        // Save scroll position and interactive element index before reload
        let saved_scroll = self.content_scroll;
        let saved_element_idx = self.interactive_state.current_index;

        // Reload the document
        self.reload_current_file()?;

        // Re-index interactive elements
        self.reindex_interactive_elements();

        // Restore scroll position (clamped to valid range)
        self.content_scroll = saved_scroll.min(self.max_content_scroll());
        self.content_scroll_state = self
            .content_scroll_state
            .position(self.content_scroll as usize);

        // Restore interactive element selection if still valid
        if let Some(idx) = saved_element_idx
            && idx < self.interactive_state.elements.len()
        {
            self.interactive_state.current_index = Some(idx);
        }

        // IMPORTANT: Sync previous_selection to prevent update_content_metrics() from resetting scroll
        // After reload, load_document() sets previous_selection = None, but current selection is restored.
        // Without this sync, update_content_metrics() thinks selection changed and resets scroll to 0.
        self.previous_selection = self.outline_state.selected().map(|i| {
            self.outline_items
                .get(i)
                .and_then(|item| item.heading_index)
        });

        // Suppress file watcher for this save - we already reloaded internally
        // Without this, file watcher detects our save and triggers a second reload
        self.suppress_file_watch = true;

        let new_state = if checked { "unchecked" } else { "checked" };
        self.status_message = Some(if had_pending {
            format!(
                "✓ Checkbox {} and saved (pending edits included)",
                new_state
            )
        } else {
            format!("✓ Checkbox {} and saved", new_state)
        });

        Ok(())
    }

    /// 0-indexed line number of a byte offset in the document content.
    fn line_of_offset(&self, offset: usize) -> usize {
        let clamped = offset.min(self.document.content.len());
        self.document.content[..clamped].matches('\n').count()
    }

    /// 0-indexed `[start, end)` source line range of the currently selected
    /// section (heading line through the line before the next heading at the
    /// same or higher level), or the whole document when nothing is selected.
    fn current_section_line_range(&self) -> (usize, usize) {
        // Exclusive upper bound in the same 0-indexed line space as
        // `line_of_offset` (newline count), so start/end/total never disagree.
        let total = self.line_of_offset(self.document.content.len()) + 1;
        let Some(idx) = self.selected_heading_index() else {
            return (0, total);
        };
        let Some(heading) = self.document.headings.get(idx) else {
            return (0, total);
        };
        let start = self.line_of_offset(heading.offset);
        let end = self.document.headings[idx + 1..]
            .iter()
            .find(|h| h.level <= heading.level)
            .map(|h| self.line_of_offset(h.offset))
            .unwrap_or(total);
        (start, end)
    }

    /// Follow a link from interactive mode
    fn follow_link_from_interactive(&mut self, link: &crate::parser::Link) -> Result<(), String> {
        use crate::parser::LinkTarget;

        match &link.target {
            LinkTarget::Anchor(anchor) => {
                // Jump to heading in current document
                self.jump_to_anchor(anchor)?;
                self.exit_interactive_mode();
                self.status_message = Some(format!("✓ Jumped to #{}", anchor));
                Ok(())
            }
            LinkTarget::RelativeFile { path, anchor } => {
                if self.resolve_relative_file_link(path, anchor)? {
                    self.exit_interactive_mode();
                }
                Ok(())
            }
            LinkTarget::WikiLink { target, .. } => {
                // Try to find and load the wikilinked file
                self.load_wikilink(target)?;
                // Only exit interactive mode if we're not prompting for file creation
                if self.mode != AppMode::ConfirmFileCreate {
                    self.exit_interactive_mode();
                }
                Ok(())
            }
            LinkTarget::External(url) => {
                // Security: Validate URL scheme (only http/https allowed)
                if !url.starts_with("http://") && !url.starts_with("https://") {
                    return Err(
                        "Unsafe URL scheme. Only http:// and https:// URLs are allowed."
                            .to_string(),
                    );
                }

                // Use the `open` crate for safe URL opening (no shell injection)
                open::that(url).map_err(|e| format!("Failed to open URL: {}", e))?;

                self.status_message = Some(format!("✓ Opened {}", url));
                Ok(())
            }
        }
    }

    /// Copy text to clipboard
    fn copy_to_clipboard(&mut self, text: &str) -> Result<(), String> {
        if let Some(clipboard) = &mut self.clipboard {
            clipboard
                .set_text(text.to_string())
                .map_err(|e| format!("Clipboard error: {}", e))?;
            Ok(())
        } else {
            Err("Clipboard not available".to_string())
        }
    }

    /// Get table data for current interactive element
    fn get_current_table_data(&self) -> Option<(Vec<String>, Vec<Vec<String>>)> {
        if let Some(element) = self.interactive_state.current_element()
            && let crate::tui::interactive::ElementType::Table { block_idx, .. } =
                &element.element_type
        {
            // Parse current section to get table data
            let content = self.current_section_content();

            use crate::parser::content::parse_content;
            let blocks = parse_content(&content, 0);

            if let Some(crate::parser::output::Block::Table { headers, rows, .. }) =
                blocks.get(*block_idx)
            {
                return Some((headers.clone(), rows.clone()));
            }
        }
        None
    }

    /// Copy table cell to clipboard
    pub fn copy_table_cell(&mut self) -> Result<(), String> {
        if let Some((headers, rows)) = self.get_current_table_data()
            && let Some(cell) = self.interactive_state.get_table_cell(&headers, &rows)
        {
            self.copy_to_clipboard(&cell)?;
            self.status_message = Some(format!("✓ Cell copied: {}", cell));
            return Ok(());
        }
        Err("No cell selected".to_string())
    }

    /// Copy table row to clipboard (tab-separated)
    pub fn copy_table_row(&mut self) -> Result<(), String> {
        if let Some((headers, rows)) = self.get_current_table_data()
            && let Some(row) = self.interactive_state.get_table_row(&headers, &rows)
        {
            let row_text = row.join("\t");
            self.copy_to_clipboard(&row_text)?;
            self.status_message = Some("✓ Row copied (tab-separated)".to_string());
            return Ok(());
        }
        Err("No row selected".to_string())
    }

    /// Copy entire table as markdown
    pub fn copy_table_markdown(&mut self) -> Result<(), String> {
        if let Some((headers, rows)) = self.get_current_table_data() {
            let mut table_md = String::new();

            // Header row
            table_md.push_str("| ");
            table_md.push_str(&headers.join(" | "));
            table_md.push_str(" |\n");

            // Separator row
            table_md.push_str("| ");
            table_md.push_str(&vec!["---"; headers.len()].join(" | "));
            table_md.push_str(" |\n");

            // Data rows
            for row in &rows {
                table_md.push_str("| ");
                table_md.push_str(&row.join(" | "));
                table_md.push_str(" |\n");
            }

            self.copy_to_clipboard(&table_md)?;
            self.status_message = Some("✓ Table copied as markdown".to_string());
            Ok(())
        } else {
            Err("No table data available".to_string())
        }
    }

    /// Enter cell edit mode for the currently selected table cell
    pub fn enter_cell_edit_mode(&mut self) -> Result<(), String> {
        if let Some((headers, rows)) = self.get_current_table_data()
            && let Some((row, col)) = self.interactive_state.get_table_position()
        {
            // Get current cell value
            let cell_value = if row == 0 {
                // Header row
                headers.get(col).cloned().unwrap_or_default()
            } else {
                // Data row
                rows.get(row - 1)
                    .and_then(|r| r.get(col))
                    .cloned()
                    .unwrap_or_default()
            };

            self.cell_edit_value = cell_value.clone();
            self.cell_edit_original_value = cell_value; // Store original for undo
            self.cell_edit_row = row;
            self.cell_edit_col = col;
            self.mode = AppMode::CellEdit;
            return Ok(());
        }
        Err("No cell selected for editing".to_string())
    }

    /// Sanitize table cell content to prevent markdown injection
    fn sanitize_table_cell(value: &str) -> String {
        value
            .replace('|', "\\|") // Escape pipe characters (table delimiters)
            .replace(['\n', '\r'], " ") // Replace newlines and carriage returns
    }

    /// Buffer the edited cell value in memory (does not write to file)
    /// Use save_pending_edits_to_file() to write changes to disk
    pub fn save_edited_cell(&mut self) -> Result<(), String> {
        // Sanitize the cell value to prevent table structure corruption
        let sanitized_value = Self::sanitize_table_cell(&self.cell_edit_value);

        // Skip if no actual change was made
        if sanitized_value == self.cell_edit_original_value {
            self.status_message = Some("No changes made".to_string());
            return Ok(());
        }

        // Calculate the table index for this edit
        let table_index = self.calculate_current_table_index()?;
        let section_start_line = self.current_section_line_range().0;

        // Apply the edit to the in-memory document content first; only
        // record it as pending once it has actually been applied.
        let new_content = crate::tui::edits::replace_table_cell(
            &self.document.content,
            section_start_line,
            table_index,
            self.cell_edit_row,
            self.cell_edit_col,
            &sanitized_value,
        )?;
        self.document.content = new_content;

        // Store the edit in the pending buffer for undo capability
        let pending_edit = PendingEdit {
            section_start_line,
            table_index,
            row: self.cell_edit_row,
            col: self.cell_edit_col,
            original_value: self.cell_edit_original_value.clone(),
            new_value: sanitized_value,
        };
        self.pending_edits.push(pending_edit);
        self.has_unsaved_changes = true;

        // Re-parse headings if needed (table edits don't affect heading structure)
        // The document tree stays the same, only content changed

        let edit_count = self.pending_edits.len();
        self.status_message = Some(format!(
            "✓ Cell updated ({} unsaved change{})",
            edit_count,
            if edit_count == 1 { "" } else { "s" }
        ));
        Ok(())
    }

    /// Calculate the table index (0-based, within the current section) for
    /// the currently selected table element. Paired with the section start
    /// line, this unambiguously identifies the table for the file writer.
    fn calculate_current_table_index(&self) -> Result<usize, String> {
        use crate::parser::content::parse_content;
        use crate::parser::output::Block;

        // Get the current section content to find the right table
        let section_content = self.current_section_content();

        // Parse to find the table block
        let blocks = parse_content(&section_content, 0);

        // Find the block index of the current table element
        if let Some(element) = self.interactive_state.current_element() {
            let block_idx = element.id.block_idx;

            if let Some(Block::Table { .. }) = blocks.get(block_idx) {
                // Count tables before this one in the section
                let tables_before_in_section: usize = blocks[..block_idx]
                    .iter()
                    .filter(|b| matches!(b, Block::Table { .. }))
                    .count();

                return Ok(tables_before_in_section);
            }
        }

        Err("Could not locate table".to_string())
    }

    /// Write all pending edits to the file
    pub fn save_pending_edits_to_file(&mut self) -> Result<(), String> {
        use std::io::Write;

        if !self.has_unsaved_changes {
            self.status_message = Some("No changes to save".to_string());
            return Ok(());
        }

        // Atomic write: write to temp file, then rename (prevents data corruption)
        let parent_dir = self
            .current_file_path
            .parent()
            .ok_or("Cannot determine parent directory")?;

        let mut temp_file = tempfile::NamedTempFile::new_in(parent_dir)
            .map_err(|e| format!("Failed to create temp file: {}", e))?;

        temp_file
            .write_all(self.document.content.as_bytes())
            .map_err(|e| format!("Failed to write temp file: {}", e))?;

        temp_file
            .flush()
            .map_err(|e| format!("Failed to flush temp file: {}", e))?;

        // Suppress file watcher for our own save
        self.suppress_file_watch = true;

        // Atomic rename
        temp_file
            .persist(&self.current_file_path)
            .map_err(|e| format!("Failed to save file: {}", e))?;

        // Clear the pending edits buffer
        let edit_count = self.pending_edits.len();
        self.pending_edits.clear();
        self.has_unsaved_changes = false;

        self.status_message = Some(format!(
            "✓ Saved {} change{} to {}",
            edit_count,
            if edit_count == 1 { "" } else { "s" },
            self.filename
        ));
        Ok(())
    }

    /// Undo the last pending edit
    pub fn undo_last_edit(&mut self) -> Result<(), String> {
        if let Some(edit) = self.pending_edits.pop() {
            // Apply the original value back to the in-memory content
            let new_content = crate::tui::edits::replace_table_cell(
                &self.document.content,
                edit.section_start_line,
                edit.table_index,
                edit.row,
                edit.col,
                &edit.original_value,
            )?;

            self.document.content = new_content;
            self.has_unsaved_changes = !self.pending_edits.is_empty();

            if self.pending_edits.is_empty() {
                self.status_message = Some("✓ Undone - no unsaved changes".to_string());
            } else {
                let remaining = self.pending_edits.len();
                self.status_message = Some(format!(
                    "✓ Undone ({} unsaved change{} remaining)",
                    remaining,
                    if remaining == 1 { "" } else { "s" }
                ));
            }
            Ok(())
        } else {
            self.status_message = Some("Nothing to undo".to_string());
            Ok(())
        }
    }

    /// Resolve an image path relative to the current markdown file.
    ///
    /// Supports both relative and absolute paths:
    /// - Relative paths are resolved against the current file's directory
    /// - Absolute paths are returned as-is
    ///
    /// # Examples
    ///
    /// If current file is `/docs/file.md`:
    /// - `./images/photo.png` → `/docs/images/photo.png`
    /// - `../assets/logo.png` → `/assets/logo.png`
    /// - `/etc/hosts` → `/etc/hosts`
    pub fn resolve_image_path(&self, src: &str) -> Result<std::path::PathBuf, String> {
        let path = std::path::Path::new(src);

        if path.is_absolute() {
            return Ok(path.to_path_buf());
        }

        // Resolve relative to markdown file's directory
        let base_dir = self
            .current_file_path
            .parent()
            .ok_or_else(|| "No parent directory for current file".to_string())?;

        Ok(base_dir.join(src))
    }
}

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

    fn cmd(name: &'static str, aliases: &'static [&'static str]) -> PaletteCommand {
        PaletteCommand::new(name, aliases, "desc", CommandAction::Quit)
    }

    // ---------- ascii-fold helpers ----------

    #[test]
    fn starts_with_ignore_ascii_case_basics() {
        assert!(starts_with_ignore_ascii_case("Quit", "qu"));
        assert!(starts_with_ignore_ascii_case("Quit", "QUIT"));
        assert!(starts_with_ignore_ascii_case("Quit", ""));
        assert!(!starts_with_ignore_ascii_case("Quit", "uit"));
        assert!(!starts_with_ignore_ascii_case("Quit", "quitter"));
    }

    #[test]
    fn contains_ignore_ascii_case_basics() {
        assert!(contains_ignore_ascii_case("Toggle outline", "outline"));
        assert!(contains_ignore_ascii_case("Toggle outline", "OUT"));
        assert!(contains_ignore_ascii_case("anything", "")); // empty needle always matches
        assert!(!contains_ignore_ascii_case("abc", "abcd")); // needle longer than haystack
        assert!(!contains_ignore_ascii_case("hello", "xyz"));
    }

    // ---------- matches() ----------

    #[test]
    fn matches_empty_query_matches_all() {
        let c = cmd("Quit", &["q", "quit"]);
        assert!(c.matches(""));
    }

    #[test]
    fn matches_name_substring_case_insensitive() {
        let c = cmd("Toggle outline", &["outline"]);
        assert!(c.matches("toggle"));
        assert!(c.matches("OUT"));
        assert!(c.matches("line"));
    }

    #[test]
    fn matches_alias_prefix() {
        let c = cmd("Save changes", &["w", "write", "save"]);
        // alias-prefix path: "wri" is a prefix of "write"
        assert!(c.matches("wri"));
        // but "rite" isn't a prefix of any alias and doesn't fuzzy-match name
        assert!(!c.matches("rite"));
    }

    #[test]
    fn matches_fuzzy_in_name() {
        // "tgo" appears in order in "toggle outline" → t...o...g...
        let c = cmd("Toggle outline", &["outline"]);
        assert!(c.matches("tgo"));
        // chars not in order should fail
        assert!(!c.matches("zzz"));
    }

    #[test]
    fn matches_fuzzy_requires_in_order() {
        let c = cmd("Save changes", &["save"]);
        // 's', 'a', 'v', 'e' are present in order
        assert!(c.matches("sve"));
        // 'e' before 's' is not in order in "save changes"
        // ('e' first appears after 's', so this should still match — pick a real reverse)
        assert!(!c.matches("xq"));
    }

    // ---------- match_score() ----------

    #[test]
    fn score_empty_query_is_baseline() {
        let c = cmd("Quit", &["q", "quit"]);
        assert_eq!(c.match_score(""), 100);
    }

    #[test]
    fn score_exact_alias_is_highest() {
        let c = cmd("Quit", &["q", "quit", "exit"]);
        assert_eq!(c.match_score("q"), 1000);
        assert_eq!(c.match_score("quit"), 1000);
        assert_eq!(c.match_score("EXIT"), 1000); // case-insensitive
    }

    #[test]
    fn score_alias_prefix_beats_name() {
        let c = cmd("Save changes", &["w", "write", "save"]);
        // "wri" prefixes "write" → 500, not 300/200
        assert_eq!(c.match_score("wri"), 500);
    }

    #[test]
    fn score_name_starts_with_beats_contains() {
        let c = cmd("Toggle outline", &["sidebar"]);
        // "togg" is a prefix of name (no alias match)
        assert_eq!(c.match_score("togg"), 300);
    }

    #[test]
    fn score_name_contains_below_starts_with() {
        let c = cmd("Toggle outline", &["sidebar"]);
        // "outline" is contained but not a prefix of "Toggle outline"
        assert_eq!(c.match_score("outline"), 200);
    }

    #[test]
    fn score_fuzzy_only_match_is_baseline() {
        let c = cmd("Toggle outline", &["sidebar"]);
        // "tgo" matches via fuzzy (in matches()) but doesn't hit any score tier
        // above baseline — match_score has no fuzzy tier, so it falls through to 100.
        assert_eq!(c.match_score("tgo"), 100);
    }

    #[test]
    fn score_priority_ordering() {
        // Build commands that each hit a different tier and confirm relative order.
        let exact_alias = cmd("Anything", &["xx"]);
        let alias_prefix = cmd("Anything", &["xxlong"]);
        let name_prefix = cmd("XxName", &["other"]);
        let name_contains = cmd("Has Xx Inside", &["other"]);

        let q = "xx";
        let s1 = exact_alias.match_score(q);
        let s2 = alias_prefix.match_score(q);
        let s3 = name_prefix.match_score(q);
        let s4 = name_contains.match_score(q);

        assert!(s1 > s2, "exact alias should beat alias prefix");
        assert!(s2 > s3, "alias prefix should beat name prefix");
        assert!(s3 > s4, "name prefix should beat name contains");
    }

    // ---------- registered palette commands ----------
    // Sanity-check the actual PALETTE_COMMANDS table — these are the commands
    // users actually type, so it's worth pinning their behavior.

    #[test]
    fn registered_aliases_are_exact_matchable() {
        for cmd in PALETTE_COMMANDS {
            for alias in cmd.aliases {
                let lower = alias.to_lowercase();
                assert_eq!(
                    cmd.match_score(&lower),
                    1000,
                    "alias {:?} of {:?} should be exact-match",
                    alias,
                    cmd.name
                );
            }
        }
    }

    #[test]
    fn registered_quit_command_resolves() {
        // Typing "q" should pick the Quit command as the top score.
        let q = "q";
        let best = PALETTE_COMMANDS
            .iter()
            .max_by_key(|c| c.match_score(q))
            .expect("non-empty");
        assert_eq!(best.action, CommandAction::Quit);
    }
}

#[cfg(test)]
mod image_picker_tests {
    use super::*;
    use ratatui_image::picker::{Picker, ProtocolType};

    #[test]
    fn env_protocol_fallback_is_allowed_for_empty_halfblock_picker() {
        let picker = Picker::halfblocks();

        assert!(App::should_use_env_protocol_fallback(&picker));
    }

    #[test]
    fn env_protocol_fallback_does_not_override_image_protocol() {
        let mut picker = Picker::halfblocks();
        picker.set_protocol_type(ProtocolType::Kitty);

        assert!(!App::should_use_env_protocol_fallback(&picker));
    }
}

#[cfg(test)]
mod outline_integration_tests {
    use super::*;
    use crate::Document;
    use crate::parser::parse_markdown;
    use std::collections::HashSet;

    fn make_doc(content: &str) -> Document {
        parse_markdown(content)
    }

    #[test]
    fn duplicate_headings_resolve_to_correct_content() {
        let content = "\
# Chapter 1
chapter 1 body

## Details
details for chapter 1

# Chapter 2
chapter 2 body

## Details
details for chapter 2
";
        let doc = make_doc(content);
        let tree = doc.build_tree();
        let items = App::flatten_tree(&tree, &HashSet::new());

        assert_eq!(items.len(), 4);
        assert_eq!(items[1].text, "Details");
        assert_eq!(items[3].text, "Details");
        assert_ne!(items[1].heading_index, items[3].heading_index);

        let first = doc
            .extract_section_at_index(items[1].heading_index.unwrap())
            .unwrap();
        assert!(first.contains("details for chapter 1"));
        assert!(!first.contains("details for chapter 2"));

        let second = doc
            .extract_section_at_index(items[3].heading_index.unwrap())
            .unwrap();
        assert!(second.contains("details for chapter 2"));
        assert!(!second.contains("details for chapter 1"));
    }

    #[test]
    fn collapsed_parent_hides_children_but_indices_stay_correct() {
        let content = "\
# A
body a

## Sub A
sub a content

# B
body b

## Sub B
sub b content
";
        let doc = make_doc(content);
        let tree = doc.build_tree();

        let mut collapsed = HashSet::new();
        collapsed.insert("A".to_string());
        let items = App::flatten_tree(&tree, &collapsed);

        // Sub A hidden, Sub B still accessible
        assert_eq!(items.len(), 3);
        assert_eq!(items[2].text, "Sub B");

        let sub_b = doc
            .extract_section_at_index(items[2].heading_index.unwrap())
            .unwrap();
        assert!(sub_b.contains("sub b content"));
        assert!(!sub_b.contains("sub a content"));
    }
}
