//! Export tests for Markdown format (Lex → Markdown)
//!
//! These tests verify that Lex documents are correctly converted to Markdown
//! by checking the resulting Comrak AST structure.

use comrak::nodes::NodeValue;
use comrak::{parse_document, Arena, ComrakOptions};
use insta::assert_snapshot;
use lex_babel::format::Format;
use lex_babel::formats::markdown::MarkdownFormat;
use lex_core::lex::transforms::standard::STRING_TO_AST;

/// Helper to convert Lex source to Markdown and parse to Comrak AST
fn lex_to_comrak_ast<'a>(
    lex_src: &str,
    arena: &'a Arena<comrak::nodes::AstNode<'a>>,
) -> &'a comrak::nodes::AstNode<'a> {
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();
    let options = ComrakOptions::default();
    parse_document(arena, &md, &options)
}

/// Helper to recursively collect node types from a Comrak AST
fn collect_node_types<'a>(
    node: &'a comrak::nodes::AstNode<'a>,
    types: &mut std::collections::HashSet<String>,
) {
    let value = &node.data.borrow().value;
    let type_name = match value {
        NodeValue::Document => "Document",
        NodeValue::Paragraph => "Paragraph",
        NodeValue::Heading(_) => "Heading",
        NodeValue::List(_) => "List",
        NodeValue::Item(_) => "Item",
        NodeValue::CodeBlock(_) => "CodeBlock",
        NodeValue::Strong => "Strong",
        NodeValue::Emph => "Emph",
        NodeValue::Code(_) => "Code",
        NodeValue::Link(_) => "Link",
        _ => "Other",
    };
    types.insert(type_name.to_string());

    for child in node.children() {
        collect_node_types(child, types);
    }
}

#[test]
fn test_paragraph_simple() {
    let lex_src = "This is a simple paragraph.\n";

    let arena = Arena::new();
    let root = lex_to_comrak_ast(lex_src, &arena);

    // Verify structure: Document → Paragraph → Text
    let mut found_paragraph = false;
    for child in root.children() {
        if matches!(child.data.borrow().value, NodeValue::Paragraph) {
            found_paragraph = true;
        }
    }
    assert!(found_paragraph, "Should have a paragraph node");
}

#[test]
fn test_heading_and_paragraph_separation() {
    // Session title followed by paragraph content should not be merged into the heading
    let lex_src = "1. Title\n\n    Body text.\n";

    let arena = Arena::new();
    let root = lex_to_comrak_ast(lex_src, &arena);

    let mut heading_text = String::new();
    let mut paragraph_text = String::new();

    for child in root.children() {
        match &child.data.borrow().value {
            NodeValue::Heading(_) => {
                for inline in child.children() {
                    if let NodeValue::Text(t) = &inline.data.borrow().value {
                        heading_text.push_str(t);
                    }
                }
            }
            NodeValue::Paragraph => {
                for inline in child.children() {
                    if let NodeValue::Text(t) = &inline.data.borrow().value {
                        paragraph_text.push_str(t);
                    }
                }
            }
            _ => {}
        }
    }

    // Session numbering must be preserved in the heading text
    assert_eq!(heading_text.trim(), "1. Title");
    assert!(paragraph_text.contains("Body text."));
}

// ============================================================================
// TRIFECTA TESTS - Document Structure
// ============================================================================

#[test]
fn test_trifecta_010_paragraphs_sessions_flat_single() {
    let lex_src = std::fs::read_to_string(
        "../../comms/specs/trifecta/010-paragraphs-sessions-flat-single.lex",
    )
    .expect("trifecta 010 file should exist");

    let arena = Arena::new();
    let root = lex_to_comrak_ast(&lex_src, &arena);

    // Collect all top-level node types
    let mut paragraphs = 0;
    let mut headings = 0;
    let mut heading_levels = Vec::new();

    for child in root.children() {
        match &child.data.borrow().value {
            NodeValue::Paragraph => paragraphs += 1,
            NodeValue::Heading(h) => {
                headings += 1;
                heading_levels.push(h.level);
            }
            _ => {}
        }
    }

    // Should have:
    // - Initial paragraph ("Paragraphs and Single Session Test")
    // - Another paragraph ("This document tests...")
    // - Heading for "1. Introduction"
    // - Paragraphs that were nested in the session (now at document level)
    // - Paragraph after session
    // - Heading for "Another Session"
    // - Paragraph nested in that session
    // - Final paragraph

    assert!(paragraphs > 0, "Should have paragraphs");
    assert!(
        headings >= 3,
        "Should have at least 3 headings (1 title + 2 sessions), found {headings}"
    );

    // Document title is H1, sessions at root level should be H2
    assert!(
        heading_levels.contains(&1),
        "Should have H1 for document title"
    );
    let session_levels: Vec<_> = heading_levels
        .iter()
        .filter(|&&l| l != 1)
        .copied()
        .collect();
    for level in &session_levels {
        assert_eq!(*level, 2, "Root-level sessions should be h2");
    }

    println!(
        "Trifecta 010: {paragraphs} paragraphs, {headings} headings (levels: {heading_levels:?})"
    );
}

