//! Low-level markdown event renderer for the TUI transcript.
//!
//! This module consumes `pulldown-cmark` events and emits styled `ratatui`
//! lines, including table layout, width-aware wrapping, and local file-link
//! display. It is the final rendering stage used by higher-level helpers in
//! `markdown.rs`.
//!
//! This renderer intentionally treats local file links differently from normal web links. For
//! local paths, the displayed text comes from the destination, not the markdown label, so
//! transcripts show the real file target (including normalized location suffixes) and can shorten
//! absolute paths relative to a known working directory.
//!
//! ## Table rendering pipeline
//!
//! When the parser emits `Tag::Table` .. `TagEnd::Table`, the writer
//! accumulates header and body rows into a `TableState`, then hands it to
//! `render_table_lines` which runs this pipeline:
//!
//! 1. **Filter spillover rows** -- heuristic extraction of rows that are
//!    artifacts of pulldown-cmark's lenient parsing.
//! 2. **Normalize column counts** -- pad or truncate so every row matches the
//!    alignment count.
//! 3. **Compute column widths** -- allocate widths with Narrative/Structured
//!    priority and iterative shrinking.
//! 4. **Render box grid** -- Unicode borders (`┌───┬───┐`) or fallback to pipe
//!    format when the minimum cannot fit.
//! 5. **Append spillover** -- extracted spillover rows rendered as plain text
//!    after the table.
//!
//! ## Width allocation
//!
//! Columns are classified as Narrative (long prose, >= 4 avg words or >= 28
//! avg char width) or Structured (short tokens).  The shrink loop removes
//! one character at a time, preferring Narrative columns, until the total
//! fits the available width.  A guard cost penalises shrinking below a
//! column's header token width.  When even 3-char-wide columns cannot fit,
//! the table falls back to pipe-delimited format.

use crate::render::highlight::highlight_code_to_lines;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::push_owned_lines;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use crate::wrapping::word_wrap_line;
use codex_utils_string::normalize_markdown_hash_location_suffix;
use dirs::home_dir;
use pulldown_cmark::Alignment;
use pulldown_cmark::CodeBlockKind;
use pulldown_cmark::CowStr;
use pulldown_cmark::Event;
use pulldown_cmark::HeadingLevel;
use pulldown_cmark::Options;
use pulldown_cmark::Parser;
use pulldown_cmark::Tag;
use pulldown_cmark::TagEnd;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use regex_lite::Regex;
use std::ops::Range;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
use unicode_width::UnicodeWidthStr;
use url::Url;

struct MarkdownStyles {
    h1: Style,
    h2: Style,
    h3: Style,
    h4: Style,
    h5: Style,
    h6: Style,
    code: Style,
    emphasis: Style,
    strong: Style,
    strikethrough: Style,
    ordered_list_marker: Style,
    unordered_list_marker: Style,
    link: Style,
    blockquote: Style,
}

impl Default for MarkdownStyles {
    fn default() -> Self {
        Self {
            h1: Style::new().bold().underlined(),
            h2: Style::new().bold(),
            h3: Style::new().bold().italic(),
            h4: Style::new().italic(),
            h5: Style::new().italic(),
            h6: Style::new().italic(),
            code: Style::new().cyan(),
            emphasis: Style::new().italic(),
            strong: Style::new().bold(),
            strikethrough: Style::new().crossed_out(),
            ordered_list_marker: Style::new().light_blue(),
            unordered_list_marker: Style::new(),
            link: Style::new().cyan().underlined(),
            blockquote: Style::new().green(),
        }
    }
}

#[derive(Clone, Debug)]
struct IndentContext {
    prefix: Vec<Span<'static>>,
    marker: Option<Vec<Span<'static>>>,
    is_list: bool,
}

impl IndentContext {
    fn new(prefix: Vec<Span<'static>>, marker: Option<Vec<Span<'static>>>, is_list: bool) -> Self {
        Self {
            prefix,
            marker,
            is_list,
        }
    }
}

/// Styled content of a single cell in the table being parsed.
///
/// A cell can contain multiple lines (hard breaks inside the cell) and rich inline spans (bold,
/// code, links).  The `plain_text()` projection is used for column-width measurement; the styled
/// `lines` are used for final rendering.
#[derive(Clone, Debug, Default)]
struct TableCell {
    lines: Vec<Line<'static>>,
}

// TableCell mutators inlined — called per-span during table event parsing.
impl TableCell {
    #[inline]
    fn ensure_line(&mut self) {
        if self.lines.is_empty() {
            self.lines.push(Line::default());
        }
    }

    #[inline]
    fn push_span(&mut self, span: Span<'static>) {
        self.ensure_line();
        if let Some(line) = self.lines.last_mut() {
            line.push_span(span);
        }
    }

    #[inline]
    fn hard_break(&mut self) {
        self.lines.push(Line::default());
    }

    fn plain_text(&self) -> String {
        use std::fmt::Write;
        let mut buf = String::new();
        for (i, line) in self.lines.iter().enumerate() {
            if i > 0 {
                buf.push(' ');
            }
            for span in &line.spans {
                let _ = write!(buf, "{}", span.content);
            }
        }
        buf
    }
}

/// Accumulates pulldown-cmark table events into a structured representation.
///
/// `TableState` is created on `Tag::Table` and consumed on `TagEnd::Table`. Between those events,
/// the Writer delegates cell content (text, code, html, breaks) into the `current_cell`, which is
/// flushed into `current_row` on `TagEnd::TableCell`, then into `header`/`rows` on row/head end
/// events.
#[derive(Debug)]
struct TableBodyRow {
    cells: Vec<TableCell>,
    has_table_pipe_syntax: bool,
}

#[derive(Debug)]
struct TableState {
    alignments: Vec<Alignment>,
    header: Option<Vec<TableCell>>,
    rows: Vec<TableBodyRow>,
    current_row: Option<Vec<TableCell>>,
    current_row_has_table_pipe_syntax: bool,
    current_cell: Option<TableCell>,
    in_header: bool,
}

impl TableState {
    fn new(alignments: Vec<Alignment>) -> Self {
        Self {
            alignments,
            header: None,
            rows: Vec::new(),
            current_row: None,
            current_row_has_table_pipe_syntax: false,
            current_cell: None,
            in_header: false,
        }
    }
}

/// Rendered table output split by wrapping behavior.
///
/// `table_lines` are either prewrapped grid rows (box rendering) or pipe
/// fallback rows that should still pass through normal wrapping.
/// `spillover_lines` are prose rows extracted from parser artifacts and should
/// be routed through normal wrapping.
struct RenderedTableLines {
    table_lines: Vec<Line<'static>>,
    table_lines_prewrapped: bool,
    spillover_lines: Vec<Line<'static>>,
}

/// Classification of a table column for width-allocation priority.
///
/// Narrative columns (long prose, many words per cell) are shrunk first when the table exceeds
/// available width.  Structured columns (short tokens like dates, status words, numbers) are
/// preserved as long as possible to keep their content on a single line.
///
/// The heuristic is simple: >= 4 average words per cell OR >= 28 average character width →
/// Narrative. Everything else → Structured.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TableColumnKind {
    /// Long-form prose content (>= 4 avg words/cell or >= 28 avg char width).
    Narrative,
    /// Short, token-like content that should resist wrapping.
    Structured,
}

/// Per-column statistics used to drive the width-allocation algorithm.
///
/// Collected in a single pass over the header and body rows before any
/// shrinking decisions are made.
#[derive(Clone, Debug)]
struct TableColumnMetrics {
    /// Widest cell content (display width) across header and all body rows.
    max_width: usize,
    /// Display width of the longest whitespace-delimited token in the header.
    header_token_width: usize,
    /// Display width of the longest whitespace-delimited token across body rows.
    body_token_width: usize,
    /// Average number of whitespace-delimited words per non-empty body cell.
    avg_words_per_cell: f64,
    /// Average display width of non-empty body cells.
    avg_cell_width: f64,
    /// Classification derived from `avg_words_per_cell` and `avg_cell_width`.
    kind: TableColumnKind,
}

/// Render markdown with default wrapping behavior.
///
/// Use this when the caller does not have a concrete render width yet (for
/// example, snapshot tests or contexts that intentionally defer wrapping). If
/// a viewport width is known, prefer [`render_markdown_text_with_width`] so
/// table fallback and line wrapping decisions match the visible terminal.
pub fn render_markdown_text(input: &str) -> Text<'static> {
    render_markdown_text_with_width(input, /*width*/ None)
}

/// Render markdown constrained to a known terminal width.
///
/// The renderer preserves table structure when possible and falls back to
/// pipe-table output when a box table cannot fit the available width. Passing
/// `None` keeps intrinsic line widths and disables width-driven wrapping in the
/// markdown writer. Local file links render relative to the current process
/// working directory.
pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static> {
    let cwd = std::env::current_dir().ok();
    render_markdown_text_with_width_and_cwd(input, width, cwd.as_deref())
}

/// Render markdown with an explicit working directory for local file links.
///
/// The `cwd` parameter controls how absolute local targets are shortened before display. Passing
/// the session cwd keeps full renders, history cells, and streamed deltas visually aligned even
/// when rendering happens away from the process cwd.
pub(crate) fn render_markdown_text_with_width_and_cwd(
    input: &str,
    width: Option<usize>,
    cwd: Option<&Path>,
) -> Text<'static> {
    let mut options = Options::empty();
    options.insert(Options::ENABLE_STRIKETHROUGH);
    options.insert(Options::ENABLE_TABLES);
    let parser = Parser::new_ext(input, options).into_offset_iter();
    let mut w = Writer::new(input, parser, width, cwd);
    w.run();
    w.text
}

