use std::collections::HashSet;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::time::{Duration, Instant};

use crossterm::{
    cursor::{Hide, MoveTo, Show},
    event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
    execute, queue,
    style::{Attribute, Color, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor},
    terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};

#[cfg(not(target_os = "windows"))]
use crossterm::event::{
    KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};

use super::command::{FilterMode, HostFilter};
use super::config::{KeymapMode, PreviewConfig, RecallConfig};
use super::engine::{HistoryEntry, SearchEngine, format_relative_time};

const SCROLL_MARGIN: usize = 5;

/// Base64-encode bytes for OSC 52 clipboard escape.
fn base64_encode(input: &[u8]) -> String {
    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut output = String::with_capacity(input.len().div_ceil(3) * 4);
    for chunk in input.chunks(3) {
        let b0 = chunk[0] as u32;
        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
        let triple = (b0 << 16) | (b1 << 8) | b2;
        output.push(ALPHABET[(triple >> 18 & 0x3F) as usize] as char);
        output.push(ALPHABET[(triple >> 12 & 0x3F) as usize] as char);
        if chunk.len() > 1 {
            output.push(ALPHABET[(triple >> 6 & 0x3F) as usize] as char);
        } else {
            output.push('=');
        }
        if chunk.len() > 2 {
            output.push(ALPHABET[(triple & 0x3F) as usize] as char);
        } else {
            output.push('=');
        }
    }
    output
}

/// Sanitize a string for safe terminal display by removing ANSI escape sequences
/// and control characters that could affect cursor position or terminal state.
fn sanitize_for_display(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    let mut chars = s.chars().peekable();

    while let Some(c) = chars.next() {
        match c {
            '\x1b' => {
                match chars.peek() {
                    // CSI: \x1b[ ... letter
                    Some(&'[') => {
                        chars.next();
                        while let Some(&c) = chars.peek() {
                            chars.next();
                            if c.is_ascii_alphabetic() {
                                break;
                            }
                        }
                    }
                    // OSC: \x1b] ... (BEL or ST)
                    Some(&']') => {
                        chars.next();
                        while let Some(c) = chars.next() {
                            if c == '\x07' {
                                break;
                            }
                            if c == '\x1b' && chars.peek() == Some(&'\\') {
                                chars.next();
                                break;
                            }
                        }
                    }
                    // DCS: \x1bP ... ST (\x1b\\)
                    Some(&'P') => {
                        chars.next();
                        while let Some(c) = chars.next() {
                            if c == '\x1b' && chars.peek() == Some(&'\\') {
                                chars.next();
                                break;
                            }
                        }
                    }
                    // Any other ESC + char: skip one char (SS2, SS3, etc.)
                    Some(_) => {
                        chars.next();
                    }
                    None => {}
                }
            }
            '\n' | '\r' => result.push(' '),
            '\x00'..='\x08' | '\x0b'..='\x0c' | '\x0e'..='\x1f' | '\x7f' => {}
            '\t' => result.push(' '),
            // C1 controls (U+0080-U+009F) include CSI (U+009B) and OSC (U+009D)
            // which can inject terminal commands in 8-bit terminal modes
            '\u{0080}'..='\u{009F}' => {}
            _ => result.push(c),
        }
    }

    result
}

const PREVIEW_HEIGHT: usize = 5; // Height of preview pane in lines
const FLASH_DURATION_MS: u64 = 100; // Duration of visual flash in milliseconds

/// Deduplicate history entries by command string, keeping the most recent occurrence.
/// Entries are already sorted by timestamp (most recent first), so we keep the first
/// occurrence of each command. Trailing whitespace is ignored for dedup since
/// "df -h" and "df -h " are visually identical and semantically the same.
fn deduplicate_entries(entries: Vec<HistoryEntry>) -> Vec<HistoryEntry> {
    let mut seen = HashSet::new();
    entries.into_iter().filter(|e| seen.insert(e.command.trim_end().to_string())).collect()
}

/// Highlight matching portions of a command for display (substring matching).
/// Returns spans with (text, is_highlight) pairs.
/// Only used in tests now - fuzzy matching uses highlight_command_with_indices.
#[cfg(test)]
fn highlight_command(cmd: &str, query: &str, max_len: usize) -> Vec<(String, bool)> {
    if query.is_empty() {
        let truncated = if cmd.chars().count() > max_len {
            let t: String = cmd.chars().take(max_len.saturating_sub(3)).collect();
            format!("{t}...")
        } else {
            cmd.to_string()
        };
        return vec![(truncated, false)];
    }

    let cmd_lower = cmd.to_lowercase();
    let query_lower = query.to_lowercase();
    let mut spans = Vec::new();
    let mut pos = 0;
    let cmd_chars: Vec<char> = cmd.chars().collect();
    let mut total_len = 0;

    while let Some(match_start) = cmd_lower[pos..].find(&query_lower) {
        let abs_start = pos + match_start;
        let abs_end = abs_start + query_lower.len();

        // Add non-matching text before this match
        if abs_start > pos {
            let text: String = cmd_chars
                [char_pos_from_byte(cmd, pos)..char_pos_from_byte(cmd, abs_start)]
                .iter()
                .collect();
            let text_len = text.chars().count();
            if total_len + text_len > max_len.saturating_sub(3) {
                let remaining = max_len.saturating_sub(3).saturating_sub(total_len);
                let truncated: String = text.chars().take(remaining).collect();
                spans.push((truncated, false));
                spans.push(("...".to_string(), false));
                return spans;
            }
            total_len += text_len;
            spans.push((text, false));
        }

        // Add matching text
        let match_text: String = cmd_chars
            [char_pos_from_byte(cmd, abs_start)..char_pos_from_byte(cmd, abs_end)]
            .iter()
            .collect();
        let match_len = match_text.chars().count();
        if total_len + match_len > max_len.saturating_sub(3) {
            let remaining = max_len.saturating_sub(3).saturating_sub(total_len);
            let truncated: String = match_text.chars().take(remaining).collect();
            spans.push((truncated, true));
            spans.push(("...".to_string(), false));
            return spans;
        }
        total_len += match_len;
        spans.push((match_text, true));

        pos = abs_end;
    }

    // Add remaining text after last match
    if pos < cmd.len() {
        let text: String = cmd_chars[char_pos_from_byte(cmd, pos)..].iter().collect();
        let text_len = text.chars().count();
        if total_len + text_len > max_len {
            let remaining = max_len.saturating_sub(3).saturating_sub(total_len);
            let truncated: String = text.chars().take(remaining).collect();
            spans.push((truncated, false));
            spans.push(("...".to_string(), false));
        } else {
            spans.push((text, false));
        }
    }

    spans
}

/// Convert byte position to char position in a string.
#[cfg(test)]
fn char_pos_from_byte(s: &str, byte_pos: usize) -> usize {
    s[..byte_pos].chars().count()
}

