//! Markdown string → kode-doc tree parser.
//!
//! Uses tree-sitter-markdown for block-level structure, then parses inline
//! content (bold, italic, code, links, etc.) into properly marked text nodes.

use std::cell::RefCell;

use arborium_tree_sitter::{Language, Parser};

use crate::attrs::{
    code_block_attrs, empty_attrs, heading_attrs, image_attrs, image_block_attrs, link_attrs,
    ordered_list_attrs, table_cell_attrs,
};
use crate::fragment::Fragment;
use crate::mark::{Mark, MarkType};
use crate::node::Node;
use crate::node_type::NodeType;

thread_local! {
    /// Cached tree-sitter parser — avoids re-creating and re-configuring
    /// the parser on every `parse_markdown` call.
    static PARSER: RefCell<Parser> = RefCell::new({
        let language = Language::new(arborium_markdown::language());
        let mut parser = Parser::new();
        parser
            .set_language(&language)
            .expect("failed to set markdown language");
        parser
    });
}

/// Configuration for markdown parsing behavior.
#[derive(Clone, Debug, Default)]
pub struct MarkdownConfig {
    /// URL patterns that cause links to be parsed as FileBlock nodes.
    /// A link `[text](url)` becomes a FileBlock if `url` contains any of these patterns.
    pub file_link_patterns: Vec<String>,
}

/// Parse a markdown string into a kode-doc document tree.
///
/// Uses tree-sitter-markdown to parse the source, then walks the CST
/// to build a typed document tree with proper node types and marks.
pub fn parse_markdown(source: &str) -> Node {
    parse_markdown_with_config(source, &MarkdownConfig::default())
}

/// Parse a markdown string into a kode-doc document tree with custom configuration.
///
/// Like [`parse_markdown`] but accepts a [`MarkdownConfig`] that controls
/// behaviors like file link detection.
pub fn parse_markdown_with_config(source: &str, config: &MarkdownConfig) -> Node {
    let tree = PARSER.with(|p| p.borrow_mut().parse(source, None));

    let Some(tree) = tree else {
        return Node::branch(NodeType::Doc, Fragment::empty());
    };

    let root = tree.root_node();
    let children = convert_block_children(&root, source, config);
    Node::branch(NodeType::Doc, Fragment::from_vec(children))
}

// ── Block-level conversion ──────────────────────────────────────────────

/// Convert all named children of a tree-sitter node into kode-doc nodes.
fn convert_block_children(
    node: &arborium_tree_sitter::Node,
    source: &str,
    config: &MarkdownConfig,
) -> Vec<Node> {
    let mut result = Vec::new();
    let mut cursor = node.walk();
    let mut prev_end_byte: Option<usize> = None;

    for child in node.named_children(&mut cursor) {
        // Insert empty paragraphs for extra blank lines between blocks.
        // Tree-sitter's paragraph nodes include a trailing `\n`, so the gap
        // between two standard-separated paragraphs contains only 1 newline
        // (the second half of the `\n\n` separator). Each additional newline
        // in the gap represents one visible blank line (empty paragraph).
        if let Some(prev_end) = prev_end_byte {
            let gap = &source[prev_end..child.start_byte()];
            let newline_count = gap.chars().filter(|c| *c == '\n').count();
            for _ in 0..newline_count.saturating_sub(1) {
                result.push(Node::branch(NodeType::Paragraph, Fragment::empty()));
            }
        }

        let kind = child.kind();
        match kind {
            // Flatten containers: recurse into sections instead of wrapping
            "document" | "section" => {
                result.extend(convert_block_children(&child, source, config));
            }
            _ => {
                if let Some(doc_node) = convert_block_node(&child, source, config) {
                    result.push(doc_node);
                }
            }
        }

        prev_end_byte = Some(child.end_byte());
    }
    result
}

/// Convert a single tree-sitter block node into a kode-doc Node.
fn convert_block_node(
    node: &arborium_tree_sitter::Node,
    source: &str,
    config: &MarkdownConfig,
) -> Option<Node> {
    match node.kind() {
        "paragraph" => {
            let text = node_text(node, source);
            let inlines = parse_inline(text);
            if inlines.is_empty() {
                // Empty paragraph: still produce a node with no children
                Some(Node::branch(NodeType::Paragraph, Fragment::empty()))
            } else {
                // Image promotion: if the paragraph contains exactly one inline
                // Image node (and nothing else), promote it to a block-level ImageBlock.
                if inlines.len() == 1 && inlines[0].node_type == NodeType::Image {
                    let img = &inlines[0];
                    let src = match crate::attrs::get_attr(&img.attrs, "src") {
                        Some(crate::attrs::AttrValue::String(s)) => s.clone(),
                        _ => String::new(),
                    };
                    let alt = match crate::attrs::get_attr(&img.attrs, "alt") {
                        Some(crate::attrs::AttrValue::String(s)) => s.clone(),
                        _ => String::new(),
                    };
                    return Some(Node::leaf_with_attrs(
                        NodeType::ImageBlock,
                        image_block_attrs(&src, &alt, None, None, None),
                    ));
                }

                // File link detection: if the paragraph contains exactly one text
                // node with a Link mark whose href matches a file_link_pattern,
                // convert to a FileBlock.
                if !config.file_link_patterns.is_empty() && inlines.len() == 1 {
                    let node_ref = &inlines[0];
                    if node_ref.node_type == NodeType::Text && node_ref.marks.len() == 1 {
                        let mark = &node_ref.marks[0];
                        if mark.mark_type == crate::mark::MarkType::Link {
                            let href = match crate::attrs::get_attr(&mark.attrs, "href") {
                                Some(crate::attrs::AttrValue::String(s)) => s.clone(),
                                _ => String::new(),
                            };
                            let matches_pattern = config
                                .file_link_patterns
                                .iter()
                                .any(|pattern| href.contains(pattern.as_str()));
                            if matches_pattern {
                                let filename = node_ref.text().unwrap_or("").to_string();
                                return Some(Node::leaf_with_attrs(
                                    NodeType::FileBlock,
                                    crate::attrs::file_block_attrs(
                                        &href, &filename, None, None, None,
                                    ),
                                ));
                            }
                        }
                    }
                }

                Some(Node::branch(
                    NodeType::Paragraph,
                    Fragment::from_vec(inlines),
                ))
            }
        }

        "atx_heading" => {
            let level = detect_heading_level(node);
            let content = heading_inline_text(node, source);
            let inlines = parse_inline(&content);
            Some(Node::branch_with_attrs(
                NodeType::Heading,
                heading_attrs(level),
                Fragment::from_vec(inlines),
            ))
        }

        "setext_heading" => {
            let text = node
                .named_children(&mut node.walk())
                .find(|c| c.kind() == "paragraph")
                .map(|c| node_text(&c, source))
                .unwrap_or_default();
            let has_h1 = node
                .children(&mut node.walk())
                .any(|c| c.kind() == "setext_h1_underline");
            let level: u8 = if has_h1 { 1 } else { 2 };
            let inlines = parse_inline(text);
            Some(Node::branch_with_attrs(
                NodeType::Heading,
                heading_attrs(level),
                Fragment::from_vec(inlines),
            ))
        }

        "list" => {
            let is_ordered = node
                .children(&mut node.walk())
                .find(|c| c.kind() == "list_item")
                .map(|item| {
                    item.children(&mut item.walk()).any(|c| {
                        c.kind() == "list_marker_dot" || c.kind() == "list_marker_parenthesis"
                    })
                })
                .unwrap_or(false);

            let items = convert_list_items(node, source, config);

            if is_ordered {
                let start = detect_ordered_list_start(node, source);
                Some(Node::branch_with_attrs(
                    NodeType::OrderedList,
                    ordered_list_attrs(start),
                    Fragment::from_vec(items),
                ))
            } else {
                Some(Node::branch(
                    NodeType::BulletList,
                    Fragment::from_vec(items),
                ))
            }
        }

        "block_quote" => {
            let children = convert_block_children(node, source, config);
            Some(Node::branch(
                NodeType::Blockquote,
                Fragment::from_vec(children),
            ))
        }

        "fenced_code_block" => {
            let lang = code_block_language(node, source).unwrap_or("");
            let content = code_block_content(node, source).unwrap_or_default();
            let attrs = if lang.is_empty() {
                empty_attrs()
            } else {
                code_block_attrs(lang)
            };
            let children = if content.is_empty() {
                vec![]
            } else {
                vec![Node::new_text(&content)]
            };
            Some(Node::branch_with_attrs(
                NodeType::CodeBlock,
                attrs,
                Fragment::from_vec(children),
            ))
        }

        "indented_code_block" => {
            let text = node_text(node, source);
            // Strip 4 spaces or 1 tab of indent from each line
            let content: String = text
                .lines()
                .map(|l| {
                    if let Some(stripped) = l.strip_prefix("    ") {
                        stripped
                    } else if let Some(stripped) = l.strip_prefix('\t') {
                        stripped
                    } else {
                        l
                    }
                })
                .collect::<Vec<_>>()
                .join("\n");
            let children = if content.is_empty() {
                vec![]
            } else {
                vec![Node::new_text(&content)]
            };
            Some(Node::branch(
                NodeType::CodeBlock,
                Fragment::from_vec(children),
            ))
        }

        "thematic_break" => Some(Node::leaf(NodeType::HorizontalRule)),

        "html_block" => {
            // Treat HTML blocks as paragraphs with raw content
            let text = node_text(node, source);
            let trimmed = text.trim();
            if trimmed.is_empty() {
                None
            } else {
                Some(Node::branch(
                    NodeType::Paragraph,
                    Fragment::from_node(Node::new_text(trimmed)),
                ))
            }
        }

        "pipe_table" => {
            let rows = convert_table_children(node, source);
            Some(Node::branch(NodeType::Table, Fragment::from_vec(rows)))
        }

        // Skip structural nodes that are handled by their parents
        "list_item" | "block_continuation" | "block_quote_marker"
        | "list_marker_minus" | "list_marker_plus" | "list_marker_star"
        | "list_marker_dot" | "list_marker_parenthesis" => None,

        // Front matter: skip
        "minus_metadata" | "plus_metadata" => None,

        // Link reference definitions: skip
        "link_reference_definition" => None,

        // Unknown: treat as paragraph with raw text
        _ => {
            let text = node_text(node, source);
            let trimmed = text.trim();
            if trimmed.is_empty() {
                None
            } else {
                Some(Node::branch(
                    NodeType::Paragraph,
                    Fragment::from_node(Node::new_text(trimmed)),
                ))
            }
        }
    }
}

