use std::collections::HashMap;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use anyhow::Result;
use crossterm::{
    ExecutableCommand,
    event::{
        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, MouseButton,
        MouseEvent, MouseEventKind,
    },
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
    Terminal,
    layout::{Constraint, Direction, Layout, Rect},
    prelude::CrosstermBackend,
};

use notify::event::{EventKind, ModifyKind, RenameMode};

use crate::commands::chord_engine::{ChordEngine, parens_balanced};
use crate::commands::lsp_engine::{InstallProgress, LspEngine, LspEngineConfig};
use crate::commands::syntax_engine::{SyntaxEngine, SyntaxFrontend};
use crate::data::buffer::Buffer;
use crate::data::file_tree::FileEntry;
use crate::data::lsp::types::{InstallLine, Language, LspSharedState, SemanticToken, ServerState};
use crate::data::state::{
    EditorState, Mode, Selection, TreeDeleteState, TreeNewFileState, TreeRenameState,
};

use super::chord_box;
use super::editor_pane;
use super::exit_modal;
use super::fs_watcher::FsWatcher;
use super::list_dialog;
use super::status_bar;
use super::title_bar;
use super::tree_pane;

fn refresh_buffer_caches(state: &mut EditorState, syntax_engine: &mut SyntaxEngine) {
    if let Some(buf) = state.current_buffer() {
        let content = buf.content();
        syntax_engine.compute(&buf.path, &content);
        state.cached_token_count = title_bar::compute_token_count(&content);
    }
}

fn prev_char_boundary(s: &str, from: usize) -> usize {
    let mut idx = from.min(s.len());
    while idx > 0 && !s.is_char_boundary(idx) {
        idx -= 1;
    }
    if idx > 0 {
        idx -= 1;
        while idx > 0 && !s.is_char_boundary(idx) {
            idx -= 1;
        }
    }
    idx
}

fn next_char_boundary(s: &str, from: usize) -> usize {
    let mut idx = from.min(s.len());
    if idx < s.len() {
        idx += 1;
        while idx < s.len() && !s.is_char_boundary(idx) {
            idx += 1;
        }
    }
    idx
}

fn snap_to_char_boundary(s: &str, pos: usize) -> usize {
    let mut idx = pos.min(s.len());
    while idx > 0 && !s.is_char_boundary(idx) {
        idx -= 1;
    }
    idx
}

/// Delete the currently-selected range from the active buffer, place the
/// cursor at the start of the selection, and clear the selection. Returns
/// true if any buffer content was removed.
fn delete_selection(state: &mut EditorState) -> bool {
    let sel = match state.selection {
        Some(s) => s,
        None => return false,
    };
    let (start_line, start_col, end_line, end_col) = sel.ordered();

    let buf = match state.buffers.get_mut(state.active_buffer) {
        Some(b) => b,
        None => {
            state.selection = None;
            return false;
        }
    };

    if buf.lines.is_empty() || start_line >= buf.lines.len() {
        state.selection = None;
        return false;
    }

    let start_col = snap_to_char_boundary(&buf.lines[start_line], start_col);
    let modified;

    if start_line == end_line {
        let end_col = snap_to_char_boundary(&buf.lines[start_line], end_col);
        if start_col < end_col {
            buf.lines[start_line].drain(start_col..end_col);
            buf.dirty = true;
            modified = true;
        } else {
            modified = false;
        }
    } else {
        let last_line = end_line.min(buf.lines.len() - 1);
        let suffix = if end_line < buf.lines.len() {
            let ec = snap_to_char_boundary(&buf.lines[end_line], end_col);
            buf.lines[end_line][ec..].to_string()
        } else {
            String::new()
        };
        buf.lines[start_line].truncate(start_col);
        buf.lines[start_line].push_str(&suffix);
        let drain_end = (last_line + 1).min(buf.lines.len());
        if drain_end > start_line + 1 {
            buf.lines.drain(start_line + 1..drain_end);
        }
        buf.dirty = true;
        modified = true;
    }

    state.cursor_line = start_line;
    state.cursor_col = start_col;
    state.selection = None;
    modified
}

fn compute_text_width(state: &EditorState, term_width: u16) -> usize {
    let total = state.current_buffer().map_or(1, |b| b.line_count());
    let line_num_width = format!("{}", total.saturating_sub(1)).len();
    let has_tree = state.file_tree.is_some() && state.focus_tree;
    let editor_width = if has_tree {
        (term_width as usize) / 2
    } else {
        term_width as usize
    };
    editor_width.saturating_sub(line_num_width + 1)
}

fn move_cursor_left(state: &mut EditorState) {
    let at_start = state
        .current_buffer()
        .and_then(|b| b.lines.get(state.cursor_line))
        .is_some_and(|line| state.cursor_col.min(line.len()) == 0);
    if at_start && state.cursor_line > 0 {
        state.cursor_line -= 1;
        let end = state
            .current_buffer()
            .and_then(|b| b.lines.get(state.cursor_line))
            .map_or(0, |l| l.len());
        state.cursor_col = end;
    } else if let Some(line) = state
        .current_buffer()
        .and_then(|b| b.lines.get(state.cursor_line))
    {
        state.cursor_col = prev_char_boundary(line, state.cursor_col.min(line.len()));
    }
}

fn move_cursor_right(state: &mut EditorState) {
    let (at_end, line_count) = {
        let at_end = state
            .current_buffer()
            .and_then(|b| b.lines.get(state.cursor_line))
            .is_none_or(|line| snap_to_char_boundary(line, state.cursor_col) >= line.len());
        let count = state.current_buffer().map_or(0, |b| b.line_count());
        (at_end, count)
    };
    if at_end && state.cursor_line + 1 < line_count {
        state.cursor_line += 1;
        state.cursor_col = 0;
    } else if let Some(line) = state
        .current_buffer()
        .and_then(|b| b.lines.get(state.cursor_line))
    {
        state.cursor_col = next_char_boundary(line, snap_to_char_boundary(line, state.cursor_col));
    }
}

fn move_cursor_up(state: &mut EditorState, text_width: usize) {
    let cur_info = state
        .current_buffer()
        .and_then(|b| b.lines.get(state.cursor_line))
        .map(|line| {
            let dc = editor_pane::display_col(line, state.cursor_col);
            let offsets = editor_pane::wrap_offsets(line, text_width);
            let (row, col_in_row) = editor_pane::display_col_to_wrap_pos(&offsets, dc);
            (row, col_in_row, offsets)
        });
    if let Some((visual_row, col_in_row, offsets)) = cur_info {
        if visual_row > 0 {
            let target = editor_pane::wrap_row_start(&offsets, visual_row - 1) + col_in_row;
            if let Some(line) = state
                .current_buffer()
                .and_then(|b| b.lines.get(state.cursor_line))
            {
                state.cursor_col = editor_pane::byte_col_from_display(line, target);
            }
        } else if state.cursor_line > 0 {
            state.cursor_line -= 1;
            if let Some(prev_line) = state
                .current_buffer()
                .and_then(|b| b.lines.get(state.cursor_line))
            {
                if text_width > 0 {
                    let prev_offsets = editor_pane::wrap_offsets(prev_line, text_width);
                    let last_row = prev_offsets.len() - 1;
                    let target = editor_pane::wrap_row_start(&prev_offsets, last_row) + col_in_row;
                    state.cursor_col = editor_pane::byte_col_from_display(prev_line, target);
                } else {
                    state.cursor_col = snap_to_char_boundary(prev_line, state.cursor_col);
                }
            }
        }
    }
}

fn move_cursor_down(state: &mut EditorState, text_width: usize) {
    let cur_info = state
        .current_buffer()
        .and_then(|b| b.lines.get(state.cursor_line))
        .map(|line| {
            let dc = editor_pane::display_col(line, state.cursor_col);
            let offsets = editor_pane::wrap_offsets(line, text_width);
            let (visual_row, col_in_row) = editor_pane::display_col_to_wrap_pos(&offsets, dc);
            let total_rows = offsets.len();
            (visual_row, col_in_row, total_rows, offsets)
        });
    let has_next = state
        .current_buffer()
        .and_then(|b| b.lines.get(state.cursor_line + 1))
        .is_some();
    if let Some((visual_row, col_in_row, total_rows, offsets)) = cur_info {
        if total_rows > 1 && visual_row < total_rows - 1 {
            let target = editor_pane::wrap_row_start(&offsets, visual_row + 1) + col_in_row;
            if let Some(line) = state
                .current_buffer()
                .and_then(|b| b.lines.get(state.cursor_line))
            {
                state.cursor_col = editor_pane::byte_col_from_display(line, target);
            }
        } else if has_next {
            state.cursor_line += 1;
            if let Some(next_line) = state
                .current_buffer()
                .and_then(|b| b.lines.get(state.cursor_line))
            {
                if text_width > 0 {
                    state.cursor_col = editor_pane::byte_col_from_display(next_line, col_in_row);
                } else {
                    state.cursor_col = snap_to_char_boundary(next_line, state.cursor_col);
                }
            }
        }
    }
}

use super::tui_frontend::TuiFrontend;

use crate::frontend::traits::ApplyChordAction;

struct TuiInstallProgress {
    shared: Arc<Mutex<LspSharedState>>,
}

impl InstallProgress for TuiInstallProgress {
    fn on_stdout(&self, line: &str) {
        self.shared.lock().unwrap().install_line = Some(InstallLine::Stdout(line.to_string()));
    }
    fn on_stderr(&self, line: &str) {
        self.shared.lock().unwrap().install_line = Some(InstallLine::Stderr(line.to_string()));
    }
    fn on_failed(&self, message: &str) {
        self.shared.lock().unwrap().install_line = Some(InstallLine::Failed(message.to_string()));
    }
    fn on_complete(&self) {
        self.shared.lock().unwrap().install_line = None;
    }
}

/// TUI implementation of SyntaxFrontend — stores tokens per-path for the render loop.
struct TuiSyntaxReceiver {
    tokens: Arc<Mutex<HashMap<PathBuf, Vec<SemanticToken>>>>,
}

impl TuiSyntaxReceiver {
    fn new() -> Self {
        Self {
            tokens: Arc::new(Mutex::new(HashMap::new())),
        }
    }

    fn tokens_for(&self, path: &Path) -> Vec<SemanticToken> {
        self.tokens
            .lock()
            .unwrap()
            .get(path)
            .cloned()
            .unwrap_or_default()
    }
}

impl SyntaxFrontend for TuiSyntaxReceiver {
    fn set_semantic_tokens(&self, path: &Path, tokens: Vec<SemanticToken>) {
        self.tokens
            .lock()
            .unwrap()
            .insert(path.to_path_buf(), tokens);
    }
}

pub fn run(path: &Path) -> Result<()> {
    let mut state = if path.is_dir() {
        EditorState::for_directory(path)?
    } else {
        EditorState::for_file(path)?
    };

    let engine = Arc::new(Mutex::new(LspEngine::new(LspEngineConfig::default())));
    let root = if path.is_dir() {
        path.to_path_buf()
    } else {
        path.parent().unwrap_or(Path::new(".")).to_path_buf()
    };
    let files: Vec<&Path> = if path.is_file() { vec![path] } else { vec![] };
    {
        let mut eng = engine.lock().unwrap();
        let progress: Arc<dyn InstallProgress> = Arc::new(TuiInstallProgress {
            shared: Arc::clone(&state.lsp_state),
        });
        eng.set_install_progress(progress);
        let _ = eng.start_for_context(&root, &files);
    }

    // Create the syntax receiver (Layer 2's impl of SyntaxFrontend)
    let syntax_receiver = Arc::new(TuiSyntaxReceiver::new());

    // Create SyntaxEngine (Layer 1) — it owns the LspEngine reference
    // and spawns its own background worker for debounced LSP requests
    let mut syntax_engine = SyntaxEngine::new(
        Arc::clone(&engine),
        Arc::clone(&syntax_receiver) as Arc<dyn SyntaxFrontend>,
    );

    // Initial compute for the opened file
    refresh_buffer_caches(&mut state, &mut syntax_engine);

    let mut fs_watcher = FsWatcher::new().ok();
    if let Some(ref mut watcher) = fs_watcher {
        if let Some(buf) = state.current_buffer_mut() {
            buf.record_disk_mtime();
            let _ = watcher.watch_file(&buf.path);
        }
        if let Some(tree) = &state.file_tree {
            let _ = watcher.watch_tree(&tree.root);
        }
    }

    // Status polling thread — polls all server statuses
    {
        let poll_engine = Arc::clone(&engine);
        let poll_shared = Arc::clone(&state.lsp_state);
        std::thread::spawn(move || {
            status_polling_task(poll_engine, poll_shared);
        });
    }

    enable_raw_mode()?;
    io::stdout().execute(EnterAlternateScreen)?;
    io::stdout().execute(EnableMouseCapture)?;
    let backend = CrosstermBackend::new(io::stdout());
    let mut terminal = Terminal::new(backend)?;

    let mut frontend = TuiFrontend::new();
    let result = event_loop(
        &mut terminal,
        &mut state,
        &mut frontend,
        &engine,
        &mut syntax_engine,
        &syntax_receiver,
        &mut fs_watcher,
    );

    {
        let mut eng = engine.lock().unwrap();
        eng.shutdown_all();
    }
    io::stdout().execute(DisableMouseCapture)?;
    disable_raw_mode()?;
    io::stdout().execute(LeaveAlternateScreen)?;

    result
}

fn status_polling_task(engine: Arc<Mutex<LspEngine>>, shared: Arc<Mutex<LspSharedState>>) {
    loop {
        let summary = {
            let eng = engine.lock().unwrap();
            eng.status_summary()
        };

        let all_terminal = {
            let mut s = shared.lock().unwrap();
            s.status.clear();
            let mut all_terminal = !summary.is_empty();
            for (lang, state) in &summary {
                s.status.insert(*lang, *state);
                if !state.is_terminal() {
                    all_terminal = false;
                }
            }
            all_terminal
        };

        if all_terminal && !summary.is_empty() {
            break;
        }

        let any_running = summary.iter().any(|(_, s)| *s == ServerState::Running);
        let interval = if any_running {
            Duration::from_secs(3)
        } else {
            Duration::from_secs(1)
        };
        std::thread::sleep(interval);
    }
}