#[test]
fn test_trifecta_020_paragraphs_sessions_flat_multiple() {
    let lex_src = std::fs::read_to_string(
        "../../comms/specs/trifecta/020-paragraphs-sessions-flat-multiple.lex",
    )
    .expect("trifecta 020 file should exist");

    let arena = Arena::new();
    let root = lex_to_comrak_ast(&lex_src, &arena);

    // Count headings (sessions)
    let heading_count = root
        .children()
        .filter(|child| matches!(child.data.borrow().value, NodeValue::Heading(_)))
        .count();

    // Should have multiple sessions including nested ones
    // Root: "First Session", "Second Session", "Third Session", "Session Without Numbering"
    // Nested: "Session titles don't require numbering markers." (nested in "Session Without Numbering")
    assert!(
        heading_count >= 4,
        "Should have at least 4 headings (sessions), found {heading_count}"
    );

    // Collect heading levels to see if we have nesting
    let mut has_h1 = false;
    let mut heading_levels_vec = Vec::new();
    for child in root.children() {
        if let NodeValue::Heading(h) = &child.data.borrow().value {
            heading_levels_vec.push(h.level);
            if h.level == 2 {
                has_h1 = true;
            }
        }
    }

    assert!(has_h1, "Should have at least one h2 (root session)");
    println!("Trifecta 020: Heading levels: {heading_levels_vec:?}");

    println!("Trifecta 020: {heading_count} headings");
}

#[test]
fn test_trifecta_060_nesting() {
    let lex_src = std::fs::read_to_string("../../comms/specs/trifecta/060-trifecta-nesting.lex")
        .expect("trifecta 060 file should exist");

    let arena = Arena::new();
    let root = lex_to_comrak_ast(&lex_src, &arena);

    // Collect heading levels to verify nesting structure
    let mut heading_levels = Vec::new();
    let mut has_paragraphs = false;
    let mut has_lists = false;

    for child in root.children() {
        match &child.data.borrow().value {
            NodeValue::Heading(h) => heading_levels.push(h.level),
            NodeValue::Paragraph => has_paragraphs = true,
            NodeValue::List(_) => has_lists = true,
            _ => {}
        }
    }

    // Verify we have multiple heading levels (representing nested sessions)
    let min_level = heading_levels.iter().min().copied().unwrap_or(0);
    let max_level = heading_levels.iter().max().copied().unwrap_or(0);

    assert!(
        min_level >= 1 && max_level >= 2,
        "Should have nested heading levels (h1, h2, h3...), found range {min_level}..{max_level}"
    );

    // Should have all three element types
    assert!(has_paragraphs, "Should have paragraphs");
    assert!(has_lists, "Should have lists");
    assert!(!heading_levels.is_empty(), "Should have headings");

    // Verify heading sequence makes sense
    // Session 1.2.1 should generate h3, 1.1 should generate h2, etc.
    assert!(
        heading_levels.contains(&2),
        "Should have h2 for root sessions"
    );
    assert!(
        heading_levels.contains(&3) || heading_levels.contains(&4),
        "Should have h3 or h4 for nested sessions"
    );

    println!(
        "Trifecta 060: {} headings with levels {:?}, {} paragraphs, {} lists",
        heading_levels.len(),
        heading_levels,
        if has_paragraphs { "some" } else { "no" },
        if has_lists { "some" } else { "no" }
    );
}

// ============================================================================
// BENCHMARK TESTS
// ============================================================================

#[test]
fn test_kitchensink() {
    let lex_src = std::fs::read_to_string("../../comms/specs/benchmark/010-kitchensink.lex")
        .expect("kitchensink file should exist");

    let arena = Arena::new();
    let root = lex_to_comrak_ast(&lex_src, &arena);

    // Kitchensink should have variety of nodes
    let mut node_types = std::collections::HashSet::new();
    collect_node_types(root, &mut node_types);

    // Kitchensink should exercise multiple element types
    assert!(
        node_types.len() >= 5,
        "Kitchensink should have at least 5 different node types, found: {node_types:?}"
    );

    println!("Kitchensink node types: {node_types:?}");
}

