use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect, Spacing};
use ratatui::style::{Modifier, Style};
use ratatui::symbols::merge::MergeStrategy;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use unicode_width::UnicodeWidthStr;

use crate::diff::{FileDiff, LineKind, build_split_rows};
use crate::state::{App, Focus, Mode};
use crate::theme::{Theme, mix_color};

pub fn render(f: &mut Frame, app: &mut App) {
    let theme = Theme::for_name_with_overrides(app.theme_name, &app.color_overrides);

    let footer_height = if app.show_hints { 2 } else { 1 };
    let outer = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Min(1),
            Constraint::Length(footer_height),
        ])
        .split(f.area());

    crate::components::header::render(f, app, outer[0], &theme);
    render_body(f, app, outer[1], &theme);
    crate::components::footer::render(f, app, outer[2], &theme);

    if app.show_comments_list {
        crate::components::comments_list::render(f, app, &theme);
    }
    if app.show_revision_selector {
        crate::components::revision_selector::render(f, app, &theme);
    }
    if app.show_help {
        crate::components::help_overlay::render(f, &theme);
    }
    if app.preview_mode {
        render_comments_preview_modal(f, app, &theme);
    }
    if app.mode == Mode::Insert && app.comment_modal {
        crate::components::comment_composer::render(f, app, &theme);
    }
}

/// Chrome for the body region. Neighbouring Blocks use
/// `MergeStrategy::Exact` + `Spacing::Overlap(1)` so shared edges collapse
/// into the correct junction glyphs natively.
fn render_body(f: &mut Frame, app: &mut App, area: Rect, theme: &Theme) {
    if area.height < 3 || area.width < 4 {
        app.file_list_area = Rect::default();
        app.diff_area = Rect::default();
        return;
    }

    if !theme.chrome.show_borders() {
        render_body_sparse(f, app, area, theme);
        return;
    }

    let [subheader_band, content_band] =
        Layout::vertical([Constraint::Length(2), Constraint::Min(1)])
            .spacing(Spacing::Overlap(1))
            .areas::<2>(area);

    let border_fg = theme.border_unfocused;
    let make_block = |borders: Borders, bg| {
        Block::new()
            .borders(borders)
            .merge_borders(MergeStrategy::Exact)
            .border_style(Style::default().fg(border_fg).bg(bg))
            .style(Style::default().bg(bg))
    };
    let sh_borders = Borders::LEFT | Borders::RIGHT | Borders::BOTTOM;
    let body_borders = Borders::LEFT | Borders::RIGHT | Borders::TOP;

    if app.show_file_list {
        // Pane width includes the overlap column — ratatui merges it with
        // the neighbouring block's LEFT border so it renders once.
        let pane_w = app.file_list_width + 1;
        let h_layout = Layout::horizontal([Constraint::Length(pane_w), Constraint::Min(10)])
            .spacing(Spacing::Overlap(1));
        let [files_sh_area, diff_sh_area] = h_layout.areas::<2>(subheader_band);
        let [files_body_area, diff_body_area] = h_layout.areas::<2>(content_band);

        let files_sh_block = make_block(sh_borders, theme.bg);
        let diff_sh_block = make_block(sh_borders, theme.bg);
        let files_body_block = make_block(body_borders, theme.bg);
        let diff_body_block = make_block(body_borders, theme.bg);

        let files_sh_inner = files_sh_block.inner(files_sh_area);
        let diff_sh_inner = diff_sh_block.inner(diff_sh_area);
        let files_body_inner = files_body_block.inner(files_body_area);
        let diff_body_inner = diff_body_block.inner(diff_body_area);

        f.render_widget(files_sh_block, files_sh_area);
        f.render_widget(diff_sh_block, diff_sh_area);
        f.render_widget(files_body_block, files_body_area);
        f.render_widget(diff_body_block, diff_body_area);

        app.file_list_area = files_body_inner;
        app.diff_area = diff_body_inner;

        let focus = app.focus;
        render_file_list(f, app, files_sh_inner, files_body_inner, theme);
        render_diff(f, app, diff_sh_inner, diff_body_inner, theme);

        // Dim the unfocused pane so the focused one reads as brighter.
        let unfocused = match focus {
            Focus::Files => diff_body_inner,
            Focus::Diff => files_body_inner,
        };
        f.buffer_mut()
            .set_style(unfocused, Style::default().add_modifier(Modifier::DIM));
    } else {
        let diff_sh_block = make_block(sh_borders, theme.bg);
        let diff_body_block = make_block(body_borders, theme.bg);

        let diff_sh_inner = diff_sh_block.inner(subheader_band);
        let diff_body_inner = diff_body_block.inner(content_band);

        f.render_widget(diff_sh_block, subheader_band);
        f.render_widget(diff_body_block, content_band);

        app.file_list_area = Rect::default();
        app.diff_area = diff_body_inner;
        render_diff(f, app, diff_sh_inner, diff_body_inner, theme);
    }
}

/// Sumi (sparse) body — no block chrome, just a flat divider rule.
fn render_body_sparse(f: &mut Frame, app: &mut App, area: Rect, theme: &Theme) {
    let sh_area = Rect {
        x: area.x,
        y: area.y,
        width: area.width,
        height: 1,
    };
    let divider_area = Rect {
        x: area.x,
        y: area.y + 1,
        width: area.width,
        height: 1,
    };
    let content_area = Rect {
        x: area.x,
        y: area.y + 2,
        width: area.width,
        height: area.height - 2,
    };

    let rule_style = Style::default().fg(theme.border_unfocused).bg(theme.bg);
    let rule = Line::from(Span::styled("─".repeat(area.width as usize), rule_style))
        .style(Style::default().bg(theme.bg));
    f.render_widget(
        Paragraph::new(rule).style(Style::default().bg(theme.bg)),
        divider_area,
    );

    if app.show_file_list {
        let h_layout =
            Layout::horizontal([Constraint::Length(app.file_list_width), Constraint::Min(10)]);
        let [files_sh, diff_sh] = h_layout.areas::<2>(sh_area);
        let [files_body, diff_body] = h_layout.areas::<2>(content_area);

        app.file_list_area = files_body;
        app.diff_area = diff_body;
        render_file_list(f, app, files_sh, files_body, theme);
        render_diff(f, app, diff_sh, diff_body, theme);
    } else {
        app.file_list_area = Rect::default();
        app.diff_area = content_area;
        render_diff(f, app, sh_area, content_area, theme);
    }
}