#[derive(Clone, Debug)]
struct LinkState {
    destination: String,
    show_destination: bool,
    /// Pre-rendered display text for local file links.
    ///
    /// When this is present, the markdown label is intentionally suppressed so the rendered
    /// transcript always reflects the real target path.
    local_target_display: Option<String>,
}

fn should_render_link_destination(dest_url: &str) -> bool {
    !is_local_path_like_link(dest_url)
}

static COLON_LOCATION_SUFFIX_RE: LazyLock<Regex> =
    LazyLock::new(
        || match Regex::new(r":\d+(?::\d+)?(?:[-–]\d+(?::\d+)?)?$") {
            Ok(regex) => regex,
            Err(error) => panic!("invalid location suffix regex: {error}"),
        },
    );

// Covered by load_location_suffix_regexes.
static HASH_LOCATION_SUFFIX_RE: LazyLock<Regex> =
    LazyLock::new(|| match Regex::new(r"^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$") {
        Ok(regex) => regex,
        Err(error) => panic!("invalid hash location regex: {error}"),
    });

/// Stateful pulldown-cmark event consumer that builds styled `ratatui` output.
///
/// Tracks inline style nesting, indent/blockquote context, list numbering,
/// and an optional `TableState` for accumulating table events.  The
/// `wrap_width` field enables width-aware line wrapping and table column
/// allocation; when `None`, lines keep their intrinsic width.
struct Writer<'a, I>
where
    I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
    input: &'a str,
    iter: I,
    text: Text<'static>,
    styles: MarkdownStyles,
    inline_styles: Vec<Style>,
    indent_stack: Vec<IndentContext>,
    list_indices: Vec<Option<u64>>,
    list_needs_blank_before_next_item: Vec<bool>,
    list_item_contains_code_block: Vec<bool>,
    link: Option<LinkState>,
    needs_newline: bool,
    pending_marker_line: bool,
    in_paragraph: bool,
    in_code_block: bool,
    code_block_lang: Option<String>,
    code_block_buffer: String,
    wrap_width: Option<usize>,
    cwd: Option<PathBuf>,
    line_ends_with_local_link_target: bool,
    pending_local_link_soft_break: bool,
    current_line_content: Option<Line<'static>>,
    current_initial_indent: Vec<Span<'static>>,
    current_subsequent_indent: Vec<Span<'static>>,
    current_line_style: Style,
    current_line_in_code_block: bool,
    table_state: Option<TableState>,
}

