use ratatui::{
    Frame,
    layout::Rect,
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph},
};
use unicode_width::UnicodeWidthStr;

use crate::app::{
    App, DiffSource, ExpandDirection, FocusedPanel, GAP_EXPAND_BATCH, GapId, InputMode,
};
use crate::model::{DiffLine, FileStatus, LineOrigin, LineRange, LineSide};
use crate::theme::Theme;
use crate::ui::comment_panel;
use crate::ui::diff_view::{
    apply_horizontal_scroll, comment_type_presentation, cursor_indicator, cursor_indicator_spaced,
    diff_stat_title, hunk_header_text_and_style, paint_cursor_line_highlight,
    paint_visual_selection_overlay, populate_row_to_annotation, render_expander_line,
    render_hidden_lines, scroll_comment_input_into_view,
};
use crate::ui::styles;
use crate::ui::text_utils::{truncate_or_pad, truncate_or_pad_spans, wrap_spans};
use crate::vcs::git::calculate_gap;

#[derive(Clone, Default)]
struct SbsRowMeta {
    left_content: Vec<Span<'static>>,
    right_content: Vec<Span<'static>>,
    left_prefix: Vec<Span<'static>>,
    right_prefix: Vec<Span<'static>>,
    left_pad_style: Style,
    right_pad_style: Style,
}

fn content_spans_for_diff_line(
    theme: &Theme,
    dl: &DiffLine,
    origin: LineOrigin,
) -> Vec<Span<'static>> {
    let base = match origin {
        LineOrigin::Context => styles::diff_context_style(theme),
        LineOrigin::Addition => styles::diff_add_style(theme),
        LineOrigin::Deletion => styles::diff_del_style(theme),
    };
    if let Some(ref h) = dl.highlighted_spans {
        h.iter().map(|(s, t)| Span::styled(t.clone(), *s)).collect()
    } else {
        vec![Span::styled(dl.content.clone(), base)]
    }
}

fn column_pad_style(theme: &Theme, dl: &DiffLine, origin: LineOrigin) -> Style {
    match origin {
        LineOrigin::Context => styles::diff_context_style(theme),
        LineOrigin::Addition => {
            if dl.highlighted_spans.is_some() {
                Style::default().fg(theme.diff_add).bg(theme.syntax_add_bg)
            } else {
                styles::diff_add_style(theme)
            }
        }
        LineOrigin::Deletion => {
            if dl.highlighted_spans.is_some() {
                Style::default().fg(theme.diff_del).bg(theme.syntax_del_bg)
            } else {
                styles::diff_del_style(theme)
            }
        }
    }
}

fn pad_spans_to_width(
    mut spans: Vec<Span<'static>>,
    width: usize,
    pad_style: Style,
) -> Vec<Span<'static>> {
    let cur: usize = spans.iter().map(|s| s.content.width()).sum();
    if cur < width {
        spans.push(Span::styled(" ".repeat(width - cur), pad_style));
    }
    spans
}

struct SideSpec {
    lineno: Option<u32>,
    marker: &'static str,
    marker_style: Style,
}

fn sbs_row_prefixes(
    theme: &Theme,
    indicator: &'static str,
    left: SideSpec,
    right: SideSpec,
    lw: usize,
) -> (Vec<Span<'static>>, Vec<Span<'static>>) {
    let dim = styles::dim_style(theme);
    let old_num = left
        .lineno
        .map(|n| format!("{n:>lw$}"))
        .unwrap_or_else(|| " ".repeat(lw));
    let new_num = right
        .lineno
        .map(|n| format!("{n:>lw$}"))
        .unwrap_or_else(|| " ".repeat(lw));

    let left_prefix = vec![
        Span::styled(indicator, styles::current_line_indicator_style(theme)),
        Span::styled(format!("{old_num} "), dim),
        Span::styled(left.marker.to_string(), left.marker_style),
    ];
    let right_prefix = vec![
        Span::styled(" │ ", dim),
        Span::styled(format!("{new_num} "), dim),
        Span::styled(right.marker.to_string(), right.marker_style),
    ];
    (left_prefix, right_prefix)
}

/// Continuation-row prefixes shared by every wrapped line: blank in place of
/// the line numbers (same width, so columns stay aligned) with the center
/// divider preserved.
fn sbs_blank_prefixes(theme: &Theme, lw: usize) -> (Vec<Span<'static>>, Vec<Span<'static>>) {
    let dim = styles::dim_style(theme);
    let left = vec![Span::styled(" ".repeat(lw + 3), Style::default())];
    let right = vec![
        Span::styled(" │ ", dim),
        Span::styled(" ".repeat(lw + 2), Style::default()),
    ];
    (left, right)
}

/// Cursor info for the inline comment input box in side-by-side view:
/// (cursor_logical_line, cursor_column, box_start_line, box_end_line)
type SideBySideCursorInfo = (usize, u16, usize, usize, usize);

/// Context for rendering side-by-side diff lines
struct SideBySideContext<'a> {
    app: &'a App,
    theme: &'a Theme,
    content_width: usize,
    panel_width: usize,
    current_line_idx: usize,
    lineno_width: usize,
    // Comment input state for inline editing
    comment_input_mode: bool,
    comment_line: Option<(u32, LineSide)>,
    comment_type: crate::model::CommentType,
    comment_buffer: &'a str,
    comment_cursor: usize,
    comment_line_range: Option<LineRange>,
    editing_comment_id: Option<&'a str>,
    current_file_idx: usize,
    // RefCell so deeply-nested rendering helpers can push without each
    // intermediate function needing a `&mut Vec` parameter threaded through.
    comment_bars: std::cell::RefCell<Vec<crate::ui::diff_view::CommentBarAnchor>>,
    sbs_meta: std::cell::RefCell<std::collections::HashMap<usize, SbsRowMeta>>,
    // Only fully build spans for diff lines whose `line_idx` falls in this
    // half-open range; off-screen rows push `Line::default()` placeholders.
    visible_start: usize,
    visible_end: usize,
}

impl SideBySideContext<'_> {
    fn is_visible(&self, line_idx: usize) -> bool {
        line_idx >= self.visible_start && line_idx < self.visible_end
    }

    fn display_lineno(&self, source_line: Option<u32>, line_idx: usize) -> Option<u32> {
        source_line.map(|line| {
            if self.app.relative_line_numbers {
                line_idx.abs_diff(self.current_line_idx) as u32
            } else {
                line
            }
        })
    }
}

