//! Line viewer popup for selecting line ranges from a file.
//!
//! A centered modal overlay showing syntax-highlighted file content with
//! line numbers. Backed by [`ListPaneState`] for navigation, visual selection,
//! and search. Used to build `@foo/bar.rs:10-12` line references.
//!
//! ## Lifecycle
//!
//! 1. Opened via `:` in dropdown, `Ctrl-L` on element, or `<left>:` after element.
//! 2. User navigates with j/k, searches with `/`, selects range with `v`.
//! 3. **Enter** confirms → element updated with line range, undo group closed.
//! 4. **Esc** cancels → undo group cancelled, reverts to pre-viewer state.

use std::ops::Range;
use std::path::{Path, PathBuf};

use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{StatefulWidget, Widget};
use syntect::easy::HighlightLines;

use crate::render::scrollbar::SCROLLBAR_TOTAL_COLS;
use crate::render::wrapping::word_wrap_line;
use crate::scrollback::blocks::markdown_content::MarkdownContent;
use crate::syntax::get_syntect;
use crate::theme::Theme;
use crate::views::list_pane::{
    ListItem, ListPane, ListPaneConfig, ListPaneState, ListPaneStyle, WrapMode,
};

use kigi_ratatui_textarea::ElementId;

// ── Line item ───────────────────────────────────────────────────────────

/// A single source line for the line viewer.
///
/// In normal mode, each item has one `content` line (syntax-highlighted source).
/// In markdown mode, `rendered_lines` holds the rendered markdown output for this
/// source line (may be multiple visual lines, e.g., table with borders). The item
/// uses custom `render()` and `desired_height()` for multi-line display.
#[derive(Clone)]
pub struct SourceLine {
    /// 1-based line number (for display and `@file:N-M` references).
    line_number: usize,
    /// Unique item ID for ListPane selection tracking. In normal mode this
    /// equals `line_number`. In markdown mode, source lines can repeat
    /// (e.g., table borders) so we use a monotonic counter instead.
    item_id: u64,
    /// Styled content (syntax highlighted). Used in normal mode.
    content: Line<'static>,
    /// Prefix: right-aligned line number (dim — default).
    prefix: Line<'static>,
    /// Prefix for visual selection range (medium brightness).
    prefix_in_selection: Line<'static>,
    /// Prefix for the cursor line (brightest).
    prefix_cursor: Line<'static>,
    /// Blank prefix for continuation lines in multi-line items.
    prefix_blank: Line<'static>,
    /// Plain text for search matching.
    plain_text: String,
    /// Rendered markdown lines (empty in normal mode).
    rendered_lines: Vec<Line<'static>>,
    /// Per-rendered-line background color (for code blocks).
    rendered_bgs: Vec<Option<Color>>,
    /// Whether this source line has a comment attached (for highlight).
    pub commented: bool,
}

impl SourceLine {
    fn new(
        line_number: usize,
        content: Line<'static>,
        plain_text: String,
        max_digits: usize,
    ) -> Self {
        let (prefix, prefix_in_selection, prefix_cursor, prefix_blank) =
            build_prefixes(line_number, max_digits);
        Self {
            line_number,
            item_id: line_number as u64,
            content,
            prefix,
            prefix_in_selection,
            prefix_cursor,
            prefix_blank,
            plain_text,
            rendered_lines: Vec::new(),
            rendered_bgs: Vec::new(),
            commented: false,
        }
    }

    fn new_markdown(
        item_id: u64,
        line_number: usize,
        rendered_lines: Vec<Line<'static>>,
        rendered_bgs: Vec<Option<Color>>,
        plain_text: String,
        max_digits: usize,
    ) -> Self {
        let (prefix, prefix_in_selection, prefix_cursor, prefix_blank) =
            build_prefixes(line_number, max_digits);
        Self {
            line_number,
            item_id,
            content: Line::default(),
            prefix,
            prefix_in_selection,
            prefix_cursor,
            prefix_blank,
            plain_text,
            rendered_lines,
            rendered_bgs,
            commented: false,
        }
    }

    fn is_markdown(&self) -> bool {
        !self.rendered_lines.is_empty()
    }
}

fn build_prefixes(
    line_number: usize,
    max_digits: usize,
) -> (Line<'static>, Line<'static>, Line<'static>, Line<'static>) {
    let theme = Theme::current();
    let num_str = format!("{:>width$} ", line_number, width = max_digits);
    let blank_str = " ".repeat(num_str.len());
    let prefix = Line::from(Span::styled(
        num_str.clone(),
        Style::default().fg(theme.gray_dim),
    ));
    let prefix_in_selection = Line::from(Span::styled(
        num_str.clone(),
        Style::default().fg(theme.gray),
    ));
    let prefix_cursor = Line::from(Span::styled(
        num_str,
        Style::default().fg(theme.text_secondary),
    ));
    let prefix_blank = Line::from(Span::styled(blank_str, Style::default().fg(theme.gray_dim)));
    (prefix, prefix_in_selection, prefix_cursor, prefix_blank)
}

impl ListItem for SourceLine {
    fn content(&self) -> &Line<'_> {
        if self.is_markdown() {
            // Empty content triggers custom render().
            static EMPTY: std::sync::LazyLock<Line<'static>> =
                std::sync::LazyLock::new(Line::default);
            &EMPTY
        } else {
            &self.content
        }
    }

    fn prefix(&self) -> Option<Line<'_>> {
        if self.commented {
            Some(self.prefix_in_selection.clone())
        } else {
            Some(self.prefix.clone())
        }
    }

    fn prefix_in_selection(&self) -> Option<Line<'_>> {
        Some(self.prefix_in_selection.clone())
    }

    fn prefix_cursor(&self) -> Option<Line<'_>> {
        Some(self.prefix_cursor.clone())
    }

    fn stable_id(&self) -> u64 {
        self.item_id
    }

    fn search_text(&self) -> &str {
        &self.plain_text
    }

    fn copy_text(&self) -> String {
        if self.is_markdown() {
            self.plain_text.clone()
        } else {
            self.content
                .spans
                .iter()
                .map(|s| s.content.as_ref())
                .collect()
        }
    }

    fn desired_height(&self, width: u16) -> u16 {
        if !self.is_markdown() || width == 0 {
            return 1;
        }
        let prefix_w = self
            .prefix()
            .map(|p| crate::views::list_pane::line_display_width(&p))
            .unwrap_or(0);
        let text_w = (width as usize).saturating_sub(prefix_w).max(1);
        let mut total: u16 = 0;
        for line in &self.rendered_lines {
            let wrapped = word_wrap_line(line, text_w);
            total += (wrapped.len() as u16).max(1);
        }
        total.max(1)
    }

    fn goto_line_number(&self) -> Option<usize> {
        Some(self.line_number)
    }

    fn render(&self, area: Rect, buf: &mut Buffer, _selected: bool, _focused: bool) {
        if !self.is_markdown() || area.height == 0 || area.width == 0 {
            return;
        }
        let theme = Theme::current();
        let prefix_w = self
            .prefix()
            .map(|p| crate::views::list_pane::line_display_width(&p))
            .unwrap_or(0) as u16;
        let text_x = area.x + prefix_w;
        let text_w = area.width.saturating_sub(prefix_w);

        // Apply commented background tint to the full item area.
        if self.commented {
            buf.set_style(area, Style::default().bg(theme.bg_visual));
        }

        // Render prefix on the first visual line (brighter when commented).
        let pfx = if self.commented {
            &self.prefix_in_selection
        } else {
            &self.prefix
        };
        buf.set_line(area.x, area.y, pfx, prefix_w);

        let mut y = area.y;
        let mut is_first_visual = true;
        for (i, line) in self.rendered_lines.iter().enumerate() {
            let bg = self.rendered_bgs.get(i).copied().flatten();
            let wrapped = word_wrap_line(line, text_w as usize);
            let visual_lines = if wrapped.is_empty() {
                vec![Line::default()]
            } else {
                wrapped
            };
            for wline in &visual_lines {
                if y >= area.y + area.height {
                    break;
                }
                if let Some(bg) = bg {
                    let bg_rect = Rect {
                        x: text_x,
                        y,
                        width: text_w,
                        height: 1,
                    };
                    buf.set_style(bg_rect, Style::default().bg(bg));
                }
                if !is_first_visual {
                    buf.set_line(area.x, y, &self.prefix_blank, prefix_w);
                }
                buf.set_line(text_x, y, wline, text_w);
                y += 1;
                is_first_visual = false;
            }
        }
    }
}