impl<'a, I> Writer<'a, I>
where
    I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
    fn new(input: &'a str, iter: I, wrap_width: Option<usize>, cwd: Option<&Path>) -> Self {
        Self {
            input,
            iter,
            text: Text::default(),
            styles: MarkdownStyles::default(),
            inline_styles: Vec::new(),
            indent_stack: Vec::new(),
            list_indices: Vec::new(),
            list_needs_blank_before_next_item: Vec::new(),
            list_item_contains_code_block: Vec::new(),
            link: None,
            needs_newline: false,
            pending_marker_line: false,
            in_paragraph: false,
            in_code_block: false,
            code_block_lang: None,
            code_block_buffer: String::new(),
            wrap_width,
            cwd: cwd.map(Path::to_path_buf),
            line_ends_with_local_link_target: false,
            pending_local_link_soft_break: false,
            current_line_content: None,
            current_initial_indent: Vec::new(),
            current_subsequent_indent: Vec::new(),
            current_line_style: Style::default(),
            current_line_in_code_block: false,
            table_state: None,
        }
    }

    fn run(&mut self) {
        while let Some((ev, range)) = self.iter.next() {
            self.handle_event(ev, range);
        }
        self.flush_current_line();
    }

    fn handle_event(&mut self, event: Event<'a>, range: Range<usize>) {
        self.prepare_for_event(&event);
        match event {
            Event::Start(tag) => self.start_tag(tag, range),
            Event::End(tag) => self.end_tag(tag),
            Event::Text(text) => self.text(text),
            Event::Code(code) => self.code(code),
            Event::SoftBreak => self.soft_break(),
            Event::HardBreak => self.hard_break(),
            Event::Rule => {
                self.flush_current_line();
                if !self.text.lines.is_empty() {
                    self.push_blank_line();
                }
                self.push_line(Line::from("———"));
                self.needs_newline = true;
            }
            Event::Html(html) => self.html(html, /*inline*/ false),
            Event::InlineHtml(html) => self.html(html, /*inline*/ true),
            Event::FootnoteReference(_) => {}
            Event::TaskListMarker(_) => {}
        }
    }

    fn prepare_for_event(&mut self, event: &Event<'a>) {
        if !self.pending_local_link_soft_break {
            return;
        }

        // Local file links render from the destination at `TagEnd::Link`, so a Markdown soft break
        // immediately before a descriptive `: ...` should stay inline instead of splitting the
        // list item across two lines.
        if matches!(event, Event::Text(text) if text.trim_start().starts_with(':')) {
            self.pending_local_link_soft_break = false;
            return;
        }

        self.pending_local_link_soft_break = false;
        self.push_line(Line::default());
    }

    fn start_tag(&mut self, tag: Tag<'a>, range: Range<usize>) {
        match tag {
            Tag::Paragraph => self.start_paragraph(),
            Tag::Heading { level, .. } => self.start_heading(level),
            Tag::BlockQuote => self.start_blockquote(),
            Tag::CodeBlock(kind) => {
                let indent = match kind {
                    CodeBlockKind::Fenced(_) => None,
                    CodeBlockKind::Indented => Some(Span::from(" ".repeat(4))),
                };
                let lang = match kind {
                    CodeBlockKind::Fenced(lang) => Some(lang.to_string()),
                    CodeBlockKind::Indented => None,
                };
                self.start_codeblock(lang, indent)
            }
            Tag::List(start) => self.start_list(start),
            Tag::Item => self.start_item(),
            Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
            Tag::Strong => self.push_inline_style(self.styles.strong),
            Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
            Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
            Tag::Table(alignments) => self.start_table(alignments),
            Tag::TableHead => self.start_table_head(),
            Tag::TableRow => self.start_table_row(range),
            Tag::TableCell => self.start_table_cell(),
            Tag::HtmlBlock
            | Tag::FootnoteDefinition(_)
            | Tag::Image { .. }
            | Tag::MetadataBlock(_) => {}
        }
    }

    fn end_tag(&mut self, tag: TagEnd) {
        match tag {
            TagEnd::Paragraph => self.end_paragraph(),
            TagEnd::Heading(_) => self.end_heading(),
            TagEnd::BlockQuote => self.end_blockquote(),
            TagEnd::CodeBlock => self.end_codeblock(),
            TagEnd::List(_) => self.end_list(),
            TagEnd::Item => {
                if self.list_item_contains_code_block.pop().unwrap_or(false)
                    && let Some(needs_blank) = self.list_needs_blank_before_next_item.last_mut()
                {
                    *needs_blank = true;
                }
                self.indent_stack.pop();
                self.pending_marker_line = false;
            }
            TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline_style(),
            TagEnd::Link => self.pop_link(),
            TagEnd::Table => self.end_table(),
            TagEnd::TableHead => self.end_table_head(),
            TagEnd::TableRow => self.end_table_row(),
            TagEnd::TableCell => self.end_table_cell(),
            TagEnd::HtmlBlock
            | TagEnd::FootnoteDefinition
            | TagEnd::Image
            | TagEnd::MetadataBlock(_) => {}
        }
    }

    fn start_paragraph(&mut self) {
        if self.in_table_cell() {
            return;
        }
        if self.needs_newline {
            self.push_blank_line();
        }
        self.push_line(Line::default());
        self.needs_newline = false;
        self.in_paragraph = true;
    }

    fn end_paragraph(&mut self) {
        if self.in_table_cell() {
            return;
        }
        self.needs_newline = true;
        self.in_paragraph = false;
        self.pending_marker_line = false;
    }

    fn start_heading(&mut self, level: HeadingLevel) {
        if self.in_table_cell() {
            return;
        }
        if self.needs_newline {
            self.push_line(Line::default());
            self.needs_newline = false;
        }
        let heading_style = match level {
            HeadingLevel::H1 => self.styles.h1,
            HeadingLevel::H2 => self.styles.h2,
            HeadingLevel::H3 => self.styles.h3,
            HeadingLevel::H4 => self.styles.h4,
            HeadingLevel::H5 => self.styles.h5,
            HeadingLevel::H6 => self.styles.h6,
        };
        let content = format!("{} ", "#".repeat(level as usize));
        self.push_line(Line::from(vec![Span::styled(content, heading_style)]));
        self.push_inline_style(heading_style);
        self.needs_newline = false;
    }

    fn end_heading(&mut self) {
        if self.in_table_cell() {
            return;
        }
        self.needs_newline = true;
        self.pop_inline_style();
    }

    fn start_blockquote(&mut self) {
        if self.in_table_cell() {
            return;
        }
        if self.needs_newline {
            self.push_blank_line();
            self.needs_newline = false;
        }
        self.indent_stack.push(IndentContext::new(
            vec![Span::from("> ")],
            /*marker*/ None,
            /*is_list*/ false,
        ));
    }

    fn end_blockquote(&mut self) {
        if self.in_table_cell() {
            return;
        }
        self.indent_stack.pop();
        self.needs_newline = true;
    }

    fn text(&mut self, text: CowStr<'a>) {
        if self.suppressing_local_link_label() {
            return;
        }
        self.line_ends_with_local_link_target = false;
        if self.in_table_cell() {
            self.push_text_to_table_cell(&text);
            return;
        }

        if self.pending_marker_line {
            self.push_line(Line::default());
        }
        self.pending_marker_line = false;

        // When inside a fenced code block with a known language, accumulate
        // text into the buffer for batch highlighting in end_codeblock().
        // Append verbatim — pulldown-cmark text events already contain the
        // original line breaks, so inserting separators would double them.
        if self.in_code_block && self.code_block_lang.is_some() {
            self.code_block_buffer.push_str(&text);
            return;
        }

        if self.in_code_block && !self.needs_newline {
            let has_content = self
                .current_line_content
                .as_ref()
                .map(|line| !line.spans.is_empty())
                .unwrap_or_else(|| {
                    self.text
                        .lines
                        .last()
                        .map(|line| !line.spans.is_empty())
                        .unwrap_or(false)
                });
            if has_content {
                self.push_line(Line::default());
            }
        }
        for (i, line) in text.lines().enumerate() {
            if self.needs_newline {
                self.push_line(Line::default());
                self.needs_newline = false;
            }
            if i > 0 {
                self.push_line(Line::default());
            }
            let content = line.to_string();
            let span = Span::styled(
                content,
                self.inline_styles.last().copied().unwrap_or_default(),
            );
            self.push_span(span);
        }
        self.needs_newline = false;
    }

    fn code(&mut self, code: CowStr<'a>) {
        if self.suppressing_local_link_label() {
            return;
        }
        self.line_ends_with_local_link_target = false;
        if self.in_table_cell() {
            self.push_span_to_table_cell(Span::from(code.into_string()).style(self.styles.code));
            return;
        }

        if self.pending_marker_line {
            self.push_line(Line::default());
            self.pending_marker_line = false;
        }
        let span = Span::from(code.into_string()).style(self.styles.code);
        self.push_span(span);
    }

    fn html(&mut self, html: CowStr<'a>, inline: bool) {
        if self.suppressing_local_link_label() {
            return;
        }
        self.line_ends_with_local_link_target = false;
        if self.in_table_cell() {
            let style = self.inline_styles.last().copied().unwrap_or_default();
            for (i, line) in html.lines().enumerate() {
                if i > 0 {
                    self.push_table_cell_hard_break();
                }
                self.push_span_to_table_cell(Span::styled(line.to_string(), style));
            }
            if !inline {
                self.push_table_cell_hard_break();
            }
            return;
        }
        self.pending_marker_line = false;
        for (i, line) in html.lines().enumerate() {
            if self.needs_newline {
                self.push_line(Line::default());
                self.needs_newline = false;
            }
            if i > 0 {
                self.push_line(Line::default());
            }
            let style = self.inline_styles.last().copied().unwrap_or_default();
            self.push_span(Span::styled(line.to_string(), style));
        }
        self.needs_newline = !inline;
    }

    fn hard_break(&mut self) {
        if self.suppressing_local_link_label() {
            return;
        }
        self.line_ends_with_local_link_target = false;
        if self.in_table_cell() {
            self.push_table_cell_hard_break();
            return;
        }
        self.push_line(Line::default());
    }

    fn soft_break(&mut self) {
        if self.suppressing_local_link_label() {
            return;
        }
        if self.in_table_cell() {
            let style = self.inline_styles.last().copied().unwrap_or_default();
            self.push_span_to_table_cell(Span::styled(" ".to_string(), style));
            return;
        }
        if self.line_ends_with_local_link_target {
            self.pending_local_link_soft_break = true;
            self.line_ends_with_local_link_target = false;
            return;
        }
        self.line_ends_with_local_link_target = false;
        self.push_line(Line::default());
    }

    fn start_list(&mut self, index: Option<u64>) {
        if self.list_indices.is_empty() && self.needs_newline {
            self.push_line(Line::default());
        }
        self.list_indices.push(index);
        self.list_needs_blank_before_next_item.push(false);
    }

    fn end_list(&mut self) {
        self.list_indices.pop();
        self.list_needs_blank_before_next_item.pop();
        self.needs_newline = true;
    }

    fn start_item(&mut self) {
        if self
            .list_needs_blank_before_next_item
            .last_mut()
            .map(std::mem::take)
            .unwrap_or(false)
        {
            self.push_blank_line();
        }
        self.pending_marker_line = true;
        self.list_item_contains_code_block.push(false);
        let depth = self.list_indices.len();
        let is_ordered = self
            .list_indices
            .last()
            .map(Option::is_some)
            .unwrap_or(false);
        let width = depth * 4 - 3;
        let marker = if let Some(last_index) = self.list_indices.last_mut() {
            match last_index {
                None => Some(vec![Span::styled(
                    " ".repeat(width - 1) + "- ",
                    self.styles.unordered_list_marker,
                )]),
                Some(index) => {
                    *index += 1;
                    Some(vec![Span::styled(
                        format!("{:width$}. ", *index - 1),
                        self.styles.ordered_list_marker,
                    )])
                }
            }
        } else {
            None
        };
        let indent_prefix = if depth == 0 {
            Vec::new()
        } else {
            let indent_len = if is_ordered { width + 2 } else { width + 1 };
            vec![Span::from(" ".repeat(indent_len))]
        };
        self.indent_stack.push(IndentContext::new(
            indent_prefix,
            marker,
            /*is_list*/ true,
        ));
        self.needs_newline = false;
    }

    fn start_codeblock(&mut self, lang: Option<String>, indent: Option<Span<'static>>) {
        for item_contains_code_block in &mut self.list_item_contains_code_block {
            *item_contains_code_block = true;
        }
        self.flush_current_line();
        if !self.text.lines.is_empty() {
            self.push_blank_line();
        }
        self.in_code_block = true;

        // Extract the language token from the info string.  CommonMark info
        // strings can contain metadata after the language, separated by commas,
        // spaces, or other delimiters (e.g. "rust,no_run", "rust title=demo").
        // Take only the first token so the syntax lookup succeeds.
        let lang = lang
            .as_deref()
            .and_then(|s| s.split([',', ' ', '\t']).next())
            .filter(|s| !s.is_empty())
            .map(std::string::ToString::to_string);
        self.code_block_lang = lang;
        self.code_block_buffer.clear();

        self.indent_stack.push(IndentContext::new(
            vec![indent.unwrap_or_default()],
            /*marker*/ None,
            /*is_list*/ false,
        ));
        self.needs_newline = true;
    }

    fn end_codeblock(&mut self) {
        // If we buffered code for a known language, syntax-highlight it now.
        if let Some(lang) = self.code_block_lang.take() {
            let code = std::mem::take(&mut self.code_block_buffer);
            if !code.is_empty() {
                let highlighted = highlight_code_to_lines(&code, &lang);
                for hl_line in highlighted {
                    self.push_line(Line::default());
                    for span in hl_line.spans {
                        self.push_span(span);
                    }
                }
            }
        }

        self.needs_newline = true;
        self.in_code_block = false;
        self.indent_stack.pop();
    }

    fn start_table(&mut self, alignments: Vec<Alignment>) {
        self.flush_current_line();
        if self.needs_newline {
            self.push_blank_line();
            self.needs_newline = false;
        }
        self.table_state = Some(TableState::new(alignments));
    }

    fn end_table(&mut self) {
        let Some(table_state) = self.table_state.take() else {
            return;
        };

        let RenderedTableLines {
            table_lines,
            table_lines_prewrapped,
            spillover_lines,
        } = self.render_table_lines(table_state);
        let mut pending_marker_line = self.pending_marker_line;
        for line in table_lines {
            if table_lines_prewrapped {
                self.push_prewrapped_line(line, pending_marker_line);
            } else {
                self.push_line(line);
                self.flush_current_line();
            }
            pending_marker_line = false;
        }
        self.pending_marker_line = false;
        for spillover_line in spillover_lines {
            self.push_line(spillover_line);
            self.flush_current_line();
        }
        self.needs_newline = true;
    }

    fn start_table_head(&mut self) {
        if let Some(table_state) = self.table_state.as_mut() {
            table_state.in_header = true;
            table_state.current_row = Some(Vec::new());
        }
    }

    fn end_table_head(&mut self) {
        let Some(table_state) = self.table_state.as_mut() else {
            return;
        };
        if let Some(current_cell) = table_state.current_cell.take() {
            table_state
                .current_row
                .get_or_insert_with(Vec::new)
                .push(current_cell);
        }
        if let Some(row) = table_state.current_row.take() {
            table_state.header = Some(row);
        }
        table_state.in_header = false;
    }

    fn start_table_row(&mut self, source_range: Range<usize>) {
        let has_table_pipe_syntax = self.has_table_row_boundary_pipe(source_range);
        if let Some(table_state) = self.table_state.as_mut() {
            table_state.current_row = Some(Vec::new());
            table_state.current_row_has_table_pipe_syntax = has_table_pipe_syntax;
        }
    }

    fn has_table_row_boundary_pipe(&self, source_range: Range<usize>) -> bool {
        let Some(source) = self.input.get(source_range) else {
            return false;
        };
        let source = source.trim();
        source.starts_with('|') || source.ends_with('|')
    }

    fn end_table_row(&mut self) {
        let Some(table_state) = self.table_state.as_mut() else {
            return;
        };

        if let Some(current_cell) = table_state.current_cell.take() {
            table_state
                .current_row
                .get_or_insert_with(Vec::new)
                .push(current_cell);
        }

        let Some(row) = table_state.current_row.take() else {
            return;
        };

        if table_state.in_header {
            table_state.header = Some(row);
        } else {
            table_state.rows.push(TableBodyRow {
                cells: row,
                has_table_pipe_syntax: table_state.current_row_has_table_pipe_syntax,
            });
        }
        table_state.current_row_has_table_pipe_syntax = false;
    }

    fn start_table_cell(&mut self) {
        if let Some(table_state) = self.table_state.as_mut() {
            table_state.current_cell = Some(TableCell::default());
        }
    }

    fn end_table_cell(&mut self) {
        let Some(table_state) = self.table_state.as_mut() else {
            return;
        };

        if let Some(cell) = table_state.current_cell.take() {
            table_state
                .current_row
                .get_or_insert_with(Vec::new)
                .push(cell);
        }
    }

    fn in_table_cell(&self) -> bool {
        self.table_state
            .as_ref()
            .and_then(|table_state| table_state.current_cell.as_ref())
            .is_some()
    }

    fn push_span_to_table_cell(&mut self, span: Span<'static>) {
        if let Some(table_state) = self.table_state.as_mut()
            && let Some(cell) = table_state.current_cell.as_mut()
        {
            cell.push_span(span);
        }
    }

    fn push_table_cell_hard_break(&mut self) {
        if let Some(table_state) = self.table_state.as_mut()
            && let Some(cell) = table_state.current_cell.as_mut()
        {
            cell.hard_break();
        }
    }

    fn push_text_to_table_cell(&mut self, text: &str) {
        let style = self.inline_styles.last().copied().unwrap_or_default();
        for (i, line) in text.lines().enumerate() {
            if i > 0 {
                self.push_table_cell_hard_break();
            }
            self.push_span_to_table_cell(Span::styled(line.to_string(), style));
        }
    }

    /// Convert a completed `TableState` into styled `Line`s with Unicode
    /// box-drawing borders.
    ///
    /// Pipeline: filter spillover rows -> normalize column counts -> compute
    /// column widths -> render box grid (or fall back to pipe format if the
    /// minimum column widths exceed available terminal width). Spillover rows
    /// are appended as plain text after the table grid.
    ///
    /// Falls back to `render_table_pipe_fallback` (raw `| A | B |` format)
    /// when `compute_column_widths` returns `None` (terminal too narrow for
    /// even 3-char-wide columns).
    fn render_table_lines(&self, mut table_state: TableState) -> RenderedTableLines {
        let column_count = table_state.alignments.len();
        if column_count == 0 {
            return RenderedTableLines {
                table_lines: Vec::new(),
                table_lines_prewrapped: true,
                spillover_lines: Vec::new(),
            };
        }

        let mut spillover_rows: Vec<TableCell> = Vec::with_capacity(4);
        let mut rows: Vec<Vec<TableCell>> = Vec::with_capacity(table_state.rows.len());
        for (row_idx, row) in table_state.rows.iter().enumerate() {
            let next_row = table_state.rows.get(row_idx + 1);
            // pulldown-cmark accepts body rows without pipes, which can turn a following paragraph
            // into a one-cell table row. For multi-column tables, treat those as spillover text
            // rendered after the table.
            if column_count > 1 && Self::is_spillover_row(row, next_row) {
                if let Some(cell) = row.cells.first().cloned() {
                    spillover_rows.push(cell);
                }
            } else {
                rows.push(row.cells.clone());
            }
        }

        let mut header = table_state
            .header
            .take()
            .unwrap_or_else(|| vec![TableCell::default(); column_count]);
        Self::normalize_row(&mut header, column_count);
        for row in &mut rows {
            Self::normalize_row(row, column_count);
        }

        let available_width = self.available_table_width(column_count);
        let widths =
            self.compute_column_widths(&header, &rows, &table_state.alignments, available_width);
        let spillover_lines: Vec<Line<'static>> = spillover_rows
            .into_iter()
            .flat_map(|spillover| spillover.lines)
            .collect();

        let Some(column_widths) = widths else {
            return RenderedTableLines {
                table_lines: self.render_table_pipe_fallback(
                    &header,
                    &rows,
                    &table_state.alignments,
                ),
                table_lines_prewrapped: false,
                spillover_lines,
            };
        };

        let border_style = Style::new().dim();
        let mut out = Vec::with_capacity(3 + rows.len() * 2);
        out.push(self.render_border_line('┌', '┬', '┐', &column_widths, border_style));
        out.extend(self.render_table_row(
            &header,
            &column_widths,
            &table_state.alignments,
            border_style,
        ));
        out.push(self.render_border_line('├', '┼', '┤', &column_widths, border_style));
        for row in &rows {
            out.extend(self.render_table_row(
                row,
                &column_widths,
                &table_state.alignments,
                border_style,
            ));
        }
        out.push(self.render_border_line('└', '┴', '┘', &column_widths, border_style));
        RenderedTableLines {
            table_lines: out,
            table_lines_prewrapped: true,
            spillover_lines,
        }
    }

    fn normalize_row(row: &mut Vec<TableCell>, column_count: usize) {
        row.truncate(column_count);
        row.resize(column_count, TableCell::default());
    }

    /// subtracts the space eaten by border characters
    fn available_table_width(&self, column_count: usize) -> Option<usize> {
        self.wrap_width.map(|wrap_width| {
            let prefix_width =
                Self::spans_display_width(&self.prefix_spans(self.pending_marker_line));
            let reserved = prefix_width + 1 + (column_count * 3);
            wrap_width.saturating_sub(reserved)
        })
    }

    /// Allocate column widths for box-drawing table rendering.
    ///
    /// Each column starts at its natural (max cell content) width, then columns
    /// are iteratively shrunk one character at a time until the total fits within
    /// `available_width`. Narrative columns (long prose) are shrunk before
    /// Structured columns (short tokens). Returns `None` when even the minimum
    /// width (3 chars per column) cannot fit.
    fn compute_column_widths(
        &self,
        header: &[TableCell],
        rows: &[Vec<TableCell>],
        alignments: &[Alignment],
        available_width: Option<usize>,
    ) -> Option<Vec<usize>> {
        let min_column_width = 3usize;
        let metrics = Self::collect_table_column_metrics(header, rows, alignments.len());
        let mut widths: Vec<usize> = metrics
            .iter()
            .map(|col| col.max_width.max(min_column_width))
            .collect();

        let Some(max_width) = available_width else {
            return Some(widths);
        };
        let minimum_total = alignments.len() * min_column_width;
        if max_width < minimum_total {
            return None;
        }

        let mut floors: Vec<usize> = metrics
            .iter()
            .map(|col| Self::preferred_column_floor(col, min_column_width))
            .collect();
        let mut floor_total: usize = floors.iter().sum();
        if floor_total > max_width {
            // Relax preferred floors (starting with narrative columns) until we can satisfy the
            // width budget. We still keep hard minimums.
            while floor_total > max_width {
                let Some((idx, _)) = floors
                    .iter()
                    .enumerate()
                    .filter(|(_, floor)| **floor > min_column_width)
                    .min_by_key(|(idx, floor)| {
                        let kind_priority = match metrics[*idx].kind {
                            TableColumnKind::Narrative => 0,
                            TableColumnKind::Structured => 1,
                        };
                        (kind_priority, *floor)
                    })
                else {
                    break;
                };

                floors[idx] -= 1;
                floor_total -= 1;
            }
        }

        let mut total_width: usize = widths.iter().sum();

        while total_width > max_width {
            let Some(idx) = Self::next_column_to_shrink(&widths, &floors, &metrics) else {
                break;
            };
            widths[idx] -= 1;
            total_width -= 1;
        }

        if total_width > max_width {
            return None;
        }

        Some(widths)
    }

    fn collect_table_column_metrics(
        header: &[TableCell],
        rows: &[Vec<TableCell>],
        column_count: usize,
    ) -> Vec<TableColumnMetrics> {
        let mut metrics = Vec::with_capacity(column_count);
        for column in 0..column_count {
            let header_cell = &header[column];
            let header_plain = header_cell.plain_text();
            let header_token_width = Self::longest_token_width(&header_plain);
            let mut max_width = Self::cell_display_width(header_cell);
            let mut body_token_width = 0usize;
            let mut total_words = 0usize;
            let mut total_cells = 0usize;
            let mut total_cell_width = 0usize;

            for row in rows {
                let cell = &row[column];
                max_width = max_width.max(Self::cell_display_width(cell));
                let plain = cell.plain_text();
                body_token_width = body_token_width.max(Self::longest_token_width(&plain));
                let word_count = plain.split_whitespace().count();
                if word_count > 0 {
                    total_words += word_count;
                    total_cells += 1;
                    total_cell_width += plain.width();
                }
            }

            let avg_words_per_cell = if total_cells == 0 {
                header_plain.split_whitespace().count() as f64
            } else {
                total_words as f64 / total_cells as f64
            };
            let avg_cell_width = if total_cells == 0 {
                header_plain.width() as f64
            } else {
                total_cell_width as f64 / total_cells as f64
            };
            let kind = if body_token_width >= 20 && avg_words_per_cell <= 2.0 {
                // URL-like/token-heavy columns should resist collapse even if their
                // average cell width is high.
                TableColumnKind::Structured
            } else if avg_words_per_cell >= 4.0 || avg_cell_width >= 28.0 {
                TableColumnKind::Narrative
            } else {
                TableColumnKind::Structured
            };

            metrics.push(TableColumnMetrics {
                max_width,
                header_token_width,
                body_token_width,
                avg_words_per_cell,
                avg_cell_width,
                kind,
            });
        }

        metrics
    }

    /// Compute the preferred minimum width for a column before the shrink loop
    /// starts reducing it further.
    ///
    /// Narrative columns floor at the header's longest token (capped at 10).
    /// Structured columns floor at the larger of the header and body token widths
    /// (body capped at 16). The result is clamped to `[min_column_width, max_width]`.
    fn preferred_column_floor(metrics: &TableColumnMetrics, min_column_width: usize) -> usize {
        let token_target = match metrics.kind {
            TableColumnKind::Narrative => metrics.header_token_width.min(10),
            TableColumnKind::Structured => metrics
                .header_token_width
                .max(metrics.body_token_width.min(16)),
        };
        token_target.max(min_column_width).min(metrics.max_width)
    }

    /// Pick the next column to shrink by one character during width allocation.
    ///
    /// Priority: Narrative columns are shrunk before Structured. Within the same
    /// kind, the column with the most slack above its floor is chosen. A guard
    /// cost is added when the width would fall below the header's longest token
    /// (to avoid truncating column headers).
    fn next_column_to_shrink(
        widths: &[usize],
        floors: &[usize],
        metrics: &[TableColumnMetrics],
    ) -> Option<usize> {
        widths
            .iter()
            .enumerate()
            .filter(|(idx, width)| **width > floors[*idx])
            .min_by_key(|(idx, width)| {
                let slack = width.saturating_sub(floors[*idx]);
                let kind_cost = match metrics[*idx].kind {
                    TableColumnKind::Narrative => 0i32,
                    TableColumnKind::Structured => 2i32,
                };
                let header_guard = if **width <= metrics[*idx].header_token_width {
                    3i32
                } else {
                    0i32
                };
                let density_guard = if metrics[*idx].avg_words_per_cell >= 4.0
                    || metrics[*idx].avg_cell_width >= 24.0
                {
                    0i32
                } else {
                    1i32
                };
                (
                    kind_cost + header_guard + density_guard,
                    usize::MAX.saturating_sub(slack),
                )
            })
            .map(|(idx, _)| idx)
    }

    fn render_border_line(
        &self,
        left: char,
        sep: char,
        right: char,
        column_widths: &[usize],
        style: Style,
    ) -> Line<'static> {
        let mut spans = Vec::with_capacity(column_widths.len() * 2 + 1);
        spans.push(Span::styled(String::from(left), style));
        for (idx, width) in column_widths.iter().enumerate() {
            spans.push(Span::styled("─".repeat(*width + 2), style));
            if idx + 1 == column_widths.len() {
                spans.push(Span::styled(String::from(right), style));
            } else {
                spans.push(Span::styled(String::from(sep), style));
            }
        }
        Line::from(spans)
    }

    fn render_table_row(
        &self,
        row: &[TableCell],
        column_widths: &[usize],
        alignments: &[Alignment],
        border_style: Style,
    ) -> Vec<Line<'static>> {
        let wrapped_cells: Vec<Vec<Line<'static>>> = row
            .iter()
            .zip(column_widths)
            .map(|(cell, width)| self.wrap_cell(cell, *width))
            .collect();
        let row_height = wrapped_cells.iter().map(Vec::len).max().unwrap_or(1);

        let mut out = Vec::with_capacity(row_height);
        for row_line in 0..row_height {
            let mut spans = Vec::new();
            spans.push(Span::styled("│", border_style));
            for (column, width) in column_widths.iter().enumerate() {
                spans.push(Span::raw(" "));
                let line = wrapped_cells[column]
                    .get(row_line)
                    .cloned()
                    .unwrap_or_default();
                let line_width = Self::line_display_width(&line);
                let remaining = width.saturating_sub(line_width);
                let (left_padding, right_padding) = match alignments[column] {
                    Alignment::Left | Alignment::None => (0, remaining),
                    Alignment::Center => (remaining / 2, remaining - (remaining / 2)),
                    Alignment::Right => (remaining, 0),
                };
                if left_padding > 0 {
                    spans.push(Span::raw(" ".repeat(left_padding)));
                }
                spans.extend(line.spans);
                if right_padding > 0 {
                    spans.push(Span::raw(" ".repeat(right_padding)));
                }
                spans.push(Span::raw(" "));
                spans.push(Span::styled("│", border_style));
            }
            out.push(Line::from(spans));
        }
        out
    }

    /// Render the table as raw pipe-delimited lines (`| A | B |`).
    ///
    /// Used when `compute_column_widths` returns `None` (terminal too narrow
    /// for even 3-char-wide columns).  Pipe characters inside cell content are
    /// escaped as `\|` so downstream parsers keep cell boundaries intact.
    fn render_table_pipe_fallback(
        &self,
        header: &[TableCell],
        rows: &[Vec<TableCell>],
        alignments: &[Alignment],
    ) -> Vec<Line<'static>> {
        let mut out = Vec::new();
        out.push(Line::from(Self::row_to_pipe_string(header)));
        out.push(Line::from(Self::alignments_to_pipe_delimiter(alignments)));
        out.extend(
            rows.iter()
                .map(|row| Line::from(Self::row_to_pipe_string(row))),
        );
        out
    }

    fn row_to_pipe_string(row: &[TableCell]) -> String {
        let mut out = String::new();
        out.push('|');
        for cell in row {
            out.push(' ');
            // Preserve literal `|` inside cell text in markdown fallback mode so
            // downstream markdown parsers keep the cell content intact.
            out.push_str(&cell.plain_text().replace('|', "\\|"));
            out.push(' ');
            out.push('|');
        }
        out
    }

    fn alignments_to_pipe_delimiter(alignments: &[Alignment]) -> String {
        let mut out = String::new();
        out.push('|');
        for alignment in alignments {
            let segment = match alignment {
                Alignment::Left => ":---",
                Alignment::Center => ":---:",
                Alignment::Right => "---:",
                Alignment::None => "---",
            };
            out.push_str(segment);
            out.push('|');
        }
        out
    }

    /// Wrap a single table cell's content to `width`, preserving rich inline
    /// styling (bold, code, links) across wrapped lines.
    ///
    /// Each logical line within the cell (separated by hard breaks) is wrapped
    /// independently.  Empty cells produce a single blank line so the row grid
    /// stays aligned.
    fn wrap_cell(&self, cell: &TableCell, width: usize) -> Vec<Line<'static>> {
        if cell.lines.is_empty() {
            return vec![Line::default()];
        }
        let mut wrapped = Vec::new();
        for source_line in &cell.lines {
            let rendered = word_wrap_line(source_line, RtOptions::new(width.max(1)));
            if rendered.is_empty() {
                wrapped.push(Line::default());
            } else {
                push_owned_lines(&rendered, &mut wrapped);
            };
        }
        if wrapped.is_empty() {
            wrapped.push(Line::default());
        }
        wrapped
    }

    /// Detect rows that are artifacts of pulldown-cmark's lenient table parsing.
    ///
    /// pulldown-cmark accepts body rows without leading pipes, which can absorb a
    /// trailing paragraph as a single-cell row in a multi-column table. These
    /// "spillover" rows are extracted and rendered as plain text after the table
    /// grid so they don't appear as malformed table content.
    ///
    /// Heuristic: a row is spillover if its only non-empty cell is the first one
    /// AND (a single-cell row lacked table pipe syntax, the content looks like
    /// HTML, it's a label line followed by HTML content, or a trailing
    /// HTML-intro label line).
    fn is_spillover_row(row: &TableBodyRow, next_row: Option<&TableBodyRow>) -> bool {
        let Some(first_text) = Self::first_non_empty_only_text(&row.cells) else {
            return false;
        };

        if row.cells.len() == 1 && !row.has_table_pipe_syntax {
            return true;
        }

        if Self::looks_like_html_content(&first_text) {
            return true;
        }

        // Keep common intro + html-block spillover together:
        // "HTML block:" followed by "<div ...>".
        if first_text.trim_end().ends_with(':') {
            if next_row
                .and_then(|row| Self::first_non_empty_only_text(&row.cells))
                .is_some_and(|text| Self::looks_like_html_content(&text))
            {
                return true;
            }

            // pulldown can end the table before the corresponding HTML block line.
            // In that case, treat trailing HTML-intro labels (e.g., "HTML block:")
            // as spillover while keeping explicit sparse labels in real tables.
            if next_row.is_none() && Self::looks_like_html_label_line(&first_text) {
                return true;
            }
        }

        false
    }

    fn first_non_empty_only_text(row: &[TableCell]) -> Option<String> {
        let first = row.first()?.plain_text();
        if first.trim().is_empty() {
            return None;
        }
        let rest_empty = row[1..]
            .iter()
            .all(|cell| cell.plain_text().trim().is_empty());
        rest_empty.then_some(first)
    }

    fn looks_like_html_content(text: &str) -> bool {
        let bytes = text.as_bytes();
        for (idx, &byte) in bytes.iter().enumerate() {
            if byte != b'<' {
                continue;
            }

            let mut tag_start = idx + 1;
            if tag_start < bytes.len() && (bytes[tag_start] == b'/' || bytes[tag_start] == b'!') {
                tag_start += 1;
            }

            if bytes.get(tag_start).is_some_and(u8::is_ascii_alphabetic)
                && bytes
                    .get(tag_start + 1..)
                    .is_some_and(|suffix| suffix.contains(&b'>'))
            {
                return true;
            }
        }
        false
    }

    fn looks_like_html_label_line(text: &str) -> bool {
        let trimmed = text.trim();
        if !trimmed.ends_with(':') {
            return false;
        }
        let prefix = trimmed.trim_end_matches(':').trim();
        prefix
            .split_whitespace()
            .any(|word| word.eq_ignore_ascii_case("html"))
    }

    // Width-measurement helpers inlined — called per-cell during table column
    // width computation, which runs on every re-render.

    #[inline]
    fn spans_display_width(spans: &[Span<'_>]) -> usize {
        spans.iter().map(|span| span.content.width()).sum()
    }

    #[inline]
    fn line_display_width(line: &Line<'_>) -> usize {
        Self::spans_display_width(&line.spans)
    }

    #[inline]
    fn cell_display_width(cell: &TableCell) -> usize {
        cell.lines
            .iter()
            .map(Self::line_display_width)
            .max()
            .unwrap_or(0)
    }

    #[inline]
    fn longest_token_width(text: &str) -> usize {
        text.split_whitespace().map(str::width).max().unwrap_or(0)
    }

    fn push_inline_style(&mut self, style: Style) {
        let current = self.inline_styles.last().copied().unwrap_or_default();
        let merged = current.patch(style);
        self.inline_styles.push(merged);
    }

    fn pop_inline_style(&mut self) {
        self.inline_styles.pop();
    }

    fn push_link(&mut self, dest_url: String) {
        let show_destination = should_render_link_destination(&dest_url);
        self.link = Some(LinkState {
            show_destination,
            local_target_display: if is_local_path_like_link(&dest_url) {
                render_local_link_target(&dest_url, self.cwd.as_deref())
            } else {
                None
            },
            destination: dest_url,
        });
    }

    fn pop_link(&mut self) {
        if let Some(link) = self.link.take() {
            if link.show_destination {
                // Link destinations are rendered as " (url)" suffixes. When parsing table cells,
                // append the suffix into the active cell buffer rather than the outer paragraph
                // line to avoid detached url lines.
                if self.in_table_cell() {
                    self.push_span_to_table_cell(" (".into());
                    self.push_span_to_table_cell(Span::styled(link.destination, self.styles.link));
                    self.push_span_to_table_cell(")".into());
                } else {
                    self.push_span(" (".into());
                    self.push_span(Span::styled(link.destination, self.styles.link));
                    self.push_span(")".into());
                }
            } else if let Some(local_target_display) = link.local_target_display {
                // Local file links are rendered as code-like path text so the transcript shows the
                // resolved target instead of arbitrary caller-provided label text.
                let style = self
                    .inline_styles
                    .last()
                    .copied()
                    .unwrap_or_default()
                    .patch(self.styles.code);
                let span = Span::styled(local_target_display, style);
                if self.in_table_cell() {
                    self.push_span_to_table_cell(span);
                } else {
                    if self.pending_marker_line {
                        self.push_line(Line::default());
                    }
                    self.push_span(span);
                    self.line_ends_with_local_link_target = true;
                }
            }
        }
    }

    fn suppressing_local_link_label(&self) -> bool {
        self.link
            .as_ref()
            .and_then(|link| link.local_target_display.as_ref())
            .is_some()
    }

    fn flush_current_line(&mut self) {
        if let Some(line) = self.current_line_content.take() {
            let style = self.current_line_style;
            // NB we don't wrap code in code blocks, in order to preserve whitespace for copy/paste.
            if !self.current_line_in_code_block
                && let Some(width) = self.wrap_width
            {
                let opts = RtOptions::new(width)
                    .initial_indent(self.current_initial_indent.clone().into())
                    .subsequent_indent(self.current_subsequent_indent.clone().into());
                for wrapped in adaptive_wrap_line(&line, opts) {
                    let owned = line_to_static(&wrapped).style(style);
                    self.push_output_line(owned);
                }
            } else {
                let mut spans = self.current_initial_indent.clone();
                let mut line = line;
                spans.append(&mut line.spans);
                self.push_output_line(Line::from_iter(spans).style(style));
            }
            self.current_initial_indent.clear();
            self.current_subsequent_indent.clear();
            self.current_line_in_code_block = false;
            self.line_ends_with_local_link_target = false;
        }
    }

    /// Push a line that has already been laid out at the correct width, skipping
    /// word wrapping.
    ///
    /// Table lines are pre-formatted with exact column widths and box-drawing
    /// borders. Passing them through `word_wrap_line` would break the grid at
    /// arbitrary positions. This method prepends the indent/blockquote prefix
    /// and pushes directly to `self.text.lines`.
    fn is_blockquote_active(&self) -> bool {
        self.indent_stack
            .iter()
            .any(|ctx| ctx.prefix.iter().any(|p| p.content.contains('>')))
    }

    fn push_prewrapped_line(&mut self, line: Line<'static>, pending_marker_line: bool) {
        self.flush_current_line();
        let blockquote_active = self.is_blockquote_active();
        let style = if blockquote_active {
            self.styles.blockquote.patch(line.style)
        } else {
            line.style
        };

        let mut spans = self.prefix_spans(pending_marker_line);
        spans.extend(line.spans);
        self.push_output_line(Line::from(spans).style(style));
    }

    fn push_line(&mut self, line: Line<'static>) {
        self.flush_current_line();
        let blockquote_active = self.is_blockquote_active();
        let style = if blockquote_active {
            self.styles.blockquote
        } else {
            line.style
        };
        let was_pending = self.pending_marker_line;

        self.current_initial_indent = self.prefix_spans(was_pending);
        self.current_subsequent_indent = self.prefix_spans(/*pending_marker_line*/ false);
        self.current_line_style = style;
        self.current_line_content = Some(line);
        self.current_line_in_code_block = self.in_code_block;
        self.line_ends_with_local_link_target = false;

        self.pending_marker_line = false;
    }

    fn push_span(&mut self, span: Span<'static>) {
        if let Some(line) = self.current_line_content.as_mut() {
            line.push_span(span);
        } else {
            self.push_line(Line::from(vec![span]));
        }
    }

    fn push_blank_line(&mut self) {
        self.flush_current_line();
        if self.indent_stack.iter().all(|ctx| ctx.is_list) {
            self.push_output_line(Line::default());
        } else {
            self.push_line(Line::default());
            self.flush_current_line();
        }
    }

    fn push_output_line(&mut self, line: Line<'static>) {
        self.text.lines.push(line);
    }

    fn prefix_spans(&self, pending_marker_line: bool) -> Vec<Span<'static>> {
        let mut prefix: Vec<Span<'static>> = Vec::new();
        let last_marker_index = if pending_marker_line {
            self.indent_stack
                .iter()
                .enumerate()
                .rev()
                .find_map(|(i, ctx)| if ctx.marker.is_some() { Some(i) } else { None })
        } else {
            None
        };
        let last_list_index = self.indent_stack.iter().rposition(|ctx| ctx.is_list);

        for (i, ctx) in self.indent_stack.iter().enumerate() {
            if pending_marker_line {
                if Some(i) == last_marker_index
                    && let Some(marker) = &ctx.marker
                {
                    prefix.extend(marker.iter().cloned());
                    continue;
                }
                if ctx.is_list && last_marker_index.is_some_and(|idx| idx > i) {
                    continue;
                }
            } else if ctx.is_list && Some(i) != last_list_index {
                continue;
            }
            prefix.extend(ctx.prefix.iter().cloned());
        }

        prefix
    }
}