/// Convert list_item children of a list node.
fn convert_list_items(
    list_node: &arborium_tree_sitter::Node,
    source: &str,
    config: &MarkdownConfig,
) -> Vec<Node> {
    let mut items = Vec::new();
    let mut cursor = list_node.walk();

    for child in list_node.named_children(&mut cursor) {
        if child.kind() == "list_item" {
            let block_children = convert_block_children(&child, source, config);
            items.push(Node::branch(
                NodeType::ListItem,
                Fragment::from_vec(block_children),
            ));
        }
    }
    items
}

/// Parse column alignments from a pipe_table_delimiter_row.
///
/// The delimiter row looks like `| --- | :---: | ---: |`. We parse each
/// column's alignment marker to determine left, center, or right alignment.
fn parse_delimiter_alignments(row_node: &arborium_tree_sitter::Node, source: &str) -> Vec<String> {
    let text = node_text(row_node, source);
    let stripped = text.trim().trim_start_matches('|').trim_end_matches('|');
    stripped
        .split('|')
        .map(|col| {
            let trimmed = col.trim();
            let starts_colon = trimmed.starts_with(':');
            let ends_colon = trimmed.ends_with(':');
            match (starts_colon, ends_colon) {
                (true, true) => "center".to_string(),
                (false, true) => "right".to_string(),
                _ => "left".to_string(),
            }
        })
        .collect()
}

/// Convert children of a pipe_table node into Table{Header,Row} nodes.
///
/// A pipe_table has children: pipe_table_header, pipe_table_delimiter_row,
/// and one or more pipe_table_row. We parse the delimiter row to extract
/// column alignments, then apply them as attrs on each cell.
fn convert_table_children(
    table_node: &arborium_tree_sitter::Node,
    source: &str,
) -> Vec<Node> {
    // First pass: find the delimiter row and extract alignments.
    let mut alignments = Vec::new();
    let mut cursor = table_node.walk();
    for child in table_node.named_children(&mut cursor) {
        if child.kind() == "pipe_table_delimiter_row" {
            alignments = parse_delimiter_alignments(&child, source);
            break;
        }
    }

    // Second pass: convert header and data rows, passing alignments.
    let mut rows = Vec::new();
    let mut cursor2 = table_node.walk();
    for child in table_node.named_children(&mut cursor2) {
        match child.kind() {
            "pipe_table_header" => {
                let cells = convert_table_cells(&child, source, &alignments);
                rows.push(Node::branch(
                    NodeType::TableHeader,
                    Fragment::from_vec(cells),
                ));
            }
            "pipe_table_row" => {
                let cells = convert_table_cells(&child, source, &alignments);
                rows.push(Node::branch(
                    NodeType::TableRow,
                    Fragment::from_vec(cells),
                ));
            }
            // Skip delimiter row (already parsed above)
            _ => {}
        }
    }

    rows
}

/// Extract cells from a pipe_table_header or pipe_table_row.
///
/// The raw text looks like `| col1 | col2 | col3 |`. We split on `|`,
/// trim each cell, and parse inline markdown (bold, italic, code, links).
///
/// Cells whose column alignment (from `alignments`) is not "left" get an
/// `align` attr so the alignment survives the document round-trip.
fn convert_table_cells(
    row_node: &arborium_tree_sitter::Node,
    source: &str,
    alignments: &[String],
) -> Vec<Node> {
    let text = node_text(row_node, source);
    // Strip leading/trailing pipe and split on `|`
    let stripped = text.trim().trim_start_matches('|').trim_end_matches('|');
    stripped
        .split('|')
        .enumerate()
        .map(|(i, cell_text)| {
            let trimmed = cell_text.trim();
            let inlines = parse_inline(trimmed);
            if let Some(align) = alignments.get(i).filter(|a| *a != "left") {
                Node::branch_with_attrs(
                    NodeType::TableCell,
                    table_cell_attrs(align),
                    Fragment::from_vec(inlines),
                )
            } else {
                Node::branch(NodeType::TableCell, Fragment::from_vec(inlines))
            }
        })
        .collect()
}

// ── Tree-sitter helpers ─────────────────────────────────────────────────

/// Extract the raw text of a tree-sitter node.
fn node_text<'a>(node: &arborium_tree_sitter::Node, source: &'a str) -> &'a str {
    &source[node.start_byte()..node.end_byte()]
}

/// Detect heading level from atx_heading marker children.
fn detect_heading_level(node: &arborium_tree_sitter::Node) -> u8 {
    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        match child.kind() {
            "atx_h1_marker" => return 1,
            "atx_h2_marker" => return 2,
            "atx_h3_marker" => return 3,
            "atx_h4_marker" => return 4,
            "atx_h5_marker" => return 5,
            "atx_h6_marker" => return 6,
            _ => {}
        }
    }
    1
}

/// Extract inline text from a heading, skipping the marker.
fn heading_inline_text(node: &arborium_tree_sitter::Node, source: &str) -> String {
    let mut cursor = node.walk();
    for child in node.named_children(&mut cursor) {
        if child.kind() == "inline" {
            return node_text(&child, source).to_string();
        }
    }
    // Fallback: extract text after the marker
    let full = node_text(node, source);
    let trimmed = full.trim_start_matches('#').trim_start();
    trimmed.to_string()
}

/// Extract the info string (language) from a fenced code block node.
fn code_block_language<'a>(
    node: &arborium_tree_sitter::Node,
    source: &'a str,
) -> Option<&'a str> {
    for i in 0..node.child_count() {
        if let Some(child) = node.child(i as u32)
            && child.kind() == "info_string"
        {
            let text = &source[child.start_byte()..child.end_byte()];
            let lang = text.trim();
            if !lang.is_empty() {
                return Some(lang);
            }
        }
    }
    None
}

/// Extract the content of a fenced code block (without fences).
///
/// When a fenced code block appears at the very end of a document with no
/// trailing newline after the closing fence (e.g. ` ```lang\ncontent\n``` `
/// with no final `\n`), tree-sitter's markdown grammar absorbs the closing
/// ` ``` ` characters into the `code_fence_content` node rather than
/// recognising them as a `fenced_code_block_delimiter`. This function strips
/// any such trailing fence sequence so callers always receive clean content
/// free of stray backticks.
fn code_block_content(
    node: &arborium_tree_sitter::Node,
    source: &str,
) -> Option<String> {
    for i in 0..node.child_count() {
        if let Some(child) = node.child(i as u32)
            && child.kind() == "code_fence_content"
        {
            let raw = &source[child.start_byte()..child.end_byte()];
            // Strip a trailing closing fence (` ``` `) that tree-sitter
            // absorbed into the content node when the document ends without
            // a newline after the closing fence.  The fence may consist of
            // three or more backticks; we strip the longest trailing run.
            let content = strip_trailing_fence(raw);
            return Some(content.to_string());
        }
    }
    None
}