// ── Comment lines ─────────────────────────────────────────────────────

/// An inline review comment displayed between source lines.
pub struct CommentLine {
    pub comment_id: u64,
    item_id: u64,
    line_label: String,
    text: String,
    prefix: Line<'static>,
}

impl CommentLine {
    pub fn new(
        comment_id: u64,
        item_id: u64,
        line_range: &std::ops::Range<usize>,
        text: String,
        max_digits: usize,
    ) -> Self {
        let theme = Theme::current();
        let pad = " ".repeat(max_digits);
        let prefix = Line::from(Span::styled(
            format!("{pad} "),
            Style::default().fg(theme.accent_plan),
        ));
        let line_label = if line_range.len() == 1 {
            format!("L{}", line_range.start)
        } else {
            format!("L{}-{}", line_range.start, line_range.end - 1)
        };
        Self {
            comment_id,
            item_id,
            line_label,
            text,
            prefix,
        }
    }
}

impl ListItem for CommentLine {
    fn content(&self) -> &Line<'_> {
        static EMPTY: std::sync::LazyLock<Line<'static>> = std::sync::LazyLock::new(Line::default);
        &EMPTY
    }

    fn prefix(&self) -> Option<Line<'_>> {
        Some(self.prefix.clone())
    }

    fn prefix_in_selection(&self) -> Option<Line<'_>> {
        Some(self.prefix.clone())
    }

    fn prefix_cursor(&self) -> Option<Line<'_>> {
        Some(self.prefix.clone())
    }

    fn stable_id(&self) -> u64 {
        self.item_id
    }

    fn search_text(&self) -> &str {
        &self.text
    }

    fn copy_text(&self) -> String {
        self.text.clone()
    }

    fn desired_height(&self, width: u16) -> u16 {
        if width == 0 {
            return 1;
        }
        let prefix_w =
            crate::views::list_pane::line_display_width(&self.prefix().unwrap_or_default());
        let text_w = (width as usize).saturating_sub(prefix_w).max(1);
        self.text
            .split('\n')
            .enumerate()
            .map(|(i, text_line)| {
                let display = if i == 0 {
                    format!(
                        "{} {} {}",
                        crate::glyphs::filled_dot(),
                        self.line_label,
                        text_line
                    )
                } else {
                    format!("  {text_line}")
                };
                word_wrap_line(&Line::from(display), text_w).len() as u16
            })
            .sum::<u16>()
            .max(1)
    }

    fn render(&self, area: Rect, buf: &mut Buffer, _selected: bool, _focused: bool) {
        if area.height == 0 || area.width == 0 {
            return;
        }
        let theme = Theme::current();
        let prefix_w =
            crate::views::list_pane::line_display_width(&self.prefix().unwrap_or_default()) as u16;
        let text_x = area.x + prefix_w;
        let text_w = area.width.saturating_sub(prefix_w);

        buf.set_line(area.x, area.y, &self.prefix, prefix_w);

        let mut y = area.y;
        for (i, text_line) in self.text.split('\n').enumerate() {
            if y >= area.y + area.height {
                break;
            }
            let line = if i == 0 {
                let bullet = Span::styled(
                    format!("{} ", crate::glyphs::filled_dot()),
                    Style::default().fg(theme.accent_plan),
                );
                let label = Span::styled(
                    format!("{} ", self.line_label),
                    Style::default().fg(theme.gray),
                );
                let body = Span::styled(
                    text_line.to_owned(),
                    Style::default().fg(theme.text_primary),
                );
                Line::from(vec![bullet, label, body])
            } else {
                let indent = Span::styled("  ", Style::default().fg(theme.accent_plan));
                let body = Span::styled(
                    text_line.to_owned(),
                    Style::default().fg(theme.text_primary),
                );
                Line::from(vec![indent, body])
            };
            let wrapped = word_wrap_line(&line, text_w as usize);
            for wline in &wrapped {
                if y >= area.y + area.height {
                    break;
                }
                buf.set_line(text_x, y, wline, text_w);
                y += 1;
            }
        }
    }
}

// ── Plan viewer item ──────────────────────────────────────────────────

/// A viewer item: either a source line or an inline review comment.
pub enum PlanViewerItem {
    Source(Box<SourceLine>),
    Comment(CommentLine),
}

impl PlanViewerItem {
    /// The 1-based source line number, if this is a source line.
    pub fn line_number(&self) -> Option<usize> {
        match self {
            Self::Source(s) => Some(s.line_number),
            Self::Comment(_) => None,
        }
    }

    /// The comment ID, if this is a comment item.
    pub fn comment_id(&self) -> Option<u64> {
        match self {
            Self::Source(_) => None,
            Self::Comment(c) => Some(c.comment_id),
        }
    }
}

impl ListItem for PlanViewerItem {
    fn content(&self) -> &Line<'_> {
        match self {
            Self::Source(s) => s.content(),
            Self::Comment(c) => c.content(),
        }
    }

    fn prefix(&self) -> Option<Line<'_>> {
        match self {
            Self::Source(s) => s.prefix(),
            Self::Comment(c) => c.prefix(),
        }
    }

    fn prefix_in_selection(&self) -> Option<Line<'_>> {
        match self {
            Self::Source(s) => s.prefix_in_selection(),
            Self::Comment(c) => c.prefix_in_selection(),
        }
    }

    fn prefix_cursor(&self) -> Option<Line<'_>> {
        match self {
            Self::Source(s) => s.prefix_cursor(),
            Self::Comment(c) => c.prefix_cursor(),
        }
    }

    fn stable_id(&self) -> u64 {
        match self {
            Self::Source(s) => s.stable_id(),
            Self::Comment(c) => c.stable_id(),
        }
    }

    fn is_selectable(&self) -> bool {
        true
    }

    fn search_text(&self) -> &str {
        match self {
            Self::Source(s) => s.search_text(),
            Self::Comment(c) => c.search_text(),
        }
    }

    fn copy_text(&self) -> String {
        match self {
            Self::Source(s) => s.copy_text(),
            Self::Comment(c) => c.copy_text(),
        }
    }

    fn desired_height(&self, width: u16) -> u16 {
        match self {
            Self::Source(s) => s.desired_height(width),
            Self::Comment(c) => c.desired_height(width),
        }
    }

    fn render(&self, area: Rect, buf: &mut Buffer, selected: bool, focused: bool) {
        match self {
            Self::Source(s) => s.render(area, buf, selected, focused),
            Self::Comment(c) => c.render(area, buf, selected, focused),
        }
    }

    fn goto_line_number(&self) -> Option<usize> {
        match self {
            Self::Source(s) => Some(s.line_number),
            Self::Comment(_) => None,
        }
    }
}

// ── Viewer state ────────────────────────────────────────────────────────

