//! A small, streaming-friendly Markdown renderer for assistant text.
//!
//! Deliberately **line-oriented**: [`render_block`] emits exactly one styled
//! [`Line`] per source line, and a line's styling depends only on the lines
//! *before* it (a single `in_fence` bit). Both properties matter for the
//! line-by-line scrollback commit in `app::next_commit_chunk`: it commits
//! finished assistant lines into immutable native scrollback as they stream, so
//! the rendering of a line must never change once a later line exists. A
//! forward-only, one-line-in-one-line-out renderer guarantees that — appending
//! more text can only add lines or restyle the still-growing last line, never
//! rewrite an already-committed one.
//!
//! It is **not** a CommonMark implementation — it's a pragmatic subset chosen
//! for terminal readability (headings, lists, blockquotes, rules, fenced code,
//! and inline emphasis / code / strikethrough / links), degrading unrecognised
//! markup to literal text rather than guessing.
//!
//! Fenced code blocks with a language tag are **syntax-highlighted** via
//! `syntect`. That highlighter is line-stateful (a multi-line string/comment
//! opened on one line colours the next), but the state only ever flows *forward*
//! and [`render_block`] rebuilds it from the fence open on every call, so a
//! code line's colours depend solely on the lines before it — the same stability
//! property the rest of the renderer guarantees. The `SyntaxSet`/`Theme` load
//! lazily once (see [`warm`]) and only when a language-tagged block first
//! appears, so prose-only sessions never pay for them.

use std::sync::OnceLock;

use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle, Theme, ThemeSet};
use syntect::parsing::{SyntaxReference, SyntaxSet};

/// Inline-code and fenced-code foreground (warm, reads as code; not otherwise
/// used inside an assistant block).
const CODE: Color = Color::LightYellow;
/// Structural accent for headings, list markers and the blockquote bar.
const ACCENT: Color = Color::Cyan;
/// Dim tone for rules, fence delimiters and blockquote text.
const DIM: Color = Color::DarkGray;
/// Link foreground.
const LINK: Color = Color::Blue;

/// A horizontal rule, rendered as a short dim bar (fixed width so it never needs
/// the terminal width and stays a single logical line).
const HR_BAR: &str = "────────────────────────";

/// Render the assistant text to styled lines — one [`Line`] per source line,
/// **without** the transcript gutter (the caller prepends `● ` / `  `).
pub fn render_block(text: &str) -> Vec<Line<'static>> {
    let mut out = Vec::new();
    let mut in_fence = false;
    let mut fence_delim = '`';
    // Active syntax highlighter while inside a language-tagged fence; `None` for a
    // bare or unknown-language fence (then code lines get a flat code colour).
    let mut hl: Option<HighlightLines<'static>> = None;
    for raw in text.lines() {
        if in_fence {
            // Inside a fenced block: a line that's only the fence delimiter
            // (≥3) closes it; everything else is verbatim code (no inline
            // parsing, so `*`/`_`/etc. in code stay literal).
            if is_closing_fence(raw.trim(), fence_delim) {
                out.push(span_line(raw, Style::default().fg(DIM)));
                in_fence = false;
                hl = None;
            } else if let Some(h) = hl.as_mut() {
                out.push(highlight_code_line(h, raw));
            } else {
                out.push(span_line(raw, Style::default().fg(CODE)));
            }
            continue;
        }
        let trimmed = raw.trim_start();
        if let Some(delim) = fence_opener(trimmed) {
            in_fence = true;
            fence_delim = delim;
            hl = highlighter_for(fence_lang(trimmed, delim));
            out.push(span_line(raw, Style::default().fg(DIM)));
        } else if let Some((level, content)) = heading(trimmed) {
            out.push(heading_line(level, content));
        } else if is_hr(trimmed) {
            out.push(span_line(
                HR_BAR,
                Style::default().fg(DIM).add_modifier(Modifier::DIM),
            ));
        } else if let Some(content) = blockquote(trimmed) {
            out.push(blockquote_line(content));
        } else if let Some((indent, content)) = bullet(raw) {
            out.push(list_line(indent, "• ", content));
        } else if let Some((indent, marker, content)) = ordered(raw) {
            out.push(list_line(indent, &marker, content));
        } else {
            // Plain paragraph line — inline markup only, indentation preserved.
            out.push(Line::from(inline_spans(raw, Style::default())));
        }
    }
    out
}

/// A single-span line of `content` styled with `style`.
fn span_line(content: &str, style: Style) -> Line<'static> {
    Line::from(vec![Span::styled(content.to_string(), style)])
}