/// Highlight a command using pre-computed match indices from fuzzy matching.
/// Returns spans with (text, is_highlight) pairs.
fn highlight_command_with_indices(
    cmd: &str,
    match_indices: &[u32],
    max_len: usize,
) -> Vec<(String, bool)> {
    if cmd.is_empty() {
        return vec![];
    }

    // Build a set of matched character positions for O(1) lookup
    let match_set: HashSet<u32> = match_indices.iter().copied().collect();

    let chars: Vec<char> = cmd.chars().collect();
    let mut spans = Vec::new();
    let mut current_span = String::new();
    let mut current_is_match = false;

    for (i, &c) in chars.iter().enumerate() {
        // Truncate early to leave room for "..." suffix
        if i >= max_len.saturating_sub(3) {
            // Truncate with ellipsis
            if !current_span.is_empty() {
                spans.push((current_span, current_is_match));
            }
            spans.push(("...".to_string(), false));
            return spans;
        }

        let is_match = match_set.contains(&(i as u32));

        if is_match != current_is_match && !current_span.is_empty() {
            spans.push((std::mem::take(&mut current_span), current_is_match));
        }

        current_is_match = is_match;
        current_span.push(c);
    }

    if !current_span.is_empty() {
        spans.push((current_span, current_is_match));
    }

    spans
}

fn format_duration(secs: i64) -> String {
    if secs < 60 {
        format!("{secs}s")
    } else if secs < 3600 {
        format!("{}m {}s", secs / 60, secs % 60)
    } else {
        format!("{}h {}m {}s", secs / 3600, (secs % 3600) / 60, secs % 60)
    }
}

pub struct RecallTui {
    engine: SearchEngine,
    filter_mode: FilterMode,
    host_filter: HostFilter,
    entries: Vec<HistoryEntry>,
    /// Filtered indices with match positions: (entry_index, match_char_indices)
    filtered_indices: Vec<(usize, Vec<u32>)>,
    query: String,
    selected_index: usize,
    scroll_offset: usize, // Index of entry at top of visible area
    tty: File,
    term_height: u16,
    term_width: u16,
    keymap_mode: KeymapMode,
    show_preview: bool,
    preview_config: PreviewConfig,
    shell_mode: bool, // When true, outputs command for shell execution; when false, prints details
    db_query_used: Option<String>, // Tracks the query passed to the last DB load
    flash_until: Option<Instant>, // For visual feedback on unrecognized keys
    status_message: Option<(String, Instant)>,
    needs_redraw: bool,
    #[cfg(not(target_os = "windows"))]
    keyboard_enhanced: bool,
}

impl RecallTui {
    pub fn new(
        mut engine: SearchEngine,
        initial_mode: FilterMode,
        initial_query: Option<String>,
        config: &RecallConfig,
        shell_mode: bool,
    ) -> Result<Self, Box<dyn std::error::Error>> {
        let query = initial_query.as_deref().unwrap_or("").to_string();
        let host_filter = HostFilter::default();
        let db_query_used = if query.len() >= 3 { Some(query.clone()) } else { None };
        let mut entries = deduplicate_entries(engine.load_entries(
            initial_mode,
            host_filter,
            db_query_used.as_deref(),
        )?);
        entries.truncate(engine.result_limit());

        terminal::enable_raw_mode()?;
        let mut tty = File::options().read(true).write(true).open("/dev/tty")?;

        // Enable keyboard enhancement for instant Escape key response (non-Windows)
        #[cfg(not(target_os = "windows"))]
        let keyboard_enhanced = execute!(
            tty,
            PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
        )
        .is_ok();

        execute!(
            tty,
            EnterAlternateScreen,
            Hide,
            Clear(ClearType::All),
            Clear(ClearType::Purge),
            MoveTo(0, 0)
        )?;
        tty.flush()?;

        let (term_width, term_height) = terminal::size()?;

        // Apply initial fuzzy filtering
        let filtered_indices = if query.is_empty() {
            (0..entries.len()).map(|i| (i, Vec::new())).collect()
        } else {
            engine.filter_entries(&entries, &query)
        };

        let mut tui = RecallTui {
            engine,
            filter_mode: initial_mode,
            host_filter,
            entries,
            filtered_indices,
            query,
            selected_index: 0,
            scroll_offset: 0,
            tty,
            term_height,
            term_width,
            keymap_mode: config.initial_keymap_mode(),
            show_preview: config.show_preview,
            preview_config: config.preview.clone(),
            shell_mode,
            db_query_used,
            flash_until: None,
            status_message: None,
            needs_redraw: true,
            #[cfg(not(target_os = "windows"))]
            keyboard_enhanced,
        };

        tui.adjust_scroll_for_selection();
        Ok(tui)
    }

    fn results_height(&self) -> usize {
        let base = self.term_height.saturating_sub(2) as usize;
        if self.show_preview { base.saturating_sub(PREVIEW_HEIGHT) } else { base }
    }

    fn adjust_scroll_for_selection(&mut self) {
        let results_height = self.results_height();
        if results_height == 0 || self.filtered_indices.is_empty() {
            self.scroll_offset = 0;
            return;
        }

        // In our layout, entry 0 (most recent) is at the bottom visually.
        // scroll_offset is the entry index shown at the TOP of the visible area.
        // Higher scroll_offset means we're showing older entries.
        //
        // Visible range: scroll_offset.saturating_sub(results_height - 1) to scroll_offset
        // But actually, let's think of it differently:
        //   - The bottom of the visible area shows entry index `bottom_visible`
        //   - The top shows entry index `bottom_visible + results_height - 1`
        //
        // Let's use `view_bottom` as the entry index shown at the bottom of results area.
        // Visible entries: view_bottom to view_bottom + results_height - 1

        // Calculate current view bounds based on scroll_offset
        // scroll_offset represents the entry at the bottom of the visible area
        let view_bottom = self.scroll_offset;
        let view_top = view_bottom + results_height.saturating_sub(1);

        // Check if selected is within the visible range with margins
        if self.selected_index < view_bottom + SCROLL_MARGIN {
            // Selection is too close to bottom, scroll down (show newer entries)
            self.scroll_offset = self.selected_index.saturating_sub(SCROLL_MARGIN);
        } else if self.selected_index > view_top.saturating_sub(SCROLL_MARGIN) {
            // Selection is too close to top, scroll up (show older entries)
            let new_view_top = self.selected_index + SCROLL_MARGIN;
            self.scroll_offset = new_view_top.saturating_sub(results_height.saturating_sub(1));
        }

        // Clamp scroll_offset to valid range
        let max_scroll = self.filtered_indices.len().saturating_sub(results_height);
        self.scroll_offset = self.scroll_offset.min(max_scroll);
    }