/// What kind of content the line viewer is showing.
///
/// Replaces string-based type sniffing (`title_override == Some("plan.md")`)
/// with a typed enum so that plan-specific behavior (commenting, approval,
/// double-click, shortcuts) can be dispatched via `match` rather than
/// string comparison.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LineViewerKind {
    /// Normal file preview opened from an `@file` reference.
    #[default]
    FilePreview,
    /// Plan document preview (plan.md) — supports commenting, approval
    /// buttons, send-feedback, and double-click-to-comment.
    PlanPreview,
}

#[derive(Default)]
pub struct PlanViewerExtras {
    pub send_button_area: Option<Rect>,
    pub send_hovered: bool,
    pub show_action_buttons: bool,
    pub feedback_active: bool,
    pub approve_button_area: Option<Rect>,
    pub approve_hovered: bool,
    pub comment_button_area: Option<Rect>,
    pub comment_hovered: bool,
    pub abandon_button_area: Option<Rect>,
    pub abandon_hovered: bool,
    pub last_click_at: Option<std::time::Instant>,
    pub gutter_drag_start: Option<usize>,
    pub gutter_drag_end: Option<usize>,
    pub gutter_hovered_line: Option<usize>,
    pub active_commenting_range: Option<std::ops::Range<usize>>,
}

/// Double-click detection threshold in milliseconds.
pub const DOUBLE_CLICK_MS: u128 = 400;

/// State for the line viewer popup.
pub struct LineViewerState {
    /// What kind of content is being viewed (file vs plan).
    pub kind: LineViewerKind,
    /// The file being viewed.
    pub path: PathBuf,
    /// Viewer items (source lines interleaved with comment annotations).
    pub lines: Vec<PlanViewerItem>,
    /// Raw source lines kept for rebuilding items when comments change.
    source_lines: Vec<SourceLine>,
    /// ListPane state for navigation + visual selection.
    pub list_state: ListPaneState,
    /// The element ID being modified (if editing an existing element).
    pub element_id: Option<ElementId>,
    /// Whether we're inside an undo group (to close/cancel on exit).
    pub in_undo_group: bool,
    /// Cached inner popup area from last render (for mouse hit-testing).
    /// Excludes the divider + footer rows in plan modes, so it matches
    /// the area the ListPane was rendered into — used for routing list
    /// events to `ListPaneState::handle_mouse_event`.
    pub last_popup_area: Option<Rect>,
    /// Cached full modal area (inside the border, including the footer)
    /// from the last render. Used by the click-outside-modal check so
    /// clicks landing on the divider or empty space between footer
    /// buttons don't accidentally close the modal.
    pub last_modal_area: Option<Rect>,
    /// Cached close button rect from last render (for mouse hit-testing).
    pub close_button_area: Option<Rect>,
    /// Whether the close button is hovered (for highlight).
    pub close_hovered: bool,
    /// Cached fullscreen toggle button rect from last render.
    pub fullscreen_button_area: Option<Rect>,
    /// Whether the fullscreen button is hovered.
    pub fullscreen_hovered: bool,
    /// Plan-specific state. `Some` only when `kind == PlanPreview`.
    /// Keeps plan-only fields (buttons, approval, double-click) out of
    /// the generic viewer.
    pub plan: Option<PlanViewerExtras>,
    /// Initial scroll range (0-based indices). Consumed on first prepare_layout
    /// to center the range in the viewport.
    initial_scroll_range: Option<Range<usize>>,
    /// Optional title override. When set, the viewer title bar shows this
    /// instead of the (potentially long) file path. Used for plan previews.
    pub title_override: Option<String>,
    /// Raw markdown content for rebuilding when the display width changes.
    /// `None` for non-markdown viewers.
    markdown_content: Option<String>,
    /// The last `max_table_width` used to build markdown lines.
    /// Compared against the current content width in `prepare_layout` to
    /// trigger a rebuild when the viewer is resized.
    last_table_width: Option<usize>,
    /// Copy of comments last applied via `rebuild_with_comments`, so that
    /// a width-triggered rebuild can re-interleave them automatically.
    last_comments: Vec<crate::views::plan_approval_view::PlanComment>,
    /// When `true`, the viewer uses the full overlay area instead of the
    /// 75% centered popup. Toggled by Ctrl+F.
    pub fullscreen: bool,
}

impl LineViewerState {
    /// Open a file and create the viewer state.
    ///
    /// Returns `None` if the file can't be read.
    pub fn open(path: &Path, element_id: Option<ElementId>) -> Option<Self> {
        let content = std::fs::read_to_string(path).ok()?;
        let source_lines = build_source_lines(path, &content);
        let lines = source_lines
            .iter()
            .cloned()
            .map(|s| PlanViewerItem::Source(Box::new(s)))
            .collect();

        let config = ListPaneConfig {
            follow_enabled: false,
            wrap_toggle_enabled: false,
            search_enabled: true,
            copy_enabled: true,
            show_selection_when_unfocused: true,
            visual_select_enabled: true,
            filter_enabled: false,
            goto_line_enabled: true,
        };
        let list_state = ListPaneState::new_with_config(WrapMode::Wrap, false, config);

        Some(Self {
            kind: LineViewerKind::FilePreview,
            path: path.to_path_buf(),
            lines,
            source_lines,
            list_state,
            element_id,
            in_undo_group: false,
            last_popup_area: None,
            last_modal_area: None,
            close_button_area: None,
            close_hovered: false,
            fullscreen_button_area: None,
            fullscreen_hovered: false,
            plan: None,
            initial_scroll_range: None,
            title_override: None,
            markdown_content: None,
            last_table_width: None,
            last_comments: Vec::new(),
            fullscreen: false,
        })
    }

    /// Open a file and create the viewer with markdown rendering.
    ///
    /// Same as `open()` but renders the content as rich markdown instead of
    /// raw syntax-highlighted text. Source-line-based navigation is preserved.
    pub fn open_markdown(path: &Path, element_id: Option<ElementId>) -> Option<Self> {
        let content = std::fs::read_to_string(path).ok()?;
        Self::open_markdown_content(path.to_path_buf(), content, element_id)
    }

    pub fn open_markdown_content(
        path: impl Into<PathBuf>,
        content: String,
        element_id: Option<ElementId>,
    ) -> Option<Self> {
        if content.trim().is_empty() {
            return None;
        }
        let path = path.into();

        // Defer the actual markdown render to the first `prepare_layout` call
        // which knows the display width. `rebuild_markdown_for_width` will
        // build source_lines with the correct `max_table_width`.
        let source_lines = Vec::new();
        let lines = Vec::new();

        let config = ListPaneConfig {
            follow_enabled: false,
            wrap_toggle_enabled: false,
            search_enabled: true,
            copy_enabled: true,
            show_selection_when_unfocused: true,
            visual_select_enabled: true,
            filter_enabled: false,
            goto_line_enabled: true,
        };
        let list_state = ListPaneState::new_with_config(WrapMode::Wrap, false, config);

        Some(Self {
            kind: LineViewerKind::FilePreview,
            path,
            lines,
            source_lines,
            list_state,
            element_id,
            in_undo_group: false,
            last_popup_area: None,
            last_modal_area: None,
            close_button_area: None,
            close_hovered: false,
            fullscreen_button_area: None,
            fullscreen_hovered: false,
            plan: None,
            initial_scroll_range: None,
            title_override: None,
            markdown_content: Some(content),
            last_table_width: None,
            last_comments: Vec::new(),
            fullscreen: false,
        })
    }

