//! Shared rendering infrastructure for `InternalDocument`-based renderers.
//!
//! Provides nesting state tracking, annotated text rendering, footnote collection,
//! table formatting helpers, and HTML escaping.

use std::borrow::Cow;

use crate::types::document_structure::{AnnotationKind, ContentLayer, TextAnnotation};
use crate::types::internal::{ElementKind, InternalDocument, InternalElement, RelationshipKind, RelationshipTarget};

// ============================================================================
// Nesting State
// ============================================================================

/// Kind of container on the nesting stack.
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum NestingKind {
    List { ordered: bool, item_count: u32 },
    BlockQuote,
    Group,
}

/// Tracks nesting depth during a linear pass over elements.
#[derive(Debug, Default)]
pub(crate) struct RenderState {
    /// Stack of `(depth, kind)` entries.
    stack: Vec<(u16, NestingKind)>,
}

impl RenderState {
    /// Push a container onto the nesting stack.
    pub(crate) fn push_container(&mut self, kind: NestingKind, depth: u16) {
        self.stack.push((depth, kind));
    }

    /// Pop the top container if it matches the given kind category.
    pub(crate) fn pop_container(&mut self, kind: &NestingKind) {
        // Pop the last matching entry.
        for i in (0..self.stack.len()).rev() {
            if matches!(
                (&self.stack[i].1, kind),
                (NestingKind::List { .. }, NestingKind::List { .. })
                    | (NestingKind::BlockQuote, NestingKind::BlockQuote)
                    | (NestingKind::Group, NestingKind::Group)
            ) {
                self.stack.remove(i);
                return;
            }
        }
    }

    /// Pop entries whose depth >= the given depth (fallback for missing end markers).
    pub(crate) fn pop_to_depth(&mut self, depth: u16) {
        while let Some(&(d, _)) = self.stack.last() {
            if d >= depth {
                self.stack.pop();
            } else {
                break;
            }
        }
    }

    /// Current list nesting depth.
    pub(crate) fn list_depth(&self) -> usize {
        self.stack
            .iter()
            .filter(|(_, k)| matches!(k, NestingKind::List { .. }))
            .count()
    }

    /// Current blockquote nesting depth.
    pub(crate) fn blockquote_depth(&self) -> usize {
        self.stack
            .iter()
            .filter(|(_, k)| matches!(k, NestingKind::BlockQuote))
            .count()
    }

    /// Increment and return the next list item number for the innermost list.
    pub(crate) fn next_list_number(&mut self) -> u32 {
        for (_, kind) in self.stack.iter_mut().rev() {
            if let NestingKind::List {
                ordered: true,
                item_count,
            } = kind
            {
                *item_count += 1;
                return *item_count;
            }
            if let NestingKind::List { ordered: false, .. } = kind {
                // Unordered list — still count for tracking, return the count
                break;
            }
        }
        1
    }
}

// ============================================================================
// Annotated Text Rendering
// ============================================================================

/// Render text with byte-range annotations, calling `emit` for each annotated span.
///
/// Annotations are sorted by `(start, end)`. Overlapping annotations (where
/// `start < current_pos`) are skipped, matching the existing renderer behavior.
///
/// Plain (unannotated) text segments are passed through without transformation.
#[cfg(test)]
pub(crate) fn render_annotated_text(
    text: &str,
    annotations: &[TextAnnotation],
    emit: impl Fn(&str, &AnnotationKind) -> String,
) -> String {
    render_annotated_text_with_plain(text, annotations, emit, |s| s.to_string())
}

pub(crate) fn render_annotated_text_with_plain(
    text: &str,
    annotations: &[TextAnnotation],
    emit: impl Fn(&str, &AnnotationKind) -> String,
    plain: impl Fn(&str) -> String,
) -> String {
    if annotations.is_empty() {
        return plain(text);
    }

    let mut sorted: Vec<&TextAnnotation> = annotations.iter().collect();
    sorted.sort_by_key(|a| (a.start, a.end));

    let bytes = text.as_bytes();
    let len = bytes.len() as u32;
    let mut pos: u32 = 0;
    let mut out = String::with_capacity(text.len() + 64);

    for ann in &sorted {
        let start = ann.start.min(len);
        let end = ann.end.min(len);
        if start < pos {
            continue;
        }
        if start > pos {
            out.push_str(&plain(&text[pos as usize..start as usize]));
        }
        let span = &text[start as usize..end as usize];
        out.push_str(&emit(span, &ann.kind));
        pos = end;
    }

    if (pos as usize) < bytes.len() {
        out.push_str(&plain(&text[pos as usize..]));
    }

    out
}