fn render_file_list(
    f: &mut Frame,
    app: &mut App,
    subheader_inner: Rect,
    body_inner: Rect,
    theme: &Theme,
) {
    let tree = build_file_tree(
        &app.files,
        &app.comments,
        &app.expanded_dirs,
        app.no_emoji,
        app.file_filter.as_deref(),
    );

    fn collect_items<'a>(entries: &'a [FileTreeEntry], out: &mut Vec<&'a FileTreeEntry>) {
        for entry in entries {
            out.push(entry);
            if let FileTreeEntry::Dir { children, .. } = entry {
                collect_items(children, out);
            }
        }
    }

    let mut flat_items: Vec<&FileTreeEntry> = Vec::new();
    collect_items(&tree, &mut flat_items);

    let selected_file_row = flat_items.iter().position(|e| {
        matches!(
            e,
            FileTreeEntry::FileInfo { file_idx, .. } if *file_idx == app.selected
        )
    });
    let items: Vec<ListItem> = flat_items
        .iter()
        .map(|entry| match entry {
            FileTreeEntry::Dir { label, depth, .. } => {
                let indent = "  ".repeat(*depth);
                ListItem::new(Line::from(vec![
                    Span::raw(indent),
                    Span::styled(
                        label.clone(),
                        Style::default()
                            .fg(theme.dir_fg)
                            .add_modifier(Modifier::BOLD),
                    ),
                ]))
            }
            FileTreeEntry::FileInfo {
                file_idx,
                display,
                depth,
                badge,
                status,
                counts,
                comment_badge,
                viewed,
            } => {
                let is_selected = *file_idx == app.selected;
                let badge_style = match status {
                    crate::diff::FileStatus::Added => Style::default().fg(theme.status_added),
                    crate::diff::FileStatus::Modified => Style::default().fg(theme.status_modified),
                    crate::diff::FileStatus::Deleted => Style::default().fg(theme.status_deleted),
                    crate::diff::FileStatus::Renamed => Style::default().fg(theme.status_renamed),
                };
                let dim = if *viewed && !is_selected {
                    Modifier::DIM
                } else {
                    Modifier::empty()
                };
                let viewed_mark = if *viewed && !is_selected {
                    "\u{2713}"
                } else {
                    ""
                };
                let indent = "  ".repeat(*depth);

                let line = Line::from(vec![
                    Span::raw(indent),
                    Span::styled(badge.clone(), badge_style.add_modifier(dim)),
                    Span::styled(
                        display.clone(),
                        Style::default().fg(theme.context_fg).add_modifier(dim),
                    ),
                    Span::styled(
                        counts.clone(),
                        Style::default().fg(theme.dim_fg).add_modifier(dim),
                    ),
                    Span::styled(
                        comment_badge.clone(),
                        Style::default()
                            .fg(theme.shuire)
                            .add_modifier(Modifier::BOLD),
                    ),
                    Span::styled(
                        viewed_mark.to_string(),
                        Style::default().fg(theme.status_added),
                    ),
                ]);
                ListItem::new(line)
            }
        })
        .collect();
    let highlight_idx = if flat_items.is_empty() {
        None
    } else if app.focus == Focus::Files {
        Some(app.file_tree_cursor.min(flat_items.len() - 1))
    } else {
        selected_file_row
    };

    if body_inner.height == 0 {
        return;
    }

    let title_fg = if app.focus == Focus::Files {
        theme.shuire
    } else {
        theme.dim_fg
    };
    let count_str = format!("({}) ", app.files.len());
    let mut header_spans = vec![
        Span::styled(
            " FILES ",
            Style::default()
                .fg(title_fg)
                .bg(theme.bg)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(count_str, Style::default().fg(theme.dim_fg).bg(theme.bg)),
    ];
    if app.mode == Mode::FileFilter {
        header_spans.push(Span::styled(
            format!(" /{}", app.input),
            Style::default()
                .fg(theme.shuire)
                .bg(theme.bg)
                .add_modifier(Modifier::BOLD),
        ));
        header_spans.push(Span::styled(
            "▏",
            Style::default().fg(theme.caret_fg).bg(theme.bg),
        ));
    } else if let Some(ref q) = app.file_filter {
        header_spans.push(Span::styled(
            format!(" /{}", q),
            Style::default().fg(theme.context_fg).bg(theme.bg),
        ));
    }
    let header_used: usize = header_spans.iter().map(|s| s.content.width()).sum();
    let sh_w = subheader_inner.width as usize;
    if sh_w > header_used {
        header_spans.push(Span::styled(
            " ".repeat(sh_w - header_used),
            Style::default().bg(theme.bg),
        ));
    }
    let header_line = Line::from(header_spans).style(Style::default().bg(theme.bg));
    f.render_widget(Paragraph::new(header_line), subheader_inner);

    let items: Vec<ListItem> = items
        .into_iter()
        .map(|it| it.style(Style::default().bg(theme.bg)))
        .collect();

    let list = List::new(items)
        .style(Style::default().bg(theme.bg))
        .highlight_style(Style::default().bg(theme.selection));

    let mut state = ListState::default();
    state.select(highlight_idx);
    f.render_stateful_widget(list, body_inner, &mut state);
}

enum FileTreeEntry {
    Dir {
        path: String,
        label: String,
        depth: usize,
        children: Vec<FileTreeEntry>,
    },
    FileInfo {
        file_idx: usize,
        display: String,
        depth: usize,
        badge: String,
        status: crate::diff::FileStatus,
        counts: String,
        comment_badge: String,
        viewed: bool,
    },
}

/// Emoji icon based on file extension (ccmux style).
fn get_file_icon(filename: &str) -> &'static str {
    let ext = filename.rsplit('.').next().unwrap_or("");
    match ext {
        "rs" => "🦀",
        "js" | "ts" | "jsx" | "tsx" | "mjs" | "cjs" => "⚡",
        "py" => "🐍",
        "go" => "🐹",
        "java" => "☕",
        "rb" => "💎",
        "toml" => "🔧",
        "yaml" | "yml" => "🔗",
        "json" => "📋",
        "md" => "📄",
        "css" | "scss" | "sass" | "less" => "🎨",
        "html" | "htm" => "🌐",
        "sql" => "💾",
        "sh" | "bash" | "zsh" => "🐚",
        "dockerfile" => "📦",
        "lock" => "🔒",
        "env" => "🔑",
        "gitignore" | "dockerignore" | "editorconfig" => "🚫",
        _ => "📄",
    }
}

fn build_file_tree(
    files: &[FileDiff],
    comments: &[crate::state::Comment],
    expanded_dirs: &std::collections::HashSet<String>,
    no_emoji: bool,
    file_filter: Option<&str>,
) -> Vec<FileTreeEntry> {
    use std::collections::BTreeMap;

    struct DirNode {
        children_dirs: BTreeMap<String, DirNode>,
        files: Vec<usize>,
    }

    impl DirNode {
        fn new() -> Self {
            Self {
                children_dirs: BTreeMap::new(),
                files: Vec::new(),
            }
        }

        fn has_files(&self) -> bool {
            if !self.files.is_empty() {
                return true;
            }
            self.children_dirs.values().any(|c| c.has_files())
        }
    }

    let filter_lower = file_filter
        .filter(|q| !q.is_empty())
        .map(|q| q.to_lowercase());
    let matches_filter = |path: &str| -> bool {
        match &filter_lower {
            Some(q) => path.to_lowercase().contains(q),
            None => true,
        }
    };

    let mut root = DirNode::new();
    for (i, file) in files.iter().enumerate() {
        if !matches_filter(&file.path) {
            continue;
        }
        let parts: Vec<&str> = file.path.rsplitn(2, '/').collect();
        if parts.len() == 2 {
            let dir_path = parts[1];
            let segments: Vec<&str> = dir_path.split('/').collect();
            let mut node = &mut root;
            for seg in &segments {
                node = node
                    .children_dirs
                    .entry(seg.to_string())
                    .or_insert_with(DirNode::new);
            }
            node.files.push(i);
        } else {
            root.files.push(i);
        }
    }

    fn render_tree(
        node: &DirNode,
        parent_path: &str,
        depth: usize,
        expanded_dirs: &std::collections::HashSet<String>,
        files: &[FileDiff],
        comments: &[crate::state::Comment],
        no_emoji: bool,
    ) -> Vec<FileTreeEntry> {
        let mut entries = Vec::new();

        for (name, child) in &node.children_dirs {
            if !child.has_files() {
                continue;
            }

            let full_path = if parent_path.is_empty() {
                name.clone()
            } else {
                format!("{}/{}", parent_path, name)
            };

            let is_expanded = expanded_dirs.contains(&full_path);
            let collapse_icon = if no_emoji {
                if is_expanded { "▾" } else { "▸" }
            } else if is_expanded {
                "\u{1f4c2}" // 📂 open
            } else {
                "\u{1f4c1}" // 📁 closed
            };

            let label = format!("{collapse_icon} {name}/");

            let children = if is_expanded {
                render_tree(
                    child,
                    &full_path,
                    depth + 1,
                    expanded_dirs,
                    files,
                    comments,
                    no_emoji,
                )
            } else {
                vec![]
            };

            entries.push(FileTreeEntry::Dir {
                path: full_path,
                label,
                depth,
                children,
            });
        }

        for &file_idx in &node.files {
            let file = &files[file_idx];
            let filename = file.path.rsplit('/').next().unwrap_or(&file.path);
            let n_comments = comments.iter().filter(|c| c.file == file.path).count();
            let display = if no_emoji {
                filename.to_string()
            } else {
                format!("{} {filename}", get_file_icon(filename))
            };
            let comment_badge = if n_comments == 0 {
                String::new()
            } else {
                format!(" 朱{n_comments}")
            };

            entries.push(FileTreeEntry::FileInfo {
                file_idx,
                display,
                depth,
                badge: format!("{} ", file.status.badge()),
                status: file.status,
                counts: format!(" +{}/-{}", file.added, file.removed),
                comment_badge,
                viewed: file.viewed,
            });
        }

        entries
    }

    render_tree(&root, "", 0, expanded_dirs, files, comments, no_emoji)
}