    /// Set initial selection and scroll to a line range (1-based).
    ///
    /// Enters visual mode with the range pre-selected and scrolls so the
    /// range is visible (centered if possible).
    pub fn set_initial_selection(&mut self, range: Range<usize>) {
        // Convert 1-based line numbers to 0-based ListPane indices.
        let start_idx = range.start.saturating_sub(1);
        let end_idx = range.end.saturating_sub(1).min(self.lines.len());

        if start_idx < self.lines.len() {
            // Select the start line.
            let start_id = self.lines[start_idx].stable_id();
            self.list_state.select_by_id(start_id);

            // Enter visual mode and extend to end line.
            if end_idx > start_idx + 1 {
                // Multi-line range: enter visual mode.
                self.list_state.enter_visual_mode(&self.lines);
                // Move selection to the end of the range.
                let end_line_idx = (end_idx - 1).min(self.lines.len() - 1);
                let end_id = self.lines[end_line_idx].stable_id();
                self.list_state.select_by_id(end_id);
            }

            // Store the range for scroll centering on first render.
            self.initial_scroll_range = Some(start_idx..end_idx);
        }
    }

    /// Rebuild markdown items when the available content width changes.
    ///
    /// Recomputes `max_table_width` from the ListPane's content width
    /// (total width minus line-number prefix), then re-renders markdown
    /// with constrained tables so box-drawing borders aren't word-wrapped.
    fn rebuild_markdown_for_width(&mut self, width: u16) {
        let Some(ref content) = self.markdown_content else {
            return;
        };

        let prefix_width = digit_count(source_line_count(content).max(1)) + 1;
        let scrollbar_width = SCROLLBAR_TOTAL_COLS as usize; // gap + track
        let content_width = (width as usize)
            .saturating_sub(prefix_width)
            .saturating_sub(scrollbar_width);

        if self.last_table_width == Some(content_width) {
            return;
        }
        self.last_table_width = Some(content_width);

        self.source_lines = build_markdown_lines(content, Some(content_width));

        if self.last_comments.is_empty() {
            self.lines = self
                .source_lines
                .iter()
                .cloned()
                .map(|s| PlanViewerItem::Source(Box::new(s)))
                .collect();
        } else {
            let comments = self.last_comments.clone();
            self.interleave_comments(&comments);
        }
    }

    #[cfg(test)]
    pub(crate) fn markdown_content_for_test(&self) -> Option<&str> {
        self.markdown_content.as_deref()
    }

    /// Returns the raw markdown content for feedback formatting.
    pub fn markdown_content_for_feedback(&self) -> Option<String> {
        self.markdown_content.clone()
    }

    /// Mutable access to plan-specific extras, initializing if needed.
    pub fn plan_mut(&mut self) -> &mut PlanViewerExtras {
        self.plan.get_or_insert_with(PlanViewerExtras::default)
    }

    /// Read-only access to plan-specific extras.
    pub fn plan_ref(&self) -> Option<&PlanViewerExtras> {
        self.plan.as_ref()
    }

    pub fn feedback_active(&self) -> bool {
        self.plan.as_ref().is_some_and(|p| p.feedback_active)
    }

    /// Whether the plan modal should render the action-button footer.
    /// True for both modes: plan-approval (q/c/s|a) and casual
    /// (c/s — quit via the close-X button instead of a footer button).
    pub fn show_footer(&self) -> bool {
        self.plan
            .as_ref()
            .is_some_and(|p| p.feedback_active || p.show_action_buttons)
    }

    pub fn source_line_at_screen_row(&self, row: u16, content_area: Rect) -> Option<usize> {
        if row < content_area.y || row >= content_area.y + content_area.height {
            return None;
        }
        let ry = (row - content_area.y) as usize;
        let vy = self.list_state.scroll_offset() + ry;
        let vi = self.list_state.layout().item_at_y(vy)?;
        let pi = self.list_state.to_physical(vi);
        self.lines.get(pi)?.line_number()
    }

    /// Prepare the layout for rendering (must be called each frame).
    pub fn prepare_layout(&mut self, width: u16, height: u16) {
        self.rebuild_markdown_for_width(width);
        self.list_state.prepare_layout(&self.lines, width, height);

        // On the first render with an initial range, center the range
        // in the viewport. Consumed once so subsequent navigation is normal.
        if let Some(range) = self.initial_scroll_range.take() {
            let vp = height as usize;
            let total = self.lines.len();
            let pad = 3usize; // inner padding (lines of context above/below)

            if total <= vp {
                // Entire file fits — no scrolling needed.
            } else {
                let range_len = range.end.saturating_sub(range.start);
                let offset = if range_len + pad * 2 <= vp {
                    // Range fits with padding — center it.
                    let center = range.start + range_len / 2;
                    center.saturating_sub(vp / 2)
                } else {
                    // Range larger than viewport — put start near top with padding.
                    range.start.saturating_sub(pad)
                };
                // Clamp to valid range.
                let max_offset = total.saturating_sub(vp);
                self.list_state.set_scroll_offset(offset.min(max_offset));
            }
        }
    }

    /// Rebuild `self.lines` from source lines + comments.
    ///
    /// Comments are inserted after the last source line in their range.
    /// Item IDs for comments use a high base offset to avoid colliding
    /// with source line IDs.
    pub fn rebuild_with_comments(
        &mut self,
        comments: &[crate::views::plan_approval_view::PlanComment],
    ) {
        self.last_comments = comments.to_vec();
        self.interleave_comments(comments);
        // Item count/content changed — force the list pane to recompute
        // wrapping heights on the next render frame.
        self.list_state.invalidate_layout();
    }

    /// Interleave source lines with comments without updating `last_comments`.
    fn interleave_comments(&mut self, comments: &[crate::views::plan_approval_view::PlanComment]) {
        let max_digits = digit_count(self.source_lines.len().max(1));

        let mut sorted: Vec<_> = comments.iter().collect();
        sorted.sort_by_key(|c| c.line_range.end);

        let mut commented_lines = std::collections::HashSet::new();
        for c in comments {
            for ln in c.line_range.clone() {
                commented_lines.insert(ln);
            }
        }

        let mut items: Vec<PlanViewerItem> = Vec::new();
        let mut comment_idx = 0;
        let comment_id_base: u64 = 1_000_000;

        for src in &self.source_lines {
            let ln = src.line_number;
            let mut src = src.clone();
            src.commented = commented_lines.contains(&ln);
            items.push(PlanViewerItem::Source(Box::new(src)));

            while comment_idx < sorted.len() && sorted[comment_idx].line_range.end == ln + 1 {
                let c = sorted[comment_idx];
                let item_id = comment_id_base + c.id;
                items.push(PlanViewerItem::Comment(CommentLine::new(
                    c.id,
                    item_id,
                    &c.line_range,
                    c.text.clone(),
                    max_digits,
                )));
                comment_idx += 1;
            }
        }

        for c in &sorted[comment_idx..] {
            let item_id = comment_id_base + c.id;
            items.push(PlanViewerItem::Comment(CommentLine::new(
                c.id,
                item_id,
                &c.line_range,
                c.text.clone(),
                max_digits,
            )));
        }

        self.lines = items;
    }

    /// Get the selected line range (1-based, inclusive).
    ///
    /// Returns a single line if no visual selection, or the visual range.
    pub fn selected_line_range(&self) -> Option<Range<usize>> {
        if let Some(range) = self.list_state.multi_range() {
            // Scan forward from start to find first source line.
            let start = (range.start..range.end).find_map(|i| self.lines.get(i)?.line_number())?;
            // Scan backward from end to find last source line.
            let end = (range.start..range.end)
                .rev()
                .find_map(|i| self.lines.get(i)?.line_number())?;
            Some(start..end + 1)
        } else {
            let idx = self.list_state.selected_index()?;
            let ln = self.lines.get(idx)?.line_number()?;
            Some(ln..ln + 1)
        }
    }

    /// Format the line range as a suffix string (e.g., `:10` or `:10-12`).
    pub fn line_range_suffix(&self) -> Option<String> {
        let range = self.selected_line_range()?;
        if range.len() == 1 {
            Some(format!(":{}", range.start))
        } else {
            Some(format!(":{}-{}", range.start, range.end - 1))
        }
    }