/// Strip a trailing code fence (three or more consecutive backticks at the
/// start of the last line, optionally preceded by a newline) from content
/// that tree-sitter has incorrectly absorbed into `code_fence_content`.
///
/// Returns the stripped content unchanged if no trailing fence is present.
fn strip_trailing_fence(content: &str) -> &str {
    // Find the last newline in the content. If the text after that newline
    // consists entirely of backtick characters (and nothing else), it is a
    // stray fence and must be removed together with the preceding newline.
    if let Some(last_nl) = content.rfind('\n') {
        let after_nl = &content[last_nl + 1..];
        if !after_nl.is_empty() && after_nl.chars().all(|c| c == '`') {
            // Everything from the last `\n` onward is a fence — strip it.
            return &content[..last_nl + 1];
        }
    }
    content
}

/// Detect the start number of an ordered list by parsing the first marker.
fn detect_ordered_list_start(
    list_node: &arborium_tree_sitter::Node,
    source: &str,
) -> i64 {
    let mut cursor = list_node.walk();
    for child in list_node.children(&mut cursor) {
        if child.kind() == "list_item" {
            let mut item_cursor = child.walk();
            for item_child in child.children(&mut item_cursor) {
                if item_child.kind() == "list_marker_dot"
                    || item_child.kind() == "list_marker_parenthesis"
                {
                    let marker_text = node_text(&item_child, source);
                    // Parse "1." or "1)" → 1
                    let num_str: String =
                        marker_text.chars().take_while(|c| c.is_ascii_digit()).collect();
                    if let Ok(n) = num_str.parse::<i64>() {
                        return n;
                    }
                }
            }
        }
    }
    1
}

// ── Inline parsing ──────────────────────────────────────────────────────
//
// Since arborium-markdown-inline conflicts with arborium-tree-sitter due to
// native library linking, we parse inline markdown manually. This handles:
// **bold**, *italic*, `code`, ~~strikethrough~~, [link](url), ![image](url),
// hard line breaks (two trailing spaces + newline), and backslash escapes.

/// Parse inline markdown text into a sequence of kode-doc nodes.
fn parse_inline(text: &str) -> Vec<Node> {
    let text = text.trim_end_matches('\n').trim_end_matches('\r');
    if text.is_empty() {
        return vec![];
    }
    let chars: Vec<char> = text.chars().collect();
    let mut nodes = Vec::new();
    parse_inline_recursive(&chars, &[], &mut nodes);
    nodes
}

/// Recursively parse inline content, accumulating text with the given marks.
fn parse_inline_recursive(chars: &[char], marks: &[Mark], out: &mut Vec<Node>) {
    let mut i = 0;
    let mut buf = String::new();

    while i < chars.len() {
        match chars[i] {
            // Backslash escape
            '\\' if i + 1 < chars.len() && is_punctuation(chars[i + 1]) => {
                buf.push(chars[i + 1]);
                i += 2;
            }

            // Hard line break: two+ spaces before newline
            ' ' if has_hard_break_at(chars, i) => {
                flush_text(&mut buf, marks, out);
                out.push(Node::leaf(NodeType::HardBreak));
                // Skip trailing spaces and the newline
                while i < chars.len() && chars[i] == ' ' {
                    i += 1;
                }
                if i < chars.len() && chars[i] == '\n' {
                    i += 1;
                }
            }

            '\n' => {
                flush_text(&mut buf, marks, out);
                out.push(Node::leaf(NodeType::HardBreak));
                i += 1;
            }

            // HTML <br> tag → HardBreak (used in table cells where `  \n` is not viable)
            '<' if matches_br_tag(chars, i) => {
                flush_text(&mut buf, marks, out);
                out.push(Node::leaf(NodeType::HardBreak));
                i += skip_br_tag(chars, i);
            }

            // Image: ![alt](src "title")
            '!' if i + 1 < chars.len() && chars[i + 1] == '[' => {
                if let Some((alt, src, title, end)) = parse_image_or_link(chars, i + 1) {
                    flush_text(&mut buf, marks, out);
                    out.push(Node::leaf_with_attrs(
                        NodeType::Image,
                        image_attrs(&src, &alt, title.as_deref()),
                    ));
                    i = end;
                } else {
                    buf.push('!');
                    i += 1;
                }
            }

            // Link: [text](url "title")
            '[' => {
                if let Some((link_text, href, title, end)) = parse_image_or_link(chars, i) {
                    flush_text(&mut buf, marks, out);
                    let link_mark =
                        Mark::with_attrs(MarkType::Link, link_attrs(&href, title.as_deref()));
                    let mut link_marks = marks.to_vec();
                    link_marks = link_mark.add_to_set(&link_marks);
                    // Parse the link text for nested inline formatting
                    let link_chars: Vec<char> = link_text.chars().collect();
                    parse_inline_recursive(&link_chars, &link_marks, out);
                    i = end;
                } else {
                    buf.push('[');
                    i += 1;
                }
            }

            // Inline code: `code` or ``code``
            '`' => {
                let backtick_count = count_char(chars, i, '`');
                if let Some((content, end)) =
                    find_backtick_content(chars, i + backtick_count, backtick_count)
                {
                    flush_text(&mut buf, marks, out);
                    let code_mark = Mark::new(MarkType::Code);
                    let code_marks = code_mark.add_to_set(marks);
                    if !content.is_empty() {
                        out.push(Node::new_text_with_marks(&content, code_marks));
                    }
                    i = end;
                } else {
                    buf.push('`');
                    i += 1;
                }
            }

            // Strikethrough: ~~text~~
            '~' if i + 1 < chars.len() && chars[i + 1] == '~' => {
                if let Some((content, end)) = find_delimited(chars, i + 2, "~~") {
                    flush_text(&mut buf, marks, out);
                    let strike_mark = Mark::new(MarkType::Strike);
                    let mut inner_marks = marks.to_vec();
                    inner_marks = strike_mark.add_to_set(&inner_marks);
                    let inner_chars: Vec<char> = content.chars().collect();
                    parse_inline_recursive(&inner_chars, &inner_marks, out);
                    i = end;
                } else {
                    buf.push('~');
                    i += 1;
                }
            }

            // Bold+italic: ***text*** (3 asterisks)
            '*' if i + 2 < chars.len()
                && chars[i + 1] == '*'
                && chars[i + 2] == '*'
                && (i + 3 >= chars.len() || chars[i + 3] != '*') =>
            {
                if let Some((content, end)) = find_delimited(chars, i + 3, "***") {
                    flush_text(&mut buf, marks, out);
                    let strong_mark = Mark::new(MarkType::Strong);
                    let em_mark = Mark::new(MarkType::Em);
                    let mut inner_marks = marks.to_vec();
                    inner_marks = strong_mark.add_to_set(&inner_marks);
                    inner_marks = em_mark.add_to_set(&inner_marks);
                    let inner_chars: Vec<char> = content.chars().collect();
                    parse_inline_recursive(&inner_chars, &inner_marks, out);
                    i = end;
                } else {
                    buf.push('*');
                    i += 1;
                }
            }

            // Bold: **text**
            '*' if i + 1 < chars.len() && chars[i + 1] == '*' => {
                if let Some((content, end)) = find_delimited(chars, i + 2, "**") {
                    flush_text(&mut buf, marks, out);
                    let strong_mark = Mark::new(MarkType::Strong);
                    let mut inner_marks = marks.to_vec();
                    inner_marks = strong_mark.add_to_set(&inner_marks);
                    let inner_chars: Vec<char> = content.chars().collect();
                    parse_inline_recursive(&inner_chars, &inner_marks, out);
                    i = end;
                } else {
                    buf.push('*');
                    i += 1;
                }
            }

            // Italic: *text*
            '*' => {
                if let Some((content, end)) = find_delimited(chars, i + 1, "*") {
                    // Ensure we didn't match the start of **
                    if !content.is_empty() {
                        flush_text(&mut buf, marks, out);
                        let em_mark = Mark::new(MarkType::Em);
                        let mut inner_marks = marks.to_vec();
                        inner_marks = em_mark.add_to_set(&inner_marks);
                        let inner_chars: Vec<char> = content.chars().collect();
                        parse_inline_recursive(&inner_chars, &inner_marks, out);
                        i = end;
                    } else {
                        buf.push('*');
                        i += 1;
                    }
                } else {
                    buf.push('*');
                    i += 1;
                }
            }

            // Bold with underscores: __text__
            // Only treat as delimiter when preceded by a word boundary
            '_' if i + 1 < chars.len()
                && chars[i + 1] == '_'
                && is_delimiter_boundary(if i > 0 { Some(chars[i - 1]) } else { None }) =>
            {
                if let Some((content, end)) = find_delimited(chars, i + 2, "__") {
                    // The character after the closing __ must also be a boundary
                    if is_delimiter_boundary(chars.get(end).copied()) {
                        flush_text(&mut buf, marks, out);
                        let strong_mark = Mark::new(MarkType::Strong);
                        let mut inner_marks = marks.to_vec();
                        inner_marks = strong_mark.add_to_set(&inner_marks);
                        let inner_chars: Vec<char> = content.chars().collect();
                        parse_inline_recursive(&inner_chars, &inner_marks, out);
                        i = end;
                    } else {
                        buf.push('_');
                        i += 1;
                    }
                } else {
                    buf.push('_');
                    i += 1;
                }
            }

            // Italic with underscore: _text_
            // Only treat as delimiter when preceded by a word boundary
            '_' if is_delimiter_boundary(if i > 0 { Some(chars[i - 1]) } else { None }) => {
                if let Some((content, end)) = find_delimited(chars, i + 1, "_") {
                    // The character after the closing _ must also be a boundary
                    if !content.is_empty()
                        && is_delimiter_boundary(chars.get(end).copied())
                    {
                        flush_text(&mut buf, marks, out);
                        let em_mark = Mark::new(MarkType::Em);
                        let mut inner_marks = marks.to_vec();
                        inner_marks = em_mark.add_to_set(&inner_marks);
                        let inner_chars: Vec<char> = content.chars().collect();
                        parse_inline_recursive(&inner_chars, &inner_marks, out);
                        i = end;
                    } else {
                        buf.push('_');
                        i += 1;
                    }
                } else {
                    buf.push('_');
                    i += 1;
                }
            }

            // Regular character
            c => {
                buf.push(c);
                i += 1;
            }
        }
    }

    flush_text(&mut buf, marks, out);
}

