use std::collections::HashSet;
use std::ops::Range;
use std::time::Instant;

use log::info;
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};

use super::content_index::{BlockMapping, ContentIndex, SpanKind, TextSpan};
use super::markup_util::{
    escape_typst, escape_typst_string_literal, typst_image, typst_image_placeholder,
};

/// Convert a LaTeX math expression to Typst math markup using mitex.
/// Returns the original LaTeX string (prefixed with indicators for debugging)
/// if conversion fails.
fn latex_to_typst_math(latex: &str) -> String {
    match mitex::convert_math(latex, None) {
        Ok(typst_math) => typst_math,
        Err(_) => {
            // Fallback: emit as-is in a Typst math block so it's visible
            // (though likely broken). This avoids silently dropping content.
            latex.to_string()
        }
    }
}

/// State tracking for nested containers during conversion.
#[derive(Debug)]
enum Container {
    Heading,
    Strong,
    Emphasis,
    Strikethrough,
    Link { _url: String },
    Image { path: String },
    BlockQuote,
    BlockQuoteCapped,
    List { ordered: bool },
    Item,
    CodeBlock,
    Table { _col_count: usize },
    TableHead,
    TableRow,
    TableCell,
}

const MAX_BLOCKQUOTE_DEPTH: usize = 10;

/// Information collected from a lightweight pre-scan of the Markdown source.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Prescan {
    /// Image paths referenced in the document (deduplicated).
    pub image_paths: Vec<String>,
    /// Whether the document contains any CJK characters.
    pub has_cjk: bool,
}

/// Check if a character is CJK (Chinese, Japanese, Korean).
fn is_cjk(c: char) -> bool {
    matches!(c,
        '\u{3000}'..='\u{9FFF}'   // CJK Unified + Hiragana, Katakana, symbols
        | '\u{F900}'..='\u{FAFF}' // CJK Compatibility Ideographs
        | '\u{FF00}'..='\u{FFEF}' // Halfwidth/Fullwidth Forms
    )
}

/// Pre-scan Markdown source to collect metadata without full conversion.
///
/// Collects image paths and detects CJK content in a single pass.
pub fn prescan(markdown: &str) -> Prescan {
    let mut options = Options::empty();
    options.insert(Options::ENABLE_TABLES);
    options.insert(Options::ENABLE_STRIKETHROUGH);
    options.insert(Options::ENABLE_MATH);
    let parser = Parser::new_ext(markdown, options);

    let mut paths = Vec::new();
    let mut seen = HashSet::new();
    let mut has_cjk = false;
    for event in parser {
        match event {
            Event::Start(Tag::Image { dest_url, .. }) => {
                let url = dest_url.to_string();
                if !url.is_empty() && seen.insert(url.clone()) {
                    paths.push(url);
                }
            }
            Event::Html(html) | Event::InlineHtml(html) => {
                if !has_cjk && html.chars().any(is_cjk) {
                    has_cjk = true;
                }
                for src in super::markup_html::extract_img_srcs(&html) {
                    if seen.insert(src.clone()) {
                        paths.push(src);
                    }
                }
            }
            Event::Text(text) | Event::Code(text) if !has_cjk && text.chars().any(is_cjk) => {
                has_cjk = true;
            }
            _ => {}
        }
    }
    Prescan {
        image_paths: paths,
        has_cjk,
    }
}