/// File indices ordered to match the file-tree display (directories first, alphabetically).
/// Theme-independent, so app-layer code can use it for navigation.
pub fn tree_file_order(files: &[FileDiff]) -> Vec<usize> {
    use std::collections::BTreeMap;

    struct Node {
        dirs: BTreeMap<String, Node>,
        files: Vec<usize>,
    }
    impl Node {
        fn new() -> Self {
            Self {
                dirs: BTreeMap::new(),
                files: Vec::new(),
            }
        }
    }

    let mut root = Node::new();
    for (i, file) in files.iter().enumerate() {
        let parts: Vec<&str> = file.path.rsplitn(2, '/').collect();
        if parts.len() == 2 {
            let mut node = &mut root;
            for seg in parts[1].split('/') {
                node = node.dirs.entry(seg.to_string()).or_insert_with(Node::new);
            }
            node.files.push(i);
        } else {
            root.files.push(i);
        }
    }

    fn flatten(node: &Node, out: &mut Vec<usize>) {
        for child in node.dirs.values() {
            flatten(child, out);
        }
        out.extend(&node.files);
    }

    let mut out = Vec::with_capacity(files.len());
    flatten(&root, &mut out);
    out
}

pub fn file_tree_indices(
    files: &[FileDiff],
    comments: &[crate::state::Comment],
    expanded_dirs: &std::collections::HashSet<String>,
    file_filter: Option<&str>,
) -> Vec<Option<usize>> {
    flatten_file_tree(files, comments, expanded_dirs, file_filter)
        .into_iter()
        .map(|e| match e {
            FlatFileTreeEntry::Dir { .. } => None,
            FlatFileTreeEntry::File { file_idx } => Some(file_idx),
        })
        .collect()
}

/// Flat representation of the file tree for navigation (both dirs and files).
#[derive(Debug, Clone)]
pub enum FlatFileTreeEntry {
    Dir { path: String },
    File { file_idx: usize },
}

pub fn flatten_file_tree(
    files: &[FileDiff],
    comments: &[crate::state::Comment],
    expanded_dirs: &std::collections::HashSet<String>,
    file_filter: Option<&str>,
) -> Vec<FlatFileTreeEntry> {
    // Navigation mirrors the rendered tree so j/k / next-file don't land on
    // filtered-out entries. Icon choice has no effect on the flat structure.
    let tree = build_file_tree(files, comments, expanded_dirs, false, file_filter);
    let mut out: Vec<FlatFileTreeEntry> = Vec::new();
    fn walk(entries: &[FileTreeEntry], out: &mut Vec<FlatFileTreeEntry>) {
        for e in entries {
            match e {
                FileTreeEntry::Dir { path, children, .. } => {
                    out.push(FlatFileTreeEntry::Dir { path: path.clone() });
                    walk(children, out);
                }
                FileTreeEntry::FileInfo { file_idx, .. } => {
                    out.push(FlatFileTreeEntry::File {
                        file_idx: *file_idx,
                    });
                }
            }
        }
    }
    walk(&tree, &mut out);
    out
}

/// Get the topmost parent directory path for a given file.
pub fn get_parent_dirs(file_path: &str) -> Vec<String> {
    if let Some(parent) = std::path::Path::new(file_path).parent() {
        let mut paths = Vec::new();
        let mut cur = parent.as_os_str().to_string_lossy().into_owned();
        loop {
            if cur.is_empty() || cur == "." {
                break;
            }
            paths.push(cur.clone());
            if let Some(p) = std::path::Path::new(&cur).parent() {
                cur = p.as_os_str().to_string_lossy().into_owned();
            } else {
                break;
            }
        }
        paths.reverse();
        paths
    } else {
        Vec::new()
    }
}

fn render_diff(
    f: &mut Frame,
    app: &mut App,
    subheader_inner: Rect,
    body_inner: Rect,
    theme: &Theme,
) {
    render_diff_subheader(f, app, subheader_inner, theme, app.split_view);

    if app.split_view {
        render_split_diff(f, app, body_inner, theme);
        return;
    }

    if body_inner.height == 0 {
        return;
    }

    app.viewport_height = body_inner.height as usize;
    let (lines, row_map) = match app.current() {
        Some(file) => render_file_diff(
            file,
            app.diff_scroll,
            body_inner.height as usize,
            body_inner.width as usize,
            app.cursor_line,
            app,
            theme,
        ),
        None => (vec![Line::from("(no files)")], Vec::new()),
    };

    let paragraph = Paragraph::new(lines).style(Style::default().bg(theme.bg));
    f.render_widget(paragraph, body_inner);
    app.diff_row_map = row_map;
}

fn render_comments_preview_modal(f: &mut Frame, app: &App, theme: &Theme) {
    use ratatui::widgets::{Block, Borders, Clear};
    let area = centered_rect(80, 80, f.area());
    f.render_widget(Clear, area);

    let block = Block::default()
        .borders(Borders::ALL)
        .title(Span::styled(
            " Preview (stdout) ",
            Style::default()
                .fg(theme.bg)
                .bg(theme.shuire)
                .add_modifier(Modifier::BOLD),
        ))
        .border_style(Style::default().fg(theme.shuire).bg(theme.bg))
        .style(Style::default().bg(theme.bg));
    let inner = block.inner(area);
    f.render_widget(block, area);
    if inner.width == 0 || inner.height == 0 {
        return;
    }

    let text = app.format_all_comments();
    let lines: Vec<Line> = if text.is_empty() {
        vec![Line::from(Span::styled(
            "  (no comments — stdout output would be empty)".to_string(),
            Style::default().fg(theme.dim_fg).bg(theme.bg),
        ))]
    } else {
        text.split('\n')
            .map(|l| {
                let style = if l.starts_with("> ") {
                    Style::default().fg(theme.dim_fg).bg(theme.bg)
                } else if l == "=====" {
                    Style::default().fg(theme.shuire_dim).bg(theme.bg)
                } else if l.contains(":L") {
                    Style::default()
                        .fg(theme.shuire)
                        .bg(theme.bg)
                        .add_modifier(Modifier::BOLD)
                } else {
                    Style::default().fg(theme.context_fg).bg(theme.bg)
                };
                Line::from(Span::styled(l.to_string(), style))
            })
            .collect()
    };
    f.render_widget(
        Paragraph::new(lines).style(Style::default().bg(theme.bg)),
        inner,
    );
}

