use crate::utils::decode_bin;

use ansi_colours::AsRGB;
use anyhow::{Context, Result, anyhow};
use comfy_table::{CellAlignment, ContentArrangement, Table, presets::UTF8_FULL};
use crossterm::style::{Color, SetForegroundColor, Stylize};
use crossterm::terminal;
use fancy_regex::{Captures, Regex};
use std::collections::HashMap;
use std::iter;
use std::sync::LazyLock;
use syntect::highlighting::{Color as SyntectColor, FontStyle, Style, Theme, ThemeItem};
use syntect::parsing::SyntaxSet;
use syntect::{easy::HighlightLines, parsing::SyntaxReference};

/// Comes from <https://github.com/sharkdp/bat/raw/5e77ca37e89c873e4490b42ff556370dc5c6ba4f/assets/syntaxes.bin>
const SYNTAXES: &[u8] = include_bytes!("../../assets/syntaxes.bin");

static LANG_MAPS: LazyLock<HashMap<String, String>> = LazyLock::new(|| {
    let mut m = HashMap::new();
    m.insert("csharp".into(), "C#".into());
    m.insert("php".into(), "PHP Source".into());
    m
});

static HEADING_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*(#{1,6}) +.+").unwrap());
static BLOCKQUOTE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*>").unwrap());
static TASK_ITEM_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^\s*[-*+] \[([ xX])\] +.+").unwrap());
static BULLET_ITEM_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*[-*+] +.+").unwrap());
static NUMBERED_ITEM_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\d+\. +.+").unwrap());
static HRULE_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^\s*(-{3,}|_{3,}|\*{3,})\s*$").unwrap());
static TABLE_SEPARATOR_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^\s*\|(\s*:?-+:?\s*\|)+\s*$").unwrap());
static TABLE_ROW_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\|.*\|\s*$").unwrap());

static INLINE_CODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`([^`\n]+)`").unwrap());
static IMAGE_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").unwrap());
static LINK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
static BOLD_AST_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*\n]+)\*\*").unwrap());
static BOLD_US_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"__([^_\n]+)__").unwrap());
static ITALIC_AST_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?<![*\w])\*(?!\s)([^*\n]+?)(?<!\s)\*(?!\*)").unwrap());
static ITALIC_US_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?<![_\w])_(?!\s)([^_\n]+?)(?<!\s)_(?!_)").unwrap());
static STRIKETHROUGH_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~~([^~\n]+)~~").unwrap());
static CODE_PLACEHOLDER_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\x00C(\d+)\x00").unwrap());

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineKind {
    Heading(u8),
    Blockquote,
    TaskItem(bool),
    BulletItem,
    NumberedItem,
    HorizontalRule,
    TableRow,
    TableSeparator,
    Paragraph,
}

fn detect_line_kind(line: &str) -> LineKind {
    if HRULE_RE.is_match(line).unwrap_or(false) {
        return LineKind::HorizontalRule;
    }
    if let Ok(Some(caps)) = HEADING_RE.captures(line) {
        let level = caps
            .get(1)
            .map(|m| m.as_str().len() as u8)
            .unwrap_or(1)
            .min(6);
        return LineKind::Heading(level);
    }
    if BLOCKQUOTE_RE.is_match(line).unwrap_or(false) {
        return LineKind::Blockquote;
    }
    if let Ok(Some(caps)) = TASK_ITEM_RE.captures(line) {
        let checked = caps.get(1).map(|m| m.as_str() != " ").unwrap_or(false);
        return LineKind::TaskItem(checked);
    }
    if BULLET_ITEM_RE.is_match(line).unwrap_or(false) {
        return LineKind::BulletItem;
    }
    if NUMBERED_ITEM_RE.is_match(line).unwrap_or(false) {
        return LineKind::NumberedItem;
    }
    if TABLE_SEPARATOR_RE.is_match(line).unwrap_or(false) {
        return LineKind::TableSeparator;
    }
    if TABLE_ROW_RE.is_match(line).unwrap_or(false) {
        return LineKind::TableRow;
    }

    LineKind::Paragraph
}

fn parse_table_row(line: &str) -> Vec<String> {
    let inner = line.trim().trim_start_matches('|').trim_end_matches('|');
    inner.split('|').map(|c| c.trim().to_string()).collect()
}

fn parse_alignments(separator_row: &str) -> Vec<CellAlignment> {
    parse_table_row(separator_row)
        .iter()
        .map(|c| {
            let trimmed = c.trim();
            let starts = trimmed.starts_with(':');
            let ends = trimmed.ends_with(':');
            match (starts, ends) {
                (true, true) => CellAlignment::Center,
                (false, true) => CellAlignment::Right,
                _ => CellAlignment::Left,
            }
        })
        .collect()
}

fn regex_replace<F>(text: &str, re: &Regex, mut f: F) -> String
where
    F: FnMut(&Captures) -> String,
{
    let mut out = String::new();
    let mut last_end = 0;
    for caps_result in re.captures_iter(text) {
        let Ok(caps) = caps_result else { continue };
        let Some(whole) = caps.get(0) else { continue };
        out.push_str(&text[last_end..whole.start()]);
        out.push_str(&f(&caps));
        last_end = whole.end();
    }

    out.push_str(&text[last_end..]);
    out
}

fn wrap_osc8(url: &str, visible: &str) -> String {
    format!("\x1b]8;;{url}\x1b\\{visible}\x1b]8;;\x1b\\")
}

fn style_inline_code(content: &str, styles: &MarkdownStyles) -> String {
    let styled = content.with(styles.inline_code_fg);
    match styles.inline_code_bg {
        Some(bg) => styled.on(bg).to_string(),
        None => styled.to_string(),
    }
}

fn render_markdown_line(
    line: &str,
    kind: LineKind,
    styles: &MarkdownStyles,
    wrap_width: Option<u16>,
) -> String {
    match kind {
        LineKind::Heading(level) => render_heading(line, level, styles),
        LineKind::Blockquote => render_blockquote(line, styles, wrap_width),
        LineKind::BulletItem => render_bullet(line, styles, wrap_width),
        LineKind::NumberedItem => render_numbered(line, styles, wrap_width),
        LineKind::TaskItem(checked) => render_task(line, checked, styles, wrap_width),
        LineKind::HorizontalRule => render_hrule(styles),
        LineKind::TableRow | LineKind::TableSeparator => apply_inline(line, styles),
        LineKind::Paragraph => apply_inline(line, styles),
    }
}

fn kind_pre_wraps(kind: LineKind) -> bool {
    matches!(
        kind,
        LineKind::BulletItem
            | LineKind::NumberedItem
            | LineKind::TaskItem(_)
            | LineKind::Blockquote
    )
}

fn wrap_styled_content(styled: &str, effective_width: usize) -> Vec<String> {
    let effective_width = effective_width.max(1);
    let wrapped: Vec<String> = textwrap::wrap(styled, effective_width)
        .into_iter()
        .map(|c| c.into_owned())
        .collect();
    reflow_ansi_state(wrapped)
}

fn apply_base_style(styled: &str, fg: Color, bold: bool) -> String {
    let mut opener = String::new();
    if fg != Color::Reset {
        opener.push_str(&SetForegroundColor(fg).to_string());
    }
    if bold {
        opener.push_str("\x1b[1m");
    }
    if opener.is_empty() {
        return styled.to_string();
    }
    let restored = styled
        .replace("\x1b[0m", &format!("\x1b[0m{opener}"))
        .replace("\x1b[39m", &format!("\x1b[39m{opener}"));
    format!("{opener}{restored}\x1b[0m")
}

#[derive(Default, Clone, PartialEq, Eq)]
struct SgrState {
    fg: Option<String>,
    bold: bool,
    italic: bool,
    strike: bool,
    underline: bool,
}

impl SgrState {
    fn is_default(&self) -> bool {
        self.fg.is_none() && !self.bold && !self.italic && !self.strike && !self.underline
    }

    fn as_opener(&self) -> String {
        if self.is_default() {
            return String::new();
        }
        let mut params: Vec<String> = Vec::new();
        if let Some(fg) = &self.fg {
            params.push(fg.clone());
        }
        if self.bold {
            params.push("1".into());
        }
        if self.italic {
            params.push("3".into());
        }
        if self.underline {
            params.push("4".into());
        }
        if self.strike {
            params.push("9".into());
        }

        format!("\x1b[{}m", params.join(";"))
    }

    fn apply(&mut self, params: &str) {
        if params.is_empty() {
            *self = Self::default();
            return;
        }
        let parts: Vec<&str> = params.split(';').collect();
        let mut i = 0;
        while i < parts.len() {
            let code = parts[i];
            match code {
                "0" | "" => *self = Self::default(),
                "1" => self.bold = true,
                "3" => self.italic = true,
                "4" => self.underline = true,
                "9" => self.strike = true,
                "22" => self.bold = false,
                "23" => self.italic = false,
                "24" => self.underline = false,
                "29" => self.strike = false,
                "39" => self.fg = None,
                "38" if i + 1 < parts.len() => match parts[i + 1] {
                    "5" if i + 2 < parts.len() => {
                        self.fg = Some(format!("38;5;{}", parts[i + 2]));
                        i += 2;
                    }
                    "2" if i + 4 < parts.len() => {
                        self.fg = Some(format!(
                            "38;2;{};{};{}",
                            parts[i + 2],
                            parts[i + 3],
                            parts[i + 4]
                        ));
                        i += 4;
                    }
                    _ => {}
                },
                _ => {
                    if let Ok(n) = code.parse::<u8>()
                        && ((30..=37).contains(&n) || (90..=97).contains(&n))
                    {
                        self.fg = Some(n.to_string());
                    }
                }
            }
            i += 1;
        }
    }
}

fn parse_sgr_updates(line: &str, state: &mut SgrState) {
    let bytes = line.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
            let mut j = i + 2;
            while j < bytes.len() && bytes[j] != b'm' {
                if bytes[j] == 0x1b {
                    break;
                }
                j += 1;
            }

            if j < bytes.len() && bytes[j] == b'm' {
                let params = &line[i + 2..j];
                state.apply(params);
                i = j + 1;
                continue;
            }
        }

        i += 1;
    }
}

fn reflow_ansi_state(lines: Vec<String>) -> Vec<String> {
    let mut result = Vec::with_capacity(lines.len());
    let mut open_state = SgrState::default();

    for line in lines {
        let opener = open_state.as_opener();
        let mut end_state = open_state.clone();
        parse_sgr_updates(&line, &mut end_state);

        let mut full = if opener.is_empty() {
            line
        } else {
            format!("{opener}{line}")
        };
        if !end_state.is_default() {
            full.push_str("\x1b[0m");
        }
        open_state = end_state;
        result.push(full);
    }

    result
}

fn split_indent(line: &str) -> (&str, &str) {
    let indent_len = line
        .char_indices()
        .find(|(_, c)| !c.is_whitespace())
        .map(|(i, _)| i)
        .unwrap_or(line.len());
    line.split_at(indent_len)
}

fn render_heading(line: &str, level: u8, styles: &MarkdownStyles) -> String {
    let (indent, rest) = split_indent(line);
    let content = rest.trim_start_matches('#').trim_start();
    let inline = apply_inline(content, styles);
    let (color, _force_bold) = styles.heading;

    let body = if level == 1 {
        format!(" {inline} ")
    } else {
        format!("{} {inline}", "#".repeat(level as usize))
    };

    format!("{indent}{}", apply_base_style(&body, color, true))
}

fn render_blockquote(line: &str, styles: &MarkdownStyles, wrap_width: Option<u16>) -> String {
    let (indent, rest) = split_indent(line);
    let content = rest.trim_start_matches('>').trim_start();
    let prefix = "│ ".with(styles.blockquote).to_string();

    let styled = apply_base_style(&apply_inline(content, styles), styles.blockquote, false);

    let Some(wrap_width) = wrap_width else {
        return format!("{indent}{prefix}{styled}");
    };

    let prefix_width = 2;
    let leading_width = indent.chars().count();
    let effective_width = (wrap_width as usize).saturating_sub(leading_width + prefix_width);
    let wrapped = wrap_styled_content(&styled, effective_width);
    if wrapped.is_empty() {
        return format!("{indent}{prefix}");
    }

    let mut out = String::new();
    for (i, chunk) in wrapped.iter().enumerate() {
        if i > 0 {
            out.push('\n');
        }
        out.push_str(&format!("{indent}{prefix}{chunk}"));
    }

    out
}

fn render_bullet(line: &str, styles: &MarkdownStyles, wrap_width: Option<u16>) -> String {
    let (indent, rest) = split_indent(line);
    let content = rest.get(2..).unwrap_or("");
    let bullet = "•".with(styles.list_bullet).to_string();

    let Some(wrap_width) = wrap_width else {
        return format!("{indent}{bullet} {}", apply_inline(content, styles));
    };

    let prefix_width = 2;
    let leading_width = indent.chars().count();
    let effective_width = (wrap_width as usize).saturating_sub(leading_width + prefix_width);
    let styled = apply_inline(content, styles);
    let wrapped = wrap_styled_content(&styled, effective_width);
    if wrapped.is_empty() {
        return format!("{indent}{bullet} ");
    }

    let subseq = " ".repeat(prefix_width);
    let mut out = String::new();
    for (i, chunk) in wrapped.iter().enumerate() {
        if i > 0 {
            out.push('\n');
        }
        if i == 0 {
            out.push_str(&format!("{indent}{bullet} {chunk}"));
        } else {
            out.push_str(&format!("{indent}{subseq}{chunk}"));
        }
    }

    out
}

fn render_numbered(line: &str, styles: &MarkdownStyles, wrap_width: Option<u16>) -> String {
    let (indent, rest) = split_indent(line);
    let Some(dot_pos) = rest.find('.') else {
        return format!("{indent}{}", apply_inline(rest, styles));
    };
    let number = &rest[..dot_pos];
    let after = rest[dot_pos + 1..].trim_start();
    let styled_dot = ".".with(styles.list_bullet).to_string();

    let Some(wrap_width) = wrap_width else {
        return format!(
            "{indent}{number}{styled_dot} {}",
            apply_inline(after, styles)
        );
    };

    let prefix_width = number.chars().count() + 2;
    let leading_width = indent.chars().count();
    let effective_width = (wrap_width as usize).saturating_sub(leading_width + prefix_width);
    let styled = apply_inline(after, styles);
    let wrapped = wrap_styled_content(&styled, effective_width);
    if wrapped.is_empty() {
        return format!("{indent}{number}{styled_dot} ");
    }

    let subseq = " ".repeat(prefix_width);
    let mut out = String::new();
    for (i, chunk) in wrapped.iter().enumerate() {
        if i > 0 {
            out.push('\n');
        }
        if i == 0 {
            out.push_str(&format!("{indent}{number}{styled_dot} {chunk}"));
        } else {
            out.push_str(&format!("{indent}{subseq}{chunk}"));
        }
    }

    out
}

fn render_task(
    line: &str,
    checked: bool,
    styles: &MarkdownStyles,
    wrap_width: Option<u16>,
) -> String {
    let (indent, rest) = split_indent(line);
    let after_bullet = rest.get(2..).unwrap_or("");
    let after_brackets = after_bullet.get(3..).map(str::trim_start).unwrap_or("");
    let glyph = if checked { "[✓]" } else { "[ ]" };
    let styled_brackets = glyph.with(styles.list_bullet).to_string();

    let Some(wrap_width) = wrap_width else {
        return format!(
            "{indent}{styled_brackets} {}",
            apply_inline(after_brackets, styles)
        );
    };

    let prefix_width = 4;
    let leading_width = indent.chars().count();
    let effective_width = (wrap_width as usize).saturating_sub(leading_width + prefix_width);
    let styled = apply_inline(after_brackets, styles);
    let wrapped = wrap_styled_content(&styled, effective_width);
    if wrapped.is_empty() {
        return format!("{indent}{styled_brackets} ");
    }

    let subseq = " ".repeat(prefix_width);
    let mut out = String::new();
    for (i, chunk) in wrapped.iter().enumerate() {
        if i > 0 {
            out.push('\n');
        }
        if i == 0 {
            out.push_str(&format!("{indent}{styled_brackets} {chunk}"));
        } else {
            out.push_str(&format!("{indent}{subseq}{chunk}"));
        }
    }

    out
}

fn render_hrule(styles: &MarkdownStyles) -> String {
    "────────".with(styles.hrule).to_string()
}

fn colorize_box_chars(text: &str, color: Color) -> String {
    let sample = "X".with(color).to_string();
    let paint_idx = match sample.find('X') {
        Some(i) => i,
        None => return text.to_string(),
    };
    let prefix = &sample[..paint_idx];
    let suffix = &sample[paint_idx + 1..];
    if prefix.is_empty() && suffix.is_empty() {
        return text.to_string();
    }

    let mut out = String::with_capacity(text.len() + text.len() / 4);
    let mut in_border = false;
    for c in text.chars() {
        let is_border = matches!(c, '\u{2500}'..='\u{257F}');
        if is_border && !in_border {
            out.push_str(prefix);
            in_border = true;
        } else if !is_border && in_border {
            out.push_str(suffix);
            in_border = false;
        }
        out.push(c);
    }

    if in_border {
        out.push_str(suffix);
    }

    out
}

fn apply_inline(text: &str, styles: &MarkdownStyles) -> String {
    let mut code_bank: Vec<String> = Vec::new();
    let masked = regex_replace(text, &INLINE_CODE_RE, |caps| {
        let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
        let idx = code_bank.len();
        code_bank.push(style_inline_code(content, styles));
        format!("\x00C{idx}\x00")
    });

    let with_images = regex_replace(&masked, &IMAGE_RE, |caps| {
        let alt = caps.get(1).map(|m| m.as_str()).unwrap_or("");
        let url = caps.get(2).map(|m| m.as_str()).unwrap_or("");
        let visible = format!("Image: {alt} → {url}")
            .with(styles.link_url)
            .to_string();
        wrap_osc8(url, &visible)
    });

    let with_links = regex_replace(&with_images, &LINK_RE, |caps| {
        let label = caps.get(1).map(|m| m.as_str()).unwrap_or("");
        let url = caps.get(2).map(|m| m.as_str()).unwrap_or("");
        let styled_label = label.with(styles.link_text).to_string();
        let styled_url = url.with(styles.link_url).to_string();
        wrap_osc8(url, &format!("{styled_label} {styled_url}"))
    });

    let with_bold = regex_replace(&with_links, &BOLD_AST_RE, |caps| {
        let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
        content.with(styles.bold).bold().to_string()
    });
    let with_bold = regex_replace(&with_bold, &BOLD_US_RE, |caps| {
        let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
        content.with(styles.bold).bold().to_string()
    });

    let with_italic = regex_replace(&with_bold, &ITALIC_AST_RE, |caps| {
        let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
        content.with(styles.italic).italic().to_string()
    });
    let with_italic = regex_replace(&with_italic, &ITALIC_US_RE, |caps| {
        let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
        content.with(styles.italic).italic().to_string()
    });

    let with_strike = regex_replace(&with_italic, &STRIKETHROUGH_RE, |caps| {
        let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
        content.with(styles.strikethrough).crossed_out().to_string()
    });

    regex_replace(&with_strike, &CODE_PLACEHOLDER_RE, |caps| {
        let idx: usize = caps
            .get(1)
            .and_then(|m| m.as_str().parse().ok())
            .unwrap_or(0);
        code_bank
            .get(idx)
            .cloned()
            .unwrap_or_else(|| caps.get(0).unwrap().as_str().to_string())
    })
}

enum TableState {
    PendingHeader(String),
    Active {
        header: Vec<String>,
        alignments: Vec<CellAlignment>,
        rows: Vec<Vec<String>>,
    },
}

enum TableAction {
    Consumed(String),
    ConsumedSilent,
    FlushAndContinue(String),
    Passthrough,
}

pub struct MarkdownRender {
    options: RenderOptions,
    syntax_set: SyntaxSet,
    code_color: Option<Color>,
    md_syntax: SyntaxReference,
    code_syntax: Option<SyntaxReference>,
    prev_line_type: LineType,
    wrap_width: Option<u16>,
    styles: MarkdownStyles,
    table_state: Option<TableState>,
}

impl MarkdownRender {
    pub fn init(options: RenderOptions) -> Result<Self> {
        let syntax_set: SyntaxSet =
            decode_bin(SYNTAXES).with_context(|| "MarkdownRender: invalid syntaxes binary")?;

        let code_color = options
            .theme
            .as_ref()
            .map(|theme| get_code_color(theme, options.truecolor));
        let md_syntax = syntax_set.find_syntax_by_extension("md").unwrap().clone();
        let line_type = LineType::Normal;
        let wrap_width = match options.wrap.as_deref() {
            None => None,
            Some(value) => match terminal::size() {
                Ok((columns, _)) => {
                    if value == "auto" {
                        Some(columns)
                    } else {
                        let value = value
                            .parse::<u16>()
                            .map_err(|_| anyhow!("Invalid wrap value"))?;
                        Some(columns.min(value))
                    }
                }
                Err(_) => None,
            },
        };
        let styles = MarkdownStyles::from_theme(options.theme.as_ref(), options.truecolor);
        Ok(Self {
            syntax_set,
            code_color,
            md_syntax,
            code_syntax: None,
            prev_line_type: line_type,
            wrap_width,
            styles,
            table_state: None,
            options,
        })
    }

    pub fn render(&mut self, text: &str) -> String {
        text.split('\n')
            .filter_map(|line| self.render_line_mut(line))
            .collect::<Vec<String>>()
            .join("\n")
    }

    pub fn render_line(&self, line: &str) -> String {
        let (_, line_kind, code_syntax, is_code) = self.check_line(line);
        if is_code {
            self.highlight_code_line(line, &code_syntax)
        } else if self.options.raw_markdown {
            self.highlight_line(line, &self.md_syntax, false)
        } else {
            self.render_rich_markdown_line(line, line_kind)
        }
    }

    fn render_line_mut(&mut self, line: &str) -> Option<String> {
        let (line_type, line_kind, code_syntax, is_code) = self.check_line(line);

        let table_prefix = if self.options.raw_markdown {
            None
        } else {
            let effective_kind = if is_code {
                LineKind::Paragraph
            } else {
                line_kind
            };
            match self.handle_table_state(line, effective_kind) {
                TableAction::Consumed(s) => {
                    self.prev_line_type = line_type;
                    self.code_syntax = code_syntax;
                    return Some(s);
                }
                TableAction::ConsumedSilent => {
                    self.prev_line_type = line_type;
                    self.code_syntax = code_syntax;
                    return None;
                }
                TableAction::FlushAndContinue(s) => Some(s),
                TableAction::Passthrough => None,
            }
        };

        let output = if is_code {
            self.highlight_code_line(line, &code_syntax)
        } else if self.options.raw_markdown {
            self.highlight_line(line, &self.md_syntax, false)
        } else {
            self.render_rich_markdown_line(line, line_kind)
        };
        self.prev_line_type = line_type;
        self.code_syntax = code_syntax;

        Some(match table_prefix {
            Some(prefix) => format!("{prefix}\n{output}"),
            None => output,
        })
    }

    fn render_as_paragraph(&self, line: &str) -> String {
        self.render_rich_markdown_line(line, LineKind::Paragraph)
    }

    fn handle_table_state(&mut self, line: &str, kind: LineKind) -> TableAction {
        match (self.table_state.take(), kind) {
            (None, LineKind::TableRow) => {
                self.table_state = Some(TableState::PendingHeader(line.to_string()));
                TableAction::ConsumedSilent
            }
            (None, LineKind::TableSeparator) => TableAction::Passthrough,
            (None, _) => TableAction::Passthrough,
            (Some(TableState::PendingHeader(header_line)), LineKind::TableSeparator) => {
                let header = parse_table_row(&header_line);
                let alignments = parse_alignments(line);
                self.table_state = Some(TableState::Active {
                    header,
                    alignments,
                    rows: Vec::new(),
                });
                TableAction::ConsumedSilent
            }
            (Some(TableState::PendingHeader(header_line)), LineKind::TableRow) => {
                let a = self.render_as_paragraph(&header_line);
                let b = self.render_as_paragraph(line);
                TableAction::Consumed(format!("{a}\n{b}"))
            }
            (Some(TableState::PendingHeader(header_line)), _) => {
                let flushed = self.render_as_paragraph(&header_line);
                TableAction::FlushAndContinue(flushed)
            }
            (
                Some(TableState::Active {
                    header,
                    alignments,
                    mut rows,
                }),
                LineKind::TableRow,
            ) => {
                rows.push(parse_table_row(line));
                self.table_state = Some(TableState::Active {
                    header,
                    alignments,
                    rows,
                });
                TableAction::ConsumedSilent
            }
            (
                Some(TableState::Active {
                    header,
                    alignments,
                    rows,
                }),
                _,
            ) => {
                let rendered = self.render_table(header, alignments, rows);
                TableAction::FlushAndContinue(rendered)
            }
        }
    }

    pub fn finalize(&mut self) -> String {
        match self.table_state.take() {
            None => String::new(),
            Some(TableState::PendingHeader(line)) => self.render_as_paragraph(&line),
            Some(TableState::Active {
                header,
                alignments,
                rows,
            }) => self.render_table(header, alignments, rows),
        }
    }

    fn render_rich_markdown_line(&self, line: &str, kind: LineKind) -> String {
        let styled = render_markdown_line(line, kind, &self.styles, self.wrap_width);
        if kind_pre_wraps(kind) {
            styled
        } else {
            self.wrap_line(styled, false)
        }
    }

    fn render_table(
        &self,
        header: Vec<String>,
        alignments: Vec<CellAlignment>,
        rows: Vec<Vec<String>>,
    ) -> String {
        let mut table = Table::new();
        table.load_preset(UTF8_FULL);
        table.set_content_arrangement(ContentArrangement::Dynamic);
        if let Some(width) = self.wrap_width {
            table.set_width(width);
        }

        let (heading_color, _) = self.styles.heading;
        let styled_header: Vec<String> = header
            .iter()
            .map(|c| apply_base_style(&apply_inline(c, &self.styles), heading_color, true))
            .collect();
        table.set_header(styled_header);

        for (i, align) in alignments.iter().enumerate() {
            if let Some(col) = table.column_mut(i) {
                col.set_cell_alignment(*align);
            }
        }

        for row in rows {
            let styled_row: Vec<String> =
                row.iter().map(|c| apply_inline(c, &self.styles)).collect();
            table.add_row(styled_row);
        }

        colorize_box_chars(&table.to_string(), self.styles.table_border)
    }

    fn check_line(&self, line: &str) -> (LineType, LineKind, Option<SyntaxReference>, bool) {
        let mut line_type = self.prev_line_type;
        let mut code_syntax = self.code_syntax.clone();
        let mut is_code = false;
        if let Some(lang) = detect_code_block(line) {
            match line_type {
                LineType::Normal | LineType::CodeEnd => {
                    line_type = LineType::CodeBegin;
                    code_syntax = if lang.is_empty() {
                        None
                    } else {
                        self.find_syntax(&lang).cloned()
                    };
                }
                LineType::CodeBegin | LineType::CodeInner => {
                    line_type = LineType::CodeEnd;
                    code_syntax = None;
                }
            }
        } else {
            match line_type {
                LineType::Normal => {}
                LineType::CodeEnd => {
                    line_type = LineType::Normal;
                }
                LineType::CodeBegin => {
                    if code_syntax.is_none()
                        && let Some(syntax) = self.syntax_set.find_syntax_by_first_line(line)
                    {
                        code_syntax = Some(syntax.clone());
                    }
                    line_type = LineType::CodeInner;
                    is_code = true;
                }
                LineType::CodeInner => {
                    is_code = true;
                }
            }
        }
        let line_kind = if is_code {
            LineKind::Paragraph
        } else {
            detect_line_kind(line)
        };

        (line_type, line_kind, code_syntax, is_code)
    }

    fn highlight_line(&self, line: &str, syntax: &SyntaxReference, is_code: bool) -> String {
        let ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
        let trimmed_line: &str = &line[ws.len()..];
        let mut line_highlighted = None;
        if let Some(theme) = &self.options.theme {
            let mut highlighter = HighlightLines::new(syntax, theme);
            if let Ok(ranges) = highlighter.highlight_line(trimmed_line, &self.syntax_set) {
                line_highlighted = Some(format!(
                    "{ws}{}",
                    as_terminal_escaped(&ranges, self.options.truecolor)
                ))
            }
        }
        let line = line_highlighted.unwrap_or_else(|| line.into());
        self.wrap_line(line, is_code)
    }

    fn highlight_code_line(&self, line: &str, code_syntax: &Option<SyntaxReference>) -> String {
        if let Some(syntax) = code_syntax {
            self.highlight_line(line, syntax, true)
        } else {
            let line = match self.code_color {
                Some(color) => line.with(color).to_string(),
                None => line.to_string(),
            };
            self.wrap_line(line, true)
        }
    }

    fn wrap_line(&self, line: String, is_code: bool) -> String {
        if let Some(width) = self.wrap_width {
            if is_code && !self.options.wrap_code {
                return line;
            }
            wrap(&line, width as usize)
        } else {
            line
        }
    }

    fn find_syntax(&self, lang: &str) -> Option<&SyntaxReference> {
        if let Some(new_lang) = LANG_MAPS.get(&lang.to_ascii_lowercase()) {
            self.syntax_set.find_syntax_by_name(new_lang)
        } else {
            self.syntax_set
                .find_syntax_by_token(lang)
                .or_else(|| self.syntax_set.find_syntax_by_extension(lang))
        }
    }
}

fn wrap(text: &str, width: usize) -> String {
    let indent: usize = text.chars().take_while(|c| *c == ' ').count();
    let wrap_options = textwrap::Options::new(width)
        .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit)
        .initial_indent(&text[0..indent]);
    textwrap::wrap(&text[indent..], wrap_options).join("\n")
}

#[derive(Debug, Clone, Default)]
pub struct RenderOptions {
    pub theme: Option<Theme>,
    pub wrap: Option<String>,
    pub wrap_code: bool,
    pub raw_markdown: bool,
    pub truecolor: bool,
}

impl RenderOptions {
    pub(crate) fn new(
        theme: Option<Theme>,
        wrap: Option<String>,
        wrap_code: bool,
        raw_markdown: bool,
        truecolor: bool,
    ) -> Self {
        Self {
            theme,
            wrap,
            wrap_code,
            raw_markdown,
            truecolor,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineType {
    Normal,
    CodeBegin,
    CodeInner,
    CodeEnd,
}

fn as_terminal_escaped(ranges: &[(Style, &str)], truecolor: bool) -> String {
    let mut output = String::new();
    for (style, text) in ranges {
        let fg = blend_fg_color(style.foreground, style.background);
        let mut text = text.with(convert_color(fg, truecolor));
        if style.font_style.contains(FontStyle::BOLD) {
            text = text.bold();
        }
        if style.font_style.contains(FontStyle::UNDERLINE) {
            text = text.underlined();
        }
        output.push_str(&text.to_string());
    }
    output
}

fn convert_color(c: SyntectColor, truecolor: bool) -> Color {
    if truecolor {
        Color::Rgb {
            r: c.r,
            g: c.g,
            b: c.b,
        }
    } else {
        let value = (c.r, c.g, c.b).to_ansi256();
        // lower contrast
        let value = match value {
            7 | 15 | 231 | 252..=255 => 252,
            _ => value,
        };
        Color::AnsiValue(value)
    }
}

fn blend_fg_color(fg: SyntectColor, bg: SyntectColor) -> SyntectColor {
    if fg.a == 0xff {
        return fg;
    }
    let ratio = u32::from(fg.a);
    let r = (u32::from(fg.r) * ratio + u32::from(bg.r) * (255 - ratio)) / 255;
    let g = (u32::from(fg.g) * ratio + u32::from(bg.g) * (255 - ratio)) / 255;
    let b = (u32::from(fg.b) * ratio + u32::from(bg.b) * (255 - ratio)) / 255;
    SyntectColor {
        r: u8::try_from(r).unwrap_or(u8::MAX),
        g: u8::try_from(g).unwrap_or(u8::MAX),
        b: u8::try_from(b).unwrap_or(u8::MAX),
        a: 255,
    }
}

fn detect_code_block(line: &str) -> Option<String> {
    let line = line.trim_start();
    if !line.starts_with("```") {
        return None;
    }
    let lang = line
        .chars()
        .skip(3)
        .take_while(|v| !v.is_whitespace())
        .collect();
    Some(lang)
}