// ============================================================================
// Footnote Collector
// ============================================================================

/// Collected footnote data: definition text and assigned number.
#[derive(Debug)]
pub(crate) struct FootnoteEntry {
    pub(crate) text: String,
    pub(crate) number: u32,
}

/// Pre-scans elements and relationships to build a sequential footnote numbering.
#[derive(Debug)]
pub(crate) struct FootnoteCollector {
    /// Map from element index (FootnoteRef) -> assigned number.
    ref_numbers: ahash::AHashMap<u32, u32>,
    /// Ordered definitions.
    definitions: Vec<FootnoteEntry>,
}

impl FootnoteCollector {
    /// Scan the document and build footnote mappings.
    pub(crate) fn new(doc: &InternalDocument) -> Self {
        // Collect footnote definitions: element index -> (anchor, text)
        let mut def_by_anchor: ahash::AHashMap<String, (u32, String)> = ahash::AHashMap::new();
        for (i, elem) in doc.elements.iter().enumerate() {
            if elem.kind == ElementKind::FootnoteDefinition
                && let Some(ref anchor) = elem.anchor
            {
                def_by_anchor.insert(anchor.clone(), (i as u32, elem.text.clone()));
            }
        }

        // Collect footnote references from relationships
        let mut ref_to_def_anchor: ahash::AHashMap<u32, String> = ahash::AHashMap::new();
        for rel in &doc.relationships {
            if rel.kind == RelationshipKind::FootnoteReference {
                match &rel.target {
                    RelationshipTarget::Key(key) => {
                        ref_to_def_anchor.insert(rel.source, key.clone());
                    }
                    RelationshipTarget::Index(idx) => {
                        // Find the anchor of the target element
                        if let Some(elem) = doc.elements.get(*idx as usize)
                            && let Some(ref anchor) = elem.anchor
                        {
                            ref_to_def_anchor.insert(rel.source, anchor.clone());
                        }
                    }
                }
            }
        }

        // Also find FootnoteRef elements that reference by anchor directly
        for (i, elem) in doc.elements.iter().enumerate() {
            if elem.kind == ElementKind::FootnoteRef {
                let idx = i as u32;
                if !ref_to_def_anchor.contains_key(&idx) {
                    // Use text or anchor as the key
                    if let Some(ref anchor) = elem.anchor {
                        ref_to_def_anchor.insert(idx, anchor.clone());
                    } else if !elem.text.is_empty() {
                        ref_to_def_anchor.insert(idx, elem.text.clone());
                    }
                }
            }
        }

        // Assign sequential numbers by order of first reference in document
        let mut ref_numbers: ahash::AHashMap<u32, u32> = ahash::AHashMap::new();
        let mut anchor_to_number: ahash::AHashMap<String, u32> = ahash::AHashMap::new();
        let mut next_number: u32 = 1;
        let mut definitions = Vec::new();

        // Iterate in document order to assign numbers
        for (i, elem) in doc.elements.iter().enumerate() {
            if elem.kind == ElementKind::FootnoteRef {
                let idx = i as u32;
                if let Some(anchor) = ref_to_def_anchor.get(&idx) {
                    let number = *anchor_to_number.entry(anchor.clone()).or_insert_with(|| {
                        let n = next_number;
                        next_number += 1;
                        // Find definition text
                        let text = def_by_anchor.get(anchor).map(|(_, t)| t.clone()).unwrap_or_default();
                        definitions.push(FootnoteEntry { text, number: n });
                        n
                    });
                    ref_numbers.insert(idx, number);
                }
            }
        }

        Self {
            ref_numbers,
            definitions,
        }
    }

    /// Get the footnote number for a FootnoteRef element at the given index.
    pub(crate) fn ref_number(&self, elem_index: u32) -> Option<u32> {
        self.ref_numbers.get(&elem_index).copied()
    }

    /// Get ordered footnote definitions.
    pub(crate) fn definitions(&self) -> &[FootnoteEntry] {
        &self.definitions
    }
}

// ============================================================================
// Table Rendering Helpers
// ============================================================================

