//! Markdown rendering for PDF page content.
//!
//! Converts extracted text, tables, and structural elements into
//! GitHub Flavored Markdown (GFM) format. Useful for LLM/RAG pipelines.

use crate::layout::{
    TextBlock, TextLine, cluster_lines_into_blocks, cluster_words_into_lines,
    sort_blocks_reading_order, split_lines_at_columns,
};
use crate::table::Table;
use crate::text::Char;
use crate::words::{Word, WordExtractor, WordOptions};

/// Options for Markdown rendering.
#[derive(Debug, Clone)]
pub struct MarkdownOptions {
    /// Vertical tolerance for clustering words into lines (in points).
    pub y_tolerance: f64,
    /// Maximum vertical gap for grouping lines into blocks (in points).
    pub y_density: f64,
    /// Minimum horizontal gap to detect column boundaries (in points).
    pub x_density: f64,
    /// Minimum font size ratio (relative to median) to consider text a heading.
    /// A ratio of 1.2 means text must be at least 20% larger than the median.
    pub heading_min_ratio: f64,
    /// Whether to detect bullet/numbered lists from text patterns.
    pub detect_lists: bool,
    /// Whether to detect bold/italic from font name analysis.
    pub detect_emphasis: bool,
}

impl Default for MarkdownOptions {
    fn default() -> Self {
        Self {
            y_tolerance: 3.0,
            y_density: 10.0,
            x_density: 10.0,
            heading_min_ratio: 1.2,
            detect_lists: true,
            detect_emphasis: true,
        }
    }
}

/// A content element identified during Markdown rendering.
#[derive(Debug, Clone, PartialEq)]
enum ContentElement {
    /// A heading with level (1-6) and text.
    Heading { level: u8, text: String },
    /// A paragraph of text.
    Paragraph(String),
    /// A GFM table.
    Table(String),
    /// A list item (bullet or numbered).
    ListItem {
        /// Original prefix (e.g., "- ", "1. ")
        prefix: String,
        /// The text after the prefix.
        text: String,
    },
}

/// Renders PDF page content as Markdown.
pub struct MarkdownRenderer;

impl MarkdownRenderer {
    /// Render characters and tables as Markdown text.
    ///
    /// This is the main entry point. It:
    /// 1. Extracts words from characters
    /// 2. Groups words into text blocks
    /// 3. Classifies blocks as headings, paragraphs, or lists
    /// 4. Converts tables to GFM syntax
    /// 5. Interleaves text and tables in reading order
    pub fn render(chars: &[Char], tables: &[Table], options: &MarkdownOptions) -> String {
        if chars.is_empty() && tables.is_empty() {
            return String::new();
        }

        let words = WordExtractor::extract(
            chars,
            &WordOptions {
                y_tolerance: options.y_tolerance,
                ..WordOptions::default()
            },
        );

        let lines = cluster_words_into_lines(&words, options.y_tolerance);
        let split = split_lines_at_columns(lines, options.x_density);
        let mut blocks = cluster_lines_into_blocks(split, options.y_density);
        sort_blocks_reading_order(&mut blocks, options.x_density);

        let median_size = compute_median_font_size(chars);

        // Classify blocks and interleave with tables
        let mut elements = classify_blocks(&blocks, median_size, options);

        // Insert tables at the right position based on vertical ordering
        for table in tables {
            let table_md = table_to_gfm(table);
            let table_top = table.bbox.top;
            // Find insertion point: after the last element that starts above the table
            let insert_pos = elements
                .iter()
                .enumerate()
                .rev()
                .find(|(_, _)| true) // We need block positions, so use a different approach
                .map(|(i, _)| i + 1)
                .unwrap_or(0);
            // Instead, insert at end and we'll handle position separately
            let _ = insert_pos;
            let _ = table_top;
            elements.push(ContentElement::Table(table_md));
        }

        // Render elements to Markdown string
        render_elements(&elements)
    }

    /// Render characters as Markdown text (no tables).
    pub fn render_text(chars: &[Char], options: &MarkdownOptions) -> String {
        Self::render(chars, &[], options)
    }