#[test]
fn test_kitchensink_snapshot() {
    let lex_src = std::fs::read_to_string("../../comms/specs/benchmark/010-kitchensink.lex")
        .expect("kitchensink file should exist");

    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    assert_snapshot!("kitchensink_markdown", md);
}

// ============================================================================
// ISSUE A: Reference Escaping Tests
// ============================================================================

#[test]
fn test_reference_url_converted_to_link() {
    let lex_src = "Visit [https://example.com] for more.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // URLs should be converted to Markdown links (as autolinks or regular links)
    // Comrak may render as <url> autolink or [text](url) depending on context
    assert!(
        md.contains("https://example.com"),
        "URL should be present in output"
    );
    assert!(
        !md.contains("\\[https://"),
        "Should not escape brackets for URLs"
    );
}

#[test]
fn test_verbatim_bracket_lines_stay_literal_not_autolinks() {
    // lex#755: a `[...]`-led line inside a verbatim body (e.g. a TOML table
    // header `[server]` or a dotted one `[formatting.rules]`) must stay literal
    // inside the fence — it must NOT be ejected from the block and re-emitted as
    // a dangling shortcut auto-link `[server](server)` outside it.
    let lex_src =
        "Config example:\n\n    [server]\n    [formatting.rules]\n    port = 8080\n:: toml ::\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // Both bracket lines appear literally in the output...
    assert!(
        md.contains("[server]"),
        "`[server]` must be present literally: {md}"
    );
    assert!(
        md.contains("[formatting.rules]"),
        "`[formatting.rules]` must be present literally: {md}"
    );
    // ...and never as a shortcut auto-link `(...)`.
    assert!(
        !md.contains("[server](server)") && !md.contains("](server)"),
        "`[server]` must not become an auto-link: {md}"
    );
    assert!(
        !md.contains("](formatting.rules)"),
        "`[formatting.rules]` must not become an auto-link: {md}"
    );

    // The bracket lines must live INSIDE the code fence, not as a sibling
    // paragraph (auto-link OR escaped prose) after it. Walk the comrak tree and
    // assert three things at once: (1) no Link node anywhere (no auto-link
    // ejection), (2) the bracket text never appears in a Text node (no
    // escaped-prose leak outside the fence), and (3) at least one CodeBlock
    // carries both bracket lines.
    let arena = Arena::new();
    let options = ComrakOptions::default();
    let root = parse_document(&arena, &md, &options);

    fn walk<'a>(node: &'a comrak::nodes::AstNode<'a>, saw_brackets_in_code: &mut bool) {
        match &node.data.borrow().value {
            NodeValue::Link(_) => panic!("verbatim bracket line leaked out as a link"),
            NodeValue::CodeBlock(cb) => {
                if cb.literal.contains("[server]") && cb.literal.contains("[formatting.rules]") {
                    *saw_brackets_in_code = true;
                }
            }
            NodeValue::Text(t) => {
                assert!(
                    !t.contains("[server]") && !t.contains("[formatting.rules]"),
                    "bracket line leaked outside the fence as prose text: {t:?}"
                );
            }
            _ => {}
        }
        for child in node.children() {
            walk(child, saw_brackets_in_code);
        }
    }

    let mut saw_brackets_in_code = false;
    walk(root, &mut saw_brackets_in_code);
    assert!(
        saw_brackets_in_code,
        "a code fence must carry both bracket lines literally: {md}"
    );
}

#[test]
fn test_reference_anchor_converted_to_link() {
    let lex_src = "See section [#introduction] above.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // Implicit anchor: the word before the reference ("section") becomes the link text
    assert!(
        md.contains("(#introduction)"),
        "Anchor should link to #introduction"
    );
    assert!(
        md.contains("[section]"),
        "Link text should be the implicit anchor word 'section'"
    );
}

#[test]
fn test_citation_converted_to_ref_link() {
    let lex_src = "According to [@smith2023], this is true.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // Citations should be converted to #ref-* links
    assert!(md.contains("[@smith2023]"), "Citation should be a link");
    assert!(
        !md.contains("\\[@"),
        "Should not escape brackets for citations"
    );
}

#[test]
fn test_citation_anchor_excludes_locator() {
    // Regression: the anchor must be the citation KEY only — never the locator.
    // Slugifying the raw `@spec2025, pp. 45-46` literal baked "pp. 45-46" into
    // the href (`#ref-spec2025,%20pp.%2045-46`), which never resolved (MD051).
    let lex_src = "See [@spec2025, pp. 45-46] for details.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // Display text keeps the full literal; the anchor is the bare key.
    assert!(
        md.contains("(#ref-spec2025)"),
        "Anchor must be the citation key only, got: {md}"
    );
    assert!(
        !md.contains("#ref-spec2025,") && !md.contains("pp.%20"),
        "Locator must not leak into the anchor, got: {md}"
    );
}

