//! Markdown parsing (Markdown → Lex import)
//!
//! Converts CommonMark Markdown to Lex documents.
//! Pipeline: Markdown string → Comrak AST → Events → IR → Lex AST

use crate::common::flat_to_nested::events_to_tree;
use crate::error::FormatError;
use crate::ir::events::Event;
use crate::ir::nodes::{InlineContent, LabelForm, TableCellAlignment};
use comrak::nodes::{AstNode, NodeValue, TableAlignment};
use comrak::{parse_document, Arena, ComrakOptions};
use lex_core::lex::assembling::stages::normalize_labels::{classify_label, Resolution};
use lex_core::lex::ast::Document;

/// Classify a label spelling captured from markdown source (or any
/// non-lex format that lifts annotations into the IR directly) into
/// its `(canonical_value, form)` pair. Unrecognised / rejected
/// spellings fall back to `(input, Community)` so the markdown text
/// round-trips losslessly through `to_lex` — the resulting AST will
/// then run through `NormalizeLabels` on a subsequent parse if the
/// `.lex` output is re-read in strict mode.
fn resolve_label_form(label: &str) -> (String, LabelForm) {
    match classify_label(label) {
        Resolution::Resolved(canonical, form) => (canonical, form),
        Resolution::Rejected(_) => (label.to_string(), LabelForm::Community),
    }
}

/// Parse Markdown string to Lex document
pub fn parse_from_markdown(source: &str) -> Result<Document, FormatError> {
    // Step 1: Parse Markdown string to Comrak AST
    let arena = Arena::new();
    let options = default_comrak_options();
    let root = parse_document(&arena, source, &options);

    // Step 2: Convert Comrak AST to IR events. A leading `# H1` is lifted out
    // as the document title (Markdown's only title concept, ADR-0002).
    let (events, document_title) = comrak_ast_to_events(root)?;

    // Step 3: Convert events to IR tree
    let mut ir_doc = events_to_tree(&events).map_err(|e| {
        FormatError::ParseError(format!("Failed to build IR tree from events: {e}"))
    })?;

    // Title model (ADR-0002): record the title on the IR so `to_lex` sets
    // `Document.title` (surviving the Lex round-trip via the title→body blank).
    // A heading-less source *with content* genuinely has no title — emit the
    // explicit `:: doc.untitled ::` no-title marker so the parser does not
    // promote the first body paragraph into a title on re-parse. A genuinely
    // empty document has no paragraph to promote, so it needs no marker;
    // emitting one there would turn an empty Markdown file into a non-empty Lex
    // document and break empty ↔ empty faithfulness.
    match document_title {
        Some(title) => {
            ir_doc.title = Some(vec![InlineContent::Text(title)]);
        }
        None if !ir_doc.children.is_empty() => {
            ir_doc
                .document_annotations
                .push(crate::ir::nodes::Annotation {
                    label: "doc.untitled".to_string(),
                    parameters: Vec::new(),
                    content: Vec::new(),
                    form: LabelForm::Canonical,
                });
        }
        None => {}
    }

    // Step 4: Convert IR to Lex AST
    let lex_doc = crate::from_ir(&ir_doc);
    Ok(lex_doc)
}

fn default_comrak_options() -> ComrakOptions<'static> {
    let mut options = ComrakOptions::default();
    options.extension.table = true;
    options.extension.strikethrough = true;
    options.extension.autolink = true;
    options.extension.tasklist = true;
    options.extension.superscript = true;
    options.extension.front_matter_delimiter = Some("---".to_string());
    // Recognize Pandoc-flavored definition lists on import (mirrors the
    // serializer side — #605). Without this, output written as
    // `Term\n\n: details` would import back as two paragraphs and the
    // round-trip would lose the `<dl>` structure.
    options.extension.description_lists = true;
    options
}

type DefinitionPieces = Option<(Vec<InlineContent>, Vec<InlineContent>)>;