/// Flush accumulated plain text as a Text node with the given marks.
fn flush_text(buf: &mut String, marks: &[Mark], out: &mut Vec<Node>) {
    if buf.is_empty() {
        return;
    }
    let text = std::mem::take(buf);
    if marks.is_empty() {
        out.push(Node::new_text(&text));
    } else {
        out.push(Node::new_text_with_marks(&text, marks.to_vec()));
    }
}

/// Check if a character is a word boundary for underscore-based delimiters.
///
/// Underscores inside words (e.g., `foo_bar_baz`) must NOT trigger emphasis.
/// A boundary is whitespace, punctuation, or an implicit start/end of string.
fn is_delimiter_boundary(c: Option<char>) -> bool {
    match c {
        None => true,
        Some(ch) => ch.is_whitespace() || is_punctuation(ch),
    }
}

/// Check if a character is ASCII punctuation (for backslash escapes).
fn is_punctuation(c: char) -> bool {
    matches!(
        c,
        '!' | '"'
            | '#'
            | '$'
            | '%'
            | '&'
            | '\''
            | '('
            | ')'
            | '*'
            | '+'
            | ','
            | '-'
            | '.'
            | '/'
            | ':'
            | ';'
            | '<'
            | '='
            | '>'
            | '?'
            | '@'
            | '['
            | '\\'
            | ']'
            | '^'
            | '_'
            | '`'
            | '{'
            | '|'
            | '}'
            | '~'
    )
}

/// Check if position `i` starts a hard line break (2+ spaces followed by \n).
/// Check if chars at position `i` start with `<br>`, `<br/>`, or `<br />` (case-insensitive).
fn matches_br_tag(chars: &[char], i: usize) -> bool {
    skip_br_tag(chars, i) > 0
}

/// Return the number of characters consumed by a `<br>` variant at position `i`, or 0 if none.
fn skip_br_tag(chars: &[char], i: usize) -> usize {
    let remaining: String = chars[i..].iter().collect();
    for pat in ["<br>", "<br/>", "<br />", "<BR>", "<BR/>", "<BR />"] {
        if remaining.starts_with(pat) {
            return pat.len();
        }
    }
    0
}

fn has_hard_break_at(chars: &[char], i: usize) -> bool {
    // Need at least 2 spaces then a newline
    if i + 2 >= chars.len() {
        return false;
    }
    let mut j = i;
    while j < chars.len() && chars[j] == ' ' {
        j += 1;
    }
    let space_count = j - i;
    space_count >= 2 && j < chars.len() && chars[j] == '\n'
}

/// Count consecutive occurrences of `c` starting at position `i`.
fn count_char(chars: &[char], i: usize, c: char) -> usize {
    let mut count = 0;
    let mut j = i;
    while j < chars.len() && chars[j] == c {
        count += 1;
        j += 1;
    }
    count
}

/// Find content enclosed by `count` backticks, handling inline code spans.
fn find_backtick_content(
    chars: &[char],
    start: usize,
    count: usize,
) -> Option<(String, usize)> {
    let mut i = start;
    while i + count <= chars.len() {
        if count_char(chars, i, '`') == count {
            let raw: String = chars[start..i].iter().collect();
            // CommonMark §6.1: If the resulting string both begins AND ends
            // with a space character, and does not consist entirely of space
            // characters, a single space is stripped from the front and back.
            // We compare `chars().count()` (not `len()`) so multi-byte
            // codepoints don't break the "only spaces" guard.
            let char_count = raw.chars().count();
            let content = if raw.starts_with(' ')
                && raw.ends_with(' ')
                && char_count > 1
                && raw.chars().any(|c| c != ' ')
            {
                raw[1..raw.len() - 1].to_string()
            } else {
                raw
            };
            return Some((content, i + count));
        }
        i += 1;
    }
    None
}

/// Find content delimited by the given string (e.g., "**", "*", "~~").
fn find_delimited(chars: &[char], start: usize, delim: &str) -> Option<(String, usize)> {
    let delim_chars: Vec<char> = delim.chars().collect();
    let dlen = delim_chars.len();

    if start >= chars.len() {
        return None;
    }

    let mut i = start;
    while i + dlen <= chars.len() {
        // Check for backslash escape
        if chars[i] == '\\' && i + 1 < chars.len() {
            i += 2;
            continue;
        }
        if chars[i..i + dlen] == delim_chars[..] {
            let content: String = chars[start..i].iter().collect();
            return Some((content, i + dlen));
        }
        i += 1;
    }
    None
}