    /// Reload entries from database and re-apply fuzzy filtering.
    /// Called when mode (directory/global) or host filter changes.
    /// Always loads the broad set (no query filter at DB level).
    fn reload_entries(&mut self) {
        match self.engine.load_entries(self.filter_mode, self.host_filter, None) {
            Ok(entries) => {
                let mut deduped = deduplicate_entries(entries);
                deduped.truncate(self.engine.result_limit());
                self.entries = deduped;
                self.db_query_used = None;
            }
            Err(e) => {
                eprintln!("pxh recall: failed to reload entries: {e}");
                self.flash();
                return;
            }
        }
        self.update_filtered_indices();
    }

    /// Apply fuzzy filtering to the current entries based on query.
    /// For queries >= 3 chars, uses a DB-level LIKE filter to ensure old
    /// commands beyond the initial result_limit window are included.
    /// Only re-queries when the loaded set might not cover the current query.
    fn update_filtered_indices(&mut self) {
        if self.query.len() >= 3 {
            // Check if the loaded entries already cover this query. They do when
            // the current query extends a previous DB query (LIKE '%abc%' is a
            // superset of LIKE '%abcd%'), so client-side fuzzy suffices.
            let needs_db_reload = match &self.db_query_used {
                None => true,
                Some(prev) => !self.query.starts_with(prev.as_str()),
            };
            if needs_db_reload {
                let query = self.query.clone();
                match self.engine.load_entries(self.filter_mode, self.host_filter, Some(&query)) {
                    Ok(entries) => {
                        let mut deduped = deduplicate_entries(entries);
                        deduped.truncate(self.engine.result_limit());
                        self.entries = deduped;
                        self.db_query_used = Some(query);
                    }
                    Err(e) => {
                        eprintln!("pxh recall: failed to reload entries: {e}");
                        self.flash();
                    }
                }
            }
        } else if self.db_query_used.is_some() {
            // Query shortened below threshold -- restore broad entry set
            self.reload_entries();
            return;
        }

        if self.query.is_empty() {
            self.filtered_indices = (0..self.entries.len()).map(|i| (i, Vec::new())).collect();
        } else {
            self.filtered_indices = self.compute_filtered_indices();
        }

        if self.selected_index >= self.filtered_indices.len() {
            self.selected_index = 0;
        }
        self.adjust_scroll_for_selection();
    }

    fn compute_filtered_indices(&mut self) -> Vec<(usize, Vec<u32>)> {
        self.engine.filter_entries(&self.entries, &self.query)
    }

    /// Trigger a brief visual flash for feedback on unrecognized keys
    fn flash(&mut self) {
        self.flash_until = Some(Instant::now() + Duration::from_millis(FLASH_DURATION_MS));
    }

    /// Check if flash effect is currently active
    fn is_flashing(&self) -> bool {
        self.flash_until.is_some_and(|until| Instant::now() < until)
    }

    /// Copy the selected command to clipboard via OSC 52 escape sequence.
    fn copy_to_clipboard(&mut self) {
        if let Some(cmd) = self.get_selected_command() {
            let encoded = base64_encode(cmd.as_bytes());
            let _ = write!(self.tty, "\x1b]52;c;{encoded}\x07");
            let _ = self.tty.flush();
            self.status_message =
                Some(("(copied)".to_string(), Instant::now() + Duration::from_secs(1)));
        }
    }

    /// Delete all database rows matching the selected entry's command text,
    /// since the recall list is deduplicated by command.
    fn delete_selected_entry(&mut self) {
        let Some(&(entry_idx, _)) = self.filtered_indices.get(self.selected_index) else {
            return;
        };
        let command = self.entries[entry_idx].command.clone();
        if let Ok(n) = self.engine.delete_entries_by_command(&command) {
            self.entries.remove(entry_idx);
            // Rebuild filtered indices -- adjust all indices >= entry_idx
            self.filtered_indices.retain_mut(|(idx, _)| {
                if *idx == entry_idx {
                    return false;
                }
                if *idx > entry_idx {
                    *idx -= 1;
                }
                true
            });
            if self.selected_index >= self.filtered_indices.len() && self.selected_index > 0 {
                self.selected_index -= 1;
            }
            self.status_message =
                Some((format!("(deleted {n})"), Instant::now() + Duration::from_secs(1)));
        }
    }

    fn toggle_host_filter(&mut self) {
        self.host_filter = match self.host_filter {
            HostFilter::ThisHost => HostFilter::AllHosts,
            HostFilter::AllHosts => HostFilter::ThisHost,
        };
        self.reload_entries();
    }

    fn toggle_filter_mode(&mut self) {
        self.filter_mode = match self.filter_mode {
            FilterMode::Directory => FilterMode::Global,
            FilterMode::Global => FilterMode::Directory,
        };
        self.reload_entries();
    }

    pub fn run(&mut self) -> Result<Option<String>, Box<dyn std::error::Error>> {
        loop {
            // Check if timed effects have expired
            if let Some((_, until)) = self.status_message
                && Instant::now() >= until
            {
                self.status_message = None;
                self.needs_redraw = true;
            }
            if let Some(until) = self.flash_until
                && Instant::now() >= until
            {
                self.flash_until = None;
                self.needs_redraw = true;
            }

            if self.needs_redraw {
                self.draw()?;
                self.needs_redraw = false;
            }

            if !event::poll(Duration::from_millis(100))? {
                continue;
            }

            match event::read()? {
                Event::Key(key) => {
                    self.needs_redraw = true;
                    let action = self.handle_key(key)?;
                    match action {
                        KeyAction::Continue => continue,
                        KeyAction::Select | KeyAction::Edit | KeyAction::EditBeginning => {
                            self.cleanup()?;
                            if !self.shell_mode {
                                self.print_entry_details();
                                return Ok(None);
                            }
                            let prefix = match action {
                                KeyAction::Select => "run",
                                KeyAction::EditBeginning => "edit-a",
                                _ => "edit",
                            };
                            let result =
                                self.get_selected_command().map(|cmd| format!("{prefix}:{cmd}"));
                            return Ok(result);
                        }
                        KeyAction::Cancel => {
                            self.cleanup()?;
                            return Ok(None);
                        }
                    }
                }
                Event::Resize(_, _) => {
                    self.needs_redraw = true;
                }
                _ => {}
            }
        }
    }

