//! Diff/review screen: a two-pane layout with a left file sidebar listing every
//! file in the diff (status, viewed mark, comment count) and a right pane that
//! renders the visible slice of the selected file's hunks, lines, and inline
//! comment blocks, keeping the cursor in view.

use std::collections::HashMap;

use diffler_core::highlight::StyledRange;
use diffler_core::model::{DiffLine, DiffModel, FileDiff};
use diffler_core::session::{Comment, CommentStatus, Session};
use diffler_core::source::ReviewSource;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};

use crate::app::composer::{Composer, ComposerKind, ComposerLine};
use crate::app::markdown::MdSpan;
use crate::app::{
    App, CommentLine, DiffRow, DiffView, FileHighlights, FileScope, Pane, SplitRow, SplitSide,
    comment_display,
};
use crate::config::FileLayout;
use crate::keymap::Action;
use crate::search::Search;
use crate::theme::Theme;
use crate::tree::{Bucket, TreeNode};
use crate::ui::Hint;
use crate::ui::diff_render::{
    LineFlags, PairSelection, align_scroll, cursor_band, diff_line_height, file_gutter_width,
    hunk_header, line_syntax, render_diff_line, render_split_pair, split_pair_height,
};
use crate::ui::{diffstat_spans, proportion_bar, status_bar, status_color};

/// Hint entries, rendered against the live keymap so remaps show.
const HINTS: &[Hint] = &[
    Hint::Leaf(&[Action::Comment], "comment"),
    Hint::Leaf(&[Action::Reply], "reply"),
    Hint::Leaf(&[Action::MarkViewed], "viewed"),
    Hint::Leaf(&[Action::CommentsOverview], "all comments"),
    Hint::Leaf(&[Action::Help], "help"),
];

/// Sidebar width: a quarter of the screen, clamped to a readable band.
fn sidebar_width(total: u16) -> u16 {
    (total / 4).clamp(28, 44).min(total)
}

pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
    let (body, bar) = super::screen_chrome(frame, app, HINTS);

    // enrichment (emphasis/highlight/scope) runs on the blocking pool; this
    // only queues work, and the pane renders plain until the result lands
    app.queue_enrich_selected();

    // disjoint field borrows: the diff view mutates (scroll, highlight
    // cache) while theme and review stay read-only
    let theme = &app.theme;
    let review = &app.review;
    let search = app.search.as_ref();
    let highlighter = app.highlighter.as_ref();
    if let Some(diff) = app.diff.as_mut() {
        // comment wrap follows the diff pane's inner width: the body minus
        // the sidebar column and the pane block's two border columns
        diff.set_wrap_width(body.width.saturating_sub(sidebar_width(body.width) + 2));
        diff.ensure_rows(review);
        // a commit view renders from its pinned model; only fall back to the
        // (lazily computed) working-tree model for the working-tree view
        let review_model = (diff.commit_model.is_none()).then(|| review.model());
        let session = review.session_for(&diff.source);
        let ctx = RenderCtx {
            theme,
            session,
            review_model,
            search,
            highlighter,
        };
        draw_body(frame, body, &ctx, diff);
    }

    frame.render_widget(
        Paragraph::new(status_bar(app, bar.width)).style(Style::new().bg(app.theme.panel)),
        bar,
    );
}

struct RenderCtx<'a> {
    theme: &'a Theme,
    session: &'a Session,
    review_model: Option<&'a DiffModel>,
    search: Option<&'a Search>,
    highlighter: &'a diffler_core::highlight::Highlighter,
}

/// Whether a row sits under the cursor, and whether its pane holds focus
/// (focus decides how bright the cursor band renders).
#[derive(Clone, Copy)]
struct RowState {
    selected: bool,
    focused: bool,
}

/// A sidebar tree row's shared rendering inputs: theme, its indent depth, the
/// pane width, cursor/focus state, and any search ranges to highlight.
#[derive(Clone, Copy)]
struct TreeRowCtx<'a> {
    theme: &'a Theme,
    depth: usize,
    width: u16,
    on_cursor: bool,
    focused: bool,
    search: &'a [(std::ops::Range<usize>, bool)],
}

/// The side-by-side view's currently open file: its diff, cached highlights,
/// and gutter width, constant for every row while that file is open.
#[derive(Clone, Copy)]
struct SplitFileCtx<'a> {
    file: &'a FileDiff,
    highlights: Option<&'a FileHighlights>,
    gutter: usize,
}

/// Columns of empty background between the two panes. The gap is what reads
/// as their divider.
const PANE_GAP: u16 = 1;

fn draw_body(frame: &mut Frame<'_>, area: Rect, ctx: &RenderCtx<'_>, diff: &mut DiffView) {
    // the sidebar takes one column beyond its content so the gap and the
    // pane's own left column both stay clear of it
    let width = (sidebar_width(area.width) + 1).min(area.width);
    let comments = comments_width(area.width, diff.comments_open);
    let [list_area, _gap, pane_area, _right_gap, comments_area] = Layout::horizontal([
        Constraint::Length(width),
        Constraint::Length(PANE_GAP),
        Constraint::Min(0),
        Constraint::Length(if comments == 0 { 0 } else { PANE_GAP }),
        Constraint::Length(comments),
    ])
    .areas(area);
    draw_sidebar(frame, list_area, ctx, diff);
    draw_pane(frame, pane_area, ctx, diff);
    if comments > 0 {
        draw_comments(frame, comments_area, ctx, diff);
    }
}

/// The comments sidebar mirrors the file list's band, and yields the whole
/// width back when closed.
fn comments_width(total: u16, open: bool) -> u16 {
    if !open {
        return 0;
    }
    sidebar_width(total).min(total / 3)
}

/// What a card needs to light its search matches: the live query, and whether
/// this card holds the active match so its hits take the stronger colour.
#[derive(Clone, Copy)]
struct CardSearch<'a> {
    query: &'a str,
    current: bool,
}

/// A comment card's shared rendering inputs, the sidebar's counterpart to
/// [`TreeRowCtx`].
#[derive(Clone, Copy)]
struct CardCtx<'a> {
    theme: &'a Theme,
    budget: usize,
    bg: Color,
    width: u16,
    on_cursor: bool,
    orphan: bool,
    search: Option<CardSearch<'a>>,
}

/// Right pane: every comment of the review, each a header line (file, line,
/// status) and its body wrapped to the column. The selection drives the diff
/// cursor, so the highlighted card is always the one the pane's verbs act on.
fn draw_comments(frame: &mut Frame<'_>, area: Rect, ctx: &RenderCtx<'_>, diff: &mut DiffView) {
    let (theme, search) = (ctx.theme, ctx.search);
    let focused = diff.focus == Pane::Comments;
    let surface = sidebar_bg(theme);
    frame.render_widget(Block::new().style(Style::new().bg(surface)), area);
    let [heading, inner] =
        Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
    let ordered = ordered_comments(ctx, diff);
    frame.render_widget(
        Paragraph::new(pane_heading(
            theme,
            &format!("Comments ({})", ordered.len()),
            focused,
            surface,
        )),
        heading,
    );
    diff.comments_rect = inner;

    // one entry per rendered line back to the comment it belongs to, so a
    // click on a wrapped body line selects that comment
    let mut lines: Vec<Line<'static>> = Vec::new();
    let mut owners: Vec<Option<usize>> = Vec::new();
    let budget = (inner.width as usize).saturating_sub(2).max(1);
    let mut cursor_line = 0usize;
    for (index, (comment, orphan)) in ordered.iter().enumerate() {
        let on_cursor = index == diff.comments_cursor;
        if on_cursor {
            cursor_line = lines.len();
        }
        let card = CardCtx {
            theme,
            budget,
            bg: sidebar_row_bg(theme, on_cursor, focused),
            width: inner.width,
            on_cursor,
            orphan: *orphan,
            search: search.filter(|_| focused).map(|search| CardSearch {
                query: search.query(),
                current: search.current_row() == Some(index),
            }),
        };
        for line in comment_card(&card, comment) {
            lines.push(line);
            owners.push(Some(index));
        }
        lines.push(Line::styled(
            " ".repeat(inner.width as usize),
            Style::new().bg(surface),
        ));
        owners.push(None);
    }
    diff.comment_lines = owners;
    if lines.is_empty() {
        let dim = Style::new().fg(theme.dim).bg(surface);
        lines.push(Line::styled(" no comments yet", dim));
        lines.push(Line::styled(" c writes the first", dim));
    }

    let height = inner.height.max(1) as usize;
    diff.comments_scroll =
        super::scroll_to_cursor(cursor_line, diff.comments_scroll, height, lines.len());
    let shown: Vec<Line<'static>> = lines
        .into_iter()
        .skip(diff.comments_scroll)
        .take(height)
        .collect();
    frame.render_widget(Paragraph::new(shown), inner);
}

/// The review's comments in sidebar order: by file as the diff lists them,
/// then by line, matching `App::comment_order`. A file the diff no longer
/// carries ranks last, which is the same thing as being orphaned, so each
/// comment comes back paired with that answer.
fn ordered_comments<'a>(
    ctx: &'a RenderCtx<'_>,
    diff: &DiffView,
) -> Vec<(&'a diffler_core::session::Comment, bool)> {
    let rank = |path: &str| {
        diff.commit_model
            .as_ref()
            .or(ctx.review_model)
            .and_then(|model| model.files.iter().position(|file| file.path == path))
            .unwrap_or(usize::MAX)
    };
    let mut ordered: Vec<(&diffler_core::session::Comment, usize)> = ctx
        .session
        .comments
        .iter()
        .map(|comment| (comment, rank(&comment.anchor.file)))
        .collect();
    ordered.sort_by_key(|(comment, rank)| (*rank, comment.anchor.line.unwrap_or(0)));
    ordered
        .into_iter()
        .map(|(comment, rank)| (comment, rank == usize::MAX))
        .collect()
}