/// Parse a link or image: `[text](url "title")`.
/// `start` should point at the opening `[`.
/// Returns `(text, url, optional_title, end_position)`.
fn parse_image_or_link(
    chars: &[char],
    start: usize,
) -> Option<(String, String, Option<String>, usize)> {
    if start >= chars.len() || chars[start] != '[' {
        return None;
    }

    // Find matching ]
    let mut depth = 0;
    let mut i = start;
    let bracket_end;
    loop {
        if i >= chars.len() {
            return None;
        }
        if chars[i] == '\\' && i + 1 < chars.len() {
            i += 2;
            continue;
        }
        if chars[i] == '[' {
            depth += 1;
        } else if chars[i] == ']' {
            depth -= 1;
            if depth == 0 {
                bracket_end = i;
                break;
            }
        }
        i += 1;
    }

    let link_text: String = chars[start + 1..bracket_end].iter().collect();

    // Expect ( immediately after ]
    let paren_start = bracket_end + 1;
    if paren_start >= chars.len() || chars[paren_start] != '(' {
        return None;
    }

    // Find matching )
    let mut j = paren_start + 1;
    // Skip whitespace
    while j < chars.len() && chars[j].is_whitespace() {
        j += 1;
    }

    // Parse URL (may be in angle brackets)
    let url;
    if j < chars.len() && chars[j] == '<' {
        // Angle-bracket URL
        j += 1;
        let url_start = j;
        while j < chars.len() && chars[j] != '>' {
            j += 1;
        }
        url = chars[url_start..j].iter().collect();
        if j < chars.len() {
            j += 1; // skip >
        }
    } else {
        // Bare URL: read until whitespace or )
        let url_start = j;
        let mut paren_depth = 0;
        while j < chars.len() && (chars[j] != ')' || paren_depth > 0) && !chars[j].is_whitespace()
        {
            if chars[j] == '(' {
                paren_depth += 1;
            } else if chars[j] == ')' {
                paren_depth -= 1;
            }
            j += 1;
        }
        url = chars[url_start..j].iter().collect();
    }

    // Skip whitespace
    while j < chars.len() && chars[j].is_whitespace() {
        j += 1;
    }

    // Optional title
    let title;
    if j < chars.len() && (chars[j] == '"' || chars[j] == '\'') {
        let quote = chars[j];
        j += 1;
        let title_start = j;
        while j < chars.len() && chars[j] != quote {
            j += 1;
        }
        title = Some(chars[title_start..j].iter().collect::<String>());
        if j < chars.len() {
            j += 1; // skip closing quote
        }
    } else {
        title = None;
    }

    // Skip whitespace and expect )
    while j < chars.len() && chars[j].is_whitespace() {
        j += 1;
    }
    if j >= chars.len() || chars[j] != ')' {
        return None;
    }

    Some((link_text, url, title, j + 1))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::attrs::{get_attr, AttrValue};

    // ── Empty document ──────────────────────────────────────────────

    #[test]
    fn parse_empty_document() {
        let doc = parse_markdown("");
        assert_eq!(doc.node_type, NodeType::Doc);
        assert_eq!(doc.child_count(), 0);
    }

    // ── Single paragraph ────────────────────────────────────────────

    #[test]
    fn parse_single_paragraph() {
        let doc = parse_markdown("Hello world\n");
        assert_eq!(doc.node_type, NodeType::Doc);
        assert_eq!(doc.child_count(), 1);

        let para = doc.child(0);
        assert_eq!(para.node_type, NodeType::Paragraph);
        assert_eq!(para.child_count(), 1);

        let text = para.child(0);
        assert_eq!(text.node_type, NodeType::Text);
        assert_eq!(text.text(), Some("Hello world"));
    }

    // ── Blank lines between paragraphs ─────────────────────────────

    #[test]
    fn parse_blank_lines_between_paragraphs() {
        let doc = parse_markdown("Hello\n\n\n\nasdasd");
        assert_eq!(doc.child_count(), 4);
        assert_eq!(doc.child(0).text_content(), "Hello");
        assert!(doc.child(1).content.children().is_empty());
        assert!(doc.child(2).content.children().is_empty());
        assert_eq!(doc.child(3).text_content(), "asdasd");
    }

    #[test]
    fn parse_single_blank_line_no_extra_paragraphs() {
        // Standard paragraph separator: \n\n produces no empty paragraphs
        let doc = parse_markdown("First\n\nSecond");
        assert_eq!(doc.child_count(), 2);
        assert_eq!(doc.child(0).text_content(), "First");
        assert_eq!(doc.child(1).text_content(), "Second");
    }

    #[test]
    fn parse_triple_newline_one_empty_paragraph() {
        // \n\n\n = standard separator (2) + 1 extra = 1 empty paragraph
        let doc = parse_markdown("AAA\n\n\nBBB");
        assert_eq!(doc.child_count(), 3);
        assert_eq!(doc.child(0).text_content(), "AAA");
        assert!(doc.child(1).content.children().is_empty());
        assert_eq!(doc.child(2).text_content(), "BBB");
    }

    // ── Heading ─────────────────────────────────────────────────────

    #[test]
    fn parse_heading() {
        let doc = parse_markdown("## My Heading\n");
        assert_eq!(doc.child_count(), 1);

        let heading = doc.child(0);
        assert_eq!(heading.node_type, NodeType::Heading);
        assert_eq!(
            get_attr(&heading.attrs, "level"),
            Some(&AttrValue::Int(2))
        );

        let text = heading.child(0);
        assert_eq!(text.node_type, NodeType::Text);
        assert_eq!(text.text(), Some("My Heading"));
    }

    // ── Bold text ───────────────────────────────────────────────────

    #[test]
    fn parse_bold_text() {
        let doc = parse_markdown("Hello **bold** world\n");
        let para = doc.child(0);
        assert_eq!(para.node_type, NodeType::Paragraph);

        // Should be: "Hello " (no marks), "bold" (Strong), " world" (no marks)
        assert_eq!(para.child_count(), 3);

        let hello = para.child(0);
        assert_eq!(hello.text(), Some("Hello "));
        assert!(hello.marks.is_empty());

        let bold = para.child(1);
        assert_eq!(bold.text(), Some("bold"));
        assert_eq!(bold.marks.len(), 1);
        assert_eq!(bold.marks[0].mark_type, MarkType::Strong);

        let world = para.child(2);
        assert_eq!(world.text(), Some(" world"));
        assert!(world.marks.is_empty());
    }

    // ── Italic text ─────────────────────────────────────────────────

    #[test]
    fn parse_italic_text() {
        let doc = parse_markdown("Hello *italic* world\n");
        let para = doc.child(0);

        assert_eq!(para.child_count(), 3);

        let italic = para.child(1);
        assert_eq!(italic.text(), Some("italic"));
        assert_eq!(italic.marks.len(), 1);
        assert_eq!(italic.marks[0].mark_type, MarkType::Em);
    }

    // ── Inline code ─────────────────────────────────────────────────

    #[test]
    fn parse_inline_code() {
        let doc = parse_markdown("Use `code` here\n");
        let para = doc.child(0);

        assert_eq!(para.child_count(), 3);

        let code = para.child(1);
        assert_eq!(code.text(), Some("code"));
        assert_eq!(code.marks.len(), 1);
        assert_eq!(code.marks[0].mark_type, MarkType::Code);
    }

    // ── Bullet list ─────────────────────────────────────────────────

    #[test]
    fn parse_bullet_list() {
        let doc = parse_markdown("- item 1\n- item 2\n");
        assert_eq!(doc.child_count(), 1);

        let list = doc.child(0);
        assert_eq!(list.node_type, NodeType::BulletList);
        assert_eq!(list.child_count(), 2);

        let item1 = list.child(0);
        assert_eq!(item1.node_type, NodeType::ListItem);
        assert_eq!(item1.child_count(), 1);

        let para = item1.child(0);
        assert_eq!(para.node_type, NodeType::Paragraph);
        assert_eq!(para.text_content(), "item 1");
    }

    // ── Ordered list ────────────────────────────────────────────────

    #[test]
    fn parse_ordered_list() {
        let doc = parse_markdown("1. first\n2. second\n");
        assert_eq!(doc.child_count(), 1);

        let list = doc.child(0);
        assert_eq!(list.node_type, NodeType::OrderedList);
        assert_eq!(list.child_count(), 2);
        assert_eq!(get_attr(&list.attrs, "start"), Some(&AttrValue::Int(1)));

        let item1 = list.child(0);
        assert_eq!(item1.node_type, NodeType::ListItem);
        assert_eq!(item1.child(0).text_content(), "first");
    }

    // ── Blockquote ──────────────────────────────────────────────────

    #[test]
    fn parse_blockquote() {
        let doc = parse_markdown("> quoted text\n");
        assert_eq!(doc.child_count(), 1);

        let bq = doc.child(0);
        assert_eq!(bq.node_type, NodeType::Blockquote);
        assert_eq!(bq.child_count(), 1);

        let para = bq.child(0);
        assert_eq!(para.node_type, NodeType::Paragraph);
        assert_eq!(para.text_content(), "quoted text");
    }

    // ── Code block ──────────────────────────────────────────────────

    #[test]
    fn parse_code_block() {
        let doc = parse_markdown("```rust\nfn main() {}\n```\n");
        assert_eq!(doc.child_count(), 1);

        let code = doc.child(0);
        assert_eq!(code.node_type, NodeType::CodeBlock);
        assert_eq!(
            get_attr(&code.attrs, "language"),
            Some(&AttrValue::String("rust".to_string()))
        );

        assert_eq!(code.child_count(), 1);
        let text = code.child(0);
        assert_eq!(text.node_type, NodeType::Text);
        assert_eq!(text.text(), Some("fn main() {}\n"));
    }

    // ── Horizontal rule ─────────────────────────────────────────────

    #[test]
    fn parse_horizontal_rule() {
        let doc = parse_markdown("---\n");
        assert_eq!(doc.child_count(), 1);

        let hr = doc.child(0);
        assert_eq!(hr.node_type, NodeType::HorizontalRule);
    }

    // ── Link ────────────────────────────────────────────────────────

    #[test]
    fn parse_link() {
        let doc = parse_markdown("Click [here](https://example.com) please\n");
        let para = doc.child(0);
        assert_eq!(para.node_type, NodeType::Paragraph);

        // "Click " (no marks), "here" (Link mark), " please" (no marks)
        assert_eq!(para.child_count(), 3);

        let link = para.child(1);
        assert_eq!(link.text(), Some("here"));
        assert_eq!(link.marks.len(), 1);
        assert_eq!(link.marks[0].mark_type, MarkType::Link);
        assert_eq!(
            get_attr(&link.marks[0].attrs, "href"),
            Some(&AttrValue::String("https://example.com".to_string()))
        );
    }

    // ── Mixed content ───────────────────────────────────────────────

    #[test]
    fn parse_mixed_content() {
        let md = "# Title\n\nA paragraph.\n\n- item 1\n- item 2\n\n```js\nconsole.log();\n```\n";
        let doc = parse_markdown(md);

        assert_eq!(doc.child_count(), 4);
        assert_eq!(doc.child(0).node_type, NodeType::Heading);
        assert_eq!(doc.child(1).node_type, NodeType::Paragraph);
        assert_eq!(doc.child(2).node_type, NodeType::BulletList);
        assert_eq!(doc.child(3).node_type, NodeType::CodeBlock);
    }

    // ── Bold + italic nested ────────────────────────────────────────

    #[test]
    fn parse_bold_italic_nested() {
        let doc = parse_markdown("***bold and italic***\n");
        let para = doc.child(0);

        // The outermost is **, inner is *
        // Should produce text with both Strong and Em marks
        assert_eq!(para.child_count(), 1);
        let text = para.child(0);
        assert_eq!(text.text(), Some("bold and italic"));
        assert!(text.marks.iter().any(|m| m.mark_type == MarkType::Strong));
        assert!(text.marks.iter().any(|m| m.mark_type == MarkType::Em));
    }

    // ── Hard break ──────────────────────────────────────────────────

    #[test]
    fn parse_hard_break() {
        let doc = parse_markdown("line one  \nline two\n");
        let para = doc.child(0);

        // Should be: "line one" (text), HardBreak, "line two" (text)
        assert_eq!(para.child_count(), 3);
        assert_eq!(para.child(0).text(), Some("line one"));
        assert_eq!(para.child(1).node_type, NodeType::HardBreak);
        assert_eq!(para.child(2).text(), Some("line two"));
    }

    #[test]
    fn parse_bare_newline_as_hard_break() {
        let doc = parse_markdown("line one\nline two\n");
        let para = doc.child(0);

        assert_eq!(para.child_count(), 3);
        assert_eq!(para.child(0).text(), Some("line one"));
        assert_eq!(para.child(1).node_type, NodeType::HardBreak);
        assert_eq!(para.child(2).text(), Some("line two"));
    }

    // ── Round-trip text content ─────────────────────────────────────

    #[test]
    fn round_trip_text_content() {
        let sources = [
            "Hello world\n",
            "**bold** text\n",
            "- item one\n- item two\n",
        ];

        for src in sources {
            let doc = parse_markdown(src);
            let content = doc.text_content();
            // The visible text should be present (minus markdown syntax)
            // For "**bold** text" → "bold text"
            // For "- item one\n- item two\n" → "item oneitem two"
            assert!(
                !content.is_empty(),
                "text_content should not be empty for: {src:?}"
            );
        }
    }

    // ── Image ───────────────────────────────────────────────────────

    #[test]
    fn parse_image() {
        // A standalone image (sole content of a paragraph) is promoted to ImageBlock.
        let doc = parse_markdown("![alt text](image.png)\n");
        let img = doc.child(0);

        assert_eq!(img.node_type, NodeType::ImageBlock);
        assert_eq!(
            get_attr(&img.attrs, "src"),
            Some(&AttrValue::String("image.png".to_string()))
        );
        assert_eq!(
            get_attr(&img.attrs, "alt"),
            Some(&AttrValue::String("alt text".to_string()))
        );
    }

    #[test]
    fn parse_image_inline_in_text() {
        // An image within surrounding text remains inline.
        let doc = parse_markdown("See ![photo](pic.png) here\n");
        let para = doc.child(0);
        assert_eq!(para.node_type, NodeType::Paragraph);
        assert_eq!(para.child_count(), 3);
        assert_eq!(para.child(1).node_type, NodeType::Image);
        assert_eq!(
            get_attr(&para.child(1).attrs, "src"),
            Some(&AttrValue::String("pic.png".to_string()))
        );
    }

    // ── Strikethrough ───────────────────────────────────────────────

    #[test]
    fn parse_strikethrough() {
        let doc = parse_markdown("~~deleted~~\n");
        let para = doc.child(0);

        assert_eq!(para.child_count(), 1);
        let text = para.child(0);
        assert_eq!(text.text(), Some("deleted"));
        assert_eq!(text.marks.len(), 1);
        assert_eq!(text.marks[0].mark_type, MarkType::Strike);
    }

    // ── Code block without language ─────────────────────────────────

    #[test]
    fn parse_code_block_no_language() {
        let doc = parse_markdown("```\nsome code\n```\n");
        let code = doc.child(0);
        assert_eq!(code.node_type, NodeType::CodeBlock);
        assert!(code.attrs.is_empty());
    }

    // ── Heading levels ──────────────────────────────────────────────

    #[test]
    fn parse_heading_levels() {
        for level in 1..=6 {
            let hashes = "#".repeat(level);
            let md = format!("{hashes} Level {level}\n");
            let doc = parse_markdown(&md);
            let heading = doc.child(0);
            assert_eq!(heading.node_type, NodeType::Heading);
            assert_eq!(
                get_attr(&heading.attrs, "level"),
                Some(&AttrValue::Int(level as i64))
            );
        }
    }

    // ── Link with title ─────────────────────────────────────────────

    #[test]
    fn parse_link_with_title() {
        let doc = parse_markdown("[link](http://example.com \"Example\")\n");
        let para = doc.child(0);
        let link_node = para.child(0);
        assert_eq!(link_node.marks.len(), 1);
        assert_eq!(
            get_attr(&link_node.marks[0].attrs, "title"),
            Some(&AttrValue::String("Example".to_string()))
        );
    }

    // ── Ordered list with non-1 start ───────────────────────────────

    #[test]
    fn parse_ordered_list_start_number() {
        let doc = parse_markdown("3. third\n4. fourth\n");
        let list = doc.child(0);
        assert_eq!(list.node_type, NodeType::OrderedList);
        assert_eq!(get_attr(&list.attrs, "start"), Some(&AttrValue::Int(3)));
    }

    // ── Underscore italic ───────────────────────────────────────────

    #[test]
    fn parse_underscore_italic() {
        let doc = parse_markdown("Hello _italic_ world\n");
        let para = doc.child(0);

        assert_eq!(para.child_count(), 3);

        let hello = para.child(0);
        assert_eq!(hello.text(), Some("Hello "));
        assert!(hello.marks.is_empty());

        let em = para.child(1);
        assert_eq!(em.text(), Some("italic"));
        assert_eq!(em.marks.len(), 1);
        assert_eq!(em.marks[0].mark_type, MarkType::Em);

        let world = para.child(2);
        assert_eq!(world.text(), Some(" world"));
        assert!(world.marks.is_empty());
    }

    // ── Underscore bold ─────────────────────────────────────────────

    #[test]
    fn parse_underscore_bold() {
        let doc = parse_markdown("Hello __bold__ world\n");
        let para = doc.child(0);

        assert_eq!(para.child_count(), 3);

        let hello = para.child(0);
        assert_eq!(hello.text(), Some("Hello "));
        assert!(hello.marks.is_empty());

        let strong = para.child(1);
        assert_eq!(strong.text(), Some("bold"));
        assert_eq!(strong.marks.len(), 1);
        assert_eq!(strong.marks[0].mark_type, MarkType::Strong);

        let world = para.child(2);
        assert_eq!(world.text(), Some(" world"));
        assert!(world.marks.is_empty());
    }

    // ── Underscores inside words should NOT trigger emphasis ────────

    #[test]
    fn underscores_inside_words_are_literal() {
        let doc = parse_markdown("foo_bar_baz\n");
        let para = doc.child(0);

        // Should be a single text node with no marks
        assert_eq!(para.child_count(), 1);
        let text = para.child(0);
        assert_eq!(text.text(), Some("foo_bar_baz"));
        assert!(text.marks.is_empty());
    }

    // ── Nested list items ───────────────────────────────────────────

    #[test]
    fn parse_nested_list() {
        let md = "- parent\n  - child\n";
        let doc = parse_markdown(md);
        assert_eq!(doc.child_count(), 1);

        let outer = doc.child(0);
        assert_eq!(outer.node_type, NodeType::BulletList);
        // tree-sitter may represent this as a single item with a nested list,
        // or as two items — assert that it parses without panicking and
        // produces at least one list item.
        assert!(outer.child_count() >= 1);
        assert_eq!(outer.child(0).node_type, NodeType::ListItem);
    }

    // ── Backtick edge case: only backticks ──────────────────────────

    #[test]
    fn backtick_only_content_no_panic() {
        // Should NOT panic even when the string is only backticks with
        // no closing delimiter.
        let doc = parse_markdown("```\n");
        // This is a fenced code block opening; tree-sitter handles it.
        // The key assertion is that we don't panic.
        assert!(doc.child_count() <= 1);

        // A single backtick with no match should be literal.
        let doc2 = parse_markdown("` only backtick\n");
        let para = doc2.child(0);
        let content = para.text_content();
        assert!(content.contains('`'));
    }

    // ── find_backtick_content does not panic on empty input ─────────

    #[test]
    fn find_backtick_content_empty_slice() {
        // Regression: `while i <= chars.len() - count` would underflow
        // when chars is empty and count > 0.
        let result = find_backtick_content(&[], 0, 1);
        assert!(result.is_none());
    }

    // ── Table parsing ──────────────────────────────────────────────

    #[test]
    fn parse_simple_table() {
        let doc = parse_markdown("| A | B |\n| --- | --- |\n| 1 | 2 |");
        assert_eq!(doc.child_count(), 1);

        let table = doc.child(0);
        assert_eq!(table.node_type, NodeType::Table);
        assert_eq!(table.child_count(), 2);

        // First child: TableHeader
        let header = table.child(0);
        assert_eq!(header.node_type, NodeType::TableHeader);
        assert_eq!(header.child_count(), 2);
        assert_eq!(header.child(0).node_type, NodeType::TableCell);
        assert_eq!(header.child(0).text_content(), "A");
        assert_eq!(header.child(1).node_type, NodeType::TableCell);
        assert_eq!(header.child(1).text_content(), "B");

        // Second child: TableRow (data row)
        let row = table.child(1);
        assert_eq!(row.node_type, NodeType::TableRow);
        assert_eq!(row.child_count(), 2);
        assert_eq!(row.child(0).text_content(), "1");
        assert_eq!(row.child(1).text_content(), "2");
    }

    #[test]
    fn parse_table_with_inline_formatting() {
        let doc =
            parse_markdown("| **bold** | *italic* |\n| --- | --- |\n| plain | `code` |");

        let table = doc.child(0);
        assert_eq!(table.node_type, NodeType::Table);

        // Header cells: bold and italic marks
        let header = table.child(0);
        assert_eq!(header.node_type, NodeType::TableHeader);
        assert_eq!(header.child_count(), 2);

        let bold_cell = header.child(0);
        assert_eq!(bold_cell.node_type, NodeType::TableCell);
        assert_eq!(bold_cell.child_count(), 1);
        let bold_text = bold_cell.child(0);
        assert_eq!(bold_text.text(), Some("bold"));
        assert_eq!(bold_text.marks.len(), 1);
        assert_eq!(bold_text.marks[0].mark_type, MarkType::Strong);

        let italic_cell = header.child(1);
        assert_eq!(italic_cell.node_type, NodeType::TableCell);
        assert_eq!(italic_cell.child_count(), 1);
        let italic_text = italic_cell.child(0);
        assert_eq!(italic_text.text(), Some("italic"));
        assert_eq!(italic_text.marks.len(), 1);
        assert_eq!(italic_text.marks[0].mark_type, MarkType::Em);

        // Body cells: plain text and code mark
        let row = table.child(1);
        assert_eq!(row.node_type, NodeType::TableRow);
        assert_eq!(row.child_count(), 2);

        let plain_cell = row.child(0);
        assert_eq!(plain_cell.text_content(), "plain");
        assert_eq!(plain_cell.child_count(), 1);
        assert!(plain_cell.child(0).marks.is_empty());

        let code_cell = row.child(1);
        assert_eq!(code_cell.text_content(), "code");
        assert_eq!(code_cell.child_count(), 1);
        let code_text = code_cell.child(0);
        assert_eq!(code_text.marks.len(), 1);
        assert_eq!(code_text.marks[0].mark_type, MarkType::Code);
    }

    #[test]
    fn parse_table_multiple_rows() {
        let doc = parse_markdown("| X |\n| --- |\n| a |\n| b |\n| c |");

        let table = doc.child(0);
        assert_eq!(table.node_type, NodeType::Table);
        // 1 header + 3 data rows = 4 children
        assert_eq!(table.child_count(), 4);

        assert_eq!(table.child(0).node_type, NodeType::TableHeader);
        assert_eq!(table.child(1).node_type, NodeType::TableRow);
        assert_eq!(table.child(2).node_type, NodeType::TableRow);
        assert_eq!(table.child(3).node_type, NodeType::TableRow);

        assert_eq!(table.child(0).text_content(), "X");
        assert_eq!(table.child(1).text_content(), "a");
        assert_eq!(table.child(2).text_content(), "b");
        assert_eq!(table.child(3).text_content(), "c");
    }

    #[test]
    fn parse_table_alignment() {
        let doc =
            parse_markdown("| Left | Center | Right |\n| --- | :---: | ---: |\n| a | b | c |");

        let table = doc.child(0);
        assert_eq!(table.node_type, NodeType::Table);

        let header = table.child(0);
        assert_eq!(header.node_type, NodeType::TableHeader);
        assert_eq!(header.child_count(), 3);

        // First cell: no align attr (left-aligned by default).
        let left_cell = header.child(0);
        assert_eq!(left_cell.text_content(), "Left");
        assert_eq!(
            get_attr(&left_cell.attrs, "align"),
            None,
            "left-aligned cell should have no align attr"
        );

        // Second cell: align="center".
        let center_cell = header.child(1);
        assert_eq!(center_cell.text_content(), "Center");
        assert_eq!(
            get_attr(&center_cell.attrs, "align"),
            Some(&AttrValue::String("center".to_string())),
            "second cell should have align=center"
        );

        // Third cell: align="right".
        let right_cell = header.child(2);
        assert_eq!(right_cell.text_content(), "Right");
        assert_eq!(
            get_attr(&right_cell.attrs, "align"),
            Some(&AttrValue::String("right".to_string())),
            "third cell should have align=right"
        );

        // Data row cells should also inherit alignment attrs.
        let data_row = table.child(1);
        assert_eq!(data_row.child_count(), 3);
        assert_eq!(get_attr(&data_row.child(0).attrs, "align"), None);
        assert_eq!(
            get_attr(&data_row.child(1).attrs, "align"),
            Some(&AttrValue::String("center".to_string()))
        );
        assert_eq!(
            get_attr(&data_row.child(2).attrs, "align"),
            Some(&AttrValue::String("right".to_string()))
        );
    }
}

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

    #[test]
    fn double_underscores_at_word_boundary_is_bold() {
        // CommonMark: __init__ IS bold when at word boundaries
        let doc = parse_markdown("__init__\n");
        let para = doc.child(0);
        assert_eq!(para.node_type, NodeType::Paragraph);
        assert_eq!(para.child_count(), 1);
        let text = para.child(0);
        assert_eq!(text.text(), Some("init"));
        assert!(!text.marks.is_empty());
    }

    #[test]
    fn single_underscores_dunder_is_literal() {
        let doc = parse_markdown("_foo_bar_\n");
        let para = doc.child(0);
        // Should be literal text, not italic
        assert_eq!(para.text_content(), "_foo_bar_");
    }

}