/// Convert Comrak AST to IR events, plus the document title lifted from a
/// leading `# H1` (if any).
///
/// Per ADR-0002, Markdown's only title concept is a leading level-1 heading:
/// when present it is consumed here and returned as the title (the caller
/// records it on `Document.title`), *not* emitted as a body paragraph. A
/// document with no leading H1 returns `None`, and the caller marks it
/// title-less with `:: doc.untitled ::`.
fn comrak_ast_to_events<'a>(
    root: &'a AstNode<'a>,
) -> Result<(Vec<Event>, Option<String>), FormatError> {
    let mut events = vec![Event::StartDocument];

    // Per ADR-0002 the document title is the first *content* element when it is
    // a leading level-1 heading. Leading metadata nodes (e.g. YAML frontmatter)
    // are document metadata, not body content, so scan past them before
    // deciding whether the first content node is that H1.
    let children: Vec<&'a AstNode<'a>> = root.children().collect();
    let mut document_title: Option<String> = None;
    let mut title_index: Option<usize> = None;

    for (idx, child) in children.iter().enumerate() {
        if is_leading_metadata(child) {
            continue;
        }
        // First content node: lift it as the title only if it is an H1.
        if let NodeValue::Heading(heading) = &child.data.borrow().value {
            if heading.level == 1 {
                let mut title_text = String::new();
                for c in child.children() {
                    collect_text_content(c, &mut title_text);
                }
                let title_text = title_text.trim();
                // A whitespace-only H1 is not a usable title: leave it in the
                // body so the untitled-marker path applies, rather than lifting
                // an empty title that `to_lex` drops — which would leave the
                // document with neither a title nor a `:: doc.untitled ::`
                // marker and mis-promote the first body paragraph on re-parse.
                if !title_text.is_empty() {
                    document_title = Some(title_text.to_string());
                    title_index = Some(idx);
                }
            }
        }
        break;
    }

    // Emit every child except the consumed title node, preserving order so the
    // leading frontmatter stays as document metadata ahead of the body.
    let remaining = children
        .into_iter()
        .enumerate()
        .filter(|(idx, _)| Some(*idx) != title_index)
        .map(|(_, node)| node);
    collect_children_with_definitions(remaining, &mut events)?;

    events.push(Event::EndDocument);
    Ok((events, document_title))
}

/// Whether a Comrak node is document metadata that precedes body content, and
/// therefore should not count as the "first content element" when deciding the
/// document title (ADR-0002).
fn is_leading_metadata(node: &AstNode<'_>) -> bool {
    matches!(node.data.borrow().value, NodeValue::FrontMatter(_))
}

/// Collect text content from a node (for extracting document title)
fn collect_text_content<'a>(node: &'a AstNode<'a>, output: &mut String) {
    match &node.data.borrow().value {
        NodeValue::Text(text) => output.push_str(text),
        NodeValue::SoftBreak | NodeValue::LineBreak => output.push(' '),
        _ => {
            for child in node.children() {
                collect_text_content(child, output);
            }
        }
    }
}