/// One comment as a header line plus its wrapped body.
fn comment_card(cc: &CardCtx<'_>, comment: &diffler_core::session::Comment) -> Vec<Line<'static>> {
    let &CardCtx {
        theme,
        budget,
        bg,
        width,
        on_cursor,
        orphan,
        search,
    } = cc;
    // one line's matches, in the shape `highlight_spans` paints everywhere
    let ranges = |text: &str| {
        search
            .map(|search| {
                crate::search::find_matches(&[(0, text.to_owned())], search.query)
                    .into_iter()
                    .map(|found| (found.range, search.current))
                    .collect::<Vec<_>>()
            })
            .unwrap_or_default()
    };
    // the tree's own lead cell, so the `▌` rail runs down the selected card
    // exactly as it marks the selected file row
    let lead = || tree_lead(theme, 0, bg, on_cursor);
    let pad = |spans: Vec<Span<'static>>| pad_line(spans, bg, width);
    // an orphan outranks its status: the file it points at is gone, which is
    // the only thing worth saying about it
    let (status, colour) = match comment.status {
        _ if orphan => ("⚠", theme.error_fg),
        CommentStatus::Open => ("○", theme.warn_fg),
        CommentStatus::Replied => ("◐", theme.accent),
        CommentStatus::Resolved => ("✓", theme.added),
    };
    let file = comment
        .anchor
        .file
        .rsplit('/')
        .next()
        .unwrap_or(&comment.anchor.file)
        .to_owned();
    let line_no = comment
        .anchor
        .line
        .map_or(String::new(), |l| format!(":{l}"));
    let anchor = super::elide(&format!("{file}{line_no}"), budget.saturating_sub(2));
    let mut header = vec![
        lead(),
        Span::styled(format!("{status} "), Style::new().fg(colour).bg(bg)),
    ];
    header.extend(super::highlight_spans(
        &anchor,
        Style::new().fg(theme.fg).bg(bg),
        &ranges(&anchor),
        theme,
    ));
    let mut out = vec![pad(header)];
    for runs in crate::app::markdown::parse(&comment.body, None, budget) {
        for wrapped in crate::app::markdown::wrap(&runs, budget, budget) {
            let mut spans = vec![lead(), Span::styled(" ".to_owned(), Style::new().bg(bg))];
            let text: String = wrapped.iter().map(|run| run.text.as_str()).collect();
            let line_ranges = ranges(&text);
            let mut offset = 0usize;
            for run in &wrapped {
                let span = md_span(run, Style::new().fg(theme.dim).bg(bg), theme);
                let len = span.content.len();
                let local: Vec<_> = line_ranges
                    .iter()
                    .filter(|(range, _)| range.start < offset + len && range.end > offset)
                    .map(|(range, current)| {
                        (
                            range.start.saturating_sub(offset)..(range.end - offset).min(len),
                            *current,
                        )
                    })
                    .collect();
                spans.extend(super::highlight_spans(
                    &span.content,
                    span.style,
                    &local,
                    theme,
                ));
                offset += len;
            }
            out.push(pad(spans));
        }
    }
    if !comment.replies.is_empty() {
        out.push(pad(vec![
            lead(),
            Span::styled(
                format!(" +{} replies", comment.replies.len()),
                Style::new().fg(theme.purple).bg(bg),
            ),
        ]));
    }
    out
}

/// Left pane: a heading row then one row per file in the diff, the selected
/// one highlighted.
fn draw_sidebar(frame: &mut Frame<'_>, area: Rect, ctx: &RenderCtx<'_>, diff: &mut DiffView) {
    let (theme, session, review_model, search) =
        (ctx.theme, ctx.session, ctx.review_model, ctx.search);
    let focused = diff.focus == Pane::List;
    let surface = sidebar_bg(theme);
    frame.render_widget(Block::new().style(Style::new().bg(surface)), area);
    let [heading, inner] =
        Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
    frame.render_widget(
        Paragraph::new(pane_heading(theme, "Files", focused, surface)),
        heading,
    );
    diff.sidebar = inner;
    let Some(model) = diff.commit_model.as_ref().or(review_model) else {
        return;
    };
    // build only the visible slice: the tree can be far taller than the pane
    // and styling every row per frame is O(files)
    let height = inner.height.max(1) as usize;
    let rows = diff.tree_rows(model, session);
    let stat = GroupStat::collect(diff, model, session);
    let scroll = super::scroll_to_cursor(diff.tree_cursor, diff.sidebar_scroll, height, rows.len());
    diff.sidebar_scroll = scroll;
    let lines: Vec<Line<'static>> = rows
        .iter()
        .enumerate()
        .skip(scroll)
        .take(height)
        .map(|(row_index, row)| {
            let on_cursor = row_index == diff.tree_cursor;
            // ranges are offsets into the row's name, so the `/` match
            // highlights the exact substring like the log and diff panes do
            let ranges = search
                .filter(|_| focused)
                .map(|s| s.ranges_for(row_index))
                .unwrap_or_default();
            let row_ctx = TreeRowCtx {
                theme,
                depth: row.depth,
                width: inner.width,
                on_cursor,
                focused,
                search: &ranges,
            };
            match &row.node {
                TreeNode::Dir { name, path } => sidebar_dir_line(
                    &row_ctx,
                    name,
                    diff.folded_dirs.contains(path),
                    stat.dirs.get(path).copied().unwrap_or_default(),
                ),
                TreeNode::Section {
                    bucket,
                    count,
                    folded,
                } => sidebar_section_line(
                    &row_ctx,
                    *bucket,
                    *count,
                    stat.sections.get(bucket).copied().unwrap_or_default(),
                    *folded,
                ),
                TreeNode::File { index, name } => {
                    let Some(file) = model.files.get(*index) else {
                        return Line::default();
                    };
                    let viewed = session.is_viewed(&file.path, &file.content_hash());
                    let open = open_comment_count(session, &file.path);
                    sidebar_file_line(&row_ctx, file, name, viewed, open)
                }
            }
        })
        .collect();
    frame.render_widget(Paragraph::new(lines), inner);
}

/// Right pane: the selected file's header then the visible slice of its rows,
/// in unified or side-by-side mode.
#[allow(clippy::too_many_lines)]
fn draw_pane(frame: &mut Frame<'_>, area: Rect, ctx: &RenderCtx<'_>, diff: &mut DiffView) {
    let (theme, session, review_model, search) =
        (ctx.theme, ctx.session, ctx.review_model, ctx.search);
    let focused = diff.focus == Pane::Diff;
    let title = pane_title(&diff.source);
    frame.render_widget(Block::new().style(Style::new().bg(theme.panel)), area);
    let [heading, inner] =
        Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
    frame.render_widget(
        Paragraph::new(pane_heading(theme, &title, focused, theme.panel)),
        heading,
    );

    let Some((model, file)) = diff
        .commit_model
        .as_ref()
        .or(review_model)
        .and_then(|model| model.files.get(diff.selected).map(|file| (model, file)))
    else {
        frame.render_widget(
            Paragraph::new(Line::styled(
                " nothing to review",
                Style::new().fg(theme.dim).bg(theme.panel),
            )),
            inner,
        );
        return;
    };

    // header is fixed; the rows scroll beneath it
    let [header_area, body_area] =
        Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(inner);
    let viewed = session.is_viewed(&file.path, &file.content_hash());
    let open = open_comment_count(session, &file.path);
    let total = session
        .comments
        .iter()
        .filter(|c| c.anchor.file == file.path)
        .count();
    frame.render_widget(
        Paragraph::new(pane_header_line(
            theme,
            file,
            viewed,
            (open, total),
            header_area.width,
        )),
        header_area,
    );

    // the breadcrumb row is reserved only for files that have definitions, so
    // plain files keep their full height
    let has_scope = diff
        .scopes
        .get(&file.path)
        .is_some_and(|s| !s.index.is_empty());
    let (crumb_area, rows_area) = if has_scope {
        let [crumb, rows] =
            Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(body_area);
        (Some(crumb), rows)
    } else {
        (None, body_area)
    };

    diff.viewport = rows_area.height;
    diff.pane = rows_area;

    if diff.side_by_side {
        let split = diff.split_rows.clone();
        let (sel, side) = diff.split_cursor(&split);
        let height = rows_area.height.max(1) as usize;
        let gutter = file_gutter_width(file);
        let highlights = diff.highlights.get(&file.path);
        let file_ctx = SplitFileCtx {
            file,
            highlights,
            gutter,
        };
        let heights: Vec<usize> = split
            .iter()
            .map(|row| split_row_height(file_ctx, row, rows_area.width))
            .collect();
        let mut starts = Vec::with_capacity(heights.len());
        let mut total = 0usize;
        for h in &heights {
            starts.push(total);
            total += h;
        }
        let (sel_start, sel_height) = match (starts.get(sel), heights.get(sel)) {
            (Some(s), Some(h)) => (*s, *h),
            _ => (0, 1),
        };
        let base = diff.scroll_align.take().map_or(diff.split_scroll, |a| {
            align_scroll(a, sel_start, sel_height, height)
        });
        let scroll = super::scroll_to_span(sel_start, sel_height, base, height, total);
        diff.split_scroll = scroll;
        let mut lines: Vec<Line<'static>> = Vec::with_capacity(height);
        let mut top_row = None;
        for (index, row) in split.iter().enumerate() {
            let (Some(&start), Some(&row_h)) = (starts.get(index), heights.get(index)) else {
                break;
            };
            if start + row_h <= scroll {
                continue;
            }
            if start >= scroll + height {
                break;
            }
            top_row.get_or_insert(index);
            let rendered = split_row_lines(
                ctx,
                file_ctx,
                rows_area.width,
                row,
                RowState {
                    selected: index == sel,
                    focused,
                },
                side,
                diff.composer.as_ref(),
            );
            for (offset, line) in rendered.into_iter().enumerate() {
                let at = start + offset;
                if at < scroll || at >= scroll + height {
                    continue;
                }
                lines.push(line);
            }
        }
        let top = top_row.and_then(|from| {
            split
                .iter()
                .skip(from)
                .find_map(|r| split_right_new_no(file, r))
        });
        render_scope_crumb(frame, crumb_area, theme, diff.scopes.get(&file.path), top);
        frame.render_widget(Paragraph::new(lines), rows_area);
        return;
    }

    let height = rows_area.height.max(1) as usize;
    let cursor = diff.cursor;
    let selection = diff.selection();
    let selected = |index: usize| {
        index == cursor || selection.is_some_and(|(start, end)| index >= start && index <= end)
    };

    // long lines wrap, so rows vary in height: place every row first, then
    // scroll in visual lines keeping the whole cursor row on screen
    let rows = diff.rows().to_vec();
    let heights: Vec<usize> = rows
        .iter()
        .map(|row| row_height(model, row, rows_area.width))
        .collect();
    let mut starts = Vec::with_capacity(heights.len());
    let mut total = 0usize;
    for h in &heights {
        starts.push(total);
        total += h;
    }
    let (cur_start, cur_height) = match (starts.get(cursor), heights.get(cursor)) {
        (Some(s), Some(h)) => (*s, *h),
        _ => (0, 1),
    };
    let base = diff.scroll_align.take().map_or(diff.scroll, |a| {
        align_scroll(a, cur_start, cur_height, height)
    });
    let scroll = super::scroll_to_span(cur_start, cur_height, base, height, total);
    diff.scroll = scroll;

    let mut lines: Vec<Line<'static>> = Vec::with_capacity(height);
    let mut line_rows: Vec<Option<usize>> = Vec::with_capacity(height);
    let mut top_row = None;
    for (index, row) in rows.iter().enumerate() {
        let (Some(&start), Some(&row_h)) = (starts.get(index), heights.get(index)) else {
            break;
        };
        if start + row_h <= scroll {
            continue;
        }
        if start >= scroll + height {
            break;
        }
        top_row.get_or_insert(index);
        // only the focused pane highlights; otherwise the sidebar's
        // matches (keyed by row index) would bleed onto diff rows
        let ranges = search
            .filter(|_| focused)
            .map(|s| s.ranges_for(index))
            .unwrap_or_default();
        let rendered = row_lines(
            ctx,
            model,
            diff,
            row,
            rows_area.width,
            RowState {
                selected: selected(index),
                focused,
            },
            &ranges,
        );
        for (offset, line) in rendered.into_iter().enumerate() {
            let at = start + offset;
            if at < scroll || at >= scroll + height {
                continue;
            }
            lines.push(line);
            line_rows.push(Some(index));
        }
    }
    diff.line_rows = line_rows;
    frame.render_widget(Paragraph::new(lines), rows_area);

    let top = top_row.and_then(|from| rows.iter().skip(from).find_map(|r| row_new_no(file, r)));
    render_scope_crumb(frame, crumb_area, theme, diff.scopes.get(&file.path), top);
}

/// Terminal rows a split row occupies at `width`; only pairs can wrap.
fn split_row_height(file_ctx: SplitFileCtx<'_>, row: &SplitRow, width: u16) -> usize {
    let SplitRow::Pair { hunk, left, right } = *row else {
        return 1;
    };
    let Some(hunk) = file_ctx.file.hunks.get(hunk) else {
        return 1;
    };
    split_pair_height(
        left.and_then(|i| hunk.lines.get(i)),
        right.and_then(|i| hunk.lines.get(i)),
        file_ctx.gutter,
        width,
    )
}

fn split_row_lines(
    ctx: &RenderCtx<'_>,
    file_ctx: SplitFileCtx<'_>,
    width: u16,
    row: &SplitRow,
    state: RowState,
    cursor_side: Option<SplitSide>,
    composer: Option<&Composer>,
) -> Vec<Line<'static>> {
    let SplitFileCtx {
        file,
        highlights,
        gutter,
    } = file_ctx;
    match *row {
        SplitRow::Hunk { hunk } => match file.hunks.get(hunk) {
            Some(hunk) => vec![hunk_header(
                ctx.theme,
                hunk,
                width,
                state.selected,
                state.focused,
            )],
            None => vec![Line::default()],
        },
        SplitRow::Pair { hunk, left, right } => {
            let Some(hunk) = file.hunks.get(hunk) else {
                return vec![Line::default()];
            };
            let cell = |index: Option<usize>, side: SplitSide| {
                index.and_then(|i| hunk.lines.get(i)).map(|line| {
                    (
                        line,
                        highlights.and_then(|hl| split_side_syntax(hl, line, side)),
                        line_annotated(ctx.session, &file.path, line),
                    )
                })
            };
            let sel_left = state.selected && !matches!(cursor_side, Some(SplitSide::Right));
            let sel_right = state.selected && !matches!(cursor_side, Some(SplitSide::Left));
            render_split_pair(
                ctx.theme,
                cell(left, SplitSide::Left),
                cell(right, SplitSide::Right),
                gutter,
                width,
                PairSelection {
                    left: sel_left,
                    right: sel_right,
                    focused: state.focused,
                },
            )
        }
        SplitRow::Comment {
            comment,
            line,
            outdated,
        } => match ctx.session.comments.get(comment) {
            Some(comment) => {
                vec![comment_row_line(ctx, comment, line, outdated, width, state)]
            }
            None => vec![Line::default()],
        },
        SplitRow::Composer { line } => match composer {
            Some(composer) => vec![composer_row_line(
                ctx.theme,
                composer,
                line,
                width,
                state.selected,
                state.focused,
            )],
            None => vec![Line::default()],
        },
    }
}