/// Render a table (from `Table.cells`) as a GFM pipe table.
pub(crate) fn render_table_markdown(cells: &[Vec<String>]) -> String {
    if cells.is_empty() {
        return String::new();
    }
    let num_cols = cells.iter().map(|r| r.len()).max().unwrap_or(0);
    if num_cols == 0 {
        return String::new();
    }

    let mut out = String::new();

    // Header row
    if let Some(header) = cells.first() {
        out.push('|');
        for col in 0..num_cols {
            out.push(' ');
            let content = header.get(col).map(|s| s.as_str()).unwrap_or("");
            push_escaped_pipe(&mut out, content);
            out.push_str(" |");
        }
        out.push('\n');

        // Separator
        out.push('|');
        for _ in 0..num_cols {
            out.push_str(" --- |");
        }
        out.push('\n');
    }

    // Data rows
    for row in cells.iter().skip(1) {
        out.push('|');
        for col in 0..num_cols {
            out.push(' ');
            let content = row.get(col).map(|s| s.as_str()).unwrap_or("");
            push_escaped_pipe(&mut out, content);
            out.push_str(" |");
        }
        out.push('\n');
    }

    out
}

/// Push `content` into `out`, escaping pipe characters. Avoids allocation when
/// there are no pipes (the common case for table cell content).
fn push_escaped_pipe(out: &mut String, content: &str) {
    if memchr::memchr(b'|', content.as_bytes()).is_none() {
        out.push_str(content);
    } else {
        for ch in content.chars() {
            if ch == '|' {
                out.push_str("\\|");
            } else {
                out.push(ch);
            }
        }
    }
}

/// Render a table as plain space-separated text.
pub(crate) fn render_table_plain(cells: &[Vec<String>]) -> String {
    if cells.is_empty() {
        return String::new();
    }

    let mut out = String::new();
    for row in cells {
        out.push_str(&row.join(" "));
        out.push('\n');
    }
    out
}

/// Render a table as djot pipe table (same syntax as GFM).
pub(crate) fn render_table_djot(cells: &[Vec<String>]) -> String {
    render_table_markdown(cells)
}

// ============================================================================
// Text Normalization
// ============================================================================

/// Normalize inline text for consistent output across renderers.
///
/// - Collapses multiple consecutive whitespace (spaces, tabs) into a single space
/// - Replaces newlines with spaces (mid-paragraph line breaks from PDF extraction)
/// - Strips control characters (< 0x20) except tab
pub(crate) fn normalize_inline_text(text: &str) -> String {
    let mut result = String::with_capacity(text.len());
    let mut prev_space = false;
    for ch in text.chars() {
        if ch == '\n' || ch == ' ' {
            if !prev_space {
                result.push(' ');
            }
            prev_space = true;
        } else if ch < '\u{20}' && ch != '\t' {
            // Strip control characters (STX, etc.)
        } else {
            prev_space = false;
            result.push(ch);
        }
    }
    result
}

// ============================================================================
// String Helpers
// ============================================================================

/// Ensure the output has a trailing newline (but not doubled).
pub(crate) fn ensure_trailing_newline(out: &mut String) {
    if !out.ends_with('\n') {
        out.push('\n');
    }
}

/// Trim trailing whitespace, then ensure exactly one trailing newline.
pub(crate) fn finalize_output(mut out: String) -> String {
    let trimmed_len = out.trim_end().len();
    if trimmed_len == 0 {
        return String::new();
    }
    out.truncate(trimmed_len);
    out.push('\n');
    out
}

/// Prefix every line of `text` with the blockquote prefix (`> ` repeated N times).
pub(crate) fn apply_blockquote_prefix(text: &str, depth: usize) -> Cow<'_, str> {
    if depth == 0 {
        return Cow::Borrowed(text);
    }
    let prefix = "> ".repeat(depth);
    let mut out = String::with_capacity(text.len() + prefix.len() * text.lines().count());
    for line in text.lines() {
        out.push_str(&prefix);
        out.push_str(line);
        out.push('\n');
    }
    // If original text ended with newline and we already added one, that's fine.
    // If it didn't end with newline, the lines() iterator already handled it.
    Cow::Owned(out)
}

// ============================================================================
// Blockquote Push Helper
// ============================================================================

/// Push a block of text, optionally applying blockquote prefixes.
pub(crate) fn push_with_bq(out: &mut String, text: &str, bq_depth: usize) {
    if bq_depth > 0 {
        out.push_str(&apply_blockquote_prefix(text, bq_depth));
    } else {
        out.push_str(text);
    }
}

// ============================================================================
// Container End Handling
// ============================================================================