/// Recursively collect events from a Comrak AST node
fn collect_events_from_node<'a>(
    node: &'a AstNode<'a>,
    events: &mut Vec<Event>,
) -> Result<(), FormatError> {
    let node_data = node.data.borrow();

    match &node_data.value {
        NodeValue::Document => {
            // Skip document wrapper, process children
            collect_children_with_definitions(node.children(), events)?;
        }

        NodeValue::Heading(heading) => {
            let level = heading.level as usize;

            // Just emit StartHeading - flat_to_nested will auto-close headings
            events.push(Event::StartHeading(level));

            // Process heading text (inline content)
            for child in node.children() {
                collect_inline_events(child, events)?;
            }

            // No EndHeading needed - the generic flat_to_nested converter
            // automatically closes headings when it sees a new heading at same/higher level
        }

        NodeValue::Paragraph => {
            events.push(Event::StartParagraph);

            // Process inline content
            for child in node.children() {
                collect_inline_events(child, events)?;
            }

            events.push(Event::EndParagraph);
        }

        NodeValue::List(list) => {
            let ordered = matches!(list.list_type, comrak::nodes::ListType::Ordered);
            let style = if ordered {
                crate::ir::nodes::ListStyle::Numeric
            } else {
                crate::ir::nodes::ListStyle::Bullet
            };
            events.push(Event::StartList {
                ordered,
                style,
                form: crate::ir::nodes::ListForm::Short,
            });

            // Process list items
            for child in node.children() {
                collect_events_from_node(child, events)?;
            }

            events.push(Event::EndList);
        }

        NodeValue::Item(_) => {
            events.push(Event::StartListItem);

            // Process list item content
            collect_children_with_definitions(node.children(), events)?;

            events.push(Event::EndListItem);
        }

        NodeValue::CodeBlock(code_block) => {
            let language = if code_block.info.is_empty() {
                None
            } else {
                Some(code_block.info.clone())
            };

            events.push(Event::StartVerbatim {
                language,
                subject: None,
                subject_href: None,
                parameters: Vec::new(),
            });
            events.push(Event::Inline(InlineContent::Text(
                code_block.literal.clone(),
            )));
            events.push(Event::EndVerbatim);
        }

        NodeValue::HtmlBlock(html) => {
            // Try to parse as Lex annotation
            if let Some((source_label, parameters, content)) = parse_lex_annotation(&html.literal) {
                // Tag the resolved canonical + form so `to_lex` emits
                // the blessed source spelling on the way out. A `<title>`
                // html block in the markdown source then round-trips to
                // `:: title :: ...` (shortcut form) rather than the
                // verbose canonical `:: lex.metadata.title :: ...`.
                // Issue #593.
                let (canonical_label, form) = resolve_label_form(&source_label);
                events.push(Event::StartAnnotation {
                    label: canonical_label.clone(),
                    parameters,
                    form,
                });

                if let Some(text) = content {
                    events.push(Event::StartParagraph);
                    events.push(Event::Inline(InlineContent::Text(text)));
                    events.push(Event::EndParagraph);
                    // If it had content, it's a self-contained annotation block, so we close it immediately
                    events.push(Event::EndAnnotation {
                        label: canonical_label,
                    });
                }
                // If no content, it's a start tag, closing tag will be found later
            } else if let Some(label) = parse_lex_annotation_close(&html.literal) {
                // Closing tag: resolve to the same canonical so the
                // events_to_tree state machine matches it against the
                // opener that was stored under the canonical label.
                let (canonical_label, _) = resolve_label_form(&label);
                events.push(Event::EndAnnotation {
                    label: canonical_label,
                });
            }
            // Otherwise skip HTML blocks
        }

        NodeValue::FrontMatter(content) => {
            // Parse YAML frontmatter
            // We'll treat it as a special annotation with label "frontmatter"
            // and parameters derived from the YAML content (flattened)

            let yaml_str = content.trim();
            // Remove delimiters if present (Comrak might include them or not depending on version/options)
            let yaml_str = yaml_str
                .trim_start_matches("---")
                .trim_end_matches("---")
                .trim();

            let mut parameters = vec![];

            // Simple YAML parsing: key: value
            // For a real implementation, we should use serde_yaml, but for now we'll do basic parsing
            // to avoid adding a dependency if possible, or we can just store the raw content
            // as a special parameter if we want to be safe.
            // Let's try to parse simple key-value pairs.

            for line in yaml_str.lines() {
                if let Some((key, value)) = line.split_once(':') {
                    let key = key.trim().to_string();
                    let value = value.trim().to_string();
                    // Remove quotes if present
                    let value = value.trim_matches('"').trim_matches('\'').to_string();

                    // Handle arrays like [a, b] -> just store as string for now
                    parameters.push((key, value));
                }
            }

            events.push(Event::StartAnnotation {
                label: "frontmatter".to_string(),
                parameters,
                // `frontmatter` is a synthetic label the markdown
                // import layer uses to convey YAML key/value pairs
                // to downstream serializers — there is no
                // user-typed lex spelling to preserve here.
                form: LabelForm::Canonical,
            });
            events.push(Event::EndAnnotation {
                label: "frontmatter".to_string(),
            });
        }

        NodeValue::ThematicBreak => {
            // Thematic breaks (---) don't have direct Lex equivalent, skip
        }

        NodeValue::BlockQuote => {
            // Block quotes don't have direct Lex equivalent
            // Process children as regular content
            for child in node.children() {
                collect_events_from_node(child, events)?;
            }
        }

        NodeValue::Table(_) => {
            events.push(Event::StartTable {
                caption: None,
                fullwidth: false,
            });
            for child in node.children() {
                collect_events_from_node(child, events)?;
            }
            events.push(Event::EndTable);
        }

        NodeValue::TableRow(header) => {
            events.push(Event::StartTableRow { header: *header });
            for child in node.children() {
                collect_events_from_node(child, events)?;
            }
            events.push(Event::EndTableRow);
        }

        // #605 round-trip: recognize Pandoc-style description lists Comrak
        // parsed via the `description_lists` extension. Each
        // DescriptionList → DescriptionItem → (DescriptionTerm,
        // DescriptionDetails) maps to a Definition with term + description.
        NodeValue::DescriptionList => {
            for child in node.children() {
                collect_events_from_node(child, events)?;
            }
        }

        NodeValue::DescriptionItem(_) => {
            events.push(Event::StartDefinition);
            for child in node.children() {
                collect_events_from_node(child, events)?;
            }
            events.push(Event::EndDefinition);
        }

        NodeValue::DescriptionTerm => {
            events.push(Event::StartDefinitionTerm);
            // Comrak wraps the term content in a Paragraph; flatten to inlines.
            for child in node.children() {
                if matches!(child.data.borrow().value, NodeValue::Paragraph) {
                    for inline in child.children() {
                        collect_inline_events(inline, events)?;
                    }
                } else {
                    collect_inline_events(child, events)?;
                }
            }
            events.push(Event::EndDefinitionTerm);
        }

        NodeValue::DescriptionDetails => {
            events.push(Event::StartDefinitionDescription);
            for child in node.children() {
                collect_events_from_node(child, events)?;
            }
            events.push(Event::EndDefinitionDescription);
        }

        NodeValue::TableCell => {
            let (header, align) = get_table_cell_info(node);
            events.push(Event::StartTableCell {
                header,
                align,
                colspan: 1,
                rowspan: 1,
            });

            events.push(Event::StartParagraph);
            for child in node.children() {
                collect_inline_events(child, events)?;
            }
            events.push(Event::EndParagraph);

            events.push(Event::EndTableCell);
        }

        _ => {
            // Unknown block type, skip
        }
    }

    Ok(())
}