/// Per-side syntax for a split row: the old highlights for the left column, the
/// new highlights for the right, indexed by that side's line number.
fn split_side_syntax<'a>(
    highlights: &'a FileHighlights,
    line: &DiffLine,
    side: SplitSide,
) -> Option<&'a [StyledRange]> {
    let (column, number) = match side {
        SplitSide::Left => (&highlights.old, line.old_no),
        SplitSide::Right => (&highlights.new, line.new_no),
    };
    let index = usize::try_from(number?).ok()?.checked_sub(1)?;
    column.get(index).map(Vec::as_slice)
}

/// Diff-pane title: a plain "Diff" for the working tree or a single commit, a
/// `oldest7..newest7` range span when the pane shows a combined commit range.
fn pane_title(source: &ReviewSource) -> String {
    match source {
        ReviewSource::WorkingTree | ReviewSource::Commit { .. } => "Diff".to_owned(),
        ReviewSource::Range { oldest, newest } => {
            let short = |oid: &str| oid.get(..7).unwrap_or(oid).to_owned();
            format!("Diff {}..{}", short(oldest), short(newest))
        }
        ReviewSource::Pr { number } => format!("PR #{number}"),
        ReviewSource::Against { .. } => format!("Diff {}", source.label()),
    }
}

/// The file sidebar's surface. A step away from the diff pane's surface is
/// what tells the two panes apart, so neither needs a border drawn: the diff
/// is the lit card, the file list lies flat on the app background.
fn sidebar_bg(theme: &Theme) -> Color {
    theme.bg
}

/// A pane's name as a plain heading row over its own surface, accented when
/// the pane holds focus.
fn pane_heading(theme: &Theme, title: &str, focused: bool, bg: Color) -> Line<'static> {
    Line::styled(
        format!(" {title}"),
        Style::new()
            .fg(if focused { theme.accent } else { theme.dim })
            .bg(bg),
    )
}

fn open_comment_count(session: &Session, path: &str) -> usize {
    session
        .comments
        .iter()
        .filter(|c| c.anchor.file == path && c.status != CommentStatus::Resolved)
        .count()
}

/// The `(added, deleted)` line counts each sidebar group covers, built once per
/// frame from one pass over the model: a header row knows its own name, not its
/// members. A directory covers every file beneath it at any depth; a section
/// covers the files its bucket holds. Only the layout on screen is walked.
#[derive(Default)]
struct GroupStat {
    dirs: HashMap<String, (usize, usize)>,
    sections: HashMap<Bucket, (usize, usize)>,
}

impl GroupStat {
    fn collect(diff: &DiffView, model: &DiffModel, session: &Session) -> Self {
        let mut stat = Self::default();
        for file in &model.files {
            let (added, deleted) = file.diffstat();
            let tally = |slot: &mut (usize, usize)| {
                slot.0 += added;
                slot.1 += deleted;
            };
            match diff.layout {
                FileLayout::Kinds => tally(
                    stat.sections
                        .entry(Bucket::Kind(diff.kind_of(&file.path)))
                        .or_default(),
                ),
                FileLayout::Review => {
                    let bucket = if session.is_viewed(&file.path, &file.content_hash()) {
                        Bucket::Viewed
                    } else {
                        Bucket::ToReview
                    };
                    tally(stat.sections.entry(bucket).or_default());
                }
                FileLayout::Tree | FileLayout::List => {
                    for (at, _) in file.path.match_indices('/') {
                        tally(stat.dirs.entry(file.path[..at].to_owned()).or_default());
                    }
                }
            }
        }
        stat
    }
}

/// Push `tail` against the row's right edge, gap-padded, when what is already
/// in `spans` leaves room for it. A row too narrow keeps its name and marks and
/// loses the tail, which is the part a reader can do without.
fn push_right(spans: &mut Vec<Span<'static>>, tail: Vec<Span<'static>>, width: u16, bg: Color) {
    if tail.is_empty() {
        return;
    }
    let tail_width: usize = tail.iter().map(Span::width).sum();
    let used: usize = spans.iter().map(Span::width).sum();
    let Some(pad) = super::right_align_pad(used, tail_width, width as usize) else {
        return;
    };
    if pad > 0 {
        spans.push(Span::styled(" ".repeat(pad), Style::new().bg(bg)));
    }
    spans.extend(tail);
}

/// A sidebar row's background: the list surface, or the cursor band over it for
/// the row under the cursor.
fn sidebar_row_bg(theme: &Theme, on_cursor: bool, focused: bool) -> Color {
    if on_cursor {
        cursor_band(theme, sidebar_bg(theme), focused)
    } else {
        sidebar_bg(theme)
    }
}

/// Sidebar leading cells: the cursor `▌` marker plus the tree indent for
/// `depth`. Shared by dir and file rows so columns line up.
fn tree_lead(theme: &Theme, depth: usize, bg: Color, on_cursor: bool) -> Span<'static> {
    // a left bar makes the cursor row unmistakable where the bg tint is subtle
    let marker = if on_cursor { "▌" } else { " " };
    Span::styled(
        format!("{marker}{}", " ".repeat(depth * 2)),
        Style::new().fg(theme.accent).bg(bg),
    )
}

/// A directory row: indent, fold arrow, the dim directory name, and the
/// diffstat of everything beneath it.
fn sidebar_dir_line(
    rc: &TreeRowCtx<'_>,
    name: &str,
    folded: bool,
    stat: (usize, usize),
) -> Line<'static> {
    let &TreeRowCtx {
        theme,
        depth,
        width,
        on_cursor,
        focused,
        search,
    } = rc;
    let bg = sidebar_row_bg(theme, on_cursor, focused);
    let arrow = if folded { "▸ " } else { "▾ " };
    let name_style = Style::new()
        .fg(if on_cursor { theme.accent } else { theme.fg })
        .bg(bg);
    let mut spans = vec![
        tree_lead(theme, depth, bg, on_cursor),
        Span::styled(arrow.to_owned(), Style::new().fg(theme.dim).bg(bg)),
    ];
    // dir names are never clipped, so the highlight maps straight onto them
    spans.extend(super::highlight_spans(name, name_style, search, theme));
    let tail = diffstat_spans(theme, stat.0, stat.1, bg);
    push_right(&mut spans, tail, width, bg);
    pad_line(spans, bg, width)
}

/// A section header row: fold arrow, bold label, the count of files it holds,
/// and the diffstat they add up to.
fn sidebar_section_line(
    rc: &TreeRowCtx<'_>,
    bucket: Bucket,
    count: usize,
    stat: (usize, usize),
    folded: bool,
) -> Line<'static> {
    let &TreeRowCtx {
        theme,
        width,
        on_cursor,
        focused,
        ..
    } = rc;
    let bg = sidebar_row_bg(theme, on_cursor, focused);
    let arrow = if folded { "▸ " } else { "▾ " };
    let label_style = Style::new()
        .fg(if on_cursor { theme.accent } else { theme.fg })
        .bg(bg);
    let dim = Style::new().fg(theme.dim).bg(bg);
    let mut spans = vec![
        tree_lead(theme, 0, bg, on_cursor),
        Span::styled(arrow.to_owned(), dim),
        Span::styled(bucket.label().to_owned(), label_style),
        Span::styled(format!(" ({count})"), dim),
    ];
    let tail = diffstat_spans(theme, stat.0, stat.1, bg);
    push_right(&mut spans, tail, width, bg);
    pad_line(spans, bg, width)
}

/// A file row: indent, status glyph (colored), basename, then the viewed and
/// comment-count marks and the `+A -B` diffstat. The diffstat is dropped first
/// when the sidebar is too narrow to keep the name and marks legible.
fn sidebar_file_line(
    rc: &TreeRowCtx<'_>,
    file: &FileDiff,
    name: &str,
    viewed: bool,
    open: usize,
) -> Line<'static> {
    let &TreeRowCtx {
        theme,
        depth,
        width,
        on_cursor,
        focused,
        search,
    } = rc;
    let bg = sidebar_row_bg(theme, on_cursor, focused);
    let dim = Style::new().fg(theme.dim).bg(bg);
    let glyph = file.status.glyph();
    let mut spans = vec![
        tree_lead(theme, depth, bg, on_cursor),
        Span::styled(
            format!("{glyph} "),
            Style::new().fg(status_color(theme, file.status)).bg(bg),
        ),
    ];
    // reserve room for the trailing markers, then clip the basename into the
    // rest. " ·{open}" is 2 + the count's digits wide; " ✓" is 2
    let suffix_width = usize::from(viewed) * 2
        + if open > 0 {
            2 + open.to_string().len()
        } else {
            0
        };
    let used = spans.iter().map(Span::width).sum::<usize>() + suffix_width;
    let room = (width as usize).saturating_sub(used + 1);
    let name_style = Style::new()
        .fg(if on_cursor { theme.accent } else { theme.fg })
        .bg(bg);
    // highlight the whole name, then clip the spans so a match stays lit on the
    // visible part; a path row (with a `/`) dims its parents and front-elides,
    // so its tail (the basename, the file's identity) stays in view and reads
    // as the name it is
    let parent = name.rfind('/').map_or(0, |at| at + 1);
    let highlighted = super::highlight_spans_split(name, parent, dim, name_style, search, theme);
    spans.extend(clip_spans(
        highlighted,
        room,
        name.contains('/'),
        name_style,
    ));
    if viewed {
        spans.push(Span::styled(" ✓".to_owned(), dim));
    }
    if open > 0 {
        spans.push(Span::styled(format!(" ·{open}"), dim));
    }
    // GitHub-PR style: the file's `+A -B` hugs the right edge, but only if it
    // fits after the name and marks: name + marks stay legible first
    let (added, deleted) = file.diffstat();
    push_right(
        &mut spans,
        diffstat_spans(theme, added, deleted, bg),
        width,
        bg,
    );
    pad_line(spans, bg, width)
}