/// 1-row diff pane sub-header:
///   ` ━━ path/to/filename  +add −del  (pos/total)  │  unified  │  side-by-side `
fn render_diff_subheader(f: &mut Frame, app: &App, area: Rect, theme: &Theme, split: bool) {
    let Some(file) = app.current() else {
        let line = Line::from(Span::styled(
            " (no diff) ",
            Style::default().fg(theme.dim_fg).bg(theme.bg),
        ));
        f.render_widget(
            Paragraph::new(line).style(Style::default().bg(theme.bg)),
            area,
        );
        return;
    };
    let (dir_part, file_part) = match file.path.rfind('/') {
        Some(p) => (&file.path[..=p], &file.path[p + 1..]),
        None => ("", file.path.as_str()),
    };
    let (pos, total) = if app.files.is_empty() {
        (0, 0)
    } else {
        let order = tree_file_order(&app.files);
        let p = order.iter().position(|&i| i == app.selected).unwrap_or(0);
        (p + 1, app.files.len())
    };
    let stats = format!("  +{} −{}  ({pos}/{total})  ", file.added, file.removed);

    let bar_bg = theme.bg;
    let (unified_fg, sbs_fg) = if split {
        (theme.dim_fg, theme.shuire)
    } else {
        (theme.shuire, theme.dim_fg)
    };

    let spans = vec![
        Span::styled(
            " ━━ ",
            Style::default()
                .fg(theme.shuire)
                .bg(bar_bg)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            dir_part.to_string(),
            Style::default().fg(theme.context_fg).bg(bar_bg),
        ),
        Span::styled(
            file_part.to_string(),
            Style::default()
                .fg(theme.shuire)
                .bg(bar_bg)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(stats, Style::default().fg(theme.dim_fg).bg(bar_bg)),
        Span::styled("│", Style::default().fg(theme.border_unfocused).bg(bar_bg)),
        Span::styled(
            "  unified  ",
            Style::default()
                .fg(unified_fg)
                .bg(bar_bg)
                .add_modifier(if !split {
                    Modifier::BOLD
                } else {
                    Modifier::empty()
                }),
        ),
        Span::styled("│", Style::default().fg(theme.border_unfocused).bg(bar_bg)),
        Span::styled(
            "  side-by-side  ",
            Style::default()
                .fg(sbs_fg)
                .bg(bar_bg)
                .add_modifier(if split {
                    Modifier::BOLD
                } else {
                    Modifier::empty()
                }),
        ),
    ];
    let line = Line::from(spans).style(Style::default().bg(bar_bg));
    f.render_widget(Paragraph::new(line), area);
}

fn render_split_diff(f: &mut Frame, app: &mut App, body_inner: Rect, theme: &Theme) {
    if body_inner.height == 0 || body_inner.width < 21 {
        return;
    }
    // Reserve 1 column in the middle for a `│` separator between OLD and
    // NEW panes (design.md §6.6).
    let halves = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Min(10),
            Constraint::Length(1),
            Constraint::Min(10),
        ])
        .split(body_inner);

    let left_inner = halves[0];
    let right_inner = halves[2];

    // Draw the center separator column.
    let sep_col_x = halves[1].x;
    let sep_style = Style::default().fg(theme.border_unfocused).bg(theme.bg);
    let sep_lines: Vec<Line> = (0..body_inner.height)
        .map(|_| Line::from(Span::styled("│", sep_style)))
        .collect();
    f.render_widget(
        Paragraph::new(sep_lines).style(Style::default().bg(theme.bg)),
        Rect {
            x: sep_col_x,
            y: body_inner.y,
            width: 1,
            height: body_inner.height,
        },
    );
    app.viewport_height = left_inner.height as usize;
    let file = match app.current() {
        Some(f) => f,
        None => {
            let p = Paragraph::new("(no files)").style(Style::default().bg(theme.bg));
            f.render_widget(p.clone(), halves[0]);
            f.render_widget(p, halves[2]);
            return;
        }
    };

    let split_rows = build_split_rows(file);

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

    let height = left_inner.height as usize;
    let lw = left_inner.width as usize;
    let rw = right_inner.width as usize;

    let mut left_lines: Vec<Line> = Vec::new();
    let mut right_lines: Vec<Line> = Vec::new();
    let blank_line = || Line::from(Span::raw(""));
    let empty_side_line =
        |w: usize| Line::from(Span::raw(" ".repeat(w))).style(Style::default().bg(theme.bg_alt));
    let search_needle_lower: Option<String> = app.search_query.as_deref().map(str::to_lowercase);

    for row in split_rows.iter().skip(scroll_row) {
        if left_lines.len() >= height {
            break;
        }

        let left = build_split_side_line(
            row.left,
            file,
            app,
            lw,
            true,
            search_needle_lower.as_deref(),
            theme,
        );
        let right = build_split_side_line(
            row.right,
            file,
            app,
            rw,
            false,
            search_needle_lower.as_deref(),
            theme,
        );
        let max_wrap = left.len().max(right.len());
        for ri in 0..max_wrap {
            if left_lines.len() >= height {
                break;
            }
            left_lines.push(left.get(ri).cloned().unwrap_or_else(|| empty_side_line(lw)));
            right_lines.push(
                right
                    .get(ri)
                    .cloned()
                    .unwrap_or_else(|| empty_side_line(rw)),
            );
        }

        let left_comments: Vec<&crate::state::Comment> = row
            .left
            .map(|i| app.comments_on(file, i))
            .unwrap_or_default()
            .into_iter()
            .filter(|c| c.side == crate::state::Side::Old)
            .collect();
        let right_comments: Vec<&crate::state::Comment> = row
            .right
            .map(|i| app.comments_on(file, i))
            .unwrap_or_default()
            .into_iter()
            .filter(|c| c.side == crate::state::Side::New)
            .collect();

        let cursor_idx = app.cursor_line;
        let cursor_on_left = row.left == Some(cursor_idx) && row.right != Some(cursor_idx);
        let cursor_on_right = row.right == Some(cursor_idx);

        let max_comments = left_comments.len().max(right_comments.len());
        for ci in 0..max_comments {
            if left_lines.len() >= height {
                break;
            }
            let mut left_block: Vec<Line> = Vec::new();
            let mut right_block: Vec<Line> = Vec::new();
            if let Some(c) = left_comments.get(ci) {
                let is_focused = cursor_on_left && app.comment_focus == Some(ci);
                let loc = c.location();
                push_comment_block(
                    &mut left_block,
                    height,
                    lw,
                    SPLIT_GUTTER_WIDTH,
                    Some(&loc),
                    &c.body,
                    is_focused,
                    theme,
                );
            }
            if let Some(c) = right_comments.get(ci) {
                let is_focused = cursor_on_right && app.comment_focus == Some(ci);
                let loc = c.location();
                push_comment_block(
                    &mut right_block,
                    height,
                    rw,
                    SPLIT_GUTTER_WIDTH,
                    Some(&loc),
                    &c.body,
                    is_focused,
                    theme,
                );
            }
            let max_rows = left_block.len().max(right_block.len());
            for ri in 0..max_rows {
                if left_lines.len() >= height {
                    break;
                }
                left_lines.push(left_block.get(ri).cloned().unwrap_or_else(&blank_line));
                right_lines.push(right_block.get(ri).cloned().unwrap_or_else(&blank_line));
            }
        }
    }

    let lp = Paragraph::new(left_lines).style(Style::default().bg(theme.bg));
    let rp = Paragraph::new(right_lines).style(Style::default().bg(theme.bg));
    f.render_widget(lp, halves[0]);
    f.render_widget(rp, halves[2]);
}

pub(crate) const SPLIT_GUTTER_WIDTH: usize = 6; // 5 (line number) + 1 (prefix)

fn code_line_style(
    diff_line: &crate::diff::DiffLine,
    theme: &Theme,
) -> (
    &'static str,
    ratatui::style::Color,
    Option<ratatui::style::Color>,
) {
    match diff_line.kind {
        LineKind::Added => ("+", theme.added_fg, Some(theme.added_bg)),
        LineKind::Removed => ("-", theme.removed_fg, Some(theme.removed_bg)),
        _ => (" ", theme.context_fg, None),
    }
}