    fn print_entry_details(&self) {
        let Some(entry) = self
            .filtered_indices
            .get(self.selected_index)
            .and_then(|(idx, _)| self.entries.get(*idx))
        else {
            return;
        };

        let mut stdout = std::io::stdout();

        // Command (bold)
        let _ = execute!(stdout, SetAttribute(Attribute::Bold));
        println!("{}", sanitize_for_display(&entry.command));
        let _ = execute!(stdout, SetAttribute(Attribute::Reset));

        // Timestamp
        if let Some(ts) = entry.timestamp {
            let datetime = chrono::DateTime::from_timestamp(ts, 0)
                .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
                .unwrap_or_else(|| "?".to_string());
            let relative = format_relative_time(Some(ts));
            let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
            print!("  Time: ");
            let _ = execute!(stdout, ResetColor);
            println!("{datetime} ({relative} ago)");
        }

        // Directory
        if let Some(ref dir) = entry.working_directory {
            let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
            print!("   Dir: ");
            let _ = execute!(stdout, ResetColor);
            println!("{}", sanitize_for_display(&String::from_utf8_lossy(dir)));
        }

        // Exit status
        if let Some(status) = entry.exit_status {
            let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
            print!("Status: ");
            let _ = execute!(stdout, ResetColor);
            if status == 0 {
                let _ = execute!(stdout, SetForegroundColor(Color::Green));
                println!("0 (success)");
            } else {
                let _ = execute!(stdout, SetForegroundColor(Color::Red));
                println!("{status} (error)");
            }
            let _ = execute!(stdout, ResetColor);
        }

        // Duration
        if let Some(secs) = entry.duration_secs {
            let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
            print!("  Took: ");
            let _ = execute!(stdout, ResetColor);
            println!("{}", format_duration(secs));
        }

        // Hostname
        if let Some(ref host) = entry.hostname {
            let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
            print!("  Host: ");
            let _ = execute!(stdout, ResetColor);
            println!("{}", sanitize_for_display(&String::from_utf8_lossy(host)));
        }
    }

    /// Draw once and exit (for profiling)
    pub fn draw_once(&mut self) -> Result<(), Box<dyn std::error::Error>> {
        self.draw()?;
        self.cleanup()?;
        Ok(())
    }

    fn cleanup(&mut self) -> Result<(), Box<dyn std::error::Error>> {
        #[cfg(not(target_os = "windows"))]
        if self.keyboard_enhanced {
            let _ = execute!(self.tty, PopKeyboardEnhancementFlags);
        }
        execute!(self.tty, Show, LeaveAlternateScreen)?;
        terminal::disable_raw_mode()?;
        Ok(())
    }

    fn get_selected_command(&self) -> Option<String> {
        self.filtered_indices
            .get(self.selected_index)
            .and_then(|(idx, _)| self.entries.get(*idx))
            .map(|e| e.command.clone())
    }

    fn handle_key(&mut self, key: KeyEvent) -> Result<KeyAction, Box<dyn std::error::Error>> {
        match self.keymap_mode {
            KeymapMode::Emacs => self.handle_key_emacs(key),
            KeymapMode::VimInsert => self.handle_key_vim_insert(key),
            KeymapMode::VimNormal => self.handle_key_vim_normal(key),
        }
    }