/// Clip a name's already-styled `spans` to `room` cells, preserving each span's
/// style (so a search highlight survives on the visible cells). `front` elides
/// from the left with a leading `…` (for flat-list paths, keeping the tail
/// basename in view), otherwise from the right with a trailing `…`. The ellipsis
/// takes `ellipsis_style`. Char-based, multibyte-safe.
fn clip_spans(
    spans: Vec<Span<'static>>,
    room: usize,
    front: bool,
    ellipsis_style: Style,
) -> Vec<Span<'static>> {
    let total: usize = spans.iter().map(|s| s.content.chars().count()).sum();
    if room == 0 {
        return Vec::new();
    }
    if total <= room {
        return spans;
    }
    let keep = room - 1;
    if front {
        let skip = total - keep;
        let mut out = vec![Span::styled("…".to_owned(), ellipsis_style)];
        let mut seen = 0;
        for span in spans {
            let len = span.content.chars().count();
            let start = seen;
            seen += len;
            if seen <= skip {
                continue;
            }
            let drop_here = skip.saturating_sub(start);
            let kept: String = span.content.chars().skip(drop_here).collect();
            out.push(Span::styled(kept, span.style));
        }
        out
    } else {
        let mut out = Vec::new();
        let mut taken = 0;
        for span in spans {
            if taken >= keep {
                break;
            }
            let len = span.content.chars().count();
            let take = (keep - taken).min(len);
            let kept: String = span.content.chars().take(take).collect();
            out.push(Span::styled(kept, span.style));
            taken += len;
        }
        out.push(Span::styled("…".to_owned(), ellipsis_style));
        out
    }
}

/// Terminal rows `row` occupies at `width`; only diff lines can wrap.
fn row_height(model: &DiffModel, row: &DiffRow, width: u16) -> usize {
    let DiffRow::Line { file, hunk, line } = row else {
        return 1;
    };
    model
        .files
        .get(*file)
        .and_then(|f| {
            let line = f.hunks.get(*hunk)?.lines.get(*line)?;
            Some(diff_line_height(line, file_gutter_width(f), width))
        })
        .unwrap_or(1)
}

fn row_lines(
    ctx: &RenderCtx<'_>,
    model: &DiffModel,
    diff: &DiffView,
    row: &DiffRow,
    width: u16,
    state: RowState,
    search: &[(std::ops::Range<usize>, bool)],
) -> Vec<Line<'static>> {
    let highlights = &diff.highlights;
    match row {
        DiffRow::Hunk { file, hunk } => {
            match model.files.get(*file).and_then(|f| f.hunks.get(*hunk)) {
                Some(hunk) => vec![hunk_header(
                    ctx.theme,
                    hunk,
                    width,
                    state.selected,
                    state.focused,
                )],
                None => vec![Line::default()],
            }
        }
        DiffRow::Line { file, hunk, line } => {
            let Some(file) = model.files.get(*file) else {
                return vec![Line::default()];
            };
            let Some(line) = file.hunks.get(*hunk).and_then(|h| h.lines.get(*line)) else {
                return vec![Line::default()];
            };
            let syntax = highlights
                .get(&file.path)
                .and_then(|cached| line_syntax(&cached.old, &cached.new, line));
            let annotated = line_annotated(ctx.session, &file.path, line);
            render_diff_line(
                ctx.theme,
                line,
                syntax,
                file_gutter_width(file),
                width,
                LineFlags {
                    selected: state.selected,
                    focused: state.focused,
                    annotated,
                },
                search,
            )
        }
        DiffRow::Comment {
            comment,
            line,
            outdated,
        } => match ctx.session.comments.get(*comment) {
            Some(comment) => {
                vec![comment_row_line(
                    ctx, comment, *line, *outdated, width, state,
                )]
            }
            None => vec![Line::default()],
        },
        DiffRow::Composer { line } => match diff.composer.as_ref() {
            Some(composer) => vec![composer_row_line(
                ctx.theme,
                composer,
                *line,
                width,
                state.selected,
                state.focused,
            )],
            None => vec![Line::default()],
        },
    }
}

/// One terminal row: the enclosing-definition breadcrumb for the top visible
/// line, styled like a hunk heading. Blank when the top line is at top level.
fn scope_line(theme: &Theme, crumbs: &[String], width: u16) -> Line<'static> {
    let text = if crumbs.is_empty() {
        String::new()
    } else {
        format!(" {}", crumbs.join(" › "))
    };
    let pad = (width as usize).saturating_sub(text.chars().count());
    Line::from(vec![
        Span::styled(text, Style::new().fg(theme.dim).bg(theme.panel)),
        Span::styled(" ".repeat(pad), Style::new().bg(theme.panel)),
    ])
}

fn render_scope_crumb(
    frame: &mut Frame<'_>,
    area: Option<Rect>,
    theme: &Theme,
    scope: Option<&FileScope>,
    top_new_line: Option<u32>,
) {
    let Some(area) = area else {
        return;
    };
    let crumbs = match (scope, top_new_line) {
        (Some(scope), Some(line)) => scope.index.crumbs(line.saturating_sub(1) as usize),
        _ => Vec::new(),
    };
    frame.render_widget(Paragraph::new(scope_line(theme, &crumbs, area.width)), area);
}

/// Whether `line` falls inside any comment's anchored range for `file_path`:
/// drives the GitHub-style highlight marking a multi-line comment's scope.
fn line_annotated(session: &Session, file_path: &str, line: &DiffLine) -> bool {
    session.comments.iter().any(|c| {
        if c.anchor.file != file_path {
            return false;
        }
        let Some(start) = c.anchor.line else {
            return false;
        };
        let end = c.anchor.line_end.unwrap_or(start);
        let no = if c.anchor.on_old_side {
            line.old_no
        } else {
            line.new_no
        };
        no.is_some_and(|n| start <= n && n <= end)
    })
}

/// New-side line number of a unified diff row, if it has one.
fn row_new_no(file: &FileDiff, row: &DiffRow) -> Option<u32> {
    match *row {
        DiffRow::Line { hunk, line, .. } => file.hunks.get(hunk)?.lines.get(line)?.new_no,
        _ => None,
    }
}

/// New-side line number of a split row's right cell, if any.
fn split_right_new_no(file: &FileDiff, row: &SplitRow) -> Option<u32> {
    match *row {
        SplitRow::Pair { hunk, right, .. } => file.hunks.get(hunk)?.lines.get(right?)?.new_no,
        _ => None,
    }
}

/// Right-pane header: status, path, binary/viewed marks, comment count.
fn pane_header_line(
    theme: &Theme,
    file: &FileDiff,
    viewed: bool,
    // (open or replied, total) comment counts for the file
    comments: (usize, usize),
    width: u16,
) -> Line<'static> {
    let bg = theme.panel;
    let dim = Style::new().fg(theme.dim).bg(bg);
    let mode = file.status.label();
    let mut spans = vec![Span::styled(format!(" {mode:<10}"), dim)];
    spans.push(Span::styled(
        file.path.clone(),
        Style::new().fg(theme.accent).bg(bg),
    ));
    if file.binary {
        spans.push(Span::styled(" (binary)".to_owned(), dim));
    }
    if viewed {
        spans.push(Span::styled(" ✓ viewed".to_owned(), dim));
    }
    // resolved-only files read as done: no count, just a quiet marker
    let (open, total) = comments;
    if open > 0 {
        let noun = if open == 1 { "comment" } else { "comments" };
        spans.push(Span::styled(format!(" · {open} {noun}"), dim));
    } else if total > 0 {
        spans.push(Span::styled(" · resolved".to_owned(), dim));
    }
    // GitHub-PR style: the file's `+A -B` and its proportion bar hug the right
    // edge of the header, mirroring the status screen's grand-total summary
    let (added, deleted) = file.diffstat();
    let mut tail = diffstat_spans(theme, added, deleted, bg);
    let bar = proportion_bar(theme, added, deleted, bg);
    if !bar.is_empty() {
        tail.push(Span::styled(" ".to_owned(), Style::new().bg(bg)));
        tail.extend(bar);
    }
    let tail_width: usize = tail.iter().map(Span::width).sum();
    let used: usize = spans.iter().map(Span::width).sum();
    let gap = (width as usize).saturating_sub(used + tail_width);
    if gap > 0 {
        spans.push(Span::styled(" ".repeat(gap), Style::new().bg(bg)));
    }
    spans.extend(tail);
    pad_line(spans, bg, width)
}

fn comment_row_line(
    ctx: &RenderCtx<'_>,
    comment: &Comment,
    line: usize,
    outdated: bool,
    width: u16,
    state: RowState,
) -> Line<'static> {
    let theme = ctx.theme;
    let bg = if state.selected {
        cursor_band(theme, theme.bg, state.focused)
    } else {
        theme.bg
    };
    // a solid left bar in the comment's status color turns the block into a
    // distinct card that stands out against the diff lines around it
    let (status_label, accent) = match comment.status {
        CommentStatus::Open => ("open", theme.warn_fg),
        CommentStatus::Replied => ("replied", theme.accent),
        CommentStatus::Resolved => ("resolved", theme.dim),
    };
    let bar = Span::styled("  ▌ ".to_owned(), Style::new().fg(accent).bg(bg));
    let dim = Style::new().fg(theme.dim).bg(bg);
    let fg = Style::new().fg(theme.fg).bg(bg);
    let lines = comment_display(comment, width, Some(ctx.highlighter));
    let Some(part) = lines.get(line) else {
        return Line::default();
    };
    let spans = match part {
        CommentLine::Header => {
            let mut spans = vec![
                bar,
                Span::styled(comment.author.clone(), Style::new().fg(theme.purple).bg(bg)),
                Span::styled(" · ".to_owned(), dim),
                Span::styled(status_label.to_owned(), Style::new().fg(accent).bg(bg)),
            ];
            if outdated {
                spans.push(Span::styled(
                    " · outdated".to_owned(),
                    Style::new().fg(theme.warn_fg).bg(bg),
                ));
            }
            spans
        }
        CommentLine::Body(runs) => {
            let mut spans = vec![bar];
            spans.extend(runs.iter().map(|run| md_span(run, fg, theme)));
            spans
        }
        CommentLine::Reply {
            author,
            spans: runs,
            first,
        } => {
            let mut spans = vec![bar];
            if *first {
                spans.push(Span::styled(
                    format!("└ {author}: "),
                    Style::new().fg(theme.purple).bg(bg),
                ));
            } else {
                spans.push(Span::styled("  ".to_owned(), fg));
            }
            spans.extend(runs.iter().map(|run| md_span(run, fg, theme)));
            spans
        }
        CommentLine::Footer => vec![Span::styled(
            "  ▌".to_owned(),
            Style::new().fg(accent).bg(bg),
        )],
    };
    pad_line(spans, bg, width)
}