/// Handle container end elements (ListEnd/QuoteEnd/GroupEnd) by popping the
/// corresponding entry from the nesting state. Returns `true` if a container
/// was handled.
pub(crate) fn handle_container_end(kind: &ElementKind, state: &mut RenderState) -> bool {
    match kind {
        ElementKind::ListEnd => {
            state.pop_container(&NestingKind::List {
                ordered: false,
                item_count: 0,
            });
            true
        }
        ElementKind::QuoteEnd => {
            state.pop_container(&NestingKind::BlockQuote);
            true
        }
        ElementKind::GroupEnd => {
            state.pop_container(&NestingKind::Group);
            true
        }
        _ => false,
    }
}

// ============================================================================
// Element Helpers
// ============================================================================

/// Check if an element should be rendered in the body pass.
pub(crate) fn is_body_element(elem: &InternalElement) -> bool {
    elem.layer == ContentLayer::Body
}

/// Check if an element is a container end marker.
pub(crate) fn is_container_end(elem: &InternalElement) -> bool {
    elem.kind.is_container_end()
}

/// Get the language attribute from an element's attributes map.
pub(crate) fn get_language(elem: &InternalElement) -> Option<&str> {
    elem.attributes
        .as_ref()
        .and_then(|attrs| attrs.get("language").map(|s| s.as_str()))
}

/// Get the admonition kind from attributes.
pub(crate) fn get_admonition_kind(elem: &InternalElement) -> &str {
    elem.attributes
        .as_ref()
        .and_then(|attrs| attrs.get("kind").map(|s| s.as_str()))
        .unwrap_or("note")
}

/// Get the admonition title from attributes.
pub(crate) fn get_admonition_title(elem: &InternalElement) -> Option<&str> {
    elem.attributes
        .as_ref()
        .and_then(|attrs| attrs.get("title").map(|s| s.as_str()))
}