#[test]
fn test_placeholder_reference_as_text() {
    // `[TK-ref]` classifies as a To-Come placeholder (lowercase identifier per
    // references-general.lex §8.2), which is marker-style (§2.3.4) — it never
    // takes a word anchor and renders as plain text with escaped brackets.
    // (An uppercase / multi-segment spelling like `TK-REF-2025-01` is *not* a
    // valid TK identifier; lex-core classifies it as a General link instead.)
    let lex_src = "This needs citation [TK-ref].\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // Placeholders should render as plain text with escaped brackets
    // since they're not recognized as links
    assert!(
        md.contains("\\[TK-ref\\]"),
        "Placeholder should be visible as text with escaped brackets, got: {md}"
    );
    assert!(
        md.contains("TK-ref"),
        "Placeholder content should be present"
    );
}

#[test]
fn test_multi_group_verbatim_exports_every_group_as_a_fence() {
    // lex#769: a verbatim block with multiple subject/content groups sharing one
    // closing marker (`:: shell ::`) must export EVERY group as its own fenced
    // code block. Pre-fix, only the first group survived the lex → IR conversion
    // (the IR verbatim node holds a single subject + content), so groups 2..N
    // silently vanished from the markdown.
    let lex_src = "First group:\n\n    cmd one\nSecond group:\n\n    cmd two\nThird group:\n\n    cmd three\n:: shell ::\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // Every group's command lands in the output, not just the first.
    for cmd in ["cmd one", "cmd two", "cmd three"] {
        assert!(md.contains(cmd), "group command `{cmd}` missing: {md}");
    }
    // No later group leaked as escaped prose.
    assert!(
        !md.contains("\\#") && !md.contains("cmd two cmd"),
        "later groups must not render as escaped/joined prose: {md}"
    );

    // Walk the comrak tree: exactly three CodeBlocks, each carrying one command
    // and the shared `shell` language hint.
    let arena = Arena::new();
    let options = ComrakOptions::default();
    let root = parse_document(&arena, &md, &options);

    fn collect_code_blocks<'a>(
        node: &'a comrak::nodes::AstNode<'a>,
        out: &mut Vec<(String, String)>,
    ) {
        if let NodeValue::CodeBlock(cb) = &node.data.borrow().value {
            out.push((cb.info.clone(), cb.literal.clone()));
        }
        for child in node.children() {
            collect_code_blocks(child, out);
        }
    }

    let mut blocks = Vec::new();
    collect_code_blocks(root, &mut blocks);
    assert_eq!(
        blocks.len(),
        3,
        "all three verbatim groups must become separate code fences: {blocks:?}"
    );
    for (info, literal) in &blocks {
        assert_eq!(info, "shell", "each group keeps the shared language hint");
        assert!(
            literal.contains("cmd "),
            "each fence carries its group's command: {literal:?}"
        );
    }
}

#[test]
fn test_anchorless_file_reference_becomes_a_link_not_escaped_text() {
    // lex#770: a `[./path]` file reference with no anchorable adjacent word
    // (here the two refs abut each other, so word-anchoring finds no word to
    // wrap) must still emit a real Markdown link using the target as its own
    // link text — not fall through to escaped literal `\[./why\]`.
    let lex_src = "Nav: [./editors] [./why]\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // The anchorless `[./why]` is a self-link: text and href both the target.
    assert!(
        md.contains("[./why](./why)"),
        "anchorless file ref must self-link, got: {md}"
    );
    // It must NOT be escaped as literal bracket text.
    assert!(
        !md.contains("\\[./why\\]"),
        "anchorless file ref must not be escaped to literal text, got: {md}"
    );
}