fn get_table_cell_info<'a>(node: &'a AstNode<'a>) -> (bool, TableCellAlignment) {
    let parent = match node.parent() {
        Some(p) => p,
        None => return (false, TableCellAlignment::None),
    };

    let is_header = if let NodeValue::TableRow(header) = &parent.data.borrow().value {
        *header
    } else {
        false
    };

    let mut col_index = 0;
    let mut curr = node.previous_sibling();
    while let Some(sibling) = curr {
        col_index += 1;
        curr = sibling.previous_sibling();
    }

    let grandparent = match parent.parent() {
        Some(p) => p,
        None => return (is_header, TableCellAlignment::None),
    };

    let align = if let NodeValue::Table(table) = &grandparent.data.borrow().value {
        if col_index < table.alignments.len() {
            match table.alignments[col_index] {
                TableAlignment::Left => TableCellAlignment::Left,
                TableAlignment::Right => TableCellAlignment::Right,
                TableAlignment::Center => TableCellAlignment::Center,
                TableAlignment::None => TableCellAlignment::None,
            }
        } else {
            TableCellAlignment::None
        }
    } else {
        TableCellAlignment::None
    };

    (is_header, align)
}

/// Collect inline events from a Comrak node
fn collect_inline_events<'a>(
    node: &'a AstNode<'a>,
    events: &mut Vec<Event>,
) -> Result<(), FormatError> {
    let node_data = node.data.borrow();

    match &node_data.value {
        NodeValue::Text(text) => {
            events.push(Event::Inline(InlineContent::Text(text.clone())));
        }

        NodeValue::Strong => {
            // Collect children as bold content
            let mut children = vec![];
            for child in node.children() {
                collect_inline_content(child, &mut children)?;
            }
            events.push(Event::Inline(InlineContent::Bold(children)));
        }

        NodeValue::Emph => {
            // Collect children as italic content
            let mut children = vec![];
            for child in node.children() {
                collect_inline_content(child, &mut children)?;
            }
            events.push(Event::Inline(InlineContent::Italic(children)));
        }

        NodeValue::Code(code) => {
            events.push(Event::Inline(InlineContent::Code(code.literal.clone())));
        }

        NodeValue::Link(link) => {
            let text = collect_text_from_children(node);
            events.push(Event::Inline(InlineContent::Link {
                text,
                href: link.url.clone(),
            }));
        }

        NodeValue::SoftBreak | NodeValue::LineBreak => {
            events.push(Event::Inline(InlineContent::Text(" ".to_string())));
        }

        NodeValue::Image(link) => {
            let alt = collect_text_from_children(node);
            events.push(Event::Inline(InlineContent::Image(
                crate::ir::nodes::Image {
                    src: link.url.clone(),
                    alt,
                    title: if link.title.is_empty() {
                        None
                    } else {
                        Some(link.title.clone())
                    },
                },
            )));
        }

        _ => {
            // Skip unknown inline types
        }
    }

    Ok(())
}