/// `### Heading` → its level (1–6) and the trailing content, or `None`. Requires
/// a space (or end of line) after the `#`s, per CommonMark, so `#tag` is text.
fn heading(trimmed: &str) -> Option<(usize, &str)> {
    let hashes = trimmed.chars().take_while(|&c| c == '#').count();
    if (1..=6).contains(&hashes) {
        let rest = &trimmed[hashes..];
        if rest.is_empty() || rest.starts_with(' ') {
            return Some((hashes, rest.trim_start()));
        }
    }
    None
}

/// Render a heading: its content with inline markup over a bold accent base.
fn heading_line(_level: usize, content: &str) -> Line<'static> {
    let base = Style::default().fg(ACCENT).add_modifier(Modifier::BOLD);
    Line::from(inline_spans(content, base))
}

/// True for a thematic break: a line of ≥3 of the same `-`/`*`/`_`, optionally
/// space-separated (`---`, `***`, `_ _ _`). Checked before list/emphasis so a
/// `***` rule isn't mistaken for emphasis.
fn is_hr(trimmed: &str) -> bool {
    let t = trimmed.trim_end();
    if t.len() < 3 {
        return false;
    }
    ['-', '*', '_'].into_iter().any(|m| {
        t.chars().all(|c| c == m || c == ' ') && t.chars().filter(|&c| c == m).count() >= 3
    })
}

/// `> quote` → the text after the marker (one optional leading space stripped).
fn blockquote(trimmed: &str) -> Option<&str> {
    let rest = trimmed.strip_prefix('>')?;
    Some(rest.strip_prefix(' ').unwrap_or(rest))
}

/// Render a blockquote line: a dim accent bar, then the quoted text (inline
/// markup) in dim italic.
fn blockquote_line(content: &str) -> Line<'static> {
    let mut spans = vec![Span::styled("▏ ", Style::default().fg(DIM))];
    spans.extend(inline_spans(
        content,
        Style::default().fg(DIM).add_modifier(Modifier::ITALIC),
    ));
    Line::from(spans)
}

/// `- item` / `* item` / `+ item` → (leading indent, content after the marker),
/// or `None`. The indent is preserved so nested lists stay indented.
fn bullet(raw: &str) -> Option<(&str, &str)> {
    let indent_len = raw.len() - raw.trim_start().len();
    let (indent, trimmed) = raw.split_at(indent_len);
    for m in ['-', '*', '+'] {
        if let Some(rest) = trimmed.strip_prefix(m) {
            if let Some(content) = rest.strip_prefix(' ') {
                return Some((indent, content));
            }
        }
    }
    None
}

/// `1. item` / `2) item` → (indent, the `N.`/`N)` marker, content), or `None`.
fn ordered(raw: &str) -> Option<(&str, String, &str)> {
    let indent_len = raw.len() - raw.trim_start().len();
    let (indent, trimmed) = raw.split_at(indent_len);
    let digits: String = trimmed.chars().take_while(|c| c.is_ascii_digit()).collect();
    if digits.is_empty() || digits.len() > 9 {
        return None;
    }
    let after = &trimmed[digits.len()..];
    let sep = after.chars().next()?;
    if (sep == '.' || sep == ')') && after[1..].starts_with(' ') {
        return Some((indent, format!("{digits}{sep} "), &after[2..]));
    }
    None
}

/// Render a list item: preserved indent, an accent marker, then the content with
/// inline markup.
fn list_line(indent: &str, marker: &str, content: &str) -> Line<'static> {
    let mut spans = vec![
        Span::raw(indent.to_string()),
        Span::styled(marker.to_string(), Style::default().fg(ACCENT)),
    ];
    spans.extend(inline_spans(content, Style::default()));
    Line::from(spans)
}

/// If `trimmed` opens a fenced code block (≥3 backticks or tildes), return the
/// fence char; else `None`. The info string (language) after it is ignored for
/// detection but kept on the rendered delimiter line.
fn fence_opener(trimmed: &str) -> Option<char> {
    ['`', '~']
        .into_iter()
        .find(|&d| trimmed.starts_with(d) && trimmed.chars().take_while(|&c| c == d).count() >= 3)
}

/// True if `trimmed` is a closing fence: only the delimiter char, ≥3 of it.
fn is_closing_fence(trimmed: &str, delim: char) -> bool {
    trimmed.len() >= 3 && trimmed.chars().all(|c| c == delim)
}

/// The info-string language token of a fence opener (the first word after the
/// delimiters), e.g. ```` ```rust ```` → `"rust"`, ```` ```rust,no_run ```` →
/// `"rust"`. Empty for a bare fence.
fn fence_lang(trimmed: &str, delim: char) -> &str {
    trimmed
        .trim_start_matches(delim)
        .split(|c: char| c.is_whitespace() || c == ',')
        .next()
        .unwrap_or("")
        .trim()
}