#[test]
fn test_anchorless_link_like_references_self_link_not_escaped() {
    // Generalisation of the #770 fix (Copilot review on PR #771): every
    // link-like reference kind (`Url` / `Session` / `General` / `File`, i.e.
    // `AnchorKind::WholeLineCapable`) with no anchorable adjacent word must
    // self-link rather than escape to literal brackets, matching the HTML
    // serializer. The leading `X:` anchors the first ref, leaving the second
    // ref of each pair anchorless.
    let cases = [
        (
            "X: [https://a.example.com] [https://b.example.com]",
            "b.example.com",
        ),
        ("X: [#sess-one] [#sess-two]", "#sess-two"),
        ("X: [Introduction] [Conclusion]", "Conclusion"),
    ];
    for (lex_src, needle) in cases {
        let lex_doc = STRING_TO_AST.run(format!("{lex_src}\n")).unwrap();
        let md = MarkdownFormat.serialize(&lex_doc).unwrap();
        // The anchorless ref must appear as a link target (comrak may render a
        // URL as a `<...>` autolink or `[..](..)`, so assert on the destination).
        assert!(
            md.contains(needle),
            "anchorless link-like ref `{needle}` must survive as a link: {md}"
        );
        assert!(
            !md.contains(&format!("\\[{needle}")) && !md.contains("\\]"),
            "anchorless link-like ref must not be escaped to literal text: {md}"
        );
    }
}

#[test]
fn test_marker_reference_still_escaped_not_self_linked() {
    // Guard for the generalised #770 fix: a marker-style reference
    // (`AnchorKind::MarkerOnly`, here a `[TK-ref]` placeholder) has no
    // destination and must NOT become a self-link — it stays plain escaped text.
    let lex_src = "This needs [TK-ref].\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    assert!(
        md.contains("\\[TK-ref\\]"),
        "marker-style placeholder must stay escaped, got: {md}"
    );
    assert!(
        !md.contains("(TK-ref)"),
        "marker-style placeholder must not self-link, got: {md}"
    );
}

#[test]
fn test_anchored_file_reference_unchanged() {
    // Guard for lex#770: when an adjacent anchor word IS present, the existing
    // word-anchoring path wins — `Editors [./editors]` links the word "Editors"
    // to the target. The anchorless fix must not perturb this.
    let lex_src = "See Editors [./editors] for details.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    assert!(
        md.contains("[Editors](./editors)"),
        "anchored file ref must link the anchor word, got: {md}"
    );
    // The bare target must not also appear as a separate self-link.
    assert!(
        !md.contains("[./editors](./editors)"),
        "anchored ref must not additionally self-link the target, got: {md}"
    );
}

#[test]
fn test_self_link_placed_after_multi_group_verbatim_not_between_groups() {
    // Regression for the #769 fix: expanding a multi-group verbatim into N IR
    // nodes must keep the self-link index mapping aligned, so a sibling
    // reference-line self-link following the block lands AFTER all its groups —
    // not spliced between them. A `[https://...]` alone on its line anchors the
    // preceding element as a self-link.
    let lex_src = "First:\n\n    cmd one\nSecond:\n\n    cmd two\n:: shell ::\n\n[https://after.example.com]\n\n2. Tail\n\n    More body.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // The self-link must appear after BOTH `cmd one` and `cmd two`.
    let after_pos = md
        .find("after.example.com")
        .unwrap_or_else(|| panic!("self-link missing: {md}"));
    let cmd_two_pos = md
        .find("cmd two")
        .unwrap_or_else(|| panic!("second group missing: {md}"));
    assert!(
        after_pos > cmd_two_pos,
        "self-link must land after all verbatim groups, not between them: {md}"
    );
    // And before the tail section.
    let tail_pos = md.find("Tail").unwrap();
    assert!(
        after_pos < tail_pos,
        "self-link must precede the following section: {md}"
    );
}

// ============================================================================
// ISSUE C: Markdown List Formatting Tests
// ============================================================================

#[test]
fn test_list_with_simple_items() {
    let lex_src = "- First item\n- Second item\n- Third item\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // Simple list items should be rendered correctly
    assert!(md.contains("- First item"));
    assert!(md.contains("- Second item"));
    assert!(md.contains("- Third item"));
}

#[test]
fn test_list_with_multi_paragraph_items() {
    // Test that list items with multiple paragraphs are properly indented
    // Comrak should handle indentation automatically
    let lex_src = concat!(
        "- Item one with first paragraph.\n",
        "\n",
        "    Second paragraph in item one.\n",
        "\n",
        "- Item two.\n"
    );
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // The output should maintain list structure
    // Comrak handles proper indentation for multi-paragraph items
    assert!(md.contains("- "), "Should have list markers");
    assert!(md.contains("Item one"));
    assert!(md.contains("Item two"));
}

// ============================================================================
// DEFINITION LIST TESTS (#605)
// ============================================================================