pub(super) fn render_side_by_side_diff(frame: &mut Frame, app: &mut App, area: Rect) {
    let focused = app.focused_panel == FocusedPanel::Diff;

    let title = crate::ui::diff_view::diff_title(app, area.width);

    let block = Block::default()
        .title(title)
        .title_top(diff_stat_title(app).right_aligned())
        .borders(Borders::ALL)
        .style(styles::panel_style(&app.theme))
        .border_style(styles::border_style(&app.theme, focused));

    let inner = block.inner(area);
    frame.render_widget(block, area);

    // Update viewport height for scroll calculations
    app.diff_state.viewport_height = inner.height as usize;
    app.diff_inner_area = Some(inner);

    // Reset comment input annotation offset (will be set if a comment input box is rendered)
    app.comment_input_annotation_offset = None;

    let lw = app.lineno_width();
    let available_width = inner.width.saturating_sub(crate::app::sbs_overhead(lw)) as usize;
    let content_width = available_width / 2;

    // Determine if we're in line comment mode (not file-level)
    let comment_input_mode = app.input_mode == InputMode::Comment
        && !app.comment_is_file_level
        && !app.comment_is_review_level;

    let (visible_start, visible_end) = crate::ui::diff_view::diff_visible_range(app, inner);

    let ctx = SideBySideContext {
        app,
        theme: &app.theme,
        content_width,
        panel_width: inner.width as usize,
        current_line_idx: app.diff_state.cursor_line,
        lineno_width: lw,
        comment_input_mode,
        comment_line: app.comment_line,
        comment_type: app.comment_type.clone(),
        comment_buffer: &app.comment_buffer,
        comment_cursor: app.comment_cursor,
        comment_line_range: app.comment_line_range.map(|(r, _)| r),
        editing_comment_id: app.editing_comment_id.as_deref(),
        current_file_idx: app.diff_state.current_file_idx,
        comment_bars: std::cell::RefCell::new(Vec::new()),
        sbs_meta: std::cell::RefCell::new(std::collections::HashMap::new()),
        visible_start,
        visible_end,
    };

    // Build all diff lines for side-by-side view
    let mut lines: Vec<Line> = Vec::new();
    let mut line_idx: usize = 0;

    // Track cursor position for IME when in Comment mode
    let mut comment_cursor_logical_line: Option<usize> = None;
    let mut comment_cursor_column: u16 = 0;
    // Track the full extent of the comment input box so we can auto-scroll
    // the viewport to keep it visible while the user types.
    let mut comment_input_box_range: Option<(usize, usize)> = None;
    let mut annotation_offset: Option<(usize, usize, usize)> = None;

    let is_review_comment_mode =
        app.input_mode == InputMode::Comment && app.comment_is_review_level;

    crate::ui::pr_info_panel::append_pr_info_section(
        app,
        &mut lines,
        &mut line_idx,
        ctx.current_line_idx,
    );

    // The `═══ Review Comments ═══` label is redundant in single-file
    // view -- see the matching guard in `src/ui/diff_unified.rs`.
    if !app.is_single_file_view {
        let general_indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
        lines.push(Line::from(vec![
            Span::styled(
                general_indicator,
                styles::current_line_indicator_style(&app.theme),
            ),
            Span::styled(
                crate::ui::diff_view::REVIEW_COMMENTS_HEADER_PREFIX,
                styles::file_header_style(&app.theme),
            ),
            Span::styled(
                crate::ui::diff_view::HEADER_RULE,
                styles::file_header_style(&app.theme),
            ),
        ]));
        line_idx += 1;
    }

    for summary in &app.forge_review_summaries {
        let summary_lines = comment_panel::format_remote_review_summary_lines(
            &app.theme,
            summary,
            app.forge_kind(),
        );
        for mut summary_line in summary_lines {
            let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
            summary_line.spans.insert(
                0,
                Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
            );
            lines.push(summary_line);
            line_idx += 1;
        }
    }

    for comment in &app.session.review_comments {
        let is_being_edited =
            app.editing_comment_id.as_ref() == Some(&comment.id) && is_review_comment_mode;

        if is_being_edited {
            let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
                &app.theme,
                comment_type_presentation(app, &app.comment_type),
                &app.comment_buffer,
                app.comment_cursor,
                None,
                true,
                ctx.panel_width.saturating_sub(1),
                app.comment_vim_mode_label()
                    .as_ref()
                    .map(|(t, w)| (t.as_str(), *w)),
            );
            comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
            comment_cursor_column = 1 + cursor_info.column;
            comment_input_box_range =
                Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
            let annotations_replaced = App::comment_display_lines(comment, inner.width as usize);
            annotation_offset = Some((line_idx, input_lines.len(), annotations_replaced));

            for mut input_line in input_lines {
                let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
                input_line.spans.insert(
                    0,
                    Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
                );
                lines.push(input_line);
                line_idx += 1;
            }
        } else {
            let comment_lines = comment_panel::format_comment_lines(
                &app.theme,
                comment_type_presentation(app, &comment.comment_type),
                &comment.content,
                None,
                ctx.panel_width.saturating_sub(1),
                (comment.author != app.username).then_some(comment.author.as_str()),
            );
            for mut comment_line in comment_lines {
                let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
                comment_line.spans.insert(
                    0,
                    Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
                );
                lines.push(comment_line);
                line_idx += 1;
            }
        }
    }

    // Render remote review-level threads (general MR notes, line: None).
    {
        use crate::forge::remote_comments::{PrCommentsVisibility, RemoteCommentSide};
        let _ = RemoteCommentSide::Right; // ensure import is used
        let visibility = app.session.remote_comments_visibility;
        if !matches!(visibility, PrCommentsVisibility::Hide) {
            for thread in &app.forge_review_threads {
                if thread.line.is_some() {
                    continue; // inline threads are rendered in-diff
                }
                let Some(muted) = visibility.render_decision(thread) else {
                    continue;
                };
                let thread_lines = comment_panel::format_remote_thread_lines(
                    &app.theme,
                    thread,
                    muted,
                    app.forge_kind(),
                );
                for mut comment_line in thread_lines {
                    let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
                    comment_line.spans.insert(
                        0,
                        Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
                    );
                    lines.push(comment_line);
                    line_idx += 1;
                }
            }
        }
    }

    if is_review_comment_mode && app.editing_comment_id.is_none() {
        let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
            &app.theme,
            comment_type_presentation(app, &app.comment_type),
            &app.comment_buffer,
            app.comment_cursor,
            None,
            false,
            ctx.panel_width.saturating_sub(1),
            app.comment_vim_mode_label()
                .as_ref()
                .map(|(t, w)| (t.as_str(), *w)),
        );
        comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
        comment_cursor_column = 1 + cursor_info.column;
        comment_input_box_range = Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
        annotation_offset = Some((line_idx, input_lines.len(), 0));

        for mut input_line in input_lines {
            let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
            input_line.spans.insert(
                0,
                Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
            );
            lines.push(input_line);
            line_idx += 1;
        }
    }

    crate::ui::pr_info_panel::append_issue_comments_section(
        app,
        &mut lines,
        &mut line_idx,
        ctx.current_line_idx,
        ctx.panel_width.saturating_sub(1),
    );

    for (file_idx, file) in app.diff_files.iter().enumerate() {
        // Single-file view: hide everything except the cursor's file. See
        // src/ui/diff_unified.rs for the matching guard.
        if app.is_single_file_view && file_idx != app.diff_state.current_file_idx {
            continue;
        }
        // See the matching filter guard in src/ui/diff_unified.rs.
        if !app.file_passes_filter(file) {
            continue;
        }
        let path = file.display_path();
        let is_reviewed = app.session.is_file_reviewed(path);

        if !app.is_single_file_view {
            let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
            let header_text = crate::ui::diff_view::file_header_prefix_text(app, file);
            lines.push(Line::from(vec![
                Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
                Span::styled(header_text, styles::file_header_style(&app.theme)),
                Span::styled(
                    crate::ui::diff_view::HEADER_RULE,
                    styles::file_header_style(&app.theme),
                ),
            ]));
            line_idx += 1;
        }

        // If file is reviewed (and we're in multi-file view), skip the
        // body. Single-file view keeps the focused file visible under a
        // dimmed banner.
        if is_reviewed && !app.is_single_file_view {
            continue;
        }
        if is_reviewed && app.is_single_file_view {
            let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
            lines.push(Line::from(vec![
                Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
                Span::styled(
                    crate::ui::diff_view::REVIEWED_BANNER_TEXT,
                    Style::default()
                        .fg(app.theme.fg_secondary)
                        .add_modifier(Modifier::DIM),
                ),
            ]));
            line_idx += 1;
        }

        // Check if we're editing/adding a file-level comment for this file
        let is_file_comment_mode = app.input_mode == InputMode::Comment
            && app.comment_is_file_level
            && file_idx == app.diff_state.current_file_idx;

        // Show file-level comments
        if let Some(review) = app.session.files.get(path) {
            for comment in &review.file_comments {
                if !app.comment_visible(comment) {
                    continue;
                }
                // Skip rendering this comment if it's being edited
                let is_being_edited =
                    app.editing_comment_id.as_ref() == Some(&comment.id) && is_file_comment_mode;

                if is_being_edited {
                    // Render the inline input instead
                    let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
                        &app.theme,
                        comment_type_presentation(app, &app.comment_type),
                        &app.comment_buffer,
                        app.comment_cursor,
                        None,
                        true,
                        ctx.panel_width.saturating_sub(1),
                        app.comment_vim_mode_label()
                            .as_ref()
                            .map(|(t, w)| (t.as_str(), *w)),
                    );
                    comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
                    comment_cursor_column = 1 + cursor_info.column;
                    comment_input_box_range =
                        Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
                    let annotations_replaced =
                        App::comment_display_lines(comment, inner.width as usize);
                    annotation_offset = Some((line_idx, input_lines.len(), annotations_replaced));

                    for mut input_line in input_lines {
                        let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
                        input_line.spans.insert(
                            0,
                            Span::styled(
                                indicator,
                                styles::current_line_indicator_style(&app.theme),
                            ),
                        );
                        lines.push(input_line);
                        line_idx += 1;
                    }
                } else {
                    let comment_lines = comment_panel::format_comment_lines(
                        &app.theme,
                        comment_type_presentation(app, &comment.comment_type),
                        &comment.content,
                        None,
                        ctx.panel_width.saturating_sub(1),
                        (comment.author != app.username).then_some(comment.author.as_str()),
                    );
                    for mut comment_line in comment_lines {
                        let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
                        comment_line.spans.insert(
                            0,
                            Span::styled(
                                indicator,
                                styles::current_line_indicator_style(&app.theme),
                            ),
                        );
                        lines.push(comment_line);
                        line_idx += 1;
                    }
                }
            }
        }

        // Render inline input for new file-level comment
        if is_file_comment_mode && app.editing_comment_id.is_none() {
            let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
                &app.theme,
                comment_type_presentation(app, &app.comment_type),
                &app.comment_buffer,
                app.comment_cursor,
                None,
                false,
                ctx.panel_width.saturating_sub(1),
                app.comment_vim_mode_label()
                    .as_ref()
                    .map(|(t, w)| (t.as_str(), *w)),
            );
            comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
            comment_cursor_column = 1 + cursor_info.column;
            comment_input_box_range =
                Some((line_idx, line_idx + input_lines.len().saturating_sub(1)));
            annotation_offset = Some((line_idx, input_lines.len(), 0));

            for mut input_line in input_lines {
                let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
                input_line.spans.insert(
                    0,
                    Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
                );
                lines.push(input_line);
                line_idx += 1;
            }
        }

        if file.is_too_large || file.is_binary || file.hunks.is_empty() {
            let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
            lines.push(Line::from(vec![
                Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
                Span::styled(
                    crate::ui::diff_view::binary_or_empty_label(file),
                    styles::dim_style(&app.theme),
                ),
            ]));
            line_idx += 1;
        } else {
            let line_comments = app
                .session
                .files
                .get(path)
                .map(|r| &r.line_comments)
                .unwrap_or(&crate::ui::diff_view::EMPTY_LINE_COMMENTS);

            for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
                // Calculate and render gap before this hunk
                let prev_hunk = if hunk_idx > 0 {
                    file.hunks.get(hunk_idx - 1)
                } else {
                    None
                };
                let gap = calculate_gap(
                    prev_hunk.map(|h| (&h.new_start, &h.new_count)),
                    hunk.new_start,
                );

                let gap_id = GapId { file_idx, hunk_idx };

                if gap > 0 && app.should_render_gap_before_hunk(file_idx, hunk_idx) {
                    let top_lines = app.expanded_top.get(&gap_id);
                    let bot_lines = app.expanded_bottom.get(&gap_id);
                    let top_len = top_lines.map_or(0, |v| v.len());
                    let bot_len = bot_lines.map_or(0, |v| v.len());
                    let remaining = (gap as usize).saturating_sub(top_len + bot_len);
                    let is_top_of_file = hunk_idx == 0;

                    // Render top expanded lines
                    if let Some(top) = top_lines {
                        for expanded_line in top {
                            if !ctx.is_visible(line_idx) {
                                lines.push(Line::default());
                                line_idx += 1;
                                continue;
                            }
                            render_sbs_expanded_context_line(
                                &mut lines,
                                &mut line_idx,
                                expanded_line,
                                &ctx,
                            );
                        }
                    }

                    // Render expanders / hidden lines
                    if remaining > 0 {
                        if is_top_of_file {
                            if remaining > GAP_EXPAND_BATCH {
                                render_hidden_lines(
                                    &mut lines,
                                    &mut line_idx,
                                    ctx.current_line_idx,
                                    remaining,
                                    &app.theme,
                                );
                            }
                            render_expander_line(
                                &mut lines,
                                &mut line_idx,
                                ctx.current_line_idx,
                                ExpandDirection::Up,
                                remaining,
                                &app.theme,
                            );
                        } else if remaining >= GAP_EXPAND_BATCH {
                            render_expander_line(
                                &mut lines,
                                &mut line_idx,
                                ctx.current_line_idx,
                                ExpandDirection::Down,
                                remaining,
                                &app.theme,
                            );
                            render_hidden_lines(
                                &mut lines,
                                &mut line_idx,
                                ctx.current_line_idx,
                                remaining,
                                &app.theme,
                            );
                            render_expander_line(
                                &mut lines,
                                &mut line_idx,
                                ctx.current_line_idx,
                                ExpandDirection::Up,
                                remaining,
                                &app.theme,
                            );
                        } else {
                            render_expander_line(
                                &mut lines,
                                &mut line_idx,
                                ctx.current_line_idx,
                                ExpandDirection::Both,
                                remaining,
                                &app.theme,
                            );
                        }
                    }

                    // Render bottom expanded lines
                    if let Some(bot) = bot_lines {
                        for expanded_line in bot {
                            if !ctx.is_visible(line_idx) {
                                lines.push(Line::default());
                                line_idx += 1;
                                continue;
                            }
                            render_sbs_expanded_context_line(
                                &mut lines,
                                &mut line_idx,
                                expanded_line,
                                &ctx,
                            );
                        }
                    }
                }

                // Hunk header
                let is_hunk_reviewed = app.is_hunk_reviewed(file_idx, hunk_idx);
                let (hunk_header_text, hunk_header_style) =
                    hunk_header_text_and_style(&app.theme, hunk, is_hunk_reviewed);
                let indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
                lines.push(Line::from(vec![
                    Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
                    Span::styled(hunk_header_text, hunk_header_style),
                ]));
                line_idx += 1;
                if is_hunk_reviewed {
                    continue;
                }

                // Process diff lines in side-by-side format
                let (new_line_idx, cursor_info) = render_hunk_lines_side_by_side(
                    &hunk.lines,
                    line_comments,
                    &ctx,
                    file_idx,
                    line_idx,
                    &mut lines,
                );
                line_idx = new_line_idx;
                if let Some((line, col, box_start, box_end, annotations_replaced)) = cursor_info {
                    comment_cursor_logical_line = Some(line);
                    comment_cursor_column = col;
                    comment_input_box_range = Some((box_start, box_end));
                    let box_len = box_end - box_start + 1;
                    annotation_offset = Some((box_start, box_len, annotations_replaced));
                }
            }
        }

        // End-of-file gap (after all hunks, not for deleted files)
        if file.status != FileStatus::Deleted
            && matches!(
                app.diff_source,
                DiffSource::WorkingTree
                    | DiffSource::Unstaged
                    | DiffSource::StagedAndUnstaged
                    | DiffSource::StagedUnstagedAndCommits(_)
                    | DiffSource::CommitRange(_)
                    | DiffSource::PullRequest(_)
            )
            && let Some(last_hunk) = file.hunks.last()
        {
            let eof_start = last_hunk.new_start + last_hunk.new_count;
            if let Some(&total) = app.file_line_count_cache.get(&file_idx)
                && eof_start <= total
            {
                let gap = (total - eof_start + 1) as usize;
                let eof_gap_id = GapId {
                    file_idx,
                    hunk_idx: file.hunks.len(),
                };
                let top_lines = app.expanded_top.get(&eof_gap_id);
                let bot_lines = app.expanded_bottom.get(&eof_gap_id);
                let top_len = top_lines.map_or(0, |v| v.len());
                let bot_len = bot_lines.map_or(0, |v| v.len());
                let remaining = gap.saturating_sub(top_len + bot_len);

                // Render top expanded lines (↓ direction)
                if let Some(top) = top_lines {
                    for expanded_line in top {
                        render_sbs_expanded_context_line(
                            &mut lines,
                            &mut line_idx,
                            expanded_line,
                            &ctx,
                        );
                    }
                }

                // Expander / hidden lines
                if remaining > 0 {
                    render_expander_line(
                        &mut lines,
                        &mut line_idx,
                        ctx.current_line_idx,
                        ExpandDirection::Down,
                        remaining,
                        &app.theme,
                    );
                    if remaining > GAP_EXPAND_BATCH {
                        render_hidden_lines(
                            &mut lines,
                            &mut line_idx,
                            ctx.current_line_idx,
                            remaining,
                            &app.theme,
                        );
                    }
                }

                // Render bottom expanded lines
                if let Some(bot) = bot_lines {
                    for expanded_line in bot {
                        render_sbs_expanded_context_line(
                            &mut lines,
                            &mut line_idx,
                            expanded_line,
                            &ctx,
                        );
                    }
                }
            }
        }

        // Spacing between files
        let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
        lines.push(Line::from(Span::styled(
            indicator,
            styles::current_line_indicator_style(&app.theme),
        )));
        line_idx += 1;
    }

    let comment_bars = {
        let mut bars = ctx.comment_bars.borrow_mut();
        std::mem::take(&mut *bars)
    };
    let sbs_meta = {
        let mut m = ctx.sbs_meta.borrow_mut();
        std::mem::take(&mut *m)
    };
    drop(ctx);
    app.comment_input_annotation_offset = annotation_offset;

    // Auto-scroll so the comment input box stays visible while the user types.
    scroll_comment_input_into_view(
        &mut app.diff_state.scroll_offset,
        comment_input_box_range,
        comment_cursor_logical_line,
        inner.height as usize,
        lines.len(),
    );

    let visible_lines_unscrolled: Vec<Line> = lines
        .into_iter()
        .skip(app.diff_state.scroll_offset)
        .take(inner.height as usize)
        .collect();

    // Calculate the width of each line for max_content_width and visible line count
    let line_widths: Vec<usize> = visible_lines_unscrolled
        .iter()
        .map(|line| {
            line.spans
                .iter()
                .map(|span| span.content.width())
                .sum::<usize>()
        })
        .collect();

    let max_content_width = line_widths.iter().copied().max().unwrap_or(0);

    app.sync_viewport_width(inner.width as usize);
    app.diff_state.max_content_width = max_content_width;

    let scroll_offset = app.diff_state.scroll_offset;
    let wrap = app.diff_state.wrap_lines;
    let viewport_width = inner.width as usize;
    let visible_lines_unscrolled_for_overlay = visible_lines_unscrolled.clone();
    // Single pass: wrap each logical line once, producing both the visual
    // rows to render and the per-line height used by every row-mapping
    // consumer below, so the two can't disagree.
    let (row_heights, wrapped_lines): (Vec<usize>, Option<Vec<Line>>) = if wrap && content_width > 0
    {
        let mut heights = Vec::with_capacity(visible_lines_unscrolled_for_overlay.len());
        let mut out: Vec<Line> = Vec::new();
        let (left_prefix_blank, right_prefix_blank) = sbs_blank_prefixes(&app.theme, lw);
        for (i, line) in visible_lines_unscrolled_for_overlay.iter().enumerate() {
            let logical_idx = scroll_offset + i;
            match sbs_meta.get(&logical_idx) {
                Some(m) => {
                    let left_rows = if m.left_content.is_empty() {
                        vec![Vec::new()]
                    } else {
                        wrap_spans(&m.left_content, content_width)
                    };
                    let right_rows = if m.right_content.is_empty() {
                        vec![Vec::new()]
                    } else {
                        wrap_spans(&m.right_content, content_width)
                    };
                    let n = left_rows.len().max(right_rows.len()).max(1);
                    heights.push(n);
                    let empty_row: Vec<Span> = Vec::new();
                    for k in 0..n {
                        let left_content_row = left_rows.get(k).unwrap_or(&empty_row).clone();
                        let right_content_row = right_rows.get(k).unwrap_or(&empty_row).clone();
                        let left_padded =
                            pad_spans_to_width(left_content_row, content_width, m.left_pad_style);
                        let right_padded =
                            pad_spans_to_width(right_content_row, content_width, m.right_pad_style);
                        let (left_prefix, right_prefix) = if k == 0 {
                            (m.left_prefix.clone(), m.right_prefix.clone())
                        } else {
                            (left_prefix_blank.clone(), right_prefix_blank.clone())
                        };
                        let mut spans = left_prefix;
                        spans.extend(left_padded);
                        spans.extend(right_prefix);
                        spans.extend(right_padded);
                        out.push(Line::from(spans));
                    }
                }
                None => {
                    let rows = wrap_spans(&line.spans, viewport_width);
                    heights.push(rows.len());
                    out.extend(rows.into_iter().map(Line::from));
                }
            }
        }
        (heights, Some(out))
    } else {
        (vec![1; visible_lines_unscrolled_for_overlay.len()], None)
    };
    app.diff_state.visible_line_count = populate_row_to_annotation(
        &mut app.diff_row_to_annotation,
        &row_heights,
        viewport_width,
        inner.height as usize,
        wrap,
        scroll_offset,
    );

    let max_scroll_x = max_content_width.saturating_sub(viewport_width);
    if app.diff_state.scroll_x > max_scroll_x {
        app.diff_state.scroll_x = max_scroll_x;
    }
    if app.diff_state.wrap_lines {
        app.diff_state.scroll_x = 0;
    }

    let scroll_x = app.diff_state.scroll_x;
    let visible_lines: Vec<Line> = match wrapped_lines {
        Some(out) => out,
        None => visible_lines_unscrolled
            .into_iter()
            .map(|line| apply_horizontal_scroll(line, scroll_x))
            .collect(),
    };

    let overlay_ctx = crate::ui::diff_view::DiffOverlayPaint {
        inner,
        visible_lines_unscrolled: &visible_lines_unscrolled_for_overlay,
        line_widths: &line_widths,
        row_heights: &row_heights,
        wrap_lines: app.diff_state.wrap_lines,
        viewport_width: inner.width as usize,
        scroll_x,
        scroll_offset: app.diff_state.scroll_offset,
        theme: &app.theme,
        comment_bars: &comment_bars,
    };

    // Section-marker row tint (hunk headers + expand/hidden stubs).
    crate::ui::diff_view::paint_section_highlight(frame, &overlay_ctx);

    let diff = Paragraph::new(visible_lines).style(styles::panel_style(&app.theme));
    frame.render_widget(diff, inner);

    paint_cursor_line_highlight(
        frame,
        inner,
        &visible_lines_unscrolled_for_overlay,
        &row_heights,
        app,
    );

    // Painted last so the cell overlay wins over cursor-line bg on overlap.
    if let Some(sel) = app.visual_selection {
        paint_visual_selection_overlay(frame, inner, app, sel, &app.theme);
    }

    // File-section header rules extended to the full viewport width.
    crate::ui::diff_view::paint_file_header_fill(frame, &overlay_ctx);

    // Comment-box overlays painted last so the box + bar always win on their
    // single cells.
    crate::ui::diff_view::paint_comment_box_bar(frame, &overlay_ctx);
    crate::ui::diff_view::paint_comment_box_right_border(frame, &overlay_ctx);

    // Calculate screen position for comment cursor if in Comment mode
    if let Some(cursor_logical_line) = comment_cursor_logical_line {
        let scroll_offset = app.diff_state.scroll_offset;
        let visible_lines_count = app.diff_state.visible_line_count.max(1);

        // Check if the cursor line is visible (after scrolling)
        if cursor_logical_line >= scroll_offset
            && cursor_logical_line < scroll_offset + visible_lines_count
        {
            // Calculate screen row - need to account for wrapping
            let logical_offset = cursor_logical_line - scroll_offset;

            let mut visual_row: u16 = 0;
            let viewport_width = inner.width as usize;

            if app.diff_state.wrap_lines && viewport_width > 0 {
                for i in 0..logical_offset {
                    visual_row += row_heights.get(i).copied().unwrap_or(1) as u16;
                }
            } else {
                visual_row = logical_offset as u16;
            }

            let screen_col = inner.x + comment_cursor_column;
            let screen_row_abs = inner.y + visual_row;

            app.comment_cursor_screen_pos = Some((screen_col, screen_row_abs));
        }
    }
}

