//! `/memory` browser modal -- view-state, rendering, and input handling.
//!
//! A centered popup with ModalWindow chrome, horizontally split into a
//! searchable file list (left, ~40%) and a read-only content preview
//! pane (right, ~60%). File list shows all memory files grouped by source
//! (Global, Workspace, Sessions) with session logs in reverse chronological
//! order. Selecting a file loads its content into the preview pane.
//!
//! Layout collapses to single-pane (list only) on narrow terminals (< 80 cols).
//!
//! `/` enters filter mode (type to search, Escape to exit).
//! `x` deletes with double-press confirmation (session logs only).

use std::borrow::Cow;
use std::path::PathBuf;

use crossterm::event::KeyModifiers;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, MouseEventKind};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;

use crate::app::actions::Action;
use crate::app::app_view::InputOutcome;

use crate::render::SafeBuf;
use crate::render::scrollbar::{ScrollbarClickResult, render_scrollbar, scrollbar_click_to_offset};
use crate::scrollback::blocks::markdown_content::MarkdownContent;
use crate::theme::Theme;
use crate::views::modal_window::{
    self, ModalContentArea, ModalSizing, ModalWindowConfig, ModalWindowState, Shortcut,
};

const SPLIT_MIN_WIDTH: u16 = 80;
const LIST_WIDTH_RATIO: f64 = 0.40;
const MAX_PREVIEW_BYTES: u64 = 1_048_576;

#[derive(Debug, Clone)]
pub struct MemoryFileEntry {
    pub path: PathBuf,
    /// `"global"`, `"workspace"`, or `"session"`.
    pub source: String,
    pub label: String,
    /// Pre-formatted `"size · modified"` string for the list row.
    /// Empty for headers.
    pub meta_display: String,
    pub is_header: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MemoryModalMode {
    Browse,
    /// Filter input is focused — all single-char keys go to the filter.
    /// Only Escape (exit filter) and Enter (no-op / stay) remain active.
    FilterFocused,
    ConfirmingDelete {
        idx: usize,
    },
}

#[derive(Debug, Clone)]
pub struct MemoryModalState {
    pub window: ModalWindowState,
    pub entries: Vec<MemoryFileEntry>,
    pub selected: usize,
    pub scroll_offset: usize,
    pub preview_markdown: Option<MarkdownContent>,
    pub preview_scroll: usize,
    pub mode: MemoryModalMode,
    pub query: String,
    /// Whether memory is currently enabled for this session.
    pub memory_enabled: bool,
    /// Whether the modal is rendered in fullscreen mode (persisted to config).
    pub fullscreen: bool,
    /// Cached filtered indices. Recomputed when `query` or `entries` change.
    filtered_cache: Vec<usize>,
    /// Cached total lines in the preview (for scrollbar rendering).
    preview_total_lines: usize,
    /// Hit-test rects for mouse interaction (set during render).
    list_area: Rect,
    preview_area: Rect,
    list_scrollbar_area: Option<Rect>,
    preview_scrollbar_area: Option<Rect>,
}

impl MemoryModalState {
    pub fn new(entries: Vec<MemoryFileEntry>) -> Self {
        let filtered_cache = compute_filtered(&entries, "");
        let mut state = Self {
            window: ModalWindowState::new(),
            entries,
            selected: 0,
            scroll_offset: 0,
            preview_markdown: None,
            preview_scroll: 0,
            mode: MemoryModalMode::Browse,
            query: String::new(),
            memory_enabled: true,
            fullscreen: load_fullscreen_pref(),
            filtered_cache,
            preview_total_lines: 0,
            list_area: Rect::default(),
            preview_area: Rect::default(),
            list_scrollbar_area: None,
            preview_scrollbar_area: None,
        };
        state.advance_past_headers();
        state.load_preview();
        state
    }

    pub fn filtered_indices(&self) -> &[usize] {
        &self.filtered_cache
    }

    fn invalidate_filter(&mut self) {
        self.filtered_cache = compute_filtered(&self.entries, &self.query);
    }

    pub fn selected_entry(&self) -> Option<&MemoryFileEntry> {
        self.filtered_cache
            .get(self.selected)
            .and_then(|&i| self.entries.get(i))
    }

    /// Advance `selected` past any leading headers to the first selectable entry.
    fn advance_past_headers(&mut self) {
        let filtered = &self.filtered_cache;
        for (i, &orig) in filtered.iter().enumerate() {
            if !self.entries[orig].is_header {
                self.selected = i;
                return;
            }
        }
    }

    pub fn select_next(&mut self) {
        if self.advance_next() {
            self.load_preview();
        }
    }

    pub fn select_prev(&mut self) {
        if self.advance_prev() {
            self.load_preview();
        }
    }

    /// Move `selected` forward to the next non-header entry without
    /// loading preview. Returns `true` if the selection changed.
    fn advance_next(&mut self) -> bool {
        let filtered = &self.filtered_cache;
        let mut next = self.selected + 1;
        while next < filtered.len() {
            if !self.entries[filtered[next]].is_header {
                self.selected = next;
                return true;
            }
            next += 1;
        }
        false
    }

    /// Move `selected` backward to the previous non-header entry without
    /// loading preview. Returns `true` if the selection changed.
    fn advance_prev(&mut self) -> bool {
        if self.selected == 0 {
            return false;
        }
        let filtered = &self.filtered_cache;
        let mut prev = self.selected - 1;
        loop {
            if !self.entries[filtered[prev]].is_header {
                self.selected = prev;
                return true;
            }
            if prev == 0 {
                break;
            }
            prev -= 1;
        }
        false
    }

    /// Select the entry at a given filtered index (used by mouse click).
    /// Skips headers. Returns `true` if the selection changed.
    pub fn select_at(&mut self, filt_idx: usize) -> bool {
        let filtered = &self.filtered_cache;
        if filt_idx >= filtered.len() {
            return false;
        }
        if self.entries[filtered[filt_idx]].is_header {
            return false;
        }
        if self.selected == filt_idx {
            return false;
        }
        self.selected = filt_idx;
        self.load_preview();
        true
    }