// ── Regression tests for code block content extraction ──────────────────────

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

    /// Regression test for KYO-220: when a fenced code block appears at the
    /// very end of a document without a trailing newline after the closing
    /// fence, tree-sitter absorbs the closing ` ``` ` into `code_fence_content`.
    /// `strip_trailing_fence` must remove it so callers receive clean content.
    #[test]
    fn strip_trailing_fence_removes_backticks() {
        assert_eq!(strip_trailing_fence("type: chart\n```"), "type: chart\n");
        assert_eq!(strip_trailing_fence("type: chart\n"), "type: chart\n");
        assert_eq!(strip_trailing_fence("type: chart"), "type: chart");
        assert_eq!(strip_trailing_fence(""), "");
        // Four backticks (longer fence) also stripped
        assert_eq!(strip_trailing_fence("content\n````"), "content\n");
        // Backtick not at start of last line — NOT stripped
        assert_eq!(
            strip_trailing_fence("foo: bar`\nbaz: qux"),
            "foo: bar`\nbaz: qux"
        );
        // Only backticks in last "line" after a newline triggers the strip
        assert_eq!(
            strip_trailing_fence("line1\nline2\n```"),
            "line1\nline2\n"
        );
    }

    /// Regression test for KYO-220: chartml code block content must be clean
    /// YAML (no trailing fence backticks) regardless of whether the markdown
    /// has a trailing newline after the closing fence.
    ///
    /// This is the key scenario: the Visual mode editor's content sync effect
    /// calls `serialize_markdown` which trims trailing whitespace and removes
    /// the trailing `\n`. When `parse_markdown` re-ingests the trimmed form,
    /// tree-sitter absorbs the closing ` ``` ` into `code_fence_content`.
    /// Without the fix the WYSIWYG editor passes `"type: chart\n\`\`\`"` to
    /// the chartml YAML parser, which fails with "found character that cannot
    /// start any token at line 2 column 1".
    #[test]
    fn code_block_content_clean_without_trailing_newline() {
        // The serializer produces this form (no trailing `\n` after the fence).
        let md_no_newline = "```chartml\ntype: chart\nversion: 1\n```";
        let doc = parse_markdown(md_no_newline);
        assert_eq!(doc.child_count(), 1);
        let code = doc.child(0);
        assert_eq!(code.node_type, NodeType::CodeBlock);
        let text = code.text_content();
        assert!(
            !text.contains('`'),
            "content must not contain backticks, got: {:?}",
            text
        );
        assert_eq!(text.trim(), "type: chart\nversion: 1");
    }

    /// With a trailing newline tree-sitter behaves correctly — verify we don't
    /// accidentally break the normal case.
    #[test]
    fn code_block_content_clean_with_trailing_newline() {
        let md_with_newline = "```chartml\ntype: chart\nversion: 1\n```\n";
        let doc = parse_markdown(md_with_newline);
        assert_eq!(doc.child_count(), 1);
        let code = doc.child(0);
        assert_eq!(code.node_type, NodeType::CodeBlock);
        let text = code.text_content();
        assert!(
            !text.contains('`'),
            "content must not contain backticks, got: {:?}",
            text
        );
        assert_eq!(text.trim(), "type: chart\nversion: 1");
    }

    /// Parse → serialize → parse roundtrip must produce clean content.
    /// Before the fix, the round-tripped content was `"type: chart\nversion: 1\n\`\`\`"`
    /// because serialize drops the trailing `\n` and the re-parse absorbs the fence.
    #[test]
    fn chartml_roundtrip_produces_clean_content() {
        let md = "```chartml\ntype: chart\nversion: 1\n```\n";
        let doc = parse_markdown(md);
        let serialized = crate::serialize::serialize_markdown(&doc);

        // Serializer trims trailing whitespace — no trailing `\n` after fence.
        assert_eq!(serialized, "```chartml\ntype: chart\nversion: 1\n```");

        // Re-parse the serialized form — this is exactly what the editor's
        // content sync effect does after calling `to_markdown()`.
        let doc2 = parse_markdown(&serialized);
        assert_eq!(doc2.child_count(), 1);
        let code2 = doc2.child(0);
        let text2 = code2.text_content();
        assert!(
            !text2.contains('`'),
            "Round-tripped content must not contain backticks, got: {:?}",
            text2
        );
        assert_eq!(text2.trim(), "type: chart\nversion: 1");
    }

    /// Multi-line YAML block must not include any fence on any line.
    #[test]
    fn chartml_multiline_excludes_fence() {
        let md = "```chartml\ntype: chart\nversion: 1\ntitle: My Chart\ndata: sales\nvisualize:\n  type: bar\n  columns: month\n  rows: revenue\n```";
        let doc = parse_markdown(md);
        let code = doc.child(0);
        let text = code.text_content();
        for line in text.lines() {
            assert!(
                !line.starts_with("```"),
                "No line should start with ```, line={:?}, content={:?}",
                line,
                text
            );
        }
    }
}