/// Render a single expanded context line in side-by-side mode
fn render_sbs_expanded_context_line(
    lines: &mut Vec<Line<'_>>,
    line_idx: &mut usize,
    expanded_line: &crate::model::DiffLine,
    ctx: &SideBySideContext,
) {
    let theme = ctx.theme;
    let lw = ctx.lineno_width;
    let content_width = ctx.content_width;
    let indicator = cursor_indicator(*line_idx, ctx.current_line_idx);
    let old_line_num = ctx
        .display_lineno(expanded_line.old_lineno, *line_idx)
        .map(|n| format!("{n:>lw$} "))
        .unwrap_or_else(|| " ".repeat(lw + 1));
    let new_line_num = ctx
        .display_lineno(expanded_line.new_lineno, *line_idx)
        .map(|n| format!("{n:>lw$} "))
        .unwrap_or_else(|| " ".repeat(lw + 1));
    let ec_style = styles::expanded_context_style(theme);
    let line_spans = vec![
        Span::styled(indicator, styles::current_line_indicator_style(theme)),
        Span::styled(old_line_num.clone(), ec_style),
        Span::styled(" ", ec_style),
        Span::styled(
            truncate_or_pad(&expanded_line.content, content_width),
            ec_style,
        ),
        Span::styled(" │ ", styles::dim_style(theme)),
        Span::styled(new_line_num.clone(), ec_style),
        Span::styled(" ", ec_style),
        Span::styled(
            truncate_or_pad(&expanded_line.content, content_width),
            ec_style,
        ),
    ];
    lines.push(Line::from(line_spans));

    let dim = styles::dim_style(theme);
    let left_prefix = vec![
        Span::styled(indicator, styles::current_line_indicator_style(theme)),
        Span::styled(old_line_num, ec_style),
        Span::styled(" ", ec_style),
    ];
    let right_prefix = vec![
        Span::styled(" │ ", dim),
        Span::styled(new_line_num, ec_style),
        Span::styled(" ", ec_style),
    ];
    let content = vec![Span::styled(expanded_line.content.clone(), ec_style)];
    ctx.sbs_meta.borrow_mut().insert(
        *line_idx,
        SbsRowMeta {
            left_content: content.clone(),
            right_content: content,
            left_prefix,
            right_prefix,
            left_pad_style: ec_style,
            right_pad_style: ec_style,
        },
    );
    *line_idx += 1;
}