fn event_loop(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    state: &mut EditorState,
    frontend: &mut TuiFrontend,
    engine: &Arc<Mutex<LspEngine>>,
    syntax_engine: &mut SyntaxEngine,
    syntax_receiver: &Arc<TuiSyntaxReceiver>,
    fs_watcher: &mut Option<FsWatcher>,
) -> Result<()> {
    let mut prev_lsp_statuses: Vec<(Language, ServerState)> = Vec::new();

    loop {
        if let Some(watcher) = fs_watcher.as_mut() {
            while let Ok(event) = watcher.rx.try_recv() {
                if let Ok(ev) = event {
                    handle_fs_event(&ev, state, watcher);
                }
            }
        }

        let lsp_statuses: Vec<(Language, ServerState)> = {
            let s = state.lsp_state.lock().unwrap();
            s.status.iter().map(|(l, s)| (*l, *s)).collect()
        };

        // Detect if any LSP server just became Running
        let lsp_became_ready = lsp_statuses.iter().any(|(lang, st)| {
            *st == ServerState::Running
                && !prev_lsp_statuses
                    .iter()
                    .any(|(l, s)| l == lang && *s == ServerState::Running)
        });
        if lsp_became_ready && let Some(buf) = state.current_buffer() {
            syntax_engine.compute(&buf.path, &buf.content());
        }
        prev_lsp_statuses = lsp_statuses.clone();

        // Read tokens from the syntax receiver
        let tokens = if let Some(buf) = state.current_buffer() {
            syntax_receiver.tokens_for(&buf.path)
        } else {
            vec![]
        };

        let term_size = terminal.size()?;
        adjust_scroll_offset(state, term_size.height, term_size.width);

        terminal.draw(|frame| {
            draw(frame, state, &tokens, &lsp_statuses);
        })?;

        if state.should_quit {
            return Ok(());
        }

        let editor_area =
            compute_editor_render_area(state, Rect::new(0, 0, term_size.width, term_size.height));

        let prev_active = state.active_buffer;
        let prev_buf_path = state.current_buffer().map(|b| b.path.clone());
        let had_tree = state.file_tree.is_some();

        if event::poll(Duration::from_millis(50))? {
            match event::read()? {
                Event::Key(key) => {
                    let buffer_modified = handle_key(
                        state,
                        frontend,
                        engine,
                        key.code,
                        key.modifiers,
                        syntax_engine,
                        &lsp_statuses,
                        term_size.width,
                    );
                    if buffer_modified {
                        state.selection = None;
                        refresh_buffer_caches(state, syntax_engine);
                    }
                }
                Event::Mouse(mouse) => {
                    handle_mouse(state, mouse, editor_area);
                }
                _ => {}
            }
        }

        if let Some(watcher) = fs_watcher.as_mut() {
            let cur_buf_path = state.current_buffer().map(|b| b.path.clone());
            if cur_buf_path != prev_buf_path || state.active_buffer != prev_active {
                watcher.unwatch_file();
                if let Some(buf) = state.current_buffer_mut() {
                    buf.record_disk_mtime();
                    let _ = watcher.watch_file(&buf.path);
                }
                state.disk_changed_path = None;
                state.pending_rewatch_path = None;
            }

            // Re-watch a file that was unwatched after a Remove/rename
            // (common during atomic saves: write-to-temp then rename).
            if let Some(rewatch_path) = state.pending_rewatch_path.take() {
                if rewatch_path.exists() {
                    let _ = watcher.watch_file(&rewatch_path);
                    mark_disk_changed(state);
                    if state.status_msg.contains("deleted from disk") {
                        state.status_msg.clear();
                    }
                } else {
                    state.pending_rewatch_path = Some(rewatch_path);
                }
            }

            let has_tree = state.file_tree.is_some();
            if has_tree && !had_tree {
                if let Some(tree) = &state.file_tree {
                    let _ = watcher.watch_tree(&tree.root);
                }
            } else if !has_tree && had_tree {
                watcher.unwatch_tree();
            }
        }
    }
}

fn adjust_scroll_offset(state: &mut EditorState, term_height: u16, term_width: u16) {
    let total = match state.current_buffer() {
        Some(buf) => buf.line_count(),
        None => return,
    };
    let chord_box_rows: usize = if state.mode == Mode::Chord && !state.focus_tree {
        4
    } else {
        0
    };
    let visible = (term_height as usize).saturating_sub(2 + chord_box_rows);
    if visible == 0 {
        state.scroll_offset = 0;
        return;
    }

    let line_num_width = format!("{}", total.saturating_sub(1)).len();
    let has_tree = state.file_tree.is_some() && state.focus_tree;
    let editor_width = if has_tree {
        (term_width as usize) / 2
    } else {
        term_width as usize
    };
    let text_width = editor_width.saturating_sub(line_num_width + 1);

    if state.cursor_line < state.scroll_offset {
        state.scroll_offset = state.cursor_line;
    }

    let active = state.active_buffer;
    loop {
        let mut visual_rows = 0;
        let cursor = state.cursor_line.min(total.saturating_sub(1));
        for i in state.scroll_offset..=cursor {
            let line = state.buffers[active]
                .lines
                .get(i)
                .map(|s| s.as_str())
                .unwrap_or("");
            if i == cursor {
                let cursor_display_col = editor_pane::display_col(line, state.cursor_col);
                let offsets = editor_pane::wrap_offsets(line, text_width);
                let (cursor_row_in_line, _) =
                    editor_pane::display_col_to_wrap_pos(&offsets, cursor_display_col);
                visual_rows += cursor_row_in_line + 1;
            } else {
                visual_rows += editor_pane::visual_row_count(line, text_width);
            }
        }

        if visual_rows <= visible || state.scroll_offset >= state.cursor_line {
            break;
        }

        state.scroll_offset += 1;
    }

    if state.scroll_offset >= total {
        state.scroll_offset = total.saturating_sub(1);
    }
}

struct PaneLayout {
    tree_area: Rect,
    title_area: Rect,
    pane_area: Rect,
    status_area: Rect,
    editor_render_area: Rect,
    has_tree: bool,
    chord_box_visible: bool,
}

fn compute_pane_layout(state: &EditorState, total: Rect) -> PaneLayout {
    let has_tree = state.file_tree.is_some() && state.focus_tree;

    let h_constraints = if has_tree {
        let content_w = tree_pane::content_width(&state.tree_view) as u16;
        let desired = content_w + 3;
        let max_w = total.width / 2;
        let tree_w = desired.min(max_w);
        vec![Constraint::Length(tree_w), Constraint::Min(0)]
    } else {
        vec![Constraint::Length(0), Constraint::Percentage(100)]
    };

    let h_layout = Layout::default()
        .direction(Direction::Horizontal)
        .constraints(h_constraints)
        .split(total);

    let tree_area = h_layout[0];
    let editor_area = h_layout[1];

    let v_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Min(0),
            Constraint::Length(1),
        ])
        .split(editor_area);

    let title_area = v_layout[0];
    let pane_area = v_layout[1];
    let status_area = v_layout[2];

    let chord_box_visible = state.mode == Mode::Chord && !state.focus_tree;
    let editor_render_area = if chord_box_visible {
        Rect::new(
            pane_area.x,
            pane_area.y,
            pane_area.width,
            pane_area.height.saturating_sub(4),
        )
    } else {
        pane_area
    };

    PaneLayout {
        tree_area,
        title_area,
        pane_area,
        status_area,
        editor_render_area,
        has_tree,
        chord_box_visible,
    }
}

fn compute_editor_render_area(state: &EditorState, term_size: Rect) -> Rect {
    compute_pane_layout(state, term_size).editor_render_area
}

fn draw(
    frame: &mut ratatui::Frame,
    state: &EditorState,
    tokens: &[SemanticToken],
    lsp_statuses: &[(Language, ServerState)],
) {
    let layout = compute_pane_layout(state, frame.area());

    if layout.has_tree {
        tree_pane::render(frame, layout.tree_area, state);
    }

    title_bar::render(frame, layout.title_area, state);
    editor_pane::render(frame, layout.editor_render_area, state, tokens);
    status_bar::render(frame, layout.status_area, state, lsp_statuses);

    if layout.chord_box_visible {
        chord_box::render(frame, layout.pane_area, state);
    }

    if let Some(ref dialog_state) = state.list_dialog {
        let dialog = list_dialog::ListDialog::new(
            dialog_state
                .items
                .iter()
                .map(
                    |(val, line, col)| crate::commands::chord_engine::types::ListItem {
                        val: val.clone(),
                        line: *line,
                        col: *col,
                    },
                )
                .collect(),
        );
        let mut d = dialog;
        d.selected = dialog_state.selected;
        list_dialog::render(frame, &d);
    } else if state.tree_delete_confirm.is_some() {
        exit_modal::render_delete_confirm(frame, state);
    } else if state.pending_open_path.is_some() {
        exit_modal::render_open_modal(frame);
    } else if state.show_exit_modal {
        exit_modal::render(frame, state);
    }
}

#[allow(clippy::too_many_arguments)]
fn handle_key(
    state: &mut EditorState,
    frontend: &mut TuiFrontend,
    engine: &Arc<Mutex<LspEngine>>,
    code: KeyCode,
    modifiers: KeyModifiers,
    syntax_engine: &mut SyntaxEngine,
    lsp_statuses: &[(Language, ServerState)],
    term_width: u16,
) -> bool {
    // Priority 0: List dialog
    if state.list_dialog.is_some() {
        handle_list_dialog(state, code);
        return false;
    }

    // Priority 1: Exit modal
    if state.show_exit_modal {
        handle_exit_modal(state, code, modifiers);
        return false;
    }

    // Priority 2: Open-confirm modal
    if state.pending_open_path.is_some() {
        handle_open_modal(state, code, modifiers, syntax_engine);
        return false;
    }

    // Priority 3: Tree operation states (rename, delete, new file).
    // These must run before the global Ctrl-T/S/Y/O shortcuts so those
    // keys are absorbed mid-edit and op state is never left dangling.
    if state.tree_rename_state.is_some() {
        handle_tree_rename_input(state, code, modifiers, syntax_engine);
        return false;
    }
    if state.tree_delete_confirm.is_some() {
        handle_tree_delete_input(state, code);
        return false;
    }
    if state.tree_new_file_state.is_some() {
        handle_tree_new_file_input(state, code, modifiers, syntax_engine);
        return false;
    }

    // Priority 4: Ctrl-O reloads file from disk when disk_changed is set
    if code == KeyCode::Char('o')
        && modifiers.contains(KeyModifiers::CONTROL)
        && state.current_buffer().is_some_and(|b| b.disk_changed)
    {
        reload_buffer_from_disk(state, syntax_engine);
        return false;
    }

    // Priority 5: Ctrl-T always handled
    if code == KeyCode::Char('t') && modifiers.contains(KeyModifiers::CONTROL) {
        toggle_tree(state);
        return false;
    }

    // Priority 5b: Ctrl-S always handled
    if code == KeyCode::Char('s') && modifiers.contains(KeyModifiers::CONTROL) {
        let write_result = state
            .buffers
            .get_mut(state.active_buffer)
            .map(|buf| buf.write());
        match write_result {
            Some(Ok(())) => {
                state.status_msg = "saved".into();
                state.disk_changed_path = None;
            }
            Some(Err(e)) => {
                state.status_msg = format!("save failed: {e}");
            }
            None => {}
        }
        return false;
    }

    // Priority 5c: Ctrl-Y yanks the active selection to the clipboard.
    // The selection is cleared after copying so the highlight and the
    // status-bar hint both disappear.
    if code == KeyCode::Char('y') && modifiers.contains(KeyModifiers::CONTROL) {
        if let Some(sel) = state.selection {
            let text = extract_selection_text(state, &sel);
            if !text.is_empty() {
                write_osc52(&text);
                state.status_msg = format!("copied {} chars", text.chars().count());
            }
            state.selection = None;
        }
        return false;
    }

    // Priority 6: Tree focus
    if state.focus_tree {
        handle_tree_keys(state, code, modifiers, syntax_engine);
        return false;
    }

    // Priority 5: Chord mode
    if state.mode == Mode::Chord {
        return handle_chord_mode(
            state,
            frontend,
            engine,
            code,
            modifiers,
            syntax_engine,
            lsp_statuses,
            term_width,
        );
    }

    // Priority 6: Edit mode
    handle_edit_mode(state, code, modifiers, term_width)
}

fn handle_list_dialog(state: &mut EditorState, code: KeyCode) {
    match code {
        KeyCode::Up => {
            if let Some(ref mut d) = state.list_dialog
                && d.selected > 0
            {
                d.selected -= 1;
            }
        }
        KeyCode::Down => {
            if let Some(ref mut d) = state.list_dialog
                && d.selected < d.items.len().saturating_sub(1)
            {
                d.selected += 1;
            }
        }
        KeyCode::Enter => {
            if let Some(ref d) = state.list_dialog
                && let Some((_, line, col)) = d.items.get(d.selected)
            {
                state.cursor_line = *line;
                state.cursor_col = *col;
            }
            state.list_dialog = None;
            state.mode = Mode::Edit;
            state.status_msg = "-- EDIT --".into();
        }
        KeyCode::Esc => {
            state.list_dialog = None;
        }
        _ => {}
    }
}

fn handle_exit_modal(state: &mut EditorState, code: KeyCode, modifiers: KeyModifiers) {
    match code {
        KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
            state.should_quit = true;
        }
        KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => {
            if let Some(buf) = state.buffers.get_mut(state.active_buffer) {
                let _ = buf.write();
            }
            state.should_quit = true;
        }
        KeyCode::Esc => {
            state.show_exit_modal = false;
        }
        _ => {}
    }
}

fn handle_open_modal(
    state: &mut EditorState,
    code: KeyCode,
    modifiers: KeyModifiers,
    syntax_engine: &mut SyntaxEngine,
) {
    match code {
        KeyCode::Char('s') if modifiers.contains(KeyModifiers::CONTROL) => {
            if let Some(buf) = state.buffers.get_mut(state.active_buffer) {
                let _ = buf.write();
            }
            if let Some(path) = state.pending_open_path.take() {
                let _ = state.open_file(&path);
                refresh_buffer_caches(state, syntax_engine);
            }
        }
        KeyCode::Char('o') if modifiers.contains(KeyModifiers::CONTROL) => {
            if let Some(path) = state.pending_open_path.take() {
                if let Some(buf) = state.buffers.get_mut(state.active_buffer) {
                    if let Ok(fresh) = Buffer::from_file(&buf.path) {
                        *buf = fresh;
                    } else {
                        buf.dirty = false;
                    }
                }
                let _ = state.open_file(&path);
                refresh_buffer_caches(state, syntax_engine);
            }
        }
        KeyCode::Esc => {
            state.pending_open_path = None;
        }
        _ => {}
    }
}