/// Convert Markdown text to Typst markup.
///
/// `available_images` is the set of image paths that were successfully loaded.
/// When `Some`, images in the set produce `#image()` calls; others produce a
/// placeholder block. When `None`, all images produce placeholders.
///
/// Returns `(typst_markup, ContentIndex)`.
pub fn markdown_to_typst(
    markdown: &str,
    available_images: Option<&HashSet<String>>,
) -> (String, ContentIndex) {
    let start = Instant::now();
    let mut options = Options::empty();
    options.insert(Options::ENABLE_TABLES);
    options.insert(Options::ENABLE_STRIKETHROUGH);
    options.insert(Options::ENABLE_MATH);
    let parser = Parser::new_ext(markdown, options);

    let mut output = String::new();
    let mut stack: Vec<Container> = Vec::new();
    let mut in_code_block = false;
    // Buffer for collecting code block content (deferred fence writing)
    let mut code_block_buf = String::new();
    let mut code_block_lang = String::new();
    // Buffer for collecting table cell content
    let mut cell_buf: Option<String> = None;
    // Collected cells for current table
    let mut table_cells: Vec<String> = Vec::new();
    let mut table_col_count: usize = 0;

    // Source mapping: track top-level block boundaries
    let mut source_map_blocks: Vec<BlockMapping> = Vec::new();
    // Stack of (typst_start, md_range) for open top-level blocks
    let mut block_starts: Vec<(usize, Range<usize>)> = Vec::new();
    // Depth counter for block-level nesting (only record at depth 0)
    let mut block_depth: usize = 0;

    // ContentIndex collectors
    let mut text_spans: Vec<TextSpan> = Vec::new();
    // Deferred code spans: (md_range, buf_start..buf_end) within code_block_buf
    let mut code_spans_pending: Vec<(Range<usize>, Range<usize>)> = Vec::new();
    // Deferred cell spans: (md_range, buf_offset_range, SpanKind) within current cell_buf
    type PendingSpan = (Range<usize>, Range<usize>, SpanKind);
    let mut cell_spans_pending: Vec<PendingSpan> = Vec::new();
    // Collected cell spans for all cells in current table (parallel to table_cells)
    let mut table_cell_spans: Vec<Vec<PendingSpan>> = Vec::new();

    for (event, md_range) in parser.into_offset_iter() {
        match event {
            // === Block-level Start tags ===
            Event::Start(Tag::Paragraph) => {
                if block_depth == 0 {
                    block_starts.push((output.len(), md_range));
                }
                block_depth += 1;
                let in_item = stack.iter().any(|c| matches!(c, Container::Item));
                if in_item {
                    // Inside a list item (loose list): first paragraph follows
                    // the marker directly; subsequent paragraphs get blank line
                    // + indent to stay within the item.
                    if !output.ends_with("- ") && !output.ends_with("+ ") {
                        if !output.ends_with('\n') {
                            output.push('\n');
                        }
                        output.push('\n');
                        let list_depth = stack
                            .iter()
                            .filter(|c| matches!(c, Container::List { .. }))
                            .count();
                        let indent = list_depth.saturating_sub(1) * 2 + 2;
                        for _ in 0..indent {
                            output.push(' ');
                        }
                    }
                } else {
                    if !output.is_empty() && !output.ends_with('\n') {
                        output.push('\n');
                    }
                    if !output.is_empty() {
                        output.push('\n');
                    }
                }
            }
            Event::Start(Tag::Heading { level, .. }) => {
                if block_depth == 0 {
                    block_starts.push((output.len(), md_range));
                }
                block_depth += 1;
                if !output.is_empty() && !output.ends_with('\n') {
                    output.push('\n');
                }
                if !output.is_empty() {
                    output.push('\n');
                }
                let prefix = "=".repeat(level as usize);
                output.push_str(&prefix);
                output.push(' ');
                stack.push(Container::Heading);
            }
            Event::Start(Tag::BlockQuote(_)) => {
                if block_depth == 0 {
                    block_starts.push((output.len(), md_range));
                }
                block_depth += 1;
                if !output.is_empty() && !output.ends_with('\n') {
                    output.push('\n');
                }
                if !output.is_empty() {
                    output.push('\n');
                }
                let bq_depth = stack
                    .iter()
                    .filter(|c| matches!(c, Container::BlockQuote | Container::BlockQuoteCapped))
                    .count();
                if bq_depth < MAX_BLOCKQUOTE_DEPTH {
                    output.push_str("#quote(block: true)[");
                    stack.push(Container::BlockQuote);
                } else {
                    stack.push(Container::BlockQuoteCapped);
                }
            }
            Event::Start(Tag::CodeBlock(kind)) => {
                if block_depth == 0 {
                    block_starts.push((output.len(), md_range));
                }
                block_depth += 1;
                if !output.is_empty() && !output.ends_with('\n') {
                    output.push('\n');
                }
                if !output.is_empty() {
                    output.push('\n');
                }
                in_code_block = true;
                code_block_lang = match &kind {
                    CodeBlockKind::Fenced(lang) if !lang.is_empty() => lang.to_string(),
                    _ => String::new(),
                };
                code_block_buf.clear();
                stack.push(Container::CodeBlock);
            }
            Event::Start(Tag::List(start)) => {
                if block_depth == 0 {
                    block_starts.push((output.len(), md_range));
                }
                block_depth += 1;
                let is_nested = stack.iter().any(|c| matches!(c, Container::List { .. }));
                if is_nested {
                    // Nested list: newline only (no blank line)
                    if !output.is_empty() && !output.ends_with('\n') {
                        output.push('\n');
                    }
                } else {
                    // Top-level list: blank line before
                    if !output.is_empty() && !output.ends_with('\n') {
                        output.push('\n');
                    }
                    if !output.is_empty() {
                        output.push('\n');
                    }
                }
                stack.push(Container::List {
                    ordered: start.is_some(),
                });
            }
            Event::Start(Tag::Item) => {
                if !output.is_empty() && !output.ends_with('\n') {
                    output.push('\n');
                }
                // Indent based on list nesting depth (0-indexed, capped at 7)
                let depth = stack
                    .iter()
                    .filter(|c| matches!(c, Container::List { .. }))
                    .count()
                    .saturating_sub(1)
                    .min(7);
                for _ in 0..depth {
                    output.push_str("  ");
                }
                // Determine marker from parent list
                let marker = stack
                    .iter()
                    .rev()
                    .find_map(|c| match c {
                        Container::List { ordered: true } => Some("+ "),
                        Container::List { ordered: false } => Some("- "),
                        _ => None,
                    })
                    .unwrap_or("- ");
                output.push_str(marker);
                stack.push(Container::Item);
            }
            Event::Start(Tag::Table(alignments)) => {
                if block_depth == 0 {
                    block_starts.push((output.len(), md_range));
                }
                block_depth += 1;
                if !output.is_empty() && !output.ends_with('\n') {
                    output.push('\n');
                }
                if !output.is_empty() {
                    output.push('\n');
                }
                table_col_count = alignments.len();
                table_cells.clear();
                stack.push(Container::Table {
                    _col_count: table_col_count,
                });
            }
            Event::Start(Tag::TableHead) => {
                stack.push(Container::TableHead);
            }
            Event::Start(Tag::TableRow) => {
                stack.push(Container::TableRow);
            }
            Event::Start(Tag::TableCell) => {
                cell_buf = Some(String::new());
                stack.push(Container::TableCell);
            }

            // === Inline Start tags ===
            Event::Start(Tag::Strong) => {
                push_to_target(&mut output, &mut cell_buf, "#strong[");
                stack.push(Container::Strong);
            }
            Event::Start(Tag::Emphasis) => {
                push_to_target(&mut output, &mut cell_buf, "#emph[");
                stack.push(Container::Emphasis);
            }
            Event::Start(Tag::Strikethrough) => {
                push_to_target(&mut output, &mut cell_buf, "#strike[");
                stack.push(Container::Strikethrough);
            }
            Event::Start(Tag::Link { dest_url, .. }) => {
                let url = dest_url.to_string();
                if !url.is_empty() {
                    let escaped_url = escape_typst_string_literal(&url);
                    push_to_target(
                        &mut output,
                        &mut cell_buf,
                        &format!("#link(\"{escaped_url}\")["),
                    );
                }
                stack.push(Container::Link { _url: url });
            }
            Event::Start(Tag::Image { dest_url, .. }) => {
                stack.push(Container::Image {
                    path: dest_url.to_string(),
                });
            }

            // === End tags ===
            Event::End(TagEnd::Paragraph) => {
                if !output.ends_with('\n') {
                    output.push('\n');
                }
                block_depth -= 1;
                if block_depth == 0
                    && let Some((typst_start, md_range_start)) = block_starts.pop()
                {
                    source_map_blocks.push(BlockMapping {
                        typst_byte_range: typst_start..output.len(),
                        md_byte_range: md_range_start,
                    });
                }
            }
            Event::End(TagEnd::Heading(_)) => {
                if !output.ends_with('\n') {
                    output.push('\n');
                }
                pop_expect(&mut stack, "Heading");
                block_depth -= 1;
                if block_depth == 0
                    && let Some((typst_start, md_range_start)) = block_starts.pop()
                {
                    source_map_blocks.push(BlockMapping {
                        typst_byte_range: typst_start..output.len(),
                        md_byte_range: md_range_start,
                    });
                }
            }
            Event::End(TagEnd::BlockQuote(_)) => {
                match stack.pop() {
                    Some(Container::BlockQuote) => {
                        let trimmed = output.trim_end().len();
                        output.truncate(trimmed);
                        output.push_str("]\n");
                    }
                    Some(Container::BlockQuoteCapped) => {
                        // Depth-capped: no closing bracket needed
                    }
                    other => {
                        debug_assert!(false, "expected BlockQuote/BlockQuoteCapped, got {other:?}");
                    }
                }
                block_depth -= 1;
                if block_depth == 0
                    && let Some((typst_start, md_range_start)) = block_starts.pop()
                {
                    source_map_blocks.push(BlockMapping {
                        typst_byte_range: typst_start..output.len(),
                        md_byte_range: md_range_start,
                    });
                }
            }
            Event::End(TagEnd::CodeBlock) => {
                in_code_block = false;
                if code_block_lang == "mermaid" {
                    // Emit as image reference; diagram rendering happens later
                    let key = super::diagram::diagram_key(&code_block_buf);
                    let is_available = available_images.is_some_and(|set| set.contains(&key));
                    if is_available {
                        if cell_buf.is_none() {
                            let typst_start = output.len();
                            output.push_str(&typst_image(&key));
                            let typst_end = output.len();
                            text_spans.push(TextSpan {
                                typst_range: typst_start..typst_end,
                                md_range,
                                kind: SpanKind::Opaque,
                            });
                        } else {
                            push_to_target(&mut output, &mut cell_buf, &typst_image(&key));
                        }
                    } else if cell_buf.is_some() {
                        // Mermaid fallback inside table cell
                        let fence_len = max_backtick_run(&code_block_buf).max(2) + 1;
                        let fence: String = "`".repeat(fence_len);
                        push_to_target(&mut output, &mut cell_buf, &fence);
                        push_to_target(&mut output, &mut cell_buf, "mermaid");
                        push_to_target(&mut output, &mut cell_buf, "\n");
                        push_to_target(&mut output, &mut cell_buf, &code_block_buf);
                        if !code_block_buf.ends_with('\n') {
                            push_to_target(&mut output, &mut cell_buf, "\n");
                        }
                        push_to_target(&mut output, &mut cell_buf, &fence);
                        push_to_target(&mut output, &mut cell_buf, "\n");
                        code_spans_pending.clear();
                    } else {
                        // Mermaid fallback: render as regular code block with span recording
                        let fence_len = max_backtick_run(&code_block_buf).max(2) + 1;
                        let fence: String = "`".repeat(fence_len);
                        output.push_str(&fence);
                        output.push_str("mermaid");
                        output.push('\n');
                        let content_start = output.len();
                        output.push_str(&code_block_buf);
                        if !code_block_buf.ends_with('\n') {
                            output.push('\n');
                        }
                        for (pending_md_range, buf_range) in code_spans_pending.drain(..) {
                            text_spans.push(TextSpan {
                                typst_range: (content_start + buf_range.start)
                                    ..(content_start + buf_range.end),
                                md_range: pending_md_range,
                                kind: SpanKind::Code,
                            });
                        }
                        output.push_str(&fence);
                        output.push('\n');
                    }
                } else if cell_buf.is_some() {
                    // Inside a table cell: use push_to_target, no TextSpan recording
                    let fence_len = max_backtick_run(&code_block_buf).max(2) + 1;
                    let fence: String = "`".repeat(fence_len);
                    push_to_target(&mut output, &mut cell_buf, &fence);
                    push_to_target(&mut output, &mut cell_buf, &code_block_lang);
                    push_to_target(&mut output, &mut cell_buf, "\n");
                    push_to_target(&mut output, &mut cell_buf, &code_block_buf);
                    if !code_block_buf.ends_with('\n') {
                        push_to_target(&mut output, &mut cell_buf, "\n");
                    }
                    push_to_target(&mut output, &mut cell_buf, &fence);
                    push_to_target(&mut output, &mut cell_buf, "\n");
                    code_spans_pending.clear();
                } else {
                    // Use a fence longer than any backtick run in the content
                    let fence_len = max_backtick_run(&code_block_buf).max(2) + 1;
                    let fence: String = "`".repeat(fence_len);
                    output.push_str(&fence);
                    output.push_str(&code_block_lang);
                    output.push('\n');
                    let content_start = output.len();
                    output.push_str(&code_block_buf);
                    if !code_block_buf.ends_with('\n') {
                        output.push('\n');
                    }
                    // Finalize pending code spans
                    for (pending_md_range, buf_range) in code_spans_pending.drain(..) {
                        text_spans.push(TextSpan {
                            typst_range: (content_start + buf_range.start)
                                ..(content_start + buf_range.end),
                            md_range: pending_md_range,
                            kind: SpanKind::Code,
                        });
                    }
                    output.push_str(&fence);
                    output.push('\n');
                }
                code_block_buf.clear();
                code_block_lang.clear();
                pop_expect(&mut stack, "CodeBlock");
                block_depth -= 1;
                if block_depth == 0
                    && let Some((typst_start, md_range_start)) = block_starts.pop()
                {
                    source_map_blocks.push(BlockMapping {
                        typst_byte_range: typst_start..output.len(),
                        md_byte_range: md_range_start,
                    });
                }
            }
            Event::End(TagEnd::List(_)) => {
                pop_expect(&mut stack, "List");
                block_depth -= 1;
                if block_depth == 0
                    && let Some((typst_start, md_range_start)) = block_starts.pop()
                {
                    source_map_blocks.push(BlockMapping {
                        typst_byte_range: typst_start..output.len(),
                        md_byte_range: md_range_start,
                    });
                }
            }
            Event::End(TagEnd::Item) => {
                if !output.ends_with('\n') {
                    output.push('\n');
                }
                pop_expect(&mut stack, "Item");
            }
            Event::End(TagEnd::Table) => {
                // Emit the entire table
                // For tables, typst_start is recorded at Start(Table) but the
                // actual Typst output is emitted here at End(Table).
                // We need to update the typst_start to the current position
                // before emitting so the range covers the actual output.
                let table_typst_start = output.len();
                output.push_str(&format!("#table(columns: {table_col_count},\n"));
                for (i, cell) in table_cells.iter().enumerate() {
                    output.push_str("  [");
                    let cell_content_start = output.len();
                    output.push_str(cell);
                    output.push_str("],\n");
                    if let Some(pending) = table_cell_spans.get(i) {
                        for (pending_md_range, buf_range, kind) in pending {
                            text_spans.push(TextSpan {
                                typst_range: (cell_content_start + buf_range.start)
                                    ..(cell_content_start + buf_range.end),
                                md_range: pending_md_range.clone(),
                                kind: *kind,
                            });
                        }
                    }
                }
                output.push_str(")\n");
                table_cells.clear();
                table_cell_spans.clear();
                pop_expect(&mut stack, "Table");
                block_depth -= 1;
                if block_depth == 0
                    && let Some((_typst_start, md_range_start)) = block_starts.pop()
                {
                    source_map_blocks.push(BlockMapping {
                        typst_byte_range: table_typst_start..output.len(),
                        md_byte_range: md_range_start,
                    });
                }
            }
            Event::End(TagEnd::TableHead) => {
                pop_expect(&mut stack, "TableHead");
            }
            Event::End(TagEnd::TableRow) => {
                pop_expect(&mut stack, "TableRow");
            }
            Event::End(TagEnd::TableCell) => {
                if let Some(buf) = cell_buf.take() {
                    table_cells.push(buf);
                    table_cell_spans.push(std::mem::take(&mut cell_spans_pending));
                } else {
                    cell_spans_pending.clear();
                }
                pop_expect(&mut stack, "TableCell");
            }
            Event::End(TagEnd::Strong) => {
                push_to_target(&mut output, &mut cell_buf, "]");
                pop_expect(&mut stack, "Strong");
            }
            Event::End(TagEnd::Emphasis) => {
                push_to_target(&mut output, &mut cell_buf, "]");
                pop_expect(&mut stack, "Emphasis");
            }
            Event::End(TagEnd::Strikethrough) => {
                push_to_target(&mut output, &mut cell_buf, "]");
                pop_expect(&mut stack, "Strikethrough");
            }
            Event::End(TagEnd::Link) => {
                match stack.pop() {
                    Some(Container::Link { _url }) if !_url.is_empty() => {
                        push_to_target(&mut output, &mut cell_buf, "]");
                    }
                    Some(Container::Link { .. }) => {
                        // Empty URL — text was output as plain text, nothing to close
                    }
                    other => {
                        debug_assert!(false, "Expected Link, got {other:?}");
                    }
                }
            }
            Event::End(TagEnd::Image) => match stack.pop() {
                Some(Container::Image { path }) => {
                    let is_available = available_images.is_some_and(|set| set.contains(&path));
                    if let Some(buf) = cell_buf.as_mut() {
                        let buf_start = buf.len();
                        if is_available {
                            buf.push_str(&typst_image(&path));
                        } else {
                            buf.push_str(&typst_image_placeholder(&path));
                        }
                        let buf_end = buf.len();
                        cell_spans_pending.push((md_range, buf_start..buf_end, SpanKind::Opaque));
                    } else {
                        let typst_start = output.len();
                        if is_available {
                            output.push_str(&typst_image(&path));
                        } else {
                            output.push_str(&typst_image_placeholder(&path));
                        }
                        let typst_end = output.len();
                        text_spans.push(TextSpan {
                            typst_range: typst_start..typst_end,
                            md_range,
                            kind: SpanKind::Opaque,
                        });
                    }
                }
                other => {
                    debug_assert!(false, "Expected Image, got {other:?}");
                }
            },

            // === Leaf events ===
            Event::Text(text) => {
                // Suppress alt text inside images
                if stack.iter().any(|c| matches!(c, Container::Image { .. })) {
                    continue;
                }
                if in_code_block {
                    // Insert a space on blank lines so Typst generates a TextItem
                    // (needed for visual line extraction in the sidebar).
                    let buf_start = code_block_buf.len();
                    let text = fill_blank_lines(&text);
                    code_block_buf.push_str(&text);
                    let buf_end = code_block_buf.len();
                    code_spans_pending.push((md_range, buf_start..buf_end));
                } else if let Some(buf) = cell_buf.as_mut() {
                    let buf_start = buf.len();
                    buf.push_str(&escape_typst(&text));
                    let buf_end = buf.len();
                    cell_spans_pending.push((md_range, buf_start..buf_end, SpanKind::Plain));
                } else {
                    let typst_start = output.len();
                    output.push_str(&escape_typst(&text));
                    let typst_end = output.len();
                    text_spans.push(TextSpan {
                        typst_range: typst_start..typst_end,
                        md_range,
                        kind: SpanKind::Plain,
                    });
                }
            }
            Event::Code(code) => {
                let s = if code.contains('`') {
                    // Can't use backtick delimiters when code contains backticks;
                    // use #raw() function call instead.
                    let escaped = code.replace('\\', "\\\\").replace('"', "\\\"");
                    format!("#raw(\"{}\")", escaped)
                } else {
                    format!("`{code}`")
                };
                if let Some(buf) = cell_buf.as_mut() {
                    let buf_start = buf.len();
                    buf.push_str(&s);
                    let buf_end = buf.len();
                    cell_spans_pending.push((md_range, buf_start..buf_end, SpanKind::Code));
                } else {
                    let typst_start = output.len();
                    output.push_str(&s);
                    let typst_end = output.len();
                    text_spans.push(TextSpan {
                        typst_range: typst_start..typst_end,
                        md_range,
                        kind: SpanKind::Code,
                    });
                }
            }
            Event::SoftBreak => {
                if in_code_block {
                    code_block_buf.push('\n');
                } else if let Some(buf) = cell_buf.as_mut() {
                    let buf_start = buf.len();
                    buf.push('\n');
                    let buf_end = buf.len();
                    cell_spans_pending.push((md_range, buf_start..buf_end, SpanKind::Break));
                } else {
                    let typst_start = output.len();
                    output.push('\n');
                    let in_item = stack.iter().any(|c| matches!(c, Container::Item));
                    if in_item {
                        let list_depth = stack
                            .iter()
                            .filter(|c| matches!(c, Container::List { .. }))
                            .count();
                        let indent = list_depth.saturating_sub(1) * 2 + 2;
                        for _ in 0..indent {
                            output.push(' ');
                        }
                    }
                    let typst_end = output.len();
                    text_spans.push(TextSpan {
                        typst_range: typst_start..typst_end,
                        md_range,
                        kind: SpanKind::Break,
                    });
                }
            }
            Event::HardBreak => {
                if let Some(buf) = cell_buf.as_mut() {
                    let buf_start = buf.len();
                    buf.push_str("\\ \n");
                    let buf_end = buf.len();
                    cell_spans_pending.push((md_range, buf_start..buf_end, SpanKind::Break));
                } else {
                    let typst_start = output.len();
                    output.push_str("\\ \n");
                    let typst_end = output.len();
                    text_spans.push(TextSpan {
                        typst_range: typst_start..typst_end,
                        md_range,
                        kind: SpanKind::Break,
                    });
                }
            }
            Event::Rule => {
                let rule_start = output.len();
                if !output.is_empty() && !output.ends_with('\n') {
                    output.push('\n');
                }
                if !output.is_empty() {
                    output.push('\n');
                }
                output.push_str("#line(length: 100%)\n");
                // Only record a top-level source mapping; inside a list or other
                // block the enclosing block's range already covers this rule.
                if block_depth == 0 {
                    source_map_blocks.push(BlockMapping {
                        typst_byte_range: rule_start..output.len(),
                        md_byte_range: md_range,
                    });
                }
            }
            Event::InlineMath(latex) => {
                let typst_math = latex_to_typst_math(&latex);
                let s = format!("${typst_math}$");
                if let Some(buf) = cell_buf.as_mut() {
                    let buf_start = buf.len();
                    buf.push_str(&s);
                    let buf_end = buf.len();
                    cell_spans_pending.push((md_range, buf_start..buf_end, SpanKind::Math));
                } else {
                    let typst_start = output.len();
                    output.push_str(&s);
                    let typst_end = output.len();
                    text_spans.push(TextSpan {
                        typst_range: typst_start..typst_end,
                        md_range,
                        kind: SpanKind::Math,
                    });
                }
            }
            Event::DisplayMath(latex) => {
                if block_depth == 0 {
                    block_starts.push((output.len(), md_range.clone()));
                }
                if !output.is_empty() && !output.ends_with('\n') {
                    output.push('\n');
                }
                if !output.is_empty() {
                    output.push('\n');
                }
                let typst_math = latex_to_typst_math(&latex);
                let math_typst_start = output.len();
                output.push_str(&format!("$ {typst_math} $\n"));
                let math_typst_end = output.len();
                text_spans.push(TextSpan {
                    typst_range: math_typst_start..math_typst_end,
                    md_range,
                    kind: SpanKind::Math,
                });
                if block_depth == 0
                    && let Some((typst_start, md_range_start)) = block_starts.pop()
                {
                    source_map_blocks.push(BlockMapping {
                        typst_byte_range: typst_start..output.len(),
                        md_byte_range: md_range_start,
                    });
                }
            }
            Event::Html(html) | Event::InlineHtml(html) => {
                let snippets = super::markup_html::render_html_imgs(&html, available_images);
                if !snippets.is_empty() {
                    if block_depth == 0 {
                        block_starts.push((output.len(), md_range.clone()));
                    }
                    if cell_buf.is_none() {
                        let typst_start = output.len();
                        for s in &snippets {
                            output.push_str(s);
                        }
                        let typst_end = output.len();
                        text_spans.push(TextSpan {
                            typst_range: typst_start..typst_end,
                            md_range,
                            kind: SpanKind::Opaque,
                        });
                    } else {
                        for s in &snippets {
                            push_to_target(&mut output, &mut cell_buf, s);
                        }
                    }
                    if block_depth == 0
                        && let Some((typst_start, md_range_start)) = block_starts.pop()
                    {
                        source_map_blocks.push(BlockMapping {
                            typst_byte_range: typst_start..output.len(),
                            md_byte_range: md_range_start,
                        });
                    }
                }
            }
            _ => {}
        }
    }

    if !output.is_empty() && !output.ends_with('\n') {
        output.push('\n');
    }

    let content_index = ContentIndex::new(text_spans, source_map_blocks);
    info!(
        "convert: completed in {:.1}ms (input: {} bytes, output: {} bytes)",
        start.elapsed().as_secs_f64() * 1000.0,
        markdown.len(),
        output.len()
    );
    (output, content_index)
}