/// Process and render all diff lines in a hunk for side-by-side view
/// Returns (new_line_idx, optional cursor info for inline comment input)
fn render_hunk_lines_side_by_side(
    hunk_lines: &[crate::model::DiffLine],
    line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
    ctx: &SideBySideContext,
    file_idx: usize,
    mut line_idx: usize,
    lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
    let mut i = 0;
    let mut cursor_info_out: Option<SideBySideCursorInfo> = None;

    // A commit message is a synthetic "added" file; its lines are Context so
    // the unified view renders them neutrally. In side-by-side that would
    // duplicate the message across both columns, so render it right-side only
    // as an addition instead.
    let is_commit_msg = ctx
        .app
        .diff_files
        .get(file_idx)
        .is_some_and(|f| f.is_commit_message);

    while i < hunk_lines.len() {
        let diff_line = &hunk_lines[i];

        match diff_line.origin {
            LineOrigin::Context if is_commit_msg => {
                let (new_line_idx, cursor_info) = render_commit_message_line_side_by_side(
                    diff_line,
                    line_comments,
                    ctx,
                    file_idx,
                    line_idx,
                    lines,
                );
                line_idx = new_line_idx;
                if cursor_info.is_some() {
                    cursor_info_out = cursor_info;
                }
                i += 1;
            }
            LineOrigin::Context => {
                let (new_line_idx, cursor_info) = render_context_line_side_by_side(
                    diff_line,
                    line_comments,
                    ctx,
                    file_idx,
                    line_idx,
                    lines,
                );
                line_idx = new_line_idx;
                if cursor_info.is_some() {
                    cursor_info_out = cursor_info;
                }
                i += 1;
            }
            LineOrigin::Deletion => {
                let (new_line_idx, lines_processed, cursor_info) =
                    render_deletion_addition_pair_side_by_side(
                        hunk_lines,
                        i,
                        line_comments,
                        ctx,
                        file_idx,
                        line_idx,
                        lines,
                    );
                line_idx = new_line_idx;
                if cursor_info.is_some() {
                    cursor_info_out = cursor_info;
                }
                i = lines_processed;
            }
            LineOrigin::Addition => {
                let (new_line_idx, cursor_info) = render_standalone_addition_side_by_side(
                    diff_line,
                    line_comments,
                    ctx,
                    file_idx,
                    line_idx,
                    lines,
                );
                line_idx = new_line_idx;
                if cursor_info.is_some() {
                    cursor_info_out = cursor_info;
                }
                i += 1;
            }
        }
    }
    (line_idx, cursor_info_out)
}