/// Lazily-loaded syntax + theme assets for code-block highlighting. Loaded once
/// (a few MB), only when the first language-tagged fence is rendered (or
/// proactively via [`warm`]).
struct HiAssets {
    syntaxes: SyntaxSet,
    theme: Theme,
}

static ASSETS: OnceLock<HiAssets> = OnceLock::new();

fn assets() -> &'static HiAssets {
    ASSETS.get_or_init(|| {
        let syntaxes = SyntaxSet::load_defaults_newlines();
        let mut themes = ThemeSet::load_defaults();
        // A dark theme: code blocks render foreground-only (we never paint the
        // theme's background), so this reads well on the usual dark terminal.
        let theme = themes
            .themes
            .remove("base16-ocean.dark")
            .or_else(|| themes.themes.values().next().cloned())
            .unwrap_or_default();
        HiAssets { syntaxes, theme }
    })
}

/// Proactively load the highlighting assets (call once at startup, off the hot
/// path) so the first code block doesn't load them mid-stream. No-op if already
/// loaded. Cheap to call from a background thread — the `OnceLock` serializes it.
pub fn warm() {
    let _ = assets();
}

/// A highlighter for `lang`, or `None` for an empty/unknown language (then code
/// renders with a flat colour). Building it loads the assets on first use.
fn highlighter_for(lang: &str) -> Option<HighlightLines<'static>> {
    if lang.is_empty() {
        return None;
    }
    let a = assets();
    let syntax = find_syntax(&a.syntaxes, lang)?;
    Some(HighlightLines::new(syntax, &a.theme))
}

/// Resolve a fence language token to a syntect syntax, trying its built-in
/// token/extension/name lookups plus a few aliases assistants commonly use.
fn find_syntax<'a>(syntaxes: &'a SyntaxSet, lang: &str) -> Option<&'a SyntaxReference> {
    let lower = lang.trim().to_ascii_lowercase();
    if lower.is_empty() {
        return None;
    }
    let token = match lower.as_str() {
        "shell" | "zsh" | "console" => "bash",
        "node" => "javascript",
        "yml" => "yaml",
        other => other,
    };
    syntaxes
        .find_syntax_by_token(token)
        .or_else(|| syntaxes.find_syntax_by_extension(token))
        .or_else(|| syntaxes.find_syntax_by_name(lang.trim()))
}

/// Highlight one source line of code with the block's running highlighter,
/// producing styled spans (foreground only, so the terminal background shows
/// through). Falls back to a flat code colour if syntect errors. The trailing
/// newline we feed syntect (its line syntaxes want it) is stripped back off.
fn highlight_code_line(h: &mut HighlightLines<'static>, raw: &str) -> Line<'static> {
    let with_newline = format!("{raw}\n");
    match h.highlight_line(&with_newline, &assets().syntaxes) {
        Ok(ranges) => {
            let spans: Vec<Span<'static>> = ranges
                .into_iter()
                .filter_map(|(style, piece)| {
                    let piece = piece.strip_suffix('\n').unwrap_or(piece);
                    (!piece.is_empty())
                        .then(|| Span::styled(piece.to_string(), convert_style(style)))
                })
                .collect();
            Line::from(spans)
        }
        Err(_) => span_line(raw, Style::default().fg(CODE)),
    }
}

/// Convert a syntect style to a ratatui one: 24-bit foreground (background and
/// alpha dropped so the terminal's own background shows through) plus any
/// bold/italic/underline from the theme.
fn convert_style(s: syntect::highlighting::Style) -> Style {
    let fg = s.foreground;
    let mut style = Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b));
    if s.font_style.contains(FontStyle::BOLD) {
        style = style.add_modifier(Modifier::BOLD);
    }
    if s.font_style.contains(FontStyle::ITALIC) {
        style = style.add_modifier(Modifier::ITALIC);
    }
    if s.font_style.contains(FontStyle::UNDERLINE) {
        style = style.add_modifier(Modifier::UNDERLINED);
    }
    style
}