    /// ListPane style matching the pager's visual language.
    pub fn list_pane_style() -> ListPaneStyle {
        let theme = Theme::current();
        ListPaneStyle {
            input_bar_bg: theme.bg_dark,
            input_bar_prompt_fg: theme.command,
            scrollbar_bg: theme.bg_dark,
            scrollbar_fg: theme.gray_dim,
            uniform_visual_bg: true,
            ..ListPaneStyle::default()
        }
    }
}

// ── Syntax highlighting ─────────────────────────────────────────────────

/// Build syntax-highlighted source lines from file content.
fn build_source_lines(path: &Path, content: &str) -> Vec<SourceLine> {
    let syntect = get_syntect();
    let mut highlighter = syntect.highlight_lines_by_file_path(path);

    // Split preserving all lines including trailing empty ones.
    // `split('\n')` keeps trailing empty strings unlike `.lines()`.
    let raw_lines: Vec<&str> = content.split('\n').collect();
    // If the file ends with a newline, remove the trailing empty split artifact.
    let line_count = if content.ends_with('\n') && raw_lines.last() == Some(&"") {
        raw_lines.len() - 1
    } else {
        raw_lines.len()
    };
    let max_digits = digit_count(line_count.max(1));

    raw_lines
        .iter()
        .take(line_count)
        .enumerate()
        .map(|(i, text)| {
            let line_number = i + 1;
            let styled_line = if text.is_empty() {
                // Empty line — still needs a Line (for line number prefix).
                Line::from(" ".to_owned())
            } else if let Some(ref mut hl) = highlighter {
                highlight_to_ratatui_line(hl, text, &syntect.syntax_set)
            } else {
                Line::from((*text).to_owned())
            };
            SourceLine::new(line_number, styled_line, (*text).to_owned(), max_digits)
        })
        .collect()
}

/// Count source lines in content, matching `str::split('\n')` semantics
/// with trailing-newline handling.
fn source_line_count(content: &str) -> usize {
    let count = content.split('\n').count();
    if content.ends_with('\n') {
        count - 1
    } else {
        count
    }
}

/// Build markdown-rendered source lines from file content.
///
/// Uses `MarkdownContent` to render the full document, then groups rendered
/// lines by source line using `line_source_map`. Each source line becomes
/// one `SourceLine` item that may span multiple visual lines (e.g., a table
/// block renders as border + header + separator + data + border).
fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> Vec<SourceLine> {
    let md = MarkdownContent::new_source_faithful(content, max_table_width);
    let pre_wrap = md.pre_wrap_lines();
    let source_map = md.line_source_map();

    // Background colors come from each line's style (set by the renderer
    // for code blocks etc.). pre_wrap_lines() returns owned Lines that
    // carry their style including bg.
    let line_bgs: Vec<Option<Color>> = pre_wrap.iter().map(|line| line.style.bg).collect();

    // Split source text into raw lines for plain_text / search.
    let raw_lines: Vec<&str> = content.split('\n').collect();
    let slc = source_line_count(content);
    let max_digits = digit_count(slc.max(1));

    // Group rendered lines by source line number.
    // source_map is indexed by rendered-line index, value is 0-based source line.
    let mut groups: Vec<(usize, Vec<Line<'static>>, Vec<Option<Color>>)> = Vec::new();
    for (rendered_idx, rendered_line) in pre_wrap.into_iter().enumerate() {
        let src_line = source_map.get(rendered_idx).copied().unwrap_or(0);
        let bg = line_bgs.get(rendered_idx).copied().flatten();
        if let Some(last) = groups.last_mut()
            && last.0 == src_line
        {
            last.1.push(rendered_line);
            last.2.push(bg);
            continue;
        }
        groups.push((src_line, vec![rendered_line], vec![bg]));
    }

    let mut source_lines = Vec::new();
    let mut next_item_id = 0u64;
    let mut next_blank_src = 0usize;

    for (src_line_0based, rendered_lines, rendered_bgs) in groups {
        for blank_src in next_blank_src..src_line_0based.min(slc) {
            if raw_lines
                .get(blank_src)
                .is_some_and(|line| line.trim().is_empty())
            {
                source_lines.push(SourceLine::new_markdown(
                    next_item_id,
                    blank_src + 1,
                    vec![Line::default()],
                    vec![None],
                    raw_lines.get(blank_src).unwrap_or(&"").to_string(),
                    max_digits,
                ));
                next_item_id += 1;
            }
        }

        source_lines.push(SourceLine::new_markdown(
            next_item_id,
            src_line_0based + 1,
            rendered_lines,
            rendered_bgs,
            raw_lines.get(src_line_0based).unwrap_or(&"").to_string(),
            max_digits,
        ));
        next_item_id += 1;
        next_blank_src = next_blank_src.max(src_line_0based.saturating_add(1));
    }

    for blank_src in next_blank_src..slc {
        if raw_lines
            .get(blank_src)
            .is_some_and(|line| line.trim().is_empty())
        {
            source_lines.push(SourceLine::new_markdown(
                next_item_id,
                blank_src + 1,
                vec![Line::default()],
                vec![None],
                raw_lines.get(blank_src).unwrap_or(&"").to_string(),
                max_digits,
            ));
            next_item_id += 1;
        }
    }

    source_lines
}

/// Convert syntect highlighting output to a ratatui Line.
fn highlight_to_ratatui_line(
    hl: &mut HighlightLines<'_>,
    text: &str,
    syntax_set: &syntect::parsing::SyntaxSet,
) -> Line<'static> {
    let highlighted = match hl.highlight_line(text, syntax_set) {
        Ok(h) => h,
        Err(_) => return Line::from(text.to_owned()),
    };

    let spans: Vec<Span<'static>> = highlighted
        .into_iter()
        .map(|(style, content)| {
            let fg = syntect_to_ratatui_color(style.foreground);
            Span::styled(content.to_owned(), Style::default().fg(fg))
        })
        .collect();

    Line::from(spans)
}

/// Convert a syntect RGBA color to a ratatui Color.
///
/// Quantizes the RGB value to the terminal's supported color level.
fn syntect_to_ratatui_color(c: syntect::highlighting::Color) -> ratatui::style::Color {
    crate::theme::quantize(ratatui::style::Color::Rgb(c.r, c.g, c.b))
}

/// Count digits in a number (for line number padding).
fn digit_count(n: usize) -> usize {
    if n == 0 {
        1
    } else {
        ((n as f64).log10().floor() as usize) + 1
    }
}

// ── Rendering helpers ───────────────────────────────────────────────────

/// Build a single review-footer shortcut button styled to match the
/// shortcut hints in `modal_window::render_modal_shortcuts`:
/// bold key in the primary text color + dim label, with a
/// hover-highlighted background.
fn build_shortcut_button<'a>(
    key: char,
    rest: &str,
    hovered: bool,
    theme: &crate::theme::Theme,
) -> Vec<Span<'a>> {
    let bg = if hovered {
        theme.bg_highlight
    } else {
        theme.bg_base
    };
    let key_style = Style::default()
        .fg(theme.text_primary)
        .bg(bg)
        .add_modifier(Modifier::BOLD);
    let label_style = Style::default().fg(theme.gray).bg(bg);
    vec![
        Span::styled(key.to_string(), key_style),
        Span::styled(format!(" {rest}"), label_style),
    ]
}