fn build_split_side_line(
    line_idx: Option<usize>,
    file: &FileDiff,
    app: &App,
    width: usize,
    is_left: bool,
    search_needle_lower: Option<&str>,
    theme: &Theme,
) -> Vec<Line<'static>> {
    let Some(idx) = line_idx else {
        return vec![
            Line::from(Span::raw(" ".repeat(width))).style(Style::default().bg(theme.bg_alt)),
        ];
    };
    let diff_line = &file.lines[idx];
    let is_cursor = idx == app.cursor_line;
    let side = if is_left {
        crate::state::Side::Old
    } else {
        crate::state::Side::New
    };
    let underline = app.underline_kind_for_side(file, idx, side);

    let gutter_num = if is_left {
        diff_line.old_lineno
    } else {
        diff_line.new_lineno
    };
    let gutter = match gutter_num {
        Some(n) => format!("{:>4} ", n),
        None => "     ".to_string(),
    };

    if diff_line.kind.is_fold() {
        let style = if is_cursor {
            Style::default()
                .fg(theme.fold_fg)
                .bg(theme.cursor_blend(theme.header_bg))
        } else {
            Style::default().fg(theme.fold_fg).bg(theme.header_bg)
        };
        let cursor_header_bg = if is_cursor {
            theme.cursor_blend(theme.header_bg)
        } else {
            theme.header_bg
        };
        let arrow = match diff_line.kind {
            LineKind::FoldDown => "▼",
            LineKind::FoldUp => "▲",
            _ => " ",
        };
        let mut spans = vec![
            Span::styled(
                gutter,
                Style::default().fg(theme.gutter_fg).bg(cursor_header_bg),
            ),
            Span::styled(" ", Style::default().bg(cursor_header_bg)),
            Span::styled(format!("{arrow} {:>3} lines ", diff_line.fold_count), style),
        ];
        if !is_left && !diff_line.hunk_header.is_empty() {
            let (range, ctx) = split_hunk_header(&diff_line.hunk_header);
            spans.push(Span::styled(
                range,
                Style::default().fg(theme.header_range_fg),
            ));
            if !ctx.is_empty() {
                spans.push(Span::styled(
                    ctx,
                    Style::default()
                        .fg(theme.header_context_fg)
                        .add_modifier(Modifier::BOLD),
                ));
            }
        }
        pad_line_bg(&mut spans, width, cursor_header_bg);
        return vec![Line::from(spans).style(Style::default().bg(cursor_header_bg))];
    }

    if diff_line.kind == LineKind::HunkHeader {
        let (range, ctx) = split_hunk_header(&diff_line.text);
        let banner_bg = theme.bg_alt;
        let mut spans = vec![
            Span::styled("  ", Style::default().bg(banner_bg)),
            Span::styled(gutter, Style::default().fg(theme.gutter_fg).bg(banner_bg)),
            Span::styled(
                range,
                Style::default()
                    .fg(theme.header_range_fg)
                    .bg(banner_bg)
                    .add_modifier(Modifier::BOLD),
            ),
        ];
        if !ctx.is_empty() {
            spans.push(Span::styled(
                ctx,
                Style::default()
                    .fg(theme.header_range_fg)
                    .bg(banner_bg)
                    .add_modifier(Modifier::BOLD),
            ));
        }
        pad_line_bg(&mut spans, width, banner_bg);
        return vec![Line::from(spans).style(Style::default().bg(banner_bg))];
    }

    let (prefix, kind_fg, bg) = code_line_style(diff_line, theme);

    // When cursor is on a line with a diff background, blend the two colors
    let effective_bg = if is_cursor {
        Some(match bg {
            Some(b) => theme.cursor_blend(b),
            None => theme.cursor_bg,
        })
    } else {
        bg
    };

    let mut spans: Vec<Span<'static>> = Vec::new();
    let mut gs = Style::default().fg(theme.gutter_fg);
    if let Some(b) = effective_bg {
        gs = gs.bg(b);
    }
    spans.push(Span::styled(gutter, gs));

    let mut ps = Style::default().fg(kind_fg);
    if let Some(b) = effective_bg {
        ps = ps.bg(b);
    }
    spans.push(Span::styled(prefix.to_string(), ps));

    push_code_spans(
        &mut spans,
        diff_line,
        kind_fg,
        effective_bg,
        is_cursor,
        search_needle_lower,
        theme,
    );
    push_no_newline_marker(&mut spans, diff_line, effective_bg, theme);

    let base_style = match effective_bg {
        Some(b) => Style::default().bg(b),
        None => Style::default(),
    };
    let mut lines = wrap_line(spans, width, SPLIT_GUTTER_WIDTH, base_style);
    if let Some(color) = underline_color(underline, theme) {
        apply_comment_underline(&mut lines, color, SPLIT_GUTTER_WIDTH);
    }
    if let Some(b) = effective_bg {
        for line in &mut lines {
            pad_line_bg(&mut line.spans, width, b);
        }
    }
    lines
}

fn underline_color(
    kind: crate::state::UnderlineKind,
    theme: &Theme,
) -> Option<ratatui::style::Color> {
    match kind {
        crate::state::UnderlineKind::None => None,
        crate::state::UnderlineKind::Dim => Some(theme.comment_bar_dim),
        crate::state::UnderlineKind::Bright => Some(theme.comment_bar),
    }
}

fn apply_comment_underline(
    lines: &mut [Line<'static>],
    color: ratatui::style::Color,
    gutter_width: usize,
) {
    for line in lines.iter_mut() {
        let mut consumed = 0usize;
        for span in line.spans.iter_mut() {
            if consumed >= gutter_width {
                span.style = span
                    .style
                    .add_modifier(Modifier::UNDERLINED)
                    .underline_color(color);
            }
            consumed += span.content.width();
        }
    }
}

pub(crate) const GUTTER_INDENT: &str = "            ";

/// Byte-range matches of `needle_lower` inside `haystack`, case-insensitive.
/// `needle_lower` is the caller-lowercased query; hoisting the lowercase
/// out of this function matters because it's called per visible diff line.
/// Returns non-overlapping `(start, end)` pairs in the original haystack.
fn find_case_insensitive_ranges(haystack: &str, needle_lower: &str) -> Vec<(usize, usize)> {
    if needle_lower.is_empty() {
        return Vec::new();
    }
    let hay_lower = haystack.to_lowercase();
    let mut out = Vec::new();
    let mut start = 0;
    while let Some(rel) = hay_lower[start..].find(needle_lower) {
        let s = start + rel;
        let e = s + needle_lower.len();
        // Clamp to haystack bounds — lowercase can change byte length for
        // some code points, so guard against drift.
        if e > haystack.len() {
            break;
        }
        out.push((s, e));
        start = e;
    }
    out
}

/// Push syntax-highlighted code spans, overlaying word-level diff highlights
/// on byte ranges listed in `diff_line.word_highlights`, plus search-match
/// ranges computed from `search_needle_lower` (already lowercased by the
/// frame-level caller to avoid re-lowering per line).
fn push_code_spans(
    spans: &mut Vec<Span<'static>>,
    diff_line: &crate::diff::DiffLine,
    kind_fg: ratatui::style::Color,
    effective_bg: Option<ratatui::style::Color>,
    is_cursor: bool,
    search_needle_lower: Option<&str>,
    theme: &Theme,
) {
    let word_bg = match diff_line.kind {
        LineKind::Added => Some(theme.added_word_bg),
        LineKind::Removed => Some(theme.removed_word_bg),
        _ => None,
    };
    let effective_word_bg = if is_cursor {
        word_bg.map(|b| theme.cursor_blend(b))
    } else {
        word_bg
    };

    let search_ranges: Vec<(usize, usize)> = search_needle_lower
        .filter(|q| !q.is_empty())
        .map(|q| find_case_insensitive_ranges(&diff_line.text, q))
        .unwrap_or_default();
    let search_bg = if is_cursor {
        theme.cursor_blend(theme.mode_search_bg)
    } else {
        theme.mode_search_bg
    };

    let in_word_hl = |offset: usize| -> bool {
        diff_line
            .word_highlights
            .iter()
            .any(|(s, e)| offset >= *s && offset < *e)
    };
    let in_search_hl = |offset: usize| -> bool {
        search_ranges
            .iter()
            .any(|(s, e)| offset >= *s && offset < *e)
    };

    let push_chunk = |spans: &mut Vec<Span<'static>>,
                      text: &str,
                      start_offset: usize,
                      fg: ratatui::style::Color| {
        let bytes = text.as_bytes();
        let mut i = 0;
        while i < bytes.len() {
            let start = i;
            let start_word = in_word_hl(start_offset + start);
            let start_search = in_search_hl(start_offset + start);
            let mut j = start + 1;
            while j < bytes.len() && (bytes[j] & 0xC0) == 0x80 {
                j += 1;
            }
            while j < bytes.len()
                && in_word_hl(start_offset + j) == start_word
                && in_search_hl(start_offset + j) == start_search
            {
                let mut k = j + 1;
                while k < bytes.len() && (bytes[k] & 0xC0) == 0x80 {
                    k += 1;
                }
                j = k;
            }
            let bg = if start_search {
                Some(search_bg)
            } else if start_word {
                effective_word_bg.or(effective_bg)
            } else {
                effective_bg
            };
            let mut style = Style::default().fg(fg);
            if let Some(b) = bg {
                style = style.bg(b);
            }
            spans.push(Span::styled(text[start..j].to_string(), style));
            i = j;
        }
    };

    if !diff_line.segments.is_empty() {
        let mut offset = 0usize;
        for (kind, text) in &diff_line.segments {
            let fg = theme.syntax.color(*kind);
            push_chunk(spans, text, offset, fg);
            offset += text.len();
        }
    } else {
        push_chunk(spans, &diff_line.text, 0, kind_fg);
    }
}

fn render_file_diff<'a>(
    file: &'a FileDiff,
    scroll: usize,
    height: usize,
    width: usize,
    cursor_line: usize,
    app: &'a App,
    theme: &Theme,
) -> (Vec<Line<'a>>, Vec<usize>) {
    let mut out: Vec<Line<'a>> = Vec::new();
    let mut row_map: Vec<usize> = Vec::new();
    let search_needle_lower: Option<String> = app.search_query.as_deref().map(str::to_lowercase);
    let mut idx = scroll;
    while idx < file.lines.len() && out.len() < height {
        let diff_line = &file.lines[idx];
        let is_cursor = idx == cursor_line;
        let code_active = is_cursor && app.comment_focus.is_none();
        let is_visual = app.is_in_visual(idx);

        let comments = app.comments_on(file, idx);
        let underline = app.underline_kind(file, idx);
        let code_lines = build_code_line(
            diff_line,
            code_active,
            is_visual,
            underline,
            width,
            search_needle_lower.as_deref(),
            theme,
        );
        for cl in code_lines {
            if out.len() >= height {
                break;
            }
            out.push(cl);
            row_map.push(idx);
        }

        for (ci, c) in comments.iter().enumerate() {
            let is_focused = is_cursor && app.comment_focus == Some(ci);
            let before = out.len();
            let loc = c.location();
            push_comment_block(
                &mut out,
                height,
                width,
                GUTTER_INDENT.len(),
                Some(&loc),
                &c.body,
                is_focused,
                theme,
            );
            for _ in before..out.len() {
                row_map.push(idx);
            }
            for reply in &c.replies {
                if out.len() >= height {
                    break;
                }
                let before = out.len();
                push_reply_block(&mut out, height, width, GUTTER_INDENT.len(), reply, theme);
                for _ in before..out.len() {
                    row_map.push(idx);
                }
            }
            if out.len() >= height {
                break;
            }
        }

        if !app.comment_modal
            && app.mode == Mode::Insert
            && is_cursor
            && app.editing_comment.is_none()
            && out.len() < height
        {
            push_inline_input(&mut out, height, &app.input, app.input_cursor, theme);
            let input_rows = app.input.matches('\n').count() + 1;
            for _ in 0..input_rows {
                row_map.push(idx);
            }
        }

        idx += 1;
    }
    (out, row_map)
}