fn handle_tree_keys(
    state: &mut EditorState,
    code: KeyCode,
    modifiers: KeyModifiers,
    syntax_engine: &mut SyntaxEngine,
) {
    match code {
        KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
            state.show_exit_modal = true;
        }
        KeyCode::Char('e') if modifiers.contains(KeyModifiers::CONTROL) => {
            state.focus_tree = false;
            state.mode = Mode::Edit;
            state.status_msg = "-- EDIT --".into();
        }
        KeyCode::Char('r') if modifiers.contains(KeyModifiers::CONTROL) => {
            begin_tree_rename(state);
        }
        KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) => {
            begin_tree_delete(state);
        }
        KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => {
            begin_tree_new_file(state);
        }
        KeyCode::Up => {
            state.tree_selected = state.tree_selected.saturating_sub(1);
        }
        KeyCode::Down if !state.tree_view.is_empty() => {
            let max = state.tree_view.len().saturating_sub(1);
            if state.tree_selected < max {
                state.tree_selected += 1;
            }
        }
        KeyCode::Right => {
            if let Some(entry) = state
                .tree_view
                .get(state.tree_selected)
                .filter(|e| e.is_dir)
            {
                let _ = entry;
                tree_pane::expand(state, state.tree_selected);
            }
        }
        KeyCode::Left => {
            let selected = state.tree_selected;
            let is_expanded_dir = state.tree_view.get(selected).is_some_and(|e| {
                e.is_dir
                    && state
                        .tree_view
                        .get(selected + 1)
                        .is_some_and(|next| next.depth > e.depth)
            });
            if is_expanded_dir {
                tree_pane::collapse(state, selected);
            } else if let Some(depth) = state.tree_view.get(selected).map(|e| e.depth)
                && depth > 0
            {
                let parent = (0..selected)
                    .rev()
                    .find(|&j| state.tree_view[j].is_dir && state.tree_view[j].depth < depth);
                if let Some(idx) = parent {
                    state.tree_selected = idx;
                    tree_pane::collapse(state, idx);
                }
            }
        }
        KeyCode::Enter => {
            let selected = state.tree_selected;
            if let Some(entry) = state.tree_view.get(selected).filter(|e| !e.is_dir) {
                let path = entry.path.clone();
                let is_dirty = state.current_buffer().is_some_and(|b| b.dirty);
                if is_dirty {
                    state.pending_open_path = Some(path);
                } else {
                    let _ = state.open_file(&path);
                    refresh_buffer_caches(state, syntax_engine);
                }
            }
        }
        _ => {}
    }
}

#[allow(clippy::too_many_arguments)]
fn handle_chord_mode(
    state: &mut EditorState,
    frontend: &mut TuiFrontend,
    engine: &Arc<Mutex<LspEngine>>,
    code: KeyCode,
    modifiers: KeyModifiers,
    syntax_engine: &mut SyntaxEngine,
    lsp_statuses: &[(Language, ServerState)],
    term_width: u16,
) -> bool {
    match code {
        KeyCode::Char('e') if modifiers.contains(KeyModifiers::CONTROL) => {
            state.mode = Mode::Edit;
            state.status_msg = "-- EDIT --".into();
        }
        KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
            state.show_exit_modal = true;
        }
        KeyCode::Char('r')
            if modifiers.contains(KeyModifiers::CONTROL) && !state.chord_history.is_empty() =>
        {
            let idx = match state.chord_history_index {
                Some(i) if i > 0 => i - 1,
                Some(_) => 0,
                None => state.chord_history.len() - 1,
            };
            state.chord_history_index = Some(idx);
            state.chord_input = state.chord_history[idx].clone();
            state.chord_cursor_col = state.chord_input.len();
            state.chord_error = false;
        }
        KeyCode::Up if state.chord_input.is_empty() => {
            let tw = compute_text_width(state, term_width);
            move_cursor_up(state, tw);
        }
        KeyCode::Down if state.chord_input.is_empty() => {
            let tw = compute_text_width(state, term_width);
            move_cursor_down(state, tw);
        }
        KeyCode::Left if state.chord_input.is_empty() => {
            move_cursor_left(state);
        }
        KeyCode::Right if state.chord_input.is_empty() => {
            move_cursor_right(state);
        }
        KeyCode::Left => {
            state.chord_cursor_col = state.chord_cursor_col.saturating_sub(1);
        }
        KeyCode::Right if state.chord_cursor_col < state.chord_input.len() => {
            state.chord_cursor_col += 1;
        }
        KeyCode::Enter if !state.chord_input.is_empty() => {
            let input = state.chord_input.clone();
            match ChordEngine::parse(&input) {
                Ok(_) => {
                    clear_chord(state);
                    execute_chord_input(state, frontend, engine, &input, lsp_statuses);
                    if !state.status_msg.starts_with("error:")
                        && !state.status_msg.starts_with("resolve error:")
                        && !state.status_msg.starts_with("patch error:")
                        && !state.status_msg.starts_with("parse error:")
                    {
                        state.chord_history.push(input.clone());
                    }
                    refresh_buffer_caches(state, syntax_engine);
                }
                Err(_) => {
                    state.chord_error = true;
                    state.status_msg = "invalid chord".into();
                }
            }
        }
        KeyCode::Backspace if state.chord_cursor_col > 0 && !state.chord_input.is_empty() => {
            let col = state.chord_cursor_col.min(state.chord_input.len());
            state.chord_input.remove(col - 1);
            state.chord_cursor_col = col - 1;
            state.chord_error = false;
            state.chord_history_index = None;
            try_auto_submit(state, frontend, engine, syntax_engine, lsp_statuses);
        }
        KeyCode::Esc => {
            clear_chord(state);
            state.status_msg.clear();
            state.selection = None;
        }
        KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
            let col = state.chord_cursor_col.min(state.chord_input.len());
            state.chord_input.insert(col, c);
            state.chord_cursor_col = col + 1;
            state.chord_error = false;
            state.chord_history_index = None;
            try_auto_submit(state, frontend, engine, syntax_engine, lsp_statuses);
        }
        _ => {}
    }
    false
}

fn try_auto_submit(
    state: &mut EditorState,
    frontend: &mut TuiFrontend,
    engine: &Arc<Mutex<LspEngine>>,
    syntax_engine: &mut SyntaxEngine,
    lsp_statuses: &[(Language, ServerState)],
) {
    let input = &state.chord_input;

    if input.len() == 4 && input.chars().next().is_some_and(|c| c.is_lowercase()) {
        if let Some(_query) =
            ChordEngine::try_auto_submit_short(input, state.cursor_line, state.cursor_col)
        {
            let input_clone = state.chord_input.clone();
            state.chord_running = true;
            clear_chord(state);
            execute_chord_input(state, frontend, engine, &input_clone, lsp_statuses);
            if !state.status_msg.starts_with("error:")
                && !state.status_msg.starts_with("resolve error:")
                && !state.status_msg.starts_with("patch error:")
                && !state.status_msg.starts_with("parse error:")
            {
                state.chord_history.push(input_clone);
            }
            state.chord_running = false;
            refresh_buffer_caches(state, syntax_engine);
        }
    } else if input.ends_with(')')
        && input.chars().next().is_some_and(|c| c.is_uppercase())
        && parens_balanced(input)
        && ChordEngine::parse(input).is_ok()
    {
        let input_clone = state.chord_input.clone();
        state.chord_running = true;
        clear_chord(state);
        execute_chord_input(state, frontend, engine, &input_clone, lsp_statuses);
        if !state.status_msg.starts_with("error:")
            && !state.status_msg.starts_with("resolve error:")
            && !state.status_msg.starts_with("patch error:")
            && !state.status_msg.starts_with("parse error:")
        {
            state.chord_history.push(input_clone);
        }
        state.chord_running = false;
        refresh_buffer_caches(state, syntax_engine);
    }
}

fn clear_chord(state: &mut EditorState) {
    state.chord_input.clear();
    state.chord_cursor_col = 0;
    state.chord_error = false;
    state.chord_running = false;
    state.chord_history_index = None;
}

fn handle_edit_mode(
    state: &mut EditorState,
    code: KeyCode,
    modifiers: KeyModifiers,
    term_width: u16,
) -> bool {
    let mut modified = false;
    match code {
        KeyCode::Char('e') if modifiers.contains(KeyModifiers::CONTROL) => {
            state.mode = Mode::Chord;
            state.status_msg.clear();
            clear_chord(state);
        }
        KeyCode::Esc => {
            state.mode = Mode::Chord;
            state.status_msg.clear();
            clear_chord(state);
            state.selection = None;
        }
        KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
            state.show_exit_modal = true;
        }
        KeyCode::Tab => {
            modified |= delete_selection(state);
            let line = state.cursor_line;
            let col = state.cursor_col;
            if let Some(buf) = state.buffers.get_mut(state.active_buffer)
                && line < buf.lines.len()
            {
                let col = snap_to_char_boundary(&buf.lines[line], col);
                buf.lines[line].insert(col, '\t');
                buf.dirty = true;
                state.cursor_col = col + 1;
                modified = true;
            }
        }
        KeyCode::Up => {
            let tw = compute_text_width(state, term_width);
            move_cursor_up(state, tw);
        }
        KeyCode::Down => {
            let tw = compute_text_width(state, term_width);
            move_cursor_down(state, tw);
        }
        KeyCode::Left => {
            move_cursor_left(state);
        }
        KeyCode::Right => {
            move_cursor_right(state);
        }
        KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
            modified |= delete_selection(state);
            let line = state.cursor_line;
            let col = state.cursor_col;
            if let Some(buf) = state.buffers.get_mut(state.active_buffer)
                && line < buf.lines.len()
            {
                let col = snap_to_char_boundary(&buf.lines[line], col);
                buf.lines[line].insert(col, c);
                buf.dirty = true;
                state.cursor_col = col + c.len_utf8();
                modified = true;
            }
        }
        KeyCode::Backspace => {
            // Backspace on an active selection just deletes the selection —
            // no extra character is removed.
            if state.selection.is_some() {
                modified |= delete_selection(state);
            } else {
                let line = state.cursor_line;
                let col = state.cursor_col;
                if let Some(buf) = state.buffers.get_mut(state.active_buffer) {
                    let snapped = if line < buf.lines.len() {
                        snap_to_char_boundary(&buf.lines[line], col)
                    } else {
                        0
                    };
                    if snapped > 0 && line < buf.lines.len() {
                        let prev = prev_char_boundary(&buf.lines[line], snapped);
                        buf.lines[line].drain(prev..snapped);
                        buf.dirty = true;
                        state.cursor_col = prev;
                        modified = true;
                    } else if snapped == 0 && line > 0 {
                        let current_line = buf.lines.remove(line);
                        buf.dirty = true;
                        let prev_line = line - 1;
                        let prev_len = buf.lines[prev_line].len();
                        buf.lines[prev_line].push_str(&current_line);
                        state.cursor_line = prev_line;
                        state.cursor_col = prev_len;
                        modified = true;
                    }
                }
            }
        }
        KeyCode::Enter => {
            modified |= delete_selection(state);
            let line = state.cursor_line;
            let col = state.cursor_col;
            if let Some(buf) = state.buffers.get_mut(state.active_buffer)
                && line < buf.lines.len()
            {
                let current = buf.lines[line].clone();
                let col = snap_to_char_boundary(&current, col);
                let remainder = current[col..].to_string();
                buf.lines[line] = current[..col].to_string();
                buf.insert_line(line + 1, remainder);
                state.cursor_line += 1;
                state.cursor_col = 0;
                modified = true;
            }
        }
        _ => {}
    }
    modified
}

fn mark_disk_changed(state: &mut EditorState) {
    if let Some(buf) = state.buffers.get_mut(state.active_buffer) {
        let new_mtime = std::fs::metadata(&buf.path).and_then(|m| m.modified()).ok();
        if new_mtime != buf.last_disk_mtime {
            buf.disk_changed = true;
            let fname = buf
                .path
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("file");
            state.disk_changed_path = Some(PathBuf::from(fname));
        }
    }
}

fn handle_fs_event(event: &notify::Event, state: &mut EditorState, watcher: &mut FsWatcher) {
    let watched_file = watcher.watched_file().map(|p| p.to_path_buf());

    for path in &event.paths {
        let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());

        // Fast path: a file we're waiting to re-watch just reappeared (atomic save completed).
        if let Some(rewatch) = &state.pending_rewatch_path
            && canonical == *rewatch
            && matches!(event.kind, EventKind::Create(_))
        {
            state.pending_rewatch_path = None;
            let _ = watcher.watch_file(path);
            if let Some(buf) = state.buffers.get_mut(state.active_buffer) {
                buf.disk_deleted = false;
            }
            mark_disk_changed(state);
        }

        let is_watched_file = watched_file.as_ref().is_some_and(|wf| *wf == canonical);

        if is_watched_file {
            let is_rename_away = matches!(
                event.kind,
                EventKind::Modify(ModifyKind::Name(RenameMode::From | RenameMode::Both))
            ) && watched_file.as_ref().is_some_and(|wf| {
                event
                    .paths
                    .first()
                    .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()) == *wf)
                    .unwrap_or(false)
            });
            match event.kind {
                EventKind::Modify(
                    ModifyKind::Data(_) | ModifyKind::Metadata(_) | ModifyKind::Any,
                ) => {
                    mark_disk_changed(state);
                }
                EventKind::Remove(_) => {
                    // Check if the file still exists (atomic save: temp+rename
                    // replaces the file before the Remove event is processed).
                    let file_exists = state
                        .buffers
                        .get(state.active_buffer)
                        .is_some_and(|b| b.path.exists());
                    if file_exists {
                        mark_disk_changed(state);
                        if let Some(buf) = state.buffers.get(state.active_buffer) {
                            let _ = watcher.watch_file(&buf.path);
                        }
                    } else {
                        state.pending_rewatch_path =
                            watcher.watched_file().map(|p| p.to_path_buf());
                        handle_active_file_removed(state);
                        watcher.unwatch_file();
                    }
                }
                EventKind::Modify(ModifyKind::Name(_)) if is_rename_away => {
                    state.pending_rewatch_path = watcher.watched_file().map(|p| p.to_path_buf());
                    handle_active_file_removed(state);
                    watcher.unwatch_file();
                }
                // macOS FSEvents emits Name(Any) for atomic saves and ambiguous
                // renames. Check if the file still exists to distinguish.
                EventKind::Modify(ModifyKind::Name(RenameMode::Any)) => {
                    let file_exists = state
                        .buffers
                        .get(state.active_buffer)
                        .is_some_and(|b| b.path.exists());
                    if file_exists {
                        mark_disk_changed(state);
                    } else {
                        state.pending_rewatch_path =
                            watcher.watched_file().map(|p| p.to_path_buf());
                        handle_active_file_removed(state);
                        watcher.unwatch_file();
                    }
                }
                _ => {}
            }
        }
    }

    if state.file_tree.is_some() {
        match event.kind {
            EventKind::Create(_) | EventKind::Remove(_) => {
                apply_tree_event(state, event);
            }
            EventKind::Modify(ModifyKind::Name(_)) => {
                apply_tree_event(state, event);
            }
            _ => {}
        }
    }
}