/// Render the line viewer popup.
///
/// In normal mode, draws a 75% centered panel with dimmed background
/// (modifiers reset). In fullscreen mode (`viewer.fullscreen`), fills
/// the entire overlay area without dimming. Renders the ListPane
/// inside the panel with syntax-highlighted lines.
pub fn render_line_viewer(
    buf: &mut Buffer,
    full_area: Rect,
    viewer: &mut LineViewerState,
    cwd: &Path,
    theme: &Theme,
    comment_count: usize,
) {
    // Compute popup area. In enlarge (fullscreen) mode the popup
    // nearly fills the overlay, but leaves 1 row of top padding and
    // 2 cols of side padding so it doesn't crowd the screen edges
    // (the caller already excludes the prompt + turn_status from
    // `full_area`). In normal mode it sits in a 75% centered popup.
    let (popup_area, should_dim) = if viewer.fullscreen {
        const TOP_PAD: u16 = 1;
        const SIDE_PAD: u16 = 2;
        let pad_w = SIDE_PAD.saturating_mul(2);
        let popup_x = full_area.x + SIDE_PAD.min(full_area.width);
        let popup_y = full_area.y + TOP_PAD.min(full_area.height);
        let popup_w = full_area.width.saturating_sub(pad_w);
        let popup_h = full_area.height.saturating_sub(TOP_PAD);
        (Rect::new(popup_x, popup_y, popup_w, popup_h), false)
    } else {
        let popup_width = (full_area.width as f32 * 0.75) as u16;
        let popup_height = (full_area.height as f32 * 0.75) as u16;
        let popup_x = full_area.x + (full_area.width.saturating_sub(popup_width)) / 2;
        let popup_y = full_area.y + (full_area.height.saturating_sub(popup_height)) / 2;
        (Rect::new(popup_x, popup_y, popup_width, popup_height), true)
    };

    // Plan modes (both review and casual) reserve 2 extra rows inside
    // the frame for the divider + action-button footer, so they need
    // a slightly taller minimum than ordinary file previews.
    let min_height: u16 = if viewer.show_footer() { 7 } else { 5 };
    if popup_area.width < 10 || popup_area.height < min_height {
        viewer.last_popup_area = None;
        viewer.last_modal_area = None;
        return;
    }

    // 1. Dim the entire screen behind the popup (skip when fullscreen).
    if should_dim {
        dim_area(buf, full_area, theme.bg_base, 0.5);
    }

    // 2. Clear the popup area.
    ratatui::widgets::Clear.render(popup_area, buf);
    buf.set_style(
        popup_area,
        Style::default().fg(theme.text_primary).bg(theme.bg_base),
    );

    // 3. Draw border.
    let border = ratatui::widgets::Block::default()
        .borders(ratatui::widgets::Borders::ALL)
        .border_type(ratatui::widgets::BorderType::Rounded)
        .border_style(Style::default().fg(theme.gray_dim))
        .style(Style::default().bg(theme.bg_base));
    let inner = border.inner(popup_area);
    border.render(popup_area, buf);

    // Plan modes reserve 2 rows at the bottom of `inner` for the
    // divider + action-button row (rendered in step 8 below). Compute
    // the actual content_area now so `prepare_layout` sees the true
    // viewport height — passing the larger `inner.height` would make
    // `ListPaneState`'s auto-scroll/paging math off by `footer_rows`
    // (the selection can hide behind the footer; Ctrl-D/Ctrl-U jump
    // too far; initial-scroll-to-range centering is mis-sized).
    let footer_rows: u16 = if viewer.show_footer() { 2 } else { 0 };
    let content_area = Rect {
        x: inner.x,
        y: inner.y,
        width: inner.width,
        height: inner.height.saturating_sub(footer_rows),
    };

    // 4. Prepare layout (resolves selection index for title + rendering).
    viewer.prepare_layout(content_area.width, content_area.height);

    // 5. Title bar: styled file path + line range.
    //    Only show line range when visual selection is active.
    //    When title_override is set (e.g. plan preview), use that instead
    //    of the full file path to avoid overflow.
    if inner.width > 4 {
        let rel_path = viewer.path.strip_prefix(cwd).unwrap_or(&viewer.path);
        let rel_path_str = viewer
            .title_override
            .as_deref()
            .unwrap_or_else(|| rel_path.to_str().unwrap_or(""))
            .to_string();
        let line_range = if viewer.list_state.visual_mode {
            viewer.line_range_suffix().map(|s| {
                // Strip leading ':' — styled_file_ref adds its own.
                s.strip_prefix(':').unwrap_or(&s).to_owned()
            })
        } else {
            None
        };

        // Build styled title with shared helper.
        let mut title = super::styled_file_ref(
            &rel_path_str,
            line_range.as_deref(),
            theme,
            false, // no @ prefix in viewer title
        );
        // Add bg to all spans (title sits on the border).
        for span in &mut title.spans {
            span.style = span.style.bg(theme.bg_base);
        }

        // Wrap with `─ ... ─` decorations to match other modals
        // (see modal_window.rs:341-346) and left-align flush with the
        // top-left corner.
        let deco = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
        title.spans.insert(0, Span::styled("\u{2500} ", deco));
        title.spans.push(Span::styled(" \u{2500}", deco));

        let title_width = title.width() as u16;
        let title_x = popup_area.x + 1;
        let max_title_width = popup_area.width.saturating_sub(2);
        buf.set_line(
            title_x,
            popup_area.y,
            &title,
            title_width.min(max_title_width),
        );
    }

    // 6. Action buttons on the top border, right-aligned.
    //    Layout: ... [↗][✗]  (rightmost buttons first; the two abut
    //    flush — see the spacing notes on the close/fullscreen labels
    //    below).
    //    The close [✗] is omitted in plan-review (feedback) mode because
    //    the modal is not user-closeable in that state — clicking it
    //    would be a no-op (see the close-button branch of
    //    `handle_line_viewer_mouse` in agent_view.rs).
    let mut right_edge = popup_area.x + popup_area.width - 1;

    if !viewer.feedback_active() {
        let close_text = crate::glyphs::ballot_x(); // ✗ (ASCII on legacy ConHost)
        // Label is `[✗] ` (trailing space, no leading space). The
        // fullscreen button's label has no trailing space when the
        // close is visible, so the two buttons abut flush as `[↗][✗]`,
        // tucked under the top-right corner with one space inside the
        // frame on each side: ` [↗][✗] `.
        let close_w: u16 = 4; // "[✗] "
        if popup_area.width > close_w + 2 {
            let close_x = right_edge - close_w;
            let close_style = if viewer.close_hovered {
                Style::default()
                    .fg(theme.text_primary)
                    .bg(theme.bg_base)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(theme.gray).bg(theme.bg_base)
            };
            let close_span = Span::styled(format!("[{close_text}] "), close_style);
            buf.set_span(close_x, popup_area.y, &close_span, close_w);
            viewer.close_button_area = Some(Rect::new(close_x, popup_area.y, close_w, 1));
            right_edge = close_x;
        } else {
            viewer.close_button_area = None;
        }
    } else {
        viewer.close_button_area = None;
    }

    // Fullscreen toggle button. The icon stays constant regardless of
    // current state — the button is a toggle, not a status indicator.
    //
    // Spacing: when the close button is rendered (casual mode) the
    // fullscreen drops its trailing space so the two sit flush as
    // `[↗][✗]`. When the close is hidden (plan-review mode) the
    // fullscreen keeps its trailing space so it doesn't crowd the
    // corner `╮`.
    let fs_icon = crate::glyphs::enlarge(); // ↗ (ASCII on legacy ConHost)
    let close_visible = viewer.close_button_area.is_some();
    let (fs_label, fs_w): (String, u16) = if close_visible {
        (format!(" [{fs_icon}]"), 4)
    } else {
        (format!(" [{fs_icon}] "), 5)
    };
    if right_edge > popup_area.x + fs_w + 2 {
        let fs_x = right_edge - fs_w;
        let fs_style = if viewer.fullscreen_hovered {
            Style::default()
                .fg(theme.text_primary)
                .bg(theme.bg_base)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(theme.gray).bg(theme.bg_base)
        };
        let fs_span = Span::styled(fs_label, fs_style);
        buf.set_span(fs_x, popup_area.y, &fs_span, fs_w);
        viewer.fullscreen_button_area = Some(Rect::new(fs_x, popup_area.y, fs_w, 1));
    } else {
        viewer.fullscreen_button_area = None;
    }

    // The legacy top-border "send" button is gone — both plan-approval
    // and casual modes now render the send action in the modal footer
    // alongside the other shortcut buttons. Clear stale hit-rects so
    // mouse handlers don't act on positions from a previous render.
    if let Some(plan) = viewer.plan.as_mut() {
        plan.send_button_area = None;
        plan.approve_button_area = None;
        plan.abandon_button_area = None;
    }

    // 7. Render ListPane.
    //    Plan modes (review + casual) reserve 2 rows at the bottom of
    //    `inner` for a horizontal divider plus the action-button row.
    //    The divider sits at `inner.bottom() - 2` and the buttons at
    //    `inner.bottom() - 1`, both inside the modal frame.
    //
    //    `footer_rows` / `content_area` were computed above (just
    //    after `inner`) so `prepare_layout` could see the true
    //    viewport height. Reuse them here.
    let style = LineViewerState::list_pane_style();

    let pane = ListPane::new(&viewer.lines).focused(true).style(style);
    StatefulWidget::render(pane, content_area, buf, &mut viewer.list_state);

    // Cache the list-rendered area (for ListPane mouse dispatch) and
    // the full modal area inside the border (for the click-outside
    // check that decides whether to close the modal).
    viewer.last_popup_area = Some(content_area);
    viewer.last_modal_area = Some(inner);

    // 7b. Line range highlight — active drag or commenting range.
    if let Some(plan) = viewer.plan_ref() {
        let highlight_range =
            if let (Some(start), Some(end)) = (plan.gutter_drag_start, plan.gutter_drag_end) {
                if start != end {
                    Some((start.min(end), start.max(end)))
                } else {
                    None
                }
            } else {
                plan.active_commenting_range
                    .as_ref()
                    .map(|r| (r.start, r.end.saturating_sub(1)))
            };
        if let Some((lo, hi)) = highlight_range {
            let blend_bg =
                crate::render::color::blend_color(theme.bg_base, theme.accent_plan, 0.15)
                    .unwrap_or(theme.accent_plan);
            // Stop the highlight one column before the scrollbar so
            // the gap + track stay readable instead of being tinted
            // by the comment-range overlay.
            let highlight_width = content_area.width.saturating_sub(SCROLLBAR_TOTAL_COLS);
            for row in content_area.y..content_area.y + content_area.height {
                if let Some(ln) = viewer.source_line_at_screen_row(row, content_area)
                    && ln >= lo
                    && ln <= hi
                {
                    let row_rect = Rect::new(content_area.x, row, highlight_width, 1);
                    buf.set_style(row_rect, Style::default().bg(blend_bg));
                }
            }
        }
    }

    // 8. Action buttons inside the modal footer (centered), for both
    //    plan-approval and casual plan-preview modes. A full-width `─`
    //    divider separates the button row from the content above
    //    (matches modal_window.rs's tab divider style).
    //
    //    Buttons use the same `key bold + label dim` treatment as
    //    `render_modal_shortcuts`, sit in a single row separated by
    //    `  |  `, centered within the modal frame.
    //
    //    - Plan-approval:  q quit | c comment | s send / a approve
    //    - Casual preview:           c comment | s send  (no `q` —
    //      the close-X button handles closing in casual mode)
    if viewer.show_footer() && inner.height >= 2 {
        let div_y = inner.y + inner.height - 2;
        let div_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
        let line: String = std::iter::repeat_n('\u{2500}', inner.width as usize).collect();
        buf.set_string(inner.x, div_y, &line, div_style);

        let bottom_y = inner.y + inner.height - 1;

        let abandon_hovered = viewer.plan_ref().is_some_and(|p| p.abandon_hovered);
        let comment_hovered = viewer.plan_ref().is_some_and(|p| p.comment_hovered);
        let approve_hovered = viewer.plan_ref().is_some_and(|p| p.approve_hovered);
        let is_approval = viewer.feedback_active();

        let comment_spans = build_shortcut_button('c', "comment", comment_hovered, theme);
        let comment_w: u16 = comment_spans.iter().map(|s| s.width() as u16).sum();

        // In approval mode, always show `a approve`. When there are
        // pending review comments, also show `s revise` (request changes).
        // In approval mode, show `a approve` (or `a approve w/ comments`
        // when inline comments are pending). In casual mode, show `s send`
        // only when comments exist.
        let (_action_label, action_w, action_spans): (&str, u16, Option<Vec<Span>>) = if is_approval
        {
            let label = if comment_count > 0 {
                "approve w/ comments"
            } else {
                "approve"
            };
            let spans = build_shortcut_button('a', label, approve_hovered, theme);
            let w: u16 = spans.iter().map(|s| s.width() as u16).sum();
            (label, w, Some(spans))
        } else if comment_count > 0 {
            let spans = build_shortcut_button('s', "send", approve_hovered, theme);
            let w: u16 = spans.iter().map(|s| s.width() as u16).sum();
            ("send", w, Some(spans))
        } else {
            ("", 0, None)
        };

        // `s revise` button — always visible in approval mode so the
        // user can request changes (switches to prompt for revision notes).
        let (revise_w, revise_spans): (u16, Option<Vec<Span>>) = if is_approval {
            let send_hovered = viewer.plan_ref().is_some_and(|p| p.send_hovered);
            let spans = build_shortcut_button('s', "request changes", send_hovered, theme);
            let w: u16 = spans.iter().map(|s| s.width() as u16).sum();
            (w, Some(spans))
        } else {
            (0, None)
        };

        // Quit button only renders in approval mode (casual closes via X).
        let quit_spans = if is_approval {
            let s = build_shortcut_button('q', "quit plan", abandon_hovered, theme);
            let w: u16 = s.iter().map(|s| s.width() as u16).sum();
            Some((s, w))
        } else {
            None
        };

        // Pending-comment badge rendered after the `c comment` button
        // as ` N ●` in `accent_plan`. Shown whenever comments exist.
        use unicode_width::UnicodeWidthStr;
        let badge_text: String = if comment_count > 0 {
            format!(" {comment_count} {}", crate::glyphs::filled_dot())
        } else {
            String::new()
        };
        let badge_w: u16 = badge_text.width() as u16;
        let badge_style = Style::default().fg(theme.accent_plan).bg(theme.bg_base);

        let separator = "  |  ";
        let sep_w: u16 = 5; // separator is fixed-width ASCII; matches modal_window.rs:565
        let sep_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);

        // Total width: [action] + (sep + revise)? + sep + comment[badge?] + (sep + quit)?
        let mut total_w: u16 = 0;
        if action_w > 0 {
            total_w = total_w.saturating_add(action_w).saturating_add(sep_w);
        }
        if revise_w > 0 {
            total_w = total_w.saturating_add(revise_w).saturating_add(sep_w);
        }
        total_w = total_w.saturating_add(comment_w).saturating_add(badge_w);
        if let Some((_, w)) = &quit_spans {
            total_w = total_w.saturating_add(sep_w).saturating_add(*w);
        }

        if total_w <= inner.width {
            let mut x = inner.x + (inner.width - total_w) / 2;

            // Action button (approve / send) — left-most.
            if let Some(spans) = &action_spans {
                let approve_x = x;
                for span in spans {
                    let w = span.width() as u16;
                    buf.set_span(x, bottom_y, span, w);
                    x += w;
                }
                viewer.plan_mut().approve_button_area =
                    Some(Rect::new(approve_x, bottom_y, action_w, 1));

                buf.set_string(x, bottom_y, separator, sep_style);
                x += sep_w;
            } else {
                viewer.plan_mut().approve_button_area = None;
            }

            // Revise button — approval mode with comments.
            if let Some(spans) = &revise_spans {
                let revise_x = x;
                for span in spans {
                    let w = span.width() as u16;
                    buf.set_span(x, bottom_y, span, w);
                    x += w;
                }
                viewer.plan_mut().send_button_area =
                    Some(Rect::new(revise_x, bottom_y, revise_w, 1));

                buf.set_string(x, bottom_y, separator, sep_style);
                x += sep_w;
            } else {
                viewer.plan_mut().send_button_area = None;
            }

            // Comment button — always present in both modes.
            let comment_x = x;
            for span in &comment_spans {
                let w = span.width() as u16;
                buf.set_span(x, bottom_y, span, w);
                x += w;
            }
            viewer.plan_mut().comment_button_area =
                Some(Rect::new(comment_x, bottom_y, comment_w, 1));

            if badge_w > 0 {
                buf.set_string(x, bottom_y, &badge_text, badge_style);
                x += badge_w;
            }

            // Quit button — approval mode only.
            if let Some((spans, w)) = quit_spans {
                buf.set_string(x, bottom_y, separator, sep_style);
                x += sep_w;
                let quit_x = x;
                for span in &spans {
                    let sw = span.width() as u16;
                    buf.set_span(x, bottom_y, span, sw);
                    x += sw;
                }
                viewer.plan_mut().abandon_button_area = Some(Rect::new(quit_x, bottom_y, w, 1));
            } else {
                viewer.plan_mut().abandon_button_area = None;
            }
        } else {
            // Footer too narrow — disable hit-tests so stale rects
            // from a previous render don't fire.
            let plan = viewer.plan_mut();
            plan.approve_button_area = None;
            plan.comment_button_area = None;
            plan.abandon_button_area = None;
        }
    }
}