fn get_code_color(theme: &Theme, truecolor: bool) -> Color {
    let scope = theme.scopes.iter().find(|v| {
        v.scope
            .selectors
            .iter()
            .any(|v| v.path.scopes.iter().any(|v| v.to_string() == "string"))
    });
    scope
        .and_then(|v| v.style.foreground)
        .map_or_else(|| Color::Yellow, |c| convert_color(c, truecolor))
}

#[derive(Debug, Clone, Copy, Default)]
struct ResolvedStyle {
    fg: Option<Color>,
    bg: Option<Color>,
    font_style: FontStyle,
}

fn find_theme_scope<'a>(theme: &'a Theme, name: &str) -> Option<&'a ThemeItem> {
    theme.scopes.iter().find(|item| {
        item.scope
            .selectors
            .iter()
            .any(|sel| sel.path.scopes.iter().any(|s| s.to_string() == name))
    })
}

fn resolve_scope_style(
    theme: &Theme,
    primary: &str,
    fallbacks: &[&str],
    truecolor: bool,
) -> ResolvedStyle {
    for scope_name in iter::once(primary).chain(fallbacks.iter().copied()) {
        let Some(item) = find_theme_scope(theme, scope_name) else {
            continue;
        };
        let resolved = ResolvedStyle {
            fg: item.style.foreground.map(|c| convert_color(c, truecolor)),
            bg: item.style.background.map(|c| convert_color(c, truecolor)),
            font_style: item.style.font_style.unwrap_or_default(),
        };

        if resolved.fg.is_some() || resolved.bg.is_some() || !resolved.font_style.is_empty() {
            return resolved;
        }
    }

    ResolvedStyle::default()
}

