use std::collections::{HashMap, HashSet};

use ratatui::layout::Rect;
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::UnboundedSender;

use crate::action::Action;
use crate::cli::DiffRange;
use crate::diff::{DiffLine, FileDiff, LineKind, build_split_rows};

#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum ThemeName {
    /// 朱入レ classic — dark, practical, shuire-red accent.
    #[default]
    Classic,
    /// 和紙 — warm paper, proofreader's desk.
    Washi,
    /// 墨 — black ink, high contrast, calligraphic.
    Sumi,
    /// Legacy GitHub-style dark theme (kept for backward compatibility).
    Dark,
    /// Legacy GitHub-style light theme (kept for backward compatibility).
    Light,
    /// Deuteranopia-friendly variant of the legacy dark theme.
    Deuteranopia,
}

impl ThemeName {
    /// Cycle through the three canonical 朱入レ themes. Legacy themes fall
    /// back onto the shuire cycle when toggled.
    pub fn next(self) -> Self {
        match self {
            ThemeName::Classic => ThemeName::Washi,
            ThemeName::Washi => ThemeName::Sumi,
            ThemeName::Sumi => ThemeName::Classic,
            // Legacy themes cycle back to the canonical lineup when `T` is pressed.
            ThemeName::Dark | ThemeName::Light | ThemeName::Deuteranopia => ThemeName::Classic,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
    Files,
    Diff,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    Normal,
    Insert,
    Search,
    FileFilter,
    Visual,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Side {
    Old,
    New,
}

impl Side {
    pub fn tag(self) -> &'static str {
        match self {
            Side::Old => "-",
            Side::New => "+",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnderlineKind {
    None,
    Dim,
    Bright,
}

#[derive(Debug, Clone)]
pub struct Comment {
    pub file: String,
    pub side: Side,
    pub lineno: u32,
    pub lineno_end: Option<u32>,
    pub body: String,
    pub replies: Vec<String>,
    /// True for comments loaded from an existing PR (read-only, not written by the user).
    pub from_pr: bool,
}

impl Comment {
    pub fn anchored_at(&self, file: &str, side: Side, lineno: u32) -> bool {
        self.file == file && self.side == side && self.lineno == lineno
    }

    pub fn location(&self) -> String {
        match self.lineno_end {
            Some(end) => format!("{}:L{}{}-L{}", self.file, self.lineno, self.side.tag(), end),
            None => format!("{}:L{}{}", self.file, self.lineno, self.side.tag()),
        }
    }
}

/// Anchor a diff line to a (side, lineno) for comment attachment.
/// Context lines prefer the new side; added → new, removed → old.
pub fn line_anchor(line: &DiffLine) -> Option<(Side, u32)> {
    match line.kind {
        LineKind::Removed => line.old_lineno.map(|n| (Side::Old, n)),
        LineKind::Added => line.new_lineno.map(|n| (Side::New, n)),
        LineKind::Context => line.new_lineno.map(|n| (Side::New, n)),
        _ => None,
    }
}

pub fn find_display_index(file: &FileDiff, side: Side, lineno: u32) -> Option<usize> {
    file.lines
        .iter()
        .position(|l| line_anchor(l) == Some((side, lineno)))
}

/// Rows a comment block consumes: top border + header label + body + bottom
/// border, plus any replies (one row per newline-split reply segment). Must
/// stay in sync with `push_comment_block` / `push_reply_block` in ui.rs.
fn comment_plus_replies_rows(c: &Comment) -> usize {
    let mut n = 3 + c.body.matches('\n').count() + 1;
    for r in &c.replies {
        n += r.matches('\n').count() + 1;
    }
    n
}

/// Chrome widths in front of code text: gutter + prefix on the first row,
/// and the indent that wrapped continuation rows share with the code column.
#[derive(Clone, Copy)]
struct CodeLayout {
    header: usize,
    indent: usize,
}

/// "  " anchor (2) + "NNNN NNNN" gutter (9) + " " marker (1) + prefix (1).
const UNIFIED_LAYOUT: CodeLayout = CodeLayout {
    header: 14,
    indent: 12, // crate::ui::GUTTER_INDENT.len()
};
const SPLIT_LAYOUT: CodeLayout = CodeLayout {
    header: crate::ui::SPLIT_GUTTER_WIDTH,
    indent: crate::ui::SPLIT_GUTTER_WIDTH,
};

/// Rows a single code line occupies at `full_width`, mirroring `wrap_line` in
/// ui.rs — the first row gets `full_width`, subsequent rows get
/// `full_width - indent`.
fn line_visual_rows(line: &DiffLine, full_width: usize, m: CodeLayout) -> usize {
    if line.kind.is_fixed_height() || full_width == 0 {
        return 1;
    }
    let text_width = unicode_width::UnicodeWidthStr::width(line.text.as_str());
    let content_width = text_width + m.header;
    if content_width <= full_width {
        return 1;
    }
    let avail_rest = full_width.saturating_sub(m.indent);
    if avail_rest == 0 {
        return 1;
    }
    1 + (content_width - full_width).div_ceil(avail_rest)
}

pub struct App {
    pub files: Vec<FileDiff>,
    pub range: DiffRange,
    /// Fingerprint of the diff payload at construction (or last reload).
    /// Used to scope persisted comments so a changed diff doesn't surface
    /// stale, line-misaligned comments from a previous run.
    pub diff_fingerprint: String,
    pub selected: usize,
    pub diff_scroll: usize,
    pub cursor_line: usize,
    pub focus: Focus,
    pub mode: Mode,
    pub input: String,
    pub input_cursor: usize,
    pub comments: Vec<Comment>,
    pub should_quit: bool,
    pub viewport_height: usize,
    pub pending_g: bool,
    pub pending_d: bool,
    pub show_comments_list: bool,
    pub comments_list_cursor: usize,
    pub pending_expand: Option<usize>,
    pub file_list_area: Rect,
    pub diff_area: Rect,
    pub split_view: bool,
    pub preview_mode: bool,
    pub comment_modal: bool,
    pub show_file_list: bool,
    pub show_help: bool,
    pub show_hints: bool,
    pub diff_row_map: Vec<usize>,
    pub comment_focus: Option<usize>,
    pub editing_comment: Option<usize>,
    pub search_query: Option<String>,
    pub search_matches: Vec<(usize, usize)>,
    pub search_match_cursor: usize,
    pub file_filter: Option<String>,
    pub file_list_width: u16,
    pub expanded_dirs: HashSet<String>,
    pub visual_start: Option<usize>,
    pub theme_name: ThemeName,
    pub color_overrides: HashMap<String, Color>,
    pub no_emoji: bool,
    pub action_tx: Option<UnboundedSender<Action>>,
    pub comment_line_end: Option<usize>,
    pub pending_open_editor: bool,
    pub pending_reload: bool,
    pub show_revision_selector: bool,
    pub revisions: Vec<String>,
    pub revision_cursor: usize,
    pub pending_range_change: Option<String>,
    pub file_tree_cursor: usize,
}

impl App {
    pub fn new(files: Vec<FileDiff>, range: DiffRange, theme_name: ThemeName) -> Self {
        // Files are stored in the order git emits them, but the file list is
        // rendered as a sorted tree. Select the file that shows up first in
        // that tree so the cursor starts at the top of what the user sees.
        let initial_selected = crate::ui::tree_file_order(&files)
            .first()
            .copied()
            .unwrap_or(0);
        let diff_fingerprint = crate::storage::diff_fingerprint(&files);
        let mut app = Self {
            files,
            range,
            diff_fingerprint,
            selected: initial_selected,
            diff_scroll: 0,
            cursor_line: 0,
            focus: Focus::Diff,
            mode: Mode::Normal,
            input: String::new(),
            input_cursor: 0,
            comments: Vec::new(),
            should_quit: false,
            viewport_height: 20,
            pending_g: false,
            pending_d: false,
            show_comments_list: false,
            comments_list_cursor: 0,
            pending_expand: None,
            file_list_area: Rect::default(),
            diff_area: Rect::default(),
            split_view: false,
            preview_mode: false,
            comment_modal: true,
            show_file_list: true,
            show_help: false,
            show_hints: true,
            diff_row_map: Vec::new(),
            comment_focus: None,
            editing_comment: None,
            search_query: None,
            search_matches: Vec::new(),
            search_match_cursor: 0,
            file_filter: None,
            file_list_width: 34,
            expanded_dirs: HashSet::new(),
            visual_start: None,
            theme_name,
            color_overrides: HashMap::new(),
            no_emoji: false,
            action_tx: None,
            comment_line_end: None,
            pending_open_editor: false,
            pending_reload: false,
            show_revision_selector: false,
            revisions: Vec::new(),
            revision_cursor: 0,
            pending_range_change: None,
            file_tree_cursor: 0,
        };
        // Expand all directory paths so files are visible by default
        for file in &app.files {
            if let Some(dir) = file.path.rsplit_once('/').map(|x| x.0) {
                let mut cur = String::new();
                for (i, seg) in dir.split('/').enumerate() {
                    if i > 0 {
                        cur.push('/');
                    }
                    cur.push_str(seg);
                    app.expanded_dirs.insert(cur.clone());
                }
            }
        }
        app.cursor_line = app.first_navigable_line();
        app.sync_file_tree_cursor_to_selected();
        app
    }

    fn flat_tree(&self) -> Vec<crate::ui::FlatFileTreeEntry> {
        crate::ui::flatten_file_tree(
            &self.files,
            &self.comments,
            &self.expanded_dirs,
            self.file_filter.as_deref(),
        )
    }

    pub fn file_tree_cursor_down(&mut self) {
        let entries = self.flat_tree();
        if entries.is_empty() {
            return;
        }
        let n = entries.len();
        self.file_tree_cursor = (self.file_tree_cursor + 1).min(n - 1);
        self.apply_file_tree_cursor_selection(&entries);
    }

    pub fn file_tree_cursor_up(&mut self) {
        let entries = self.flat_tree();
        if entries.is_empty() {
            return;
        }
        self.file_tree_cursor = self.file_tree_cursor.saturating_sub(1);
        self.apply_file_tree_cursor_selection(&entries);
    }

    fn apply_file_tree_cursor_selection(&mut self, entries: &[crate::ui::FlatFileTreeEntry]) {
        if let Some(crate::ui::FlatFileTreeEntry::File { file_idx }) =
            entries.get(self.file_tree_cursor)
        {
            if *file_idx != self.selected {
                self.selected = *file_idx;
                self.reset_view();
            }
        }
    }

    pub fn toggle_file_tree_dir_at_cursor(&mut self) -> bool {
        let entries = self.flat_tree();
        let Some(crate::ui::FlatFileTreeEntry::Dir { path }) = entries.get(self.file_tree_cursor)
        else {
            return false;
        };
        let path = path.clone();
        if self.expanded_dirs.contains(&path) {
            self.expanded_dirs.remove(&path);
        } else {
            self.expanded_dirs.insert(path);
        }
        let new_len = self.flat_tree().len();
        if new_len > 0 {
            self.file_tree_cursor = self.file_tree_cursor.min(new_len - 1);
        }
        true
    }

    pub fn sync_file_tree_cursor_to_selected(&mut self) {
        if let Some(file) = self.files.get(self.selected) {
            for parent in crate::ui::get_parent_dirs(&file.path) {
                self.expanded_dirs.insert(parent);
            }
        }
        let entries = self.flat_tree();
        if let Some(pos) = entries.iter().position(|e| {
            matches!(
                e,
                crate::ui::FlatFileTreeEntry::File { file_idx } if *file_idx == self.selected
            )
        }) {
            self.file_tree_cursor = pos;
        }
    }

    pub fn current(&self) -> Option<&FileDiff> {
        self.files.get(self.selected)
    }

    /// True when the file-tree cursor sits on a directory entry — i.e.
    /// when `Enter`/`Space` would toggle expansion.
    pub fn file_tree_cursor_on_dir(&self) -> bool {
        matches!(
            self.flat_tree().get(self.file_tree_cursor),
            Some(crate::ui::FlatFileTreeEntry::Dir { .. })
        )
    }

    /// True when the diff cursor is parked on a fold/expand marker line.
    pub fn cursor_on_fold(&self) -> bool {
        self.current()
            .and_then(|f| f.lines.get(self.cursor_line))
            .map(|l| l.kind.is_fold())
            .unwrap_or(false)
    }

    pub fn current_path(&self) -> String {
        self.current().map(|f| f.path.clone()).unwrap_or_default()
    }

    pub fn current_lineno(&self) -> String {
        self.current()
            .and_then(|f| f.lines.get(self.cursor_line))
            .map(|l| {
                l.new_lineno
                    .or(l.old_lineno)
                    .map(|n| n.to_string())
                    .unwrap_or_else(|| "-".to_string())
            })
            .unwrap_or_else(|| "-".to_string())
    }

    pub fn select_next_file(&mut self) {
        self.step_file_in_tree_order(1);
    }

    pub fn select_prev_file(&mut self) {
        self.step_file_in_tree_order(-1);
    }

    /// Tree-ordered file indices, narrowed to the active file filter so
    /// rotation (`[` / `]` / `{` / `}`) only cycles through visible files.
    fn filtered_tree_order(&self) -> Vec<usize> {
        let mut order = crate::ui::tree_file_order(&self.files);
        if let Some(ref q) = self.file_filter {
            let q_lower = q.to_lowercase();
            order.retain(|&i| self.files[i].path.to_lowercase().contains(&q_lower));
        }
        order
    }

    fn step_file_in_tree_order(&mut self, delta: isize) {
        let order = self.filtered_tree_order();
        if order.is_empty() {
            return;
        }
        let cur_pos = order.iter().position(|&i| i == self.selected).unwrap_or(0);
        let n = order.len() as isize;
        let next_pos = ((cur_pos as isize + delta).rem_euclid(n)) as usize;
        self.selected = order[next_pos];
        self.reset_view();
        self.sync_file_tree_cursor_to_selected();
    }

    pub fn select_first_file(&mut self) {
        let order = self.filtered_tree_order();
        let Some(&first) = order.first() else { return };
        self.selected = first;
        self.reset_view();
        self.sync_file_tree_cursor_to_selected();
    }

    pub fn select_last_file(&mut self) {
        let order = self.filtered_tree_order();
        let Some(&last) = order.last() else { return };
        self.selected = last;
        self.reset_view();
        self.sync_file_tree_cursor_to_selected();
    }

    fn reset_view(&mut self) {
        self.cursor_line = self.first_navigable_line();
        self.diff_scroll = 0;
        self.comment_focus = None;
        self.ensure_cursor_visible();
    }

    pub fn total_lines(&self) -> usize {
        self.current().map(|f| f.lines.len()).unwrap_or(0)
    }

    fn is_code_line(&self, idx: usize) -> bool {
        self.current()
            .and_then(|f| f.lines.get(idx))
            .map(|l| l.kind.is_code())
            .unwrap_or(false)
    }

    pub fn first_navigable_line(&self) -> usize {
        self.current()
            .and_then(|f| f.lines.iter().position(|l| l.kind.is_navigable()))
            .unwrap_or(0)
    }

    fn last_navigable_line(&self) -> usize {
        self.current()
            .and_then(|f| f.lines.iter().rposition(|l| l.kind.is_navigable()))
            .unwrap_or(0)
    }

    fn step_down(&self, from: usize) -> Option<usize> {
        let file = self.current()?;
        ((from + 1)..file.lines.len()).find(|&i| file.lines[i].kind.is_navigable())
    }

    fn step_up(&self, from: usize) -> Option<usize> {
        let file = self.current()?;
        (0..from).rev().find(|&i| file.lines[i].kind.is_navigable())
    }

    fn comment_count_on_cursor(&self) -> usize {
        self.current()
            .map(|f| self.comments_on(f, self.cursor_line).len())
            .unwrap_or(0)
    }

    pub fn move_cursor_down(&mut self, n: usize) {
        let skip_comments = self.mode == Mode::Visual;
        for _ in 0..n {
            if !skip_comments {
                let cc = self.comment_count_on_cursor();
                if let Some(cf) = self.comment_focus {
                    if cf + 1 < cc {
                        self.comment_focus = Some(cf + 1);
                        continue;
                    }
                } else if cc > 0 {
                    self.comment_focus = Some(0);
                    continue;
                }
            }
            self.comment_focus = None;
            match self.step_down(self.cursor_line) {
                Some(i) => self.cursor_line = i,
                None => break,
            }
        }
        self.ensure_cursor_visible();
    }

    pub fn move_cursor_up(&mut self, n: usize) {
        let skip_comments = self.mode == Mode::Visual;
        for _ in 0..n {
            if !skip_comments {
                if let Some(cf) = self.comment_focus {
                    if cf > 0 {
                        self.comment_focus = Some(cf - 1);
                        continue;
                    } else {
                        self.comment_focus = None;
                        continue;
                    }
                }
            }
            match self.step_up(self.cursor_line) {
                Some(i) => {
                    self.cursor_line = i;
                    if !skip_comments {
                        let cc = self
                            .current()
                            .map(|f| self.comments_on(f, i).len())
                            .unwrap_or(0);
                        if cc > 0 {
                            self.comment_focus = Some(cc - 1);
                        }
                    }
                }
                None => break,
            }
        }
        self.ensure_cursor_visible();
    }

    pub fn cursor_top(&mut self) {
        self.cursor_line = self.first_navigable_line();
        self.ensure_cursor_visible();
    }

    pub fn cursor_bottom(&mut self) {
        self.cursor_line = self.last_navigable_line();
        self.ensure_cursor_visible();
    }

    pub fn center_cursor(&mut self) {
        if self.split_view {
            self.center_cursor_split();
        } else {
            let half = self.viewport_height / 2;
            self.diff_scroll = self.cursor_line.saturating_sub(half);
        }
    }

    fn center_cursor_split(&mut self) {
        let file = match self.current() {
            Some(f) => f,
            None => return,
        };
        let split_rows = build_split_rows(file);
        let cursor_row = split_rows
            .iter()
            .position(|r| r.left == Some(self.cursor_line) || r.right == Some(self.cursor_line))
            .unwrap_or(0);
        let half = self.viewport_height / 2;
        let target_row = cursor_row.saturating_sub(half);
        if let Some(row) = split_rows.get(target_row) {
            self.diff_scroll = row.left.or(row.right).unwrap_or(0);
        }
    }

    fn land_on_hunk(&mut self, i: usize) {
        let kind = self.current().unwrap().lines[i].kind;
        self.cursor_line = i;
        if kind == crate::diff::LineKind::HunkHeader {
            if let Some(next) = self.step_down(i) {
                self.cursor_line = next;
            }
        }
        self.comment_focus = None;
        self.ensure_cursor_visible();
    }

    pub fn next_hunk(&mut self) {
        let file = match self.current() {
            Some(f) => f,
            None => return,
        };
        for i in (self.cursor_line + 1)..file.lines.len() {
            if file.lines[i].kind.is_hunk_boundary() {
                self.land_on_hunk(i);
                return;
            }
        }
    }

    pub fn prev_hunk(&mut self) {
        let file = match self.current() {
            Some(f) => f,
            None => return,
        };
        let mut found_current = false;
        for i in (0..self.cursor_line).rev() {
            if file.lines[i].kind.is_hunk_boundary() {
                if !found_current {
                    found_current = true;
                    continue;
                }
                self.land_on_hunk(i);
                return;
            }
        }
        if found_current {
            self.cursor_top();
        }
    }

    pub fn ensure_cursor_visible(&mut self) {
        if self.split_view {
            self.ensure_cursor_visible_split();
        } else {
            self.ensure_cursor_visible_unified();
        }
    }

    fn ensure_cursor_visible_unified(&mut self) {
        let vh = self.viewport_height.max(1);
        if self.cursor_line < self.diff_scroll {
            self.diff_scroll = self.cursor_line;
        }
        // Compute the running total once, then subtract the leading row's
        // contribution as we advance — O(N) vs the previous O(N^2).
        let mut visual = self.visual_rows_unified(self.diff_scroll, self.cursor_line);
        while self.diff_scroll < self.cursor_line && visual > vh {
            visual = visual.saturating_sub(self.row_visual_rows_unified(self.diff_scroll));
            self.diff_scroll += 1;
        }
    }

    fn ensure_cursor_visible_split(&mut self) {
        let file = match self.current() {
            Some(f) => f,
            None => return,
        };
        let vh = self.viewport_height.max(1);
        let split_rows = build_split_rows(file);

        let scroll_row = split_rows
            .iter()
            .position(|r| r.left == Some(self.diff_scroll) || r.right == Some(self.diff_scroll))
            .unwrap_or(0);
        let cursor_row = split_rows
            .iter()
            .position(|r| r.left == Some(self.cursor_line) || r.right == Some(self.cursor_line))
            .unwrap_or(0);

        if cursor_row < scroll_row {
            if let Some(row) = split_rows.get(cursor_row) {
                self.diff_scroll = row.left.or(row.right).unwrap_or(0);
            }
            return;
        }

        // Each split row can consume more than 1 visual row because of
        // attached comments and wrapped code, so count visual rows instead
        // of split rows.
        let side_width = (self.diff_area.width as usize).saturating_sub(1) / 2;
        let mut visible = self.visual_rows_split(scroll_row, cursor_row, &split_rows);
        let mut sr = scroll_row;
        while sr < cursor_row && visible > vh {
            visible =
                visible.saturating_sub(self.row_visual_rows_split(&split_rows[sr], side_width));
            sr += 1;
        }
        if sr != scroll_row {
            if let Some(row) = split_rows.get(sr) {
                self.diff_scroll = row.left.or(row.right).unwrap_or(0);
            }
        }
    }

    fn row_visual_rows_unified(&self, idx: usize) -> usize {
        let Some(file) = self.current() else {
            return 0;
        };
        let Some(line) = file.lines.get(idx) else {
            return 0;
        };
        let full_width = self.diff_area.width as usize;
        let mut count = line_visual_rows(line, full_width, UNIFIED_LAYOUT);
        for c in self.comments_on(file, idx) {
            count += comment_plus_replies_rows(c);
        }
        count + self.insert_draft_rows(idx)
    }

    fn visual_rows_unified(&self, from: usize, to: usize) -> usize {
        let Some(file) = self.current() else {
            return 0;
        };
        let end = to.min(file.lines.len().saturating_sub(1));
        (from..=end)
            .map(|idx| self.row_visual_rows_unified(idx))
            .sum()
    }

    /// Extra rows an inline-input draft occupies below the cursor line when
    /// the composer is rendered inline (non-modal, unified, not editing).
    fn insert_draft_rows(&self, idx: usize) -> usize {
        if self.mode == Mode::Insert
            && !self.comment_modal
            && idx == self.cursor_line
            && self.editing_comment.is_none()
        {
            self.input.matches('\n').count() + 1
        } else {
            0
        }
    }

    fn row_visual_rows_split(&self, row: &crate::diff::SplitRow, side_width: usize) -> usize {
        let Some(file) = self.current() else {
            return 1;
        };
        let side_line_rows = |idx_opt: Option<usize>| -> usize {
            idx_opt
                .and_then(|i| file.lines.get(i))
                .map(|l| line_visual_rows(l, side_width, SPLIT_LAYOUT))
                .unwrap_or(1)
        };
        let mut count = side_line_rows(row.left).max(side_line_rows(row.right));

        let side_comments = |idx_opt: Option<usize>, side: Side| -> Vec<&Comment> {
            idx_opt
                .map(|i| self.comments_on(file, i))
                .unwrap_or_default()
                .into_iter()
                .filter(|c| c.side == side)
                .collect()
        };
        let left_comments = side_comments(row.left, Side::Old);
        let right_comments = side_comments(row.right, Side::New);
        for ci in 0..left_comments.len().max(right_comments.len()) {
            let lh = left_comments
                .get(ci)
                .map_or(0, |c| comment_plus_replies_rows(c));
            let rh = right_comments
                .get(ci)
                .map_or(0, |c| comment_plus_replies_rows(c));
            count += lh.max(rh);
        }
        count
    }

    fn visual_rows_split(
        &self,
        from_row: usize,
        to_row: usize,
        split_rows: &[crate::diff::SplitRow],
    ) -> usize {
        if split_rows.is_empty() {
            return 0;
        }
        let side_width = (self.diff_area.width as usize).saturating_sub(1) / 2;
        let end = to_row.min(split_rows.len() - 1);
        split_rows
            .iter()
            .take(end + 1)
            .skip(from_row)
            .map(|row| self.row_visual_rows_split(row, side_width))
            .sum()
    }

    pub fn enter_insert(&mut self) {
        if self.files.is_empty() || !self.is_code_line(self.cursor_line) {
            return;
        }
        if let Some(cf) = self.comment_focus {
            let comments = self
                .current()
                .map(|f| self.comments_on(f, self.cursor_line))
                .unwrap_or_default();
            if let Some(c) = comments.get(cf) {
                self.input = c.body.clone();
                self.input_cursor = self.input.len();
                self.editing_comment = Some(cf);
                self.mode = Mode::Insert;
                return;
            }
        }
        self.editing_comment = None;
        self.mode = Mode::Insert;
        self.input.clear();
        self.input_cursor = 0;
    }

    pub fn cancel_insert(&mut self) {
        self.reset_input(Mode::Normal);
        self.editing_comment = None;
    }

    // ── Revision selector ──

    pub fn open_revision_selector(&mut self) {
        self.revisions = crate::git::list_revisions();
        self.revision_cursor = 0;
        self.input.clear();
        self.input_cursor = 0;
        self.show_revision_selector = true;
    }

    pub fn close_revision_selector(&mut self) {
        self.show_revision_selector = false;
        self.input.clear();
        self.input_cursor = 0;
    }

    pub fn filtered_revisions(&self) -> Vec<usize> {
        if self.input.is_empty() {
            return (0..self.revisions.len()).collect();
        }
        let q = self.input.to_lowercase();
        self.revisions
            .iter()
            .enumerate()
            .filter(|(_, r)| r.to_lowercase().contains(&q))
            .map(|(i, _)| i)
            .collect()
    }

    pub fn revision_selector_next(&mut self) {
        let n = self.filtered_revisions().len();
        if n == 0 {
            return;
        }
        self.revision_cursor = (self.revision_cursor + 1) % n;
    }

    pub fn revision_selector_prev(&mut self) {
        let n = self.filtered_revisions().len();
        if n == 0 {
            return;
        }
        if self.revision_cursor == 0 {
            self.revision_cursor = n - 1;
        } else {
            self.revision_cursor -= 1;
        }
    }

    pub fn confirm_revision_selector(&mut self) {
        let filtered = self.filtered_revisions();
        let Some(&idx) = filtered.get(self.revision_cursor) else {
            return;
        };
        let target = self.revisions[idx]
            .split('\t')
            .next()
            .unwrap_or(&self.revisions[idx])
            .to_string();
        self.pending_range_change = Some(target);
        self.close_revision_selector();
    }

    pub fn on_revision_filter_change(&mut self) {
        self.revision_cursor = 0;
    }

    /// (side, lineno) anchor for the line the cursor is currently on, if any.
    fn cursor_anchor(&self) -> Option<(Side, u32)> {
        line_anchor(self.current()?.lines.get(self.cursor_line)?)
    }

    pub fn submit_comment(&mut self) {
        let body = self.input.trim().to_string();
        if let Some(cf) = self.editing_comment {
            let path = self.current_path();
            let matching: Vec<usize> = self
                .cursor_anchor()
                .map(|(side, lineno)| {
                    self.comments
                        .iter()
                        .enumerate()
                        .filter(|(_, c)| c.anchored_at(&path, side, lineno))
                        .map(|(i, _)| i)
                        .collect()
                })
                .unwrap_or_default();
            if let Some(&global_idx) = matching.get(cf) {
                if body.is_empty() {
                    self.comments.remove(global_idx);
                } else {
                    self.comments[global_idx].body = body;
                }
            }
        } else if !body.is_empty() {
            if let Some((side, lineno)) = self.cursor_anchor() {
                // Visual-mode end-line may be on the opposite side (e.g. selection
                // spans Added and Removed); fall back to the other side so we still
                // record a range rather than dropping the endpoint silently.
                let lineno_end = self.comment_line_end.take().and_then(|end_idx| {
                    let end_line = self.current()?.lines.get(end_idx)?;
                    match side {
                        Side::Old => end_line.old_lineno.or(end_line.new_lineno),
                        Side::New => end_line.new_lineno.or(end_line.old_lineno),
                    }
                });
                self.comments.push(Comment {
                    file: self.current_path(),
                    side,
                    lineno,
                    lineno_end,
                    body,
                    replies: Vec::new(),
                    from_pr: false,
                });
            }
        }
        self.comment_line_end = None;
        self.cancel_insert();
    }

    fn jump_to_comment_line(&mut self, file_idx: usize, line: usize) {
        self.selected = file_idx;
        self.diff_scroll = 0;
        self.cursor_line = line;
        self.comment_focus = Some(0);
        self.ensure_cursor_visible();
    }

    /// Collect display indices of every comment anchor in the given file, sorted ascending.
    fn comment_display_indices_in_file(&self, file_idx: usize) -> Vec<usize> {
        let Some(file) = self.files.get(file_idx) else {
            return Vec::new();
        };
        let mut out: Vec<usize> = self
            .comments
            .iter()
            .filter(|c| c.file == file.path)
            .filter_map(|c| find_display_index(file, c.side, c.lineno))
            .collect();
        out.sort_unstable();
        out.dedup();
        out
    }

    pub fn next_comment(&mut self) {
        let indices = self.comment_display_indices_in_file(self.selected);
        if let Some(&line) = indices.iter().find(|&&i| i > self.cursor_line) {
            self.jump_to_comment_line(self.selected, line);
            return;
        }

        for offset in 1..=self.files.len() {
            let idx = (self.selected + offset) % self.files.len();
            let indices = self.comment_display_indices_in_file(idx);
            if let Some(&line) = indices.first() {
                self.jump_to_comment_line(idx, line);
                return;
            }
        }
    }

    pub fn prev_comment(&mut self) {
        let indices = self.comment_display_indices_in_file(self.selected);
        if let Some(&line) = indices.iter().rev().find(|&&i| i < self.cursor_line) {
            self.jump_to_comment_line(self.selected, line);
            return;
        }

        for offset in 1..=self.files.len() {
            let idx = (self.selected + self.files.len() - offset) % self.files.len();
            let indices = self.comment_display_indices_in_file(idx);
            if let Some(&line) = indices.last() {
                self.jump_to_comment_line(idx, line);
                return;
            }
        }
    }

    /// Delete the focused comment on the current line (for `dd` keybind).
    /// Does nothing if no comment is currently focused.
    pub fn delete_focused_comment(&mut self) {
        let Some(focus) = self.comment_focus else {
            return;
        };
        let path = self.current_path();
        let Some((side, lineno)) = self.cursor_anchor() else {
            return;
        };
        let matching: Vec<usize> = self
            .comments
            .iter()
            .enumerate()
            .filter(|(_, c)| c.anchored_at(&path, side, lineno))
            .map(|(i, _)| i)
            .collect();
        if matching.is_empty() {
            return;
        }
        let focus_idx = focus.min(matching.len() - 1);
        let global_idx = matching[focus_idx];
        self.comments.remove(global_idx);

        let remaining = matching.len() - 1;
        if remaining == 0 {
            self.comment_focus = None;
        } else if focus_idx >= remaining {
            self.comment_focus = Some(remaining - 1);
        }
        self.set_flash("Comment deleted".to_string());
    }

    pub fn toggle_viewed(&mut self) {
        if let Some(file) = self.files.get_mut(self.selected) {
            file.viewed = !file.viewed;
        }
    }

    pub fn comments_on(&self, file: &FileDiff, line: usize) -> Vec<&Comment> {
        let Some(diff_line) = file.lines.get(line) else {
            return Vec::new();
        };
        let Some((side, lineno)) = line_anchor(diff_line) else {
            return Vec::new();
        };
        self.comments
            .iter()
            .filter(|c| c.anchored_at(&file.path, side, lineno))
            .collect()
    }

    fn clamp_comments_cursor(&mut self) {
        if self.comments.is_empty() {
            self.comments_list_cursor = 0;
        } else if self.comments_list_cursor >= self.comments.len() {
            self.comments_list_cursor = self.comments.len() - 1;
        }
    }

    pub fn toggle_comments_list(&mut self) {
        if self.comments.is_empty() {
            return;
        }
        self.show_comments_list = !self.show_comments_list;
        self.clamp_comments_cursor();
    }

    pub fn close_comments_list(&mut self) {
        self.show_comments_list = false;
    }

    /// Delete the comment currently highlighted in the comments-list popup.
    pub fn delete_selected_comment_in_list(&mut self) {
        if self.comments_list_cursor >= self.comments.len() {
            return;
        }
        self.comments.remove(self.comments_list_cursor);
        if self.comments.is_empty() {
            self.show_comments_list = false;
            self.comments_list_cursor = 0;
        } else {
            if self.comments_list_cursor >= self.comments.len() {
                self.comments_list_cursor = self.comments.len() - 1;
            }
        }
        self.set_flash("Comment deleted".to_string());
    }

    pub fn selected_comment_in_list(&self) -> Option<&Comment> {
        self.comments.get(self.comments_list_cursor)
    }

    pub fn comments_list_next(&mut self) {
        self.comments_list_cursor = self.comments_list_cursor.saturating_add(1);
        self.clamp_comments_cursor();
    }

    pub fn comments_list_prev(&mut self) {
        self.comments_list_cursor = self.comments_list_cursor.saturating_sub(1);
    }

    pub fn jump_to_selected_comment(&mut self) {
        let Some(comment) = self.comments.get(self.comments_list_cursor).cloned() else {
            return;
        };
        if let Some(pos) = self.files.iter().position(|f| f.path == comment.file) {
            self.selected = pos;
        }
        let total = self.total_lines();
        if total == 0 {
            self.show_comments_list = false;
            return;
        }
        if let Some(file) = self.current() {
            if let Some(idx) = find_display_index(file, comment.side, comment.lineno) {
                self.cursor_line = idx;
            } else {
                self.cursor_line = self.cursor_line.min(total - 1);
            }
        }
        let vh = self.viewport_height.max(1);
        self.diff_scroll = self.cursor_line.saturating_sub(vh / 2);
        self.show_comments_list = false;
    }

    fn reset_input(&mut self, mode: Mode) {
        self.mode = mode;
        self.input.clear();
        self.input_cursor = 0;
    }

    pub fn enter_search(&mut self) {
        self.reset_input(Mode::Search);
    }

    pub fn cancel_search(&mut self) {
        self.reset_input(Mode::Normal);
    }

    pub fn submit_search(&mut self) {
        let query = self.input.trim().to_string();
        self.reset_input(Mode::Normal);
        if query.is_empty() {
            self.search_query = None;
            self.search_matches.clear();
            return;
        }
        self.search_query = Some(query.clone());
        self.build_search_matches(&query);
        self.search_match_cursor = 0;
        self.jump_to_search_match();
    }

    fn build_search_matches(&mut self, query: &str) {
        self.search_matches.clear();
        let q = query.to_lowercase();
        for (fi, file) in self.files.iter().enumerate() {
            for (li, line) in file.lines.iter().enumerate() {
                if line.kind.is_code() && line.text.to_lowercase().contains(&q) {
                    self.search_matches.push((fi, li));
                }
            }
        }
    }

    pub fn next_search_match(&mut self) {
        if self.search_matches.is_empty() {
            return;
        }
        self.search_match_cursor = (self.search_match_cursor + 1) % self.search_matches.len();
        self.jump_to_search_match();
    }

    pub fn prev_search_match(&mut self) {
        if self.search_matches.is_empty() {
            return;
        }
        if self.search_match_cursor == 0 {
            self.search_match_cursor = self.search_matches.len() - 1;
        } else {
            self.search_match_cursor -= 1;
        }
        self.jump_to_search_match();
    }

    fn jump_to_search_match(&mut self) {
        if let Some(&(fi, li)) = self.search_matches.get(self.search_match_cursor) {
            self.selected = fi;
            self.cursor_line = li;
            self.comment_focus = None;
            self.ensure_cursor_visible();
        }
    }

    pub fn clear_search(&mut self) {
        self.search_query = None;
        self.search_matches.clear();
        self.search_match_cursor = 0;
    }

    // ── File filter operations ──

    pub fn enter_file_filter(&mut self) {
        self.reset_input(Mode::FileFilter);
    }

    pub fn cancel_file_filter(&mut self) {
        self.file_filter = None;
        self.reset_input(Mode::Normal);
    }

    pub fn submit_file_filter(&mut self) {
        let query = self.input.trim().to_string();
        self.reset_input(Mode::Normal);
        if query.is_empty() {
            self.file_filter = None;
        } else {
            self.file_filter = Some(query);
        }
    }

    pub fn update_file_filter_live(&mut self) {
        let query = self.input.trim().to_string();
        if query.is_empty() {
            self.file_filter = None;
        } else {
            self.file_filter = Some(query);
        }
    }

    // ── Visual mode operations ──

    pub fn enter_visual(&mut self) {
        if self.files.is_empty() || !self.is_code_line(self.cursor_line) {
            return;
        }
        self.mode = Mode::Visual;
        self.visual_start = Some(self.cursor_line);
        self.comment_focus = None;
    }

    pub fn cancel_visual(&mut self) {
        self.mode = Mode::Normal;
        self.visual_start = None;
    }

    pub fn visual_range(&self) -> Option<(usize, usize)> {
        self.visual_start.map(|start| {
            let a = start.min(self.cursor_line);
            let b = start.max(self.cursor_line);
            (a, b)
        })
    }

    pub fn is_in_visual(&self, idx: usize) -> bool {
        if self.mode != Mode::Visual {
            return false;
        }
        self.visual_range()
            .map(|(a, b)| idx >= a && idx <= b)
            .unwrap_or(false)
    }

    /// Range of display-line indices (inclusive) that the given comment spans.
    pub fn comment_display_range(
        &self,
        file: &FileDiff,
        comment: &Comment,
    ) -> Option<(usize, usize)> {
        let start = find_display_index(file, comment.side, comment.lineno)?;
        let end = comment
            .lineno_end
            .and_then(|end| find_display_index(file, comment.side, end))
            .unwrap_or(start);
        let (a, b) = if start <= end {
            (start, end)
        } else {
            (end, start)
        };
        Some((a, b))
    }

    /// How a display-line should render its comment underline.
    /// - `Bright`: the line is part of the actively focused/edited comment range.
    /// - `Dim`: the line has at least one comment anchored to it but nothing
    ///   about that comment is currently focused.
    /// - `None`: no comment involvement.
    ///
    /// Split-view variant: render the underline only on the column that
    /// matches the comment's anchor side.
    ///
    /// - Bright when the active comment (being edited / focused) or the
    ///   in-progress Visual/Insert selection targets `side`.
    /// - Dim when an existing comment anchored to `side` covers this `idx`.
    pub fn underline_kind_for_side(
        &self,
        file: &FileDiff,
        idx: usize,
        side: Side,
    ) -> UnderlineKind {
        // Which side will the currently-in-progress action anchor to?
        let pending_side = || line_anchor(&file.lines[self.cursor_line]).map(|(s, _)| s);

        if self.mode == Mode::Visual {
            if let Some((a, b)) = self.visual_range()
                && idx >= a
                && idx <= b
                && pending_side() == Some(side)
            {
                return UnderlineKind::Bright;
            }
        } else if self.mode == Mode::Insert {
            let bright = if let Some(cf) = self.editing_comment {
                self.comments_on(file, self.cursor_line)
                    .get(cf)
                    .filter(|c| c.side == side)
                    .and_then(|c| self.comment_display_range(file, c))
            } else if pending_side() == Some(side) {
                let start = self.cursor_line;
                let end = self.comment_line_end.unwrap_or(start);
                Some((start.min(end), start.max(end)))
            } else {
                None
            };
            if let Some((a, b)) = bright
                && idx >= a
                && idx <= b
            {
                return UnderlineKind::Bright;
            }
        } else if let Some(cf) = self.comment_focus
            && let Some(c) = self
                .comments_on(file, self.cursor_line)
                .get(cf)
                .filter(|c| c.side == side)
            && let Some((a, b)) = self.comment_display_range(file, c)
            && idx >= a
            && idx <= b
        {
            return UnderlineKind::Bright;
        }

        for c in &self.comments {
            if c.file != file.path || c.side != side {
                continue;
            }
            if let Some((a, b)) = self.comment_display_range(file, c)
                && idx >= a
                && idx <= b
            {
                return UnderlineKind::Dim;
            }
        }
        UnderlineKind::None
    }

    pub fn underline_kind(&self, file: &FileDiff, idx: usize) -> UnderlineKind {
        if self.mode == Mode::Visual {
            if let Some((a, b)) = self.visual_range() {
                if idx >= a && idx <= b {
                    return UnderlineKind::Bright;
                }
            }
        } else if self.mode == Mode::Insert {
            let bright_range = if let Some(cf) = self.editing_comment {
                self.comments_on(file, self.cursor_line)
                    .get(cf)
                    .and_then(|c| self.comment_display_range(file, c))
            } else {
                let start = self.cursor_line;
                let end = self.comment_line_end.unwrap_or(start);
                Some((start.min(end), start.max(end)))
            };
            if let Some((a, b)) = bright_range {
                if idx >= a && idx <= b {
                    return UnderlineKind::Bright;
                }
            }
        } else if let Some(cf) = self.comment_focus {
            if let Some(c) = self.comments_on(file, self.cursor_line).get(cf) {
                if let Some((a, b)) = self.comment_display_range(file, c) {
                    if idx >= a && idx <= b {
                        return UnderlineKind::Bright;
                    }
                }
            }
        }

        // Fall back to dim: does any comment on this file cover this line?
        for c in &self.comments {
            if c.file != file.path {
                continue;
            }
            if let Some((a, b)) = self.comment_display_range(file, c) {
                if idx >= a && idx <= b {
                    return UnderlineKind::Dim;
                }
            }
        }
        UnderlineKind::None
    }

    pub fn submit_visual_comment(&mut self) {
        if let Some((start, end)) = self.visual_range() {
            self.cursor_line = end;
            self.visual_start = None;
            self.editing_comment = None;
            self.mode = Mode::Insert;
            self.input.clear();
            self.input_cursor = 0;
            if start == end {
                self.comment_line_end = None;
            } else {
                self.comment_line_end = Some(start);
            }
        }
    }

    // ── Flash message ──

    /// Format a comment with the targeted code lines interleaved as quoted
    /// context between the location header and the comment body.
    /// Preferred when the user copies a comment to paste somewhere else.
    pub fn format_comment_full(&self, c: &Comment) -> String {
        let mut out = format!("{}\n", c.location());
        if let Some(file) = self.files.iter().find(|f| f.path == c.file) {
            let start = c.lineno;
            let end = c.lineno_end.unwrap_or(start);
            let (lo, hi) = (start.min(end), start.max(end));
            for line in &file.lines {
                let Some((side, n)) = line_anchor(line) else {
                    continue;
                };
                if side == c.side && n >= lo && n <= hi {
                    out.push_str("> ");
                    out.push_str(&line.text);
                    out.push('\n');
                }
            }
        }
        out.push_str(&c.body);
        for r in &c.replies {
            out.push_str("\n↳ ");
            out.push_str(r);
        }
        out
    }

    pub fn format_all_comments(&self) -> String {
        self.comments
            .iter()
            .filter(|c| !c.from_pr)
            .enumerate()
            .map(|(i, c)| {
                let prefix = if i > 0 { "=====\n" } else { "" };
                format!("{}{}", prefix, self.format_comment_full(c))
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    pub fn set_flash(&mut self, msg: String) {
        if let Some(tx) = &self.action_tx {
            let _ = tx.send(Action::Flash(msg));
        }
    }

    // ── Input cursor operations ──

    pub fn input_insert_char(&mut self, c: char) {
        self.input.insert(self.input_cursor, c);
        self.input_cursor += c.len_utf8();
    }

    pub fn input_insert_newline(&mut self) {
        self.input_insert_char('\n');
    }

    pub fn input_delete_backward(&mut self) {
        if self.input_cursor == 0 {
            return;
        }
        let prev = prev_char_boundary(&self.input, self.input_cursor);
        self.input.drain(prev..self.input_cursor);
        self.input_cursor = prev;
    }

    pub fn input_move_left(&mut self) {
        if self.input_cursor > 0 {
            self.input_cursor = prev_char_boundary(&self.input, self.input_cursor);
        }
    }

    pub fn input_move_right(&mut self) {
        if self.input_cursor < self.input.len() {
            self.input_cursor = next_char_boundary(&self.input, self.input_cursor);
        }
    }

    pub fn input_move_word_left(&mut self) {
        let s = &self.input[..self.input_cursor];
        let trimmed = s.trim_end_matches(|c: char| c.is_whitespace() && c != '\n');
        let word_end = trimmed
            .rfind(|c: char| c.is_whitespace() || c == '\n')
            .map(|i| {
                i + trimmed[i..]
                    .chars()
                    .next()
                    .map(|c| c.len_utf8())
                    .unwrap_or(0)
            })
            .unwrap_or(0);
        self.input_cursor = word_end;
    }

    pub fn input_move_word_right(&mut self) {
        let s = &self.input[self.input_cursor..];
        let skip_word = s.find(|c: char| c.is_whitespace()).unwrap_or(s.len());
        let skip_ws = s[skip_word..]
            .find(|c: char| !c.is_whitespace())
            .unwrap_or(s[skip_word..].len());
        self.input_cursor += skip_word + skip_ws;
    }

    pub fn input_move_line_start(&mut self) {
        let s = &self.input[..self.input_cursor];
        self.input_cursor = s.rfind('\n').map(|i| i + 1).unwrap_or(0);
    }

    pub fn input_move_line_end(&mut self) {
        let s = &self.input[self.input_cursor..];
        self.input_cursor += s.find('\n').unwrap_or(s.len());
    }

    pub fn input_move_up(&mut self) {
        let col = self.input_col();
        self.input_move_line_start();
        if self.input_cursor == 0 {
            return;
        }
        self.input_cursor -= 1; // skip the \n
        self.input_move_line_start();
        let line_len = self.input[self.input_cursor..]
            .find('\n')
            .unwrap_or(self.input.len() - self.input_cursor);
        self.input_cursor = snap_back_to_boundary(
            &self.input,
            self.input_cursor + col.min(line_len),
            self.input_cursor,
        );
    }

    pub fn input_move_down(&mut self) {
        let col = self.input_col();
        self.input_move_line_end();
        if self.input_cursor >= self.input.len() {
            return;
        }
        self.input_cursor += 1; // skip the \n
        let line_start = self.input_cursor;
        let line_len = self.input[line_start..]
            .find('\n')
            .unwrap_or(self.input.len() - line_start);
        self.input_cursor =
            snap_back_to_boundary(&self.input, line_start + col.min(line_len), line_start);
    }

    fn input_col(&self) -> usize {
        let s = &self.input[..self.input_cursor];
        self.input_cursor - s.rfind('\n').map(|i| i + 1).unwrap_or(0)
    }

    pub fn input_delete_word_backward(&mut self) {
        let start = self.input_cursor;
        self.input_move_word_left();
        self.input.drain(self.input_cursor..start);
    }

    pub fn input_kill_to_end_of_line(&mut self) {
        let end = self.input[self.input_cursor..]
            .find('\n')
            .map(|i| self.input_cursor + i)
            .unwrap_or(self.input.len());
        if end == self.input_cursor && end < self.input.len() {
            self.input.remove(end);
        } else {
            self.input.drain(self.input_cursor..end);
        }
    }

    pub fn input_clear(&mut self) {
        self.input.clear();
        self.input_cursor = 0;
    }
}

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

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

/// Snap a byte index back to the nearest char boundary at or before `target`,
/// but never earlier than `floor`. Vertical cursor motion computes the
/// destination in raw byte columns and may otherwise land mid-codepoint.
fn snap_back_to_boundary(s: &str, target: usize, floor: usize) -> usize {
    let mut i = target.min(s.len());
    while i > floor && !s.is_char_boundary(i) {
        i -= 1;
    }
    i
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::DiffRange;
    use crate::diff::{DiffLine, FileDiff, FileStatus, LineKind};

    fn make_app_with_input(input: &str, cursor: usize) -> App {
        let file = FileDiff {
            path: "f.txt".to_string(),
            old_path: Some("f.txt".to_string()),
            status: FileStatus::Modified,
            added: 0,
            removed: 0,
            lines: vec![DiffLine::body(
                LineKind::Context,
                "line".to_string(),
                Some(1),
                Some(1),
            )],
            auto_collapsed: false,
            viewed: false,
        };
        let mut app = App::new(vec![file], DiffRange::Working, ThemeName::default());
        app.input = input.to_string();
        app.input_cursor = cursor;
        app
    }

    #[test]
    fn input_move_down_over_multibyte_lands_on_char_boundary() {
        // `abcd\n日本`: naive column 4 on the target line lands at byte 9
        // (mid-codepoint in `本`); must snap back to byte 8 (`本` start).
        let text = "abcd\n日本";
        let mut app = make_app_with_input(text, 4);
        app.input_move_down();
        assert!(app.input.is_char_boundary(app.input_cursor));
        assert_eq!(app.input_cursor, 8);
        app.input_insert_char('x');
    }

    #[test]
    fn input_move_up_over_multibyte_lands_on_char_boundary() {
        let text = "日本\nxxxxxx";
        let mut app = make_app_with_input(text, text.len());
        app.input_move_up();
        assert!(app.input.is_char_boundary(app.input_cursor));
        assert_eq!(app.input_cursor, 6);
        app.input_insert_char('z');
    }

    #[test]
    fn input_move_down_at_last_line_lands_on_line_end() {
        // With no newline below the cursor, Down stops at the end of the
        // current line (matches emacs / most editors).
        let mut app = make_app_with_input("one", 1);
        app.input_move_down();
        assert_eq!(app.input_cursor, 3);
    }

    #[test]
    fn input_move_up_at_first_line_lands_on_line_start() {
        // With no newline above the cursor, Up stops at the start of the
        // current line.
        let mut app = make_app_with_input("one", 2);
        app.input_move_up();
        assert_eq!(app.input_cursor, 0);
    }

    #[test]
    fn input_move_word_right_skips_multibyte() {
        // "日本 世界" → after Word-right from 0, cursor should land after the
        // whitespace at the start of `世`.
        let mut app = make_app_with_input("日本 世界", 0);
        app.input_move_word_right();
        assert!(app.input.is_char_boundary(app.input_cursor));
        assert_eq!(&app.input[app.input_cursor..], "世界");
    }

    #[test]
    fn input_delete_word_backward_preserves_boundary() {
        // Deleting a word from end-of-input with trailing `()` after the word:
        // word-left moves to the position after the space; drain removes the
        // word but keeps trailing `()`.
        let prefix = "fn 日本語";
        let mut app = make_app_with_input("fn 日本語()", prefix.len());
        app.input_delete_word_backward();
        assert!(app.input.is_char_boundary(app.input_cursor));
        assert_eq!(app.input, "fn ()");
    }

    fn make_app_with_lines(n: usize) -> App {
        let lines: Vec<DiffLine> = (0..n)
            .map(|i| {
                DiffLine::body(
                    LineKind::Context,
                    format!("line {}", i),
                    Some(i as u32 + 1),
                    Some(i as u32 + 1),
                )
            })
            .collect();
        let file = FileDiff {
            path: "f.txt".to_string(),
            old_path: Some("f.txt".to_string()),
            status: FileStatus::Modified,
            added: 0,
            removed: 0,
            lines,
            auto_collapsed: false,
            viewed: false,
        };
        App::new(vec![file], DiffRange::Working, ThemeName::default())
    }

    #[test]
    fn adding_comment_near_bottom_advances_scroll_to_show_whole_comment() {
        // Regression: the comment bubble (top border + header + body + bottom
        // border) was being undercounted, so the scroll stopped before the
        // trailing rows of a long comment could fit in the viewport.
        let mut app = make_app_with_lines(30);
        app.viewport_height = 10;
        app.cursor_line = 29;
        app.ensure_cursor_visible();
        let scroll_before = app.diff_scroll;

        app.comments.push(Comment {
            file: "f.txt".to_string(),
            side: Side::New,
            lineno: 30,
            lineno_end: None,
            body: "l1\nl2\nl3\nl4\nl5".to_string(),
            replies: Vec::new(),
            from_pr: false,
        });
        app.ensure_cursor_visible();

        assert!(
            app.diff_scroll > scroll_before,
            "scroll must advance when a comment is added at the bottom (before={}, after={})",
            scroll_before,
            app.diff_scroll,
        );

        // The visual rows from diff_scroll to cursor_line (including the
        // comment's 3 chrome rows + 5 body rows) must now fit the viewport.
        let visual = app.visual_rows_unified(app.diff_scroll, app.cursor_line);
        assert!(
            visual <= app.viewport_height,
            "comment must fit in viewport after scroll (visual={}, vh={})",
            visual,
            app.viewport_height,
        );
    }

    #[test]
    fn split_view_scrolls_enough_to_show_last_row_with_comments() {
        let mut app = make_app_with_lines(30);
        app.viewport_height = 10;
        app.split_view = true;
        app.cursor_line = 29;
        // Attach a comment to a line visible in the viewport so the visual
        // rows before the cursor exceed the naïve split-row count.
        app.comments.push(Comment {
            file: "f.txt".to_string(),
            side: Side::New,
            lineno: 25,
            lineno_end: None,
            body: "heavy\ncomment\nbody".to_string(),
            replies: Vec::new(),
            from_pr: false,
        });
        app.ensure_cursor_visible();

        let split_rows = crate::diff::build_split_rows(app.current().unwrap());
        let scroll_row = split_rows
            .iter()
            .position(|r| r.left == Some(app.diff_scroll) || r.right == Some(app.diff_scroll))
            .expect("diff_scroll must point at a split row");
        let cursor_row = split_rows
            .iter()
            .position(|r| r.left == Some(app.cursor_line) || r.right == Some(app.cursor_line))
            .expect("cursor must point at a split row");

        let visible = app.visual_rows_split(scroll_row, cursor_row, &split_rows);
        assert!(
            visible <= app.viewport_height,
            "cursor split row must fit in viewport (visible={}, vh={})",
            visible,
            app.viewport_height,
        );
    }
}