    /// Convert a table to GFM (GitHub Flavored Markdown) table syntax.
    pub fn table_to_gfm(table: &Table) -> String {
        table_to_gfm(table)
    }

    /// Detect heading level from font size relative to median.
    ///
    /// Returns `Some(level)` (1-6) if the text qualifies as a heading,
    /// or `None` if it's normal text.
    pub fn detect_heading_level(font_size: f64, median_size: f64, min_ratio: f64) -> Option<u8> {
        detect_heading_level(font_size, median_size, min_ratio)
    }

    /// Detect if a line is a list item.
    ///
    /// Returns `Some((prefix, rest))` if the text matches a bullet or numbered
    /// list pattern.
    pub fn detect_list_item(text: &str) -> Option<(String, String)> {
        detect_list_item(text)
    }
}

/// Compute the median font size from characters.
fn compute_median_font_size(chars: &[Char]) -> f64 {
    if chars.is_empty() {
        return 12.0; // default
    }

    let mut sizes: Vec<f64> = chars
        .iter()
        .filter(|c| c.size > 0.0 && !c.text.trim().is_empty())
        .map(|c| c.size)
        .collect();

    if sizes.is_empty() {
        return 12.0;
    }

    sizes.sort_by(|a, b| a.partial_cmp(b).unwrap());
    let mid = sizes.len() / 2;
    if sizes.len() % 2 == 0 {
        (sizes[mid - 1] + sizes[mid]) / 2.0
    } else {
        sizes[mid]
    }
}

/// Detect heading level from font size ratio.
fn detect_heading_level(font_size: f64, median_size: f64, min_ratio: f64) -> Option<u8> {
    if median_size <= 0.0 || font_size <= 0.0 {
        return None;
    }

    let ratio = font_size / median_size;
    if ratio < min_ratio {
        return None;
    }

    // Map ratio ranges to heading levels
    // H1: ratio >= 2.0
    // H2: ratio >= 1.6
    // H3: ratio >= 1.3
    // H4: ratio >= 1.2 (min_ratio default)
    if ratio >= 2.0 {
        Some(1)
    } else if ratio >= 1.6 {
        Some(2)
    } else if ratio >= 1.3 {
        Some(3)
    } else {
        Some(4)
    }
}

/// Detect if text is a list item. Returns (prefix, rest_text).
fn detect_list_item(text: &str) -> Option<(String, String)> {
    let trimmed = text.trim_start();

    // Bullet patterns: "- ", "* ", "• "
    for prefix in &["- ", "* ", "• ", "– ", "— "] {
        if let Some(rest) = trimmed.strip_prefix(prefix) {
            return Some((prefix.to_string(), rest.to_string()));
        }
    }

    // Numbered patterns: "1. ", "2) ", "(a) ", etc.
    if let Some(rest) = try_parse_numbered_list(trimmed) {
        return Some(rest);
    }

    None
}

/// Try to parse a numbered list prefix like "1. " or "2) ".
fn try_parse_numbered_list(text: &str) -> Option<(String, String)> {
    let bytes = text.as_bytes();
    if bytes.is_empty() {
        return None;
    }

    // Check for digit(s) followed by ". " or ") "
    let mut i = 0;
    while i < bytes.len() && bytes[i].is_ascii_digit() {
        i += 1;
    }
    if i == 0 || i >= bytes.len() {
        return None;
    }

    if i + 1 < bytes.len() {
        let sep = bytes[i];
        let space = bytes[i + 1];
        if (sep == b'.' || sep == b')') && space == b' ' {
            let prefix = &text[..i + 2];
            let rest = &text[i + 2..];
            return Some((prefix.to_string(), rest.to_string()));
        }
    }

    None
}