fn collect_text_from_children<'a>(node: &'a AstNode<'a>) -> String {
    let mut text = String::new();
    for child in node.children() {
        collect_text_content(child, &mut text);
    }
    text
}

/// Recursively collect inline content (for nested inlines like bold/italic)
fn collect_inline_content<'a>(
    node: &'a AstNode<'a>,
    content: &mut Vec<InlineContent>,
) -> Result<(), FormatError> {
    let node_data = node.data.borrow();

    match &node_data.value {
        NodeValue::Text(text) => {
            content.push(InlineContent::Text(text.clone()));
        }

        NodeValue::Strong => {
            let mut children = vec![];
            for child in node.children() {
                collect_inline_content(child, &mut children)?;
            }
            content.push(InlineContent::Bold(children));
        }

        NodeValue::Emph => {
            let mut children = vec![];
            for child in node.children() {
                collect_inline_content(child, &mut children)?;
            }
            content.push(InlineContent::Italic(children));
        }

        NodeValue::Code(code) => {
            content.push(InlineContent::Code(code.literal.clone()));
        }

        NodeValue::Link(link) => {
            let text = collect_text_from_children(node);
            content.push(InlineContent::Link {
                text,
                href: link.url.clone(),
            });
        }

        NodeValue::SoftBreak | NodeValue::LineBreak => {
            content.push(InlineContent::Text(" ".to_string()));
        }

        _ => {}
    }

    Ok(())
}

/// Determine if a node is a heading (used to know when to stop collecting
/// definition description siblings).
fn is_heading_node(node: &AstNode<'_>) -> bool {
    matches!(node.data.borrow().value, NodeValue::Heading(_))
}

/// Attempt to parse a paragraph as a definition term of the form
/// `**Term**: Description...` returning the term inline content and any
/// inline description that appears in the same paragraph after the colon.
fn try_parse_definition_term<'a>(node: &'a AstNode<'a>) -> Result<DefinitionPieces, FormatError> {
    if !matches!(node.data.borrow().value, NodeValue::Paragraph) {
        return Ok(None);
    }

    let mut children = node.children();
    let first = match children.next() {
        Some(child) => child,
        None => return Ok(None),
    };

    if !matches!(first.data.borrow().value, NodeValue::Strong) {
        return Ok(None);
    }

    // Gather the term content from the strong node
    let mut term_inlines = Vec::new();
    for child in first.children() {
        collect_inline_content(child, &mut term_inlines)?;
    }

    let mut description_inlines = Vec::new();
    let mut saw_colon = false;

    for child in children {
        let child_data = child.data.borrow();
        match &child_data.value {
            NodeValue::Text(text) => {
                if !saw_colon {
                    let trimmed = text.trim_start();
                    if let Some(rest) = trimmed.strip_prefix(':') {
                        saw_colon = true;
                        let rest = rest.trim_start();
                        if !rest.is_empty() {
                            description_inlines.push(InlineContent::Text(rest.to_string()));
                        }
                    } else {
                        // Text before a colon means this is not a definition pattern
                        return Ok(None);
                    }
                } else if !text.is_empty() {
                    description_inlines.push(InlineContent::Text(text.clone()));
                }
            }
            // Only collect additional inline nodes after we have seen the colon
            NodeValue::Strong
            | NodeValue::Emph
            | NodeValue::Code(_)
            | NodeValue::Link(_)
            | NodeValue::SoftBreak
            | NodeValue::LineBreak => {
                if !saw_colon {
                    return Ok(None);
                }
                collect_inline_content(child, &mut description_inlines)?;
            }
            _ => {
                if !saw_colon {
                    return Ok(None);
                }
            }
        }
    }

    if !saw_colon {
        return Ok(None);
    }

    Ok(Some((term_inlines, description_inlines)))
}