/// Push string to the cell buffer if active, otherwise to the main output.
fn push_to_target(output: &mut String, cell_buf: &mut Option<String>, s: &str) {
    if let Some(buf) = cell_buf.as_mut() {
        buf.push_str(s);
    } else {
        output.push_str(s);
    }
}

/// Pop the stack and assert the expected container type (debug only).
fn pop_expect(stack: &mut Vec<Container>, expected: &str) {
    if let Some(container) = stack.pop() {
        debug_assert!(
            matches!(
                (&container, expected),
                (Container::Heading, "Heading")
                    | (Container::Strong, "Strong")
                    | (Container::Emphasis, "Emphasis")
                    | (Container::Strikethrough, "Strikethrough")
                    | (Container::Link { .. }, "Link")
                    | (Container::Image { .. }, "Image")
                    | (Container::BlockQuote, "BlockQuote")
                    | (Container::BlockQuoteCapped, "BlockQuote")
                    | (Container::List { .. }, "List")
                    | (Container::Item, "Item")
                    | (Container::CodeBlock, "CodeBlock")
                    | (Container::Table { .. }, "Table")
                    | (Container::TableHead, "TableHead")
                    | (Container::TableRow, "TableRow")
                    | (Container::TableCell, "TableCell")
            ),
            "Expected {expected}, got {container:?}"
        );
    }
}