#[derive(Clone, Copy, PartialEq)]
enum BodySegKind {
    Text,
    SuggestionNew,
}

struct BodySeg<'a> {
    kind: BodySegKind,
    text: &'a str,
}

fn parse_comment_body<'a>(body: &'a str) -> Vec<BodySeg<'a>> {
    let mut out = Vec::new();
    let mut in_suggestion = false;
    for line in body.split('\n') {
        let trimmed = line.trim_start();
        if !in_suggestion && (trimmed == "```suggestion" || trimmed.starts_with("```suggestion ")) {
            in_suggestion = true;
            continue;
        }
        if in_suggestion && trimmed == "```" {
            in_suggestion = false;
            continue;
        }
        let kind = if in_suggestion {
            BodySegKind::SuggestionNew
        } else {
            BodySegKind::Text
        };
        out.push(BodySeg { kind, text: line });
    }
    out
}

fn render_markdown_line(
    text: &str,
    base_fg: ratatui::style::Color,
    bg: ratatui::style::Color,
) -> Vec<Span<'static>> {
    let base = Style::default().fg(base_fg).bg(bg);
    let bold_like = base.add_modifier(Modifier::BOLD);
    let mut spans: Vec<Span<'static>> = Vec::new();

    let (line_style, rest) = if let Some(rest) = text
        .strip_prefix("### ")
        .or_else(|| text.strip_prefix("## "))
        .or_else(|| text.strip_prefix("# "))
    {
        (bold_like, rest)
    } else if let Some(rest) = text.strip_prefix("> ") {
        spans.push(Span::styled(
            "┃ ".to_string(),
            base.add_modifier(Modifier::DIM),
        ));
        (base.add_modifier(Modifier::ITALIC), rest)
    } else if let Some(rest) = text.strip_prefix("- ").or_else(|| text.strip_prefix("* ")) {
        spans.push(Span::styled("• ".to_string(), base));
        (base, rest)
    } else {
        (base, text)
    };

    let bytes = rest.as_bytes();
    let mut i = 0;
    let mut buf = String::new();
    while i < bytes.len() {
        if bytes[i] == b'`' {
            if !buf.is_empty() {
                spans.push(Span::styled(std::mem::take(&mut buf), line_style));
            }
            let start = i + 1;
            let mut j = start;
            while j < bytes.len() && bytes[j] != b'`' {
                j += 1;
            }
            spans.push(Span::styled(
                rest[start..j].to_string(),
                base.add_modifier(Modifier::REVERSED),
            ));
            i = if j < bytes.len() { j + 1 } else { j };
            continue;
        }
        if i + 1 < bytes.len() && bytes[i] == b'*' && bytes[i + 1] == b'*' {
            if !buf.is_empty() {
                spans.push(Span::styled(std::mem::take(&mut buf), line_style));
            }
            let start = i + 2;
            let mut j = start;
            while j + 1 < bytes.len() && !(bytes[j] == b'*' && bytes[j + 1] == b'*') {
                j += 1;
            }
            spans.push(Span::styled(
                rest[start..j].to_string(),
                line_style.add_modifier(Modifier::BOLD),
            ));
            i = if j + 1 < bytes.len() {
                j + 2
            } else {
                bytes.len()
            };
            continue;
        }
        if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] != b' ' && bytes[i + 1] != b'*' {
            if !buf.is_empty() {
                spans.push(Span::styled(std::mem::take(&mut buf), line_style));
            }
            let start = i + 1;
            let mut j = start;
            while j < bytes.len() && bytes[j] != b'*' {
                j += 1;
            }
            spans.push(Span::styled(
                rest[start..j].to_string(),
                line_style.add_modifier(Modifier::ITALIC),
            ));
            i = if j < bytes.len() { j + 1 } else { j };
            continue;
        }
        let ch_len = rest[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
        buf.push_str(&rest[i..i + ch_len]);
        i += ch_len;
    }
    if !buf.is_empty() {
        spans.push(Span::styled(buf, line_style));
    }
    spans
}