#[test]
fn test_definition_exports_as_pandoc_style() {
    // Regression test for #605: definitions previously emitted as
    // `**Term**:\n\ndescription`. That loses the `<dl>` structure (no
    // round-trip can recover it). Switch to Pandoc-flavored definition
    // list syntax: term on its own line, followed by `:   definition`.
    let lex_src = "term-a:\n    Definition body for term a.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    assert!(
        !md.contains("**term-a**:"),
        "should not emit bold-term fallback: {md}"
    );
    // Pandoc syntax — term on its own line, then `:` + spaces + description.
    assert!(
        md.contains("term-a\n"),
        "term should be on its own line: {md}"
    );
    assert!(
        md.contains(": Definition body for term a."),
        "description should be prefixed with `:`: {md}"
    );
}

#[test]
fn test_pandoc_definition_round_trips_lex_to_md_to_lex() {
    // Round-trip regression for #605: a Lex definition should serialize to
    // Pandoc-style markdown and import back to a Lex Definition (not two
    // sibling paragraphs). Without parser-side `description_lists`, the
    // import would drop the `<dl>` structure.
    use lex_core::lex::ast::ContentItem;
    let lex_src = "term-a:\n    Definition body for term a.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();
    let reparsed = MarkdownFormat.parse(&md).expect("re-parse markdown");

    let definition_count: usize = reparsed
        .root
        .children
        .iter()
        .filter(|item| matches!(item, ContentItem::Definition(_)))
        .count();
    assert_eq!(
        definition_count, 1,
        "expected one Definition after round-trip, got {definition_count}; md was: {md}"
    );
}

#[test]
fn test_multiple_definitions_pandoc_style() {
    // Several siblings should each get their own Term/:   def block.
    let lex_src = concat!("term-a:\n", "    def a\n\n", "term-b:\n", "    def b\n",);
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    assert!(md.contains("term-a\n"), "term-a expected: {md}");
    assert!(md.contains(": def a"), "def a body expected: {md}");
    assert!(md.contains("term-b\n"), "term-b expected: {md}");
    assert!(md.contains(": def b"), "def b body expected: {md}");
}

// ============================================================================
// DOCUMENT TITLE TESTS
// ============================================================================

#[test]
fn test_document_title_exported_as_h1() {
    // Use spec file: document with explicit title should export with H1 heading
    let lex_src = std::fs::read_to_string(
        "../../comms/specs/elements/document.docs/document-01-title-explicit.lex",
    )
    .expect("document-01 spec file should exist");
    let lex_doc = STRING_TO_AST.run(lex_src).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // Should start with H1 heading containing the title
    assert!(
        md.starts_with("# My Document Title\n"),
        "Should start with H1 title heading"
    );
}

#[test]
fn test_document_first_paragraph_as_title() {
    // Use spec file: first paragraph followed by blank line becomes document title
    let lex_src = std::fs::read_to_string(
        "../../comms/specs/elements/document.docs/document-06-title-empty.lex",
    )
    .expect("document-06 spec file should exist");
    let lex_doc = STRING_TO_AST.run(lex_src).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // First paragraph "Just a paragraph with no title." becomes the H1 title
    assert!(
        md.starts_with("# Just a paragraph with no title.\n"),
        "First paragraph should become H1 title"
    );
}

#[test]
fn test_document_session_only_no_h1_title() {
    // Use spec file: document starts with session (no explicit document title)
    let lex_src = std::fs::read_to_string(
        "../../comms/specs/elements/document.docs/document-05-title-session-hoist.lex",
    )
    .expect("document-05 spec file should exist");
    let lex_doc = STRING_TO_AST.run(lex_src).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // Should not have H1 title when document starts with session
    assert!(
        !md.starts_with("# "),
        "Session-only doc should not have H1 title"
    );
    // Session should be H2
    assert!(md.contains("## "), "Session should be exported as H2");
}

// ============================================================================
// SESSION NUMBERING PRESERVATION TESTS
// ============================================================================

#[test]
fn test_numbered_session_preserves_numbering() {
    let lex_src = "1. Introduction\n\n    Content here.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // Heading must contain the numbering
    assert!(
        md.contains("## 1"),
        "Numbered session heading must contain '1' prefix: {md}"
    );
    assert!(
        md.contains("Introduction"),
        "Heading must contain title text"
    );
}

#[test]
fn test_numeric_heading_no_backslash_escape() {
    // Regression test for #606: Comrak's `format_commonmark` escapes the
    // period in `1.` at heading start to disambiguate it from an ordered-
    // list marker (e.g. `## 1\. Glossary`). The escape is visually noisy
    // and unnecessary inside a heading line (a `#` heading can't open a
    // list). Post-process strips the backslash from headings only.
    let lex_src = "1. Glossary\n\n    Some content.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    assert!(
        md.contains("## 1. Glossary"),
        "numeric heading should render with unescaped period: {md}"
    );
    assert!(
        !md.contains("## 1\\."),
        "heading should not contain `1\\.` escape: {md}"
    );
}