fn is_local_path_like_link(dest_url: &str) -> bool {
    dest_url.starts_with("file://")
        || dest_url.starts_with('/')
        || dest_url.starts_with("~/")
        || dest_url.starts_with("./")
        || dest_url.starts_with("../")
        || dest_url.starts_with("\\\\")
        || matches!(
            dest_url.as_bytes(),
            [drive, b':', separator, ..]
                if drive.is_ascii_alphabetic() && matches!(separator, b'/' | b'\\')
        )
}

/// Parse a local link target into normalized path text plus an optional location suffix.
///
/// This accepts the path shapes Codex emits today: `file://` URLs, absolute and relative paths,
/// `~/...`, Windows paths, and `#L..C..` or `:line:col` suffixes.
fn render_local_link_target(dest_url: &str, cwd: Option<&Path>) -> Option<String> {
    let (path_text, location_suffix) = parse_local_link_target(dest_url)?;
    let mut rendered = display_local_link_path(&path_text, cwd);
    if let Some(location_suffix) = location_suffix {
        rendered.push_str(&location_suffix);
    }
    Some(rendered)
}

/// Split a local-link destination into `(normalized_path_text, location_suffix)`.
///
/// The returned path text never includes a trailing `#L..` or `:line[:col]` suffix. Path
/// normalization expands `~/...` when possible and rewrites path separators into display-stable
/// forward slashes. The suffix, when present, is returned separately in normalized markdown form.
///
/// Returns `None` only when the destination looks like a `file://` URL but cannot be parsed into a
/// local path. Plain path-like inputs always return `Some(...)` even if they are relative.
fn parse_local_link_target(dest_url: &str) -> Option<(String, Option<String>)> {
    if dest_url.starts_with("file://") {
        let url = Url::parse(dest_url).ok()?;
        let path_text = file_url_to_local_path_text(&url)?;
        let location_suffix = url
            .fragment()
            .and_then(normalize_hash_location_suffix_fragment);
        return Some((path_text, location_suffix));
    }

    let mut path_text = dest_url;
    let mut location_suffix = None;
    // Prefer `#L..` style fragments when both forms are present so URLs like `path#L10` do not
    // get misparsed as a plain path ending in `:10`.
    if let Some((candidate_path, fragment)) = dest_url.rsplit_once('#')
        && let Some(normalized) = normalize_hash_location_suffix_fragment(fragment)
    {
        path_text = candidate_path;
        location_suffix = Some(normalized);
    }
    if location_suffix.is_none()
        && let Some(suffix) = extract_colon_location_suffix(path_text)
    {
        let path_len = path_text.len().saturating_sub(suffix.len());
        path_text = &path_text[..path_len];
        location_suffix = Some(suffix);
    }

    let decoded_path_text =
        urlencoding::decode(path_text).unwrap_or(std::borrow::Cow::Borrowed(path_text));
    Some((expand_local_link_path(&decoded_path_text), location_suffix))
}