/// Render a context line (appears on both sides)
/// Returns (new_line_idx, optional cursor info for inline comment input)
fn render_context_line_side_by_side(
    diff_line: &crate::model::DiffLine,
    line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
    ctx: &SideBySideContext,
    file_idx: usize,
    mut line_idx: usize,
    lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
    if ctx.is_visible(line_idx) {
        let w = ctx.lineno_width;
        let old_line_num = ctx
            .display_lineno(diff_line.old_lineno, line_idx)
            .map(|n| format!("{n:>w$}"))
            .unwrap_or_else(|| " ".repeat(w));
        let new_line_num = ctx
            .display_lineno(diff_line.new_lineno, line_idx)
            .map(|n| format!("{n:>w$}"))
            .unwrap_or_else(|| " ".repeat(w));

        let indicator = cursor_indicator(line_idx, ctx.current_line_idx);

        let mut spans = vec![
            Span::styled(indicator, styles::current_line_indicator_style(ctx.theme)),
            Span::styled(format!("{old_line_num} "), styles::dim_style(ctx.theme)),
            Span::styled(" ".to_string(), styles::diff_context_style(ctx.theme)),
        ];

        // Left side content - use syntax highlighting if available
        if let Some(ref highlighted) = diff_line.highlighted_spans {
            let content_spans = truncate_or_pad_spans(
                highlighted,
                ctx.content_width,
                styles::diff_context_style(ctx.theme),
            );
            spans.extend(content_spans);
        } else {
            let content = truncate_or_pad(&diff_line.content, ctx.content_width);
            spans.push(Span::styled(content, styles::diff_context_style(ctx.theme)));
        }

        // Separator
        spans.push(Span::styled(" │ ", styles::dim_style(ctx.theme)));
        spans.push(Span::styled(
            format!("{new_line_num} "),
            styles::dim_style(ctx.theme),
        ));
        spans.push(Span::styled(
            " ".to_string(),
            styles::diff_context_style(ctx.theme),
        ));

        // Right side content - use same highlighting
        if let Some(ref highlighted) = diff_line.highlighted_spans {
            let content_spans = truncate_or_pad_spans(
                highlighted,
                ctx.content_width,
                styles::diff_context_style(ctx.theme),
            );
            spans.extend(content_spans);
        } else {
            let content = truncate_or_pad(&diff_line.content, ctx.content_width);
            spans.push(Span::styled(content, styles::diff_context_style(ctx.theme)));
        }

        lines.push(Line::from(spans));

        let content = content_spans_for_diff_line(ctx.theme, diff_line, LineOrigin::Context);
        let ctx_style = styles::diff_context_style(ctx.theme);
        let (lp, rp) = sbs_row_prefixes(
            ctx.theme,
            indicator,
            SideSpec {
                lineno: ctx.display_lineno(diff_line.old_lineno, line_idx),
                marker: " ",
                marker_style: ctx_style,
            },
            SideSpec {
                lineno: ctx.display_lineno(diff_line.new_lineno, line_idx),
                marker: " ",
                marker_style: ctx_style,
            },
            w,
        );
        ctx.sbs_meta.borrow_mut().insert(
            line_idx,
            SbsRowMeta {
                left_content: content.clone(),
                right_content: content,
                left_prefix: lp,
                right_prefix: rp,
                left_pad_style: ctx_style,
                right_pad_style: ctx_style,
            },
        );
    } else {
        lines.push(Line::default());
    }
    line_idx += 1;

    // Add comments if any
    let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
    if let Some(new_ln) = diff_line.new_lineno {
        let (new_line_idx, cursor_info) = add_comments_to_line(
            new_ln,
            line_comments,
            LineSide::New,
            ctx,
            file_idx,
            line_idx,
            lines,
        );
        line_idx = new_line_idx;
        cursor_info_out = cursor_info;
        if let Some(file) = ctx.app.diff_files.get(file_idx) {
            line_idx = add_remote_threads_to_line(
                new_ln,
                LineSide::New,
                ctx,
                file.display_path(),
                line_idx,
                lines,
            );
        }
    }

    (line_idx, cursor_info_out)
}

/// Render paired deletions and additions side-by-side
/// Returns (line_idx, skip_count, optional cursor info for inline comment input)
fn render_deletion_addition_pair_side_by_side(
    hunk_lines: &[crate::model::DiffLine],
    start_idx: usize,
    line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
    ctx: &SideBySideContext,
    file_idx: usize,
    mut line_idx: usize,
    lines: &mut Vec<Line>,
) -> (usize, usize, Option<SideBySideCursorInfo>) {
    // Find the range of consecutive deletions
    let mut del_end = start_idx + 1;
    while del_end < hunk_lines.len() && hunk_lines[del_end].origin == LineOrigin::Deletion {
        del_end += 1;
    }

    // Find the range of consecutive additions following the deletions
    let add_start = del_end;
    let mut add_end = add_start;
    while add_end < hunk_lines.len() && hunk_lines[add_end].origin == LineOrigin::Addition {
        add_end += 1;
    }

    let del_count = del_end - start_idx;
    let add_count = add_end - add_start;
    let max_lines = del_count.max(add_count);
    let mut cursor_info_out: Option<SideBySideCursorInfo> = None;

    // Render each pair of deletion/addition
    for offset in 0..max_lines {
        let del_opt = (offset < del_count).then(|| &hunk_lines[start_idx + offset]);
        let add_opt = (offset < add_count).then(|| &hunk_lines[add_start + offset]);
        if ctx.is_visible(line_idx) {
            let indicator = cursor_indicator(line_idx, ctx.current_line_idx);

            let mut spans = vec![Span::styled(
                indicator,
                styles::current_line_indicator_style(ctx.theme),
            )];

            // Left side (deletion)
            if let Some(del_line) = del_opt {
                add_deletion_spans(
                    ctx.theme,
                    &mut spans,
                    del_line,
                    ctx.content_width,
                    ctx.lineno_width,
                    ctx.display_lineno(del_line.old_lineno, line_idx),
                );
            } else {
                add_empty_column_spans(&mut spans, ctx.content_width, ctx.lineno_width);
            }

            spans.push(Span::styled(" │ ", styles::dim_style(ctx.theme)));

            // Right side (addition)
            if let Some(add_line) = add_opt {
                add_addition_spans(
                    ctx.theme,
                    &mut spans,
                    add_line,
                    ctx.content_width,
                    ctx.lineno_width,
                    ctx.display_lineno(add_line.new_lineno, line_idx),
                );
            } else {
                add_empty_column_spans(&mut spans, ctx.content_width, ctx.lineno_width);
            }

            lines.push(Line::from(spans));

            let w = ctx.lineno_width;
            let (left_content, left_pad, left_marker, left_lineno, left_marker_style) =
                match del_opt {
                    Some(dl) => (
                        content_spans_for_diff_line(ctx.theme, dl, LineOrigin::Deletion),
                        column_pad_style(ctx.theme, dl, LineOrigin::Deletion),
                        "▌",
                        ctx.display_lineno(dl.old_lineno, line_idx),
                        styles::diff_del_style(ctx.theme),
                    ),
                    None => (Vec::new(), Style::default(), " ", None, Style::default()),
                };
            let (right_content, right_pad, right_marker, right_lineno, right_marker_style) =
                match add_opt {
                    Some(al) => (
                        content_spans_for_diff_line(ctx.theme, al, LineOrigin::Addition),
                        column_pad_style(ctx.theme, al, LineOrigin::Addition),
                        "▌",
                        ctx.display_lineno(al.new_lineno, line_idx),
                        styles::diff_add_style(ctx.theme),
                    ),
                    None => (Vec::new(), Style::default(), " ", None, Style::default()),
                };
            let (lp, rp) = sbs_row_prefixes(
                ctx.theme,
                indicator,
                SideSpec {
                    lineno: left_lineno,
                    marker: left_marker,
                    marker_style: left_marker_style,
                },
                SideSpec {
                    lineno: right_lineno,
                    marker: right_marker,
                    marker_style: right_marker_style,
                },
                w,
            );
            ctx.sbs_meta.borrow_mut().insert(
                line_idx,
                SbsRowMeta {
                    left_content,
                    right_content,
                    left_prefix: lp,
                    right_prefix: rp,
                    left_pad_style: left_pad,
                    right_pad_style: right_pad,
                },
            );
        } else {
            lines.push(Line::default());
        }
        line_idx += 1;

        // Add comments for deletion
        if let Some(del_line) = del_opt
            && let Some(old_ln) = del_line.old_lineno
        {
            let (new_line_idx, cursor_info) = add_comments_to_line(
                old_ln,
                line_comments,
                LineSide::Old,
                ctx,
                file_idx,
                line_idx,
                lines,
            );
            line_idx = new_line_idx;
            if cursor_info.is_some() {
                cursor_info_out = cursor_info;
            }
            if let Some(file) = ctx.app.diff_files.get(file_idx) {
                line_idx = add_remote_threads_to_line(
                    old_ln,
                    LineSide::Old,
                    ctx,
                    file.display_path(),
                    line_idx,
                    lines,
                );
            }
        }

        // Add comments for addition
        if let Some(add_line) = add_opt
            && let Some(new_ln) = add_line.new_lineno
        {
            let (new_line_idx, cursor_info) = add_comments_to_line(
                new_ln,
                line_comments,
                LineSide::New,
                ctx,
                file_idx,
                line_idx,
                lines,
            );
            line_idx = new_line_idx;
            if cursor_info.is_some() {
                cursor_info_out = cursor_info;
            }
            if let Some(file) = ctx.app.diff_files.get(file_idx) {
                line_idx = add_remote_threads_to_line(
                    new_ln,
                    LineSide::New,
                    ctx,
                    file.display_path(),
                    line_idx,
                    lines,
                );
            }
        }
    }

    (line_idx, add_end, cursor_info_out)
}