#[test]
fn test_dotted_numeric_heading_no_backslash_escape() {
    // Nested headings like `## 1.1.` should also lose the escape.
    let lex_src = "1. Parent\n\n    1.1. Child\n\n        Content.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    assert!(
        !md.contains("\\."),
        "no backslash-escaped period anywhere when only headings have digit-dot: {md}"
    );
    assert!(md.contains("## 1. Parent"), "parent heading: {md}");
    assert!(md.contains("### 1.1. Child"), "child heading: {md}");
}

#[test]
fn test_paragraph_starting_with_digit_dot_still_escapes() {
    // A literal `1.` at the *start* of a paragraph (not a heading) must
    // remain escaped — Comrak escapes it to keep CommonMark from
    // interpreting the paragraph as an ordered-list item. The heading
    // strip in #606 must not over-strip and remove this protection.
    //
    // Force-resolved as a paragraph by giving lex two paragraphs: a Lex
    // single-line input starting with `1. ` would parse as a session, so
    // we use a two-paragraph shape where the second paragraph starts with
    // a digit-dot pattern.
    let lex_src = "Intro paragraph here.\n\n1.5x is the speed.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // `1.5x` should still parse as a paragraph; the digit-dot escape
    // should still apply if Comrak emits one — and the heading-strip
    // regex must not touch it (it only matches lines prefixed with `#+ `).
    assert!(
        !md.contains("## 1.5x") && !md.contains("# 1.5x"),
        "non-heading line should not have been hoisted to a heading: {md}"
    );
    // Paragraph still present
    assert!(md.contains("1.5x") || md.contains("1\\.5x"), "{md}");
}

#[test]
fn test_dotted_numbering_preserved() {
    let lex_src = "1. Parent\n\n    1.1. Child\n\n        Content.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    assert!(md.contains("Parent"), "Parent heading must be present");
    assert!(
        md.contains("1.1"),
        "Dotted numbering '1.1' must be preserved in nested heading: {md}"
    );
    assert!(md.contains("Child"), "Child heading text must be present");
}

#[test]
fn test_unnumbered_session_no_numbering_added() {
    let lex_src = "My Session Title\n\n    Content.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // Should have heading with just the title, no added numbering
    assert!(
        md.contains("## My Session Title"),
        "Unnumbered session should export as-is: {md}"
    );
}

#[test]
fn test_numbering_round_trip_lex_md_lex() {
    let lex_src =
        "1. First Session\n\n    Paragraph one.\n\n2. Second Session\n\n    Paragraph two.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();

    // Export to Markdown
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();
    assert!(md.contains("1"), "MD should contain numbering '1'");
    assert!(md.contains("2"), "MD should contain numbering '2'");

    // Import back to Lex
    let lex_doc2 = MarkdownFormat.parse(&md).unwrap();

    // Re-export and verify numbering survived
    let md2 = MarkdownFormat.serialize(&lex_doc2).unwrap();
    assert!(
        md2.contains("First Session"),
        "First session must survive round-trip"
    );
    assert!(
        md2.contains("Second Session"),
        "Second session must survive round-trip"
    );
}

#[test]
fn test_inline_math_not_escaped() {
    // Regression test for #600: math content was passed through Comrak Text
    // nodes, which apply CommonMark escaping (`\` -> `\\`, `_` -> `\_`).
    // That escaping breaks LaTeX: `\\` means newline in KaTeX/MathJax.
    let lex_src = "A factor of #\\log_2# bits.\n";
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    assert!(
        md.contains("$\\log_2$"),
        "math content must emit verbatim, got: {md:?}"
    );
    assert!(
        !md.contains("\\\\log"),
        "backslash must not be double-escaped, got: {md:?}"
    );
    assert!(
        !md.contains("\\_"),
        "underscore must not be escaped inside math, got: {md:?}"
    );
}

#[test]
fn test_inline_math_complex_expressions() {
    // A handful of representative LaTeX shapes from real-world technical docs.
    // Wrapped in a session so the first line isn't interpreted as a title.
    let lex_src = concat!(
        "1. Math\n\n",
        "    Curves are #P_{out} = f(P_{in})# transfer functions.\n\n",
        "    The #\\Delta E_{OK}# threshold is small.\n\n",
        "    Dot product is #a \\cdot b# here.\n",
    );
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    assert!(md.contains("$P_{out} = f(P_{in})$"), "subscripts: {md:?}");
    assert!(md.contains("$\\Delta E_{OK}$"), "Delta + subscript: {md:?}");
    assert!(md.contains("$a \\cdot b$"), "\\cdot: {md:?}");
}