    pub fn clamp_selected(&mut self) {
        let filtered = &self.filtered_cache;
        if filtered.is_empty() {
            self.selected = 0;
            self.preview_markdown = None;
            return;
        }
        if self.selected >= filtered.len() {
            self.selected = filtered.len() - 1;
        }
        if self.entries[filtered[self.selected]].is_header {
            // Try forward.
            for (i, &orig) in filtered.iter().enumerate().skip(self.selected + 1) {
                if !self.entries[orig].is_header {
                    self.selected = i;
                    self.load_preview();
                    return;
                }
            }
            // Try backward.
            for (i, &orig) in filtered.iter().enumerate().take(self.selected).rev() {
                if !self.entries[orig].is_header {
                    self.selected = i;
                    self.load_preview();
                    return;
                }
            }
        }
        self.load_preview();
    }

    fn load_preview(&mut self) {
        self.preview_scroll = 0;
        self.preview_markdown = self
            .selected_entry()
            .filter(|e| !e.is_header)
            .and_then(|e| {
                let path = std::path::Path::new(&e.path);
                match std::fs::metadata(path) {
                    Ok(meta) if meta.len() > MAX_PREVIEW_BYTES => {
                        Some(MarkdownContent::new("*(File too large to preview)*"))
                    }
                    Err(_) => None,
                    _ => std::fs::read_to_string(path).ok().map(MarkdownContent::new),
                }
            });
    }
}

/// Compute filtered indices from entries and query. Section headers are
/// preserved only when at least one entry in the section matches.
fn compute_filtered(entries: &[MemoryFileEntry], query: &str) -> Vec<usize> {
    if query.is_empty() {
        return (0..entries.len()).collect();
    }
    let needle = query.to_lowercase();
    let mut result = Vec::new();
    let mut pending_header: Option<usize> = None;
    let mut section_has_match = false;
    for (i, entry) in entries.iter().enumerate() {
        if entry.is_header {
            // Note: `pending_header` is `Some` here only when the previous
            // section had zero matches (`.take()` clears it on first match),
            // so `section_has_match` is always false — this guard is a
            // defensive no-op. Kept for safety.
            if let Some(h) = pending_header
                && section_has_match
            {
                result.push(h);
            }
            pending_header = Some(i);
            section_has_match = false;
        } else if entry.label.to_lowercase().contains(&needle)
            || entry.source.to_lowercase().contains(&needle)
        {
            if let Some(h) = pending_header.take() {
                result.push(h);
                section_has_match = true;
            }
            result.push(i);
        }
    }
    // Same defensive no-op as the in-loop guard above.
    if let Some(h) = pending_header
        && section_has_match
    {
        result.push(h);
    }
    result
}

pub fn build_entries(
    files: Vec<kigi_shell::extensions::notification::MemoryFileInfo>,
) -> Vec<MemoryFileEntry> {
    let now_secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);

    let mut global = Vec::new();
    let mut workspace = Vec::new();
    let mut session = Vec::new();

    for f in files {
        let label = file_label(&f.path);
        let size = format_size(f.size_bytes);
        let modified = format_modified(f.modified_epoch_secs, now_secs);
        let meta_display = format!("{size} \u{00B7} {modified}");
        let bucket = match f.source.as_str() {
            "global" => &mut global,
            "workspace" => &mut workspace,
            _ => &mut session,
        };
        bucket.push(MemoryFileEntry {
            label,
            meta_display,
            path: f.path.into(),
            source: f.source,
            is_header: false,
        });
    }

    // Session logs: reverse chronological (newest first).
    session.reverse();

    let mut entries = Vec::new();
    let mut push_section = |label: &str, items: Vec<MemoryFileEntry>| {
        if !items.is_empty() {
            entries.push(MemoryFileEntry {
                path: PathBuf::new(),
                source: String::new(),
                meta_display: String::new(),
                label: label.to_string(),
                is_header: true,
            });
            entries.extend(items);
        }
    };
    push_section("Global", global);
    push_section("Workspace", workspace);
    push_section("Sessions", session);
    entries
}

pub fn render_memory_modal(
    buf: &mut Buffer,
    full_area: Rect,
    state: &mut MemoryModalState,
    compact: bool,
) {
    let theme = Theme::current();
    let shortcuts = build_shortcuts(&state.mode, state.memory_enabled, state.fullscreen);

    let modal_config = ModalWindowConfig {
        title: "Memory",
        tabs: None,
        shortcuts: &shortcuts,
        sizing: if state.fullscreen {
            ModalSizing {
                width_pct: 1.0,
                max_width: u16::MAX,
                min_width: 44,
                v_margin: 0,
                h_pad: 2,
                v_pad: 0,
                footer_lines: 2,
            }
        } else {
            ModalSizing {
                width_pct: 0.75,
                max_width: 140,
                min_width: 44,
                v_margin: 3,
                h_pad: 2,
                v_pad: 1,
                footer_lines: 2,
            }
        }
        .with_compact(compact),
        fold_info: None,
    };

    let Some(ModalContentArea {
        content: content_area,
        ..
    }) =
        modal_window::render_modal_window(buf, full_area, &mut state.window, &modal_config, &theme)
    else {
        return;
    };

    if content_area.height < 2 || content_area.width < 10 {
        return;
    }

    let show_preview = content_area.width >= SPLIT_MIN_WIDTH;
    let list_width = if show_preview {
        (content_area.width as f64 * LIST_WIDTH_RATIO) as u16
    } else {
        content_area.width
    };

    let list_area = Rect {
        x: content_area.x,
        y: content_area.y,
        width: list_width,
        height: content_area.height,
    };
    state.list_area = list_area;

    render_file_list(buf, list_area, state, &theme);

    if show_preview {
        let preview_x = content_area.x + list_width + 1;
        let preview_width = content_area.width.saturating_sub(list_width + 1);
        if preview_width > 2 {
            let sep_x = content_area.x + list_width;
            let sep_style = Style::default().fg(theme.gray_dim);
            for y in content_area.y..content_area.y + content_area.height {
                if let Some(cell) = buf.cell_mut((sep_x, y)) {
                    cell.set_symbol("\u{2502}");
                    cell.set_style(sep_style);
                }
            }

            let preview_area = Rect {
                x: preview_x,
                y: content_area.y,
                width: preview_width,
                height: content_area.height,
            };
            state.preview_area = preview_area;
            render_preview(buf, preview_area, state, &theme);
        } else {
            state.preview_area = Rect::default();
            state.preview_scrollbar_area = None;
        }
    } else {
        state.preview_area = Rect::default();
        state.preview_scrollbar_area = None;
    }
}