fn remove_path_from_tree(state: &mut EditorState, path: &Path) {
    if let Some(tree) = &mut state.file_tree {
        tree.entries
            .retain(|e| !e.path.starts_with(path) && e.path != *path);
    }
    state
        .tree_view
        .retain(|e| !e.path.starts_with(path) && e.path != *path);
    handle_removed_path(state, path);
}

fn create_path_in_tree(state: &mut EditorState, path: &Path) {
    let inserted = if let Some(tree) = &mut state.file_tree {
        insert_entry_sorted(tree, path)
    } else {
        return;
    };
    if let Some(entry) = inserted {
        propagate_create_to_tree_view(state, &entry);
    }
}

fn apply_tree_event(state: &mut EditorState, event: &notify::Event) {
    match event.kind {
        EventKind::Create(_) => {
            for path in &event.paths {
                create_path_in_tree(state, path);
            }
        }
        EventKind::Remove(_) | EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
            // File gone from this path. Don't canonicalize — the file no longer
            // exists so canonicalize would fail. Notify paths are already
            // canonical because watchers are registered with canonical roots.
            for path in &event.paths {
                remove_path_from_tree(state, path);
            }
        }
        EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
            for path in &event.paths {
                create_path_in_tree(state, path);
            }
        }
        EventKind::Modify(ModifyKind::Name(RenameMode::Both)) if event.paths.len() >= 2 => {
            let from = &event.paths[0];
            let to = &event.paths[1];
            // `from` no longer exists — use raw path (already canonical from watcher).
            let to_canonical = to.canonicalize().unwrap_or_else(|_| to.clone());
            rename_subtree(&mut state.file_tree, from, &to_canonical);
            rename_in_tree_view(&mut state.tree_view, from, &to_canonical);
        }
        EventKind::Modify(ModifyKind::Name(RenameMode::Any)) => {
            // macOS FSEvents: ambiguous rename. Check each path on disk.
            for path in &event.paths {
                if path.exists() {
                    create_path_in_tree(state, path);
                } else {
                    remove_path_from_tree(state, path);
                }
            }
        }
        _ => {}
    }
}

fn rename_subtree(tree_opt: &mut Option<crate::data::file_tree::FileTree>, from: &Path, to: &Path) {
    let Some(tree) = tree_opt else { return };
    for entry in &mut tree.entries {
        if let Some(new_path) = rebase_path(&entry.path, from, to) {
            entry.is_dir = new_path.is_dir();
            entry.path = new_path;
        }
    }
}

fn rename_in_tree_view(tree_view: &mut [FileEntry], from: &Path, to: &Path) {
    for entry in tree_view.iter_mut() {
        if let Some(new_path) = rebase_path(&entry.path, from, to) {
            entry.is_dir = new_path.is_dir();
            entry.path = new_path;
        }
    }
}

/// If `path` is `from` or a descendant of `from`, return the equivalent path
/// under `to`. Returns None when `path` is unrelated to `from`.
fn rebase_path(path: &Path, from: &Path, to: &Path) -> Option<PathBuf> {
    if path == from {
        return Some(to.to_path_buf());
    }
    path.strip_prefix(from).ok().map(|rel| to.join(rel))
}

fn handle_active_file_removed(state: &mut EditorState) {
    if let Some(buf) = state.buffers.get_mut(state.active_buffer) {
        buf.disk_changed = false;
        buf.disk_deleted = true;
        state.disk_changed_path = None;
    }
}

fn propagate_create_to_tree_view(state: &mut EditorState, new_entry: &FileEntry) {
    if state.tree_view.iter().any(|e| e.path == new_entry.path) {
        return;
    }

    let depth = new_entry.depth;

    // Depth 0: always visible — insert in sorted order among other depth-0 entries.
    if depth == 0 {
        let pos = state
            .tree_view
            .iter()
            .position(|e| e.depth == 0 && e.path > new_entry.path)
            .unwrap_or(state.tree_view.len());
        state.tree_view.insert(pos, new_entry.clone());
        return;
    }

    // Otherwise, the parent must be present in tree_view AND expanded
    // (next entry has greater depth than parent).
    let parent_path = match new_entry.path.parent() {
        Some(p) => p,
        None => return,
    };
    let parent_idx = match state.tree_view.iter().position(|e| e.path == parent_path) {
        Some(idx) => idx,
        None => return,
    };
    let parent_depth = state.tree_view[parent_idx].depth;
    let parent_expanded = state
        .tree_view
        .get(parent_idx + 1)
        .is_some_and(|next| next.depth > parent_depth);
    if !parent_expanded {
        return;
    }

    // Find the insertion point within the parent's direct children, in sorted order.
    let child_depth = parent_depth + 1;
    let mut insert_idx = parent_idx + 1;
    while insert_idx < state.tree_view.len() {
        let e = &state.tree_view[insert_idx];
        if e.depth <= parent_depth {
            break;
        }
        if e.depth == child_depth && e.path > new_entry.path {
            break;
        }
        insert_idx += 1;
    }
    state.tree_view.insert(insert_idx, new_entry.clone());
}

fn insert_entry_sorted(
    tree: &mut crate::data::file_tree::FileTree,
    path: &Path,
) -> Option<FileEntry> {
    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
    if !canonical.starts_with(&tree.root) {
        return None;
    }
    if tree.entries.iter().any(|e| e.path == canonical) {
        return None;
    }
    let depth = canonical
        .strip_prefix(&tree.root)
        .map(|rel| rel.components().count().saturating_sub(1))
        .unwrap_or(0);
    let is_dir = canonical.is_dir();
    let entry = FileEntry {
        path: canonical,
        depth,
        is_dir,
    };
    tree.entries.push(entry.clone());
    tree.entries.sort_by(|a, b| a.path.cmp(&b.path));
    Some(entry)
}

fn handle_removed_path(state: &mut EditorState, path: &Path) {
    if let Some(buf) = state.buffers.get_mut(state.active_buffer) {
        let buf_canonical = buf.path.canonicalize().unwrap_or_else(|_| buf.path.clone());
        if buf_canonical == path || buf.path == path {
            buf.disk_deleted = true;
            buf.disk_changed = false;
        }
    }
    if !state.tree_view.is_empty() {
        state.tree_selected = state
            .tree_selected
            .min(state.tree_view.len().saturating_sub(1));
    } else if state
        .file_tree
        .as_ref()
        .is_some_and(|t| t.entries.is_empty())
    {
        state.tree_selected = 0;
        state.file_tree = None;
    }
}

fn reload_buffer_from_disk(state: &mut EditorState, syntax_engine: &mut SyntaxEngine) {
    let path = match state.current_buffer() {
        Some(buf) => buf.path.clone(),
        None => return,
    };
    match Buffer::from_file(&path) {
        Ok(fresh) => {
            if let Some(buf) = state.buffers.get_mut(state.active_buffer) {
                *buf = fresh;
            }
            let line_count = state.current_buffer().map_or(0, |b| b.line_count());
            if state.cursor_line >= line_count {
                state.cursor_line = line_count.saturating_sub(1);
            }
            if let Some(line) = state
                .current_buffer()
                .and_then(|b| b.lines.get(state.cursor_line))
            {
                state.cursor_col = snap_to_char_boundary(line, state.cursor_col);
            } else {
                state.cursor_col = 0;
            }
            if state.scroll_offset >= line_count {
                state.scroll_offset = line_count.saturating_sub(1);
            }
            state.selection = None;
            state.disk_changed_path = None;
            state.status_msg = "reloaded from disk".into();
            refresh_buffer_caches(state, syntax_engine);
        }
        Err(e) => {
            state.status_msg = format!("reload failed: {e}");
        }
    }
}

// --- Tree rename ---

fn begin_tree_rename(state: &mut EditorState) {
    if let Some(entry) = state.tree_view.get(state.tree_selected) {
        let name = entry.name().to_string();
        let cursor = name.len();
        state.tree_rename_state = Some(TreeRenameState {
            index: state.tree_selected,
            input: name,
            cursor,
        });
    }
}

fn handle_tree_rename_input(
    state: &mut EditorState,
    code: KeyCode,
    modifiers: KeyModifiers,
    syntax_engine: &mut SyntaxEngine,
) {
    match code {
        KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
            if let Some(ref mut r) = state.tree_rename_state {
                r.input.insert(r.cursor, c);
                r.cursor += c.len_utf8();
            }
        }
        KeyCode::Backspace => {
            if let Some(ref mut r) = state.tree_rename_state
                && r.cursor > 0
            {
                let prev = prev_char_boundary(&r.input, r.cursor);
                r.input.drain(prev..r.cursor);
                r.cursor = prev;
            }
        }
        KeyCode::Left => {
            if let Some(ref mut r) = state.tree_rename_state
                && r.cursor > 0
            {
                r.cursor = prev_char_boundary(&r.input, r.cursor);
            }
        }
        KeyCode::Right => {
            if let Some(ref mut r) = state.tree_rename_state
                && r.cursor < r.input.len()
            {
                r.cursor = next_char_boundary(&r.input, r.cursor);
            }
        }
        KeyCode::Enter => {
            commit_tree_rename(state, syntax_engine);
        }
        KeyCode::Esc => {
            state.tree_rename_state = None;
        }
        _ => {}
    }
}

fn commit_tree_rename(state: &mut EditorState, syntax_engine: &mut SyntaxEngine) {
    let (index, new_name) = match state.tree_rename_state.as_ref() {
        Some(r) => (r.index, r.input.trim().to_string()),
        None => return,
    };

    if new_name.is_empty() {
        state.status_msg = "rename failed: name cannot be empty".into();
        return;
    }
    if new_name.contains('/') || new_name.contains('\\') {
        state.status_msg = "rename failed: name cannot contain path separators".into();
        return;
    }

    let old_path = match state.tree_view.get(index) {
        Some(entry) => entry.path.clone(),
        None => {
            state.tree_rename_state = None;
            return;
        }
    };

    let new_path = match old_path.parent() {
        Some(parent) => parent.join(&new_name),
        None => {
            state.tree_rename_state = None;
            return;
        }
    };

    // Unchanged name: clear state silently with no fs::rename and no error.
    if new_path == old_path {
        state.tree_rename_state = None;
        return;
    }

    if new_path.exists() {
        state.status_msg = "rename failed: destination exists".into();
        return;
    }

    if let Err(e) = std::fs::rename(&old_path, &new_path) {
        state.status_msg = format!("rename failed: {e}");
        state.tree_rename_state = None;
        return;
    }

    state.tree_rename_state = None;

    if let Some(ref mut tree) = state.file_tree
        && tree.root == old_path
    {
        tree.root = new_path.clone();
    }

    rename_subtree(&mut state.file_tree, &old_path, &new_path);
    rename_in_tree_view(&mut state.tree_view, &old_path, &new_path);

    for buf in &mut state.buffers {
        if let Some(rebased) = rebase_path(&buf.path, &old_path, &new_path) {
            buf.path = rebased;
        }
    }

    if state
        .current_buffer()
        .is_some_and(|b| b.path.starts_with(&new_path))
    {
        refresh_buffer_caches(state, syntax_engine);
    }

    state.status_msg = format!("renamed → {new_name}");
}

// --- Tree delete ---

fn begin_tree_delete(state: &mut EditorState) {
    let entry = match state.tree_view.get(state.tree_selected) {
        Some(e) => e,
        None => return,
    };

    if entry.is_dir
        && let Some(ref tree) = state.file_tree
        && entry.path == tree.root
    {
        state.status_msg = "cannot delete tree root".into();
        return;
    }

    let children_preview = if entry.is_dir {
        if let Some(ref tree) = state.file_tree {
            tree.entries
                .iter()
                .filter(|e| {
                    e.depth == entry.depth + 1 && e.path.parent().is_some_and(|p| p == entry.path)
                })
                .map(|e| e.name().to_string())
                .collect()
        } else {
            Vec::new()
        }
    } else {
        Vec::new()
    };

    state.tree_delete_confirm = Some(TreeDeleteState {
        index: state.tree_selected,
        children_preview,
    });
}

fn handle_tree_delete_input(state: &mut EditorState, code: KeyCode) {
    match code {
        KeyCode::Enter => {
            commit_tree_delete(state);
        }
        KeyCode::Esc => {
            state.tree_delete_confirm = None;
        }
        _ => {}
    }
}

fn commit_tree_delete(state: &mut EditorState) {
    let delete_state = match state.tree_delete_confirm.take() {
        Some(d) => d,
        None => return,
    };

    let entry = match state.tree_view.get(delete_state.index) {
        Some(e) => e.clone(),
        None => return,
    };

    let result = if entry.is_dir {
        std::fs::remove_dir_all(&entry.path)
    } else {
        std::fs::remove_file(&entry.path)
    };

    if let Err(e) = result {
        state.status_msg = format!("delete failed: {e}");
        return;
    }

    let deleted_path = entry.path.clone();
    let deleted_name = entry.name().to_string();

    if let Some(ref mut tree) = state.file_tree {
        tree.entries.retain(|e| !e.path.starts_with(&deleted_path));
    }
    state
        .tree_view
        .retain(|e| !e.path.starts_with(&deleted_path));
    state.tree_selected = state
        .tree_selected
        .min(state.tree_view.len().saturating_sub(1));

    let active_buf_affected = state
        .buffers
        .iter()
        .any(|b| b.path.starts_with(&deleted_path));
    if active_buf_affected {
        if let Some(buf) = state.buffers.get_mut(state.active_buffer) {
            buf.disk_deleted = true;
            buf.disk_changed = false;
        }
    } else {
        state.status_msg = format!("deleted {deleted_name}");
    }
}