    /// Handle common keys that work in all modes
    fn handle_common_key(&mut self, key: KeyEvent) -> Option<KeyAction> {
        match key.code {
            KeyCode::Enter => Some(KeyAction::Select),
            KeyCode::Tab => Some(KeyAction::Edit),
            KeyCode::Char('c' | 'd') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                Some(KeyAction::Cancel)
            }
            KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.move_selection_up();
                Some(KeyAction::Continue)
            }
            KeyCode::Up => {
                self.move_selection_up();
                Some(KeyAction::Continue)
            }
            KeyCode::Down => {
                self.move_selection_down();
                Some(KeyAction::Continue)
            }
            KeyCode::PageUp => {
                self.page_up();
                Some(KeyAction::Continue)
            }
            KeyCode::PageDown => {
                self.page_down();
                Some(KeyAction::Continue)
            }
            KeyCode::Char(c @ '1'..='9') if key.modifiers.contains(KeyModifiers::ALT) => {
                let num = c.to_digit(10).unwrap() as usize;
                // Alt-1 selects current, Alt-2 selects next older, etc.
                let target_index = self.selected_index + (num - 1);
                if target_index < self.filtered_indices.len() {
                    self.selected_index = target_index;
                    return Some(KeyAction::Select);
                }
                Some(KeyAction::Continue)
            }
            KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.toggle_host_filter();
                Some(KeyAction::Continue)
            }
            KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.toggle_filter_mode();
                Some(KeyAction::Continue)
            }
            KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                Some(KeyAction::Edit)
            }
            KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                Some(KeyAction::EditBeginning)
            }
            KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.copy_to_clipboard();
                Some(KeyAction::Continue)
            }
            KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.delete_selected_entry();
                Some(KeyAction::Continue)
            }
            _ => None,
        }
    }

    fn handle_key_emacs(&mut self, key: KeyEvent) -> Result<KeyAction, Box<dyn std::error::Error>> {
        // Check common keys first
        if let Some(action) = self.handle_common_key(key) {
            return Ok(action);
        }

        match key.code {
            KeyCode::Esc => Ok(KeyAction::Cancel),
            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.move_selection_up();
                Ok(KeyAction::Continue)
            }
            KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.move_selection_down();
                Ok(KeyAction::Continue)
            }
            KeyCode::Backspace => {
                self.delete_last_char();
                Ok(KeyAction::Continue)
            }
            KeyCode::Right => Ok(KeyAction::Edit),
            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.clear_query();
                Ok(KeyAction::Continue)
            }
            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.delete_last_word();
                Ok(KeyAction::Continue)
            }
            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::ALT) => {
                self.copy_to_clipboard();
                Ok(KeyAction::Continue)
            }
            KeyCode::Char(_) if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.flash();
                Ok(KeyAction::Continue)
            }
            KeyCode::Char(c) => {
                self.insert_char(c);
                Ok(KeyAction::Continue)
            }
            _ => Ok(KeyAction::Continue),
        }
    }

    fn handle_key_vim_insert(
        &mut self,
        key: KeyEvent,
    ) -> Result<KeyAction, Box<dyn std::error::Error>> {
        // Check common keys first
        if let Some(action) = self.handle_common_key(key) {
            return Ok(action);
        }

        match key.code {
            KeyCode::Esc => {
                self.keymap_mode = KeymapMode::VimNormal;
                Ok(KeyAction::Continue)
            }
            KeyCode::Backspace => {
                self.delete_last_char();
                Ok(KeyAction::Continue)
            }
            KeyCode::Right => Ok(KeyAction::Edit),
            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.clear_query();
                Ok(KeyAction::Continue)
            }
            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.delete_last_word();
                Ok(KeyAction::Continue)
            }
            KeyCode::Char(_) if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.flash();
                Ok(KeyAction::Continue)
            }
            KeyCode::Char(c) => {
                self.insert_char(c);
                Ok(KeyAction::Continue)
            }
            _ => Ok(KeyAction::Continue),
        }
    }

    fn handle_key_vim_normal(
        &mut self,
        key: KeyEvent,
    ) -> Result<KeyAction, Box<dyn std::error::Error>> {
        // Check common keys first
        if let Some(action) = self.handle_common_key(key) {
            return Ok(action);
        }

        match key.code {
            KeyCode::Esc => Ok(KeyAction::Cancel),
            KeyCode::Char('j') => {
                self.move_selection_down();
                Ok(KeyAction::Continue)
            }
            KeyCode::Char('k') => {
                self.move_selection_up();
                Ok(KeyAction::Continue)
            }
            KeyCode::Char('l') | KeyCode::Right => Ok(KeyAction::Edit),
            KeyCode::Char('i' | 'a' | 'A' | 'I') => {
                self.keymap_mode = KeymapMode::VimInsert;
                Ok(KeyAction::Continue)
            }
            KeyCode::Char('x' | 'X') => {
                self.delete_last_char();
                Ok(KeyAction::Continue)
            }
            KeyCode::Char(_) if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.flash();
                Ok(KeyAction::Continue)
            }
            _ => Ok(KeyAction::Continue),
        }
    }

    // Helper methods for selection movement and query editing

    fn move_selection_up(&mut self) {
        if self.selected_index + 1 < self.filtered_indices.len() {
            self.selected_index += 1;
            self.adjust_scroll_for_selection();
        }
    }

    fn move_selection_down(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
            self.adjust_scroll_for_selection();
        }
    }

    fn page_up(&mut self) {
        let page = self.results_height().saturating_sub(2);
        let max_index = self.filtered_indices.len().saturating_sub(1);
        self.selected_index = (self.selected_index + page).min(max_index);
        self.adjust_scroll_for_selection();
    }

    fn page_down(&mut self) {
        let page = self.results_height().saturating_sub(2);
        self.selected_index = self.selected_index.saturating_sub(page);
        self.adjust_scroll_for_selection();
    }

    fn insert_char(&mut self, c: char) {
        self.query.push(c);
        self.update_filtered_indices();
    }

    fn delete_last_char(&mut self) {
        if self.query.pop().is_some() {
            self.update_filtered_indices();
        }
    }

    fn clear_query(&mut self) {
        if !self.query.is_empty() {
            self.query.clear();
            self.update_filtered_indices();
        }
    }

    fn delete_last_word(&mut self) {
        if !self.query.is_empty() {
            let trimmed_len = self.query.trim_end().len();
            let word_start = self.query[..trimmed_len]
                .char_indices()
                .rev()
                .find(|(_, c)| c.is_whitespace())
                .map(|(i, c)| i + c.len_utf8())
                .unwrap_or(0);
            self.query.truncate(word_start);
            self.update_filtered_indices();
        }
    }

    fn draw_preview<W: Write>(
        &self,
        w: &mut W,
        start_y: u16,
        width: u16,
    ) -> Result<(), Box<dyn std::error::Error>> {
        // Get the selected entry
        let entry = self
            .filtered_indices
            .get(self.selected_index)
            .and_then(|(idx, _)| self.entries.get(*idx));

        // Draw separator line
        queue!(w, MoveTo(0, start_y), Clear(ClearType::CurrentLine))?;
        queue!(w, SetForegroundColor(Color::DarkGrey))?;
        write!(w, "{}", "─".repeat(width as usize))?;
        queue!(w, ResetColor)?;

        // If no entry selected, clear the rest and return
        let Some(entry) = entry else {
            for row in 1..PREVIEW_HEIGHT {
                queue!(w, MoveTo(0, start_y + row as u16), Clear(ClearType::CurrentLine))?;
            }
            return Ok(());
        };

        // Line 1: Full command (can truncate)
        queue!(w, MoveTo(0, start_y + 1), Clear(ClearType::CurrentLine))?;
        let safe_cmd = sanitize_for_display(&entry.command);
        let cmd_display: String = if safe_cmd.chars().count() > width as usize - 2 {
            let truncated: String = safe_cmd.chars().take(width as usize - 5).collect();
            format!("{truncated}...")
        } else {
            safe_cmd
        };
        write!(w, "  {cmd_display}")?;

        // Line 2: Directory and timestamp
        queue!(w, MoveTo(0, start_y + 2), Clear(ClearType::CurrentLine))?;
        let mut info_parts: Vec<String> = Vec::new();

        if self.preview_config.show_directory
            && let Some(ref dir) = entry.working_directory
        {
            info_parts
                .push(format!("Dir: {}", sanitize_for_display(&String::from_utf8_lossy(dir))));
        }

        if self.preview_config.show_timestamp
            && let Some(ts) = entry.timestamp
        {
            let datetime = chrono::DateTime::from_timestamp(ts, 0)
                .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
                .unwrap_or_else(|| "?".to_string());
            info_parts.push(format!("Time: {datetime}"));
        }

        queue!(w, SetForegroundColor(Color::DarkGrey))?;
        write!(w, "  {}", info_parts.join("  "))?;
        queue!(w, ResetColor)?;

        // Line 3: Exit status, duration, hostname
        queue!(w, MoveTo(0, start_y + 3), Clear(ClearType::CurrentLine))?;
        let mut status_parts: Vec<String> = Vec::new();

        if self.preview_config.show_exit_status
            && let Some(status) = entry.exit_status
        {
            let status_str = if status == 0 {
                "Status: 0 (ok)".to_string()
            } else {
                format!("Status: {status} (error)")
            };
            status_parts.push(status_str);
        }

        if self.preview_config.show_duration
            && let Some(secs) = entry.duration_secs
        {
            status_parts.push(format!("Duration: {}", format_duration(secs)));
        }

        if self.preview_config.show_hostname
            && let Some(ref host) = entry.hostname
        {
            status_parts
                .push(format!("Host: {}", sanitize_for_display(&String::from_utf8_lossy(host))));
        }

        queue!(w, SetForegroundColor(Color::DarkGrey))?;
        write!(w, "  {}", status_parts.join("  "))?;
        queue!(w, ResetColor)?;

        // Line 4: Bottom separator (blank or separator)
        queue!(w, MoveTo(0, start_y + 4), Clear(ClearType::CurrentLine))?;

        Ok(())
    }

    fn draw(&mut self) -> Result<(), Box<dyn std::error::Error>> {
        // Get fresh terminal size each frame
        let (term_width, term_height) = terminal::size()?;
        self.term_width = term_width;
        self.term_height = term_height;

        let results_height = self.results_height();
        let preview_start_y = results_height as u16;
        let input_y = term_height.saturating_sub(2);
        let help_y = term_height.saturating_sub(1);

        // Use buffered writer to batch all terminal writes into a single syscall
        let mut w = BufWriter::new(&self.tty);

        // Check if we're in flash mode for visual feedback
        let flashing = self.is_flashing();

        // Disable line wrap during render to prevent visual glitches
        write!(w, "\x1b[?7l")?;

        // Draw each line, clearing as we go (avoids full-screen clear flicker)
        // Results area: rows 0 to results_height-1
        // Layout: oldest at top (row 0), newest at bottom (row results_height-1)
        // scroll_offset is the entry index shown at the bottom of the visible area
        for row in 0..results_height {
            queue!(w, MoveTo(0, row as u16), Clear(ClearType::CurrentLine))?;

            // Calculate which entry to show at this row
            // Row 0 (top) shows oldest visible entry
            // Row results_height-1 (bottom) shows entry at scroll_offset
            let offset_from_bottom = results_height - 1 - row;
            let entry_index = self.scroll_offset + offset_from_bottom;

            if entry_index >= self.filtered_indices.len() {
                continue;
            }

            let (idx, ref match_indices) = self.filtered_indices[entry_index];
            let entry = &self.entries[idx];
            let time_str = format_relative_time(entry.timestamp);
            let is_selected = entry_index == self.selected_index;

            // Calculate quick-select number (1-9) relative to selection
            // Alt-1 = selected, Alt-2 = selected+1 (next older), etc.
            let quick_num =
                if entry_index >= self.selected_index && entry_index < self.selected_index + 9 {
                    Some(entry_index - self.selected_index + 1)
                } else {
                    None
                };

            if is_selected {
                queue!(w, SetBackgroundColor(Color::DarkGrey))?;
            }

            // Draw quick-select indicator or selection marker
            if let Some(n) = quick_num {
                queue!(w, SetForegroundColor(Color::Yellow))?;
                write!(w, "{n}")?;
                queue!(w, ResetColor)?;
                if is_selected {
                    queue!(w, SetBackgroundColor(Color::DarkGrey))?;
                    write!(w, ">")?;
                } else {
                    write!(w, " ")?;
                }
            } else if is_selected {
                write!(w, " >")?;
            } else {
                write!(w, "  ")?;
            }

            queue!(w, SetForegroundColor(Color::DarkGrey))?;
            write!(w, "{time_str}  ")?;
            queue!(w, ResetColor)?;

            if is_selected {
                queue!(w, SetBackgroundColor(Color::DarkGrey))?;
            }

            // Show host suffix for entries from other hosts (in AllHosts mode).
            // Right-aligned so command text stays column-aligned across rows.
            let host_suffix = if self.host_filter == HostFilter::AllHosts {
                entry.hostname.as_ref().and_then(|h| {
                    if !self.engine.is_this_host(h) {
                        let short =
                            String::from_utf8_lossy(h).split('.').next().unwrap_or("?").to_string();
                        Some(format!(" @{short}"))
                    } else {
                        None
                    }
                })
            } else {
                None
            };
            let suffix_len = host_suffix.as_ref().map_or(0, |s| s.chars().count());

            // Sanitize and truncate command to fit (handle UTF-8 safely)
            let safe_cmd = sanitize_for_display(&entry.command);
            let prefix_len = 9; // "n>" + " XXx  "
            let max_cmd_len = term_width.saturating_sub(prefix_len + suffix_len as u16) as usize;

            // Render command with highlighted fuzzy matches
            let spans = highlight_command_with_indices(&safe_cmd, match_indices, max_cmd_len);
            for (text, is_match) in spans {
                if is_match {
                    queue!(w, SetAttribute(Attribute::Bold))?;
                    queue!(w, SetForegroundColor(Color::Cyan))?;
                }
                write!(w, "{text}")?;
                if is_match {
                    queue!(w, SetAttribute(Attribute::Reset))?;
                    queue!(w, ResetColor)?;
                    if is_selected {
                        queue!(w, SetBackgroundColor(Color::DarkGrey))?;
                    }
                }
            }

            // Draw right-aligned host suffix
            if let Some(suffix) = host_suffix {
                let suffix_x = term_width.saturating_sub(suffix_len as u16);
                queue!(w, MoveTo(suffix_x, row as u16), SetForegroundColor(Color::Magenta))?;
                write!(w, "{suffix}")?;
            }

            queue!(w, ResetColor)?;
        }

        // Draw preview pane if enabled
        if self.show_preview {
            self.draw_preview(&mut w, preview_start_y, term_width)?;
        }

        // Draw input line
        queue!(w, MoveTo(0, input_y), Clear(ClearType::CurrentLine))?;
        write!(w, "> {}", sanitize_for_display(&self.query))?;

        // Draw mode indicators on same line (host filter + dir/global)
        let host_str = match self.host_filter {
            HostFilter::ThisHost => {
                let hostname = self.engine.primary_hostname();
                let short_host =
                    String::from_utf8_lossy(hostname).split('.').next().unwrap_or("?").to_string();
                format!("[{short_host}]")
            }
            HostFilter::AllHosts => "[All Hosts]".to_string(),
        };
        let dir_str = match self.filter_mode {
            FilterMode::Directory => {
                let dir = self.engine.working_directory();
                let name = dir
                    .file_name()
                    .map(|s| s.to_string_lossy().to_string())
                    .unwrap_or_else(|| "?".to_string());
                format!("[Dir: {name}]")
            }
            FilterMode::Global => "[Global]".to_string(),
        };
        let mode_str = format!("{host_str} {dir_str}");
        let mode_x = term_width.saturating_sub(mode_str.len() as u16 + 1);
        queue!(w, MoveTo(mode_x, input_y), SetForegroundColor(Color::Cyan))?;
        write!(w, "{mode_str}")?;
        queue!(w, ResetColor)?;

        // Draw help line (mode-aware, flashes on unrecognized keys)
        queue!(w, MoveTo(0, help_y), Clear(ClearType::CurrentLine))?;
        if flashing {
            // Flash effect: invert the help line colors
            queue!(w, SetBackgroundColor(Color::White), SetForegroundColor(Color::Black))?;
        } else {
            queue!(w, SetForegroundColor(Color::DarkGrey))?;
        }
        let help_text = match self.keymap_mode {
            KeymapMode::Emacs => {
                "↑↓/^R Nav  Enter Run  ^A/^E Edit  ^G Dir  ^H Host  ^C/^D Quit  Alt-1-9"
            }
            KeymapMode::VimInsert | KeymapMode::VimNormal => {
                "j/k Nav  Enter Run  ^A/^E Edit  ^G Dir  ^H Host  ^C/^D Quit  Esc Mode  Alt-1-9"
            }
        };
        write!(w, "{help_text}")?;
        queue!(w, ResetColor)?;

        // Show status message if active
        match self.status_message {
            Some((_, until)) if Instant::now() >= until => {
                self.status_message = None;
            }
            Some((ref msg, _)) => {
                queue!(w, SetForegroundColor(Color::Yellow))?;
                write!(w, " {msg}")?;
                queue!(w, ResetColor)?;
            }
            None => {}
        }

        // Position cursor at end of query in input line
        queue!(w, MoveTo(2 + self.query.len() as u16, input_y))?;

        // Re-enable line wrap
        write!(w, "\x1b[?7h")?;

        // Single flush writes all buffered content
        w.flush()?;
        Ok(())
    }
}