/// Render a standalone addition (no matching deletion)
/// Returns (new_line_idx, optional cursor info for inline comment input)
fn render_standalone_addition_side_by_side(
    diff_line: &crate::model::DiffLine,
    line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
    ctx: &SideBySideContext,
    file_idx: usize,
    mut line_idx: usize,
    lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
    if ctx.is_visible(line_idx) {
        let indicator = cursor_indicator(line_idx, ctx.current_line_idx);

        let mut spans = vec![Span::styled(
            indicator,
            styles::current_line_indicator_style(ctx.theme),
        )];
        add_empty_column_spans(&mut spans, ctx.content_width, ctx.lineno_width);
        spans.push(Span::styled(" │ ", styles::dim_style(ctx.theme)));
        add_addition_spans(
            ctx.theme,
            &mut spans,
            diff_line,
            ctx.content_width,
            ctx.lineno_width,
            ctx.display_lineno(diff_line.new_lineno, line_idx),
        );

        lines.push(Line::from(spans));

        let w = ctx.lineno_width;
        let right_content = content_spans_for_diff_line(ctx.theme, diff_line, LineOrigin::Addition);
        let right_pad = column_pad_style(ctx.theme, diff_line, LineOrigin::Addition);
        let (lp, rp) = sbs_row_prefixes(
            ctx.theme,
            indicator,
            SideSpec {
                lineno: None,
                marker: " ",
                marker_style: Style::default(),
            },
            SideSpec {
                lineno: ctx.display_lineno(diff_line.new_lineno, line_idx),
                marker: "▌",
                marker_style: styles::diff_add_style(ctx.theme),
            },
            w,
        );
        ctx.sbs_meta.borrow_mut().insert(
            line_idx,
            SbsRowMeta {
                left_content: Vec::new(),
                right_content,
                left_prefix: lp,
                right_prefix: rp,
                left_pad_style: Style::default(),
                right_pad_style: right_pad,
            },
        );
    } else {
        lines.push(Line::default());
    }
    line_idx += 1;

    // Add comments if any
    let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
    if let Some(new_ln) = diff_line.new_lineno {
        let (new_line_idx, cursor_info) = add_comments_to_line(
            new_ln,
            line_comments,
            LineSide::New,
            ctx,
            file_idx,
            line_idx,
            lines,
        );
        line_idx = new_line_idx;
        cursor_info_out = cursor_info;
        if let Some(file) = ctx.app.diff_files.get(file_idx) {
            line_idx = add_remote_threads_to_line(
                new_ln,
                LineSide::New,
                ctx,
                file.display_path(),
                line_idx,
                lines,
            );
        }
    }

    (line_idx, cursor_info_out)
}

/// Render a commit-message line in side-by-side mode. The commit message is a
/// synthetic "added" file, but visually it is prose, not code: delta renders it
/// as a full-width block, not confined to a diff column. So we emit a single
/// full-width, left-aligned, neutrally-styled line with no column split, diff
/// coloring, or per-column line numbers. It is deliberately NOT inserted into
/// `sbs_meta`, so the wrap path falls through to the full-width wrapping branch.
fn render_commit_message_line_side_by_side(
    diff_line: &crate::model::DiffLine,
    line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
    ctx: &SideBySideContext,
    file_idx: usize,
    mut line_idx: usize,
    lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
    let ctx_style = styles::diff_context_style(ctx.theme);

    if ctx.is_visible(line_idx) {
        let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
        let mut spans = vec![Span::styled(
            indicator,
            styles::current_line_indicator_style(ctx.theme),
        )];
        // Git-style two-space indent, then the message text at full width.
        spans.push(Span::styled("  ".to_string(), ctx_style));
        spans.push(Span::styled(diff_line.content.clone(), ctx_style));

        lines.push(Line::from(spans));
    } else {
        lines.push(Line::default());
    }
    line_idx += 1;

    let mut cursor_info_out: Option<SideBySideCursorInfo> = None;
    if let Some(new_ln) = diff_line.new_lineno {
        let (new_line_idx, cursor_info) = add_comments_to_line(
            new_ln,
            line_comments,
            LineSide::New,
            ctx,
            file_idx,
            line_idx,
            lines,
        );
        line_idx = new_line_idx;
        cursor_info_out = cursor_info;
        if let Some(file) = ctx.app.diff_files.get(file_idx) {
            line_idx = add_remote_threads_to_line(
                new_ln,
                LineSide::New,
                ctx,
                file.display_path(),
                line_idx,
                lines,
            );
        }
    }

    (line_idx, cursor_info_out)
}

/// Add deletion line spans to the spans vector
fn add_deletion_spans(
    theme: &Theme,
    spans: &mut Vec<Span>,
    diff_line: &crate::model::DiffLine,
    content_width: usize,
    lw: usize,
    display_lineno: Option<u32>,
) {
    let line_num = display_lineno
        .map(|n| format!("{n:>lw$}"))
        .unwrap_or_else(|| " ".repeat(lw));

    spans.push(Span::styled(
        format!("{line_num} "),
        styles::dim_style(theme),
    ));
    spans.push(Span::styled("▌".to_string(), styles::diff_del_style(theme)));

    // Use syntax highlighting if available
    if let Some(ref highlighted) = diff_line.highlighted_spans {
        let syntax_pad_style = Style::default().fg(theme.diff_del).bg(theme.syntax_del_bg);
        let content_spans = truncate_or_pad_spans(highlighted, content_width, syntax_pad_style);
        spans.extend(content_spans);
    } else {
        // Fall back to plain text
        let content = truncate_or_pad(&diff_line.content, content_width);
        spans.push(Span::styled(content, styles::diff_del_style(theme)));
    }
}

/// Add addition line spans to the spans vector
fn add_addition_spans(
    theme: &Theme,
    spans: &mut Vec<Span>,
    diff_line: &crate::model::DiffLine,
    content_width: usize,
    lw: usize,
    display_lineno: Option<u32>,
) {
    let line_num = display_lineno
        .map(|n| format!("{n:>lw$}"))
        .unwrap_or_else(|| " ".repeat(lw));

    spans.push(Span::styled(
        format!("{line_num} "),
        styles::dim_style(theme),
    ));
    spans.push(Span::styled("▌".to_string(), styles::diff_add_style(theme)));

    // Use syntax highlighting if available
    if let Some(ref highlighted) = diff_line.highlighted_spans {
        let syntax_pad_style = Style::default().fg(theme.diff_add).bg(theme.syntax_add_bg);
        let content_spans = truncate_or_pad_spans(highlighted, content_width, syntax_pad_style);
        spans.extend(content_spans);
    } else {
        // Fall back to plain text
        let content = truncate_or_pad(&diff_line.content, content_width);
        spans.push(Span::styled(content, styles::diff_add_style(theme)));
    }
}

/// Add empty column spans (for when one side has no content)
fn add_empty_column_spans(spans: &mut Vec<Span>, content_width: usize, lw: usize) {
    // line_num(lw) + space(1) + prefix(1) + content
    spans.push(Span::styled(
        " ".repeat(lw + 1 + 1 + content_width),
        Style::default(),
    ));
}

/// Add comments for a specific line.
/// Returns (new_line_idx, optional cursor info for inline comment input)
/// Render remote review threads anchored at this `(file, line, side)`
/// position into the side-by-side rendering. Mirrors the unified-view
/// helper but uses the side-by-side cursor indicator path.
fn add_remote_threads_to_line(
    line_num: u32,
    side: LineSide,
    ctx: &SideBySideContext,
    file_path: &std::path::Path,
    mut line_idx: usize,
    lines: &mut Vec<Line>,
) -> usize {
    use crate::forge::remote_comments::{PrCommentsVisibility, RemoteCommentSide};
    let visibility = ctx.app.session.remote_comments_visibility;
    if matches!(visibility, PrCommentsVisibility::Hide) {
        return line_idx;
    }
    let target_path = file_path.to_string_lossy();
    for thread in &ctx.app.forge_review_threads {
        let Some(muted) = visibility.render_decision(thread) else {
            continue;
        };
        if thread.path != *target_path {
            continue;
        }
        let Some(thread_line) = thread.line else {
            continue;
        };
        if thread_line != line_num {
            continue;
        }
        let matches_side = matches!(
            (thread.side, side),
            (RemoteCommentSide::Right, LineSide::New) | (RemoteCommentSide::Left, LineSide::Old)
        );
        if !matches_side {
            continue;
        }
        let thread_lines = comment_panel::format_remote_thread_lines(
            ctx.theme,
            thread,
            muted,
            ctx.app.forge_kind(),
        );
        let box_top_row = line_idx;
        for mut comment_line in thread_lines {
            let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
            comment_line.spans.insert(
                0,
                Span::styled(indicator, styles::current_line_indicator_style(ctx.theme)),
            );
            lines.push(comment_line);
            line_idx += 1;
        }
        crate::ui::diff_view::push_comment_bar(
            &mut ctx.comment_bars.borrow_mut(),
            box_top_row,
            Some(crate::model::LineRange::single(thread_line)),
        );
    }
    line_idx
}