#[test]
fn test_nested_numbering_round_trip() {
    let lex_src = concat!(
        "1. Top Level\n\n",
        "    Opening paragraph.\n\n",
        "    1.1. Nested\n\n",
        "        Nested content.\n\n",
        "    1.2. Another Nested\n\n",
        "        More content.\n",
    );
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    let md = MarkdownFormat.serialize(&lex_doc).unwrap();

    // Verify heading hierarchy
    assert!(md.contains("## "), "Top level should be H2");
    assert!(md.contains("### "), "Nested sessions should be H3: {md}");

    // Verify all titles present with numbering
    assert!(md.contains("Top Level"), "Top level title present");
    assert!(md.contains("Nested"), "First nested title present");
    assert!(md.contains("Another Nested"), "Second nested title present");

    // Round-trip back
    let lex_doc2 = MarkdownFormat.parse(&md).unwrap();
    let md2 = MarkdownFormat.serialize(&lex_doc2).unwrap();

    assert!(md2.contains("Top Level"), "Top level survives round-trip");
    assert!(md2.contains("Nested"), "Nested survives round-trip");
}

// ============================================================================
// REFERENCE ANCHORING (references-general.lex §2.3) — PR B
// ============================================================================

/// Lex → Markdown string for the anchoring tests.
fn lex_to_md(lex_src: &str) -> String {
    let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
    MarkdownFormat.serialize(&lex_doc).unwrap()
}

#[test]
fn test_md_inline_word_anchor_preceding() {
    let md = lex_to_md("Body intro.\n\nthe project website [https://lex.ing] today\n\n");
    assert!(
        md.contains("the project [website](https://lex.ing) today"),
        "preceding word anchor, got: {md}"
    );
    assert!(!md.contains("[https://lex.ing]"), "bracket not literal");
}

#[test]
fn test_md_inline_word_anchor_following() {
    let md = lex_to_md("Body intro.\n\n[https://lex.ing] is the home page.\n\n");
    assert!(
        md.contains("[is](https://lex.ing) the home page."),
        "following word anchor, got: {md}"
    );
}

#[test]
fn test_md_whole_element_anchor_session_title() {
    let md = lex_to_md("Getting Started\n[./readme.txt]\n\n    Welcome to the docs.\n\n");
    assert!(
        md.contains("## [Getting Started](./readme.txt)"),
        "session title wrapped, got: {md}"
    );
}

#[test]
fn test_md_whole_element_anchor_list_item() {
    let md = lex_to_md("Intro.\n\n- Food\n- Water\n[https://water.example]\n- Bread\n\n");
    assert!(
        md.contains("- [Water](https://water.example)"),
        "list item wrapped, got: {md}"
    );
    assert!(md.contains("- Food") && md.contains("- Bread"));
}

#[test]
fn test_md_whole_element_anchor_definition_term() {
    let md = lex_to_md("API Endpoint:\n[./endpoint.txt]\n    A URL that provides access.\n\n");
    assert!(
        md.contains("[API Endpoint](./endpoint.txt)"),
        "definition term wrapped (no trailing colon), got: {md}"
    );
}

#[test]
fn test_md_whole_element_anchor_verbatim_subject() {
    let md = lex_to_md("Example Source:\n[./example.rs]\n    fn main() {}\n:: rust ::\n\n");
    assert!(
        md.contains("**[Example Source](./example.rs)**"),
        "verbatim subject caption wraps a link, got: {md}"
    );
}

#[test]
fn test_md_self_link_reference_line() {
    let md = lex_to_md("See the upstream project:\n\n[https://github.com/lex-fmt/lex]\n\n");
    // Comrak renders a link whose text equals its href as an autolink `<url>`.
    assert!(
        md.contains("<https://github.com/lex-fmt/lex>")
            || md.contains("[https://github.com/lex-fmt/lex](https://github.com/lex-fmt/lex)"),
        "self-link standalone, got: {md}"
    );
}

#[test]
fn test_md_marker_references_unchanged() {
    let md = lex_to_md("Body.\n\nSee [42] and [@smith2023] later.\n\n");
    // Footnote stays escaped-bracket text; citation links to #ref-key.
    assert!(
        md.contains("\\[42\\]"),
        "footnote marker unchanged, got: {md}"
    );
    assert!(
        md.contains("[@smith2023](#ref-smith2023)"),
        "citation marker unchanged, got: {md}"
    );
    // No word anchor stole "See"/"and".
    assert!(!md.contains("[See](42)"));
    assert!(!md.contains("[and](#ref-smith2023)"));
}