#[derive(Debug, Clone)]
pub struct MarkdownStyles {
    heading: (Color, bool),
    bold: Color,
    italic: Color,
    inline_code_fg: Color,
    inline_code_bg: Option<Color>,
    blockquote: Color,
    list_bullet: Color,
    link_text: Color,
    link_url: Color,
    strikethrough: Color,
    hrule: Color,
    table_border: Color,
}

impl MarkdownStyles {
    fn from_theme(theme: Option<&Theme>, truecolor: bool) -> Self {
        let Some(theme) = theme else {
            return Self::none();
        };

        let heading = resolve_scope_style(
            theme,
            "markup.heading",
            &["markup.bold", "entity.name.section"],
            truecolor,
        );
        let bold = resolve_scope_style(
            theme,
            "markup.bold",
            &["entity.other.attribute-name"],
            truecolor,
        );
        let italic = resolve_scope_style(theme, "markup.italic", &["comment"], truecolor);
        let inline_code = resolve_scope_style(
            theme,
            "markup.raw.inline",
            &["markup.raw", "string"],
            truecolor,
        );
        let blockquote =
            resolve_scope_style(theme, "markup.quote", &["comment", "string"], truecolor);
        let list_bullet = resolve_scope_style(
            theme,
            "markup.list",
            &["punctuation.definition.list", "keyword"],
            truecolor,
        );
        let link_text = resolve_scope_style(
            theme,
            "markup.link",
            &["string.other.link", "entity.name.tag"],
            truecolor,
        );
        let link_url = resolve_scope_style(
            theme,
            "markup.underline.link",
            &["string.other.link", "constant"],
            truecolor,
        );
        let strikethrough = resolve_scope_style(theme, "markup.deleted", &["invalid"], truecolor);
        let hrule = resolve_scope_style(theme, "comment", &["punctuation"], truecolor);
        let table_border = resolve_scope_style(
            theme,
            "punctuation.definition.table.markdown",
            &["punctuation", "meta.separator"],
            truecolor,
        );

        Self {
            heading: (
                heading.fg.unwrap_or(Color::Yellow),
                heading.font_style.contains(FontStyle::BOLD),
            ),
            bold: bold.fg.unwrap_or(Color::Reset),
            italic: italic.fg.unwrap_or(Color::Reset),
            inline_code_fg: inline_code.fg.unwrap_or(Color::Yellow),
            inline_code_bg: inline_code.bg,
            blockquote: blockquote.fg.unwrap_or(Color::DarkGrey),
            list_bullet: list_bullet.fg.unwrap_or(Color::Reset),
            link_text: link_text.fg.unwrap_or(Color::Cyan),
            link_url: link_url.fg.unwrap_or(Color::Blue),
            strikethrough: strikethrough.fg.unwrap_or(Color::DarkGrey),
            hrule: hrule.fg.unwrap_or(Color::DarkGrey),
            table_border: table_border.fg.unwrap_or(Color::DarkGrey),
        }
    }