fn render_file_list(buf: &mut Buffer, area: Rect, state: &mut MemoryModalState, theme: &Theme) {
    let search_y = area.y;
    let filter_focused = matches!(state.mode, MemoryModalMode::FilterFocused);
    let (query_display, query_style) = if state.query.is_empty() {
        let placeholder = if filter_focused {
            "type to filter..."
        } else {
            "/ to filter..."
        };
        (
            placeholder,
            Style::default().fg(theme.gray_dim).bg(theme.bg_base),
        )
    } else {
        (
            state.query.as_str(),
            Style::default().fg(theme.text_primary).bg(theme.bg_base),
        )
    };
    buf.set_span(
        area.x,
        search_y,
        &Span::styled(query_display, query_style),
        area.width,
    );
    if filter_focused {
        let cursor_x = area.x + state.query.width() as u16;
        if cursor_x < area.x + area.width {
            buf.set_span(
                cursor_x,
                search_y,
                &Span::styled(
                    "\u{2588}",
                    Style::default().fg(theme.accent_user).bg(theme.bg_base),
                ),
                1,
            );
        }
    }

    let entries_start_y = search_y + 1;
    let available_height = area.height.saturating_sub(1) as usize;
    if state.selected < state.scroll_offset {
        state.scroll_offset = state.selected;
    }
    if state.selected >= state.scroll_offset + available_height {
        state.scroll_offset = state.selected.saturating_sub(available_height - 1);
    }

    // Compute scrollbar area before borrowing filtered_cache.
    let total_entries = sat_u16(state.filtered_indices().len());
    let sb_area = if total_entries > available_height as u16 && area.width > 4 {
        Some(Rect {
            x: area.x + area.width - 1,
            y: entries_start_y,
            width: 1,
            height: available_height as u16,
        })
    } else {
        None
    };
    state.list_scrollbar_area = sb_area;
    let content_width = if sb_area.is_some() {
        area.width.saturating_sub(2)
    } else {
        area.width
    };

    let filtered = state.filtered_indices();
    let end = filtered.len().min(state.scroll_offset + available_height);
    let visible = &filtered[state.scroll_offset..end];

    for (row, &orig_idx) in visible.iter().enumerate() {
        let y = entries_start_y + row as u16;
        if y >= area.y + area.height {
            break;
        }
        let entry = &state.entries[orig_idx];
        let filt_idx = state.scroll_offset + row;
        let is_selected = filt_idx == state.selected;

        if entry.is_header {
            let header_style = Style::default()
                .fg(theme.accent_user)
                .bg(theme.bg_base)
                .add_modifier(Modifier::BOLD);
            let line = Line::from(Span::styled(&entry.label, header_style));
            buf.set_line(area.x, y, &line, content_width);
        } else {
            let bg = if is_selected {
                theme.bg_visual
            } else {
                theme.bg_base
            };
            let label_style = Style::default().fg(theme.text_primary).bg(bg);
            let meta_style = Style::default().fg(theme.gray).bg(bg);

            let row_rect = Rect {
                x: area.x,
                y,
                width: content_width,
                height: 1,
            };
            buf.set_style(row_rect, Style::default().bg(bg));

            let max_label_w = content_width.saturating_sub(2) as usize;
            let truncated_label: Cow<str> = if entry.label.width() > max_label_w {
                let trunc = truncate_to_width(&entry.label, max_label_w.saturating_sub(3));
                format!("{trunc}...").into()
            } else {
                Cow::Borrowed(&entry.label)
            };
            buf.set_span(
                area.x + 1,
                y,
                &Span::styled(truncated_label.as_ref(), label_style),
                content_width.saturating_sub(1),
            );

            if is_selected
                && matches!(state.mode, MemoryModalMode::ConfirmingDelete { idx } if idx == filt_idx)
            {
                let hint = " [x to confirm]";
                let hint_w = hint.len() as u16;
                let hint_x = (area.x + content_width).saturating_sub(hint_w + 1);
                buf.set_span(
                    hint_x,
                    y,
                    &Span::styled(hint, Style::default().fg(theme.accent_error).bg(bg)),
                    hint_w,
                );
            } else {
                let meta_w = entry.meta_display.width() as u16;
                let meta_x = (area.x + content_width).saturating_sub(meta_w + 1);
                if meta_x > area.x + 1 {
                    buf.set_span(
                        meta_x,
                        y,
                        &Span::styled(&entry.meta_display, meta_style),
                        meta_w,
                    );
                }
            }
        }
    }

    // List scrollbar.
    render_scrollbar(
        buf,
        sb_area,
        total_entries,
        sat_u16(available_height),
        sat_u16(state.scroll_offset),
        false,
    );
}