/// The open composer, drawn as the card it is about to become: same bar, same
/// wrap, with the caret shown as a reversed cell so the writer sees where the
/// next character lands.
fn composer_row_line(
    theme: &Theme,
    composer: &Composer,
    line: usize,
    width: u16,
    selected: bool,
    focused: bool,
) -> Line<'static> {
    let bg = if selected {
        cursor_band(theme, theme.bg, focused)
    } else {
        theme.bg
    };
    let accent = theme.accent;
    let bar = Span::styled("  ▌ ".to_owned(), Style::new().fg(accent).bg(bg));
    let dim = Style::new().fg(theme.dim).bg(bg);
    let fg = Style::new().fg(theme.fg).bg(bg);
    let lines = composer.display(width);
    let Some(part) = lines.get(line) else {
        return Line::default();
    };
    let spans = match part {
        ComposerLine::Header => vec![
            bar,
            Span::styled(composer_title(composer), Style::new().fg(accent).bg(bg)),
        ],
        ComposerLine::Body { text, cursor } => {
            let mut spans = vec![bar];
            let Some(column) = *cursor else {
                spans.push(Span::styled(text.clone(), fg));
                return pad_line(spans, bg, width);
            };
            let head: String = text.chars().take(column).collect();
            let under = text.chars().nth(column).unwrap_or(' ');
            let tail: String = text.chars().skip(column + 1).collect();
            spans.push(Span::styled(head, fg));
            spans.push(Span::styled(
                under.to_string(),
                fg.add_modifier(Modifier::REVERSED),
            ));
            spans.push(Span::styled(tail, fg));
            spans
        }
        ComposerLine::Footer => vec![
            Span::styled("  ▌ ".to_owned(), Style::new().fg(accent).bg(bg)),
            Span::styled("enter".to_owned(), fg),
            Span::styled(" submit · ".to_owned(), dim),
            Span::styled("a-enter".to_owned(), fg),
            Span::styled(" newline · ".to_owned(), dim),
            Span::styled("esc".to_owned(), fg),
            Span::styled(" cancel".to_owned(), dim),
        ],
    };
    pad_line(spans, bg, width)
}

fn composer_title(composer: &Composer) -> String {
    match &composer.kind {
        ComposerKind::New { anchor } => match (anchor.line, anchor.line_end) {
            (Some(line), Some(end)) => format!("comment on {}:{line}-{end}", anchor.file),
            (Some(line), None) => format!("comment on {}:{line}", anchor.file),
            _ => format!("comment on {}", anchor.file),
        },
        ComposerKind::Reply { .. } => "reply".to_owned(),
        ComposerKind::Edit { .. } => "editing".to_owned(),
    }
}

/// Map a markdown run's flags onto `base` (the body foreground over the card
/// background). Recoloring flags (code, link, muted) win over the base fg.
fn md_span(run: &MdSpan, base: Style, theme: &Theme) -> Span<'static> {
    let mut style = base;
    if run.bold {
        style = style.add_modifier(Modifier::BOLD);
    }
    if run.italic {
        style = style.add_modifier(Modifier::ITALIC);
    }
    if run.strike {
        style = style.add_modifier(Modifier::CROSSED_OUT);
    }
    if run.code {
        style = style.fg(theme.accent);
    }
    if run.link {
        style = style.fg(theme.accent).add_modifier(Modifier::UNDERLINED);
    }
    if run.muted {
        style = style.fg(theme.dim);
    }
    if let Some((r, g, b)) = run.fg {
        style = style.fg(Color::Rgb(r, g, b));
    }
    Span::styled(run.text.clone(), style)
}