#[allow(clippy::too_many_arguments)]
fn push_comment_block<'a>(
    out: &mut Vec<Line<'a>>,
    height: usize,
    width: usize,
    gutter_width: usize,
    header_label: Option<&str>,
    body: &str,
    is_focused: bool,
    theme: &Theme,
) {
    let bg = if is_focused {
        theme.cursor_blend(theme.comment_bg)
    } else {
        theme.comment_bg
    };
    let border_color = if is_focused {
        theme.shuire
    } else {
        theme.shuire_dim
    };
    let text_fg = theme.comment_fg;
    let indent = &GUTTER_INDENT[..gutter_width.min(GUTTER_INDENT.len())];

    let box_width = width.saturating_sub(indent.width());
    if box_width < 4 {
        return;
    }
    let inner_w = box_width - 2;
    let border_style = Style::default().fg(border_color).bg(bg);

    if out.len() >= height {
        return;
    }
    push_box_border_line(out, indent, '╭', '╮', inner_w, border_style, width, bg);

    if let Some(label) = header_label {
        if out.len() < height {
            let mut spans = vec![
                Span::styled(indent, Style::default().bg(bg)),
                Span::styled("│ ", border_style),
                Span::styled(
                    label.to_string(),
                    Style::default()
                        .fg(theme.shuire)
                        .bg(bg)
                        .add_modifier(Modifier::BOLD),
                ),
            ];
            pad_to_box_right(&mut spans, width, bg, border_style);
            out.push(Line::from(spans).style(Style::default().bg(bg)));
        }
    }

    for seg in parse_comment_body(body) {
        if out.len() >= height {
            return;
        }
        let mut spans = vec![
            Span::styled(indent, Style::default().bg(bg)),
            Span::styled("│ ".to_string(), border_style),
        ];
        match seg.kind {
            BodySegKind::Text => {
                spans.extend(render_markdown_line(seg.text, text_fg, bg));
            }
            BodySegKind::SuggestionNew => {
                spans.push(Span::styled(
                    "+ ".to_string(),
                    Style::default().fg(theme.added_fg).bg(theme.added_bg),
                ));
                spans.push(Span::styled(
                    seg.text.to_string(),
                    Style::default().fg(theme.added_fg).bg(theme.added_bg),
                ));
            }
        }
        pad_to_box_right(&mut spans, width, bg, border_style);
        out.push(Line::from(spans).style(Style::default().bg(bg)));
    }

    if out.len() < height {
        push_box_border_line(out, indent, '╰', '╯', inner_w, border_style, width, bg);
    }
}

#[allow(clippy::too_many_arguments)]
fn push_box_border_line<'a>(
    out: &mut Vec<Line<'a>>,
    indent: &'a str,
    left_corner: char,
    right_corner: char,
    inner_w: usize,
    border_style: Style,
    width: usize,
    bg: ratatui::style::Color,
) {
    let mut border = String::with_capacity(inner_w + 2);
    border.push(left_corner);
    for _ in 0..inner_w {
        border.push('─');
    }
    border.push(right_corner);
    let mut spans = vec![
        Span::styled(indent, Style::default().bg(bg)),
        Span::styled(border, border_style),
    ];
    pad_line_bg(&mut spans, width, bg);
    out.push(Line::from(spans).style(Style::default().bg(bg)));
}

/// Pad the current spans to fill the row, placing a right-hand `│` just
/// inside the remaining whitespace so the comment box closes on the right.
fn pad_to_box_right<'a>(
    spans: &mut Vec<Span<'a>>,
    width: usize,
    bg: ratatui::style::Color,
    border_style: Style,
) {
    let used: usize = spans.iter().map(|s| s.content.width()).sum();
    if used + 2 >= width {
        // Not enough room for right bar — just pad background.
        pad_line_bg(spans, width, bg);
        return;
    }
    let gap = width - used - 1;
    spans.push(Span::styled(" ".repeat(gap), Style::default().bg(bg)));
    spans.push(Span::styled("│".to_string(), border_style));
}

fn push_reply_block<'a>(
    out: &mut Vec<Line<'a>>,
    height: usize,
    width: usize,
    gutter_width: usize,
    body: &str,
    theme: &Theme,
) {
    let bg = theme.comment_bg;
    let text_fg = theme.comment_fg;
    let border_style = Style::default().fg(theme.shuire_dim).bg(bg);
    let indent = &GUTTER_INDENT[..gutter_width.min(GUTTER_INDENT.len())];
    let indent_w = indent.width();
    let box_width = width.saturating_sub(indent_w + 2);
    if box_width < 4 {
        return;
    }

    for (i, text) in body.split('\n').enumerate() {
        if out.len() >= height {
            return;
        }
        let marker = if i == 0 { "↳ " } else { "  " };
        let mut spans = vec![
            Span::styled(indent, Style::default().bg(bg)),
            Span::styled("  ".to_string(), Style::default().bg(bg)),
            Span::styled("│ ".to_string(), border_style),
            Span::styled(
                marker.to_string(),
                Style::default().fg(theme.shuire_dim).bg(bg),
            ),
        ];
        spans.extend(render_markdown_line(text, text_fg, bg));
        pad_to_box_right(&mut spans, width, bg, border_style);
        out.push(Line::from(spans).style(Style::default().bg(bg)));
    }
}

fn push_inline_input<'a>(
    out: &mut Vec<Line<'a>>,
    height: usize,
    input: &'a str,
    input_cursor: usize,
    theme: &Theme,
) {
    let input_style = Style::default().fg(theme.input_fg).bg(theme.input_bg);
    let marker_style = Style::default().fg(theme.input_bar);
    let caret_style = Style::default().fg(theme.caret_fg).bg(theme.caret_bg);
    let mut byte_offset: usize = 0;
    for (i, line_text) in input.split('\n').enumerate() {
        if out.len() >= height {
            break;
        }
        let marker = if i == 0 { "▸ " } else { "│ " };
        let line_start = byte_offset;
        let line_end = line_start + line_text.len();
        let cursor_in_line = input_cursor >= line_start && input_cursor <= line_end;
        let mut spans = vec![Span::raw(GUTTER_INDENT), Span::styled(marker, marker_style)];
        if cursor_in_line {
            let local = input_cursor - line_start;
            let before = &line_text[..local];
            let rest = &line_text[local..];
            let (glyph, after): (std::borrow::Cow<'a, str>, &'a str) = match rest.chars().next() {
                Some(ch) => (ch.to_string().into(), &rest[ch.len_utf8()..]),
                None => (" ".into(), ""),
            };
            spans.push(Span::styled(before, input_style));
            spans.push(Span::styled(glyph, caret_style));
            spans.push(Span::styled(after, input_style));
        } else {
            spans.push(Span::styled(line_text, input_style));
        }
        out.push(Line::from(spans));
        byte_offset = line_end + 1;
    }
}

fn build_code_line(
    diff_line: &crate::diff::DiffLine,
    is_cursor: bool,
    is_visual: bool,
    underline: crate::state::UnderlineKind,
    width: usize,
    search_needle_lower: Option<&str>,
    theme: &Theme,
) -> Vec<Line<'static>> {
    if diff_line.kind.is_fold() {
        return vec![build_fold_line(diff_line, is_cursor, width, theme)];
    }
    if diff_line.kind == LineKind::HunkHeader {
        return vec![build_hunk_header_line(diff_line, width, theme)];
    }

    let (prefix, kind_fg, kind_bg) = code_line_style(diff_line, theme);

    let effective_bg = if is_cursor {
        Some(match kind_bg {
            Some(b) => theme.cursor_blend(b),
            None => theme.cursor_bg,
        })
    } else if is_visual {
        Some(match kind_bg {
            Some(b) => mix_color(b, theme.visual_bg, 0.5),
            None => theme.visual_bg,
        })
    } else {
        kind_bg
    };

    let mut spans: Vec<Span<'static>> = Vec::with_capacity(4 + diff_line.segments.len().max(1));

    let mut anchor_style = Style::default().fg(theme.dim_fg);
    if let Some(b) = effective_bg {
        anchor_style = anchor_style.bg(b);
    }
    spans.push(Span::styled("  ", anchor_style));

    let mut gutter_style = Style::default().fg(theme.gutter_fg);
    if let Some(b) = effective_bg {
        gutter_style = gutter_style.bg(b);
    }
    spans.push(Span::styled(diff_line.gutter.clone(), gutter_style));
    let mut marker_style = Style::default();
    if let Some(b) = effective_bg {
        marker_style = marker_style.bg(b);
    }
    spans.push(Span::styled(" ", marker_style));

    let mut prefix_style = Style::default().fg(kind_fg);
    if let Some(b) = effective_bg {
        prefix_style = prefix_style.bg(b);
    }
    spans.push(Span::styled(prefix.to_string(), prefix_style));

    push_code_spans(
        &mut spans,
        diff_line,
        kind_fg,
        effective_bg,
        is_cursor,
        search_needle_lower,
        theme,
    );
    push_no_newline_marker(&mut spans, diff_line, effective_bg, theme);

    let base_style = match effective_bg {
        Some(b) => Style::default().bg(b),
        None => Style::default(),
    };
    let mut lines = wrap_line(spans, width, GUTTER_INDENT.len(), base_style);
    if let Some(color) = underline_color(underline, theme) {
        apply_comment_underline(&mut lines, color, GUTTER_INDENT.len());
    }
    if let Some(b) = effective_bg {
        for line in &mut lines {
            pad_line_bg(&mut line.spans, width, b);
        }
    }
    lines
}