fn render_preview(buf: &mut Buffer, area: Rect, state: &mut MemoryModalState, theme: &Theme) {
    if state.preview_markdown.is_none() {
        state.preview_total_lines = 0;
        state.preview_scrollbar_area = None;
        let msg = "No file selected";
        let style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
        let cy = area.y + area.height / 2;
        let cx = area.x + area.width.saturating_sub(msg.width() as u16) / 2;
        buf.set_span(cx, cy, &Span::styled(msg, style), area.width);
        return;
    }

    // Reserve scrollbar column if needed (computed after first pass).
    // We do a two-pass approach: first compute total at full width to decide
    // if scrollbar is needed, then re-compute at narrowed width if so.
    let full_width = area.width as usize;
    if full_width == 0 {
        return;
    }

    buf.set_style(area, Style::default().bg(theme.bg_base));

    let total_at_full = state
        .preview_markdown
        .as_ref()
        .unwrap()
        .with_wrapped_lines(full_width, |w| w.lines.len());
    let visible = area.height as usize;

    let (content_width, sb_area) = if total_at_full > visible && area.width > 4 {
        let sb = Rect {
            x: area.x + area.width - 1,
            y: area.y,
            width: 1,
            height: area.height,
        };
        (full_width.saturating_sub(2), Some(sb))
    } else {
        (full_width, None)
    };

    let total = if sb_area.is_some() && content_width != full_width {
        state
            .preview_markdown
            .as_ref()
            .unwrap()
            .with_wrapped_lines(content_width, |w| w.lines.len())
    } else {
        total_at_full
    };

    state.preview_total_lines = total;
    state.preview_scrollbar_area = sb_area;
    state.preview_scroll = state.preview_scroll.min(total.saturating_sub(visible));
    let scroll = state.preview_scroll;

    state
        .preview_markdown
        .as_ref()
        .unwrap()
        .with_wrapped_lines(content_width, |wrapped| {
            for (row, line_idx) in (scroll..total.min(scroll + visible)).enumerate() {
                let y = area.y + row as u16;
                buf.set_line_safe(area.x, y, &wrapped.lines[line_idx], content_width as u16);
            }
        });

    render_scrollbar(
        buf,
        sb_area,
        sat_u16(total),
        sat_u16(visible),
        sat_u16(scroll),
        false,
    );
}

pub fn handle_memory_key(state: &mut MemoryModalState, key: &KeyEvent) -> InputOutcome {
    if key.kind == KeyEventKind::Release {
        return InputOutcome::Unchanged;
    }

    // ConfirmingDelete has an "any key cancel" contract — handle it first
    // so no other handler can silently swallow the key.
    if let MemoryModalMode::ConfirmingDelete { idx } = state.mode {
        if key.code == KeyCode::Char('x') {
            let filtered = state.filtered_indices();
            if let Some(&orig_idx) = filtered.get(idx) {
                let path = state.entries[orig_idx].path.clone();
                state.entries.remove(orig_idx);
                state.mode = MemoryModalMode::Browse;
                state.invalidate_filter();
                state.clamp_selected();
                let _ = std::fs::remove_file(&path);
                return InputOutcome::Changed;
            }
        }
        state.mode = MemoryModalMode::Browse;
        return InputOutcome::Changed;
    }

    match state.mode {
        MemoryModalMode::ConfirmingDelete { .. } => unreachable!("handled above"),
        MemoryModalMode::FilterFocused => handle_filter_focused(state, key),
        MemoryModalMode::Browse => handle_browse(state, key),
    }
}

/// Saturating cast from `usize` to `u16` (caps at `u16::MAX`).
fn sat_u16(v: usize) -> u16 {
    v.min(u16::MAX as usize) as u16
}

/// After a list scrollbar jump, select the first visible non-header entry
/// so that `render_file_list`'s scroll-clamping doesn't snap `scroll_offset`
/// back to the old selection.
fn select_first_visible(state: &mut MemoryModalState) {
    let filtered = state.filtered_indices();
    let visible_start = state.scroll_offset;
    let visible_end = filtered
        .len()
        .min(visible_start + state.list_area.height.saturating_sub(1) as usize);
    for (i, &orig) in filtered
        .iter()
        .enumerate()
        .take(visible_end)
        .skip(visible_start)
    {
        if !state.entries[orig].is_header {
            state.selected = i;
            state.load_preview();
            return;
        }
    }
}

/// Apply a scrollbar click/drag at `screen_row` within `sb_area`, updating `offset`.
fn apply_scrollbar_jump(
    screen_row: u16,
    sb_area: Rect,
    total_lines: u16,
    viewport_lines: u16,
    offset: &mut usize,
) {
    let cell_index = screen_row.saturating_sub(sb_area.y);
    let result = scrollbar_click_to_offset(cell_index, sb_area.height, total_lines, viewport_lines);
    let max_scroll = (total_lines as usize).saturating_sub(viewport_lines as usize);
    match result {
        ScrollbarClickResult::Top => *offset = 0,
        ScrollbarClickResult::Bottom => *offset = max_scroll,
        ScrollbarClickResult::Offset(o) => *offset = o.min(max_scroll),
    }
}