/// Normalize a hash fragment like `L12` or `L12C3-L14C9` into the display suffix we render.
///
/// Returns `None` for fragments that are not location references. This deliberately ignores other
/// `#...` fragments so non-location hashes stay part of the path text.
fn normalize_hash_location_suffix_fragment(fragment: &str) -> Option<String> {
    HASH_LOCATION_SUFFIX_RE
        .is_match(fragment)
        .then(|| format!("#{fragment}"))
        .and_then(|suffix| normalize_markdown_hash_location_suffix(&suffix))
}

/// Extract a trailing `:line`, `:line:col`, or range suffix from a plain path-like string.
///
/// The suffix must occur at the end of the input; embedded colons elsewhere in the path are left
/// alone. This is what keeps Windows drive letters like `C:/...` from being misread as locations.
fn extract_colon_location_suffix(path_text: &str) -> Option<String> {
    COLON_LOCATION_SUFFIX_RE
        .find(path_text)
        .filter(|matched| matched.end() == path_text.len())
        .map(|matched| matched.as_str().to_string())
}

/// Expand home-relative paths and normalize separators for display.
///
/// If `~/...` cannot be expanded because the home directory is unavailable, the original text still
/// goes through separator normalization and is returned as-is otherwise.
fn expand_local_link_path(path_text: &str) -> String {
    // Expand `~/...` eagerly so home-relative links can participate in the same normalization and
    // cwd-relative shortening path as absolute links.
    if let Some(rest) = path_text.strip_prefix("~/")
        && let Some(home) = home_dir()
    {
        return normalize_local_link_path_text(&home.join(rest).to_string_lossy());
    }

    normalize_local_link_path_text(path_text)
}