/// Find the longest consecutive run of backticks in a string.
fn max_backtick_run(s: &str) -> usize {
    let mut max = 0;
    let mut current = 0;
    for ch in s.chars() {
        if ch == '`' {
            current += 1;
            if current > max {
                max = current;
            }
        } else {
            current = 0;
        }
    }
    max
}

/// Replace blank lines with space-only lines in code block text.
///
/// Typst does not generate a TextItem for empty lines in raw blocks.
/// By inserting a space, we ensure each line has a TextItem whose Y
/// coordinate can be picked up by `extract_visual_lines()`.
fn fill_blank_lines(text: &str) -> String {
    let lines: Vec<&str> = text.split('\n').collect();
    let mut result = String::with_capacity(text.len() + lines.len());
    for (i, line) in lines.iter().enumerate() {
        if i > 0 {
            result.push('\n');
        }
        if line.is_empty() && i < lines.len() - 1 {
            result.push(' ');
        } else {
            result.push_str(line);
        }
    }
    result
}

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

    fn md_to_typst(s: &str) -> String {
        markdown_to_typst(s, None).0
    }

    #[test]
    fn test_plain_text() {
        let md = "Hello, world!";
        let typst = md_to_typst(md);
        assert_eq!(typst, "Hello, world!\n");
    }

    #[test]
    fn test_emph_followed_by_paren() {
        // crash-e263b8df: **Note*(: → \*#emph[Note](: → Typst が関数呼び出しと誤解釈
        let md = "**Note*(: text";
        let typst = md_to_typst(md);
        assert!(
            !typst.contains("]("),
            "'](' は Typst が関数引数と解釈するため不可: {typst}"
        );
        assert!(
            typst.contains("]\\("),
            "']' の直後の '(' は '\\(' にエスケープされるべき: {typst}"
        );
    }

    #[test]
    fn test_bracket_in_heading() {
        // crash-b305e5d4: ## text](url) → ] が Typst の unexpected closing bracket
        let md = "## エanguage](https://docs.invalid/book/) を参照。";
        let typst = md_to_typst(md);
        assert!(
            !typst.contains("]("),
            "'](' は Typst がコンテントブロック閉じと解釈するため不可: {typst}"
        );
        assert!(
            typst.contains("\\]"),
            "テキスト中の ']' は '\\]' にエスケープされるべき: {typst}"
        );
    }

    #[test]
    fn test_brackets_in_text() {
        let md = "配列 arr[0] と [注釈] を含む文";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("arr\\[0\\]"),
            "テキスト中の角括弧はエスケープされるべき: {typst}"
        );
        assert!(
            typst.contains("\\[注釈\\]"),
            "テキスト中の角括弧はエスケープされるべき: {typst}"
        );
    }

    #[test]
    fn test_link_text_with_bracket() {
        let md = "[foo]bar](https://example.invalid/)";
        let typst = md_to_typst(md);
        // 構造的な ] は convert.rs が直接 push、テキスト中の ] は escape される
        assert!(
            !typst.contains("bar]("),
            "リンクテキスト内の ']' は '\\]' にエスケープされるべき: {typst}"
        );
    }

    #[test]
    fn test_parens_in_text() {
        let md = "関数 foo(x, y) を呼ぶ";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("foo\\(x, y\\)"),
            "テキスト中の括弧はエスケープされるべき: {typst}"
        );
    }

    #[test]
    fn test_strong_followed_by_paren() {
        let md = "**bold** (note)";
        let typst = md_to_typst(md);
        assert!(
            !typst.contains("]("),
            "'](' は Typst が関数引数と解釈するため不可: {typst}"
        );
    }

    #[test]
    fn test_soft_break() {
        let md = "line1\nline2";
        let typst = md_to_typst(md);
        assert_eq!(typst, "line1\nline2\n");
    }

    #[test]
    fn test_hard_break() {
        let md = "line1  \nline2";
        let typst = md_to_typst(md);
        assert_eq!(typst, "line1\\ \nline2\n");
    }

    #[test]
    fn test_japanese_text() {
        let md = "日本語のテスト。句読点「、」も正しく処理される。";
        let typst = md_to_typst(md);
        assert!(typst.contains("日本語のテスト。"));
    }

    #[test]
    fn test_multiple_paragraphs() {
        let md = "段落1。\n\n段落2。";
        let typst = md_to_typst(md);
        assert_eq!(typst, "段落1。\n\n段落2。\n");
    }

    #[test]
    fn test_heading() {
        let md = "# Title\n\n## Subtitle";
        let typst = md_to_typst(md);
        assert!(typst.contains("= Title\n"));
        assert!(typst.contains("== Subtitle\n"));
    }

    #[test]
    fn test_bold_italic() {
        let md = "**bold** and *italic*";
        let typst = md_to_typst(md);
        assert!(typst.contains("#strong[bold]"));
        assert!(typst.contains("#emph[italic]"));
    }

    #[test]
    fn test_emphasis_function_syntax() {
        // fuzzer crash-301940: **Note*ks*: with delimiter syntax produced
        // \*\*Note_ks_: causing "unclosed delimiter" in Typst.
        // Function syntax (#emph[...]) avoids this class of bugs.
        let md = "**Note*ks*: hello";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("#emph[ks]"),
            "should use #emph[] function syntax, got: {typst}"
        );
        assert!(
            !typst.contains("_ks_"),
            "should not produce _..._ delimiters, got: {typst}"
        );
    }

    #[test]
    fn test_strikethrough() {
        let md = "~~deleted~~";
        let typst = md_to_typst(md);
        assert!(typst.contains("#strike[deleted]"));
    }

    #[test]
    fn test_inline_code() {
        let md = "Use `Result<T, E>` type";
        let typst = md_to_typst(md);
        assert!(typst.contains("`Result<T, E>`"));
    }

    #[test]
    fn test_inline_code_with_backticks() {
        // pulldown-cmark parses `` ` `` as Code("`")
        let md = "Use `` ` `` in code";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("#raw(\"`\")"),
            "expected #raw() call for backtick-containing code, got: {typst}"
        );
    }

    #[test]
    fn test_inline_code_with_triple_backticks() {
        // pulldown-cmark parses `` ` ``` ` `` as Code("```")
        let md = "` ``` `";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("#raw(\"```\")"),
            "expected #raw() for triple backticks, got: {typst}"
        );
        assert!(
            !typst.contains("`````"),
            "should not produce raw backtick delimiters, got: {typst}"
        );
    }

    #[test]
    fn test_inline_code_with_backticks_in_table() {
        let md = "| Header |\n|--------|\n| `` ` `` |";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("#table("),
            "expected table markup, got: {typst}"
        );
        assert!(
            typst.contains("#raw(\"`\")"),
            "expected #raw() in table cell, got: {typst}"
        );
    }

    #[test]
    fn test_link() {
        let md = "[Rust](https://rust.invalid/)";
        let typst = md_to_typst(md);
        assert!(typst.contains("#link(\"https://rust.invalid/\")[Rust]"));
    }

    #[test]
    fn test_link_empty_url() {
        let md = "[link]()";
        let typst = md_to_typst(md);
        assert!(
            !typst.contains("#link"),
            "empty URL should not produce #link"
        );
        assert!(typst.contains("link"));
    }

    #[test]
    fn test_link_url_with_quote() {
        let md = "[t](https://x.invalid/?q=\"v\")";
        let typst = md_to_typst(md);
        // " must be escaped as \" inside the Typst string literal
        assert!(
            typst.contains("\\\""),
            "double quote in URL must be escaped: {typst}"
        );
        // The #link call must be properly closed with )[
        assert!(
            typst.contains("#link(\"https://x.invalid/?q=\\\"v\\\"\")[t]"),
            "unexpected output: {typst}"
        );
    }

    #[test]
    fn test_link_url_injection_attempt() {
        // Attempt to inject Typst code via a crafted URL containing "
        let md = "[t](https://x.invalid/\"#evil)";
        let typst = md_to_typst(md);
        // The " must be escaped as \" so the Typst string literal stays closed
        assert!(
            typst.contains("\\\"#evil"),
            "quote must be escaped as \\\", got: {typst}"
        );
        // The link must close properly — the URL " did not terminate the string
        assert!(
            typst.contains(")[t]"),
            "link must be properly closed, got: {typst}"
        );
    }

    #[test]
    fn test_code_block() {
        let md = "```rust\nfn main() {}\n```";
        let typst = md_to_typst(md);
        assert!(typst.contains("```rust\nfn main() {}\n```"));
    }

    #[test]
    fn test_unordered_list() {
        let md = "- item1\n- item2";
        let typst = md_to_typst(md);
        assert!(typst.contains("- item1\n"));
        assert!(typst.contains("- item2\n"));
    }

    #[test]
    fn test_ordered_list() {
        let md = "1. first\n2. second";
        let typst = md_to_typst(md);
        assert!(typst.contains("+ first\n"));
        assert!(typst.contains("+ second\n"));
    }

    #[test]
    fn test_blockquote() {
        let md = "> quoted text";
        let typst = md_to_typst(md);
        assert!(typst.contains("#quote(block: true)["));
        assert!(typst.contains("quoted text"));
    }

    #[test]
    fn test_horizontal_rule() {
        let md = "before\n\n---\n\nafter";
        let typst = md_to_typst(md);
        assert!(typst.contains("#line(length: 100%)"));
    }

    #[test]
    fn test_rule_inside_list() {
        // pulldown-cmark parses "+\t---" as an unordered list item containing
        // a thematic break (Rule event), not plain text.
        let md = "+\t---\t\t";
        let typst = md_to_typst(md);
        assert!(typst.contains("- "), "should produce unordered list marker");
        assert!(
            typst.contains("#line(length: 100%)"),
            "should produce horizontal rule"
        );
    }

    #[test]
    fn test_rule_inside_list_source_map() {
        // crash-823d13a0: list item containing --- emitted a Rule source mapping
        // that overlapped with the enclosing List block mapping.
        let md = "+\t---\t\t";
        let (typst, ci) = markdown_to_typst(md, None);
        let blocks = ci.block_spans();
        for pair in blocks.windows(2) {
            assert!(
                pair[0].typst_byte_range.end <= pair[1].typst_byte_range.start,
                "overlapping typst ranges: {:?} and {:?}",
                pair[0].typst_byte_range,
                pair[1].typst_byte_range,
            );
        }
        for block in blocks {
            assert!(
                block.typst_byte_range.end <= typst.len(),
                "typst_byte_range {:?} out of bounds",
                block.typst_byte_range,
            );
        }
    }

    #[test]
    fn test_table() {
        let md = "| A | B |\n|---|---|\n| 1 | 2 |";
        let typst = md_to_typst(md);
        assert!(typst.contains("#table(columns: 2,"));
        assert!(typst.contains("[A]"));
        assert!(typst.contains("[B]"));
        assert!(typst.contains("[1]"));
        assert!(typst.contains("[2]"));
    }

    #[test]
    fn test_code_block_no_escape() {
        // Characters inside code blocks should NOT be escaped
        let md = "```\n#hello *world* $100\n```";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("#hello *world* $100"),
            "Code block content should not be escaped, got: {typst}"
        );
    }

    #[test]
    fn test_code_block_blank_lines_filled() {
        let md = "```\nline1\n\nline3\n```";
        let typst = md_to_typst(md);
        // Blank line should be replaced with a space
        assert!(
            typst.contains("line1\n \nline3"),
            "Blank lines in code blocks should be filled with a space, got: {typst}"
        );
    }

    #[test]
    fn test_code_block_multiple_blank_lines() {
        let md = "```\nline1\n\n\nline4\n```";
        let typst = md_to_typst(md);
        // Two consecutive blank lines should each get a space
        assert!(
            typst.contains("line1\n \n \nline4"),
            "Multiple blank lines should each be filled, got: {typst}"
        );
    }

    #[test]
    fn test_code_block_containing_backtick_fence() {
        let md = "````\n```rust\nfn main() {}\n```\n````";
        let typst = md_to_typst(md);
        // The generated fence must be longer than the 3-backtick run inside
        assert!(
            typst.contains("````"),
            "fence should be at least 4 backticks, got: {typst}"
        );
        assert!(
            typst.contains("```rust\nfn main() {}\n```"),
            "content should be preserved verbatim, got: {typst}"
        );
    }

    #[test]
    fn test_max_backtick_run() {
        assert_eq!(max_backtick_run(""), 0);
        assert_eq!(max_backtick_run("no backticks"), 0);
        assert_eq!(max_backtick_run("a`b"), 1);
        assert_eq!(max_backtick_run("```"), 3);
        assert_eq!(max_backtick_run("a```b``c"), 3);
        assert_eq!(max_backtick_run("``````"), 6);
    }

    #[test]
    fn test_blockquote_depth_capped() {
        // 15段ネスト → 最初の10段のみ #quote 出力
        let input = "> ".repeat(15) + "deep";
        let result = md_to_typst(&input);
        let quote_count = result.matches("#quote(block: true)[").count();
        assert_eq!(quote_count, MAX_BLOCKQUOTE_DEPTH);
        assert!(result.contains("deep"));
    }

    #[test]
    fn test_fill_blank_lines() {
        assert_eq!(fill_blank_lines("a\n\nb\n"), "a\n \nb\n");
        assert_eq!(fill_blank_lines("a\n\n\nb\n"), "a\n \n \nb\n");
        assert_eq!(fill_blank_lines("a\nb\n"), "a\nb\n"); // no blanks
        assert_eq!(fill_blank_lines("a\n"), "a\n"); // trailing newline preserved
    }

    #[test]
    fn test_nested_unordered_list() {
        let md = "- a\n  - b\n- c";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("- a\n  - b\n- c"),
            "nested unordered list should be indented, got: {typst}"
        );
    }

    #[test]
    fn test_nested_ordered_list() {
        let md = "1. a\n   1. b\n2. c";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("+ a\n  + b\n+ c"),
            "nested ordered list should be indented, got: {typst}"
        );
    }

    #[test]
    fn test_nested_mixed_list() {
        let md = "- a\n  1. b\n- c";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("- a\n  + b\n- c"),
            "nested mixed list should be indented, got: {typst}"
        );
    }

    #[test]
    fn test_deeply_nested_list() {
        // 10 levels deep — depth should cap at 7
        let md = "- L0\n  - L1\n    - L2\n      - L3\n        - L4\n          - L5\n            - L6\n              - L7\n                - L8\n                  - L9";
        let typst = md_to_typst(md);
        // Level 8 and 9 should both be capped at 7 (14 spaces indent)
        let max_indent = typst
            .lines()
            .map(|line| line.len() - line.trim_start().len())
            .max()
            .unwrap_or(0);
        assert_eq!(
            max_indent, 14,
            "max indent should be 14 (7 levels × 2 spaces), got: {max_indent}\n{typst}"
        );
    }

    #[test]
    fn test_loose_unordered_list() {
        // Loose list (blank line between items): text must follow marker directly
        let md = "- a\n\n- b";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("- a\n"),
            "first item text should follow marker: {typst}"
        );
        assert!(
            typst.contains("- b\n"),
            "second item text should follow marker: {typst}"
        );
        assert!(
            !typst.contains("- \n"),
            "marker and text should not be separated: {typst}"
        );
    }

    #[test]
    fn test_loose_ordered_list() {
        // Loose ordered list: text must follow + marker directly
        let md = "1. a\n\n2. b";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("+ a\n"),
            "first item text should follow marker: {typst}"
        );
        assert!(
            typst.contains("+ b\n"),
            "second item text should follow marker: {typst}"
        );
        assert!(
            !typst.contains("+ \n"),
            "marker and text should not be separated: {typst}"
        );
    }

    #[test]
    fn test_list_item_softbreak() {
        // SoftBreak inside a list item must produce indented continuation
        let md = "- first\nsecond";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("- first\n  second"),
            "continuation line should be indented by 2 spaces: {typst}"
        );
    }

    #[test]
    fn test_loose_nested_list() {
        // Nested loose list: inner items should be properly indented
        let md = "- outer\n\n  - inner1\n\n  - inner2";
        let typst = md_to_typst(md);
        assert!(typst.contains("- outer\n"), "outer item: {typst}");
        assert!(
            typst.contains("  - inner1\n"),
            "nested items should be indented: {typst}"
        );
        assert!(
            typst.contains("  - inner2\n"),
            "nested items should be indented: {typst}"
        );
    }

    #[test]
    fn test_table_cell_softbreak() {
        // SoftBreak outside a list should NOT add indentation
        // (regression: ensure non-list SoftBreak is unchanged)
        let md = "para line1\nline2";
        let typst = md_to_typst(md);
        assert_eq!(
            typst, "para line1\nline2\n",
            "non-list softbreak should not add indent"
        );
    }

    #[test]
    fn test_inline_math() {
        let md = "The formula $\\frac{a}{b}$ is inline.";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("$") && typst.contains("frac"),
            "inline math should produce Typst $...$ with converted content, got: {typst}"
        );
        // Should not be escaped as plain text ($)
        assert!(
            !typst.contains("\\$"),
            "math delimiters should not be escaped, got: {typst}"
        );
    }

    #[test]
    fn test_display_math() {
        let md = "$$\n\\sum_{i=1}^{n} x_i\n$$";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("$") && typst.contains("sum"),
            "display math should produce Typst $ ... $ with converted content, got: {typst}"
        );
    }

    #[test]
    fn test_inline_math_simple() {
        let md = "Value $x + y$ here.";
        let typst = md_to_typst(md);
        // mitex adds spacing around operators; just verify it's wrapped in $...$
        assert!(
            typst.contains("$") && typst.contains("x") && typst.contains("y"),
            "simple math should be wrapped in $...$, got: {typst}"
        );
        assert!(
            !typst.contains("\\$"),
            "math delimiters should not be escaped, got: {typst}"
        );
    }

    #[test]
    fn test_math_in_table() {
        let md = "| Formula |\n|---------|\n| $x^2$ |";
        let typst = md_to_typst(md);
        assert!(
            typst.contains("#table("),
            "should produce table, got: {typst}"
        );
        assert!(
            typst.contains("$"),
            "table cell should contain math, got: {typst}"
        );
    }

    #[test]
    fn test_display_math_source_map() {
        let md = "before\n\n$$\nx^2\n$$\n\nafter";
        let (_typst, ci) = markdown_to_typst(md, None);
        let blocks = ci.block_spans();
        // Display math should produce a source map block
        assert!(
            blocks.len() >= 2,
            "should have at least 2 blocks (paragraph + math), got: {}",
            blocks.len()
        );
        // No overlaps
        for pair in blocks.windows(2) {
            assert!(
                pair[0].typst_byte_range.end <= pair[1].typst_byte_range.start,
                "overlapping typst ranges: {:?} and {:?}",
                pair[0].typst_byte_range,
                pair[1].typst_byte_range,
            );
        }
    }

    #[test]
    fn test_image_basic() {
        let md = "![alt](photo.png)";
        let available: HashSet<String> = ["photo.png".to_string()].into_iter().collect();
        let typst = markdown_to_typst(md, Some(&available)).0;
        assert!(
            typst.contains(
                "#align(center)[#std.scale(scale * 100%, reflow: true)[#image(\"photo.png\")]]"
            ),
            "should contain wrapped #image() call, got: {typst}"
        );
    }

    #[test]
    fn test_image_alt_suppressed() {
        let md = "![alt text](photo.png)";
        let available: HashSet<String> = ["photo.png".to_string()].into_iter().collect();
        let typst = markdown_to_typst(md, Some(&available)).0;
        assert!(
            !typst.contains("alt text"),
            "alt text should be suppressed, got: {typst}"
        );
    }

    #[test]
    fn test_image_missing() {
        let md = "![alt](missing.png)";
        let available: HashSet<String> = HashSet::new();
        let typst = markdown_to_typst(md, Some(&available)).0;
        assert!(
            typst.contains("#image-placeholder("),
            "missing image should produce placeholder, got: {typst}"
        );
        assert!(
            typst.contains("missing.png"),
            "placeholder should contain path, got: {typst}"
        );
    }

    #[test]
    fn test_image_no_available_images() {
        let md = "![alt](photo.png)";
        let typst = markdown_to_typst(md, None).0;
        assert!(
            typst.contains("#image-placeholder("),
            "no available images should produce placeholder, got: {typst}"
        );
    }

    #[test]
    fn test_prescan_image_paths() {
        let md = "![a](img1.png)\n\n![b](img2.jpg)\n\n![c](img1.png)";
        let result = prescan(md);
        assert_eq!(
            result.image_paths.len(),
            2,
            "should deduplicate: {:?}",
            result.image_paths
        );
        assert!(result.image_paths.contains(&"img1.png".to_string()));
        assert!(result.image_paths.contains(&"img2.jpg".to_string()));
        assert!(!result.has_cjk);
    }

    #[test]
    fn test_prescan_empty() {
        let md = "No images here.";
        let result = prescan(md);
        assert!(result.image_paths.is_empty());
        assert!(!result.has_cjk);
    }

    #[test]
    fn test_prescan_has_cjk_japanese() {
        let md = "# こんにちは\n\nHello world";
        let result = prescan(md);
        assert!(result.has_cjk);
    }

    #[test]
    fn test_prescan_has_cjk_chinese() {
        let md = "这是中文";
        let result = prescan(md);
        assert!(result.has_cjk);
    }

    #[test]
    fn test_prescan_latin_only() {
        let md = "# Hello World\n\nThis is a **test** with `code` and *emphasis*.";
        let result = prescan(md);
        assert!(!result.has_cjk);
    }

    #[test]
    fn test_prescan_cjk_in_code() {
        let md = "Some text with `日本語コード` inline";
        let result = prescan(md);
        assert!(result.has_cjk);
    }

    #[test]
    fn test_is_cjk() {
        assert!(is_cjk('あ')); // Hiragana
        assert!(is_cjk('ア')); // Katakana
        assert!(is_cjk('漢')); // CJK Unified
        assert!(is_cjk('　')); // Ideographic space U+3000
        assert!(is_cjk('Ａ')); // Fullwidth Latin A U+FF21
        assert!(!is_cjk('A'));
        assert!(!is_cjk('é'));
        assert!(!is_cjk(' '));
    }
}