/// Handle mouse events for the memory modal content area.
///
/// Supports:
/// - Left-click on a file list row to select it
/// - Scroll wheel over the file list to scroll the list
/// - Scroll wheel over the preview pane to scroll the preview
/// - Click/drag on list scrollbar to jump-scroll the list
/// - Click/drag on preview scrollbar to jump-scroll the preview
pub fn handle_memory_mouse(
    state: &mut MemoryModalState,
    kind: MouseEventKind,
    column: u16,
    row: u16,
) -> InputOutcome {
    let in_rect = |r: Rect| -> bool {
        r.width > 0
            && r.height > 0
            && column >= r.x
            && column < r.x + r.width
            && row >= r.y
            && row < r.y + r.height
    };

    let on_list = in_rect(state.list_area);
    let on_preview = in_rect(state.preview_area);
    let on_list_sb = state.list_scrollbar_area.is_some_and(&in_rect);
    let on_preview_sb = state.preview_scrollbar_area.is_some_and(&in_rect);

    match kind {
        MouseEventKind::Down(crossterm::event::MouseButton::Left)
        | MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
            // Click/drag on list scrollbar → jump-scroll + select nearest entry.
            if on_list_sb {
                let sb = state.list_scrollbar_area.unwrap();
                let total = sat_u16(state.filtered_indices().len());
                let visible = state.list_area.height.saturating_sub(1);
                apply_scrollbar_jump(row, sb, total, visible, &mut state.scroll_offset);
                select_first_visible(state);
                return InputOutcome::Changed;
            }

            // Click/drag on preview scrollbar → jump-scroll.
            if on_preview_sb {
                let sb = state.preview_scrollbar_area.unwrap();
                let total = sat_u16(state.preview_total_lines);
                let visible = state.preview_area.height;
                apply_scrollbar_jump(row, sb, total, visible, &mut state.preview_scroll);
                return InputOutcome::Changed;
            }

            // Click on a file list row to select it (not on drag).
            if matches!(kind, MouseEventKind::Down(_)) && on_list {
                let entries_start_y = state.list_area.y + 1;
                if row >= entries_start_y {
                    let clicked_row = (row - entries_start_y) as usize;
                    let filt_idx = state.scroll_offset + clicked_row;
                    if state.select_at(filt_idx) {
                        return InputOutcome::Changed;
                    }
                }
            }

            InputOutcome::Unchanged
        }

        MouseEventKind::ScrollDown => {
            if on_list || on_list_sb {
                let mut moved = false;
                for _ in 0..3 {
                    moved |= state.advance_next();
                }
                if moved {
                    state.load_preview();
                }
                return InputOutcome::Changed;
            }
            if on_preview || on_preview_sb {
                let max = state
                    .preview_total_lines
                    .saturating_sub(state.preview_area.height as usize);
                state.preview_scroll = state.preview_scroll.saturating_add(3).min(max);
                return InputOutcome::Changed;
            }
            InputOutcome::Unchanged
        }

        MouseEventKind::ScrollUp => {
            if on_list || on_list_sb {
                let mut moved = false;
                for _ in 0..3 {
                    moved |= state.advance_prev();
                }
                if moved {
                    state.load_preview();
                }
                return InputOutcome::Changed;
            }
            if on_preview || on_preview_sb {
                state.preview_scroll = state.preview_scroll.saturating_sub(3);
                return InputOutcome::Changed;
            }
            InputOutcome::Unchanged
        }

        _ => InputOutcome::Unchanged,
    }
}

/// Keys while the filter input is focused: all chars go to the filter,
/// only Escape exits filter mode. Arrow keys still navigate the list.
fn handle_filter_focused(state: &mut MemoryModalState, key: &KeyEvent) -> InputOutcome {
    match key.code {
        KeyCode::Esc => {
            state.mode = MemoryModalMode::Browse;
            InputOutcome::Changed
        }
        KeyCode::Down => {
            state.select_next();
            InputOutcome::Changed
        }
        KeyCode::Up => {
            state.select_prev();
            InputOutcome::Changed
        }
        KeyCode::Char(c) if crate::input::key::is_text_input_key(key) => {
            state.query.push(c);
            state.invalidate_filter();
            state.clamp_selected();
            InputOutcome::Changed
        }
        KeyCode::Backspace => {
            if state.query.pop().is_some() {
                state.invalidate_filter();
                state.clamp_selected();
                InputOutcome::Changed
            } else {
                InputOutcome::Unchanged
            }
        }
        _ => InputOutcome::Unchanged,
    }
}

/// Keys in normal browse mode: single-char hotkeys active, `/` enters
/// filter mode.
fn handle_browse(state: &mut MemoryModalState, key: &KeyEvent) -> InputOutcome {
    if key.code == KeyCode::Char('f') && key.modifiers.contains(KeyModifiers::CONTROL) {
        state.fullscreen = !state.fullscreen;
        return InputOutcome::Action(Action::PersistMemoryFullscreen(state.fullscreen));
    }
    match key.code {
        KeyCode::Down | KeyCode::Char('j') => {
            state.select_next();
            InputOutcome::Changed
        }
        KeyCode::Up | KeyCode::Char('k') => {
            state.select_prev();
            InputOutcome::Changed
        }
        KeyCode::PageDown => {
            let mut moved = false;
            for _ in 0..10 {
                moved |= state.advance_next();
            }
            if moved {
                state.load_preview();
            }
            InputOutcome::Changed
        }
        KeyCode::PageUp => {
            let mut moved = false;
            for _ in 0..10 {
                moved |= state.advance_prev();
            }
            if moved {
                state.load_preview();
            }
            InputOutcome::Changed
        }
        KeyCode::Char('x') if key.modifiers.is_empty() => {
            if let Some(entry) = state.selected_entry()
                && !entry.is_header
                && entry.source == "session"
            {
                state.mode = MemoryModalMode::ConfirmingDelete {
                    idx: state.selected,
                };
                return InputOutcome::Changed;
            }
            InputOutcome::Unchanged
        }
        KeyCode::Char('y') if key.modifiers.is_empty() => {
            if let Some(entry) = state.selected_entry()
                && !entry.is_header
            {
                let _ = crate::clipboard::copy_text(&entry.path.to_string_lossy());
                return InputOutcome::Changed;
            }
            InputOutcome::Unchanged
        }
        KeyCode::Char('t') if key.modifiers.is_empty() => {
            let cmd = if state.memory_enabled {
                "/memory off"
            } else {
                "/memory on"
            };
            // Optimistic update — the shortcut bar reflects the new state
            // immediately. The shell re-syncs via the next MemoryFiles
            // notification if the command fails.
            state.memory_enabled = !state.memory_enabled;
            InputOutcome::Action(Action::SendSlashCommandPreservingDraft(cmd.to_owned()))
        }
        // `i` aliases `/` (vim-nav "press i to search").
        KeyCode::Char('/') | KeyCode::Char('i') if key.modifiers.is_empty() => {
            state.mode = MemoryModalMode::FilterFocused;
            InputOutcome::Changed
        }
        KeyCode::Backspace => {
            if state.query.pop().is_some() {
                state.invalidate_filter();
                state.clamp_selected();
                InputOutcome::Changed
            } else {
                InputOutcome::Unchanged
            }
        }
        _ => InputOutcome::Unchanged,
    }
}