// ── ImageBlock and FileBlock parsing tests ────────────────────────────────────

#[cfg(test)]
mod image_block_tests {
    use super::*;
    use crate::attrs::{get_attr, AttrValue};

    #[test]
    fn image_only_paragraph_promoted_to_image_block() {
        // A paragraph containing ONLY an image should be promoted to ImageBlock.
        let doc = parse_markdown("![A photo](photo.png)\n");
        assert_eq!(doc.child_count(), 1);

        let img = doc.child(0);
        assert_eq!(img.node_type, NodeType::ImageBlock);
        assert_eq!(
            get_attr(&img.attrs, "src"),
            Some(&AttrValue::String("photo.png".to_string()))
        );
        assert_eq!(
            get_attr(&img.attrs, "alt"),
            Some(&AttrValue::String("A photo".to_string()))
        );
    }

    #[test]
    fn image_with_surrounding_text_stays_inline() {
        // A paragraph with text alongside an image should remain a paragraph.
        let doc = parse_markdown("See ![img](pic.png) here\n");
        assert_eq!(doc.child_count(), 1);

        let para = doc.child(0);
        assert_eq!(para.node_type, NodeType::Paragraph);
        // Should have: "See " (text), Image, " here" (text)
        assert_eq!(para.child_count(), 3);
        assert_eq!(para.child(1).node_type, NodeType::Image);
    }