/// Get metadata entries from the text (stored as `key: value` lines).
pub(crate) fn parse_metadata_entries(text: &str) -> Vec<(&str, &str)> {
    text.lines()
        .filter_map(|line| {
            let idx = line.find(':')?;
            let key = line[..idx].trim();
            let value = line[idx + 1..].trim();
            if key.is_empty() { None } else { Some((key, value)) }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::document_structure::{AnnotationKind, TextAnnotation};

    // ========================================================================
    // html_escape tests
    // ========================================================================

    // ========================================================================
    // finalize_output tests
    // ========================================================================

    #[test]
    fn test_finalize_output_trims_and_adds_newline() {
        assert_eq!(finalize_output("Hello\n\n\n".to_string()), "Hello\n");
    }

    #[test]
    fn test_finalize_output_empty_input() {
        assert_eq!(finalize_output("".to_string()), "");
    }

    #[test]
    fn test_finalize_output_whitespace_only() {
        assert_eq!(finalize_output("   \n\n  ".to_string()), "");
    }

    // ========================================================================
    // ensure_trailing_newline tests
    // ========================================================================

    #[test]
    fn test_ensure_trailing_newline_adds_when_missing() {
        let mut s = "hello".to_string();
        ensure_trailing_newline(&mut s);
        assert_eq!(s, "hello\n");
    }

    #[test]
    fn test_ensure_trailing_newline_no_double() {
        let mut s = "hello\n".to_string();
        ensure_trailing_newline(&mut s);
        assert_eq!(s, "hello\n");
    }

    // ========================================================================
    // apply_blockquote_prefix tests
    // ========================================================================

    #[test]
    fn test_blockquote_prefix_depth_zero() {
        let result = apply_blockquote_prefix("hello\n", 0);
        assert_eq!(result.as_ref(), "hello\n");
        assert!(matches!(result, Cow::Borrowed(_)));
    }

    #[test]
    fn test_blockquote_prefix_depth_one() {
        let result = apply_blockquote_prefix("hello\n", 1);
        assert_eq!(result.as_ref(), "> hello\n");
    }

    #[test]
    fn test_blockquote_prefix_depth_two() {
        let result = apply_blockquote_prefix("hello\n", 2);
        assert_eq!(result.as_ref(), "> > hello\n");
    }

    #[test]
    fn test_blockquote_prefix_multiline() {
        let result = apply_blockquote_prefix("line1\nline2\n", 1);
        assert_eq!(result.as_ref(), "> line1\n> line2\n");
    }

    // ========================================================================
    // parse_metadata_entries tests
    // ========================================================================

    #[test]
    fn test_parse_metadata_entries_basic() {
        let entries = parse_metadata_entries("Author: Alice\nDate: 2024-01-01");
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0], ("Author", "Alice"));
        assert_eq!(entries[1], ("Date", "2024-01-01"));
    }

    #[test]
    fn test_parse_metadata_entries_empty() {
        let entries = parse_metadata_entries("");
        assert!(entries.is_empty());
    }

    #[test]
    fn test_parse_metadata_entries_no_colon() {
        let entries = parse_metadata_entries("no colon here");
        assert!(entries.is_empty());
    }

    #[test]
    fn test_parse_metadata_entries_empty_key() {
        let entries = parse_metadata_entries(": value");
        assert!(entries.is_empty());
    }

    // ========================================================================
    // render_annotated_text tests
    // ========================================================================

    #[test]
    fn test_render_annotated_text_no_annotations() {
        let result = render_annotated_text("Hello", &[], |span, _| span.to_string());
        assert_eq!(result, "Hello");
    }

    #[test]
    fn test_render_annotated_text_single_annotation() {
        let ann = vec![TextAnnotation {
            start: 0,
            end: 5,
            kind: AnnotationKind::Bold,
        }];
        let result = render_annotated_text("Hello world", &ann, |span, kind| match kind {
            AnnotationKind::Bold => format!("[B:{}]", span),
            _ => span.to_string(),
        });
        assert_eq!(result, "[B:Hello] world");
    }

    #[test]
    fn test_render_annotated_text_multiple_non_overlapping() {
        let ann = vec![
            TextAnnotation {
                start: 0,
                end: 5,
                kind: AnnotationKind::Bold,
            },
            TextAnnotation {
                start: 6,
                end: 11,
                kind: AnnotationKind::Italic,
            },
        ];
        let result = render_annotated_text("Hello world", &ann, |span, kind| match kind {
            AnnotationKind::Bold => format!("[B:{}]", span),
            AnnotationKind::Italic => format!("[I:{}]", span),
            _ => span.to_string(),
        });
        assert_eq!(result, "[B:Hello] [I:world]");
    }

    #[test]
    fn test_render_annotated_text_overlapping_skips_inner() {
        let ann = vec![
            TextAnnotation {
                start: 0,
                end: 11,
                kind: AnnotationKind::Bold,
            },
            TextAnnotation {
                start: 6,
                end: 11,
                kind: AnnotationKind::Italic,
            },
        ];
        let result = render_annotated_text("Hello world", &ann, |span, kind| match kind {
            AnnotationKind::Bold => format!("[B:{}]", span),
            AnnotationKind::Italic => format!("[I:{}]", span),
            _ => span.to_string(),
        });
        // The italic annotation overlaps with bold, so it should be skipped
        assert_eq!(result, "[B:Hello world]");
    }

    // ========================================================================
    // RenderState tests
    // ========================================================================

    #[test]
    fn test_render_state_blockquote_depth() {
        let mut state = RenderState::default();
        assert_eq!(state.blockquote_depth(), 0);
        state.push_container(NestingKind::BlockQuote, 0);
        assert_eq!(state.blockquote_depth(), 1);
        state.push_container(NestingKind::BlockQuote, 1);
        assert_eq!(state.blockquote_depth(), 2);
        state.pop_container(&NestingKind::BlockQuote);
        assert_eq!(state.blockquote_depth(), 1);
    }

    #[test]
    fn test_render_state_list_depth() {
        let mut state = RenderState::default();
        assert_eq!(state.list_depth(), 0);
        state.push_container(
            NestingKind::List {
                ordered: false,
                item_count: 0,
            },
            0,
        );
        assert_eq!(state.list_depth(), 1);
        state.push_container(
            NestingKind::List {
                ordered: true,
                item_count: 0,
            },
            1,
        );
        assert_eq!(state.list_depth(), 2);
    }

    #[test]
    fn test_render_state_next_list_number() {
        let mut state = RenderState::default();
        state.push_container(
            NestingKind::List {
                ordered: true,
                item_count: 0,
            },
            0,
        );
        assert_eq!(state.next_list_number(), 1);
        assert_eq!(state.next_list_number(), 2);
        assert_eq!(state.next_list_number(), 3);
    }

    // ========================================================================
    // Table rendering tests
    // ========================================================================

    #[test]
    fn test_render_table_markdown_basic() {
        let cells = vec![
            vec!["A".to_string(), "B".to_string()],
            vec!["1".to_string(), "2".to_string()],
        ];
        let out = render_table_markdown(&cells);
        assert!(out.contains("| A | B |"), "got: {}", out);
        assert!(out.contains("| --- | --- |"), "got: {}", out);
        assert!(out.contains("| 1 | 2 |"), "got: {}", out);
    }

    #[test]
    fn test_render_table_markdown_empty() {
        let out = render_table_markdown(&[]);
        assert_eq!(out, "");
    }

    #[test]
    fn test_render_table_markdown_escapes_pipe() {
        let cells = vec![vec!["A|B".to_string()], vec!["C|D".to_string()]];
        let out = render_table_markdown(&cells);
        assert!(out.contains("A\\|B"), "pipe should be escaped, got: {}", out);
    }

    #[test]
    fn test_render_table_plain_basic() {
        let cells = vec![
            vec!["A".to_string(), "B".to_string()],
            vec!["1".to_string(), "2".to_string()],
        ];
        let out = render_table_plain(&cells);
        assert!(out.contains("A B"), "got: {}", out);
        assert!(out.contains("1 2"), "got: {}", out);
    }

    #[test]
    fn test_render_table_plain_empty() {
        let out = render_table_plain(&[]);
        assert_eq!(out, "");
    }

    // ========================================================================
    // FootnoteCollector tests
    // ========================================================================

    #[test]
    fn test_footnote_collector_basic() {
        use crate::types::internal_builder::InternalDocumentBuilder;
        let mut b = InternalDocumentBuilder::new("test");
        b.push_footnote_ref("1", "fn1", None);
        let def = b.push_footnote_definition("Note text.", "fn1", None);
        b.set_layer(def, ContentLayer::Footnote);
        let doc = b.build();

        let collector = FootnoteCollector::new(&doc);
        // The ref is at element index 0
        assert_eq!(collector.ref_number(0), Some(1));
        let defs = collector.definitions();
        assert_eq!(defs.len(), 1);
        assert_eq!(defs[0].text, "Note text.");
        assert_eq!(defs[0].number, 1);
    }

    #[test]
    fn test_footnote_collector_multiple() {
        use crate::types::internal_builder::InternalDocumentBuilder;
        let mut b = InternalDocumentBuilder::new("test");
        b.push_footnote_ref("a", "fn1", None);
        b.push_footnote_ref("b", "fn2", None);
        let d1 = b.push_footnote_definition("First.", "fn1", None);
        let d2 = b.push_footnote_definition("Second.", "fn2", None);
        b.set_layer(d1, ContentLayer::Footnote);
        b.set_layer(d2, ContentLayer::Footnote);
        let doc = b.build();

        let collector = FootnoteCollector::new(&doc);
        assert_eq!(collector.ref_number(0), Some(1));
        assert_eq!(collector.ref_number(1), Some(2));
        let defs = collector.definitions();
        assert_eq!(defs.len(), 2);
        assert_eq!(defs[0].number, 1);
        assert_eq!(defs[1].number, 2);
    }

    #[test]
    fn test_footnote_collector_no_footnotes() {
        use crate::types::internal_builder::InternalDocumentBuilder;
        let mut b = InternalDocumentBuilder::new("test");
        b.push_paragraph("No footnotes here", vec![], None, None);
        let doc = b.build();

        let collector = FootnoteCollector::new(&doc);
        assert!(collector.definitions().is_empty());
        assert_eq!(collector.ref_number(0), None);
    }

    // ========================================================================
    // normalize_inline_text tests
    // ========================================================================

    #[test]
    fn test_normalize_inline_text_collapses_spaces() {
        assert_eq!(normalize_inline_text("Hello   world"), "Hello world");
    }

    #[test]
    fn test_normalize_inline_text_newlines_to_spaces() {
        assert_eq!(normalize_inline_text("Hello\nworld"), "Hello world");
    }

    #[test]
    fn test_normalize_inline_text_mixed_whitespace() {
        assert_eq!(normalize_inline_text("Hello \n  world"), "Hello world");
    }

    #[test]
    fn test_normalize_inline_text_strips_control_chars() {
        assert_eq!(normalize_inline_text("Hello\x02world"), "Helloworld");
    }

    #[test]
    fn test_normalize_inline_text_preserves_tabs() {
        assert_eq!(normalize_inline_text("Hello\tworld"), "Hello\tworld");
    }

    #[test]
    fn test_normalize_inline_text_empty() {
        assert_eq!(normalize_inline_text(""), "");
    }

    #[test]
    fn test_normalize_inline_text_no_change() {
        assert_eq!(normalize_inline_text("Hello world"), "Hello world");
    }
}