/// Get the dominant (most common) font size in a text block's words.
fn block_dominant_size(block: &TextBlock) -> f64 {
    let mut sizes: Vec<f64> = Vec::new();
    for line in &block.lines {
        for word in &line.words {
            for ch in &word.chars {
                if ch.size > 0.0 && !ch.text.trim().is_empty() {
                    sizes.push(ch.size);
                }
            }
        }
    }
    if sizes.is_empty() {
        return 0.0;
    }

    // Find most common size (mode)
    sizes.sort_by(|a, b| a.partial_cmp(b).unwrap());
    let mut best_size = sizes[0];
    let mut best_count = 1;
    let mut current_count = 1;
    for i in 1..sizes.len() {
        if (sizes[i] - sizes[i - 1]).abs() < 0.1 {
            current_count += 1;
        } else {
            if current_count > best_count {
                best_count = current_count;
                best_size = sizes[i - 1];
            }
            current_count = 1;
        }
    }
    if current_count > best_count {
        best_size = *sizes.last().unwrap();
    }
    best_size
}

/// Check if a font name indicates bold.
fn is_bold_font(fontname: &str) -> bool {
    let lower = fontname.to_lowercase();
    lower.contains("bold") || lower.contains("heavy") || lower.contains("black")
}

/// Check if a font name indicates italic.
fn is_italic_font(fontname: &str) -> bool {
    let lower = fontname.to_lowercase();
    lower.contains("italic") || lower.contains("oblique")
}

/// Get the dominant font name in a word.
fn word_dominant_font(word: &Word) -> &str {
    if word.chars.is_empty() {
        return "";
    }
    // Use the font of the first non-space character
    word.chars
        .iter()
        .find(|c| !c.text.trim().is_empty())
        .map(|c| c.fontname.as_str())
        .unwrap_or("")
}

/// Classify text blocks into content elements.
fn classify_blocks(
    blocks: &[TextBlock],
    median_size: f64,
    options: &MarkdownOptions,
) -> Vec<ContentElement> {
    let mut elements = Vec::new();

    for block in blocks {
        let block_text = block_to_text(block);
        if block_text.trim().is_empty() {
            continue;
        }

        let dominant_size = block_dominant_size(block);

        // Check for heading
        if let Some(level) =
            detect_heading_level(dominant_size, median_size, options.heading_min_ratio)
        {
            // Headings are typically short (single line or few words)
            let is_short =
                block.lines.len() <= 2 && block.lines.iter().all(|l| l.words.len() <= 15);
            if is_short {
                elements.push(ContentElement::Heading {
                    level,
                    text: block_text.trim().to_string(),
                });
                continue;
            }
        }

        // Check for list items
        if options.detect_lists {
            let line_texts: Vec<String> = block.lines.iter().map(line_to_text).collect();

            let all_list_items = line_texts.iter().all(|t| detect_list_item(t).is_some());
            if all_list_items && !line_texts.is_empty() {
                for text in &line_texts {
                    if let Some((prefix, rest)) = detect_list_item(text) {
                        elements.push(ContentElement::ListItem { prefix, text: rest });
                    }
                }
                continue;
            }
        }

        // Apply emphasis if enabled
        let rendered_text = if options.detect_emphasis {
            render_block_with_emphasis(block)
        } else {
            block_text
        };

        elements.push(ContentElement::Paragraph(rendered_text.trim().to_string()));
    }

    elements
}

/// Convert a text block to plain text.
fn block_to_text(block: &TextBlock) -> String {
    block
        .lines
        .iter()
        .map(line_to_text)
        .collect::<Vec<_>>()
        .join("\n")
}

/// Convert a text line to plain text.
fn line_to_text(line: &TextLine) -> String {
    line.words
        .iter()
        .map(|w| w.text.as_str())
        .collect::<Vec<_>>()
        .join(" ")
}

/// Render a block with bold/italic emphasis based on font names.
fn render_block_with_emphasis(block: &TextBlock) -> String {
    block
        .lines
        .iter()
        .map(render_line_with_emphasis)
        .collect::<Vec<_>>()
        .join("\n")
}

/// Render a line with emphasis markers.
fn render_line_with_emphasis(line: &TextLine) -> String {
    let mut parts: Vec<String> = Vec::new();

    for word in &line.words {
        let font = word_dominant_font(word);
        let bold = is_bold_font(font);
        let italic = is_italic_font(font);

        let text = &word.text;
        if bold && italic {
            parts.push(format!("***{text}***"));
        } else if bold {
            parts.push(format!("**{text}**"));
        } else if italic {
            parts.push(format!("*{text}*"));
        } else {
            parts.push(text.clone());
        }
    }

    parts.join(" ")
}