enum KeyAction {
    Continue,
    Select,
    Edit,
    EditBeginning,
    Cancel,
}

impl Drop for RecallTui {
    fn drop(&mut self) {
        #[cfg(not(target_os = "windows"))]
        if self.keyboard_enhanced {
            let _ = execute!(self.tty, PopKeyboardEnhancementFlags);
        }
        let _ = execute!(self.tty, Show, LeaveAlternateScreen);
        let _ = terminal::disable_raw_mode();
    }
}

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

    #[test]
    fn test_sanitize_preserves_normal_text() {
        assert_eq!(sanitize_for_display("hello world"), "hello world");
        assert_eq!(sanitize_for_display("ls -la /tmp"), "ls -la /tmp");
    }

    #[test]
    fn test_sanitize_preserves_box_drawing() {
        // Box-drawing characters should pass through
        assert_eq!(sanitize_for_display("┌History───┐"), "┌History───┐");
        assert_eq!(sanitize_for_display("│ cell │"), "│ cell │");
        assert_eq!(sanitize_for_display("└───────┘"), "└───────┘");
    }

    #[test]
    fn test_sanitize_preserves_unicode() {
        assert_eq!(sanitize_for_display("héllo wörld"), "héllo wörld");
        assert_eq!(sanitize_for_display("日本語"), "日本語");
        assert_eq!(sanitize_for_display("emoji 🎉 test"), "emoji 🎉 test");
    }

    #[test]
    fn test_sanitize_strips_c1_controls() {
        // U+009B is CSI in 8-bit terminal mode, U+009D is OSC
        assert_eq!(sanitize_for_display("hello\u{009B}31mworld"), "hello31mworld");
        assert_eq!(sanitize_for_display("test\u{009D}inject\u{009C}end"), "testinjectend");
        // U+0085 NEL, U+008E SS2
        assert_eq!(sanitize_for_display("a\u{0085}b\u{008E}c"), "abc");
    }

    #[test]
    fn test_sanitize_strips_ansi_escape_sequences() {
        // Color codes
        assert_eq!(sanitize_for_display("\x1b[31mred\x1b[0m"), "red");
        assert_eq!(sanitize_for_display("\x1b[1;32mbold green\x1b[0m"), "bold green");

        // Cursor movement
        assert_eq!(sanitize_for_display("\x1b[H"), ""); // cursor home
        assert_eq!(sanitize_for_display("\x1b[2J"), ""); // clear screen
        assert_eq!(sanitize_for_display("\x1b[10;20H"), ""); // cursor position

        // Mixed content
        assert_eq!(sanitize_for_display("before\x1b[31mred\x1b[0mafter"), "beforeredafter");
    }

    #[test]
    fn test_sanitize_converts_newlines_to_spaces() {
        assert_eq!(sanitize_for_display("line1\nline2"), "line1 line2");
        assert_eq!(sanitize_for_display("line1\r\nline2"), "line1  line2");
        assert_eq!(sanitize_for_display("a\nb\nc"), "a b c");
    }

    #[test]
    fn test_sanitize_converts_tabs_to_spaces() {
        assert_eq!(sanitize_for_display("col1\tcol2"), "col1 col2");
        assert_eq!(sanitize_for_display("\t\tindented"), "  indented");
    }

    #[test]
    fn test_sanitize_strips_control_characters() {
        // Bell, backspace, etc.
        assert_eq!(sanitize_for_display("hello\x07world"), "helloworld"); // bell
        assert_eq!(sanitize_for_display("hello\x08world"), "helloworld"); // backspace
        assert_eq!(sanitize_for_display("a\x00b\x01c"), "abc"); // null and other low controls
        assert_eq!(sanitize_for_display("test\x7fdelete"), "testdelete"); // DEL
    }

    #[test]
    fn test_sanitize_handles_binary_garbage() {
        // Simulate binary data that might corrupt terminal
        let binary_garbage = "cmd\x1b[2J\x1b[H\x00\x01\x02\x03visible\x1b[31m";
        assert_eq!(sanitize_for_display(binary_garbage), "cmdvisible");
    }

    #[test]
    fn test_sanitize_handles_incomplete_escape_sequences() {
        // Incomplete escape at end of string
        assert_eq!(sanitize_for_display("text\x1b"), "text");
        assert_eq!(sanitize_for_display("text\x1b["), "text");
        assert_eq!(sanitize_for_display("text\x1b[123"), "text");
    }

    #[test]
    fn test_sanitize_strips_osc_sequences() {
        // OSC title change: \x1b]0;title\x07
        assert_eq!(sanitize_for_display("\x1b]0;My Title\x07visible"), "visible");
        // OSC with ST terminator: \x1b]0;title\x1b\\
        assert_eq!(sanitize_for_display("\x1b]2;Title\x1b\\visible"), "visible");
        // OSC 52 clipboard: \x1b]52;c;base64\x07
        assert_eq!(sanitize_for_display("\x1b]52;c;SGVsbG8=\x07after"), "after");
    }

    #[test]
    fn test_sanitize_strips_dcs_sequences() {
        assert_eq!(sanitize_for_display("\x1bPsome data\x1b\\visible"), "visible");
    }

    #[test]
    fn test_sanitize_strips_other_esc_sequences() {
        // SS2/SS3
        assert_eq!(sanitize_for_display("\x1bNvisible"), "visible");
        assert_eq!(sanitize_for_display("\x1bOvisible"), "visible");
    }

    #[test]
    fn test_sanitize_empty_string() {
        assert_eq!(sanitize_for_display(""), "");
    }

    #[test]
    fn test_highlight_no_query() {
        // Empty query returns whole string unhighlighted
        let spans = highlight_command("ls -la", "", 100);
        assert_eq!(spans, vec![("ls -la".to_string(), false)]);
    }

    #[test]
    fn test_highlight_single_match() {
        let spans = highlight_command("grep foo bar", "foo", 100);
        assert_eq!(
            spans,
            vec![
                ("grep ".to_string(), false),
                ("foo".to_string(), true),
                (" bar".to_string(), false),
            ]
        );
    }

    #[test]
    fn test_highlight_case_insensitive() {
        let spans = highlight_command("grep FOO bar", "foo", 100);
        assert_eq!(
            spans,
            vec![
                ("grep ".to_string(), false),
                ("FOO".to_string(), true),
                (" bar".to_string(), false),
            ]
        );
    }

    #[test]
    fn test_highlight_multiple_matches() {
        let spans = highlight_command("foo bar foo", "foo", 100);
        assert_eq!(
            spans,
            vec![
                ("foo".to_string(), true),
                (" bar ".to_string(), false),
                ("foo".to_string(), true),
            ]
        );
    }

    #[test]
    fn test_highlight_at_start() {
        let spans = highlight_command("foo bar", "foo", 100);
        assert_eq!(spans, vec![("foo".to_string(), true), (" bar".to_string(), false),]);
    }

    #[test]
    fn test_highlight_at_end() {
        let spans = highlight_command("bar foo", "foo", 100);
        assert_eq!(spans, vec![("bar ".to_string(), false), ("foo".to_string(), true),]);
    }

    #[test]
    fn test_highlight_truncation() {
        // When command is too long, it should be truncated with "..."
        let spans = highlight_command("very long command here", "", 10);
        assert_eq!(spans, vec![("very lo...".to_string(), false)]);
    }

    #[test]
    fn test_highlight_no_match() {
        // Query not found - return whole string unhighlighted
        let spans = highlight_command("ls -la", "xyz", 100);
        assert_eq!(spans, vec![("ls -la".to_string(), false)]);
    }

    #[test]
    fn test_highlight_multibyte_query() {
        // Query with multi-byte UTF-8 characters
        let spans = highlight_command("find 日本語 here", "日本語", 100);
        assert_eq!(
            spans,
            vec![
                ("find ".to_string(), false),
                ("日本語".to_string(), true),
                (" here".to_string(), false),
            ]
        );
    }

    #[test]
    fn test_highlight_multibyte_command() {
        // ASCII query in command with multi-byte chars
        let spans = highlight_command("echo 日本語 foo bar", "foo", 100);
        assert_eq!(
            spans,
            vec![
                ("echo 日本語 ".to_string(), false),
                ("foo".to_string(), true),
                (" bar".to_string(), false),
            ]
        );
    }

    #[test]
    fn test_highlight_empty_command() {
        // Empty command returns empty spans (nothing to display)
        let spans = highlight_command("", "foo", 100);
        assert!(spans.is_empty());
    }

    #[test]
    fn test_highlight_empty_both() {
        // Empty command with empty query returns the empty string
        let spans = highlight_command("", "", 100);
        assert_eq!(spans, vec![("".to_string(), false)]);
    }

    #[test]
    fn test_deduplicate_empty_list() {
        use super::deduplicate_entries;
        let deduped = deduplicate_entries(vec![]);
        assert!(deduped.is_empty());
    }

    #[test]
    fn test_deduplicate_keeps_first_occurrence() {
        use super::deduplicate_entries;
        use crate::recall::engine::HistoryEntry;

        let entries = vec![
            HistoryEntry {
                id: 1,
                command: "cmd1".to_string(),
                timestamp: Some(100),
                working_directory: None,
                exit_status: None,
                duration_secs: None,
                hostname: None,
            },
            HistoryEntry {
                id: 2,
                command: "cmd2".to_string(),
                timestamp: Some(90),
                working_directory: None,
                exit_status: None,
                duration_secs: None,
                hostname: None,
            },
            HistoryEntry {
                id: 3,
                command: "cmd1".to_string(), // duplicate
                timestamp: Some(80),
                working_directory: None,
                exit_status: None,
                duration_secs: None,
                hostname: None,
            },
        ];

        let deduped = deduplicate_entries(entries);
        assert_eq!(deduped.len(), 2);
        assert_eq!(deduped[0].command, "cmd1");
        assert_eq!(deduped[0].timestamp, Some(100)); // kept first (most recent)
        assert_eq!(deduped[1].command, "cmd2");
    }

    // Tests for highlight_command_with_indices (fuzzy match highlighting)
    #[test]
    fn test_fuzzy_highlight_no_indices() {
        use super::highlight_command_with_indices;
        // No match indices - whole string unhighlighted
        let spans = highlight_command_with_indices("git fetch origin", &[], 100);
        assert_eq!(spans, vec![("git fetch origin".to_string(), false)]);
    }

    #[test]
    fn test_fuzzy_highlight_scattered_matches() {
        use super::highlight_command_with_indices;
        // "gfo" fuzzy matching "git fetch origin" -> indices 0, 4, 10
        let spans = highlight_command_with_indices("git fetch origin", &[0, 4, 10], 100);
        assert_eq!(
            spans,
            vec![
                ("g".to_string(), true),
                ("it ".to_string(), false),
                ("f".to_string(), true),
                ("etch ".to_string(), false),
                ("o".to_string(), true),
                ("rigin".to_string(), false),
            ]
        );
    }

    #[test]
    fn test_fuzzy_highlight_contiguous_matches() {
        use super::highlight_command_with_indices;
        // Contiguous matches should be grouped
        let spans = highlight_command_with_indices("grep foo bar", &[5, 6, 7], 100);
        assert_eq!(
            spans,
            vec![
                ("grep ".to_string(), false),
                ("foo".to_string(), true),
                (" bar".to_string(), false),
            ]
        );
    }

    #[test]
    fn test_fuzzy_highlight_truncation() {
        use super::highlight_command_with_indices;
        // Long command truncated with ellipsis
        // max_len=10 means we truncate at position 7 (10-3), leaving room for "..."
        let spans = highlight_command_with_indices("very long command here", &[0, 5], 10);
        assert_eq!(
            spans,
            vec![
                ("v".to_string(), true),
                ("ery ".to_string(), false),
                ("l".to_string(), true),
                ("o".to_string(), false),
                ("...".to_string(), false),
            ]
        );
    }

    #[test]
    fn test_fuzzy_highlight_empty_command() {
        use super::highlight_command_with_indices;
        let spans = highlight_command_with_indices("", &[0, 1, 2], 100);
        assert!(spans.is_empty());
    }

    #[test]
    fn test_fuzzy_highlight_match_at_start() {
        use super::highlight_command_with_indices;
        let spans = highlight_command_with_indices("foo bar", &[0, 1, 2], 100);
        assert_eq!(spans, vec![("foo".to_string(), true), (" bar".to_string(), false),]);
    }

    #[test]
    fn test_fuzzy_highlight_match_at_end() {
        use super::highlight_command_with_indices;
        let spans = highlight_command_with_indices("foo bar", &[4, 5, 6], 100);
        assert_eq!(spans, vec![("foo ".to_string(), false), ("bar".to_string(), true),]);
    }

    #[test]
    fn test_base64_encode() {
        assert_eq!(super::base64_encode(b"hello"), "aGVsbG8=");
        assert_eq!(super::base64_encode(b""), "");
        assert_eq!(super::base64_encode(b"a"), "YQ==");
        assert_eq!(super::base64_encode(b"ab"), "YWI=");
        assert_eq!(super::base64_encode(b"abc"), "YWJj");
        assert_eq!(super::base64_encode(b"echo hello world"), "ZWNobyBoZWxsbyB3b3JsZA==");
    }

    #[test]
    fn test_delete_last_word_with_multibyte_whitespace() {
        // U+00A0 NBSP is 2 bytes in UTF-8: 0xC2 0xA0
        // Using rfind(is_whitespace).map(|i| i + 1) would land mid-char
        let mut query = format!("hello\u{00A0}world");
        let trimmed_len = query.trim_end().len();
        let word_start = query[..trimmed_len]
            .char_indices()
            .rev()
            .find(|(_, c)| c.is_whitespace())
            .map(|(i, c)| i + c.len_utf8())
            .unwrap_or(0);
        query.truncate(word_start); // should not panic
        assert_eq!(query, "hello\u{00A0}", "should truncate after the NBSP");
    }
}