fn build_shortcuts(
    mode: &MemoryModalMode,
    memory_enabled: bool,
    fullscreen: bool,
) -> Vec<Shortcut<'static>> {
    match mode {
        MemoryModalMode::Browse => {
            let toggle_label = if memory_enabled {
                "t toggle (on)"
            } else {
                "t toggle (off)"
            };
            let mut shortcuts = vec![
                Shortcut {
                    label: "\u{2191}/\u{2193} nav",
                    clickable: false,
                    id: 0,
                },
                Shortcut {
                    label: "/ search",
                    clickable: false,
                    id: 0,
                },
                Shortcut {
                    label: "y copy path",
                    clickable: false,
                    id: 0,
                },
                Shortcut {
                    label: "x delete",
                    clickable: false,
                    id: 0,
                },
                Shortcut {
                    label: toggle_label,
                    clickable: false,
                    id: 0,
                },
                Shortcut {
                    label: if fullscreen {
                        "^F normal"
                    } else {
                        "^F fullscreen"
                    },
                    clickable: false,
                    id: 0,
                },
                Shortcut {
                    label: "Esc close",
                    clickable: false,
                    id: 0,
                },
            ];
            // Browse is nav mode (filter inactive), so append `i search` last
            // (matching the shared pickers).
            modal_window::push_vim_nav_search_hint(&mut shortcuts, false);
            shortcuts
        }
        MemoryModalMode::FilterFocused => vec![
            Shortcut {
                label: "type to filter",
                clickable: false,
                id: 0,
            },
            Shortcut {
                label: "Esc exit filter",
                clickable: false,
                id: 0,
            },
        ],
        MemoryModalMode::ConfirmingDelete { .. } => vec![
            Shortcut {
                label: "x confirm delete",
                clickable: false,
                id: 0,
            },
            Shortcut {
                label: "any key cancel",
                clickable: false,
                id: 0,
            },
        ],
    }
}

/// Truncate a string to fit within `max_width` display columns.
/// Delegates to `render::line_utils::byte_offset_at_width` to avoid
/// duplicating the Unicode-width scanning logic.
fn truncate_to_width(s: &str, max_width: usize) -> &str {
    let offset = crate::render::line_utils::byte_offset_at_width(s, max_width);
    &s[..offset]
}

fn file_label(path: &str) -> String {
    let p = std::path::Path::new(path);
    p.file_name()
        .map(|f| f.to_string_lossy().into_owned())
        .unwrap_or_else(|| path.to_string())
}

fn format_size(bytes: u64) -> String {
    if bytes < 1024 {
        return format!("{bytes}B");
    }
    let kb = bytes as f64 / 1024.0;
    if kb < 1024.0 {
        return format!("{kb:.1}KB");
    }
    let mb = kb / 1024.0;
    format!("{mb:.1}MB")
}

fn format_modified(epoch_secs: Option<u64>, now_secs: u64) -> String {
    let Some(modified) = epoch_secs else {
        return "unknown".to_string();
    };
    if now_secs <= modified {
        return "just now".to_string();
    }
    let delta = now_secs - modified;
    if delta < 60 {
        return "just now".to_string();
    }
    if delta < 3600 {
        let mins = delta / 60;
        return format!("{mins}m ago");
    }
    if delta < 86400 {
        let hours = delta / 3600;
        return format!("{hours}h ago");
    }
    let days = delta / 86400;
    format!("{days}d ago")
}