/// Convert a Table to GitHub Flavored Markdown table syntax.
fn table_to_gfm(table: &Table) -> String {
    if table.rows.is_empty() {
        return String::new();
    }

    let mut lines = Vec::new();

    for (i, row) in table.rows.iter().enumerate() {
        let cells: Vec<String> = row
            .iter()
            .map(|cell| {
                cell.text
                    .as_deref()
                    .unwrap_or("")
                    .replace('|', "\\|")
                    .replace('\n', " ")
            })
            .collect();

        let line = format!("| {} |", cells.join(" | "));
        lines.push(line);

        // Add separator after first row (header)
        if i == 0 {
            let sep: Vec<&str> = cells.iter().map(|_| "---").collect();
            lines.push(format!("| {} |", sep.join(" | ")));
        }
    }

    lines.join("\n")
}

/// Render content elements into a Markdown string.
fn render_elements(elements: &[ContentElement]) -> String {
    let mut parts: Vec<String> = Vec::new();

    for element in elements {
        match element {
            ContentElement::Heading { level, text } => {
                let hashes = "#".repeat(*level as usize);
                parts.push(format!("{hashes} {text}"));
            }
            ContentElement::Paragraph(text) => {
                parts.push(text.clone());
            }
            ContentElement::Table(md) => {
                parts.push(md.clone());
            }
            ContentElement::ListItem { prefix, text } => {
                // Normalize list prefix to standard Markdown
                let md_prefix = if prefix.starts_with(|c: char| c.is_ascii_digit()) {
                    prefix.clone()
                } else {
                    "- ".to_string()
                };
                parts.push(format!("{md_prefix}{text}"));
            }
        }
    }

    parts.join("\n\n")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::geometry::BBox;
    use crate::table::Cell;
    use crate::text::TextDirection;

    fn make_char(text: &str, x0: f64, top: f64, x1: f64, bottom: f64, size: f64) -> Char {
        Char {
            text: text.to_string(),
            bbox: BBox::new(x0, top, x1, bottom),
            fontname: "Helvetica".to_string(),
            size,
            doctop: top,
            upright: true,
            direction: TextDirection::Ltr,
            stroking_color: None,
            non_stroking_color: None,
            ctm: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
            char_code: 0,
            mcid: None,
            tag: None,
        }
    }

    fn make_word_from_text(
        text: &str,
        x0: f64,
        top: f64,
        x1: f64,
        bottom: f64,
        size: f64,
        fontname: &str,
    ) -> Word {
        let chars: Vec<Char> = text
            .chars()
            .enumerate()
            .map(|(i, c)| {
                let char_width = (x1 - x0) / text.len() as f64;
                let cx0 = x0 + i as f64 * char_width;
                let cx1 = cx0 + char_width;
                Char {
                    text: c.to_string(),
                    bbox: BBox::new(cx0, top, cx1, bottom),
                    fontname: fontname.to_string(),
                    size,
                    doctop: top,
                    upright: true,
                    direction: TextDirection::Ltr,
                    stroking_color: None,
                    non_stroking_color: None,
                    ctm: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
                    char_code: 0,
                    mcid: None,
                    tag: None,
                }
            })
            .collect();
        Word {
            text: text.to_string(),
            bbox: BBox::new(x0, top, x1, bottom),
            doctop: top,
            direction: TextDirection::Ltr,
            chars,
        }
    }

    // --- Heading detection tests ---

    #[test]
    fn test_detect_heading_h1() {
        assert_eq!(detect_heading_level(24.0, 12.0, 1.2), Some(1));
    }

    #[test]
    fn test_detect_heading_h2() {
        assert_eq!(detect_heading_level(20.0, 12.0, 1.2), Some(2));
    }

    #[test]
    fn test_detect_heading_h3() {
        assert_eq!(detect_heading_level(16.0, 12.0, 1.2), Some(3));
    }

    #[test]
    fn test_detect_heading_h4() {
        assert_eq!(detect_heading_level(14.5, 12.0, 1.2), Some(4));
    }

    #[test]
    fn test_detect_no_heading_normal_size() {
        assert_eq!(detect_heading_level(12.0, 12.0, 1.2), None);
    }

    #[test]
    fn test_detect_heading_zero_median() {
        assert_eq!(detect_heading_level(12.0, 0.0, 1.2), None);
    }

    #[test]
    fn test_detect_heading_zero_font_size() {
        assert_eq!(detect_heading_level(0.0, 12.0, 1.2), None);
    }

    // --- List detection tests ---

    #[test]
    fn test_detect_bullet_dash() {
        let result = detect_list_item("- item text");
        assert_eq!(result, Some(("- ".to_string(), "item text".to_string())));
    }

    #[test]
    fn test_detect_bullet_asterisk() {
        let result = detect_list_item("* item text");
        assert_eq!(result, Some(("* ".to_string(), "item text".to_string())));
    }

    #[test]
    fn test_detect_bullet_unicode() {
        let result = detect_list_item("• item text");
        assert_eq!(result, Some(("• ".to_string(), "item text".to_string())));
    }

    #[test]
    fn test_detect_numbered_list_dot() {
        let result = detect_list_item("1. first item");
        assert_eq!(result, Some(("1. ".to_string(), "first item".to_string())));
    }

    #[test]
    fn test_detect_numbered_list_paren() {
        let result = detect_list_item("2) second item");
        assert_eq!(result, Some(("2) ".to_string(), "second item".to_string())));
    }

    #[test]
    fn test_detect_no_list_normal_text() {
        assert_eq!(detect_list_item("Just normal text"), None);
    }

    #[test]
    fn test_detect_no_list_empty() {
        assert_eq!(detect_list_item(""), None);
    }

    // --- Median font size tests ---

    #[test]
    fn test_median_font_size_empty() {
        assert_eq!(compute_median_font_size(&[]), 12.0);
    }

    #[test]
    fn test_median_font_size_single() {
        let chars = vec![make_char("A", 0.0, 0.0, 10.0, 12.0, 14.0)];
        assert_eq!(compute_median_font_size(&chars), 14.0);
    }

    #[test]
    fn test_median_font_size_odd_count() {
        let chars = vec![
            make_char("A", 0.0, 0.0, 10.0, 12.0, 10.0),
            make_char("B", 10.0, 0.0, 20.0, 12.0, 12.0),
            make_char("C", 20.0, 0.0, 30.0, 12.0, 14.0),
        ];
        assert_eq!(compute_median_font_size(&chars), 12.0);
    }

    #[test]
    fn test_median_font_size_even_count() {
        let chars = vec![
            make_char("A", 0.0, 0.0, 10.0, 12.0, 10.0),
            make_char("B", 10.0, 0.0, 20.0, 12.0, 14.0),
        ];
        assert_eq!(compute_median_font_size(&chars), 12.0);
    }

    #[test]
    fn test_median_font_size_ignores_zero_size() {
        let chars = vec![
            make_char("A", 0.0, 0.0, 10.0, 12.0, 0.0),
            make_char("B", 10.0, 0.0, 20.0, 12.0, 12.0),
            make_char("C", 20.0, 0.0, 30.0, 12.0, 14.0),
        ];
        assert_eq!(compute_median_font_size(&chars), 13.0);
    }

    // --- Table to GFM tests ---

    #[test]
    fn test_table_to_gfm_simple() {
        let table = Table {
            bbox: BBox::new(0.0, 0.0, 100.0, 50.0),
            cells: vec![],
            rows: vec![
                vec![
                    Cell {
                        bbox: BBox::new(0.0, 0.0, 50.0, 25.0),
                        text: Some("Name".to_string()),
                    },
                    Cell {
                        bbox: BBox::new(50.0, 0.0, 100.0, 25.0),
                        text: Some("Age".to_string()),
                    },
                ],
                vec![
                    Cell {
                        bbox: BBox::new(0.0, 25.0, 50.0, 50.0),
                        text: Some("Alice".to_string()),
                    },
                    Cell {
                        bbox: BBox::new(50.0, 25.0, 100.0, 50.0),
                        text: Some("30".to_string()),
                    },
                ],
            ],
            columns: vec![],
        };
        let gfm = table_to_gfm(&table);
        assert_eq!(gfm, "| Name | Age |\n| --- | --- |\n| Alice | 30 |");
    }

    #[test]
    fn test_table_to_gfm_with_none_cells() {
        let table = Table {
            bbox: BBox::new(0.0, 0.0, 100.0, 50.0),
            cells: vec![],
            rows: vec![
                vec![
                    Cell {
                        bbox: BBox::new(0.0, 0.0, 50.0, 25.0),
                        text: Some("Header".to_string()),
                    },
                    Cell {
                        bbox: BBox::new(50.0, 0.0, 100.0, 25.0),
                        text: None,
                    },
                ],
                vec![
                    Cell {
                        bbox: BBox::new(0.0, 25.0, 50.0, 50.0),
                        text: None,
                    },
                    Cell {
                        bbox: BBox::new(50.0, 25.0, 100.0, 50.0),
                        text: Some("Data".to_string()),
                    },
                ],
            ],
            columns: vec![],
        };
        let gfm = table_to_gfm(&table);
        assert_eq!(gfm, "| Header |  |\n| --- | --- |\n|  | Data |");
    }

    #[test]
    fn test_table_to_gfm_empty_rows() {
        let table = Table {
            bbox: BBox::new(0.0, 0.0, 100.0, 50.0),
            cells: vec![],
            rows: vec![],
            columns: vec![],
        };
        assert_eq!(table_to_gfm(&table), "");
    }

    #[test]
    fn test_table_to_gfm_escapes_pipe() {
        let table = Table {
            bbox: BBox::new(0.0, 0.0, 100.0, 50.0),
            cells: vec![],
            rows: vec![
                vec![Cell {
                    bbox: BBox::new(0.0, 0.0, 100.0, 25.0),
                    text: Some("A|B".to_string()),
                }],
                vec![Cell {
                    bbox: BBox::new(0.0, 25.0, 100.0, 50.0),
                    text: Some("C".to_string()),
                }],
            ],
            columns: vec![],
        };
        let gfm = table_to_gfm(&table);
        assert!(gfm.contains("A\\|B"));
    }

    // --- Paragraph grouping tests ---

    #[test]
    fn test_render_simple_paragraph() {
        // Create characters that form "Hello World" on one line
        // Keep word gap < x_density (10) to avoid column split
        let chars = vec![
            make_char("H", 0.0, 0.0, 8.0, 12.0, 12.0),
            make_char("e", 8.0, 0.0, 16.0, 12.0, 12.0),
            make_char("l", 16.0, 0.0, 24.0, 12.0, 12.0),
            make_char("l", 24.0, 0.0, 32.0, 12.0, 12.0),
            make_char("o", 32.0, 0.0, 40.0, 12.0, 12.0),
            make_char(" ", 40.0, 0.0, 44.0, 12.0, 12.0),
            make_char("W", 44.0, 0.0, 52.0, 12.0, 12.0),
            make_char("o", 52.0, 0.0, 60.0, 12.0, 12.0),
            make_char("r", 60.0, 0.0, 68.0, 12.0, 12.0),
            make_char("l", 68.0, 0.0, 76.0, 12.0, 12.0),
            make_char("d", 76.0, 0.0, 84.0, 12.0, 12.0),
        ];
        let result = MarkdownRenderer::render_text(&chars, &MarkdownOptions::default());
        assert_eq!(result.trim(), "Hello World");
    }

    #[test]
    fn test_render_heading_detection() {
        // Large text at 24pt (should be H1 relative to 12pt median)
        let mut chars = Vec::new();
        // Large heading
        for (i, c) in "Title".chars().enumerate() {
            chars.push(make_char(
                &c.to_string(),
                i as f64 * 16.0,
                0.0,
                (i + 1) as f64 * 16.0,
                24.0,
                24.0,
            ));
        }
        // Normal body text on a separate line (gap > y_density)
        for (i, c) in "Body text here".chars().enumerate() {
            let x0 = i as f64 * 8.0;
            if c == ' ' {
                chars.push(make_char(" ", x0, 40.0, x0 + 8.0, 52.0, 12.0));
            } else {
                chars.push(make_char(&c.to_string(), x0, 40.0, x0 + 8.0, 52.0, 12.0));
            }
        }
        let result = MarkdownRenderer::render_text(&chars, &MarkdownOptions::default());
        assert!(
            result.contains("# Title"),
            "Expected H1 heading, got: {result}"
        );
        assert!(
            result.contains("Body text here"),
            "Expected body text, got: {result}"
        );
    }

    #[test]
    fn test_render_empty_input() {
        let result = MarkdownRenderer::render(&[], &[], &MarkdownOptions::default());
        assert_eq!(result, "");
    }

    // --- Bold/italic detection tests ---

    #[test]
    fn test_bold_font_detection() {
        assert!(is_bold_font("Helvetica-Bold"));
        assert!(is_bold_font("TimesNewRoman-BoldItalic"));
        assert!(!is_bold_font("Helvetica"));
        assert!(!is_bold_font("Times-Roman"));
    }

    #[test]
    fn test_italic_font_detection() {
        assert!(is_italic_font("Helvetica-Oblique"));
        assert!(is_italic_font("Times-Italic"));
        assert!(!is_italic_font("Helvetica"));
        assert!(!is_italic_font("Helvetica-Bold"));
    }

    #[test]
    fn test_render_with_emphasis() {
        let line = TextLine {
            words: vec![
                make_word_from_text("normal", 0.0, 0.0, 48.0, 12.0, 12.0, "Helvetica"),
                make_word_from_text("bold", 52.0, 0.0, 88.0, 12.0, 12.0, "Helvetica-Bold"),
                make_word_from_text("italic", 92.0, 0.0, 140.0, 12.0, 12.0, "Helvetica-Oblique"),
            ],
            bbox: BBox::new(0.0, 0.0, 140.0, 12.0),
        };
        let result = render_line_with_emphasis(&line);
        assert_eq!(result, "normal **bold** *italic*");
    }

    // --- MarkdownOptions default tests ---

    #[test]
    fn test_markdown_options_default() {
        let opts = MarkdownOptions::default();
        assert_eq!(opts.y_tolerance, 3.0);
        assert_eq!(opts.y_density, 10.0);
        assert_eq!(opts.x_density, 10.0);
        assert_eq!(opts.heading_min_ratio, 1.2);
        assert!(opts.detect_lists);
        assert!(opts.detect_emphasis);
    }

    // --- End-to-end rendering tests ---

    #[test]
    fn test_render_with_table() {
        let table = Table {
            bbox: BBox::new(0.0, 0.0, 100.0, 50.0),
            cells: vec![],
            rows: vec![
                vec![
                    Cell {
                        bbox: BBox::new(0.0, 0.0, 50.0, 25.0),
                        text: Some("Col1".to_string()),
                    },
                    Cell {
                        bbox: BBox::new(50.0, 0.0, 100.0, 25.0),
                        text: Some("Col2".to_string()),
                    },
                ],
                vec![
                    Cell {
                        bbox: BBox::new(0.0, 25.0, 50.0, 50.0),
                        text: Some("A".to_string()),
                    },
                    Cell {
                        bbox: BBox::new(50.0, 25.0, 100.0, 50.0),
                        text: Some("B".to_string()),
                    },
                ],
            ],
            columns: vec![],
        };
        let result = MarkdownRenderer::render(&[], &[table], &MarkdownOptions::default());
        assert!(result.contains("| Col1 | Col2 |"));
        assert!(result.contains("| --- | --- |"));
        assert!(result.contains("| A | B |"));
    }

    #[test]
    fn test_table_to_gfm_single_row() {
        let table = Table {
            bbox: BBox::new(0.0, 0.0, 100.0, 25.0),
            cells: vec![],
            rows: vec![vec![
                Cell {
                    bbox: BBox::new(0.0, 0.0, 50.0, 25.0),
                    text: Some("Only".to_string()),
                },
                Cell {
                    bbox: BBox::new(50.0, 0.0, 100.0, 25.0),
                    text: Some("Row".to_string()),
                },
            ]],
            columns: vec![],
        };
        let gfm = table_to_gfm(&table);
        // Single row should still have separator
        assert_eq!(gfm, "| Only | Row |\n| --- | --- |");
    }

    #[test]
    fn test_render_list_items() {
        // Create chars that form list items
        let mut chars = Vec::new();
        for (i, c) in "- first item".chars().enumerate() {
            let x0 = i as f64 * 8.0;
            chars.push(make_char(&c.to_string(), x0, 0.0, x0 + 8.0, 12.0, 12.0));
        }
        for (i, c) in "- second item".chars().enumerate() {
            let x0 = i as f64 * 8.0;
            chars.push(make_char(&c.to_string(), x0, 15.0, x0 + 8.0, 27.0, 12.0));
        }
        let result = MarkdownRenderer::render_text(&chars, &MarkdownOptions::default());
        assert!(
            result.contains("- first item"),
            "Expected first list item, got: {result}"
        );
        assert!(
            result.contains("- second item"),
            "Expected second list item, got: {result}"
        );
    }

    #[test]
    fn test_detect_numbered_list_multi_digit() {
        let result = detect_list_item("12. twelfth item");
        assert_eq!(
            result,
            Some(("12. ".to_string(), "twelfth item".to_string()))
        );
    }

    #[test]
    fn test_block_dominant_size() {
        let block = TextBlock {
            lines: vec![TextLine {
                words: vec![make_word_from_text(
                    "Hello",
                    0.0,
                    0.0,
                    40.0,
                    12.0,
                    14.0,
                    "Helvetica",
                )],
                bbox: BBox::new(0.0, 0.0, 40.0, 12.0),
            }],
            bbox: BBox::new(0.0, 0.0, 40.0, 12.0),
        };
        assert_eq!(block_dominant_size(&block), 14.0);
    }

    #[test]
    fn test_render_elements_heading_and_paragraph() {
        let elements = vec![
            ContentElement::Heading {
                level: 1,
                text: "My Title".to_string(),
            },
            ContentElement::Paragraph("Some body text.".to_string()),
        ];
        let result = render_elements(&elements);
        assert_eq!(result, "# My Title\n\nSome body text.");
    }

    #[test]
    fn test_render_elements_list() {
        let elements = vec![
            ContentElement::ListItem {
                prefix: "- ".to_string(),
                text: "first".to_string(),
            },
            ContentElement::ListItem {
                prefix: "- ".to_string(),
                text: "second".to_string(),
            },
        ];
        let result = render_elements(&elements);
        assert_eq!(result, "- first\n\n- second");
    }

    #[test]
    fn test_render_elements_numbered_list() {
        let elements = vec![
            ContentElement::ListItem {
                prefix: "1. ".to_string(),
                text: "first".to_string(),
            },
            ContentElement::ListItem {
                prefix: "2. ".to_string(),
                text: "second".to_string(),
            },
        ];
        let result = render_elements(&elements);
        assert_eq!(result, "1. first\n\n2. second");
    }

    #[test]
    fn test_table_to_gfm_newline_in_cell() {
        let table = Table {
            bbox: BBox::new(0.0, 0.0, 100.0, 50.0),
            cells: vec![],
            rows: vec![
                vec![Cell {
                    bbox: BBox::new(0.0, 0.0, 100.0, 25.0),
                    text: Some("Header".to_string()),
                }],
                vec![Cell {
                    bbox: BBox::new(0.0, 25.0, 100.0, 50.0),
                    text: Some("Line1\nLine2".to_string()),
                }],
            ],
            columns: vec![],
        };
        let gfm = table_to_gfm(&table);
        // Newlines in cells should be replaced with spaces
        assert!(gfm.contains("Line1 Line2"));
        // Check that the GFM has 3 lines: header, separator, data row
        let gfm_lines: Vec<&str> = gfm.lines().collect();
        assert_eq!(gfm_lines.len(), 3);
    }
}