/// Collect sibling nodes, treating definition term patterns as Definition IR
/// and consuming subsequent siblings as the description until a heading or
/// another definition term is encountered.
fn collect_children_with_definitions<'a, I>(
    children: I,
    events: &mut Vec<Event>,
) -> Result<(), FormatError>
where
    I: Iterator<Item = &'a AstNode<'a>>,
{
    let mut iter = children.peekable();

    while let Some(node) = iter.next() {
        if let Some((term_inlines, inline_description)) = try_parse_definition_term(node)? {
            events.push(Event::StartDefinition);
            events.push(Event::StartDefinitionTerm);
            for inline in term_inlines {
                events.push(Event::Inline(inline));
            }
            events.push(Event::EndDefinitionTerm);

            events.push(Event::StartDefinitionDescription);
            if !inline_description.is_empty() {
                events.push(Event::StartParagraph);
                for inline in inline_description {
                    events.push(Event::Inline(inline));
                }
                events.push(Event::EndParagraph);
            }

            // Consume subsequent siblings as the description body until we hit
            // a heading or another definition term.
            while let Some(peek) = iter.peek() {
                if is_heading_node(peek) {
                    break;
                }

                // Stop if the next paragraph is another definition term
                let should_stop = try_parse_definition_term(peek)?.is_some();
                if should_stop {
                    break;
                }

                let next = iter.next().expect("peek yielded a node");
                collect_events_from_node(next, events)?;
            }

            events.push(Event::EndDefinitionDescription);
            events.push(Event::EndDefinition);
        } else {
            collect_events_from_node(node, events)?;
        }
    }

    Ok(())
}

/// Parse Lex annotation from HTML comment
/// Format: <!-- lex:label key1=val1 key2=val2 -->
/// Or multi-line:
/// <!-- lex:label key=val
/// Content
/// -->
#[allow(clippy::type_complexity)]
fn parse_lex_annotation(html: &str) -> Option<(String, Vec<(String, String)>, Option<String>)> {
    let trimmed = html.trim();
    if !trimmed.starts_with("<!-- lex:") || !trimmed.ends_with("-->") {
        return None;
    }

    // Remove <!-- lex: prefix and --> suffix
    let content_block = trimmed
        .strip_prefix("<!-- lex:")?
        .strip_suffix("-->")?
        .trim();

    // Split into header (label + params) and body (content)
    // If there is a newline, everything after is content
    let (header, body) = if let Some((h, b)) = content_block.split_once('\n') {
        (h, Some(b.trim().to_string()))
    } else {
        (content_block, None)
    };

    // Split header on whitespace
    let parts: Vec<&str> = header.split_whitespace().collect();
    if parts.is_empty() {
        return None;
    }

    let label = parts[0].to_string();
    let mut parameters = vec![];

    for part in &parts[1..] {
        if let Some((key, value)) = part.split_once('=') {
            parameters.push((key.to_string(), value.to_string()));
        }
    }

    Some((label, parameters, body))
}