fn load_fullscreen_pref() -> bool {
    let path = kigi_tools::util::kigi_home::kigi_home().join("config.toml");
    let Some(doc) = crate::config_toml_edit::read_config_document_for_edit(&path) else {
        return false;
    };
    doc.get("hints")
        .and_then(|h| h.get("memory_modal_fullscreen"))
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
}

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

    #[test]
    fn format_size_ranges() {
        assert_eq!(format_size(0), "0B");
        assert_eq!(format_size(512), "512B");
        assert_eq!(format_size(1024), "1.0KB");
        assert_eq!(format_size(1536), "1.5KB");
        assert_eq!(format_size(1048576), "1.0MB");
    }

    #[test]
    fn format_modified_relative() {
        let now = 1_700_000_000u64;
        assert_eq!(format_modified(None, now), "unknown");
        assert_eq!(format_modified(Some(now), now), "just now");
        assert_eq!(format_modified(Some(now - 30), now), "just now");
        assert_eq!(format_modified(Some(now - 120), now), "2m ago");
        assert_eq!(format_modified(Some(now - 7200), now), "2h ago");
        assert_eq!(format_modified(Some(now - 172800), now), "2d ago");
    }

    #[test]
    fn file_label_extracts_filename() {
        assert_eq!(file_label("/home/user/.kigi/memory/MEMORY.md"), "MEMORY.md");
        assert_eq!(
            file_label("/workspace/.kigi/memory/sessions/2026-01-15-fix-bug.md"),
            "2026-01-15-fix-bug.md"
        );
    }

    #[test]
    fn build_entries_groups_by_source() {
        use kigi_shell::extensions::notification::MemoryFileInfo;

        let files = vec![
            MemoryFileInfo {
                path: "/global/MEMORY.md".into(),
                source: "global".into(),
                size_bytes: 100,
                modified_epoch_secs: Some(1_700_000_000),
            },
            MemoryFileInfo {
                path: "/workspace/MEMORY.md".into(),
                source: "workspace".into(),
                size_bytes: 200,
                modified_epoch_secs: Some(1_700_000_000),
            },
            MemoryFileInfo {
                path: "/sessions/log1.md".into(),
                source: "session".into(),
                size_bytes: 50,
                modified_epoch_secs: None,
            },
        ];

        let entries = build_entries(files);
        assert_eq!(entries.len(), 6);
        assert!(entries[0].is_header);
        assert_eq!(entries[0].label, "Global");
        assert!(!entries[1].is_header);
        assert!(entries[2].is_header);
        assert_eq!(entries[2].label, "Workspace");
        assert!(entries[4].is_header);
        assert_eq!(entries[4].label, "Sessions");
    }

    #[test]
    fn new_selects_first_non_header() {
        let entries = build_test_entries();
        let state = MemoryModalState::new(entries);
        // Index 0 is a header; selected should skip to index 1.
        assert_eq!(state.selected, 1);
        let sel = state.selected_entry().unwrap();
        assert!(!sel.is_header);
    }

    #[test]
    fn filtered_indices_preserves_headers_for_matching_entries() {
        let entries = build_test_entries();
        let mut state = MemoryModalState::new(entries);
        state.query = "memory".to_string();
        state.invalidate_filter();

        let indices = state.filtered_indices();
        assert_eq!(indices.len(), 2);
        assert_eq!(indices[0], 0); // Global header
        assert_eq!(indices[1], 1); // MEMORY.md
    }

    #[test]
    fn delete_only_allowed_for_session_entries() {
        let entries = build_test_entries();
        let mut state = MemoryModalState::new(entries);
        // Select the global entry (index 1 in filtered, which is "MEMORY.md" source="global")
        state.selected = 1;
        // Try to delete — should NOT enter confirming mode because source is "global".
        let key = crossterm::event::KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
        let outcome = handle_memory_key(&mut state, &key);
        assert!(matches!(outcome, InputOutcome::Unchanged));
        assert_eq!(state.mode, MemoryModalMode::Browse);

        // Select the session entry (index 3 in filtered, which is "session-log.md" source="session")
        state.selected = 3;
        let outcome = handle_memory_key(&mut state, &key);
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(matches!(
            state.mode,
            MemoryModalMode::ConfirmingDelete { .. }
        ));
    }

    /// `i` aliases `/` without modifiers: from Browse it enters FilterFocused
    /// exactly like `/` (vim-nav "press i to search").
    #[test]
    fn i_key_enters_filter_like_slash() {
        let mut state = MemoryModalState::new(build_test_entries());
        assert_eq!(state.mode, MemoryModalMode::Browse);
        let i = crossterm::event::KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
        assert!(matches!(
            handle_memory_key(&mut state, &i),
            InputOutcome::Changed
        ));
        assert_eq!(state.mode, MemoryModalMode::FilterFocused);
    }

    /// The `modifiers.is_empty()` guard: Ctrl+i / Alt+i must NOT enter filter.
    #[test]
    fn modified_i_does_not_enter_filter() {
        for mods in [KeyModifiers::CONTROL, KeyModifiers::ALT] {
            let mut state = MemoryModalState::new(build_test_entries());
            let k = crossterm::event::KeyEvent::new(KeyCode::Char('i'), mods);
            assert!(matches!(
                handle_memory_key(&mut state, &k),
                InputOutcome::Unchanged
            ));
            assert_eq!(state.mode, MemoryModalMode::Browse);
        }
    }

    /// Wiring check: the Browse footer carries the shared `i search` hint under
    /// vim nav mode. The gate is covered centrally by `modal_window`'s
    /// `vim_nav_search_hint_only_in_vim_nav_mode`. The explicit `set_vim_mode`
    /// pin (a thread-local that, once set, blocks disk-seeding) keeps this
    /// independent of the dev's on-disk `[ui].vim_mode`; reset afterward since
    /// libtest reuses worker threads.
    #[test]
    fn browse_footer_advertises_i_search_under_vim() {
        crate::appearance::cache::set_vim_mode(true);
        let vim = build_shortcuts(&MemoryModalMode::Browse, true, false);
        assert!(
            vim.iter().any(|s| s.label == "i search"),
            "vim-mode Browse footer must advertise `i search`"
        );
        crate::appearance::cache::set_vim_mode(false);
    }

    #[test]
    fn truncate_to_width_handles_ascii() {
        assert_eq!(truncate_to_width("hello world", 5), "hello");
        assert_eq!(truncate_to_width("hello", 10), "hello");
        assert_eq!(truncate_to_width("", 5), "");
    }

    #[test]
    fn truncate_to_width_handles_multibyte() {
        // CJK characters are 2 columns wide.
        let s = "\u{4F60}\u{597D}world"; // 你好world — 2+2+5 = 9 cols
        assert_eq!(truncate_to_width(s, 4), "\u{4F60}\u{597D}"); // 2+2 = 4
        assert_eq!(truncate_to_width(s, 3), "\u{4F60}"); // 2, next char is 2 → exceeds 3
        assert_eq!(truncate_to_width(s, 9), s); // fits
    }

    #[test]
    fn cached_filter_updates_on_invalidate() {
        let entries = build_test_entries();
        let mut state = MemoryModalState::new(entries);
        assert_eq!(state.filtered_indices().len(), 4); // all entries

        state.query = "session".to_string();
        state.invalidate_filter();
        // Only the Sessions header + session-log.md should match.
        assert_eq!(state.filtered_indices().len(), 2);

        state.query.clear();
        state.invalidate_filter();
        assert_eq!(state.filtered_indices().len(), 4);
    }

    #[test]
    fn select_at_skips_headers_and_updates() {
        let entries = build_test_entries();
        let mut state = MemoryModalState::new(entries);
        // Initial selection is index 1 (first non-header).
        assert_eq!(state.selected, 1);

        // Select the session entry at filtered index 3.
        assert!(state.select_at(3));
        assert_eq!(state.selected, 3);
        let sel = state.selected_entry().unwrap();
        assert_eq!(sel.label, "session-log.md");

        // Selecting a header should fail.
        assert!(!state.select_at(0));
        assert_eq!(state.selected, 3);

        // Selecting the same index again should return false.
        assert!(!state.select_at(3));
    }

    #[test]
    fn select_at_out_of_bounds() {
        let entries = build_test_entries();
        let mut state = MemoryModalState::new(entries);
        assert!(!state.select_at(100));
        assert_eq!(state.selected, 1);
    }

    #[test]
    fn mouse_click_selects_file() {
        let entries = build_test_entries();
        let mut state = MemoryModalState::new(entries);
        // Simulate a rendered list area.
        state.list_area = Rect::new(5, 10, 30, 20);
        state.scroll_offset = 0;

        // Click on row corresponding to filtered index 3 (entries_start_y = 11, row 3 → y=14).
        let result = handle_memory_mouse(
            &mut state,
            MouseEventKind::Down(crossterm::event::MouseButton::Left),
            10,
            14,
        );
        assert!(matches!(result, InputOutcome::Changed));
        assert_eq!(state.selected, 3);
    }

    #[test]
    fn mouse_click_on_header_unchanged() {
        let entries = build_test_entries();
        let mut state = MemoryModalState::new(entries);
        state.list_area = Rect::new(5, 10, 30, 20);
        state.scroll_offset = 0;

        // Click on row 0 (header "Global" at y=11).
        let result = handle_memory_mouse(
            &mut state,
            MouseEventKind::Down(crossterm::event::MouseButton::Left),
            10,
            11,
        );
        assert!(matches!(result, InputOutcome::Unchanged));
        assert_eq!(state.selected, 1);
    }

    #[test]
    fn mouse_scroll_up_down_on_list() {
        let entries = build_test_entries();
        let mut state = MemoryModalState::new(entries);
        state.list_area = Rect::new(0, 0, 30, 10);

        // Scroll down on the list area.
        let result = handle_memory_mouse(&mut state, MouseEventKind::ScrollDown, 5, 3);
        assert!(matches!(result, InputOutcome::Changed));

        // Scroll up on the list area.
        let result = handle_memory_mouse(&mut state, MouseEventKind::ScrollUp, 5, 3);
        assert!(matches!(result, InputOutcome::Changed));
    }

    #[test]
    fn mouse_scroll_on_preview() {
        let entries = build_test_entries();
        let mut state = MemoryModalState::new(entries);
        state.preview_area = Rect::new(40, 0, 30, 10);
        state.preview_total_lines = 100;
        state.preview_scroll = 5;

        // Scroll down on the preview area.
        let result = handle_memory_mouse(&mut state, MouseEventKind::ScrollDown, 50, 3);
        assert!(matches!(result, InputOutcome::Changed));
        assert_eq!(state.preview_scroll, 8);

        // Scroll up on the preview area.
        let result = handle_memory_mouse(&mut state, MouseEventKind::ScrollUp, 50, 3);
        assert!(matches!(result, InputOutcome::Changed));
        assert_eq!(state.preview_scroll, 5);
    }

    #[test]
    fn mouse_outside_both_panes_unchanged() {
        let entries = build_test_entries();
        let mut state = MemoryModalState::new(entries);
        state.list_area = Rect::new(0, 0, 30, 10);
        state.preview_area = Rect::new(40, 0, 30, 10);

        // Click outside both areas.
        let result = handle_memory_mouse(
            &mut state,
            MouseEventKind::Down(crossterm::event::MouseButton::Left),
            35,
            5,
        );
        assert!(matches!(result, InputOutcome::Unchanged));
    }

    #[test]
    fn ctrl_d_u_no_longer_scrolls_preview() {
        let entries = build_test_entries();
        let mut state = MemoryModalState::new(entries);
        state.preview_scroll = 5;

        // Ctrl+D should NOT scroll preview (removed hotkey).
        let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
        let result = handle_memory_key(&mut state, &key);
        assert!(matches!(result, InputOutcome::Unchanged));
        assert_eq!(state.preview_scroll, 5);

        // Ctrl+U should NOT scroll preview (removed hotkey).
        let key = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
        let result = handle_memory_key(&mut state, &key);
        assert!(matches!(result, InputOutcome::Unchanged));
        assert_eq!(state.preview_scroll, 5);
    }

    #[test]
    fn apply_scrollbar_jump_edges() {
        let mut offset = 50;
        // Top click → offset 0.
        apply_scrollbar_jump(10, Rect::new(0, 10, 1, 20), 100, 10, &mut offset);
        assert_eq!(offset, 0);

        // Bottom click → max offset.
        apply_scrollbar_jump(29, Rect::new(0, 10, 1, 20), 100, 10, &mut offset);
        assert_eq!(offset, 90);
    }

    fn build_test_entries() -> Vec<MemoryFileEntry> {
        vec![
            MemoryFileEntry {
                path: PathBuf::new(),
                source: String::new(),
                meta_display: String::new(),
                label: "Global".to_string(),
                is_header: true,
            },
            MemoryFileEntry {
                path: PathBuf::from("/test/MEMORY.md"),
                source: "global".into(),
                meta_display: "1KB \u{00B7} 1d ago".into(),
                label: "MEMORY.md".to_string(),
                is_header: false,
            },
            MemoryFileEntry {
                path: PathBuf::new(),
                source: String::new(),
                meta_display: String::new(),
                label: "Sessions".to_string(),
                is_header: true,
            },
            MemoryFileEntry {
                path: PathBuf::from("/test/session.md"),
                source: "session".into(),
                meta_display: "500B \u{00B7} 2d ago".into(),
                label: "session-log.md".to_string(),
                is_header: false,
            },
        ]
    }
}