/// Convert a `file://` URL into the normalized local-path text used for transcript rendering.
///
/// This prefers `Url::to_file_path()` for standard file URLs. When that rejects Windows-oriented
/// encodings, we reconstruct a display path from the host/path parts so UNC paths and drive-letter
/// URLs still render sensibly.
fn file_url_to_local_path_text(url: &Url) -> Option<String> {
    if let Ok(path) = url.to_file_path() {
        return Some(normalize_local_link_path_text(&path.to_string_lossy()));
    }

    // Fall back to string reconstruction for cases `to_file_path()` rejects, especially UNC-style
    // hosts and Windows drive paths encoded in URL form.
    let mut path_text = url.path().to_string();
    if let Some(host) = url.host_str()
        && !host.is_empty()
        && host != "localhost"
    {
        path_text = format!("//{host}{path_text}");
    } else if matches!(
        path_text.as_bytes(),
        [b'/', drive, b':', b'/', ..] if drive.is_ascii_alphabetic()
    ) {
        path_text.remove(0);
    }

    Some(normalize_local_link_path_text(&path_text))
}

/// Normalize local-path text into the transcript display form.
///
/// Display normalization is intentionally lexical: it does not touch the filesystem, resolve
/// symlinks, or collapse `.` / `..`. It only converts separators to forward slashes and rewrites
/// UNC-style `\\\\server\\share` inputs into `//server/share` so later prefix checks operate on a
/// stable representation.
fn normalize_local_link_path_text(path_text: &str) -> String {
    // Render all local link paths with forward slashes so display and prefix stripping are stable
    // across mixed Windows and Unix-style inputs.
    if let Some(rest) = path_text.strip_prefix("\\\\") {
        format!("//{}", rest.replace('\\', "/").trim_start_matches('/'))
    } else {
        path_text.replace('\\', "/")
    }
}