/// Parse Lex annotation closing tag from HTML comment
/// Supports both formats:
/// - Generic: <!-- /lex -->
/// - Label-specific: <!-- /lex:label -->
fn parse_lex_annotation_close(html: &str) -> Option<String> {
    let trimmed = html.trim();

    // Try label-specific format first: <!-- /lex:label -->
    if trimmed.starts_with("<!-- /lex:") && trimmed.ends_with("-->") {
        let label = trimmed
            .strip_prefix("<!-- /lex:")?
            .strip_suffix("-->")?
            .trim();
        return Some(label.to_string());
    }

    // Fall back to generic format: <!-- /lex -->
    if trimmed == "<!-- /lex -->" {
        // For generic closing tags, we'll use an empty label
        // The flat_to_nested converter will need to handle this gracefully
        return Some(String::new());
    }

    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use lex_core::lex::ast::AstNode;

    #[test]
    fn test_simple_paragraph() {
        let md = "This is a simple paragraph.\n";
        let doc = parse_from_markdown(md).unwrap();

        // Verify we got a Lex document with a root session
        assert!(!doc.root.children.is_empty());
    }

    #[test]
    fn test_heading_to_session() {
        let md = "# Introduction\n\nSome content.\n";
        let doc = parse_from_markdown(md).unwrap();

        // Should have session with content
        assert!(!doc.root.children.is_empty());
    }

    #[test]
    fn test_leading_h1_becomes_title() {
        let md = "# My Title\n\nBody.\n";
        let doc = parse_from_markdown(md).unwrap();

        assert_eq!(doc.title.as_ref().map(|t| t.as_str()), Some("My Title"));
        // No `doc.untitled` marker when a real title was lifted.
        assert!(!doc
            .annotations
            .iter()
            .any(|a| a.data.label.value == "doc.untitled"));
    }

    #[test]
    fn test_frontmatter_before_h1_still_becomes_title() {
        // Regression for #804: a leading YAML frontmatter node must not block
        // the following `# H1` from becoming the document title.
        let md = "---\nauthor: Jane\n---\n\n# My Title\n\nBody.\n";
        let doc = parse_from_markdown(md).unwrap();

        assert_eq!(
            doc.title.as_ref().map(|t| t.as_str()),
            Some("My Title"),
            "frontmatter must not suppress the leading H1 title"
        );
        // The frontmatter is metadata, not the title, so no untitled marker.
        assert!(!doc
            .annotations
            .iter()
            .any(|a| a.data.label.value == "doc.untitled"));
    }

    #[test]
    fn test_frontmatter_without_h1_stays_untitled() {
        // Frontmatter alone is metadata, not a title: a body-only document
        // still gets the explicit no-title marker.
        let md = "---\nauthor: Jane\n---\n\nJust body.\n";
        let doc = parse_from_markdown(md).unwrap();

        assert!(doc.title.is_none());
        assert!(doc
            .annotations
            .iter()
            .any(|a| a.data.label.value == "doc.untitled"));
    }

    #[test]
    fn test_whitespace_only_h1_is_not_a_title() {
        // A whitespace-only leading H1 must not be lifted into an (empty) title:
        // that would leave the document with neither a title nor a
        // `:: doc.untitled ::` marker. It stays body content and gets the marker.
        let md = "#  \n\nBody.\n";
        let doc = parse_from_markdown(md).unwrap();

        assert!(doc.title.is_none());
        assert!(doc
            .annotations
            .iter()
            .any(|a| a.data.label.value == "doc.untitled"));
    }

    #[test]
    fn test_code_block_to_verbatim() {
        let md = "```rust\nfn main() {}\n```\n";
        let doc = parse_from_markdown(md).unwrap();

        assert!(!doc.root.children.is_empty());
    }

    #[test]
    fn test_table_parsing() {
        let md = "|A|B|\n|-|-|\n|1|2|\n";
        let doc = parse_from_markdown(md).unwrap();

        println!("Children count: {}", doc.root.children.len());
        for child in &doc.root.children {
            println!("Child type: {}", child.node_type());
        }

        // Should have a table
        let has_table = doc.root.children.iter().any(|c| {
            if let lex_core::lex::ast::ContentItem::VerbatimBlock(v) = c {
                let mut text = String::new();
                for child in &v.children {
                    if let lex_core::lex::ast::ContentItem::VerbatimLine(l) = child {
                        text.push_str(l.content.as_string());
                        text.push('\n');
                    }
                }
                // Check for alignment (at least 3 dashes for separator)
                text.contains("| --- |")
            } else {
                false
            }
        });
        assert!(has_table, "Document should contain an aligned table");
    }
}