fn push_no_newline_marker(
    spans: &mut Vec<Span<'static>>,
    diff_line: &crate::diff::DiffLine,
    effective_bg: Option<ratatui::style::Color>,
    theme: &Theme,
) {
    if !diff_line.no_newline {
        return;
    }
    let mut style = Style::default().fg(theme.dim_fg);
    if let Some(b) = effective_bg {
        style = style.bg(b);
    }
    spans.push(Span::styled(" ⏎no newline at EOF", style));
}

fn build_fold_line(
    diff_line: &crate::diff::DiffLine,
    is_cursor: bool,
    width: usize,
    theme: &Theme,
) -> Line<'static> {
    let line_bg = if is_cursor {
        theme.cursor_blend(theme.header_bg)
    } else {
        theme.header_bg
    };
    let fold_style = Style::default().fg(theme.fold_fg).bg(line_bg);
    let arrow = match diff_line.kind {
        LineKind::FoldDown => "▼",
        LineKind::FoldUp => "▲",
        _ => " ",
    };

    let mut spans = vec![
        Span::styled("  ", Style::default().bg(line_bg)),
        Span::styled(
            diff_line.gutter.clone(),
            Style::default().fg(theme.gutter_fg).bg(line_bg),
        ),
        Span::styled("  ", Style::default().bg(line_bg)),
        Span::styled(
            format!("{arrow} {:>3} lines ", diff_line.fold_count),
            fold_style,
        ),
    ];

    if !diff_line.hunk_header.is_empty() {
        let (range_part, context_part) = split_hunk_header(&diff_line.hunk_header);
        spans.push(Span::styled(
            range_part,
            Style::default().fg(theme.header_range_fg),
        ));
        if !context_part.is_empty() {
            spans.push(Span::styled(
                context_part,
                Style::default()
                    .fg(theme.header_context_fg)
                    .add_modifier(Modifier::BOLD),
            ));
        }
    }

    let line_bg = if is_cursor {
        theme.cursor_bg
    } else {
        theme.header_bg
    };
    truncate_spans(&mut spans, width);
    pad_line_bg(&mut spans, width, line_bg);
    Line::from(spans).style(Style::default().bg(line_bg))
}

fn build_hunk_header_line(
    diff_line: &crate::diff::DiffLine,
    width: usize,
    theme: &Theme,
) -> Line<'static> {
    let (range_part, context_part) = split_hunk_header(&diff_line.text);
    let banner_bg = theme.bg_alt;
    let mut spans = vec![
        Span::styled("  ", Style::default().bg(banner_bg)),
        Span::styled(
            diff_line.gutter.clone(),
            Style::default().fg(theme.gutter_fg).bg(banner_bg),
        ),
        Span::styled(
            range_part,
            Style::default()
                .fg(theme.header_range_fg)
                .bg(banner_bg)
                .add_modifier(Modifier::BOLD),
        ),
    ];
    if !context_part.is_empty() {
        spans.push(Span::styled(
            context_part,
            Style::default()
                .fg(theme.header_range_fg)
                .bg(banner_bg)
                .add_modifier(Modifier::BOLD),
        ));
    }
    truncate_spans(&mut spans, width);
    pad_line_bg(&mut spans, width, banner_bg);
    Line::from(spans).style(Style::default().bg(banner_bg))
}

/// Return the byte offset in `s` where the first `max_width` display columns end.
pub(crate) fn byte_offset_at_width(s: &str, max_width: usize) -> usize {
    let mut byte_pos = 0;
    let mut w = 0;
    for ch in s.chars() {
        let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
        if w + cw > max_width {
            break;
        }
        w += cw;
        byte_pos += ch.len_utf8();
    }
    byte_pos
}

fn split_spans_at<'a>(spans: Vec<Span<'a>>, max_width: usize) -> (Vec<Span<'a>>, Vec<Span<'a>>) {
    let mut before = Vec::new();
    let mut after = Vec::new();
    let mut used = 0usize;
    let mut exceeded = false;

    for span in spans {
        if exceeded {
            after.push(span);
            continue;
        }

        let w = span.content.width();
        if used + w <= max_width {
            before.push(span);
            used += w;
        } else {
            let remaining = max_width - used;
            let content_str: &str = &span.content;
            let byte_pos = byte_offset_at_width(content_str, remaining);
            if byte_pos > 0 {
                before.push(Span::styled(
                    content_str[..byte_pos].to_string(),
                    span.style,
                ));
            }
            if byte_pos < content_str.len() {
                after.push(Span::styled(
                    content_str[byte_pos..].to_string(),
                    span.style,
                ));
            }
            exceeded = true;
        }
    }

    (before, after)
}

/// Wrap a line's spans into multiple visual lines, indenting continuation lines
/// by `indent` columns so they don't overlap the gutter/prefix area.
fn wrap_line<'a>(
    spans: Vec<Span<'a>>,
    width: usize,
    indent: usize,
    base_style: Style,
) -> Vec<Line<'a>> {
    if width == 0 {
        return vec![Line::from(spans).style(base_style)];
    }

    let total: usize = spans.iter().map(|s| s.content.width()).sum();
    if total <= width {
        return vec![Line::from(spans).style(base_style)];
    }

    let mut result: Vec<Line<'a>> = Vec::new();
    let mut remaining = spans;
    let mut is_first = true;

    while !remaining.is_empty() {
        let avail = if is_first {
            width
        } else {
            width.saturating_sub(indent)
        };
        if avail == 0 {
            break;
        }

        let (taken, rest) = split_spans_at(remaining, avail);
        if taken.is_empty() {
            break;
        }

        let line_spans = if is_first {
            taken
        } else {
            let mut v = Vec::with_capacity(1 + taken.len());
            v.push(Span::styled(&GUTTER_INDENT[..indent], base_style));
            v.extend(taken);
            v
        };

        result.push(Line::from(line_spans).style(base_style));
        remaining = rest;
        is_first = false;
    }

    if result.is_empty() {
        result.push(Line::default().style(base_style));
    }

    result
}

fn truncate_spans(spans: &mut Vec<Span<'static>>, width: usize) {
    let mut used = 0usize;
    let mut truncate_at = None;
    for (i, span) in spans.iter().enumerate() {
        let w = span.content.width();
        if used + w > width {
            truncate_at = Some((i, width - used));
            break;
        }
        used += w;
    }
    if let Some((idx, remaining_chars)) = truncate_at {
        let content = &spans[idx].content;
        let byte_end = byte_offset_at_width(content, remaining_chars);
        let style = spans[idx].style;
        let truncated = content[..byte_end].to_string();
        spans[idx] = Span::styled(truncated, style);
        spans.truncate(idx + 1);
    }
}

fn pad_line_bg<'a>(spans: &mut Vec<Span<'a>>, width: usize, bg: ratatui::style::Color) {
    let used: usize = spans.iter().map(|s| s.content.width()).sum();
    let pad = width.saturating_sub(used);
    if pad > 0 {
        spans.push(Span::styled(" ".repeat(pad), Style::default().bg(bg)));
    }
}

fn split_hunk_header(line: &str) -> (String, String) {
    if let Some(first) = line.find("@@") {
        if let Some(second) = line[first + 2..].find("@@") {
            let range_end = first + 2 + second + 2;
            let range = line[..range_end].to_string();
            let context = line[range_end..].to_string();
            return (range, context);
        }
    }
    (line.to_string(), String::new())
}

pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(r);

    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1]
}