/// Parse inline markup in `content` into styled spans layered over `base`:
/// `` `code` ``, `**bold**`/`__bold__`, `*italic*`/`_italic_`, `~~strike~~`,
/// `[label](url)`, and `\`-escapes. Unmatched/edge-case markup degrades to
/// literal text. Recurses for nested emphasis (each call strictly shrinks the
/// slice, so it terminates).
fn inline_spans(content: &str, base: Style) -> Vec<Span<'static>> {
    let chars: Vec<char> = content.chars().collect();
    let n = chars.len();
    let mut spans: Vec<Span<'static>> = Vec::new();
    let mut buf = String::new();
    let mut i = 0;
    while i < n {
        let c = chars[i];

        // Backslash escape: `\*` etc. emits the punctuation char literally.
        if c == '\\' && i + 1 < n && chars[i + 1].is_ascii_punctuation() {
            buf.push(chars[i + 1]);
            i += 2;
            continue;
        }

        // Inline code: `…` up to the next backtick on this line.
        if c == '`' {
            if let Some(close) = find_char(&chars, i + 1, '`') {
                flush(&mut spans, &mut buf, base);
                let code: String = chars[i + 1..close].iter().collect();
                spans.push(Span::styled(code, base.fg(CODE)));
                i = close + 1;
                continue;
            }
        }

        // Emphasis: ** / __ (bold) or * / _ (italic).
        if c == '*' || c == '_' {
            let double = i + 1 < n && chars[i + 1] == c;
            let len = if double { 2 } else { 1 };
            let after = chars.get(i + len).copied();
            // Left-flanking: opener followed by a non-space, non-delimiter char;
            // intraword `_` (e.g. `a_b`) doesn't open emphasis.
            let left_ok = c != '_' || i == 0 || !chars[i - 1].is_alphanumeric();
            let opens = matches!(after, Some(a) if !a.is_whitespace() && a != c) && left_ok;
            if opens {
                if let Some(close) = find_closer(&chars, i + len, c, len) {
                    flush(&mut spans, &mut buf, base);
                    let inner: String = chars[i + len..close].iter().collect();
                    let style = base.add_modifier(if double {
                        Modifier::BOLD
                    } else {
                        Modifier::ITALIC
                    });
                    spans.extend(inline_spans(&inner, style));
                    i = close + len;
                    continue;
                }
            }
        }

        // Strikethrough: ~~…~~.
        if c == '~' && i + 1 < n && chars[i + 1] == '~' {
            if let Some(close) = find_closer(&chars, i + 2, '~', 2) {
                flush(&mut spans, &mut buf, base);
                let inner: String = chars[i + 2..close].iter().collect();
                spans.extend(inline_spans(
                    &inner,
                    base.fg(DIM).add_modifier(Modifier::CROSSED_OUT),
                ));
                i = close + 2;
                continue;
            }
        }

        // Link: [label](url) — render the label as a link, drop the URL (the
        // terminal can't make it clickable; the full text is in the log).
        if c == '[' {
            if let Some((label_end, paren_end)) = parse_link(&chars, i) {
                flush(&mut spans, &mut buf, base);
                let label: String = chars[i + 1..label_end].iter().collect();
                let link = base.fg(LINK).add_modifier(Modifier::UNDERLINED);
                if label.is_empty() {
                    // No label: show the URL itself so the link isn't invisible
                    // (but a degenerate `[]()` adds no span at all).
                    let url: String = chars[label_end + 2..paren_end].iter().collect();
                    if !url.is_empty() {
                        spans.push(Span::styled(url, link));
                    }
                } else {
                    spans.extend(inline_spans(&label, link));
                }
                i = paren_end + 1;
                continue;
            }
        }

        buf.push(c);
        i += 1;
    }
    flush(&mut spans, &mut buf, base);
    spans
}

/// Push the pending plain run (if any) as a span and clear the buffer.
fn flush(spans: &mut Vec<Span<'static>>, buf: &mut String, style: Style) {
    if !buf.is_empty() {
        spans.push(Span::styled(std::mem::take(buf), style));
    }
}

/// Index of the next `target` char at or after `from`, or `None`.
fn find_char(chars: &[char], from: usize, target: char) -> Option<usize> {
    (from..chars.len()).find(|&j| chars[j] == target)
}

/// Find the closing run of `len` × `delim` for an emphasis/strike span opened at
/// `from`, applying the right-flanking rule (the closer is not preceded by a
/// space) and the intraword-`_` rule. Returns the closer's start index, or
/// `None` if there's no valid closer (so the opener stays literal).
fn find_closer(chars: &[char], from: usize, delim: char, len: usize) -> Option<usize> {
    let n = chars.len();
    let mut j = from;
    while j + len <= n {
        let is_run = chars[j] == delim && (len == 1 || chars[j + 1] == delim);
        if is_run {
            let right_flank = j > 0 && !chars[j - 1].is_whitespace();
            let word_ok = delim != '_' || chars.get(j + len).is_none_or(|a| !a.is_alphanumeric());
            if right_flank && word_ok && j > from {
                return Some(j);
            }
        }
        j += 1;
    }
    None
}