fn pad_line(mut spans: Vec<Span<'static>>, bg: Color, width: u16) -> Line<'static> {
    let used: usize = spans.iter().map(Span::width).sum();
    let pad = (width as usize).saturating_sub(used);
    if pad > 0 {
        spans.push(Span::styled(" ".repeat(pad), Style::new().bg(bg)));
    }
    Line::from(spans)
}

#[cfg(test)]
mod tests {
    use ratatui::Terminal;

    #[test]
    fn renders_the_comments_sidebar() {
        let (_fixture, mut app) = diff_app();
        app.handle(key('l'));
        app.handle(key('j'));
        app.handle(key('c'));
        for c in "this line reads oddly and should wrap across the column".chars() {
            app.handle(key(c));
        }
        app.handle(crate::test_support::code_key(
            crossterm::event::KeyCode::Enter,
        ));
        app.handle(key('C'));
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn the_comments_sidebar_opens_empty_on_a_review_without_comments() {
        let (_fixture, mut app) = diff_app();
        app.handle(key('C'));
        insta::assert_snapshot!(render(&mut app).backend());
    }
    use ratatui::backend::TestBackend;

    use ratatui::style::Style;

    use super::clip_spans;
    use crate::app::{App, DiffRow, Pane};
    use crate::config::LoadedConfig;
    use crate::test_support::{
        Fixture, key, mouse_click, mouse_drag, mouse_right_click, mouse_scroll, render,
        standard_fixture,
    };
    use crate::theme::Theme;

    fn plain(text: &str) -> Vec<ratatui::text::Span<'static>> {
        vec![ratatui::text::Span::raw(text.to_owned())]
    }

    fn joined(spans: &[ratatui::text::Span<'static>]) -> String {
        spans.iter().map(|s| s.content.as_ref()).collect()
    }

    #[test]
    fn clip_spans_keeps_a_short_name_whole() {
        let style = ratatui::style::Style::new();
        assert_eq!(
            joined(&clip_spans(plain("lib.rs"), 32, false, style)),
            "lib.rs"
        );
    }

    #[test]
    fn clip_spans_end_elides_a_long_basename() {
        let style = ratatui::style::Style::new();
        let clipped = clip_spans(plain("a_very_long_filename_indeed.rs"), 12, false, style);
        let text = joined(&clipped);
        assert_eq!(text.chars().count(), 12);
        assert!(text.ends_with('…'), "clipped at the end: {text:?}");
        assert!(text.starts_with("a_very"), "kept from the start: {text:?}");
    }

    #[test]
    fn clip_spans_front_elides_a_path_to_keep_the_basename() {
        let style = ratatui::style::Style::new();
        assert_eq!(
            joined(&clip_spans(plain("src/lib.rs"), 32, true, style)),
            "src/lib.rs"
        );
        let clipped = clip_spans(plain("deep/nested/dir/module.rs"), 12, true, style);
        let text = joined(&clipped);
        assert_eq!(text.chars().count(), 12);
        assert!(text.starts_with('…'), "front-elided: {text:?}");
        assert!(text.ends_with("module.rs"), "basename kept: {text:?}");
    }

    #[test]
    fn clip_spans_zero_room_yields_nothing() {
        let style = ratatui::style::Style::new();
        assert!(clip_spans(plain("lib.rs"), 0, false, style).is_empty());
    }

    #[test]
    fn clip_spans_keeps_a_highlight_lit_on_the_visible_part() {
        let theme = Theme::github_dark();
        // "status" starts at byte 13 of this name and survives an end-clip
        let name = "diffler__ui__status__tests__foo";
        let spans = super::super::highlight_spans(name, Style::new(), &[(13..19, true)], &theme);
        let clipped = clip_spans(spans, 20, false, Style::new());
        let lit: String = clipped
            .iter()
            .filter(|s| s.style.bg == Some(theme.search_current))
            .map(|s| s.content.as_ref())
            .collect();
        assert_eq!(
            lit, "status",
            "the match stays lit after clipping: {clipped:?}"
        );
    }

    fn diff_app() -> (crate::test_support::Fixture, App) {
        let fixture = standard_fixture();
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.author = "reviewer".to_owned();
        app.open_working_tree_diff(None);
        (fixture, app)
    }

    #[test]
    fn review_layout_sidebar_buckets_viewed_files() {
        let fixture = standard_fixture();
        let mut loaded = LoadedConfig::default();
        loaded.config.ui.diff_file_layout = crate::config::FileLayout::Review;
        let mut app = App::new(fixture.review(), loaded);
        app.author = "reviewer".to_owned();
        app.open_working_tree_diff(None);
        // one viewed file in the folded bucket, two left to review
        app.handle(key('v'));
        insta::assert_snapshot!(render(&mut app).backend());
    }

    /// The selected card has to dim when the pane loses focus, exactly as a
    /// selected file row does, or there is no telling which pane the keys go to.
    #[test]
    fn the_comments_cursor_band_follows_focus_like_the_file_rows() {
        let (_fixture, mut app) = diff_app();
        app.handle(key('l'));
        app.handle(key('j'));
        app.handle(key('c'));
        for c in "a note".chars() {
            app.handle(key(c));
        }
        app.handle(crate::test_support::code_key(
            crossterm::event::KeyCode::Enter,
        ));
        app.handle(key('C'));

        let band_of = |app: &mut App| {
            let terminal = render(app);
            let buffer = terminal.backend().buffer();
            let rect = app.diff.as_ref().expect("diff").comments_rect;
            buffer[(rect.x, rect.y)].style().bg
        };
        let focused = band_of(&mut app);
        // h leaves the comments pane for the diff, keeping the same selection
        app.handle(key('h'));
        let unfocused = band_of(&mut app);

        let theme = &app.theme;
        assert_eq!(focused, Some(super::sidebar_row_bg(theme, true, true)));
        assert_eq!(unfocused, Some(super::sidebar_row_bg(theme, true, false)));
        assert_ne!(focused, unfocused, "the band weakens with focus lost");
    }

    /// The sidebar lights matches with the same two search colours every other
    /// pane uses: the active card's stronger, the rest plain.
    #[test]
    fn searching_the_comments_sidebar_lights_the_matches() {
        let (_fixture, mut app) = diff_app();
        for body in ["the widget leaks", "another widget here"] {
            app.review.session.add_comment(
                diffler_core::session::Anchor {
                    file: "src/lib.rs".to_owned(),
                    line: Some(2),
                    line_end: None,
                    on_old_side: false,
                    line_text: None,
                },
                "reviewer",
                body,
            );
        }
        app.handle(key('C'));
        app.handle(key('/'));
        for c in "widget".chars() {
            app.handle(key(c));
        }

        let terminal = render(&mut app);
        let buffer = terminal.backend().buffer();
        let rect = app.diff.as_ref().expect("diff").comments_rect;
        let painted = |bg| {
            (rect.y..rect.y + rect.height)
                .flat_map(|y| (rect.x..rect.x + rect.width).map(move |x| (x, y)))
                .filter(|at| buffer[*at].style().bg == Some(bg))
                .count()
        };
        assert_eq!(
            painted(app.theme.search_current),
            "widget".len(),
            "the active match is lit stronger"
        );
        assert_eq!(
            painted(app.theme.search),
            "widget".len(),
            "and the other match is lit too"
        );
    }

    #[test]
    fn kinds_layout_sidebar_groups_files_by_what_they_are() {
        let fixture = standard_fixture();
        let mut loaded = LoadedConfig::default();
        loaded.config.ui.diff_file_layout = crate::config::FileLayout::Kinds;
        let mut app = App::new(fixture.review(), loaded);
        app.author = "reviewer".to_owned();
        app.open_working_tree_diff(None);
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn an_against_review_names_its_base_in_the_chip_and_the_pane_title() {
        let fixture = crate::test_support::branch_fixture();
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.open_against_diff("main");
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn review_verdict_picker_renders_its_choices() {
        let (_fixture, mut app) = diff_app();
        let comment = crate::ci::NewPrComment {
            number: 7,
            head_oid: "abc".to_owned(),
            path: "src/lib.rs".to_owned(),
            line: 2,
            start_line: None,
            new_side: true,
            counterpart: None,
            body: "looks off".to_owned(),
        };
        let pending = crate::app::pr::PrPending {
            review_comments: vec![comment.clone(), comment],
            replies: vec![crate::app::pr::PrPost::Reply {
                number: 7,
                comment_id: "c1".to_owned(),
                reply_index: 0,
                parent_remote_id: "r1".to_owned(),
                body: "and this".to_owned(),
            }],
            agent_withheld: 2,
            file_level: 1,
            ..Default::default()
        };
        app.modal = Some(crate::app::Modal::ReviewVerdict {
            number: 7,
            summary: pending.summary(),
        });
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn long_diff_lines_wrap_instead_of_clipping() {
        let fixture = standard_fixture();
        fixture.write(
            "src/lib.rs",
            &format!(
                "pub fn answer() -> u32 {{\n    42 // {}\n}}\n",
                "a very long trailing explanation that cannot possibly fit \
                 in the diff pane at the test terminal width"
                    .repeat(2)
            ),
        );
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.author = "reviewer".to_owned();
        app.open_working_tree_diff(None);
        open_lib_diff(&mut app);
        let terminal = render(&mut app);
        let text = format!("{:?}", terminal.backend().buffer());
        assert!(
            text.contains("terminal width"),
            "the tail of the long line is on screen"
        );
        insta::assert_snapshot!(terminal.backend());
    }

    /// Select src/lib.rs and focus the diff pane.
    fn open_lib_diff(app: &mut App) {
        let index = app
            .diff
            .as_ref()
            .unwrap()
            .model(&app.review)
            .files
            .iter()
            .position(|f| f.path == "src/lib.rs")
            .expect("src/lib.rs present");
        let diff = app.diff.as_mut().unwrap();
        diff.selected = index;
        diff.focus = Pane::Diff;
        diff.invalidate();
        diff.ensure_rows(&app.review);
    }

    fn cursor_to_added_line(app: &mut App) {
        let diff = app.diff.as_ref().unwrap();
        let model = diff.model(&app.review);
        let position = diff
            .rows()
            .iter()
            .position(|row| {
                let DiffRow::Line { file, hunk, line } = row else {
                    return false;
                };
                model.files.get(*file).is_some_and(|f| {
                    f.path == "src/lib.rs"
                        && f.hunks.get(*hunk).is_some_and(|h| {
                            h.lines
                                .get(*line)
                                .is_some_and(|l| l.new_no.is_some() && l.text.contains("42"))
                        })
                })
            })
            .expect("added line");
        app.diff.as_mut().unwrap().cursor = position;
    }

    #[test]
    fn diff_pane_renders_with_syntax_emphasis_and_gutter() {
        // textual engine: it word-diffs the `41`→`42` literal so the emphasis
        // background composites. The syntactic engine treats the whole literal
        // as changed (no partial highlight); that path is covered by the core
        // intraline tests.
        let fixture = standard_fixture();
        let mut loaded = LoadedConfig::default();
        loaded.config.ui.semantic_diff = false;
        let mut app = App::new(fixture.review(), loaded);
        app.author = "reviewer".to_owned();
        app.open_working_tree_diff(None);
        open_lib_diff(&mut app);
        let terminal = render(&mut app);
        // emphasis backgrounds composited over the line backgrounds
        let styles = format!("{:?}", terminal.backend().buffer());
        let add_emph = format!("{:?}", app.theme.add_emph_bg);
        let del_emph = format!("{:?}", app.theme.del_emph_bg);
        assert!(styles.contains(&add_emph), "added emphasis bg rendered");
        assert!(styles.contains(&del_emph), "deleted emphasis bg rendered");
        // the lazy cache highlighted the selected rust file
        let highlights = &app.diff.as_ref().unwrap().highlights;
        let lib = highlights
            .get("src/lib.rs")
            .expect("src/lib.rs highlighted");
        assert!(
            lib.new.iter().any(|line| !line.is_empty()),
            "rust syntax produced styled ranges"
        );
        insta::assert_snapshot!(terminal.backend());
    }

    #[test]
    fn only_the_pane_with_focus_carries_the_bright_cursor_band() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        let band = app.theme.cursor_line;
        let bands = |app: &mut App| {
            let terminal = render(app);
            let buffer = terminal.backend().buffer().clone();
            let diff = app.diff.as_ref().expect("diff view");
            let count = |area: ratatui::layout::Rect| {
                (area.top()..area.bottom())
                    .flat_map(|y| (area.left()..area.right()).map(move |x| (x, y)))
                    .filter(|&(x, y)| buffer.cell((x, y)).is_some_and(|c| c.bg == band))
                    .count()
            };
            (count(diff.sidebar), count(diff.pane))
        };

        let (sidebar, pane) = bands(&mut app);
        assert_eq!(sidebar, 0, "the sidebar gives up its band to the diff pane");
        assert!(
            pane > 0,
            "the focused diff pane holds the bright cursor row"
        );

        app.diff.as_mut().expect("diff view").focus = Pane::List;
        let (sidebar, pane) = bands(&mut app);
        assert!(sidebar > 0, "focus moves the bright band to the sidebar");
        assert_eq!(pane, 0, "and the diff pane gives it up");
    }

    #[test]
    fn comment_range_highlights_its_anchored_lines() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        app.review.session.add_comment(
            diffler_core::session::Anchor {
                file: "src/lib.rs".to_owned(),
                line: Some(1),
                line_end: Some(2),
                on_old_side: false,
                line_text: None,
            },
            "reviewer",
            "this whole block",
        );
        app.diff.as_mut().unwrap().invalidate();
        let terminal = render(&mut app);
        let styles = format!("{:?}", terminal.backend().buffer());
        assert!(
            styles.contains(&format!("{:?}", app.theme.annotated)),
            "a multi-line comment paints its anchored lines with the annotated bg"
        );
    }

    #[test]
    fn side_by_side_pane_renders_old_and_new_columns() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        app.diff.as_mut().unwrap().side_by_side = true;
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn zt_zb_zz_align_the_cursor_row_in_the_viewport() {
        let fixture = standard_fixture();
        let body: String = (0..100).fold(String::new(), |mut s, i| {
            use std::fmt::Write;
            let _ = writeln!(s, "line {i}");
            s
        });
        fixture.write("src/lib.rs", &body);
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.author = "reviewer".to_owned();
        app.open_working_tree_diff(None);
        open_lib_diff(&mut app);
        render(&mut app);

        let mid = app.diff.as_ref().unwrap().rows.len() / 2;
        app.diff.as_mut().unwrap().cursor = mid;

        let pane_row_of = |app: &App, row: usize| {
            app.diff
                .as_ref()
                .unwrap()
                .line_rows
                .iter()
                .position(|r| *r == Some(row))
        };

        // the margin the view keeps applies to the align commands too, as it
        // does in vim: `zt` stops `scrolloff` rows short of the top
        app.handle(key('z'));
        app.handle(key('t'));
        render(&mut app);
        assert_eq!(
            pane_row_of(&app, mid),
            Some(crate::ui::SCROLLOFF),
            "zt puts the cursor at the top, less the margin"
        );

        app.handle(key('z'));
        app.handle(key('b'));
        render(&mut app);
        let viewport = app.diff.as_ref().unwrap().viewport as usize;
        assert_eq!(
            pane_row_of(&app, mid),
            Some(viewport - 1 - crate::ui::SCROLLOFF),
            "zb puts the cursor at the bottom, less the margin"
        );

        app.handle(key('z'));
        app.handle(key('z'));
        render(&mut app);
        let at = pane_row_of(&app, mid).expect("cursor visible after zz");
        let center = viewport / 2;
        assert!(
            at.abs_diff(center) <= 1,
            "zz centers the cursor (row {at}, center {center})"
        );
    }

    #[test]
    fn fenced_code_block_in_a_comment_is_syntax_highlighted() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        app.review.session.add_comment(
            diffler_core::session::Anchor {
                file: "src/lib.rs".to_owned(),
                line: Some(1),
                line_end: None,
                on_old_side: false,
                line_text: None,
            },
            "reviewer",
            "try this:\n```rust\nfn answer() -> u32 { 42 }\n```",
        );
        app.diff.as_mut().unwrap().invalidate();
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn md_span_applies_the_syntax_color() {
        let theme = crate::theme::Theme::github_dark();
        let run = crate::app::markdown::MdSpan {
            text: "fn".to_owned(),
            code: true,
            fg: Some((1, 2, 3)),
            ..Default::default()
        };
        let span = super::md_span(&run, theme.base(), &theme);
        assert_eq!(
            span.style.fg,
            Some(ratatui::style::Color::Rgb(1, 2, 3)),
            "fg overrides the code accent"
        );
    }

    #[test]
    fn markdown_in_a_comment_body_renders_styled() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        app.review.session.add_comment(
            diffler_core::session::Anchor {
                file: "src/lib.rs".to_owned(),
                line: Some(1),
                line_end: None,
                on_old_side: false,
                line_text: None,
            },
            "reviewer",
            "call `foo()` then make it **bold**",
        );
        app.diff.as_mut().unwrap().invalidate();
        let terminal = render(&mut app);
        let buffer = format!("{:?}", terminal.backend().buffer());
        assert!(
            buffer.contains("BOLD"),
            "the **bold** run renders with the bold modifier"
        );
        assert!(
            buffer.contains(&format!("{:?}", app.theme.accent)),
            "the `code` run renders in the accent color"
        );
        insta::assert_snapshot!(terminal.backend());
    }

    /// Sidebar file position on screen for the last render.
    fn sidebar_file_pos(app: &App) -> (u16, u16, usize) {
        let diff = app.diff.as_ref().unwrap();
        let session = app.review.session_for(&diff.source);
        let rows = diff.tree_rows(diff.model(&app.review), session);
        let target = rows
            .iter()
            .position(|r| matches!(r.node, crate::tree::TreeNode::File { .. }))
            .expect("a file row in the sidebar");
        let x = diff.sidebar.x + 1;
        let y = diff.sidebar.y + target as u16 - diff.sidebar_scroll as u16;
        (x, y, target)
    }

    #[test]
    fn single_click_in_the_sidebar_selects_without_focusing() {
        let (_fixture, mut app) = diff_app();
        render(&mut app);
        let (x, y, target) = sidebar_file_pos(&app);
        app.handle(mouse_click(x, y));
        let diff = app.diff.as_ref().unwrap();
        assert_eq!(diff.tree_cursor, target);
        assert_eq!(diff.focus, Pane::List, "single click keeps sidebar focus");
    }

    #[test]
    fn double_click_in_the_sidebar_opens_the_file() {
        let (_fixture, mut app) = diff_app();
        render(&mut app);
        let (x, y, target) = sidebar_file_pos(&app);
        app.handle(mouse_click(x, y));
        app.handle(mouse_click(x, y));
        let diff = app.diff.as_ref().unwrap();
        assert_eq!(diff.tree_cursor, target);
        assert_eq!(diff.focus, Pane::Diff, "double-click opens into the pane");
    }

    #[test]
    fn mouse_wheel_over_the_pane_scrolls_it() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        render(&mut app);
        let before = app.diff.as_ref().unwrap().cursor;
        let pane = app.diff.as_ref().unwrap().pane;
        app.handle(mouse_scroll(true, pane.x + 1, pane.y + 1));
        assert!(
            app.diff.as_ref().unwrap().cursor > before,
            "wheel advanced the pane cursor"
        );
    }

    /// The first two `DiffRow::Line` rows and their on-screen y positions.
    fn first_two_pane_lines(app: &App) -> (u16, u16, u16, usize, usize) {
        let diff = app.diff.as_ref().unwrap();
        let lines: Vec<usize> = diff
            .rows()
            .iter()
            .enumerate()
            .filter(|(_, r)| matches!(r, DiffRow::Line { .. }))
            .map(|(i, _)| i)
            .collect();
        let x = diff.pane.x + 1;
        let y0 = diff.pane.y + lines[0] as u16 - diff.scroll as u16;
        let y1 = diff.pane.y + lines[1] as u16 - diff.scroll as u16;
        (x, y0, y1, lines[0], lines[1])
    }

    #[test]
    fn dragging_in_the_pane_selects_a_line_range() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        render(&mut app);
        let (x, y0, y1, line0, line1) = first_two_pane_lines(&app);
        app.handle(mouse_click(x, y0));
        app.handle(mouse_drag(x, y1));
        let diff = app.diff.as_ref().unwrap();
        assert!(diff.visual_anchor.is_some(), "drag started a selection");
        assert_eq!(diff.selection(), Some((line0, line1)));
    }

    #[test]
    fn double_click_in_the_pane_starts_a_comment() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        render(&mut app);
        let (x, y0, ..) = first_two_pane_lines(&app);
        app.handle(mouse_click(x, y0));
        app.handle(mouse_click(x, y0));
        assert!(app.composer_open(), "double-click opened the composer");
    }

    #[test]
    fn right_click_cancels_a_pane_selection() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        render(&mut app);
        let (x, y0, y1, ..) = first_two_pane_lines(&app);
        app.handle(mouse_click(x, y0));
        app.handle(mouse_drag(x, y1));
        assert!(app.diff.as_ref().unwrap().visual_anchor.is_some());
        app.handle(mouse_right_click(x, y0));
        assert_eq!(
            app.diff.as_ref().unwrap().visual_anchor,
            None,
            "right-click dropped the selection"
        );
    }

    #[test]
    fn sidebar_focus_renders_the_file_list_and_first_file_diff() {
        let (_fixture, mut app) = diff_app();
        assert_eq!(app.diff.as_ref().unwrap().focus, Pane::List);
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn sidebar_search_does_not_bleed_into_the_diff_pane() {
        let (_fixture, mut app) = diff_app();
        assert_eq!(app.diff.as_ref().unwrap().focus, Pane::List);
        // a filename match while the sidebar is focused must not paint the pane
        app.handle(key('/'));
        for c in "lib".chars() {
            app.handle(key(c));
        }
        app.handle(key('\n'));
        let terminal = render(&mut app);
        let buffer = terminal.backend().buffer();
        let sidebar = super::sidebar_width(120);
        let search_bgs = [app.theme.search, app.theme.search_current];
        let mut highlighted = 0;
        for y in 0..40 {
            for x in 0..120 {
                if search_bgs.contains(&buffer[(x, y)].bg) {
                    assert!(
                        x < sidebar,
                        "search bg at col {x} bleeds past sidebar {sidebar}"
                    );
                    highlighted += 1;
                }
            }
        }
        assert!(
            highlighted > 0,
            "the focused sidebar should still highlight the match"
        );
    }

    #[test]
    fn sidebar_file_row_highlights_only_the_matched_substring() {
        let (_fixture, app) = diff_app();
        let file = app
            .review
            .model()
            .files
            .iter()
            .find(|f| f.path == "src/lib.rs")
            .cloned()
            .expect("src/lib.rs present");
        // a wide row so the name is not clipped; "lib" is bytes 0..3 of "lib.rs"
        let rc = super::TreeRowCtx {
            theme: &app.theme,
            depth: 0,
            width: 80,
            on_cursor: false,
            focused: true,
            search: &[(0..3, true)],
        };
        let spans = super::sidebar_file_line(&rc, &file, "lib.rs", false, 0);
        let highlighted: Vec<&str> = spans
            .iter()
            .filter(|s| s.style.bg == Some(app.theme.search_current))
            .map(|s| s.content.as_ref())
            .collect();
        assert_eq!(
            highlighted,
            vec!["lib"],
            "only the matched word lights up, not the whole row: {spans:?}"
        );
    }

    /// A group's diffstat is the diff it holds, so a header says how big the
    /// directory or kind is without unfolding it.
    #[test]
    fn a_directory_header_sums_the_diffstat_of_every_file_below_it() {
        let fixture = crate::test_support::Fixture::new();
        fixture.write("src/a.rs", "one\n");
        fixture.write("src/nested/b.rs", "one\n");
        fixture.write("top.txt", "one\n");
        fixture.commit_all("base");
        // 2 added 1 deleted under src/, split across two depths
        fixture.write("src/a.rs", "two\n");
        fixture.write("src/nested/b.rs", "one\nthree\n");
        fixture.write("top.txt", "one\nfour\n");
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.open_working_tree_diff(None);

        let diff = app.diff.as_ref().expect("diff view");
        let session = app.review.session_for(&diff.source);
        let stat = super::GroupStat::collect(diff, diff.model(&app.review), session);
        assert_eq!(stat.dirs.get("src"), Some(&(2, 1)));
        assert_eq!(stat.dirs.get("src/nested"), Some(&(1, 0)));
        assert_eq!(stat.dirs.get("top.txt"), None, "a file is not a group");

        let text = render(&mut app).backend().to_string();
        assert!(text.contains("+2 -1"), "src/ shows its diffstat: {text}");
    }

    #[test]
    fn a_kind_header_sums_the_diffstat_of_its_bucket() {
        let fixture = crate::test_support::Fixture::new();
        fixture.write("src/a.rs", "one\n");
        fixture.write("src/b.rs", "one\n");
        fixture.write("README.md", "docs\n");
        fixture.commit_all("base");
        fixture.write("src/a.rs", "one\ntwo\n");
        fixture.write("src/b.rs", "one\nthree\n");
        fixture.write("README.md", "more docs\n");
        let mut loaded = LoadedConfig::default();
        loaded.config.ui.diff_file_layout = crate::config::FileLayout::Kinds;
        let mut app = App::new(fixture.review(), loaded);
        app.open_working_tree_diff(None);
        let text = render(&mut app).backend().to_string();
        assert!(
            text.contains("Source (2)") && text.contains("+2 -0"),
            "Source keeps its count and adds up its files: {text}"
        );
        assert!(text.contains("Docs (1)"), "the count stays: {text}");
    }

    /// The review layout groups by viewed state, so its headers say how much
    /// diff is still to read.
    #[test]
    fn the_review_buckets_split_the_diffstat_by_what_is_left() {
        let fixture = crate::test_support::Fixture::new();
        fixture.write("a.rs", "one\n");
        fixture.write("b.rs", "one\n");
        fixture.commit_all("base");
        fixture.write("a.rs", "one\ntwo\n");
        fixture.write("b.rs", "one\nthree\nfour\n");
        let mut loaded = LoadedConfig::default();
        loaded.config.ui.diff_file_layout = crate::config::FileLayout::Review;
        let mut app = App::new(fixture.review(), loaded);
        app.open_working_tree_diff(None);
        let hash = app
            .review
            .model()
            .files
            .iter()
            .find(|file| file.path == "a.rs")
            .expect("a.rs in the diff")
            .content_hash();
        app.review
            .session_for_mut(&super::ReviewSource::WorkingTree)
            .mark_viewed("a.rs", &hash);

        let diff = app.diff.as_ref().expect("diff view");
        let session = app.review.session_for(&diff.source);
        let stat = super::GroupStat::collect(diff, diff.model(&app.review), session);
        assert_eq!(stat.sections.get(&super::Bucket::Viewed), Some(&(1, 0)));
        assert_eq!(stat.sections.get(&super::Bucket::ToReview), Some(&(2, 0)));
    }

    #[test]
    fn a_group_header_carries_the_same_diffstat_colors_as_a_file_row() {
        let theme = Theme::github_dark();
        let rc = super::TreeRowCtx {
            theme: &theme,
            depth: 0,
            width: 40,
            on_cursor: false,
            focused: true,
            search: &[],
        };
        let spans = super::sidebar_dir_line(&rc, "src", false, (3, 1));
        let painted: Vec<(&str, Option<super::Color>)> = spans
            .spans
            .iter()
            .filter(|span| span.content.contains('+') || span.content.contains('-'))
            .map(|span| (span.content.as_ref(), span.style.fg))
            .collect();
        assert_eq!(
            painted,
            vec![(" +3", Some(theme.added)), (" -1", Some(theme.error_fg))],
            "the header reuses the file row's diffstat, no bar: {spans:?}"
        );
    }

    #[test]
    fn sidebar_scrolls_to_keep_the_cursor_visible() {
        let fixture = crate::test_support::Fixture::new();
        fixture.write(".keep", "x\n");
        fixture.commit_all("base");
        for i in 0..40 {
            fixture.write(&format!("f{i:02}.txt"), "x\n");
        }
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.open_working_tree_diff(None);
        let count = {
            let diff = app.diff.as_ref().unwrap();
            let session = app.review.session_for(&diff.source);
            diff.tree_rows(diff.model(&app.review), session).len()
        };
        app.diff.as_mut().unwrap().tree_cursor = count - 1;

        let backend = TestBackend::new(120, 14);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| crate::ui::draw(frame, &mut app))
            .expect("draw");
        let content = terminal.backend().to_string();
        assert!(content.contains("f39.txt"), "cursor row visible: {content}");
        // f01 lives only in the sidebar (f00 is the selected file shown in the
        // pane header), so its absence proves the sidebar scrolled past the top
        assert!(
            !content.contains("f01.txt"),
            "top rows scrolled off: {content}"
        );
    }

    #[test]
    fn commit_diff_opened_from_the_log_renders() {
        let (_fixture, mut app) = diff_app();
        // back to status, into the log, open the only commit
        app.handle(key('q'));
        app.handle(key('l'));
        app.handle(key('l'));
        app.handle(key('\n'));
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn range_diff_from_the_log_renders_with_a_range_header() {
        let fixture = standard_fixture();
        fixture.write("notes.txt", "alpha\nbeta\n");
        fixture.commit_all("add beta note");
        fixture.write(
            "src/util.rs",
            "pub fn twice(x: u32) -> u32 {\n    x * 2\n}\n",
        );
        fixture.commit_all("add util module");
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        // into the log, select the two newest commits, open the combined diff
        app.handle(key('l'));
        app.handle(key('l'));
        app.handle(key('V'));
        app.handle(key('j'));
        app.handle(key('\n'));
        let content = render(&mut app).backend().to_string();
        // the pane title carries the oldest7..newest7 span
        assert!(
            content.contains(".."),
            "range header shows a span: {content}"
        );
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn comment_blocks_render_open_and_replied_threads() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        cursor_to_added_line(&mut app);
        app.handle(key('c'));
        for c in "why 42?".chars() {
            app.handle(key(c));
        }
        app.handle(key('\n'));
        let answered = app
            .review
            .session
            .add_comment(
                diffler_core::session::Anchor {
                    file: "src/lib.rs".to_owned(),
                    line: Some(1),
                    line_end: None,
                    on_old_side: false,
                    line_text: Some("pub fn answer() -> u32 {".to_owned()),
                },
                "reviewer",
                "rename this?",
            )
            .id
            .clone();
        app.review
            .session
            .reply(&answered, "agent", "kept for api compat");
        app.diff.as_mut().unwrap().invalidate();
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn a_markdown_table_in_a_comment_renders_as_columns() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        app.review.session.add_comment(
            diffler_core::session::Anchor {
                file: "src/lib.rs".to_owned(),
                line: Some(2),
                line_end: None,
                on_old_side: false,
                line_text: Some("    42".to_owned()),
            },
            "reviewer",
            "| Between | Ordering | What a failure costs |\n\
             |---|---|---|\n\
             | Two customers | Parallel | Nothing, one lane throwing leaves the others |\n\
             | Postgres, then the sinks | Strictly ordered | No sink hears anything this run |",
        );
        app.diff.as_mut().unwrap().invalidate();
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn long_comment_text_wraps_to_the_pane_width() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        let id = app
            .review
            .session
            .add_comment(
                diffler_core::session::Anchor {
                    file: "src/lib.rs".to_owned(),
                    line: Some(2),
                    line_end: None,
                    on_old_side: false,
                    line_text: Some("    42".to_owned()),
                },
                "reviewer",
                "a negative or far too large answer breaks the callers downstream, \
                 so this needs a clamp before it ships to anyone",
            )
            .id
            .clone();
        app.review.session.reply(
            &id,
            "agent",
            "agreed, clamping to the documented range and adding a regression \
             test so the next refactor cannot silently drop it",
        );
        app.diff.as_mut().unwrap().invalidate();
        let terminal = render(&mut app);
        insta::assert_snapshot!(terminal.backend());
    }

    #[test]
    fn visual_selection_highlights_the_selected_rows() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        cursor_to_added_line(&mut app);
        app.handle(key('V'));
        app.handle(key('j'));
        assert!(app.diff.as_ref().unwrap().visual_anchor.is_some());
        let terminal = render(&mut app);
        let lit = terminal.backend().buffer().clone();
        app.diff.as_mut().unwrap().visual_anchor = None;
        let plain = render(&mut app).backend().buffer().clone();
        // the visual range repaints rows the bare cursor leaves alone
        let repainted = lit
            .content
            .iter()
            .zip(plain.content.iter())
            .filter(|(a, b)| a.bg != b.bg)
            .count();
        assert!(repainted > 0, "selection must paint extra rows");
        insta::assert_snapshot!(terminal.backend());
    }

    #[test]
    fn viewed_file_shows_a_check_and_comment_count_in_the_sidebar() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        cursor_to_added_line(&mut app);
        // a comment on the selected file
        app.handle(key('c'));
        for c in "look".chars() {
            app.handle(key(c));
        }
        app.handle(key('\n'));
        // mark src/lib.rs viewed without advancing the selection off it
        let hash = app
            .review
            .model()
            .files
            .iter()
            .find(|f| f.path == "src/lib.rs")
            .map(diffler_core::model::FileDiff::content_hash)
            .unwrap();
        app.review.session.mark_viewed("src/lib.rs", &hash);
        app.diff.as_mut().unwrap().invalidate();
        let terminal = render(&mut app);
        let content = terminal.backend().to_string();
        assert!(
            content.contains("✓"),
            "viewed check in the sidebar: {content}"
        );
        assert!(
            content.contains("·1"),
            "comment count in the sidebar: {content}"
        );
        insta::assert_snapshot!(terminal.backend());
    }

    #[test]
    fn file_header_shows_resolved_when_all_comments_are_resolved() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        cursor_to_added_line(&mut app);
        app.handle(key('c'));
        for c in "why 42?".chars() {
            app.handle(key(c));
        }
        app.handle(key('\n'));
        let id = app.review.session.comments[0].id.clone();
        app.review.session.resolve(&id);
        app.diff.as_mut().unwrap().invalidate();
        let terminal = render(&mut app);
        let content = terminal.backend().to_string();
        assert!(
            content.contains("· resolved"),
            "all-resolved file shows the marker: {content}"
        );
        assert!(
            !content.contains("1 comment"),
            "resolved comments do not count: {content}"
        );
        insta::assert_snapshot!(terminal.backend());
    }

    #[test]
    fn status_bar_shows_viewed_progress() {
        let (_fixture, mut app) = diff_app();
        let content = render(&mut app).backend().to_string();
        assert!(content.contains("viewed 0/3 files"), "{content}");
        app.handle(key('v'));
        let content = render(&mut app).backend().to_string();
        assert!(content.contains("viewed 1/3 files"), "{content}");
    }

    #[test]
    fn viewed_walk_advances_the_selection() {
        let (_fixture, mut app) = diff_app();
        app.handle(key('v'));
        app.handle(key('v'));
        // two files viewed; the sidebar cursor sits on the last unviewed
        // file, progress reads 2/3
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn markdown_diff_highlights_headings_and_inline_code() {
        let fixture = Fixture::new();
        fixture.write(
            "notes.md",
            "# Recording notes\n\nUse `record()` and set **duration** first.\n\n1. call `search(q)`\n2. share it\n",
        );
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.open_working_tree_file("notes.md");
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn expand_whole_file_reveals_the_unchanged_lines() {
        use std::fmt::Write as _;
        let fixture = Fixture::new();
        let mut base = String::new();
        for i in 1..=10 {
            let _ = writeln!(base, "line {i}");
        }
        fixture.write("notes.txt", &base);
        fixture.commit_all("base");
        fixture.write("notes.txt", &base.replace("line 5\n", "LINE FIVE\n"));
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.open_working_tree_file("notes.txt");
        app.handle(key('='));
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn expansion_reflows_rows_after_a_refresh_re_enriches() {
        use std::fmt::Write as _;
        let fixture = Fixture::new();
        let mut base = String::new();
        for i in 1..=40 {
            let _ = writeln!(base, "line {i}");
        }
        fixture.write("a.txt", &base);
        fixture.commit_all("base");
        fixture.write("a.txt", &base.replace("line 20\n", "LINE TWENTY\n"));
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.open_working_tree_file("a.txt");

        app.handle(key('=')); // whole-file expand
        let _ = render(&mut app); // draw the expanded rows, clearing rows_dirty

        // a real content change (as a watcher refresh would see) rebuilds the
        // model at default context; the per-file override lives on the view
        fixture.write("a.txt", &base.replace("line 30\n", "LINE THIRTY\n"));
        app.handle(crate::event::AppEvent::RepoChanged);
        app.settle_refresh();
        app.queue_enrich_selected(); // enrichment reinstalls the override in on_enriched
        let _ = render(&mut app);

        let rows = app.diff.as_ref().expect("diff").rows().len();
        assert!(
            rows > 20,
            "rows re-flow to the whole file after re-enrich, got {rows}"
        );
    }

    #[test]
    fn diff_pane_renders_a_sliding_window_and_jumps_to_extremes() {
        // a single file with ~2000 lines: the model dwarfs the viewport
        let fixture = Fixture::new();
        let lines: Vec<String> = (1..=2000).map(|i| format!("line {i}")).collect();
        fixture.write("big.txt", &(lines.join("\n") + "\n"));
        fixture.commit_all("initial commit");
        let edited: Vec<String> = (1..=2000)
            .map(|i| {
                if i % 10 == 0 {
                    format!("edited {i}")
                } else {
                    format!("line {i}")
                }
            })
            .collect();
        fixture.write("big.txt", &(edited.join("\n") + "\n"));

        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.open_working_tree_file("big.txt");
        let total_rows = app.diff.as_ref().unwrap().rows().len();
        assert!(
            total_rows > 200,
            "the model must dwarf the viewport: {total_rows}"
        );

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).expect("terminal");
        let mut render = |app: &mut App| {
            terminal
                .draw(|frame| crate::ui::draw(frame, app))
                .expect("draw");
            terminal.backend().to_string()
        };

        let top = render(&mut app);
        assert!(top.contains("line 1"), "{top}");
        app.enrich_now();
        let diff = app.diff.as_ref().unwrap();
        assert_eq!(diff.scroll, 0);
        assert!(diff.highlights.contains_key("big.txt"));

        // G: the cursor lands on the last row and the window slides down
        app.handle(key('G'));
        assert_eq!(app.diff.as_ref().unwrap().cursor, total_rows - 1);
        let bottom = render(&mut app);
        let diff = app.diff.as_ref().unwrap();
        let viewport = usize::from(diff.viewport);
        assert!(viewport < total_rows, "sanity: window smaller than model");
        assert_eq!(
            diff.scroll,
            total_rows - viewport,
            "scroll pins the cursor to the last body row"
        );
        assert!(bottom.contains("2000"), "tail row visible: {bottom}");

        // gg: back to the first row, the window slides up on the next render
        app.handle(key('g'));
        app.handle(key('g'));
        assert_eq!(app.diff.as_ref().unwrap().cursor, 0);
        let top_again = render(&mut app);
        assert!(top_again.contains("line 1"), "{top_again}");
        assert_eq!(app.diff.as_ref().unwrap().scroll, 0);
    }

    #[test]
    fn a_click_cannot_reach_past_an_open_composer() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        cursor_to_added_line(&mut app);
        app.handle(key('c'));
        for c in "half written".chars() {
            app.handle(key(c));
        }
        render(&mut app);
        // the gesture that opens a fresh composer over this one, and the one
        // that would switch files out from under it
        let (x, y0, ..) = first_two_pane_lines(&app);
        app.handle(mouse_click(x, y0));
        app.handle(mouse_click(x, y0));
        app.handle(mouse_click(1, 3));
        let composer = app
            .diff
            .as_ref()
            .and_then(|d| d.composer.as_ref())
            .expect("the draft survives stray clicks");
        assert_eq!(composer.buffer, "half written");
    }

    #[test]
    fn a_file_level_composer_renders_at_the_top_of_the_pane() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        app.diff.as_mut().unwrap().focus = Pane::List;
        app.handle(key('c'));
        for c in "applies to the whole file".chars() {
            app.handle(key(c));
        }
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn a_reply_composer_renders_under_the_thread_it_answers() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        cursor_to_added_line(&mut app);
        app.handle(key('c'));
        for c in "why?".chars() {
            app.handle(key(c));
        }
        app.handle(key('\n'));
        let line = app.diff.as_ref().unwrap().cursor;
        app.diff.as_mut().unwrap().cursor = line;
        app.handle(key('r'));
        for c in "because".chars() {
            app.handle(key(c));
        }
        insta::assert_snapshot!(render(&mut app).backend());
    }

    #[test]
    fn the_composer_renders_under_the_line_it_comments_on() {
        let (_fixture, mut app) = diff_app();
        open_lib_diff(&mut app);
        cursor_to_added_line(&mut app);
        app.handle(key('c'));
        for c in "why".chars() {
            app.handle(key(c));
        }
        insta::assert_snapshot!(render(&mut app).backend());
    }
}