    fn none() -> Self {
        Self {
            heading: (Color::Reset, false),
            bold: Color::Reset,
            italic: Color::Reset,
            inline_code_fg: Color::Reset,
            inline_code_bg: None,
            blockquote: Color::Reset,
            list_bullet: Color::Reset,
            link_text: Color::Reset,
            link_url: Color::Reset,
            strikethrough: Color::Reset,
            hrule: Color::Reset,
            table_border: Color::Reset,
        }
    }
}

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

    const TEXT: &str = r#"
To unzip a file in Rust, you can use the `zip` crate. Here's an example code that shows how to unzip a file:

```rust
use std::fs::File;

fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn std::error::Error>> {
    todo!()
}
```
"#;
    const TEXT_NO_WRAP_CODE: &str = r#"
To unzip a file in Rust, you can use the `zip` crate. Here's an example code
that shows how to unzip a file:

```rust
use std::fs::File;

fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn std::error::Error>> {
    todo!()
}
```
"#;

    const TEXT_WRAP_ALL: &str = r#"
To unzip a file in Rust, you can use the `zip` crate. Here's an example code
that shows how to unzip a file:

```rust
use std::fs::File;

fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn
std::error::Error>> {
    todo!()
}
```
"#;

    #[test]
    fn test_render() {
        let options = RenderOptions::default();
        let render = MarkdownRender::init(options).unwrap();
        assert!(render.find_syntax("csharp").is_some());
    }

    #[test]
    fn no_theme() {
        let options = RenderOptions {
            raw_markdown: true,
            ..Default::default()
        };
        let mut render = MarkdownRender::init(options).unwrap();
        let output = render.render(TEXT);
        assert_eq!(TEXT, output);
    }

    #[test]
    fn no_wrap_code() {
        let options = RenderOptions {
            raw_markdown: true,
            ..Default::default()
        };
        let mut render = MarkdownRender::init(options).unwrap();
        render.wrap_width = Some(80);
        let output = render.render(TEXT);
        assert_eq!(TEXT_NO_WRAP_CODE, output);
    }

    #[test]
    fn wrap_all() {
        let options = RenderOptions {
            wrap_code: true,
            raw_markdown: true,
            ..Default::default()
        };
        let mut render = MarkdownRender::init(options).unwrap();
        render.wrap_width = Some(80);
        let output = render.render(TEXT);
        assert_eq!(TEXT_WRAP_ALL, output);
    }

    #[test]
    fn test_detect_code_block() {
        assert_eq!(detect_code_block("```rust"), Some("rust".into()));
        assert_eq!(detect_code_block("```c++"), Some("c++".into()));
        assert_eq!(detect_code_block("  ```rust"), Some("rust".into()));
        assert_eq!(detect_code_block("```"), Some("".into()));
        assert_eq!(detect_code_block("``rust"), None);
    }

    use std::str::FromStr;
    use syntect::highlighting::{ScopeSelectors, StyleModifier, ThemeItem};

    const DARK_THEME_BYTES: &[u8] = include_bytes!("../../assets/monokai-extended.theme.bin");

    fn load_dark_theme() -> Theme {
        decode_bin(DARK_THEME_BYTES).expect("built-in dark theme should decode")
    }

    fn syntect_rgb(r: u8, g: u8, b: u8) -> SyntectColor {
        SyntectColor { r, g, b, a: 255 }
    }

    fn theme_item(scope: &str, fg: SyntectColor, font_style: Option<FontStyle>) -> ThemeItem {
        ThemeItem {
            scope: ScopeSelectors::from_str(scope).expect("valid scope selector"),
            style: StyleModifier {
                foreground: Some(fg),
                background: None,
                font_style,
            },
        }
    }

    fn minimal_root_scope_theme() -> Theme {
        let mut theme = Theme::default();
        theme
            .scopes
            .push(theme_item("string", syntect_rgb(0xff, 0xdd, 0x00), None));
        theme
            .scopes
            .push(theme_item("comment", syntect_rgb(0x88, 0x88, 0x88), None));
        theme
            .scopes
            .push(theme_item("keyword", syntect_rgb(0xaa, 0x00, 0xff), None));
        theme
            .scopes
            .push(theme_item("constant", syntect_rgb(0x00, 0xcc, 0xff), None));
        theme.scopes.push(theme_item(
            "entity.name.tag",
            syntect_rgb(0x11, 0x22, 0x33),
            None,
        ));
        theme.scopes.push(theme_item(
            "entity.other.attribute-name",
            syntect_rgb(0x44, 0x55, 0x66),
            Some(FontStyle::BOLD),
        ));
        theme.scopes.push(theme_item(
            "entity.name.section",
            syntect_rgb(0xde, 0xad, 0xbe),
            Some(FontStyle::BOLD),
        ));
        theme
            .scopes
            .push(theme_item("invalid", syntect_rgb(0xff, 0x00, 0x00), None));
        theme.scopes.push(theme_item(
            "punctuation",
            syntect_rgb(0x77, 0x77, 0x77),
            None,
        ));
        theme
    }

    fn rgb(r: u8, g: u8, b: u8) -> Color {
        Color::Rgb { r, g, b }
    }

    #[test]
    fn resolve_scope_style_uses_primary() {
        let mut theme = Theme::default();
        theme.scopes.push(theme_item(
            "markup.bold",
            syntect_rgb(0xaa, 0xbb, 0xcc),
            Some(FontStyle::BOLD),
        ));

        let resolved = resolve_scope_style(&theme, "markup.bold", &["fallback"], true);

        assert_eq!(resolved.fg, Some(rgb(0xaa, 0xbb, 0xcc)));
        assert!(resolved.font_style.contains(FontStyle::BOLD));
    }

    #[test]
    fn resolve_scope_style_falls_back_when_primary_missing() {
        let mut theme = Theme::default();
        theme
            .scopes
            .push(theme_item("comment", syntect_rgb(0x33, 0x44, 0x55), None));

        let resolved = resolve_scope_style(&theme, "markup.italic", &["nope", "comment"], true);

        assert_eq!(resolved.fg, Some(rgb(0x33, 0x44, 0x55)));
    }

    #[test]
    fn resolve_scope_style_returns_default_when_nothing_matches() {
        let theme = Theme::default();

        let resolved = resolve_scope_style(&theme, "markup.italic", &["comment"], true);

        assert!(resolved.fg.is_none());
        assert!(resolved.bg.is_none());
        assert!(resolved.font_style.is_empty());
    }

    #[test]
    fn markdown_styles_none_when_theme_absent() {
        let styles = MarkdownStyles::from_theme(None, true);

        assert_eq!(styles.heading, (Color::Reset, false));
        assert_eq!(styles.bold, Color::Reset);
        assert_eq!(styles.italic, Color::Reset);
        assert_eq!(styles.inline_code_fg, Color::Reset);
        assert_eq!(styles.inline_code_bg, None);
        assert_eq!(styles.blockquote, Color::Reset);
        assert_eq!(styles.list_bullet, Color::Reset);
        assert_eq!(styles.link_text, Color::Reset);
        assert_eq!(styles.link_url, Color::Reset);
        assert_eq!(styles.strikethrough, Color::Reset);
        assert_eq!(styles.hrule, Color::Reset);
        assert_eq!(styles.table_border, Color::Reset);
    }

    #[test]
    fn markdown_styles_resolve_with_builtin_dark_theme() {
        let theme = load_dark_theme();
        let styles = MarkdownStyles::from_theme(Some(&theme), true);

        assert_ne!(styles.heading.0, Color::Reset);
        assert_ne!(styles.bold, Color::Reset);
        assert_ne!(styles.italic, Color::Reset);
        assert_ne!(styles.inline_code_fg, Color::Reset);
        assert_ne!(styles.blockquote, Color::Reset);
        assert_ne!(styles.list_bullet, Color::Reset);
        assert_ne!(styles.link_text, Color::Reset);
        assert_ne!(styles.link_url, Color::Reset);
        assert_ne!(styles.strikethrough, Color::Reset);
        assert_ne!(styles.hrule, Color::Reset);
        assert_ne!(styles.table_border, Color::Reset);
    }

    #[test]
    fn detect_line_kind_heading_levels() {
        assert_eq!(detect_line_kind("# H1"), LineKind::Heading(1));
        assert_eq!(detect_line_kind("## H2"), LineKind::Heading(2));
        assert_eq!(detect_line_kind("### H3"), LineKind::Heading(3));
        assert_eq!(detect_line_kind("#### H4"), LineKind::Heading(4));
        assert_eq!(detect_line_kind("##### H5"), LineKind::Heading(5));
        assert_eq!(detect_line_kind("###### H6"), LineKind::Heading(6));
        assert_eq!(detect_line_kind("  ## Indented"), LineKind::Heading(2));
    }

    #[test]
    fn detect_line_kind_heading_requires_space_after_hashes() {
        assert_eq!(detect_line_kind("##notheading"), LineKind::Paragraph);
        assert_eq!(detect_line_kind("#"), LineKind::Paragraph);
    }

    #[test]
    fn detect_line_kind_blockquote() {
        assert_eq!(detect_line_kind("> quoted"), LineKind::Blockquote);
        assert_eq!(detect_line_kind(">no space"), LineKind::Blockquote);
        assert_eq!(detect_line_kind("  > indented"), LineKind::Blockquote);
        assert_eq!(detect_line_kind(">"), LineKind::Blockquote);
    }

    #[test]
    fn detect_line_kind_task_item() {
        assert_eq!(detect_line_kind("- [ ] todo"), LineKind::TaskItem(false));
        assert_eq!(detect_line_kind("- [x] done"), LineKind::TaskItem(true));
        assert_eq!(detect_line_kind("- [X] done"), LineKind::TaskItem(true));
        assert_eq!(detect_line_kind("* [ ] todo"), LineKind::TaskItem(false));
        assert_eq!(
            detect_line_kind("  - [x] indented"),
            LineKind::TaskItem(true)
        );
    }

    #[test]
    fn detect_line_kind_bullet_item() {
        assert_eq!(detect_line_kind("- item"), LineKind::BulletItem);
        assert_eq!(detect_line_kind("* item"), LineKind::BulletItem);
        assert_eq!(detect_line_kind("+ item"), LineKind::BulletItem);
        assert_eq!(detect_line_kind("  - nested"), LineKind::BulletItem);
        assert_eq!(detect_line_kind("-nospace"), LineKind::Paragraph);
    }

    #[test]
    fn detect_line_kind_numbered_item() {
        assert_eq!(detect_line_kind("1. first"), LineKind::NumberedItem);
        assert_eq!(detect_line_kind("42. answer"), LineKind::NumberedItem);
        assert_eq!(detect_line_kind("  3. nested"), LineKind::NumberedItem);
        assert_eq!(detect_line_kind("1.nospace"), LineKind::Paragraph);
    }

    #[test]
    fn detect_line_kind_horizontal_rule() {
        assert_eq!(detect_line_kind("---"), LineKind::HorizontalRule);
        assert_eq!(detect_line_kind("___"), LineKind::HorizontalRule);
        assert_eq!(detect_line_kind("***"), LineKind::HorizontalRule);
        assert_eq!(detect_line_kind("--------"), LineKind::HorizontalRule);
        assert_eq!(detect_line_kind("  ---"), LineKind::HorizontalRule);
        assert_eq!(detect_line_kind("---  "), LineKind::HorizontalRule);
        assert_eq!(detect_line_kind("--"), LineKind::Paragraph);
    }

    #[test]
    fn detect_line_kind_paragraph_default() {
        assert_eq!(detect_line_kind("just text"), LineKind::Paragraph);
        assert_eq!(detect_line_kind(""), LineKind::Paragraph);
        assert_eq!(detect_line_kind("   "), LineKind::Paragraph);
    }

    #[test]
    fn detect_line_kind_table_row() {
        assert_eq!(detect_line_kind("| a | b |"), LineKind::TableRow);
        assert_eq!(detect_line_kind("|a|b|c|"), LineKind::TableRow);
        assert_eq!(detect_line_kind("  | a | b |  "), LineKind::TableRow);
        assert_eq!(detect_line_kind("| | |"), LineKind::TableRow);
    }

    #[test]
    fn detect_line_kind_table_separator() {
        assert_eq!(detect_line_kind("|---|---|"), LineKind::TableSeparator);
        assert_eq!(detect_line_kind("| --- | --- |"), LineKind::TableSeparator);
        assert_eq!(
            detect_line_kind("|:---|---:|:---:|---|"),
            LineKind::TableSeparator,
        );
        assert_eq!(detect_line_kind("|:--|--:|:-:|"), LineKind::TableSeparator);
        assert_eq!(detect_line_kind("  |---|---|  "), LineKind::TableSeparator);
    }

    #[test]
    fn detect_line_kind_non_table_pipe_line_stays_paragraph() {
        assert_eq!(detect_line_kind("use `a | b` for or"), LineKind::Paragraph,);
        assert_eq!(detect_line_kind("| trailing"), LineKind::Paragraph);
        assert_eq!(detect_line_kind("no closer |"), LineKind::Paragraph);
    }

    #[test]
    fn detect_line_kind_prefers_separator_over_row() {
        assert_eq!(detect_line_kind("|---|---|"), LineKind::TableSeparator);
        assert_ne!(detect_line_kind("|---|---|"), LineKind::TableRow);
    }

    #[test]
    fn parse_table_row_splits_cells() {
        assert_eq!(parse_table_row("| a | b | c |"), vec!["a", "b", "c"]);
        assert_eq!(parse_table_row("|a|b|c|"), vec!["a", "b", "c"]);
    }

    #[test]
    fn parse_table_row_handles_empty_cells() {
        assert_eq!(parse_table_row("| a | | c |"), vec!["a", "", "c"]);
        assert_eq!(parse_table_row("| | | |"), vec!["", "", ""]);
    }

    #[test]
    fn parse_table_row_trims_whitespace() {
        assert_eq!(
            parse_table_row("  |   foo   |   bar   |  "),
            vec!["foo", "bar"],
        );
    }

    #[test]
    fn parse_alignments_reads_colons() {
        assert_eq!(
            parse_alignments("|:---|---:|:---:|---|"),
            vec![
                CellAlignment::Left,
                CellAlignment::Right,
                CellAlignment::Center,
                CellAlignment::Left,
            ],
        );
    }

    #[test]
    fn parse_alignments_short_dashes() {
        assert_eq!(
            parse_alignments("|:--|--:|:-:|"),
            vec![
                CellAlignment::Left,
                CellAlignment::Right,
                CellAlignment::Center,
            ],
        );
    }

    #[test]
    fn parse_alignments_defaults_to_left() {
        assert_eq!(
            parse_alignments("|---|---|---|"),
            vec![
                CellAlignment::Left,
                CellAlignment::Left,
                CellAlignment::Left,
            ],
        );
    }

    #[test]
    fn colorize_box_chars_wraps_border_runs() {
        let input = "┌─┐\nabc\n└─┘";

        let output = colorize_box_chars(input, Color::Red);

        assert!(
            output.starts_with("\x1b["),
            "border run starts with SGR: {output:?}"
        );
        assert!(output.contains("abc"), "non-border content preserved");
        assert!(output.contains("┌"));
        assert!(output.contains("└"));
    }

    #[test]
    fn colorize_box_chars_leaves_reset_color_untouched() {
        let output = colorize_box_chars("no borders here", Color::Red);

        assert_eq!(output, "no borders here");
    }

    #[test]
    fn render_table_renders_headers_rows_and_borders() {
        let options = RenderOptions::default();
        let render = MarkdownRender::init(options).unwrap();
        let header = vec!["A".into(), "B".into(), "C".into()];
        let alignments = vec![
            CellAlignment::Left,
            CellAlignment::Left,
            CellAlignment::Left,
        ];
        let rows = vec![
            vec!["1".into(), "2".into(), "3".into()],
            vec!["4".into(), "5".into(), "6".into()],
        ];

        let output = render.render_table(header, alignments, rows);

        for expected in ["A", "B", "C", "1", "2", "3", "4", "5", "6"] {
            assert!(
                output.contains(expected),
                "cell {expected:?} in output: {output:?}"
            );
        }
        assert!(
            output.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')),
            "table has box-drawing chars: {output:?}",
        );
    }

    #[test]
    fn render_table_header_uses_bold() {
        let options = RenderOptions::default();
        let render = MarkdownRender::init(options).unwrap();
        let header = vec!["Header".into()];
        let alignments = vec![CellAlignment::Left];

        let output = render.render_table(header, alignments, vec![]);

        assert!(output.contains("\x1b[1m"), "bold SGR present: {output:?}");
        assert!(output.contains("Header"));
    }

    #[test]
    fn render_table_applies_inline_markdown_to_cells() {
        let options = RenderOptions::default();
        let render = MarkdownRender::init(options).unwrap();
        let header = vec!["H".into()];
        let alignments = vec![CellAlignment::Left];
        let rows = vec![vec!["**bold**".into()]];

        let output = render.render_table(header, alignments, rows);

        assert!(
            !output.contains("**bold**"),
            "asterisks stripped from cell: {output:?}",
        );
        assert!(output.contains("bold"));
        assert!(output.contains("\x1b[1m"), "bold SGR present: {output:?}");
    }

    #[test]
    fn render_table_respects_alignment_specifiers() {
        let options = RenderOptions::default();
        let render = MarkdownRender::init(options).unwrap();
        let header = vec!["L".into(), "R".into(), "C".into()];
        let alignments = vec![
            CellAlignment::Left,
            CellAlignment::Right,
            CellAlignment::Center,
        ];
        let rows = vec![vec!["a".into(), "b".into(), "c".into()]];

        let output = render.render_table(header, alignments, rows);

        for expected in ["L", "R", "C", "a", "b", "c"] {
            assert!(output.contains(expected), "cell {expected:?} present");
        }
    }

    #[test]
    fn render_table_handles_wide_chars_and_emoji() {
        let options = RenderOptions::default();
        let render = MarkdownRender::init(options).unwrap();
        let header = vec!["Name".into()];
        let alignments = vec![CellAlignment::Left];
        let rows = vec![vec!["🎉".into()], vec!["日本".into()]];

        let output = render.render_table(header, alignments, rows);

        assert!(output.contains("🎉"));
        assert!(output.contains("日本"));
        assert!(output.lines().count() > 4, "multi-line output: {output:?}");
    }

    #[test]
    fn render_table_borders_wrapped_in_border_color() {
        let options = RenderOptions::default();
        let render = MarkdownRender::init(options).unwrap();
        let header = vec!["a".into()];
        let alignments = vec![CellAlignment::Left];

        let output = render.render_table(header, alignments, vec![vec!["b".into()]]);

        assert!(
            output.starts_with("\x1b["),
            "output starts with border color SGR: {output:?}",
        );
    }

    #[test]
    fn state_machine_renders_full_table_and_flushes_on_paragraph() {
        let options = RenderOptions::default();
        let mut render = MarkdownRender::init(options).unwrap();
        let text = "| A | B |\n|---|---|\n| 1 | 2 |\n\nafter\n";

        let output = render.render(text);

        for cell in ["A", "B", "1", "2"] {
            assert!(output.contains(cell), "cell {cell:?} rendered: {output:?}");
        }
        assert!(
            output.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')),
            "output has box-drawing chars: {output:?}",
        );
        assert!(output.contains("after"), "trailing paragraph preserved");
    }

    #[test]
    fn state_machine_defers_output_until_flush() {
        let options = RenderOptions::default();

        let mut render = MarkdownRender::init(options).unwrap();

        let header = render.render_line_mut("| A | B |");
        assert!(header.is_none(), "header row silently consumed");
        let sep = render.render_line_mut("|---|---|");
        assert!(sep.is_none(), "separator silently consumed");
        let data = render.render_line_mut("| 1 | 2 |");
        assert!(data.is_none(), "data row silently consumed");
    }

    #[test]
    fn finalize_emits_pending_active_table() {
        let options = RenderOptions::default();
        let mut render = MarkdownRender::init(options).unwrap();
        render.render_line_mut("| A | B |");
        render.render_line_mut("|---|---|");
        render.render_line_mut("| 1 | 2 |");

        let tail = render.finalize();

        assert!(tail.contains("A"));
        assert!(tail.contains("1"));
        assert!(tail.contains("2"));
        assert!(tail.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')));
    }

    #[test]
    fn finalize_flushes_pending_header_as_paragraph() {
        let options = RenderOptions::default();
        let mut render = MarkdownRender::init(options).unwrap();
        render.render_line_mut("| A | B |");

        let tail = render.finalize();

        assert!(tail.contains("A"));
        assert!(tail.contains("B"));
        assert!(tail.contains("|"), "raw pipes preserved: {tail:?}");
    }

    #[test]
    fn finalize_is_empty_when_no_pending_table() {
        let options = RenderOptions::default();
        let mut render = MarkdownRender::init(options).unwrap();
        render.render_line_mut("plain text");
        assert!(render.finalize().is_empty());
    }

    #[test]
    fn pipe_row_without_separator_flushes_as_paragraphs() {
        let options = RenderOptions::default();
        let mut render = MarkdownRender::init(options).unwrap();
        let text = "| A | B |\n| C | D |\nafter\n";

        let output = render.render(text);

        assert!(
            output.contains("| A | B |"),
            "raw pipes preserved for first: {output:?}",
        );
        assert!(
            output.contains("| C | D |"),
            "raw pipes preserved for second: {output:?}",
        );
        assert!(output.contains("after"));
    }

    #[test]
    fn multiple_tables_in_one_input() {
        let options = RenderOptions::default();
        let mut render = MarkdownRender::init(options).unwrap();
        let text = "| A |\n|---|\n| 1 |\n\n| B |\n|---|\n| 2 |\n";
        let output = render.render(text);
        let tail = render.finalize();

        let combined = format!("{output}{tail}");

        for cell in ["A", "B", "1", "2"] {
            assert!(combined.contains(cell), "cell {cell:?}: {combined:?}");
        }
    }

    #[test]
    fn render_line_immutable_does_not_mutate_table_state() {
        let options = RenderOptions::default();
        let render = MarkdownRender::init(options).unwrap();

        let _ = render.render_line("| foo | ba");

        assert!(
            render.table_state.is_none(),
            "render_line is immutable; state stays clean",
        );
    }

    #[test]
    fn raw_markdown_mode_bypasses_table_rendering() {
        let options = RenderOptions {
            raw_markdown: true,
            ..Default::default()
        };
        let mut render = MarkdownRender::init(options).unwrap();
        let text = "| A | B |\n|---|---|\n| 1 | 2 |\n";

        let output = render.render(text);

        assert!(
            output.contains("| A | B |"),
            "raw pipes preserved: {output:?}",
        );
        assert!(render.table_state.is_none(), "no state entered in raw mode",);
    }

    #[test]
    fn bullet_wraps_with_two_space_hanging_indent() {
        let styles = test_styles();
        let line = "- text that is long enough to wrap onto continuation lines";

        let output = render_markdown_line(line, LineKind::BulletItem, &styles, Some(20));

        assert!(output.contains('\n'), "wrapped output: {output:?}");
        let lines: Vec<&str> = output.split('\n').collect();
        assert!(lines.len() >= 2);
        for cont in &lines[1..] {
            assert!(
                cont.starts_with("  ") && !cont.starts_with("  •"),
                "continuation has 2-space indent (no bullet): {cont:?}",
            );
        }
    }

    #[test]
    fn numbered_wraps_with_digit_width_hanging_indent() {
        let styles = test_styles();
        let line = "42. text that is long enough to wrap onto continuation lines";

        let output = render_markdown_line(line, LineKind::NumberedItem, &styles, Some(22));

        assert!(output.contains('\n'), "wrapped output: {output:?}");
        let lines: Vec<&str> = output.split('\n').collect();
        for cont in &lines[1..] {
            assert!(
                cont.starts_with("    "),
                "4-space indent for `42. `: {cont:?}"
            );
        }
    }

    #[test]
    fn numbered_wraps_with_three_digit_hanging_indent() {
        let styles = test_styles();
        let line = "100. text that is long enough to wrap onto continuation lines";

        let output = render_markdown_line(line, LineKind::NumberedItem, &styles, Some(22));

        assert!(output.contains('\n'));
        let lines: Vec<&str> = output.split('\n').collect();
        for cont in &lines[1..] {
            assert!(
                cont.starts_with("     "),
                "5-space indent for `100. `: {cont:?}",
            );
        }
    }

    #[test]
    fn task_wraps_with_four_space_hanging_indent() {
        let styles = test_styles();
        let line = "- [ ] task text that is long enough to wrap around";

        let output = render_markdown_line(line, LineKind::TaskItem(false), &styles, Some(22));

        assert!(output.contains('\n'), "wrapped output: {output:?}");
        let lines: Vec<&str> = output.split('\n').collect();
        for cont in &lines[1..] {
            assert!(
                cont.starts_with("    "),
                "4-space indent for `[ ] `: {cont:?}"
            );
        }
    }

    #[test]
    fn blockquote_wraps_with_pipe_prefix_on_every_line() {
        let styles = test_styles();
        let line = "> quoted text that is long enough to wrap onto multiple continuation lines";
        let output = render_markdown_line(line, LineKind::Blockquote, &styles, Some(24));

        assert!(output.contains('\n'), "wrapped output: {output:?}");

        for wrapped in output.split('\n') {
            assert!(
                wrapped.contains("│ "),
                "pipe prefix present on line: {wrapped:?}",
            );
        }
    }

    #[test]
    fn bullet_preserves_leading_indent_when_wrapping() {
        let styles = test_styles();
        let line = "  - nested bullet text that wraps around a few times";

        let output = render_markdown_line(line, LineKind::BulletItem, &styles, Some(22));

        assert!(output.contains('\n'), "wrapped output: {output:?}");
        for wrapped in output.split('\n') {
            assert!(
                wrapped.starts_with("  "),
                "leading indent preserved: {wrapped:?}",
            );
        }
    }

    #[test]
    fn block_renderers_produce_single_line_when_wrap_width_none() {
        let styles = test_styles();
        let long_line =
            "- very long bullet text that would definitely wrap if a narrow wrap_width were set";

        let output = render_markdown_line(long_line, LineKind::BulletItem, &styles, None);

        assert!(!output.contains('\n'), "no wrapping with None: {output:?}");
    }

    #[test]
    fn bullet_wraps_with_inline_markdown_intact() {
        let styles = test_styles();
        let line = "- **bold** text with `code` that will wrap onto several lines";

        let output = render_markdown_line(line, LineKind::BulletItem, &styles, Some(22));

        assert!(output.contains('\n'), "wrapped output: {output:?}");
        assert!(
            !output.contains("**bold**"),
            "asterisks stripped: {output:?}"
        );
        assert!(!output.contains("`code`"), "backticks stripped: {output:?}");
        assert!(output.contains("bold"));
        assert!(output.contains("code"));
    }

    #[test]
    fn bullet_wraps_bold_span_across_boundary() {
        let styles = test_styles();
        let line = "- **explain some code, `value`, using _this_**";

        let output = render_markdown_line(line, LineKind::BulletItem, &styles, Some(30));

        assert!(output.contains('\n'), "must wrap: {output:?}");
        assert!(
            !output.contains("**"),
            "bold markers stripped even when span crosses wrap: {output:?}"
        );
        assert!(output.contains("\x1b[1m"), "bold SGR present: {output:?}");
        assert!(output.contains("explain"));
        assert!(output.contains("this"));
    }

    #[test]
    fn blockquote_wraps_bold_span_across_boundary() {
        let styles = test_styles();
        let line = "> **explain some code, `value`, using _this_**";

        let output = render_markdown_line(line, LineKind::Blockquote, &styles, Some(30));

        assert!(output.contains('\n'), "must wrap: {output:?}");
        assert!(!output.contains("**"), "bold markers stripped: {output:?}");
        assert!(output.contains("\x1b[1m"), "bold SGR: {output:?}");
    }

    #[test]
    fn blockquote_restores_base_color_after_inner_reset() {
        let styles = test_styles();
        let line = "> hello **bold** world after `code` more text";

        let output = render_markdown_line(line, LineKind::Blockquote, &styles, None);

        let base_open = SetForegroundColor(styles.blockquote).to_string();
        let re_opens = output.matches(base_open.as_str()).count();
        assert!(
            re_opens >= 3,
            "blockquote color re-opened after each inner reset (got {re_opens}): {output:?}"
        );
    }

    #[test]
    fn heading_restores_base_style_after_inner_reset() {
        let styles = test_styles();
        let line = "## title with **bold** and `code` trailing text";

        let output = render_markdown_line(line, LineKind::Heading(2), &styles, None);

        let (heading_color, _) = styles.heading;
        let base_open = format!("{}\x1b[1m", SetForegroundColor(heading_color));
        let re_opens = output.matches(base_open.as_str()).count();
        assert!(
            re_opens >= 3,
            "heading color+bold re-opened after each inner reset (got {re_opens}): {output:?}"
        );
    }

    #[test]
    fn table_header_restores_base_style_after_inner_reset() {
        let options = RenderOptions::default();
        let mut render = MarkdownRender::init(options).unwrap();
        let table = "| col with **bold** inside | second |\n|---|---|\n| a | b |\n";
        let output = render.render(table);

        let header_row = output
            .lines()
            .find(|l| l.contains("col with"))
            .expect("header row present");
        let bold_opens = header_row.matches("\x1b[1m").count();
        assert!(
            bold_opens >= 3,
            "table header bold re-opened after inner reset (got {bold_opens}): {header_row:?}"
        );
    }

    #[test]
    fn numbered_wraps_bold_span_across_boundary() {
        let styles = test_styles();
        let line = "1. **explain some code, `value`, using _this_**";

        let output = render_markdown_line(line, LineKind::NumberedItem, &styles, Some(30));

        assert!(output.contains('\n'), "must wrap: {output:?}");
        assert!(!output.contains("**"), "bold markers stripped: {output:?}");
        assert!(output.contains("\x1b[1m"), "bold SGR: {output:?}");
    }

    #[test]
    fn task_wraps_bold_span_across_boundary() {
        let styles = test_styles();
        let line = "- [ ] **explain some code, `value`, using _this_**";

        let output = render_markdown_line(line, LineKind::TaskItem(false), &styles, Some(32));

        assert!(output.contains('\n'), "must wrap: {output:?}");
        assert!(!output.contains("**"), "bold markers stripped: {output:?}");
        assert!(output.contains("\x1b[1m"), "bold SGR: {output:?}");
    }

    #[test]
    fn wrapped_bold_line_closes_and_reopens_ansi_across_lines() {
        let styles = test_styles();
        let line = "- **one two three four five six seven eight nine**";

        let output = render_markdown_line(line, LineKind::BulletItem, &styles, Some(20));

        assert!(output.contains('\n'), "must wrap: {output:?}");
        assert!(!output.contains("**"), "bold markers stripped: {output:?}");
        for wrapped_line in output.split('\n') {
            if wrapped_line.contains("\x1b[1m") {
                assert!(
                    wrapped_line.contains("\x1b[0m"),
                    "each bold line closes with reset: {wrapped_line:?}"
                );
            }
        }
    }

    #[test]
    fn mixed_content_renders_all_kinds() {
        let options = RenderOptions::default();
        let mut render = MarkdownRender::init(options).unwrap();
        let text = "# Heading\n\n\
                    Some paragraph.\n\n\
                    - bullet one\n\
                    - bullet two\n\n\
                    > a quote\n\n\
                    | A | B |\n\
                    |---|---|\n\
                    | 1 | 2 |\n\n\
                    Trailing prose.\n";
        let body = render.render(text);
        let tail = render.finalize();

        let output = format!("{body}{tail}");

        assert!(output.contains("Heading"), "heading rendered");
        assert!(output.contains("Some paragraph."));
        assert!(output.contains("•"), "bullet glyph rendered");
        assert!(output.contains("│"), "blockquote pipe rendered");
        assert!(
            output.contains("A") && output.contains("1"),
            "table cells rendered"
        );
        assert!(
            output.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')),
            "table borders rendered",
        );
        assert!(output.contains("Trailing prose."));
    }

    #[test]
    fn table_renders_without_theme() {
        let options = RenderOptions {
            theme: None,
            ..Default::default()
        };
        let render = MarkdownRender::init(options).unwrap();
        let header = vec!["A".into()];
        let alignments = vec![CellAlignment::Left];

        let output = render.render_table(header, alignments, vec![vec!["1".into()]]);

        assert!(output.contains("A"));
        assert!(output.contains("1"));
        assert!(
            output.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')),
            "borders present without theme: {output:?}",
        );
    }

    #[test]
    fn table_borders_pick_up_theme_color() {
        let theme = minimal_root_scope_theme();
        let styles = MarkdownStyles::from_theme(Some(&theme), true);
        assert_eq!(styles.table_border, rgb(0x77, 0x77, 0x77));

        let options = RenderOptions {
            theme: Some(theme),
            ..Default::default()
        };
        let render = MarkdownRender::init(options).unwrap();
        let header = vec!["A".into()];
        let alignments = vec![CellAlignment::Left];
        let output = render.render_table(header, alignments, vec![vec!["1".into()]]);
        assert!(
            output.starts_with("\x1b["),
            "border color SGR at start: {output:?}",
        );
    }

    #[test]
    fn table_render_does_not_emit_blank_lines_from_silent_accumulation() {
        let options = RenderOptions::default();
        let mut render = MarkdownRender::init(options).unwrap();
        let text = "## Head\n\n\
                    | A | B |\n\
                    |---|---|\n\
                    | 1 | 2 |\n\
                    | 3 | 4 |\n\
                    | 5 | 6 |\n\
                    | 7 | 8 |\n\
                    | 9 | 10 |\n\n\
                    trailing\n";
        let output = render.render(text);
        let tail = render.finalize();
        let combined = format!("{output}{tail}");
        let mut consecutive_blank = 0usize;
        let mut max_consecutive_blank = 0usize;
        for line in combined.split('\n') {
            if line.is_empty() {
                consecutive_blank += 1;
                max_consecutive_blank = max_consecutive_blank.max(consecutive_blank);
            } else {
                consecutive_blank = 0;
            }
        }
        assert!(
            max_consecutive_blank <= 1,
            "at most one blank line between blocks, got {max_consecutive_blank}: {combined:?}",
        );
    }

    #[test]
    fn table_tolerates_column_count_mismatch() {
        let options = RenderOptions::default();
        let render = MarkdownRender::init(options).unwrap();
        let header = vec!["A".into(), "B".into(), "C".into()];
        let alignments = vec![
            CellAlignment::Left,
            CellAlignment::Left,
            CellAlignment::Left,
        ];
        let rows = vec![vec!["1".into(), "2".into()]];

        let output = render.render_table(header, alignments, rows);

        for cell in ["A", "B", "C", "1", "2"] {
            assert!(output.contains(cell), "cell {cell:?} present: {output:?}");
        }
    }

    fn test_styles() -> MarkdownStyles {
        MarkdownStyles {
            heading: (Color::Yellow, true),
            bold: Color::Red,
            italic: Color::Green,
            inline_code_fg: Color::Cyan,
            inline_code_bg: None,
            blockquote: Color::DarkGrey,
            list_bullet: Color::Reset,
            link_text: Color::Blue,
            link_url: Color::Magenta,
            strikethrough: Color::DarkGrey,
            hrule: Color::DarkGrey,
            table_border: Color::DarkGrey,
        }
    }

    #[test]
    fn inline_code_strips_backticks() {
        let styles = test_styles();

        let result = apply_inline("hello `world` foo", &styles);

        assert!(!result.contains('`'), "backticks stripped: {result:?}");
        assert!(result.contains("world"));
        assert!(result.contains("hello "));
        assert!(result.contains(" foo"));
    }

    #[test]
    fn bold_asterisk_applied() {
        let styles = test_styles();

        let result = apply_inline("**loud**", &styles);

        assert!(!result.contains("**"), "markers stripped: {result:?}");
        assert!(result.contains("loud"));
        assert!(result.contains("\x1b[1m"), "bold SGR present: {result:?}");
    }

    #[test]
    fn bold_underscore_applied() {
        let styles = test_styles();

        let result = apply_inline("__loud__", &styles);

        assert!(!result.contains("__"), "markers stripped: {result:?}");
        assert!(result.contains("loud"));
        assert!(result.contains("\x1b[1m"));
    }

    #[test]
    fn italic_asterisk_applied() {
        let styles = test_styles();

        let result = apply_inline("*soft*", &styles);

        assert!(result.contains("soft"));
        assert!(result.contains("\x1b[3m"), "italic SGR present: {result:?}");
    }

    #[test]
    fn italic_underscore_applied() {
        let styles = test_styles();

        let result = apply_inline("_soft_", &styles);

        assert!(result.contains("soft"));
        assert!(result.contains("\x1b[3m"));
    }

    #[test]
    fn strikethrough_applied() {
        let styles = test_styles();

        let result = apply_inline("~~gone~~", &styles);

        assert!(!result.contains("~~"), "markers stripped");
        assert!(result.contains("gone"));
        assert!(result.contains("\x1b[9m"), "strikethrough SGR: {result:?}");
    }

    #[test]
    fn bold_wraps_inline_code() {
        let styles = test_styles();

        let result = apply_inline("**foo `bar` baz**", &styles);

        assert!(!result.contains('`'));
        assert!(!result.contains("**"));
        assert!(result.contains("foo"));
        assert!(result.contains("bar"));
        assert!(result.contains("baz"));
        assert!(
            result.contains("\x1b[1m"),
            "bold applied around code: {result:?}"
        );
    }

    #[test]
    fn partial_bold_stays_raw() {
        let styles = test_styles();

        let result = apply_inline("**unclosed", &styles);

        assert_eq!(result, "**unclosed");
    }

    #[test]
    fn partial_italic_stays_raw() {
        let styles = test_styles();

        assert_eq!(apply_inline("*unclosed", &styles), "*unclosed");
        assert_eq!(apply_inline("_unclosed", &styles), "_unclosed");
    }

    #[test]
    fn italic_ignores_word_internal_underscores() {
        let styles = test_styles();

        assert_eq!(apply_inline("some_var_name", &styles), "some_var_name");
    }

    #[test]
    fn italic_ignores_math_like_spaces() {
        let styles = test_styles();

        assert_eq!(apply_inline("a * b * c", &styles), "a * b * c");
    }

    #[test]
    fn links_emit_osc8_and_visible_parts() {
        let styles = test_styles();

        let result = apply_inline("[label](https://example.com)", &styles);

        assert!(
            result.contains("\x1b]8;;https://example.com\x1b\\"),
            "OSC 8 open present: {result:?}"
        );
        assert!(result.contains("\x1b]8;;\x1b\\"), "OSC 8 close present");
        assert!(result.contains("label"));
        assert!(result.contains("https://example.com"));
    }

    #[test]
    fn images_emit_labeled_link() {
        let styles = test_styles();

        let result = apply_inline("![alt text](https://img.example/x.png)", &styles);

        assert!(
            !result.contains("!["),
            "raw image marker removed: {result:?}"
        );
        assert!(result.contains("Image: alt text"));
        assert!(result.contains("https://img.example/x.png"));
        assert!(result.contains("\x1b]8;;https://img.example/x.png\x1b\\"));
    }

    #[test]
    fn image_processed_before_link() {
        let styles = test_styles();

        let result = apply_inline("![alt](https://example.com)", &styles);

        assert!(
            !result.starts_with('!'),
            "no stray ! left behind: {result:?}"
        );
        assert!(result.contains("Image:"));
    }

    #[test]
    fn plain_text_unchanged() {
        let styles = test_styles();

        assert_eq!(apply_inline("just plain text", &styles), "just plain text");
    }

    #[test]
    fn render_heading_level_1_pads_content() {
        let styles = test_styles();

        let result = render_markdown_line("# Big", LineKind::Heading(1), &styles, None);

        assert!(result.contains(" Big "), "H1 padded content: {result:?}");
        assert!(!result.contains('#'), "H1 hashes removed: {result:?}");
        assert!(result.contains("\x1b[1m"), "bold applied: {result:?}");
    }

    #[test]
    fn render_heading_level_2_through_6_keeps_hash_prefix() {
        let styles = test_styles();

        for level in 2u8..=6 {
            let hashes = "#".repeat(level as usize);
            let line = format!("{hashes} Title");
            let result = render_markdown_line(&line, LineKind::Heading(level), &styles, None);
            assert!(
                result.contains(&hashes),
                "H{level} keeps hashes: {result:?}"
            );
            assert!(result.contains("Title"));
            assert!(result.contains("\x1b[1m"), "H{level} bold: {result:?}");
        }
    }

    #[test]
    fn render_heading_preserves_leading_indent() {
        let styles = test_styles();

        let result = render_markdown_line("  ## Nested", LineKind::Heading(2), &styles, None);

        assert!(result.starts_with("  "), "indent preserved: {result:?}");
    }

    #[test]
    fn render_blockquote_uses_pipe_prefix() {
        let styles = test_styles();

        let result = render_markdown_line("> quoted", LineKind::Blockquote, &styles, None);

        assert!(result.contains("│ "), "pipe prefix: {result:?}");
        assert!(!result.contains('>'), "gt removed: {result:?}");
        assert!(result.contains("quoted"));
    }

    #[test]
    fn render_blockquote_preserves_indent() {
        let styles = test_styles();

        let result = render_markdown_line("  > deep", LineKind::Blockquote, &styles, None);

        assert!(result.starts_with("  "));
        assert!(result.contains("│ "));
    }

    #[test]
    fn render_bullet_uses_bullet_char() {
        let styles = test_styles();

        let result = render_markdown_line("- item", LineKind::BulletItem, &styles, None);

        assert!(result.contains("•"), "bullet glyph: {result:?}");
        assert!(!result.contains("- "), "dash removed: {result:?}");
        assert!(result.contains("item"));
    }

    #[test]
    fn render_bullet_supports_star_and_plus() {
        let styles = test_styles();

        let star = render_markdown_line("* one", LineKind::BulletItem, &styles, None);
        let plus = render_markdown_line("+ two", LineKind::BulletItem, &styles, None);

        assert!(star.contains("•") && star.contains("one"));
        assert!(plus.contains("•") && plus.contains("two"));
    }

    #[test]
    fn render_bullet_preserves_nested_indent() {
        let styles = test_styles();

        let result = render_markdown_line("    - nested", LineKind::BulletItem, &styles, None);

        assert!(result.starts_with("    "), "indent kept: {result:?}");
        assert!(result.contains("•"));
    }

    #[test]
    fn render_numbered_preserves_number_and_styles_dot() {
        let styles = test_styles();

        let result = render_markdown_line("42. answer", LineKind::NumberedItem, &styles, None);

        assert!(result.contains("42"), "number kept: {result:?}");
        assert!(result.contains("answer"));
        assert!(result.contains('.'), "dot present");
    }

    #[test]
    fn render_task_unchecked() {
        let styles = test_styles();

        let result = render_markdown_line("- [ ] todo", LineKind::TaskItem(false), &styles, None);

        assert!(result.contains("[ ]"), "unchecked glyph: {result:?}");
        assert!(!result.contains("- "), "no dash prefix: {result:?}");
        assert!(result.contains("todo"));
    }

    #[test]
    fn render_task_checked_uses_check_glyph() {
        let styles = test_styles();

        let result = render_markdown_line("- [x] done", LineKind::TaskItem(true), &styles, None);

        assert!(result.contains("[✓]"), "checked glyph: {result:?}");
        assert!(!result.contains("[x]"), "raw x removed: {result:?}");
        assert!(result.contains("done"));
    }

    #[test]
    fn render_hrule_emits_box_drawing() {
        let styles = test_styles();

        let result = render_markdown_line("---", LineKind::HorizontalRule, &styles, None);

        assert!(result.contains("────"), "box drawing chars: {result:?}");
        assert!(!result.contains("---"), "raw dashes removed: {result:?}");
    }

    #[test]
    fn render_paragraph_delegates_to_inline() {
        let styles = test_styles();

        let result = render_markdown_line("hello **world**", LineKind::Paragraph, &styles, None);

        assert!(!result.contains("**"), "bold markers stripped: {result:?}");
        assert!(result.contains("world"));
        assert!(
            result.contains("\x1b[1m"),
            "bold applied via inline: {result:?}"
        );
    }

    #[test]
    fn render_bullet_runs_inline_on_content() {
        let styles = test_styles();

        let result = render_markdown_line("- see `code`", LineKind::BulletItem, &styles, None);

        assert!(result.contains("•"));
        assert!(!result.contains('`'), "backticks stripped: {result:?}");
        assert!(result.contains("see "));
        assert!(result.contains("code"));
    }

    #[test]
    fn render_blockquote_runs_inline_on_content() {
        let styles = test_styles();

        let result = render_markdown_line(
            "> visit [here](https://example.com)",
            LineKind::Blockquote,
            &styles,
            None,
        );

        assert!(result.contains("│ "));
        assert!(result.contains("here"));
        assert!(result.contains("https://example.com"));
        assert!(result.contains("\x1b]8;;https://example.com\x1b\\"));
    }

    #[test]
    fn heading_can_contain_bold_inline() {
        let styles = test_styles();

        let result =
            render_markdown_line("## Announce **now**", LineKind::Heading(2), &styles, None);

        assert!(!result.contains("**"), "bold markers stripped: {result:?}");
        assert!(result.contains("now"));
        assert!(result.contains("Announce"));
        assert!(result.contains("\x1b[1m"), "bold present: {result:?}");
    }

    #[test]
    fn streaming_partial_bold_stays_raw() {
        let options = RenderOptions::default();
        let render = MarkdownRender::init(options).unwrap();

        let partial = render.render_line("**bo");

        assert!(
            partial.contains("**bo"),
            "unclosed bold preserved: {partial:?}"
        );
    }

    #[test]
    fn streaming_partial_link_stays_raw() {
        let options = RenderOptions::default();
        let render = MarkdownRender::init(options).unwrap();

        let partial = render.render_line("[label](https://exa");

        assert!(
            partial.contains("[label]"),
            "unclosed link preserved: {partial:?}"
        );
    }

    #[test]
    fn rich_render_strips_syntax_without_theme() {
        let options = RenderOptions::default();
        let mut render = MarkdownRender::init(options).unwrap();

        let output = render.render("# Heading\n\n> quoted\n\n- item\n");

        assert!(!output.contains("# Heading"), "hash removed: {output:?}");
        assert!(output.contains("Heading"));
        assert!(!output.contains("> quoted"), "gt removed");
        assert!(output.contains("│ "), "pipe prefix present");
        assert!(!output.contains("- item"), "dash removed");
        assert!(output.contains("•"), "bullet glyph present");
        assert!(output.contains("item"));
    }

    #[test]
    fn rich_and_raw_paths_diverge() {
        let raw_opts = RenderOptions {
            raw_markdown: true,
            ..Default::default()
        };
        let rich_opts = RenderOptions::default();
        let mut raw_render = MarkdownRender::init(raw_opts).unwrap();
        let mut rich_render = MarkdownRender::init(rich_opts).unwrap();
        let text = "# Heading\n\n**bold** text\n";

        let raw = raw_render.render(text);
        let rich = rich_render.render(text);

        assert_eq!(raw, text, "raw path preserves input");
        assert_ne!(rich, text, "rich path transforms input");
    }

    #[test]
    fn code_block_content_still_routes_to_syntect() {
        let options = RenderOptions::default();
        let mut render = MarkdownRender::init(options).unwrap();
        let text = "```rust\nfn main() {}\n```\n";

        let output = render.render(text);

        assert!(
            output.contains("fn main()"),
            "code content preserved: {output:?}"
        );
    }

    #[test]
    fn markdown_styles_fall_back_to_root_scopes() {
        let theme = minimal_root_scope_theme();

        let styles = MarkdownStyles::from_theme(Some(&theme), true);

        assert_eq!(styles.heading.0, rgb(0xde, 0xad, 0xbe));
        assert!(styles.heading.1);
        assert_eq!(styles.bold, rgb(0x44, 0x55, 0x66));
        assert_eq!(styles.italic, rgb(0x88, 0x88, 0x88));
        assert_eq!(styles.inline_code_fg, rgb(0xff, 0xdd, 0x00));
        assert_eq!(styles.inline_code_bg, None);
        assert_eq!(styles.blockquote, rgb(0x88, 0x88, 0x88));
        assert_eq!(styles.list_bullet, rgb(0xaa, 0x00, 0xff));
        assert_eq!(styles.link_text, rgb(0x11, 0x22, 0x33));
        assert_eq!(styles.link_url, rgb(0x00, 0xcc, 0xff));
        assert_eq!(styles.strikethrough, rgb(0xff, 0x00, 0x00));
        assert_eq!(styles.hrule, rgb(0x88, 0x88, 0x88));
        assert_eq!(styles.table_border, rgb(0x77, 0x77, 0x77));
    }
}