fn is_absolute_local_link_path(path_text: &str) -> bool {
    path_text.starts_with('/')
        || path_text.starts_with("//")
        || matches!(
            path_text.as_bytes(),
            [drive, b':', b'/', ..] if drive.is_ascii_alphabetic()
        )
}

/// Remove trailing separators from a local path without destroying root semantics.
///
/// Roots like `/`, `//`, and `C:/` stay intact so callers can still distinguish "the root itself"
/// from "a path under the root".
fn trim_trailing_local_path_separator(path_text: &str) -> &str {
    if path_text == "/" || path_text == "//" {
        return path_text;
    }
    if matches!(path_text.as_bytes(), [drive, b':', b'/'] if drive.is_ascii_alphabetic()) {
        return path_text;
    }
    path_text.trim_end_matches('/')
}

/// Strip `cwd_text` from the start of `path_text` when `path_text` is strictly underneath it.
///
/// Returns the relative remainder without a leading slash. If the path equals the cwd exactly, this
/// returns `None` so callers can keep rendering the full path instead of collapsing it to an empty
/// string.
fn strip_local_path_prefix<'a>(path_text: &'a str, cwd_text: &str) -> Option<&'a str> {
    let path_text = trim_trailing_local_path_separator(path_text);
    let cwd_text = trim_trailing_local_path_separator(cwd_text);
    if path_text == cwd_text {
        return None;
    }

    // Treat filesystem roots specially so `/tmp/x` under `/` becomes `tmp/x` instead of being
    // left unchanged by the generic prefix-stripping branch.
    if cwd_text == "/" || cwd_text == "//" {
        return path_text.strip_prefix('/');
    }

    path_text
        .strip_prefix(cwd_text)
        .and_then(|rest| rest.strip_prefix('/'))
}

/// Choose the visible path text for a local link after normalization.
///
/// Relative paths stay relative. Absolute paths are shortened against `cwd` only when they are
/// lexically underneath it; otherwise the absolute path is preserved. This is display logic only,
/// not filesystem canonicalization.
fn display_local_link_path(path_text: &str, cwd: Option<&Path>) -> String {
    let path_text = normalize_local_link_path_text(path_text);
    if !is_absolute_local_link_path(&path_text) {
        return path_text;
    }

    if let Some(cwd) = cwd {
        // Only shorten absolute paths that are under the provided session cwd; otherwise preserve
        // the original absolute target for clarity.
        let cwd_text = normalize_local_link_path_text(&cwd.to_string_lossy());
        if let Some(stripped) = strip_local_path_prefix(&path_text, &cwd_text) {
            return stripped.to_string();
        }
    }

    path_text
}

#[cfg(test)]
mod markdown_render_tests {
    include!("markdown_render_tests.rs");
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use ratatui::text::Text;

    fn lines_to_strings(text: &Text<'_>) -> Vec<String> {
        text.lines
            .iter()
            .map(|l| {
                l.spans
                    .iter()
                    .map(|s| s.content.clone())
                    .collect::<String>()
            })
            .collect()
    }

    #[test]
    fn wraps_plain_text_when_width_provided() {
        let markdown = "This is a simple sentence that should wrap.";
        let rendered = render_markdown_text_with_width(markdown, Some(16));
        let lines = lines_to_strings(&rendered);
        assert_eq!(
            lines,
            vec![
                "This is a simple".to_string(),
                "sentence that".to_string(),
                "should wrap.".to_string(),
            ]
        );
    }

    #[test]
    fn wraps_list_items_preserving_indent() {
        let markdown = "- first second third fourth";
        let rendered = render_markdown_text_with_width(markdown, Some(14));
        let lines = lines_to_strings(&rendered);
        assert_eq!(
            lines,
            vec!["- first second".to_string(), "  third fourth".to_string(),]
        );
    }

    #[test]
    fn wraps_nested_lists() {
        let markdown =
            "- outer item with several words to wrap\n  - inner item that also needs wrapping";
        let rendered = render_markdown_text_with_width(markdown, Some(20));
        let lines = lines_to_strings(&rendered);
        assert_eq!(
            lines,
            vec![
                "- outer item with".to_string(),
                "  several words to".to_string(),
                "  wrap".to_string(),
                "    - inner item".to_string(),
                "      that also".to_string(),
                "      needs wrapping".to_string(),
            ]
        );
    }

    #[test]
    fn wraps_ordered_lists() {
        let markdown = "1. ordered item contains many words for wrapping";
        let rendered = render_markdown_text_with_width(markdown, Some(18));
        let lines = lines_to_strings(&rendered);
        assert_eq!(
            lines,
            vec![
                "1. ordered item".to_string(),
                "   contains many".to_string(),
                "   words for".to_string(),
                "   wrapping".to_string(),
            ]
        );
    }

    #[test]
    fn wraps_blockquotes() {
        let markdown = "> block quote with content that should wrap nicely";
        let rendered = render_markdown_text_with_width(markdown, Some(22));
        let lines = lines_to_strings(&rendered);
        assert_eq!(
            lines,
            vec![
                "> block quote with".to_string(),
                "> content that should".to_string(),
                "> wrap nicely".to_string(),
            ]
        );
    }

    #[test]
    fn wraps_blockquotes_inside_lists() {
        let markdown = "- list item\n  > block quote inside list that wraps";
        let rendered = render_markdown_text_with_width(markdown, Some(24));
        let lines = lines_to_strings(&rendered);
        assert_eq!(
            lines,
            vec![
                "- list item".to_string(),
                "  > block quote inside".to_string(),
                "  > list that wraps".to_string(),
            ]
        );
    }

    #[test]
    fn wraps_list_items_containing_blockquotes() {
        let markdown = "1. item with quote\n   > quoted text that should wrap";
        let rendered = render_markdown_text_with_width(markdown, Some(24));
        let lines = lines_to_strings(&rendered);
        assert_eq!(
            lines,
            vec![
                "1. item with quote".to_string(),
                "   > quoted text that".to_string(),
                "   > should wrap".to_string(),
            ]
        );
    }

    #[test]
    fn does_not_wrap_code_blocks() {
        let markdown = "````\nfn main() { println!(\"hi from a long line\"); }\n````";
        let rendered = render_markdown_text_with_width(markdown, Some(10));
        let lines = lines_to_strings(&rendered);
        assert_eq!(
            lines,
            vec!["fn main() { println!(\"hi from a long line\"); }".to_string(),]
        );
    }

    #[test]
    fn does_not_split_long_url_like_token_without_scheme() {
        let url_like =
            "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890";
        let rendered = render_markdown_text_with_width(url_like, Some(24));
        let lines = lines_to_strings(&rendered);

        assert_eq!(
            lines.iter().filter(|line| line.contains(url_like)).count(),
            1,
            "expected full URL-like token in one rendered line, got: {lines:?}"
        );
    }

    #[test]
    fn fenced_code_info_string_with_metadata_highlights() {
        // CommonMark info strings like "rust,no_run" or "rust title=demo"
        // contain metadata after the language token.  The language must be
        // extracted (first word / comma-separated token) so highlighting works.
        for info in &["rust,no_run", "rust no_run", "rust title=\"demo\""] {
            let markdown = format!("```{info}\nfn main() {{}}\n```\n");
            let rendered = render_markdown_text(&markdown);
            let has_rgb = rendered.lines.iter().any(|line| {
                line.spans
                    .iter()
                    .any(|s| matches!(s.style.fg, Some(ratatui::style::Color::Rgb(..))))
            });
            assert!(
                has_rgb,
                "info string \"{info}\" should still produce syntax highlighting"
            );
        }
    }