// --- Tree new file ---

fn begin_tree_new_file(state: &mut EditorState) {
    let parent_dir = if state.tree_view.is_empty() {
        state
            .file_tree
            .as_ref()
            .map(|t| t.root.clone())
            .unwrap_or_else(|| PathBuf::from("."))
    } else if let Some(entry) = state.tree_view.get(state.tree_selected) {
        if entry.is_dir {
            entry.path.clone()
        } else {
            entry.path.parent().unwrap_or(Path::new(".")).to_path_buf()
        }
    } else {
        PathBuf::from(".")
    };

    state.tree_new_file_state = Some(TreeNewFileState {
        parent_dir,
        input: String::new(),
        cursor: 0,
    });
}

fn handle_tree_new_file_input(
    state: &mut EditorState,
    code: KeyCode,
    modifiers: KeyModifiers,
    syntax_engine: &mut SyntaxEngine,
) {
    match code {
        KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
            if let Some(ref mut n) = state.tree_new_file_state {
                n.input.insert(n.cursor, c);
                n.cursor += c.len_utf8();
            }
        }
        KeyCode::Backspace => {
            if let Some(ref mut n) = state.tree_new_file_state
                && n.cursor > 0
            {
                let prev = prev_char_boundary(&n.input, n.cursor);
                n.input.drain(prev..n.cursor);
                n.cursor = prev;
            }
        }
        KeyCode::Left => {
            if let Some(ref mut n) = state.tree_new_file_state
                && n.cursor > 0
            {
                n.cursor = prev_char_boundary(&n.input, n.cursor);
            }
        }
        KeyCode::Right => {
            if let Some(ref mut n) = state.tree_new_file_state
                && n.cursor < n.input.len()
            {
                n.cursor = next_char_boundary(&n.input, n.cursor);
            }
        }
        KeyCode::Enter => {
            commit_tree_new_file(state, syntax_engine);
        }
        KeyCode::Esc => {
            state.tree_new_file_state = None;
        }
        _ => {}
    }
}

fn commit_tree_new_file(state: &mut EditorState, syntax_engine: &mut SyntaxEngine) {
    let new_file_state = match state.tree_new_file_state.as_ref() {
        Some(n) => n,
        None => return,
    };

    let name = new_file_state.input.trim().to_string();
    if name.is_empty() {
        state.status_msg = "filename cannot be empty".into();
        return;
    }
    if name.contains('/') || name.contains('\\') {
        state.status_msg = "filename cannot contain path separators".into();
        return;
    }

    let new_path = new_file_state.parent_dir.join(&name);
    if new_path.exists() {
        state.status_msg = "file already exists".into();
        return;
    }

    if let Err(e) = std::fs::File::create(&new_path) {
        state.status_msg = format!("create failed: {e}");
        state.tree_new_file_state = None;
        return;
    }

    state.tree_new_file_state = None;

    if let Some(ref mut tree) = state.file_tree
        && let Some(new_entry) = insert_entry_sorted(tree, &new_path)
    {
        propagate_create_to_tree_view(state, &new_entry);
        if let Some(idx) = state
            .tree_view
            .iter()
            .position(|e| e.path == new_entry.path)
        {
            state.tree_selected = idx;
        }
    }

    let _ = state.open_file(&new_path);
    refresh_buffer_caches(state, syntax_engine);
    state.focus_tree = false;
    state.mode = Mode::Edit;
    state.status_msg = "-- EDIT --".into();
}

fn toggle_tree(state: &mut EditorState) {
    if state.file_tree.is_some() {
        if state.focus_tree {
            state.focus_tree = false;
            state.mode = state.pre_tree_mode;
        } else {
            state.pre_tree_mode = state.mode;
            state.focus_tree = true;
            if let Some(buf) = state.current_buffer() {
                let buf_path = buf.path.clone();
                if let Some(idx) = state.tree_view.iter().position(|e| e.path == buf_path) {
                    state.tree_selected = idx;
                }
            }
        }
    } else {
        let dir = if state.opened_path.is_dir() {
            state.opened_path.clone()
        } else {
            match state.opened_path.parent() {
                Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
                _ => PathBuf::from("."),
            }
        };
        match crate::data::file_tree::FileTree::from_dir(&dir) {
            Ok(tree) => {
                let tree_view: Vec<_> = tree
                    .entries
                    .iter()
                    .filter(|e| e.depth == 0)
                    .cloned()
                    .collect();
                state.file_tree = Some(tree);
                state.tree_view = tree_view;
                state.pre_tree_mode = state.mode;
                state.focus_tree = true;
                if let Some(buf) = state.current_buffer() {
                    let buf_path = buf.path.clone();
                    if let Some(idx) = state.tree_view.iter().position(|e| e.path == buf_path) {
                        state.tree_selected = idx;
                    }
                }
                state.status_msg = "file tree opened".into();
            }
            Err(e) => {
                state.status_msg = format!("tree error: {e}");
            }
        }
    }
}

fn execute_chord_input(
    state: &mut EditorState,
    frontend: &mut TuiFrontend,
    engine: &Arc<Mutex<LspEngine>>,
    input: &str,
    lsp_statuses: &[(Language, ServerState)],
) {
    // Determine the effective LSP status for the current file's language
    let lsp_status = current_file_lsp_status(state, lsp_statuses);

    match ChordEngine::parse(input) {
        Ok(mut query) => {
            if query.requires_lsp && !lsp_status.is_available() {
                if lsp_status.is_pending() {
                    state.status_msg = format!(
                        "chord {} waiting for LSP ({})",
                        query.short_form(),
                        lsp_status.display()
                    );
                } else {
                    state.status_msg = format!(
                        "chord {} requires LSP but {}",
                        query.short_form(),
                        lsp_status.display()
                    );
                }
                return;
            }

            query.args.cursor_pos = Some((state.cursor_line, state.cursor_col));

            let mut buffers = HashMap::new();
            if let Some(buf) = state.current_buffer() {
                let path_str = buf.path.to_string_lossy().to_string();
                buffers.insert(path_str, buf.clone());
            }

            let resolve_result = {
                let mut eng = engine.lock().unwrap();
                ChordEngine::resolve(&query, &buffers, &mut eng)
            };

            match resolve_result {
                Ok(resolved) => match ChordEngine::patch(&resolved, &buffers) {
                    Ok(actions) => {
                        for action in actions.values() {
                            match frontend.apply(state, action) {
                                Ok(msg) => {
                                    if !msg.is_empty() && state.status_msg.is_empty() {
                                        state.status_msg = msg;
                                    }
                                }
                                Err(e) => {
                                    state.status_msg = format!("error: {e}");
                                }
                            }
                        }
                    }
                    Err(e) => {
                        state.status_msg = format!("patch error: {e}");
                    }
                },
                Err(e) => {
                    state.status_msg = format!("resolve error: {e}");
                }
            }
        }
        Err(e) => {
            state.status_msg = format!("parse error: {e}");
        }
    }
}

fn current_file_lsp_status(
    state: &EditorState,
    lsp_statuses: &[(Language, ServerState)],
) -> ServerState {
    if let Some(buf) = state.current_buffer()
        && let Some(lang) = Language::from_path(&buf.path)
    {
        for (l, s) in lsp_statuses {
            if *l == lang {
                return *s;
            }
        }
    }
    ServerState::Undetected
}

fn handle_mouse(state: &mut EditorState, mouse: MouseEvent, editor_area: Rect) {
    match mouse.kind {
        MouseEventKind::Down(MouseButton::Left) => {
            if let Some((line, col)) =
                editor_pane::screen_to_buffer(mouse.column, mouse.row, editor_area, state)
            {
                state.cursor_line = line;
                state.cursor_col = col;
                state.selection = Some(Selection {
                    anchor_line: line,
                    anchor_col: col,
                    head_line: line,
                    head_col: col,
                });
            }
        }
        MouseEventKind::Drag(MouseButton::Left) if state.selection.is_some() => {
            if let Some((line, col)) =
                editor_pane::screen_to_buffer(mouse.column, mouse.row, editor_area, state)
            {
                let sel = state.selection.as_mut().unwrap();
                sel.head_line = line;
                sel.head_col = col;
            }
        }
        MouseEventKind::Up(MouseButton::Left) => {
            if let Some(sel) = state.selection
                && sel.anchor_line == sel.head_line
                && sel.anchor_col == sel.head_col
            {
                state.selection = None;
            }
        }
        MouseEventKind::ScrollUp => {
            let total = state.current_buffer().map_or(1, |b| b.line_count());
            let gutter = format!("{}", total.saturating_sub(1)).len() + 1;
            let tw = (editor_area.width as usize).saturating_sub(gutter);
            move_cursor_up(state, tw);
        }
        MouseEventKind::ScrollDown => {
            let total = state.current_buffer().map_or(1, |b| b.line_count());
            let gutter = format!("{}", total.saturating_sub(1)).len() + 1;
            let tw = (editor_area.width as usize).saturating_sub(gutter);
            move_cursor_down(state, tw);
        }
        _ => {}
    }
}

fn extract_selection_text(state: &EditorState, sel: &Selection) -> String {
    let (start_line, start_col, end_line, end_col) = sel.ordered();
    let buf = match state.current_buffer() {
        Some(b) => b,
        None => return String::new(),
    };
    let mut result = String::new();
    for i in start_line..=end_line {
        let line = match buf.lines.get(i) {
            Some(l) => l,
            None => break,
        };
        let from = if i == start_line {
            snap_to_char_boundary(line, start_col)
        } else {
            0
        };
        let to = if i == end_line {
            snap_to_char_boundary(line, end_col)
        } else {
            line.len()
        };
        if from <= to {
            result.push_str(&line[from..to]);
        }
        if i < end_line {
            result.push('\n');
        }
    }
    result
}

fn write_osc52(text: &str) {
    use base64::Engine as _;
    use base64::engine::general_purpose::STANDARD as BASE64;

    let encoded = BASE64.encode(text.as_bytes());
    let seq = format!("\x1b]52;c;{}\x07", encoded);
    let _ = io::stdout().write_all(seq.as_bytes());
    let _ = io::stdout().flush();
}

#[cfg(test)]
mod tests {
    use std::io::Write;
    use std::path::PathBuf;
    use std::sync::{Arc, Mutex};

    use super::*;
    use crate::commands::lsp_engine::{LspEngine, LspEngineConfig};
    use crate::commands::syntax_engine::SyntaxEngine;
    use crate::data::lsp::types::SemanticToken;
    use crate::data::state::{
        EditorState, Mode, Selection, TreeDeleteState, TreeNewFileState, TreeRenameState,
    };
    use crossterm::event::{KeyCode, KeyModifiers};

    fn tok(line: usize, start_col: usize, length: usize, token_type: &str) -> SemanticToken {
        SemanticToken {
            line,
            start_col,
            length,
            token_type: token_type.to_string(),
        }
    }

    #[test]
    fn tui_syntax_receiver_set_and_get_tokens() {
        let receiver = TuiSyntaxReceiver::new();
        let path = std::path::Path::new("/tmp/test.rs");
        let tokens = vec![tok(0, 0, 2, "keyword"), tok(0, 3, 4, "function")];
        receiver.set_semantic_tokens(path, tokens);
        let got = receiver.tokens_for(path);
        assert_eq!(got.len(), 2);
        assert_eq!(got[0].token_type, "keyword");
        assert_eq!(got[1].token_type, "function");
    }

    #[test]
    fn tui_syntax_receiver_per_path_isolation() {
        let receiver = TuiSyntaxReceiver::new();
        let path_a = std::path::Path::new("/tmp/a.rs");
        let path_b = std::path::Path::new("/tmp/b.ts");

        receiver.set_semantic_tokens(path_a, vec![tok(0, 0, 3, "keyword")]);
        receiver.set_semantic_tokens(path_b, vec![tok(1, 5, 4, "string")]);

        let got_a = receiver.tokens_for(path_a);
        let got_b = receiver.tokens_for(path_b);

        assert_eq!(got_a.len(), 1);
        assert_eq!(got_a[0].token_type, "keyword");
        assert_eq!(got_b.len(), 1);
        assert_eq!(got_b[0].token_type, "string");
        // cross-check isolation
        assert!(
            receiver
                .tokens_for(std::path::Path::new("/tmp/c.go"))
                .is_empty()
        );
    }

    #[test]
    fn lsp_readiness_triggers_merged_token_delivery() {
        // Simulate: first compute delivers ts-only; after LSP "becomes ready"
        // (worker caches tokens), second compute delivers merged ts+LSP tokens.
        let receiver = Arc::new(TuiSyntaxReceiver::new());
        let path = PathBuf::from("lsp_ready_integration.rs");

        let mut lsp = LspEngine::new(LspEngineConfig::default());
        // Inject a fake LSP token that partially overlaps with the ts "fn" keyword
        lsp.inject_test_semantic_tokens(path.clone(), vec![tok(0, 0, 2, "keyword")]);
        let engine = Arc::new(Mutex::new(lsp));

        let mut syntax = SyntaxEngine::new(
            Arc::clone(&engine),
            Arc::clone(&receiver) as Arc<dyn SyntaxFrontend>,
        );

        // First compute: LSP cache is empty — delivers tree-sitter tokens only
        syntax.compute(path.as_path(), "fn main() {}");
        let first = receiver.tokens_for(path.as_path());
        assert!(
            !first.is_empty(),
            "tree-sitter tokens should be delivered on first compute"
        );

        // Wait for the background worker to run, call semantic_tokens (returns
        // injected tokens), and populate lsp_cache
        std::thread::sleep(std::time::Duration::from_millis(600));

        // Second compute: LSP cache is now populated — merged delivery fires synchronously
        syntax.compute(path.as_path(), "fn main() {}");

        let second = receiver.tokens_for(path.as_path());
        assert!(
            second
                .iter()
                .any(|t| t.line == 0 && t.start_col == 0 && t.length == 2),
            "merged delivery should include LSP token at (0,0,2); got: {:?}",
            second
        );
    }