pub use crate::render::color::dim_area;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn open_markdown_content_uses_in_memory_content() {
        let viewer = LineViewerState::open_markdown_content(
            "plan.md",
            "# Plan\n\n- Do the thing".to_owned(),
            None,
        )
        .expect("markdown content should open");

        assert_eq!(viewer.path, PathBuf::from("plan.md"));
        assert_eq!(
            viewer.markdown_content.as_deref(),
            Some("# Plan\n\n- Do the thing")
        );
    }

    #[test]
    fn markdown_content_for_test_returns_in_memory_content() {
        let viewer =
            LineViewerState::open_markdown_content("plan.md", "# Latest Plan".to_owned(), None)
                .expect("markdown content should open");

        assert_eq!(viewer.markdown_content_for_test(), Some("# Latest Plan"));
    }

    #[test]
    fn open_markdown_content_rejects_blank_content() {
        assert!(
            LineViewerState::open_markdown_content("plan.md", "  \n".to_owned(), None).is_none()
        );
    }

    fn line_text(line: &Line<'_>) -> String {
        line.spans
            .iter()
            .map(|span| span.content.as_ref())
            .collect()
    }

    fn source_line(item: &PlanViewerItem) -> &SourceLine {
        match item {
            PlanViewerItem::Source(source) => source,
            PlanViewerItem::Comment(_) => panic!("expected source line"),
        }
    }

    fn row_text(buf: &Buffer, y: u16) -> String {
        let area = *buf.area();
        let mut row = String::new();
        for x in area.left()..area.right() {
            row.push_str(buf[(x, y)].symbol());
        }
        row
    }

    #[test]
    fn build_markdown_lines_preserves_blank_source_lines() {
        let lines = build_markdown_lines("# Plan\n\n- First\n\n- Second", Some(80));
        let numbered_rows: Vec<(usize, Vec<String>)> = lines
            .iter()
            .map(|line| {
                (
                    line.line_number,
                    line.rendered_lines.iter().map(line_text).collect(),
                )
            })
            .collect();

        assert_eq!(
            numbered_rows,
            vec![
                (1, vec!["Plan".to_owned()]),
                (2, vec![String::new()]),
                (3, vec!["• First".to_owned()]),
                (4, vec![String::new()]),
                (5, vec!["• Second".to_owned()]),
            ]
        );
    }

    #[test]
    fn markdown_source_blank_line_renders_as_numbered_empty_row() {
        let lines = build_markdown_lines("# Plan\n\n- First", Some(80));
        let blank = &lines[1];
        let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));

        blank.render(Rect::new(0, 0, 20, 1), &mut buf, false, true);

        assert_eq!(blank.line_number, 2);
        assert_eq!(row_text(&buf, 0), "2                   ");
    }

    #[test]
    fn markdown_viewer_selection_uses_source_line_numbers_with_blank_rows() {
        let mut viewer = LineViewerState::open_markdown_content(
            "plan.md",
            "# Plan\n\n- First\n\n- Second".to_owned(),
            None,
        )
        .expect("markdown content should open");
        viewer.prepare_layout(80, 20);

        let ids_by_line: Vec<(usize, u64)> = viewer
            .lines
            .iter()
            .map(|item| {
                let source = source_line(item);
                (source.line_number, source.item_id)
            })
            .collect();
        assert_eq!(
            ids_by_line
                .iter()
                .map(|(line, _)| *line)
                .collect::<Vec<_>>(),
            vec![1, 2, 3, 4, 5]
        );

        let line_4_id = ids_by_line
            .iter()
            .find_map(|(line, id)| (*line == 4).then_some(*id))
            .expect("blank source line should have its own row");
        viewer.list_state.select_by_id(line_4_id);
        viewer.prepare_layout(80, 20);

        assert_eq!(viewer.selected_line_range(), Some(4..5));
        assert_eq!(viewer.line_range_suffix(), Some(":4".to_owned()));
        assert_eq!(source_line(&viewer.lines[3]).plain_text, "");
    }

    #[test]
    fn markdown_viewer_preserves_soft_break_collapsed_lines() {
        // Repro: consecutive non-blank lines (a poem) form one
        // CommonMark paragraph. Source-faithful rendering must keep each line
        // on its own numbered row instead of collapsing to one paragraph.
        let mut viewer = LineViewerState::open_markdown_content(
            "plan.md",
            "Line one,\nLine two,\nLine three.".to_owned(),
            None,
        )
        .expect("markdown content should open");
        viewer.prepare_layout(80, 20);

        let rows: Vec<(usize, String)> = viewer
            .lines
            .iter()
            .map(|item| {
                let s = source_line(item);
                (s.line_number, line_text(&s.rendered_lines[0]))
            })
            .collect();
        assert_eq!(
            rows,
            vec![
                (1, "Line one,".to_owned()),
                (2, "Line two,".to_owned()),
                (3, "Line three.".to_owned()),
            ]
        );
    }

    #[test]
    fn markdown_viewer_comment_range_maps_full_soft_break_paragraph() {
        // Commenting round-trip: selecting all rows of a soft-break paragraph
        // must map back to the full file line range so the agent inspects the
        // correct lines. Pre-fix this collapsed to a single line number.
        let mut viewer = LineViewerState::open_markdown_content(
            "plan.md",
            "Line one,\nLine two,\nLine three.".to_owned(),
            None,
        )
        .expect("markdown content should open");
        viewer.prepare_layout(80, 20);
        viewer.set_initial_selection(1..4);
        viewer.prepare_layout(80, 20);

        assert_eq!(viewer.selected_line_range(), Some(1..4));
        assert_eq!(viewer.line_range_suffix(), Some(":1-3".to_owned()));
    }
}