fn add_comments_to_line(
    line_num: u32,
    line_comments: &std::collections::HashMap<u32, Vec<crate::model::Comment>>,
    side: LineSide,
    ctx: &SideBySideContext,
    file_idx: usize,
    mut line_idx: usize,
    lines: &mut Vec<Line>,
) -> (usize, Option<SideBySideCursorInfo>) {
    // Check if we're adding/editing a comment on this line and side
    let is_line_comment_mode = ctx.comment_input_mode
        && file_idx == ctx.current_file_idx
        && ctx.comment_line == Some((line_num, side));
    let mut cursor_info_out: Option<SideBySideCursorInfo> = None;

    if let Some(comments) = line_comments.get(&line_num) {
        for comment in comments {
            let comment_side = comment.side.unwrap_or(LineSide::New);
            if ((side == LineSide::Old && comment_side == LineSide::Old)
                || (side == LineSide::New && comment_side != LineSide::Old))
                && ctx.app.comment_visible(comment)
            {
                // Check if this comment is being edited
                let is_being_edited =
                    is_line_comment_mode && ctx.editing_comment_id == Some(comment.id.as_str());

                if is_being_edited {
                    // Render inline input instead
                    let line_range = ctx
                        .comment_line_range
                        .or_else(|| Some(LineRange::single(line_num)));
                    let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
                        ctx.theme,
                        comment_type_presentation(ctx.app, &ctx.comment_type),
                        ctx.comment_buffer,
                        ctx.comment_cursor,
                        line_range,
                        true,
                        ctx.panel_width.saturating_sub(1),
                        ctx.app
                            .comment_vim_mode_label()
                            .as_ref()
                            .map(|(t, w)| (t.as_str(), *w)),
                    );
                    let box_top_row = line_idx;
                    let box_end = line_idx + input_lines.len().saturating_sub(1);
                    let annotations_replaced = App::comment_display_lines(comment, ctx.panel_width);
                    cursor_info_out = Some((
                        line_idx + cursor_info.line_offset,
                        1 + cursor_info.column,
                        line_idx,
                        box_end,
                        annotations_replaced,
                    ));

                    for mut input_line in input_lines {
                        let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
                        input_line.spans.insert(
                            0,
                            Span::styled(
                                indicator,
                                styles::current_line_indicator_style(ctx.theme),
                            ),
                        );
                        lines.push(input_line);
                        line_idx += 1;
                    }
                    crate::ui::diff_view::push_comment_bar(
                        &mut ctx.comment_bars.borrow_mut(),
                        box_top_row,
                        line_range,
                    );
                } else {
                    let line_range = comment
                        .line_range
                        .or_else(|| Some(LineRange::single(line_num)));
                    let comment_lines = comment_panel::format_comment_lines(
                        ctx.theme,
                        comment_type_presentation(ctx.app, &comment.comment_type),
                        &comment.content,
                        line_range,
                        ctx.panel_width.saturating_sub(1),
                        (comment.author != ctx.app.username).then_some(comment.author.as_str()),
                    );
                    let box_top_row = line_idx;
                    for mut comment_line in comment_lines {
                        let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
                        comment_line.spans.insert(
                            0,
                            Span::styled(
                                indicator,
                                styles::current_line_indicator_style(ctx.theme),
                            ),
                        );
                        lines.push(comment_line);
                        line_idx += 1;
                    }
                    crate::ui::diff_view::push_comment_bar(
                        &mut ctx.comment_bars.borrow_mut(),
                        box_top_row,
                        line_range,
                    );
                }
            }
        }
    }

    // Render inline input for new line comment
    if is_line_comment_mode && ctx.editing_comment_id.is_none() {
        let line_range = ctx
            .comment_line_range
            .or_else(|| Some(LineRange::single(line_num)));
        let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
            ctx.theme,
            comment_type_presentation(ctx.app, &ctx.comment_type),
            ctx.comment_buffer,
            ctx.comment_cursor,
            line_range,
            false,
            ctx.panel_width.saturating_sub(1),
            ctx.app
                .comment_vim_mode_label()
                .as_ref()
                .map(|(t, w)| (t.as_str(), *w)),
        );
        let box_top_row = line_idx;
        let box_end = line_idx + input_lines.len().saturating_sub(1);
        cursor_info_out = Some((
            line_idx + cursor_info.line_offset,
            1 + cursor_info.column,
            line_idx,
            box_end,
            0,
        ));

        for mut input_line in input_lines {
            let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
            input_line.spans.insert(
                0,
                Span::styled(indicator, styles::current_line_indicator_style(ctx.theme)),
            );
            lines.push(input_line);
            line_idx += 1;
        }
        crate::ui::diff_view::push_comment_bar(
            &mut ctx.comment_bars.borrow_mut(),
            box_top_row,
            line_range,
        );
    }

    (line_idx, cursor_info_out)
}

#[cfg(test)]
mod remote_comments_side_by_side_snapshot_tests {
    //! Render-snapshot tests for inline remote review threads in the
    //! side-by-side diff view. Confirms the badge appears at least once
    //! when a thread is active and is hidden under `:comments hide`.
    use crate::app::{App, DiffSource, DiffViewMode, InputMode, PullRequestDiffSource};
    use crate::error::Result as TuicrResult;
    use crate::error::TuicrError;
    use crate::forge::remote_comments::{
        PrCommentsVisibility, RemoteCommentSide, RemoteReviewComment, RemoteReviewThread,
    };
    use crate::forge::traits::{ForgeRepository, PrSessionKey};
    use crate::model::{
        DiffFile, DiffHunk, DiffLine, FileStatus, LineOrigin, ReviewSession, SessionDiffSource,
    };
    use crate::syntax::SyntaxHighlighter;
    use crate::theme::Theme;
    use crate::ui::render;
    use crate::vcs::traits::{VcsBackend, VcsChangeStatus, VcsInfo, VcsType};
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;
    use ratatui::buffer::Buffer;
    use ratatui::layout::Rect;
    use std::path::{Path, PathBuf};

    struct SnapshotVcs {
        info: VcsInfo,
    }

    impl VcsBackend for SnapshotVcs {
        fn info(&self) -> &VcsInfo {
            &self.info
        }
        fn get_working_tree_diff(
            &self,
            _highlighter: &SyntaxHighlighter,
        ) -> TuicrResult<Vec<DiffFile>> {
            Err(TuicrError::NoChanges)
        }
        fn fetch_context_lines(
            &self,
            _file_path: &Path,
            _file_status: FileStatus,
            _ref_commit: Option<&str>,
            _start_line: u32,
            _end_line: u32,
        ) -> TuicrResult<Vec<DiffLine>> {
            Ok(Vec::new())
        }
        fn get_change_status(&self) -> TuicrResult<VcsChangeStatus> {
            Ok(VcsChangeStatus {
                staged: false,
                unstaged: false,
            })
        }
        fn file_line_count(
            &self,
            _file_path: &Path,
            _file_status: FileStatus,
            _ref_commit: Option<&str>,
        ) -> TuicrResult<u32> {
            Ok(0)
        }
    }

    fn repo() -> ForgeRepository {
        ForgeRepository::github("github.com", "agavra", "tuicr")
    }

    fn sample_diff_file() -> DiffFile {
        let lines = vec![
            DiffLine {
                origin: LineOrigin::Context,
                content: "first".to_string(),
                old_lineno: Some(1),
                new_lineno: Some(1),
                highlighted_spans: None,
            },
            DiffLine {
                origin: LineOrigin::Addition,
                content: "second".to_string(),
                old_lineno: None,
                new_lineno: Some(2),
                highlighted_spans: None,
            },
        ];
        let hunk = DiffHunk {
            header: "@@ -1,1 +1,2 @@".to_string(),
            lines,
            old_start: 1,
            old_count: 1,
            new_start: 1,
            new_count: 2,
        };
        let hunks = vec![hunk];
        let content_hash = DiffFile::compute_content_hash(&hunks);
        DiffFile {
            old_path: Some(PathBuf::from("src/lib.rs")),
            new_path: Some(PathBuf::from("src/lib.rs")),
            status: FileStatus::Modified,
            hunks,
            is_binary: false,
            is_too_large: false,
            is_commit_message: false,
            content_hash,
        }
    }

    fn thread() -> RemoteReviewThread {
        RemoteReviewThread {
            id: "T".to_string(),
            path: "src/lib.rs".to_string(),
            line: Some(2),
            side: RemoteCommentSide::Right,
            is_resolved: false,
            is_outdated: false,
            comments: vec![RemoteReviewComment {
                id: "C".to_string(),
                author: Some("alice".to_string()),
                body: "sbs hello".to_string(),
                created_at: None,
                in_reply_to: None,
                url: "https://example.com".to_string(),
            }],
        }
    }

    fn make_pr_app() -> App {
        let pr = PullRequestDiffSource {
            key: PrSessionKey::new(repo(), 125, "headsha".to_string()),
            base_sha: "basesha".to_string(),
            title: "test pr".to_string(),
            url: "https://example.com".to_string(),
            head_ref_name: "feat".to_string(),
            base_ref_name: "main".to_string(),
            state: "OPEN".to_string(),
            closed: false,
            merged: false,
        };
        let vcs_info = VcsInfo {
            root_path: PathBuf::from("forge:github.com/agavra/tuicr"),
            head_commit: "headsha".to_string(),
            branch_name: Some("feat".to_string()),
            vcs_type: VcsType::File,
        };
        let mut session = ReviewSession::new(
            vcs_info.root_path.clone(),
            "headsha".to_string(),
            Some("feat".to_string()),
            SessionDiffSource::PullRequest,
        );
        session.pr_session_key = Some(pr.key.clone());
        let mut app = App::build(
            Box::new(SnapshotVcs {
                info: vcs_info.clone(),
            }),
            vcs_info,
            Theme::dark(),
            None,
            false,
            vec![sample_diff_file()],
            session,
            DiffSource::PullRequest(Box::new(pr)),
            InputMode::Normal,
            Vec::new(),
            None,
            None,
        )
        .expect("build app");
        app.diff_view_mode = DiffViewMode::SideBySide;
        app
    }