/// Parse a `[label](url)` link starting at `open` (`chars[open] == '['`),
/// returning `(index of ']', index of ')')` on success. Rejects nested brackets
/// in the label and requires `](` to be adjacent.
fn parse_link(chars: &[char], open: usize) -> Option<(usize, usize)> {
    let label_end = (open + 1..chars.len()).find(|&j| chars[j] == ']' || chars[j] == '[')?;
    if chars[label_end] != ']' || chars.get(label_end + 1) != Some(&'(') {
        return None;
    }
    let paren_end = (label_end + 2..chars.len()).find(|&j| chars[j] == ')')?;
    Some((label_end, paren_end))
}

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

    /// Flatten a rendered block to one plain string per line (styles dropped).
    fn texts(text: &str) -> Vec<String> {
        render_block(text)
            .iter()
            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
            .collect()
    }

    /// The spans of the single-line render of `content`, as (text, style) pairs.
    fn inline(content: &str) -> Vec<(String, Style)> {
        inline_spans(content, Style::default())
            .into_iter()
            .map(|s| (s.content.into_owned(), s.style))
            .collect()
    }

    fn has_mod(style: Style, m: Modifier) -> bool {
        style.add_modifier.contains(m)
    }

    #[test]
    fn one_output_line_per_source_line() {
        // The streaming commit logic relies on a strict 1:1 mapping.
        let text = "# Heading\npara\n- item\n```\ncode\n```\n> quote";
        assert_eq!(render_block(text).len(), text.lines().count());
    }

    #[test]
    fn bold_italic_and_code_inline() {
        let spans = inline("a **b** c *d* `e`");
        // "a " plain, "b" bold, " c " plain, "d" italic, " " plain, "e" code.
        let bold = spans.iter().find(|(t, _)| t == "b").expect("bold span");
        assert!(has_mod(bold.1, Modifier::BOLD), "**b** is bold");
        let ital = spans.iter().find(|(t, _)| t == "d").expect("italic span");
        assert!(has_mod(ital.1, Modifier::ITALIC), "*d* is italic");
        let code = spans.iter().find(|(t, _)| t == "e").expect("code span");
        assert_eq!(code.1.fg, Some(CODE), "`e` is code-styled");
    }

    #[test]
    fn underscores_bold_and_italic_but_not_intraword() {
        let spans = inline("__b__ _i_ a_b_c");
        assert!(spans
            .iter()
            .any(|(t, s)| t == "b" && has_mod(*s, Modifier::BOLD)));
        assert!(spans
            .iter()
            .any(|(t, s)| t == "i" && has_mod(*s, Modifier::ITALIC)));
        // Intraword underscores stay literal: the run "a_b_c" survives verbatim.
        let joined: String = spans.iter().map(|(t, _)| t.as_str()).collect();
        assert!(
            joined.contains("a_b_c"),
            "intraword `_` not emphasised: {joined}"
        );
    }

    #[test]
    fn spaced_asterisks_are_literal() {
        // `a * b * c` must not become italic (flanking rules).
        let spans = inline("a * b * c");
        assert!(spans.iter().all(|(_, s)| !has_mod(*s, Modifier::ITALIC)));
        let joined: String = spans.iter().map(|(t, _)| t.as_str()).collect();
        assert_eq!(joined, "a * b * c");
    }

    #[test]
    fn unclosed_markers_stay_literal() {
        let spans = inline("a **b and `c");
        let joined: String = spans.iter().map(|(t, _)| t.as_str()).collect();
        assert_eq!(joined, "a **b and `c", "no closer -> literal");
        assert!(spans.iter().all(|(_, s)| !has_mod(*s, Modifier::BOLD)));
    }

    #[test]
    fn nested_emphasis_inside_bold() {
        let spans = inline("**bold _and italic_**");
        let inner = spans
            .iter()
            .find(|(t, _)| t == "and italic")
            .expect("nested italic run");
        assert!(has_mod(inner.1, Modifier::BOLD), "outer bold preserved");
        assert!(has_mod(inner.1, Modifier::ITALIC), "inner italic applied");
    }

    #[test]
    fn inline_code_is_not_reparsed() {
        // `*` inside inline code stays literal (not italic).
        let spans = inline("`a * b`");
        assert_eq!(spans.len(), 1);
        assert_eq!(spans[0].0, "a * b");
        assert_eq!(spans[0].1.fg, Some(CODE));
    }

    #[test]
    fn strikethrough_and_escape() {
        let st = inline("~~gone~~");
        assert!(st
            .iter()
            .any(|(t, s)| t == "gone" && has_mod(*s, Modifier::CROSSED_OUT)));
        // Escaped asterisks are literal and unstyled.
        let esc = inline(r"\*not italic\*");
        let joined: String = esc.iter().map(|(t, _)| t.as_str()).collect();
        assert_eq!(joined, "*not italic*");
        assert!(esc.iter().all(|(_, s)| !has_mod(*s, Modifier::ITALIC)));
    }

    #[test]
    fn link_renders_label_drops_url() {
        let spans = inline("see [docs](https://example.com) now");
        let joined: String = spans.iter().map(|(t, _)| t.as_str()).collect();
        assert_eq!(joined, "see docs now", "label kept, URL dropped");
        let label = spans.iter().find(|(t, _)| t == "docs").expect("label span");
        assert_eq!(label.1.fg, Some(LINK));
        assert!(has_mod(label.1, Modifier::UNDERLINED));
    }

    #[test]
    fn degenerate_and_labelless_links() {
        // `[]()` (empty label AND empty url) emits no span at all — no litter.
        assert!(inline("[]()").is_empty());
        // `[](url)` (no label, but a destination) shows the url itself.
        let spans = inline("[](http://x)");
        assert_eq!(spans.len(), 1);
        assert_eq!(spans[0].0, "http://x");
        assert_eq!(spans[0].1.fg, Some(LINK));
    }

    #[test]
    fn headings_strip_hashes_and_style() {
        let lines = render_block("## Title here");
        assert_eq!(lines.len(), 1);
        let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
        assert_eq!(text, "Title here", "hashes + space stripped");
        assert!(lines[0]
            .spans
            .iter()
            .any(|s| has_mod(s.style, Modifier::BOLD)));
        // `#nospace` is not a heading.
        assert_eq!(texts("#nospace"), vec!["#nospace".to_string()]);
    }

    #[test]
    fn bullets_and_ordered_lists() {
        let bl = texts("- first\n  - nested");
        assert_eq!(bl[0], "• first");
        assert_eq!(bl[1], "  • nested", "indent preserved");
        let ol = texts("1. one\n2) two");
        assert_eq!(ol[0], "1. one");
        assert_eq!(ol[1], "2) two");
    }

    #[test]
    fn horizontal_rule_and_not_a_bullet() {
        let hr = texts("---");
        assert_eq!(hr.len(), 1);
        assert_eq!(hr[0], HR_BAR, "--- is a rule");
        // But `- x` is a bullet, not a rule.
        assert_eq!(texts("- x"), vec!["• x".to_string()]);
        // `***` is a rule, not emphasis.
        assert_eq!(texts("***"), vec![HR_BAR.to_string()]);
    }

    #[test]
    fn fenced_code_is_verbatim() {
        // Highlighting splits a code line into spans but must preserve its text
        // verbatim — the `*`s are code, NOT emphasis.
        let lines = texts("```rust\nlet x = *p * 2;\n```");
        assert_eq!(lines.len(), 3);
        assert_eq!(lines[1], "let x = *p * 2;");
        // A bare (no-language) fence isn't highlighted: a flat code colour.
        let code_line = &render_block("```\nx*y\n```")[1];
        assert!(code_line.spans.iter().all(|s| s.style.fg == Some(CODE)));
    }

    #[test]
    fn code_block_is_syntax_highlighted() {
        let lines = render_block("```rust\nfn main() {}\n```");
        assert_eq!(lines.len(), 3, "open fence + code + close fence");
        let code = &lines[1];
        let text: String = code.spans.iter().map(|s| s.content.as_ref()).collect();
        assert_eq!(text, "fn main() {}", "text preserved verbatim");
        // The highlighter splits the line into multiple spans using more than one
        // colour (keyword vs identifier vs punctuation) — proof it ran.
        assert!(code.spans.len() > 1, "highlighted into spans: {text:?}");
        let colours: std::collections::HashSet<_> = code.spans.iter().map(|s| s.style.fg).collect();
        assert!(colours.len() > 1, "more than one colour used: {colours:?}");
    }

    #[test]
    fn unknown_or_bare_language_is_flat_code() {
        // A language syntect doesn't know falls back to the flat code colour.
        let unknown = render_block("```nonsenselang\nfn main(){}\n```");
        assert!(
            unknown[1].spans.iter().all(|s| s.style.fg == Some(CODE)),
            "unknown language is not highlighted"
        );
    }

    #[test]
    fn highlighted_code_lines_are_style_stable_while_streaming() {
        // Highlighting is line-stateful, so re-prove the streaming invariant at
        // the *style* level for a code block: a committed (non-last) code line's
        // full styled form must already equal its form in the final render.
        let repr = |text: &str| -> Vec<Vec<(String, Option<Color>, Modifier)>> {
            render_block(text)
                .iter()
                .map(|l| {
                    l.spans
                        .iter()
                        .map(|s| (s.content.to_string(), s.style.fg, s.style.add_modifier))
                        .collect()
                })
                .collect()
        };
        let final_r = repr("```rust\nlet x = 1;\nfn main() {}\n```");
        // A prefix that stops part-way through the still-open block.
        let pre_r = repr("```rust\nlet x = 1;\nfn ma");
        for i in 0..pre_r.len().saturating_sub(1) {
            assert_eq!(pre_r[i], final_r[i], "code line {i} restyled mid-stream");
        }
        assert!(final_r[1].len() > 1, "first code line was highlighted");
    }

    // === TEMP rigorous streaming-invariant probe (syntect statefulness) ===
    // Full style+text fingerprint of a render: per line, the spans as
    // (text, fg, modifier). Any change to a committed line shows up here.
    fn fingerprint(text: &str) -> Vec<Vec<(String, Option<Color>, Modifier)>> {
        render_block(text)
            .iter()
            .map(|l| {
                l.spans
                    .iter()
                    .map(|s| (s.content.to_string(), s.style.fg, s.style.add_modifier))
                    .collect()
            })
            .collect()
    }

    // For a given full document, check the streaming invariant by line APPEND
    // (the real commit granularity): for every prefix that ends exactly at a
    // source-line boundary (i.e. text + "\n" growing), every line except the
    // last must already equal its FINAL styled form.
    fn assert_line_append_stable(full: &str, label: &str) {
        let final_fp = fingerprint(full);
        let src_lines: Vec<&str> = full.lines().collect();
        // Build growing prefixes line by line, mimicking streaming where the
        // server has emitted N complete lines (+ optionally a partial next).
        for n in 1..=src_lines.len() {
            // Prefix of the first n complete lines (no trailing newline: the
            // nth line is the still-growing "last" line here).
            let prefix_complete = src_lines[..n].join("\n");
            let fp = fingerprint(&prefix_complete);
            // Lines [0, n-1) are committed (not last) -> must match final.
            for i in 0..fp.len().saturating_sub(1) {
                assert_eq!(
                    fp[i], final_fp[i],
                    "[{label}] committed line {i} changed at {n} complete lines\n  got:   {:?}\n  final: {:?}",
                    fp[i], final_fp[i]
                );
            }
            // Also test the partial-last-line case char by char: appending
            // chars to the last line must not restyle any earlier line.
            if n < src_lines.len() {
                let next = src_lines[n];
                let base = format!("{prefix_complete}\n");
                let mut acc = String::new();
                for ch in next.chars() {
                    acc.push(ch);
                    let partial = format!("{base}{acc}");
                    let pfp = fingerprint(&partial);
                    for i in 0..pfp.len().saturating_sub(1) {
                        assert_eq!(
                            pfp[i], final_fp[i],
                            "[{label}] committed line {i} changed while partial last line = {acc:?}\n  got:   {:?}\n  final: {:?}",
                            pfp[i], final_fp[i]
                        );
                    }
                }
            }
        }
    }

    // Also assert 1:1 source-line -> output-line for the code region.
    fn assert_line_count(full: &str, label: &str) {
        assert_eq!(
            render_block(full).len(),
            full.lines().count(),
            "[{label}] output line count != source line count"
        );
    }

    // Char-by-char growth over the WHOLE document (every byte prefix), the most
    // faithful model of streaming. At each char-boundary prefix, every line but
    // the last must equal its final styled form. This catches a restyle of an
    // opener line that only manifests once the construct closes.
    fn assert_byte_prefix_stable(full: &str, label: &str) {
        let final_fp = fingerprint(full);
        for end in 1..=full.len() {
            if !full.is_char_boundary(end) {
                continue;
            }
            let fp = fingerprint(&full[..end]);
            for i in 0..fp.len().saturating_sub(1) {
                assert_eq!(
                    fp[i], final_fp[i],
                    "[{label}] committed line {i} differs from final at byte prefix {end} ({:?})\n  got:   {:?}\n  final: {:?}",
                    &full[..end], fp[i], final_fp[i]
                );
            }
        }
    }

    // Does the OPENER line of a multi-line construct style identically whether or
    // not the construct is later closed? Render just the opener+one line both
    // unclosed and closed; the opener (line index 1, after the fence) must match.
    fn assert_opener_invariant(opener_block: &str, closer_suffix: &str, label: &str) {
        let unclosed = fingerprint(opener_block);
        let closed = fingerprint(&format!("{opener_block}{closer_suffix}"));
        // The fence line (0) and opener line (1) must be byte/style identical.
        for i in 0..unclosed.len().min(2) {
            assert_eq!(
                unclosed[i], closed[i],
                "[{label}] opener-region line {i} restyled by later close\n  unclosed: {:?}\n  closed:   {:?}",
                unclosed[i], closed[i]
            );
        }
    }

    #[test]
    fn temp_probe_multiline_constructs() {
        // 1. Rust raw/normal string spanning lines.
        let cases: &[(&str, &str)] = &[
            (
                "```rust\nlet s = \"start\nmiddle\nend\";\nlet y = 2;\n```",
                "rust-multiline-string",
            ),
            (
                "```rust\nlet s = r#\"raw\nstill raw\n\"#;\nfn f() {}\n```",
                "rust-raw-string",
            ),
            (
                "```rust\nlet x = 1; /* open\nstill comment\nclosed */ let y = 2;\nfn g() {}\n```",
                "rust-block-comment",
            ),
            (
                "```python\ns = '''triple\nquoted\nstring'''\nx = 1\n```",
                "python-triple-string",
            ),
            (
                "```python\nx = (1 +\n2 +\n3)\ny = 4\n```",
                "python-paren-continuation",
            ),
            (
                "```c\nint a = 1; /* c comment\nspanning\nlines */ int b = 2;\nint c = 3;\n```",
                "c-block-comment",
            ),
            (
                "```html\n<div class=\"a\n b\n c\">x</div>\n<p>y</p>\n```",
                "html-multiline-attr",
            ),
            (
                "```bash\necho 'one\ntwo\nthree'\nls\n```",
                "bash-single-quote",
            ),
            (
                "```yaml\nkey: |\n  line one\n  line two\nother: 3\n```",
                "yaml-block-scalar",
            ),
            (
                // empty code lines inside a highlighted block
                "```rust\nfn a() {}\n\n\nfn b() {}\n```",
                "rust-empty-lines",
            ),
            (
                // string that NEVER closes before the fence ends
                "```rust\nlet s = \"never closes\nstill open\nand open\n```",
                "rust-unclosed-string",
            ),
        ];
        for (full, label) in cases {
            assert_line_count(full, label);
            assert_line_append_stable(full, label);
            assert_byte_prefix_stable(full, label);
        }

        // Opener-line invariant: the line where a multi-line construct OPENS
        // must not be restyled by the later close.
        assert_opener_invariant(
            "```rust\nlet s = \"open string\n",
            "more\";\nfn f(){}\n```",
            "rust-string-opener",
        );
        assert_opener_invariant(
            "```rust\nlet x = 1; /* open comment\n",
            "still\nclose */\n```",
            "rust-comment-opener",
        );
        assert_opener_invariant(
            "```python\ns = '''open\n",
            "body\n'''\n",
            "python-triple-opener",
        );
        assert_opener_invariant(
            "```c\nint a; /* open\n",
            "x\n*/ int b;\n```",
            "c-comment-opener",
        );
    }

    // Brute-force: a corpus of code blocks, every byte prefix, every language.
    #[test]
    fn temp_probe_byte_prefixes_corpus() {
        let langs = [
            "rust",
            "python",
            "c",
            "javascript",
            "yaml",
            "html",
            "bash",
            "json",
            "go",
        ];
        let bodies = [
            "x = \"a\nb\nc\"\ny = 1",
            "/* a\nb\n*/ z = 2",
            "// trailing\nnext line\n",
            "a()\n\n\nb()",
            "v = r#\"raw\nmore\"#\nw = 0",
            "<x a=\"1\nb\">y</x>",
            "k: |\n  one\n  two\nz: 3",
        ];
        for lang in langs {
            for body in bodies {
                let full = format!("```{lang}\n{body}\n```");
                assert_line_count(&full, &format!("{lang}:{body:?}"));
                assert_byte_prefix_stable(&full, &format!("{lang}:{body:?}"));
            }
        }
    }

    #[test]
    fn blockquote_has_bar_and_text() {
        let q = texts("> hello **world**");
        assert_eq!(q.len(), 1);
        assert!(q[0].starts_with("▏ "), "quote bar: {:?}", q[0]);
        assert!(q[0].contains("hello"));
        assert!(q[0].contains("world"));
    }

    #[test]
    fn committed_lines_are_stable_as_text_grows() {
        // The crucial streaming invariant: a line that is *not* the last line of
        // some prefix render gets committed to immutable scrollback, so it must
        // already equal its form in the final render. Check that for every
        // growing prefix of a representative document.
        let full = "# Title\nintro **bold**\n- a\n- b\n```\ncode\n```\ntail";
        let final_lines = texts(full);
        for end in 1..=full.len() {
            if !full.is_char_boundary(end) {
                continue;
            }
            let rendered = texts(&full[..end]);
            for i in 0..rendered.len().saturating_sub(1) {
                assert_eq!(
                    rendered[i], final_lines[i],
                    "committed line {i} differs from final at prefix len {end}"
                );
            }
        }
    }
}