    fn make_state_with_lines(lines: &[&str]) -> (tempfile::NamedTempFile, EditorState) {
        let mut f = tempfile::NamedTempFile::new().unwrap();
        for (i, line) in lines.iter().enumerate() {
            if i > 0 {
                f.write_all(b"\n").unwrap();
            }
            f.write_all(line.as_bytes()).unwrap();
        }
        f.flush().unwrap();
        let state = EditorState::for_file(f.path()).unwrap();
        (f, state)
    }

    // --- work item 0005: Jump / To / Delimiter ---

    #[test]
    fn adjust_scroll_offset_scrolls_down_when_cursor_below_viewport() {
        let lines: Vec<&str> = (0..30).map(|_| "line").collect();
        let (_f, mut state) = make_state_with_lines(&lines);
        state.scroll_offset = 0;
        state.cursor_line = 15;
        adjust_scroll_offset(&mut state, 20, 80);
        assert_eq!(state.scroll_offset, 2);
    }

    #[test]
    fn adjust_scroll_offset_scrolls_up_when_cursor_above_scroll() {
        let lines: Vec<&str> = (0..30).map(|_| "line").collect();
        let (_f, mut state) = make_state_with_lines(&lines);
        state.scroll_offset = 10;
        state.cursor_line = 3;
        adjust_scroll_offset(&mut state, 20, 80);
        assert_eq!(state.scroll_offset, 3);
    }

    #[test]
    fn adjust_scroll_offset_no_change_when_cursor_in_viewport() {
        let lines: Vec<&str> = (0..30).map(|_| "line").collect();
        let (_f, mut state) = make_state_with_lines(&lines);
        state.scroll_offset = 0;
        state.cursor_line = 5;
        adjust_scroll_offset(&mut state, 20, 80);
        assert_eq!(state.scroll_offset, 0);
    }

    // --- work item 0007: extract_selection_text, write_osc52, selection clearing ---

    #[test]
    fn extract_selection_text_single_line() {
        let (_f, state) = make_state_with_lines(&["hello world"]);
        let sel = Selection {
            anchor_line: 0,
            anchor_col: 0,
            head_line: 0,
            head_col: 5,
        };
        assert_eq!(extract_selection_text(&state, &sel), "hello");
    }

    #[test]
    fn extract_selection_text_multi_line() {
        let (_f, state) = make_state_with_lines(&["aaa", "bbb", "ccc"]);
        let sel = Selection {
            anchor_line: 0,
            anchor_col: 1,
            head_line: 2,
            head_col: 2,
        };
        assert_eq!(extract_selection_text(&state, &sel), "aa\nbbb\ncc");
    }

    #[test]
    fn extract_selection_text_clamped_to_line_length() {
        let (_f, state) = make_state_with_lines(&["short"]);
        let sel = Selection {
            anchor_line: 0,
            anchor_col: 0,
            head_line: 0,
            head_col: 100,
        };
        assert_eq!(extract_selection_text(&state, &sel), "short");
    }

    #[test]
    fn extract_selection_text_empty_when_anchor_equals_head() {
        let (_f, state) = make_state_with_lines(&["hello"]);
        let sel = Selection {
            anchor_line: 0,
            anchor_col: 2,
            head_line: 0,
            head_col: 2,
        };
        assert_eq!(extract_selection_text(&state, &sel), "");
    }

    #[test]
    fn write_osc52_produces_correct_escape_sequence_format() {
        use base64::Engine as _;
        use base64::engine::general_purpose::STANDARD as BASE64;
        let text = "hello";
        let encoded = BASE64.encode(text.as_bytes());
        assert_eq!(encoded, "aGVsbG8=");
        let expected = format!("\x1b]52;c;{}\x07", encoded);
        assert_eq!(expected, "\x1b]52;c;aGVsbG8=\x07");
        write_osc52(text);
    }

    #[test]
    fn typing_in_edit_mode_with_selection_replaces_selection() {
        let (_f, mut state) = make_state_with_lines(&["hello world"]);
        state.mode = Mode::Edit;
        state.selection = Some(Selection {
            anchor_line: 0,
            anchor_col: 0,
            head_line: 0,
            head_col: 5,
        });
        let modified = handle_edit_mode(&mut state, KeyCode::Char('X'), KeyModifiers::empty(), 80);
        assert!(modified);
        assert!(state.selection.is_none());
        assert_eq!(state.current_buffer().unwrap().lines[0], "X world");
        assert_eq!(state.cursor_line, 0);
        assert_eq!(state.cursor_col, 1);
    }

    #[test]
    fn backspace_in_edit_mode_with_selection_deletes_only_the_selection() {
        let (_f, mut state) = make_state_with_lines(&["hello world"]);
        state.mode = Mode::Edit;
        state.cursor_col = 11;
        state.selection = Some(Selection {
            anchor_line: 0,
            anchor_col: 5,
            head_line: 0,
            head_col: 11,
        });
        let modified = handle_edit_mode(&mut state, KeyCode::Backspace, KeyModifiers::empty(), 80);
        assert!(modified);
        assert!(state.selection.is_none());
        assert_eq!(state.current_buffer().unwrap().lines[0], "hello");
        assert_eq!(state.cursor_col, 5, "cursor lands at the selection start");
    }

    #[test]
    fn enter_in_edit_mode_with_selection_replaces_with_newline() {
        let (_f, mut state) = make_state_with_lines(&["hello world"]);
        state.mode = Mode::Edit;
        state.selection = Some(Selection {
            anchor_line: 0,
            anchor_col: 5,
            head_line: 0,
            head_col: 6,
        });
        let modified = handle_edit_mode(&mut state, KeyCode::Enter, KeyModifiers::empty(), 80);
        assert!(modified);
        assert!(state.selection.is_none());
        let lines = &state.current_buffer().unwrap().lines;
        assert_eq!(lines[0], "hello");
        assert_eq!(lines[1], "world");
        assert_eq!(state.cursor_line, 1);
        assert_eq!(state.cursor_col, 0);
    }

    #[test]
    fn tab_in_edit_mode_with_selection_replaces_with_tab() {
        let (_f, mut state) = make_state_with_lines(&["abcdef"]);
        state.mode = Mode::Edit;
        state.selection = Some(Selection {
            anchor_line: 0,
            anchor_col: 1,
            head_line: 0,
            head_col: 4,
        });
        let modified = handle_edit_mode(&mut state, KeyCode::Tab, KeyModifiers::empty(), 80);
        assert!(modified);
        assert!(state.selection.is_none());
        assert_eq!(state.current_buffer().unwrap().lines[0], "a\tef");
        assert_eq!(state.cursor_col, 2);
    }