    #[test]
    fn crlf_code_block_no_extra_blank_lines() {
        // pulldown-cmark can split CRLF code blocks into multiple Text events.
        // The buffer must concatenate them verbatim — no inserted separators.
        let markdown = "```rust\r\nfn main() {}\r\n    line2\r\n```\r\n";
        let rendered = render_markdown_text(markdown);
        let lines = lines_to_strings(&rendered);
        // Should be exactly two code lines; no spurious blank line between them.
        assert_eq!(
            lines,
            vec!["fn main() {}".to_string(), "    line2".to_string()],
            "CRLF code block should not produce extra blank lines: {lines:?}"
        );
    }

    #[test]
    fn wrap_cell_preserves_hard_break_lines() {
        let mut cell = TableCell::default();
        cell.push_span("first line".into());
        cell.hard_break();
        cell.push_span("second line".into());

        let writer = W::new("", std::iter::empty(), Some(80), /*cwd*/ None);
        let wrapped = writer.wrap_cell(&cell, /*width*/ 40);
        let rendered = wrapped
            .iter()
            .map(|line| {
                line.spans
                    .iter()
                    .map(|span| span.content.clone())
                    .collect::<String>()
            })
            .collect::<Vec<_>>();

        assert_eq!(
            rendered,
            vec!["first line".to_string(), "second line".to_string()]
        );
    }

    // ---------------------------------------------------------------
    // Type alias for calling private associated functions on Writer.
    // ---------------------------------------------------------------
    type W<'a> = Writer<'a, std::iter::Empty<(Event<'a>, Range<usize>)>>;

    /// Build a single-line `TableCell` from plain text.
    fn make_cell(text: &str) -> TableCell {
        let mut cell = TableCell::default();
        cell.push_span(Span::raw(text.to_string()));
        cell
    }

    fn make_body_row(cells: Vec<TableCell>, has_table_pipe_syntax: bool) -> TableBodyRow {
        TableBodyRow {
            cells,
            has_table_pipe_syntax,
        }
    }

    // ===== Column-metrics unit tests =====

    #[test]
    fn column_classification_narrative_by_word_count() {
        // Col 0: short tokens (1-2 words each) → Structured
        // Col 1: prose (≥4 words per cell) → Narrative
        let header = vec![make_cell("ID"), make_cell("Description")];
        let rows = vec![
            vec![make_cell("1"), make_cell("a long description of the item")],
            vec![make_cell("2"), make_cell("another verbose body cell here")],
        ];
        let metrics = W::collect_table_column_metrics(&header, &rows, /*column_count*/ 2);
        assert_eq!(metrics[0].kind, TableColumnKind::Structured);
        assert_eq!(metrics[1].kind, TableColumnKind::Narrative);
    }

    #[test]
    fn column_classification_structured_by_url_like_token() {
        // Col with short word count but ≥28 avg char width and long tokens → Structured
        // (URL-like/token-heavy columns resist collapse)
        let header = vec![make_cell("URL")];
        let rows = vec![
            vec![make_cell("https://example.com/very/long/path")],
            vec![make_cell("https://another.example.org/deep")],
        ];
        let metrics = W::collect_table_column_metrics(&header, &rows, /*column_count*/ 1);
        assert!(metrics[0].avg_cell_width >= 28.0);
        assert_eq!(metrics[0].kind, TableColumnKind::Structured);
    }

    #[test]
    fn column_classification_structured_all_short() {
        // Both columns short tokens → both Structured
        let header = vec![make_cell("Status"), make_cell("Count")];
        let rows = vec![
            vec![make_cell("ok"), make_cell("42")],
            vec![make_cell("err"), make_cell("7")],
        ];
        let metrics = W::collect_table_column_metrics(&header, &rows, /*column_count*/ 2);
        assert_eq!(metrics[0].kind, TableColumnKind::Structured);
        assert_eq!(metrics[1].kind, TableColumnKind::Structured);
    }

    #[test]
    fn preferred_floor_narrative_caps_header_at_10() {
        // Narrative col: header_token_width 15 → floors at 10
        let m = TableColumnMetrics {
            max_width: 40,
            header_token_width: 15,
            body_token_width: 8,
            avg_words_per_cell: 5.0,
            avg_cell_width: 30.0,
            kind: TableColumnKind::Narrative,
        };
        assert_eq!(W::preferred_column_floor(&m, /*min_column_width*/ 3), 10);

        // Narrative col: header_token_width 6 → floors at 6 (below cap)
        let m2 = TableColumnMetrics {
            max_width: 40,
            header_token_width: 6,
            body_token_width: 8,
            avg_words_per_cell: 5.0,
            avg_cell_width: 30.0,
            kind: TableColumnKind::Narrative,
        };
        assert_eq!(W::preferred_column_floor(&m2, /*min_column_width*/ 3), 6);
    }

    #[test]
    fn preferred_floor_structured_uses_body_token() {
        // Structured: max(header_token_width, body_token_width.min(16))
        let m = TableColumnMetrics {
            max_width: 30,
            header_token_width: 5,
            body_token_width: 12,
            avg_words_per_cell: 1.0,
            avg_cell_width: 10.0,
            kind: TableColumnKind::Structured,
        };
        // max(5, min(12, 16)) = max(5, 12) = 12
        assert_eq!(W::preferred_column_floor(&m, /*min_column_width*/ 3), 12);

        // Body token exceeds 16 cap → capped at 16, then max with header
        let m2 = TableColumnMetrics {
            max_width: 30,
            header_token_width: 5,
            body_token_width: 20,
            avg_words_per_cell: 1.0,
            avg_cell_width: 10.0,
            kind: TableColumnKind::Structured,
        };
        // max(5, min(20, 16)) = max(5, 16) = 16
        assert_eq!(W::preferred_column_floor(&m2, /*min_column_width*/ 3), 16);
    }

    #[test]
    fn next_column_to_shrink_prefers_narrative() {
        // Two columns: Narrative (col 0) and Structured (col 1), both with slack.
        // Narrative should be shrunk first.
        let widths = [20usize, 20];
        let floors = [8usize, 8];
        let metrics = [
            TableColumnMetrics {
                max_width: 30,
                header_token_width: 8,
                body_token_width: 6,
                avg_words_per_cell: 5.0,
                avg_cell_width: 30.0,
                kind: TableColumnKind::Narrative,
            },
            TableColumnMetrics {
                max_width: 30,
                header_token_width: 8,
                body_token_width: 6,
                avg_words_per_cell: 1.0,
                avg_cell_width: 10.0,
                kind: TableColumnKind::Structured,
            },
        ];
        let idx = W::next_column_to_shrink(&widths, &floors, &metrics);
        assert_eq!(idx, Some(0), "Narrative column should be shrunk first");
    }

    // ===== Spillover-detection unit tests =====

    #[test]
    fn spillover_detects_single_cell_row() {
        let row = make_body_row(
            vec![make_cell("some trailing text")],
            /*has_table_pipe_syntax*/ false,
        );
        assert!(W::is_spillover_row(&row, /*next_row*/ None));
    }

    #[test]
    fn spillover_keeps_single_cell_row_with_table_pipe_syntax() {
        let row = make_body_row(
            vec![make_cell("some sparse value")],
            /*has_table_pipe_syntax*/ true,
        );
        assert!(!W::is_spillover_row(&row, /*next_row*/ None));
    }

    #[test]
    fn spillover_detects_html_content() {
        // 3-cell row where only cell 0 has HTML content
        let row = make_body_row(
            vec![
                make_cell("<div>content</div>"),
                make_cell(""),
                make_cell(""),
            ],
            /*has_table_pipe_syntax*/ false,
        );
        assert!(W::is_spillover_row(&row, /*next_row*/ None));
    }

    #[test]
    fn spillover_detects_label_followed_by_html() {
        // cell 0 = "HTML block:" and next_row cell 0 = "<div>x</div>"
        let row = make_body_row(
            vec![make_cell("HTML block:"), make_cell(""), make_cell("")],
            /*has_table_pipe_syntax*/ false,
        );
        let next = make_body_row(
            vec![make_cell("<div>x</div>"), make_cell(""), make_cell("")],
            /*has_table_pipe_syntax*/ false,
        );
        assert!(W::is_spillover_row(&row, Some(&next)));
    }

    #[test]
    fn spillover_detects_trailing_html_label() {
        // "HTML block:" with no next_row → trailing HTML label spillover
        let row = make_body_row(
            vec![make_cell("HTML block:"), make_cell(""), make_cell("")],
            /*has_table_pipe_syntax*/ false,
        );
        assert!(W::is_spillover_row(&row, /*next_row*/ None));
    }

    #[test]
    fn spillover_keeps_normal_multi_cell_row() {
        // 3 cells all non-empty → not spillover
        let row = make_body_row(
            vec![make_cell("one"), make_cell("two"), make_cell("three")],
            /*has_table_pipe_syntax*/ true,
        );
        assert!(!W::is_spillover_row(&row, /*next_row*/ None));
    }

    #[test]
    fn spillover_keeps_label_when_next_is_not_html() {
        // cell 0 = "Status:" and next_row cell 0 = "ok" → not spillover (not HTML)
        let row = make_body_row(
            vec![make_cell("Status:"), make_cell(""), make_cell("")],
            /*has_table_pipe_syntax*/ true,
        );
        let next = make_body_row(
            vec![make_cell("ok"), make_cell(""), make_cell("")],
            /*has_table_pipe_syntax*/ true,
        );
        assert!(!W::is_spillover_row(&row, Some(&next)));
    }
}