    #[test]
    fn image_block_round_trip() {
        // ImageBlock serializes as `![alt](src)` — same as inline but at block level.
        // When re-parsed, a standalone image paragraph gets promoted back.
        let doc = parse_markdown("![alt text](image.png)\n");
        assert_eq!(doc.child(0).node_type, NodeType::ImageBlock);

        let serialized = crate::serialize::serialize_markdown(&doc);
        assert_eq!(serialized, "![alt text](image.png)");

        // Re-parsing the serialized form should give us ImageBlock again
        let doc2 = parse_markdown(&serialized);
        assert_eq!(doc2.child(0).node_type, NodeType::ImageBlock);
    }

    #[test]
    fn image_block_among_other_blocks() {
        let doc = parse_markdown("# Title\n\n![photo](pic.png)\n\nSome text");
        assert_eq!(doc.child_count(), 3);
        assert_eq!(doc.child(0).node_type, NodeType::Heading);
        assert_eq!(doc.child(1).node_type, NodeType::ImageBlock);
        assert_eq!(doc.child(2).node_type, NodeType::Paragraph);
    }
}

#[cfg(test)]
mod file_block_tests {
    use super::*;
    use crate::attrs::{get_attr, AttrValue};

    #[test]
    fn link_matching_pattern_becomes_file_block() {
        let config = MarkdownConfig {
            file_link_patterns: vec!["/attachments/".to_string()],
        };
        let doc = parse_markdown_with_config(
            "[report.pdf](/attachments/abc123/report.pdf)\n",
            &config,
        );
        assert_eq!(doc.child_count(), 1);

        let file = doc.child(0);
        assert_eq!(file.node_type, NodeType::FileBlock);
        assert_eq!(
            get_attr(&file.attrs, "href"),
            Some(&AttrValue::String(
                "/attachments/abc123/report.pdf".to_string()
            ))
        );
        assert_eq!(
            get_attr(&file.attrs, "filename"),
            Some(&AttrValue::String("report.pdf".to_string()))
        );
    }

    #[test]
    fn link_not_matching_pattern_stays_as_link() {
        let config = MarkdownConfig {
            file_link_patterns: vec!["/attachments/".to_string()],
        };
        let doc = parse_markdown_with_config(
            "[click here](https://example.com)\n",
            &config,
        );
        assert_eq!(doc.child_count(), 1);

        let para = doc.child(0);
        assert_eq!(para.node_type, NodeType::Paragraph);
        let link_node = para.child(0);
        assert_eq!(link_node.node_type, NodeType::Text);
        assert_eq!(link_node.marks.len(), 1);
        assert_eq!(link_node.marks[0].mark_type, crate::mark::MarkType::Link);
    }

    #[test]
    fn link_with_no_config_stays_as_link() {
        // Default config has no patterns — links are never promoted to FileBlock.
        let doc = parse_markdown("[report.pdf](/attachments/abc123/report.pdf)\n");
        assert_eq!(doc.child_count(), 1);

        let para = doc.child(0);
        assert_eq!(para.node_type, NodeType::Paragraph);
    }

    #[test]
    fn file_block_round_trip() {
        let config = MarkdownConfig {
            file_link_patterns: vec!["/attachments/".to_string()],
        };
        let doc = parse_markdown_with_config(
            "[doc.pdf](/attachments/file-001/doc.pdf)\n",
            &config,
        );
        assert_eq!(doc.child(0).node_type, NodeType::FileBlock);

        let serialized = crate::serialize::serialize_markdown(&doc);
        assert_eq!(serialized, "[doc.pdf](/attachments/file-001/doc.pdf)");

        // Re-parsing with the same config should give FileBlock again
        let doc2 = parse_markdown_with_config(&serialized, &config);
        assert_eq!(doc2.child(0).node_type, NodeType::FileBlock);
    }

    #[test]
    fn link_with_surrounding_text_not_promoted() {
        // FileBlock detection only works when the link is the only content
        let config = MarkdownConfig {
            file_link_patterns: vec!["/attachments/".to_string()],
        };
        let doc = parse_markdown_with_config(
            "Download [report.pdf](/attachments/abc/report.pdf) here\n",
            &config,
        );
        assert_eq!(doc.child_count(), 1);
        assert_eq!(doc.child(0).node_type, NodeType::Paragraph);
    }

    #[test]
    fn multiple_file_link_patterns() {
        let config = MarkdownConfig {
            file_link_patterns: vec![
                "/attachments/".to_string(),
                "/uploads/".to_string(),
            ],
        };
        let doc = parse_markdown_with_config(
            "[file.zip](/uploads/xyz/file.zip)\n",
            &config,
        );
        assert_eq!(doc.child(0).node_type, NodeType::FileBlock);
    }
}