    fn draw(app: &mut App) -> Buffer {
        let backend = TestBackend::new(160, 30);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| render(frame, app))
            .expect("draw frame");
        terminal.backend().buffer().clone()
    }

    fn body_text(buffer: &Buffer) -> String {
        (0..buffer.area.height)
            .map(|y| {
                (0..buffer.area.width)
                    .map(|x| buffer[(x, y)].symbol().to_string())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    #[test]
    fn should_render_remote_comment_inline_in_side_by_side_diff() {
        // given
        let mut app = make_pr_app();
        app.forge_review_threads = vec![thread()];
        app.rebuild_annotations();
        // when
        let buffer = draw(&mut app);
        // then
        let body = body_text(&buffer);
        assert!(
            body.contains("[github @alice]"),
            "expected badge in side-by-side render:\n{body}"
        );
    }

    #[test]
    fn should_hide_remote_comments_under_comments_hide_in_side_by_side() {
        // given
        let mut app = make_pr_app();
        app.forge_review_threads = vec![thread()];
        app.set_remote_comments_visibility(PrCommentsVisibility::Hide);
        // when
        let buffer = draw(&mut app);
        // then
        let body = body_text(&buffer);
        assert!(
            !body.contains("[github @alice"),
            "remote comment leaked under Hide:\n{body}"
        );
    }

    fn diff_file_with_pair(left: &str, right: &str) -> DiffFile {
        let lines = vec![
            DiffLine {
                origin: LineOrigin::Deletion,
                content: left.to_string(),
                old_lineno: Some(1),
                new_lineno: None,
                highlighted_spans: None,
            },
            DiffLine {
                origin: LineOrigin::Addition,
                content: right.to_string(),
                old_lineno: None,
                new_lineno: Some(1),
                highlighted_spans: None,
            },
        ];
        let hunks = vec![DiffHunk {
            header: "@@ -1,1 +1,1 @@".to_string(),
            lines,
            old_start: 1,
            old_count: 1,
            new_start: 1,
            new_count: 1,
        }];
        let content_hash = DiffFile::compute_content_hash(&hunks);
        DiffFile {
            old_path: Some(PathBuf::from("src/lib.rs")),
            new_path: Some(PathBuf::from("src/lib.rs")),
            status: FileStatus::Modified,
            hunks,
            is_binary: false,
            is_too_large: false,
            is_commit_message: false,
            content_hash,
        }
    }

    fn diff_file_with_standalone_deletion(left: &str) -> DiffFile {
        let lines = vec![DiffLine {
            origin: LineOrigin::Deletion,
            content: left.to_string(),
            old_lineno: Some(1),
            new_lineno: None,
            highlighted_spans: None,
        }];
        let hunks = vec![DiffHunk {
            header: "@@ -1,1 +0,0 @@".to_string(),
            lines,
            old_start: 1,
            old_count: 1,
            new_start: 0,
            new_count: 0,
        }];
        let content_hash = DiffFile::compute_content_hash(&hunks);
        DiffFile {
            old_path: Some(PathBuf::from("src/lib.rs")),
            new_path: Some(PathBuf::from("src/lib.rs")),
            status: FileStatus::Modified,
            hunks,
            is_binary: false,
            is_too_large: false,
            is_commit_message: false,
            content_hash,
        }
    }

    fn draw_sbs(app: &mut App, w: u16, h: u16) -> Buffer {
        let backend = TestBackend::new(w, h);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| super::render_side_by_side_diff(frame, app, Rect::new(0, 0, w, h)))
            .expect("draw sbs");
        terminal.backend().buffer().clone()
    }

    fn char_at(buf: &Buffer, x: u16, y: u16) -> String {
        buf[(x, y)].symbol().to_string()
    }

    #[test]
    fn should_wrap_long_line_in_side_by_side_view_when_wrap_enabled() {
        let long_left = "L".repeat(200);
        let mut app = make_pr_app();
        app.diff_files = vec![diff_file_with_pair(&long_left, "short")];
        app.set_diff_wrap(true);
        app.rebuild_annotations();

        let buf = draw_sbs(&mut app, 160, 20);

        let mut rows_with_l = 0u16;
        for y in 0..buf.area.height {
            let row: String = (0..buf.area.width).map(|x| char_at(&buf, x, y)).collect();
            if row.contains("LLLLLLLLLL") {
                rows_with_l += 1;
            }
        }
        assert!(
            rows_with_l >= 2,
            "expected long left content to span >=2 visual rows, got {rows_with_l}"
        );
    }

    #[test]
    fn should_not_wrap_when_wrap_disabled_in_side_by_side() {
        let long_left = "L".repeat(200);
        let mut app = make_pr_app();
        app.diff_files = vec![diff_file_with_pair(&long_left, "short")];
        app.set_diff_wrap(false);
        app.rebuild_annotations();

        let buf = draw_sbs(&mut app, 160, 20);

        let rows_with_l: u16 = (0..buf.area.height)
            .filter(|&y| {
                (0..buf.area.width)
                    .map(|x| char_at(&buf, x, y))
                    .collect::<String>()
                    .contains("LLLLLLLLLL")
            })
            .count() as u16;
        assert_eq!(
            rows_with_l, 1,
            "wrap-off should produce exactly one row of L, got {rows_with_l}"
        );
    }

    #[test]
    fn should_align_divider_on_wrapped_rows_in_side_by_side() {
        let long_left = "L".repeat(200);
        let mut app = make_pr_app();
        app.diff_files = vec![diff_file_with_pair(&long_left, "short")];
        app.set_diff_wrap(true);
        app.rebuild_annotations();

        let lw = 1usize;
        let inner_w = 158usize;
        let content_width = (inner_w - crate::app::sbs_overhead(lw) as usize) / 2;
        let divider_x_inner = crate::app::sbs_left_gutter(lw) as usize + content_width;
        let divider_glyph_x = 1 + divider_x_inner + 1;

        let buf = draw_sbs(&mut app, 160, 20);

        let mut rows_with_l: Vec<u16> = Vec::new();
        for y in 0..buf.area.height {
            let row: String = (0..buf.area.width).map(|x| char_at(&buf, x, y)).collect();
            if row.contains("LLLLLLLLLL") {
                rows_with_l.push(y);
            }
        }
        assert!(
            rows_with_l.len() >= 2,
            "expected ≥2 wrapped rows, got {}",
            rows_with_l.len()
        );
        for y in &rows_with_l {
            let glyph = char_at(&buf, divider_glyph_x as u16, *y);
            assert_eq!(
                glyph, "│",
                "expected │ at col {divider_glyph_x} on row {y}, got {glyph:?}"
            );
        }
    }

    #[test]
    fn should_pad_shorter_column_on_wrapped_rows_in_side_by_side() {
        let long_left = "L".repeat(200);
        let mut app = make_pr_app();
        app.diff_files = vec![diff_file_with_standalone_deletion(&long_left)];
        app.set_diff_wrap(true);
        app.rebuild_annotations();

        let buf = draw_sbs(&mut app, 160, 20);

        let lw = 1usize;
        let inner_w = 158usize;
        let content_width = (inner_w - crate::app::sbs_overhead(lw) as usize) / 2;
        let divider_glyph_x = 1 + crate::app::sbs_left_gutter(lw) as usize + content_width + 1;
        let right_content_start = divider_glyph_x + 2 + lw + 1 + 1;
        let right_content_end = right_content_start + content_width;

        let mut checked = 0;
        for y in 0..buf.area.height {
            let row: String = (0..buf.area.width).map(|x| char_at(&buf, x, y)).collect();
            if !row.contains("LLLLLLLLLL") {
                continue;
            }
            checked += 1;
            let right: String = (right_content_start..right_content_end)
                .map(|x| char_at(&buf, x as u16, y))
                .collect();
            assert!(
                right.trim().is_empty(),
                "right column should be blank on wrapped L row {y}, got {right:?}"
            );
        }
        assert!(
            checked >= 2,
            "expected ≥2 wrapped rows to check, got {checked}"
        );
    }

    fn commit_message_file(message: &str) -> DiffFile {
        let lines: Vec<DiffLine> = message
            .lines()
            .enumerate()
            .map(|(i, line)| DiffLine {
                origin: LineOrigin::Context,
                content: line.to_string(),
                old_lineno: None,
                new_lineno: Some(i as u32 + 1),
                highlighted_spans: None,
            })
            .collect();
        let new_count = lines.len() as u32;
        let hunks = vec![DiffHunk {
            header: String::new(),
            lines,
            old_start: 0,
            old_count: 0,
            new_start: 1,
            new_count,
        }];
        let content_hash = DiffFile::compute_content_hash(&hunks);
        DiffFile {
            old_path: None,
            new_path: Some(PathBuf::from("Commit Message (abc1234)")),
            status: FileStatus::Added,
            hunks,
            is_binary: false,
            is_too_large: false,
            is_commit_message: true,
            content_hash,
        }
    }

    #[test]
    fn should_render_commit_message_full_width_in_side_by_side() {
        let mut app = make_pr_app();
        app.diff_files = vec![commit_message_file("COMMITMSG summary line")];
        app.rebuild_annotations();

        let buf = draw_sbs(&mut app, 160, 20);

        let mut checked = 0;
        for y in 0..buf.area.height {
            let row: String = (0..buf.area.width).map(|x| char_at(&buf, x, y)).collect();
            let Some(col) = row.find("COMMITMSG") else {
                continue;
            };
            checked += 1;
            // Full-width prose: rendered near the left edge (small indent), not
            // pushed into the right diff column, and with no column divider.
            assert!(
                col < 8,
                "commit message should start near the left edge, got col {col} on row {y}: {row:?}"
            );
            assert!(
                !row.contains(" │ "),
                "commit message row should not have a column divider on row {y}: {row:?}"
            );
        }
        assert_eq!(
            checked, 1,
            "expected the commit message body to render exactly once, got {checked}"
        );
    }
}