    #[test]
    fn type_with_multi_line_selection_replaces_across_lines() {
        let (_f, mut state) = make_state_with_lines(&["aaa", "bbb", "ccc"]);
        state.mode = Mode::Edit;
        state.selection = Some(Selection {
            anchor_line: 0,
            anchor_col: 1,
            head_line: 2,
            head_col: 2,
        });
        let modified = handle_edit_mode(&mut state, KeyCode::Char('Z'), KeyModifiers::empty(), 80);
        assert!(modified);
        let lines = &state.current_buffer().unwrap().lines;
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0], "aZc");
        assert_eq!(state.cursor_line, 0);
        assert_eq!(state.cursor_col, 2);
    }

    #[test]
    fn ctrl_e_with_selection_switches_mode_but_keeps_selection() {
        let (_f, mut state) = make_state_with_lines(&["hello"]);
        state.mode = Mode::Edit;
        state.selection = Some(Selection {
            anchor_line: 0,
            anchor_col: 0,
            head_line: 0,
            head_col: 3,
        });
        let modified = handle_edit_mode(&mut state, KeyCode::Char('e'), KeyModifiers::CONTROL, 80);
        assert!(!modified);
        assert_eq!(state.mode, Mode::Chord);
        assert!(
            state.selection.is_some(),
            "Ctrl-E must not clear the selection"
        );
    }

    #[test]
    fn typing_in_chord_mode_does_not_touch_selection_or_buffer() {
        let (_f, mut state) = make_state_with_lines(&["hello"]);
        state.mode = Mode::Chord;
        state.selection = Some(Selection {
            anchor_line: 0,
            anchor_col: 0,
            head_line: 0,
            head_col: 3,
        });

        let engine = Arc::new(Mutex::new(LspEngine::new(LspEngineConfig::default())));
        let receiver = Arc::new(TuiSyntaxReceiver::new());
        let mut syntax = SyntaxEngine::new(
            Arc::clone(&engine),
            Arc::clone(&receiver) as Arc<dyn SyntaxFrontend>,
        );
        let mut frontend = TuiFrontend::new();

        let modified = handle_chord_mode(
            &mut state,
            &mut frontend,
            &engine,
            KeyCode::Char('q'),
            KeyModifiers::empty(),
            &mut syntax,
            &[],
            80,
        );
        assert!(!modified);
        assert_eq!(state.chord_input, "q", "char keyed into chord input");
        assert_eq!(
            state.current_buffer().unwrap().lines[0],
            "hello",
            "buffer untouched"
        );
        assert!(
            state.selection.is_some(),
            "selection must survive chord-mode typing"
        );
    }

    #[test]
    fn selection_persists_across_non_modifying_key() {
        let (_f, mut state) = make_state_with_lines(&["hello world"]);
        state.mode = Mode::Edit;
        state.cursor_col = 5;
        state.selection = Some(Selection {
            anchor_line: 0,
            anchor_col: 0,
            head_line: 0,
            head_col: 5,
        });
        let modified = handle_edit_mode(&mut state, KeyCode::Left, KeyModifiers::empty(), 80);
        assert!(!modified, "cursor move must not report buffer modification");
        if modified {
            state.selection = None;
        }
        assert!(
            state.selection.is_some(),
            "selection must persist across cursor-move keys"
        );
    }

    #[test]
    fn ctrl_y_copies_selection_and_clears_it() {
        // We can't easily capture stdout from inside the test process, but we
        // can verify the handler's side effects on state: selection cleared
        // and a status message set with the copied character count.
        let (_f, mut state) = make_state_with_lines(&["hello world"]);
        state.selection = Some(Selection {
            anchor_line: 0,
            anchor_col: 0,
            head_line: 0,
            head_col: 5,
        });

        let engine = Arc::new(Mutex::new(LspEngine::new(LspEngineConfig::default())));
        let receiver = Arc::new(TuiSyntaxReceiver::new());
        let mut syntax = SyntaxEngine::new(
            Arc::clone(&engine),
            Arc::clone(&receiver) as Arc<dyn SyntaxFrontend>,
        );
        let mut frontend = TuiFrontend::new();

        let modified = handle_key(
            &mut state,
            &mut frontend,
            &engine,
            KeyCode::Char('y'),
            KeyModifiers::CONTROL,
            &mut syntax,
            &[],
            80,
        );
        assert!(!modified, "Ctrl-Y must not modify the buffer");
        assert!(
            state.selection.is_none(),
            "selection must be cleared after Ctrl-Y"
        );
        assert_eq!(state.status_msg, "copied 5 chars");
    }

    // --- work item 0011: List action ---

    #[test]
    fn handle_list_dialog_enter_navigates_to_selected_item() {
        let (_f, mut state) = make_state_with_lines(&["aaa", "bbb", "ccc"]);
        state.list_dialog = Some(crate::data::state::ListDialogState {
            items: vec![("foo".to_string(), 0, 3), ("bar".to_string(), 2, 1)],
            selected: 1,
        });
        state.cursor_line = 0;
        state.cursor_col = 0;
        handle_list_dialog(&mut state, KeyCode::Enter);
        assert!(state.list_dialog.is_none());
        assert_eq!(state.cursor_line, 2);
        assert_eq!(state.cursor_col, 1);
        assert_eq!(state.mode, Mode::Edit);
        assert_eq!(state.status_msg, "-- EDIT --");
    }

    #[test]
    fn handle_list_dialog_escape_dismisses_dialog_without_moving_cursor() {
        let (_f, mut state) = make_state_with_lines(&["aaa", "bbb", "ccc"]);
        state.list_dialog = Some(crate::data::state::ListDialogState {
            items: vec![("foo".to_string(), 2, 5)],
            selected: 0,
        });
        state.cursor_line = 0;
        state.cursor_col = 0;
        handle_list_dialog(&mut state, KeyCode::Esc);
        assert!(state.list_dialog.is_none());
        assert_eq!(state.cursor_line, 0, "cursor must not move on Escape");
        assert_eq!(state.cursor_col, 0, "cursor must not move on Escape");
    }

    #[test]
    fn ctrl_y_with_empty_selection_does_nothing() {
        let (_f, mut state) = make_state_with_lines(&["hello"]);
        let engine = Arc::new(Mutex::new(LspEngine::new(LspEngineConfig::default())));
        let receiver = Arc::new(TuiSyntaxReceiver::new());
        let mut syntax = SyntaxEngine::new(
            Arc::clone(&engine),
            Arc::clone(&receiver) as Arc<dyn SyntaxFrontend>,
        );
        let mut frontend = TuiFrontend::new();

        let modified = handle_key(
            &mut state,
            &mut frontend,
            &engine,
            KeyCode::Char('y'),
            KeyModifiers::CONTROL,
            &mut syntax,
            &[],
            80,
        );
        assert!(!modified);
        assert!(state.selection.is_none());
        assert!(
            state.status_msg.is_empty(),
            "no status message when there's nothing to copy"
        );
    }

    // --- work item 0010: title-bar token count cache ---

    #[test]
    fn cached_token_count_updates_after_buffer_modified() {
        let (_f, mut state) = make_state_with_lines(&["hello world"]);
        state.mode = Mode::Edit;
        assert_eq!(
            state.cached_token_count, 0,
            "token count starts at 0 before any update"
        );

        let modified = handle_edit_mode(&mut state, KeyCode::Char('!'), KeyModifiers::empty(), 80);
        assert!(
            modified,
            "typing a char in Edit mode must report modification"
        );

        let engine = Arc::new(Mutex::new(LspEngine::new(LspEngineConfig::default())));
        let receiver = Arc::new(TuiSyntaxReceiver::new());
        let mut syntax = SyntaxEngine::new(
            Arc::clone(&engine),
            Arc::clone(&receiver) as Arc<dyn SyntaxFrontend>,
        );
        refresh_buffer_caches(&mut state, &mut syntax);

        assert!(
            state.cached_token_count > 0,
            "cached_token_count must be non-zero after refresh_buffer_caches"
        );
    }

    // --- work item 0013: on-disk-updates ---

    fn make_syntax() -> SyntaxEngine {
        let engine = Arc::new(Mutex::new(LspEngine::new(LspEngineConfig::default())));
        let receiver = Arc::new(TuiSyntaxReceiver::new());
        SyntaxEngine::new(
            Arc::clone(&engine),
            Arc::clone(&receiver) as Arc<dyn SyntaxFrontend>,
        )
    }

    #[test]
    fn handle_fs_event_sets_disk_changed_on_modify() {
        use notify::event::{DataChange, ModifyKind as NModifyKind};
        use std::time::SystemTime;

        let (f, mut state) = make_state_with_lines(&["original"]);
        state.buffers[0].last_disk_mtime = Some(SystemTime::UNIX_EPOCH);

        let mut watcher = super::super::fs_watcher::FsWatcher::new().unwrap();
        watcher.watch_file(f.path()).unwrap();
        let canonical = f.path().canonicalize().unwrap();

        let event = notify::Event::new(notify::EventKind::Modify(NModifyKind::Data(
            DataChange::Content,
        )))
        .add_path(canonical);

        handle_fs_event(&event, &mut state, &mut watcher);

        assert!(
            state.buffers[0].disk_changed,
            "disk_changed must be set after a Modify event when mtime differs"
        );
    }

    #[test]
    fn reload_buffer_from_disk_updates_content_and_clears_flags() {
        let (f, mut state) = make_state_with_lines(&["original line"]);
        state.buffers[0].disk_changed = true;

        std::fs::write(f.path(), "new content\nsecond line\n").unwrap();

        let mut syntax = make_syntax();
        reload_buffer_from_disk(&mut state, &mut syntax);

        let buf = state.current_buffer().unwrap();
        assert_eq!(
            buf.lines,
            vec!["new content", "second line"],
            "reloaded buffer should reflect new file content"
        );
        assert!(!buf.dirty, "reloaded buffer must not be dirty");
        assert!(
            !buf.disk_changed,
            "reloaded buffer must not have disk_changed set"
        );
        assert!(
            state.status_msg.contains("reloaded"),
            "status_msg should confirm reload; got: {:?}",
            state.status_msg
        );
    }

    #[test]
    fn reload_buffer_from_disk_snaps_cursor_to_char_boundary() {
        let (f, mut state) = make_state_with_lines(&["hello"]);
        state.cursor_col = 1;
        state.buffers[0].disk_changed = true;

        // 'ø' is 2 bytes; cursor_col 1 would be inside 'ø' without snapping
        std::fs::write(f.path(), "øabc\n").unwrap();

        let mut syntax = make_syntax();
        reload_buffer_from_disk(&mut state, &mut syntax);

        assert!(
            state.current_buffer().unwrap().lines[0].is_char_boundary(state.cursor_col),
            "cursor_col {} must be on a char boundary after reload",
            state.cursor_col
        );
        assert_eq!(state.cursor_col, 0, "should snap back to byte 0");
    }

    #[test]
    fn apply_tree_event_insert_adds_entry_in_sorted_order() {
        use notify::event::CreateKind;

        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("aaa.txt"), "").unwrap();
        std::fs::write(tmp.path().join("ccc.txt"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();

        let new_file = tmp.path().join("bbb.txt");
        std::fs::write(&new_file, "").unwrap();

        let event =
            notify::Event::new(notify::EventKind::Create(CreateKind::File)).add_path(new_file);
        apply_tree_event(&mut state, &event);

        let tree = state.file_tree.as_ref().unwrap();
        let names: Vec<&str> = tree
            .entries
            .iter()
            .filter(|e| !e.is_dir)
            .map(|e| e.path.file_name().unwrap().to_str().unwrap())
            .collect();

        assert!(
            names.contains(&"bbb.txt"),
            "new file should appear in tree.entries"
        );
        let a = names.iter().position(|&n| n == "aaa.txt").unwrap();
        let b = names.iter().position(|&n| n == "bbb.txt").unwrap();
        let c = names.iter().position(|&n| n == "ccc.txt").unwrap();
        assert!(
            a < b && b < c,
            "entries should be sorted aaa < bbb < ccc; got: {names:?}"
        );
    }

    #[test]
    fn apply_tree_event_remove_deletes_from_entries_and_tree_view() {
        use notify::event::RemoveKind;

        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("keep.txt"), "").unwrap();
        std::fs::write(tmp.path().join("remove.txt"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();

        let remove_path = state
            .file_tree
            .as_ref()
            .unwrap()
            .entries
            .iter()
            .find(|e| e.path.file_name().is_some_and(|n| n == "remove.txt"))
            .unwrap()
            .path
            .clone();

        if !state.tree_view.iter().any(|e| e.path == remove_path) {
            let entry = state
                .file_tree
                .as_ref()
                .unwrap()
                .entries
                .iter()
                .find(|e| e.path == remove_path)
                .unwrap()
                .clone();
            state.tree_view.push(entry);
        }

        std::fs::remove_file(&remove_path).unwrap();

        let event = notify::Event::new(notify::EventKind::Remove(RemoveKind::File))
            .add_path(remove_path.clone());
        apply_tree_event(&mut state, &event);

        let tree = state.file_tree.as_ref().unwrap();
        assert!(
            !tree.entries.iter().any(|e| e.path == remove_path),
            "removed file must not remain in tree.entries"
        );
        assert!(
            !state.tree_view.iter().any(|e| e.path == remove_path),
            "removed file must not remain in tree_view"
        );
    }

    #[test]
    fn apply_tree_event_remove_clamps_tree_selected() {
        use notify::event::RemoveKind;

        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("a.txt"), "").unwrap();
        std::fs::write(tmp.path().join("b.txt"), "").unwrap();
        std::fs::write(tmp.path().join("c.txt"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();

        let initial_len = state.tree_view.len();
        assert!(
            initial_len >= 2,
            "need at least 2 tree_view entries for clamping test"
        );

        state.tree_selected = initial_len - 1;
        let last_path = state.tree_view[initial_len - 1].path.clone();

        std::fs::remove_file(&last_path).ok();

        let event =
            notify::Event::new(notify::EventKind::Remove(RemoveKind::File)).add_path(last_path);
        apply_tree_event(&mut state, &event);

        let new_len = state.tree_view.len();
        if new_len > 0 {
            assert!(
                state.tree_selected < new_len,
                "tree_selected ({}) must be clamped below new tree_view length ({})",
                state.tree_selected,
                new_len
            );
        } else {
            assert_eq!(
                state.tree_selected, 0,
                "tree_selected must be 0 when tree_view is empty"
            );
        }
    }

    #[test]
    fn handle_fs_event_remove_sets_disk_deleted_and_clears_disk_changed() {
        use notify::event::RemoveKind;

        let (f, mut state) = make_state_with_lines(&["content"]);
        state.buffers[0].disk_changed = true;

        let mut watcher = super::super::fs_watcher::FsWatcher::new().unwrap();
        watcher.watch_file(f.path()).unwrap();
        let canonical = f.path().canonicalize().unwrap();

        std::fs::remove_file(f.path()).unwrap();

        let event =
            notify::Event::new(notify::EventKind::Remove(RemoveKind::File)).add_path(canonical);
        handle_fs_event(&event, &mut state, &mut watcher);

        assert!(
            state.buffers[0].disk_deleted,
            "disk_deleted must be set after the file is removed"
        );
        assert!(
            !state.buffers[0].disk_changed,
            "disk_changed must be cleared after the file is removed"
        );
        assert!(
            state.pending_rewatch_path.is_some(),
            "pending_rewatch_path should be set so the file is re-watched if it reappears"
        );
    }

    #[test]
    fn apply_tree_event_create_inserts_into_tree_view_at_depth_zero() {
        use notify::event::CreateKind;

        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("aaa.txt"), "").unwrap();
        std::fs::write(tmp.path().join("ccc.txt"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();

        let new_file = tmp.path().join("bbb.txt");
        std::fs::write(&new_file, "").unwrap();

        let event =
            notify::Event::new(notify::EventKind::Create(CreateKind::File)).add_path(new_file);
        apply_tree_event(&mut state, &event);

        let names: Vec<&str> = state
            .tree_view
            .iter()
            .map(|e| e.path.file_name().unwrap().to_str().unwrap())
            .collect();
        assert!(
            names.contains(&"bbb.txt"),
            "new file should appear in tree_view (depth 0); got: {names:?}"
        );
        let a = names.iter().position(|&n| n == "aaa.txt").unwrap();
        let b = names.iter().position(|&n| n == "bbb.txt").unwrap();
        let c = names.iter().position(|&n| n == "ccc.txt").unwrap();
        assert!(
            a < b && b < c,
            "tree_view should be sorted aaa < bbb < ccc; got: {names:?}"
        );
    }

    #[test]
    fn apply_tree_event_create_skips_tree_view_when_parent_collapsed() {
        use notify::event::CreateKind;

        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::create_dir(tmp.path().join("sub")).unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();
        // sub is at depth 0 in tree_view, but its children are NOT yet inserted
        // (no expand call), so a new child file should NOT appear in tree_view.

        let new_file = tmp.path().join("sub/child.txt");
        std::fs::write(&new_file, "").unwrap();

        let event = notify::Event::new(notify::EventKind::Create(CreateKind::File))
            .add_path(new_file.clone());
        apply_tree_event(&mut state, &event);

        let canonical_child = new_file.canonicalize().unwrap();
        assert!(
            state
                .file_tree
                .as_ref()
                .unwrap()
                .entries
                .iter()
                .any(|e| e.path == canonical_child),
            "new file should still appear in tree.entries"
        );
        assert!(
            !state.tree_view.iter().any(|e| e.path == canonical_child),
            "new file must NOT appear in tree_view while parent is collapsed"
        );
    }

    #[test]
    fn apply_tree_event_rename_dir_updates_child_paths() {
        use notify::event::{ModifyKind as NModifyKind, RenameMode};

        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::create_dir(tmp.path().join("old")).unwrap();
        std::fs::write(tmp.path().join("old/inner.txt"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();

        // Ensure tree_view has the inner entry as well, mimicking an expanded dir.
        let old_dir_canonical = tmp.path().join("old").canonicalize().unwrap();
        let inner_canonical = tmp.path().join("old/inner.txt").canonicalize().unwrap();
        if !state.tree_view.iter().any(|e| e.path == inner_canonical) {
            state.tree_view.push(FileEntry {
                path: inner_canonical.clone(),
                depth: 1,
                is_dir: false,
            });
        }

        std::fs::rename(tmp.path().join("old"), tmp.path().join("new")).unwrap();
        let new_dir_canonical = tmp.path().join("new").canonicalize().unwrap();

        let event = notify::Event::new(notify::EventKind::Modify(NModifyKind::Name(
            RenameMode::Both,
        )))
        .add_path(old_dir_canonical)
        .add_path(new_dir_canonical.clone());
        apply_tree_event(&mut state, &event);

        let expected_child = new_dir_canonical.join("inner.txt");
        let tree = state.file_tree.as_ref().unwrap();
        assert!(
            tree.entries.iter().any(|e| e.path == expected_child),
            "child path under renamed dir should be rewritten; entries: {:?}",
            tree.entries.iter().map(|e| &e.path).collect::<Vec<_>>()
        );
        assert!(
            state.tree_view.iter().any(|e| e.path == expected_child),
            "tree_view child path should be rewritten; got: {:?}",
            state.tree_view.iter().map(|e| &e.path).collect::<Vec<_>>()
        );
    }

    #[test]
    fn ctrl_s_save_failure_surfaces_error_in_status_msg() {
        // Make the buffer point at a path whose parent does not exist so the
        // write call fails; assert the status message reports the failure.
        let (_f, mut state) = make_state_with_lines(&["hello"]);
        if let Some(buf) = state.current_buffer_mut() {
            buf.path = std::path::PathBuf::from("/nonexistent-dir-9d3b/file.txt");
            buf.dirty = true;
        }

        let engine = Arc::new(Mutex::new(LspEngine::new(LspEngineConfig::default())));
        let receiver = Arc::new(TuiSyntaxReceiver::new());
        let mut syntax = SyntaxEngine::new(
            Arc::clone(&engine),
            Arc::clone(&receiver) as Arc<dyn SyntaxFrontend>,
        );
        let mut frontend = TuiFrontend::new();

        let _ = handle_key(
            &mut state,
            &mut frontend,
            &engine,
            KeyCode::Char('s'),
            KeyModifiers::CONTROL,
            &mut syntax,
            &[],
            80,
        );

        assert!(
            state.status_msg.starts_with("save failed:"),
            "failed save should surface error; got: {:?}",
            state.status_msg
        );
    }

    // ── WI-0014 tests ────────────────────────────────────────────────────────

    #[test]
    fn toggle_tree_bare_filename_rooted_at_cwd() {
        let mut state = EditorState::for_file(&PathBuf::from("notes.txt")).unwrap();
        toggle_tree(&mut state);
        assert!(
            !state.status_msg.contains("tree error"),
            "expected no error; got: {:?}",
            state.status_msg
        );
        let tree = state
            .file_tree
            .expect("file_tree should be Some after toggle");
        assert!(tree.root.is_absolute(), "root should be an absolute path");
        assert!(tree.root.is_dir(), "root should be an existing directory");
    }

    #[test]
    fn begin_tree_rename_sets_input_and_cursor_to_end() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("hello.rs"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();

        let idx = state
            .tree_view
            .iter()
            .position(|e| e.name() == "hello.rs")
            .expect("hello.rs should be in tree_view");
        state.tree_selected = idx;

        begin_tree_rename(&mut state);

        let rs = state
            .tree_rename_state
            .as_ref()
            .expect("tree_rename_state should be Some");
        assert_eq!(rs.input, "hello.rs");
        assert_eq!(rs.cursor, "hello.rs".len());
    }

    #[test]
    fn commit_tree_rename_renames_file_on_disk_and_updates_tree_view() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("old.rs"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();
        let mut syntax = make_syntax();

        let idx = state
            .tree_view
            .iter()
            .position(|e| e.name() == "old.rs")
            .expect("old.rs should be in tree_view");
        state.tree_rename_state = Some(TreeRenameState {
            index: idx,
            input: "new.rs".to_string(),
            cursor: 6,
        });

        commit_tree_rename(&mut state, &mut syntax);

        assert!(
            !tmp.path().join("old.rs").exists(),
            "old.rs should be gone from disk"
        );
        assert!(
            tmp.path().join("new.rs").exists(),
            "new.rs should exist on disk"
        );
        assert!(
            state.tree_view.iter().any(|e| e.name() == "new.rs"),
            "tree_view should contain new.rs"
        );
        assert!(
            !state.tree_view.iter().any(|e| e.name() == "old.rs"),
            "tree_view should not contain old.rs"
        );
    }

    #[test]
    fn commit_tree_rename_updates_open_buffer_path() {
        let tmp = tempfile::TempDir::new().unwrap();
        let old_path = tmp.path().join("old.rs");
        std::fs::write(&old_path, "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();
        let mut syntax = make_syntax();

        state.open_file(&old_path).unwrap();

        let idx = state
            .tree_view
            .iter()
            .position(|e| e.name() == "old.rs")
            .expect("old.rs should be in tree_view");
        state.tree_rename_state = Some(TreeRenameState {
            index: idx,
            input: "new.rs".to_string(),
            cursor: 6,
        });

        commit_tree_rename(&mut state, &mut syntax);

        let new_path = tmp.path().join("new.rs");
        let canonical_new = new_path.canonicalize().unwrap_or(new_path);
        assert!(
            state.buffers.iter().any(|b| b.path == canonical_new),
            "a buffer should have the new path {:?}; buffers: {:?}",
            canonical_new,
            state.buffers.iter().map(|b| &b.path).collect::<Vec<_>>()
        );
    }

    #[test]
    fn commit_tree_rename_rejects_existing_destination() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("old.rs"), "").unwrap();
        std::fs::write(tmp.path().join("new.rs"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();
        let mut syntax = make_syntax();

        let idx = state
            .tree_view
            .iter()
            .position(|e| e.name() == "old.rs")
            .expect("old.rs should be in tree_view");
        state.tree_rename_state = Some(TreeRenameState {
            index: idx,
            input: "new.rs".to_string(),
            cursor: 6,
        });

        commit_tree_rename(&mut state, &mut syntax);

        assert!(
            state.status_msg.contains("exists"),
            "status_msg should mention 'exists'; got: {:?}",
            state.status_msg
        );
        assert!(
            tmp.path().join("old.rs").exists(),
            "old.rs should remain on disk"
        );
        assert!(
            state.tree_rename_state.is_some(),
            "tree_rename_state should remain Some so the user can correct the name"
        );
    }

    #[test]
    fn commit_tree_rename_keeps_state_on_empty_name() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("file.rs"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();
        let mut syntax = make_syntax();

        let idx = state
            .tree_view
            .iter()
            .position(|e| e.name() == "file.rs")
            .expect("file.rs should be in tree_view");
        state.tree_rename_state = Some(TreeRenameState {
            index: idx,
            input: "   ".to_string(),
            cursor: 3,
        });

        commit_tree_rename(&mut state, &mut syntax);

        assert!(
            state.status_msg.contains("empty"),
            "status_msg should mention empty; got: {:?}",
            state.status_msg
        );
        assert!(
            state.tree_rename_state.is_some(),
            "tree_rename_state should remain Some so the user can correct the name"
        );
        assert!(tmp.path().join("file.rs").exists());
    }

    #[test]
    fn commit_tree_rename_keeps_state_on_path_separator() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("file.rs"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();
        let mut syntax = make_syntax();

        let idx = state
            .tree_view
            .iter()
            .position(|e| e.name() == "file.rs")
            .expect("file.rs should be in tree_view");
        state.tree_rename_state = Some(TreeRenameState {
            index: idx,
            input: "sub/x.rs".to_string(),
            cursor: 8,
        });

        commit_tree_rename(&mut state, &mut syntax);

        assert!(
            state.status_msg.contains("path separators"),
            "status_msg should mention path separators; got: {:?}",
            state.status_msg
        );
        assert!(
            state.tree_rename_state.is_some(),
            "tree_rename_state should remain Some so the user can correct the name"
        );
        assert!(tmp.path().join("file.rs").exists());
    }

    #[test]
    fn commit_tree_rename_unchanged_name_clears_state_silently() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("file.rs"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();
        let mut syntax = make_syntax();

        let idx = state
            .tree_view
            .iter()
            .position(|e| e.name() == "file.rs")
            .expect("file.rs should be in tree_view");
        state.tree_rename_state = Some(TreeRenameState {
            index: idx,
            input: "file.rs".to_string(),
            cursor: 7,
        });

        commit_tree_rename(&mut state, &mut syntax);

        assert!(
            state.tree_rename_state.is_none(),
            "tree_rename_state should be cleared on unchanged-name commit"
        );
        assert!(
            !state.status_msg.contains("rename failed"),
            "no error should be surfaced for an unchanged name; got: {:?}",
            state.status_msg
        );
        assert!(tmp.path().join("file.rs").exists());
    }

    #[test]
    fn begin_tree_delete_populates_children_preview_for_folder() {
        let tmp = tempfile::TempDir::new().unwrap();
        let subdir = tmp.path().join("mydir");
        std::fs::create_dir(&subdir).unwrap();
        std::fs::write(subdir.join("a.rs"), "").unwrap();
        std::fs::write(subdir.join("b.rs"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();

        let idx = state
            .tree_view
            .iter()
            .position(|e| e.name() == "mydir")
            .expect("mydir should be in tree_view");
        state.tree_selected = idx;

        begin_tree_delete(&mut state);

        let ds = state
            .tree_delete_confirm
            .as_ref()
            .expect("tree_delete_confirm should be Some");
        let names: Vec<&str> = ds.children_preview.iter().map(String::as_str).collect();
        assert!(
            names.contains(&"a.rs"),
            "children_preview should contain a.rs; got: {names:?}"
        );
        assert!(
            names.contains(&"b.rs"),
            "children_preview should contain b.rs; got: {names:?}"
        );
    }

    #[test]
    fn commit_tree_delete_removes_file_from_disk_and_tree_view() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("gone.rs"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();

        let idx = state
            .tree_view
            .iter()
            .position(|e| e.name() == "gone.rs")
            .expect("gone.rs should be in tree_view");
        state.tree_selected = idx;
        state.tree_delete_confirm = Some(TreeDeleteState {
            index: idx,
            children_preview: Vec::new(),
        });

        commit_tree_delete(&mut state);

        assert!(
            !tmp.path().join("gone.rs").exists(),
            "gone.rs should be removed from disk"
        );
        assert!(
            !state.tree_view.iter().any(|e| e.name() == "gone.rs"),
            "tree_view should not contain gone.rs"
        );
    }

    #[test]
    fn commit_tree_delete_folder_removes_recursively() {
        let tmp = tempfile::TempDir::new().unwrap();
        let subdir = tmp.path().join("mydir");
        std::fs::create_dir(&subdir).unwrap();
        std::fs::write(subdir.join("inner.rs"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();

        let idx = state
            .tree_view
            .iter()
            .position(|e| e.name() == "mydir")
            .expect("mydir should be in tree_view");
        state.tree_selected = idx;
        state.tree_delete_confirm = Some(TreeDeleteState {
            index: idx,
            children_preview: vec!["inner.rs".to_string()],
        });

        commit_tree_delete(&mut state);

        assert!(!subdir.exists(), "mydir should be removed from disk");
        let tree = state.file_tree.as_ref().unwrap();
        assert!(
            !tree.entries.iter().any(|e| e.name() == "mydir"),
            "file_tree.entries should not contain mydir"
        );
        assert!(
            !tree.entries.iter().any(|e| e.name() == "inner.rs"),
            "file_tree.entries should not contain inner.rs"
        );
    }

    #[test]
    fn commit_tree_delete_clamps_tree_selected() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("aaa.rs"), "").unwrap();
        std::fs::write(tmp.path().join("bbb.rs"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();

        let last_idx = state.tree_view.len() - 1;
        state.tree_selected = last_idx;
        state.tree_delete_confirm = Some(TreeDeleteState {
            index: last_idx,
            children_preview: Vec::new(),
        });

        commit_tree_delete(&mut state);

        let new_len = state.tree_view.len();
        if new_len > 0 {
            assert!(
                state.tree_selected < new_len,
                "tree_selected ({}) must be < new length ({})",
                state.tree_selected,
                new_len
            );
        } else {
            assert_eq!(state.tree_selected, 0);
        }
    }

    #[test]
    fn begin_tree_delete_disallows_tree_root() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("file.rs"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();

        let root_path = state.file_tree.as_ref().unwrap().root.clone();
        state.tree_view.insert(
            0,
            crate::data::file_tree::FileEntry {
                path: root_path,
                depth: 0,
                is_dir: true,
            },
        );
        state.tree_selected = 0;

        begin_tree_delete(&mut state);

        assert!(
            state.tree_delete_confirm.is_none(),
            "tree_delete_confirm should remain None for root"
        );
        assert!(
            state.status_msg.contains("cannot delete tree root"),
            "status_msg should mention 'cannot delete tree root'; got: {:?}",
            state.status_msg
        );
    }

    #[test]
    fn begin_tree_new_file_on_file_entry_uses_parent_dir() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("file.rs"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();

        let idx = state
            .tree_view
            .iter()
            .position(|e| e.name() == "file.rs")
            .expect("file.rs should be in tree_view");
        state.tree_selected = idx;

        begin_tree_new_file(&mut state);

        let nfs = state
            .tree_new_file_state
            .as_ref()
            .expect("tree_new_file_state should be Some");
        let canonical_tmp = tmp.path().canonicalize().unwrap();
        assert_eq!(
            nfs.parent_dir, canonical_tmp,
            "parent_dir should be the file's parent directory"
        );
    }

    #[test]
    fn begin_tree_new_file_on_folder_entry_uses_folder_itself() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::create_dir(tmp.path().join("subdir")).unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();

        let idx = state
            .tree_view
            .iter()
            .position(|e| e.name() == "subdir")
            .expect("subdir should be in tree_view");
        state.tree_selected = idx;

        begin_tree_new_file(&mut state);

        let nfs = state
            .tree_new_file_state
            .as_ref()
            .expect("tree_new_file_state should be Some");
        let canonical_subdir = tmp.path().join("subdir").canonicalize().unwrap();
        assert_eq!(
            nfs.parent_dir, canonical_subdir,
            "parent_dir should equal the folder's own path"
        );
    }

    #[test]
    fn commit_tree_new_file_creates_file_opens_buffer_and_sets_edit_mode() {
        let tmp = tempfile::TempDir::new().unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();
        let mut syntax = make_syntax();

        let root = state.file_tree.as_ref().unwrap().root.clone();
        state.tree_new_file_state = Some(TreeNewFileState {
            parent_dir: root,
            input: "newfile.rs".to_string(),
            cursor: 10,
        });

        commit_tree_new_file(&mut state, &mut syntax);

        assert!(
            tmp.path().join("newfile.rs").exists(),
            "newfile.rs should exist on disk"
        );
        assert!(
            state.tree_view.iter().any(|e| e.name() == "newfile.rs"),
            "tree_view should contain newfile.rs"
        );
        assert_eq!(
            state.mode,
            Mode::Edit,
            "mode should be Edit after creating a new file"
        );
    }

    #[test]
    fn commit_tree_new_file_rejects_existing_file() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("exists.rs"), "content").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();
        let mut syntax = make_syntax();

        let root = state.file_tree.as_ref().unwrap().root.clone();
        state.tree_new_file_state = Some(TreeNewFileState {
            parent_dir: root,
            input: "exists.rs".to_string(),
            cursor: 9,
        });

        commit_tree_new_file(&mut state, &mut syntax);

        assert!(
            state.status_msg.contains("already exists"),
            "status_msg should contain 'already exists'; got: {:?}",
            state.status_msg
        );
        assert!(
            state.tree_new_file_state.is_some(),
            "tree_new_file_state should remain Some so the user can correct the name"
        );
    }

    #[test]
    fn commit_tree_new_file_rejects_path_separators() {
        let tmp = tempfile::TempDir::new().unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();
        let mut syntax = make_syntax();

        let root = state.file_tree.as_ref().unwrap().root.clone();
        state.tree_new_file_state = Some(TreeNewFileState {
            parent_dir: root,
            input: "sub/file.rs".to_string(),
            cursor: 11,
        });

        commit_tree_new_file(&mut state, &mut syntax);

        assert!(
            !tmp.path().join("sub").exists(),
            "no 'sub' directory should be created"
        );
        assert!(
            !tmp.path().join("sub/file.rs").exists(),
            "sub/file.rs should not be created"
        );
        assert!(
            state.status_msg.contains("cannot contain path separators"),
            "status_msg should mention path separators; got: {:?}",
            state.status_msg
        );
    }

    #[test]
    fn ctrl_t_during_rename_is_absorbed_and_does_not_toggle_tree() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("file.rs"), "").unwrap();
        let mut state = EditorState::for_directory(tmp.path()).unwrap();
        // for_directory leaves focus_tree = true; capture starting tree state.
        let had_tree_before = state.file_tree.is_some();
        let focus_before = state.focus_tree;
        state.tree_rename_state = Some(TreeRenameState {
            index: 0,
            input: "file.rs".to_string(),
            cursor: 7,
        });

        let engine = Arc::new(Mutex::new(LspEngine::new(LspEngineConfig::default())));
        let receiver = Arc::new(TuiSyntaxReceiver::new());
        let mut syntax = SyntaxEngine::new(
            Arc::clone(&engine),
            Arc::clone(&receiver) as Arc<dyn SyntaxFrontend>,
        );
        let mut frontend = TuiFrontend::new();

        let _ = handle_key(
            &mut state,
            &mut frontend,
            &engine,
            KeyCode::Char('t'),
            KeyModifiers::CONTROL,
            &mut syntax,
            &[],
            80,
        );

        // Tree state must not toggle; rename state must survive as either
        // unchanged or cleared (the Ctrl-T isn't a Char(c) under rename input
        // because the modifier check excludes Control). Either way: no toggle.
        assert_eq!(
            state.file_tree.is_some(),
            had_tree_before,
            "file_tree presence must not change when Ctrl-T is pressed mid-rename"
        );
        assert_eq!(
            state.focus_tree, focus_before,
            "focus_tree must not toggle when Ctrl-T is pressed mid-rename"
        );
    }
}
