use regex::Regex;
use std::collections::HashMap;

/// Macros to strip entirely (dynamic/heavy content).
const STRIP_MACROS: &[&str] = &[
    "jira",
    "jirachart",
    "roadmap",
    "chart",
    "drawio",
    "gliffy",
    "lucidchart",
    "confluence-embedded-file",
    "widget",
    "iframe",
    "html",
    "gallery",
    "calendar",
    "livesearch",
    "pagetree",
    "children",
    "recently-updated",
    "content-by-label",
    "blog-posts",
    "change-history",
    "contributors",
    "profile-picture",
    "user-profile",
    "space-details",
    "page-info",
    "attachments",
    "include",
    "excerpt-include",
    "multiexcerpt-include",
];

/// Process a code macro and extract its content.
fn process_code_macro(macro_html: &str) -> String {
    let lang_re = Regex::new(r#"(?i)ac:parameter\s+ac:name="language"[^>]*>([^<]*)"#).unwrap();
    let language = lang_re
        .captures(macro_html)
        .map_or("", |c| c.get(1).map_or("", |m| m.as_str()))
        .trim()
        .to_string();

    let cdata_re = Regex::new(r"<!\[CDATA\[([\s\S]*?)\]\]>").unwrap();
    let body_re =
        Regex::new(r"(?i)<ac:plain-text-body[^>]*>([\s\S]*?)</ac:plain-text-body>").unwrap();

    let code = if let Some(cap) = cdata_re.captures(macro_html) {
        cap[1].to_string()
    } else if let Some(cap) = body_re.captures(macro_html) {
        let inner = &cap[1];
        let cdata_inner = Regex::new(r"<!\[CDATA\[([\s\S]*?)\]\]>").unwrap();
        cdata_inner
            .captures(inner)
            .map_or_else(|| inner.to_string(), |c| c[1].to_string())
    } else {
        String::new()
    };

    if !code.is_empty() {
        format!("\n```{language}\n{}\n```\n", code.trim())
    } else {
        String::new()
    }
}

/// Process info/note/warning/tip panel macros.
fn process_panel_macro(macro_html: &str, macro_name: &str) -> String {
    let title_re = Regex::new(r#"(?i)ac:parameter\s+ac:name="title"[^>]*>([^<]*)"#).unwrap();
    let title = title_re
        .captures(macro_html)
        .map_or(String::new(), |c| c[1].trim().to_string());

    let body_re =
        Regex::new(r"(?i)<ac:rich-text-body[^>]*>([\s\S]*?)</ac:rich-text-body>").unwrap();
    let content = body_re
        .captures(macro_html)
        .map_or(String::new(), |c| c[1].trim().to_string());

    let mut emojis = HashMap::new();
    emojis.insert("info", "ℹ️");
    emojis.insert("note", "📝");
    emojis.insert("warning", "⚠️");
    emojis.insert("tip", "💡");
    emojis.insert("panel", "📋");

    let emoji = emojis.get(macro_name).unwrap_or(&"📌");
    let upper = macro_name.to_uppercase();
    let header = if title.is_empty() {
        format!("**{emoji} {upper}**")
    } else {
        format!("**{emoji} {upper}: {title}**")
    };

    if content.is_empty() {
        format!("\n> {header}\n")
    } else {
        let quoted = content.replace('\n', "\n> ");
        format!("\n> {header}\n> {quoted}\n")
    }
}

/// Process expand/collapsible section macros.
fn process_expand_macro(macro_html: &str) -> String {
    let title_re = Regex::new(r#"(?i)ac:parameter\s+ac:name="title"[^>]*>([^<]*)"#).unwrap();
    let title = title_re
        .captures(macro_html)
        .map_or("Expand".to_string(), |c| c[1].trim().to_string());

    let body_re =
        Regex::new(r"(?i)<ac:rich-text-body[^>]*>([\s\S]*?)</ac:rich-text-body>").unwrap();
    let content = body_re
        .captures(macro_html)
        .map_or(String::new(), |c| c[1].trim().to_string());

    format!("\n<details>\n<summary>{title}</summary>\n\n{content}\n</details>\n")
}

/// Process status macro.
fn process_status_macro(macro_html: &str) -> String {
    let title_re = Regex::new(r#"(?i)ac:parameter\s+ac:name="title"[^>]*>([^<]*)"#).unwrap();
    let title = title_re
        .captures(macro_html)
        .map_or(String::new(), |c| c[1].trim().to_string());

    let color_re = Regex::new(r#"(?i)ac:parameter\s+ac:name="colour"[^>]*>([^<]*)"#).unwrap();
    let color = color_re
        .captures(macro_html)
        .map_or(String::new(), |c| c[1].trim().to_lowercase());

    let emoji = match color.as_str() {
        "green" => "🟢",
        "yellow" => "🟡",
        "red" => "🔴",
        "blue" => "🔵",
        "grey" | "gray" => "⚪",
        _ => "⚪",
    };

    if title.is_empty() {
        String::new()
    } else {
        format!("{emoji} {title}")
    }
}

/// Process quote macro.
fn process_quote_macro(macro_html: &str) -> String {
    let body_re =
        Regex::new(r"(?i)<ac:rich-text-body[^>]*>([\s\S]*?)</ac:rich-text-body>").unwrap();
    let content = body_re
        .captures(macro_html)
        .map_or(String::new(), |c| c[1].trim().to_string());

    if content.is_empty() {
        String::new()
    } else {
        let quoted = content.replace('\n', "\n> ");
        format!("\n> {quoted}\n")
    }
}

/// Clean Confluence HTML by processing macros.
pub fn process_confluence_macros(html: &str) -> String {
    if html.is_empty() {
        return String::new();
    }

    let mut result = html.to_string();

    // Process structured macros
    let macro_re = Regex::new(
        r#"(?is)<ac:structured-macro[^>]*ac:name="([^"]*)"[^>]*>([\s\S]*?)</ac:structured-macro>"#,
    )
    .unwrap();

    result = macro_re
        .replace_all(&result, |caps: &regex::Captures| {
            let name = caps[1].to_lowercase();
            let full_match = caps.get(0).unwrap().as_str();

            match name.as_str() {
                "code" => process_code_macro(full_match),
                "info" | "note" | "warning" | "tip" | "panel" => {
                    process_panel_macro(full_match, &name)
                }
                "expand" => process_expand_macro(full_match),
                "status" => process_status_macro(full_match),
                "toc" => "\n[Table of Contents]\n".to_string(),
                "quote" => process_quote_macro(full_match),
                "excerpt" => {
                    let body_re = Regex::new(
                        r"(?i)<ac:rich-text-body[^>]*>([\s\S]*?)</ac:rich-text-body>",
                    )
                    .unwrap();
                    body_re
                        .captures(full_match)
                        .map_or(String::new(), |c| c[1].to_string())
                }
                "anchor" => {
                    let param_re = Regex::new(r"ac:parameter[^>]*>([^<]*)").unwrap();
                    param_re.captures(full_match).map_or(String::new(), |c| {
                        format!(r#"<a id="{}"></a>"#, &c[1])
                    })
                }
                _ if STRIP_MACROS.contains(&name.as_str()) => {
                    format!("\n[{name} macro removed]\n")
                }
                _ => {
                    let tag_re = Regex::new(r"<[^>]*>").unwrap();
                    let text = tag_re.replace_all(full_match, "").trim().to_string();
                    if text.is_empty() {
                        format!("\n[{name} macro]\n")
                    } else {
                        let truncated = if text.len() > 100 {
                            format!("{}...", &text[..100])
                        } else {
                            text
                        };
                        format!("\n[{name}: {truncated}]\n")
                    }
                }
            }
        })
        .to_string();

    // Remove inline comment markers
    let inline_re =
        Regex::new(r"(?is)<ac:inline-comment-marker[^>]*>[\s\S]*?</ac:inline-comment-marker>")
            .unwrap();
    result = inline_re.replace_all(&result, "").to_string();

    // Convert emoticons
    let emoticon_re = Regex::new(r#"(?i)<ac:emoticon[^>]*ac:name="([^"]*)"[^>]*/?\s*>"#).unwrap();
    result = emoticon_re
        .replace_all(&result, |caps: &regex::Captures| {
            match caps[1].to_lowercase().as_str() {
                "smile" => "😊",
                "sad" => "😢",
                "tongue" => "😛",
                "biggrin" => "😁",
                "wink" => "😉",
                "thumbs-up" => "👍",
                "thumbs-down" => "👎",
                "information" => "ℹ️",
                "tick" => "✅",
                "cross" => "❌",
                "warning" => "⚠️",
                "plus" => "➕",
                "minus" => "➖",
                "question" => "❓",
                "light-on" | "light-off" => "💡",
                "yellow-star" | "red-star" | "green-star" | "blue-star" => "⭐",
                _ => "",
            }
            .to_string()
        })
        .to_string();

    // Task lists
    let tl_re = Regex::new(r"(?is)<ac:task-list[^>]*>([\s\S]*?)</ac:task-list>").unwrap();
    result = tl_re.replace_all(&result, "$1").to_string();

    let task_re = Regex::new(r"(?is)<ac:task[^>]*>([\s\S]*?)</ac:task>").unwrap();
    result = task_re.replace_all(&result, "- [ ] $1").to_string();

    let ts_done_re = Regex::new(r"(?i)<ac:task-status[^>]*>complete</ac:task-status>").unwrap();
    result = ts_done_re.replace_all(&result, "[x]").to_string();

    let ts_re = Regex::new(r"(?i)<ac:task-status[^>]*>[^<]*</ac:task-status>").unwrap();
    result = ts_re.replace_all(&result, "[ ]").to_string();

    let tb_re = Regex::new(r"(?is)<ac:task-body[^>]*>([\s\S]*?)</ac:task-body>").unwrap();
    result = tb_re.replace_all(&result, "$1").to_string();

    let tid_re = Regex::new(r"(?i)<ac:task-id[^>]*>[^<]*</ac:task-id>").unwrap();
    result = tid_re.replace_all(&result, "").to_string();

    // Placeholder macros
    let ph_re = Regex::new(r"(?is)<ac:placeholder[^>]*>[\s\S]*?</ac:placeholder>").unwrap();
    result = ph_re.replace_all(&result, "").to_string();

    // Image attachments
    let img_re = Regex::new(r"(?is)<ac:image[^>]*>[\s\S]*?</ac:image>").unwrap();
    result = img_re
        .replace_all(&result, |caps: &regex::Captures| {
            let full = caps.get(0).unwrap().as_str();
            let fn_re = Regex::new(r#"ri:filename="([^"]*)""#).unwrap();
            fn_re.captures(full).map_or("[Image]".to_string(), |c| {
                format!("[Image: {}]", &c[1])
            })
        })
        .to_string();

    // Confluence page links
    let link_re = Regex::new(r"(?is)<ac:link[^>]*>[\s\S]*?</ac:link>").unwrap();
    result = link_re
        .replace_all(&result, |caps: &regex::Captures| {
            let full = caps.get(0).unwrap().as_str();
            let title_re = Regex::new(r#"ri:content-title="([^"]*)""#).unwrap();
            let lb_re =
                Regex::new(r"(?is)<ac:link-body[^>]*>([\s\S]*?)</ac:link-body>").unwrap();
            let plb_re = Regex::new(
                r"(?is)<ac:plain-text-link-body[^>]*>([\s\S]*?)</ac:plain-text-link-body>",
            )
            .unwrap();

            let page_title = title_re.captures(full).map(|c| c[1].to_string());
            let link_text = lb_re
                .captures(full)
                .map(|c| c[1].to_string())
                .or_else(|| plb_re.captures(full).map(|c| c[1].to_string()))
                .or_else(|| page_title.clone())
                .unwrap_or_else(|| "link".to_string());

            if let Some(pt) = page_title {
                format!("[{link_text}](confluence:{pt})")
            } else {
                link_text
            }
        })
        .to_string();

    result
}

/// Heading extracted from HTML.
pub struct Heading {
    pub level: u8,
    pub text: String,
    #[allow(dead_code)]
    pub id: Option<String>,
}

/// Extract headings from HTML for outline view.
pub fn extract_headings(html: &str) -> Vec<Heading> {
    let mut headings = Vec::new();
    let heading_re = Regex::new(r#"(?is)<h([1-6])([^>]*)>([\s\S]*?)</h[1-6]>"#).unwrap();
    let id_re = Regex::new(r#"id="([^"]*)""#).unwrap();
    let tag_strip = Regex::new(r"<[^>]*>").unwrap();

    for cap in heading_re.captures_iter(html) {
        let level: u8 = cap[1].parse().unwrap_or(1);
        let attrs = &cap[2];
        let raw_text = &cap[3];

        let id = id_re.captures(attrs).map(|c| c[1].to_string());
        let text = decode_entities(&tag_strip.replace_all(raw_text, "")).trim().to_string();

        if !text.is_empty() {
            headings.push(Heading { level, text, id });
        }
    }

    headings
}

/// Extract content for a specific section based on heading.
pub fn extract_section(html: &str, heading_text: &str, heading_level: Option<u8>) -> Option<String> {
    let normalized = heading_text.to_lowercase();
    let heading_re = Regex::new(r"(?is)<h([1-6])[^>]*>([\s\S]*?)</h[1-6]>").unwrap();
    let tag_strip = Regex::new(r"<[^>]*>").unwrap();

    let mut start_index: Option<usize> = None;
    let mut start_level: u8 = 0;

    for cap in heading_re.captures_iter(html) {
        let level: u8 = cap[1].parse().unwrap_or(1);
        let text = decode_entities(&tag_strip.replace_all(&cap[2], ""))
            .trim()
            .to_lowercase();

        let m = cap.get(0).unwrap();

        if start_index.is_none() {
            if text.contains(&normalized) && heading_level.map_or(true, |l| l == level) {
                start_index = Some(m.end());
                start_level = level;
            }
        } else if level <= start_level {
            let section = &html[start_index.unwrap()..m.start()];
            return Some(section.trim().to_string());
        }
    }

    start_index.map(|si| html[si..].trim().to_string())
}

/// Decode common HTML entities.
fn decode_entities(s: &str) -> String {
    s.replace("&nbsp;", " ")
        .replace("&amp;", "&")
        .replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&quot;", "\"")
        .replace("&#39;", "'")
}

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

    // ── process_confluence_macros ──

    #[test]
    fn empty_input() {
        assert_eq!(process_confluence_macros(""), "");
    }

    #[test]
    fn passthrough_plain_html() {
        let html = "<p>Hello world</p>";
        assert_eq!(process_confluence_macros(html), html);
    }

    #[test]
    fn code_macro_with_language() {
        let html = r#"<ac:structured-macro ac:name="code"><ac:parameter ac:name="language">rust</ac:parameter><ac:plain-text-body><![CDATA[fn main() {}]]></ac:plain-text-body></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("```rust"));
        assert!(result.contains("fn main() {}"));
        assert!(result.contains("```"));
    }

    #[test]
    fn code_macro_without_language() {
        let html = r#"<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[some code]]></ac:plain-text-body></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("```\n"));
        assert!(result.contains("some code"));
    }

    #[test]
    fn info_panel_macro() {
        let html = r#"<ac:structured-macro ac:name="info"><ac:parameter ac:name="title">Note Title</ac:parameter><ac:rich-text-body>Body text</ac:rich-text-body></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("ℹ️"));
        assert!(result.contains("INFO"));
        assert!(result.contains("Note Title"));
        assert!(result.contains("Body text"));
    }

    #[test]
    fn warning_panel_macro() {
        let html = r#"<ac:structured-macro ac:name="warning"><ac:rich-text-body>Danger!</ac:rich-text-body></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("⚠️"));
        assert!(result.contains("WARNING"));
        assert!(result.contains("Danger!"));
    }

    #[test]
    fn tip_panel_macro() {
        let html = r#"<ac:structured-macro ac:name="tip"><ac:rich-text-body>A helpful tip</ac:rich-text-body></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("💡"));
        assert!(result.contains("TIP"));
    }

    #[test]
    fn note_panel_macro() {
        let html = r#"<ac:structured-macro ac:name="note"><ac:rich-text-body>A note</ac:rich-text-body></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("📝"));
        assert!(result.contains("NOTE"));
    }

    #[test]
    fn panel_macro_empty_body() {
        let html = r#"<ac:structured-macro ac:name="info"><ac:parameter ac:name="title">Title</ac:parameter><ac:rich-text-body></ac:rich-text-body></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("INFO: Title"));
    }

    #[test]
    fn expand_macro() {
        let html = r#"<ac:structured-macro ac:name="expand"><ac:parameter ac:name="title">Click me</ac:parameter><ac:rich-text-body>Hidden content</ac:rich-text-body></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("<details>"));
        assert!(result.contains("<summary>Click me</summary>"));
        assert!(result.contains("Hidden content"));
        assert!(result.contains("</details>"));
    }

    #[test]
    fn status_macro_green() {
        let html = r#"<ac:structured-macro ac:name="status"><ac:parameter ac:name="title">Done</ac:parameter><ac:parameter ac:name="colour">Green</ac:parameter></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("🟢"));
        assert!(result.contains("Done"));
    }

    #[test]
    fn status_macro_red() {
        let html = r#"<ac:structured-macro ac:name="status"><ac:parameter ac:name="title">Blocked</ac:parameter><ac:parameter ac:name="colour">Red</ac:parameter></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("🔴"));
        assert!(result.contains("Blocked"));
    }

    #[test]
    fn status_macro_no_title() {
        let html = r#"<ac:structured-macro ac:name="status"><ac:parameter ac:name="colour">Blue</ac:parameter></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        // No title → empty string
        assert!(!result.contains("🔵"));
    }

    #[test]
    fn toc_macro() {
        let html = r#"<ac:structured-macro ac:name="toc"><ac:parameter ac:name="style">flat</ac:parameter></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("[Table of Contents]"));
    }

    #[test]
    fn quote_macro() {
        let html = r#"<ac:structured-macro ac:name="quote"><ac:rich-text-body>A wise saying</ac:rich-text-body></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("> A wise saying"));
    }

    #[test]
    fn excerpt_macro() {
        let html = r#"<ac:structured-macro ac:name="excerpt"><ac:rich-text-body>Excerpt text here</ac:rich-text-body></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("Excerpt text here"));
    }

    #[test]
    fn anchor_macro() {
        let html = r#"<ac:structured-macro ac:name="anchor"><ac:parameter ac:name="">my-anchor</ac:parameter></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains(r#"<a id="my-anchor"></a>"#));
    }

    #[test]
    fn strip_macro_jira() {
        let html = r#"<ac:structured-macro ac:name="jira"><ac:parameter ac:name="key">PROJ-123</ac:parameter></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("[jira macro removed]"));
    }

    #[test]
    fn strip_macro_drawio() {
        let html = r#"<ac:structured-macro ac:name="drawio"><ac:parameter ac:name="name">diagram</ac:parameter></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("[drawio macro removed]"));
    }

    #[test]
    fn unknown_macro_with_text() {
        let html = r#"<ac:structured-macro ac:name="custom-thing"><ac:rich-text-body>Some text</ac:rich-text-body></ac:structured-macro>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("[custom-thing:"));
    }

    #[test]
    fn emoticon_smile() {
        let html = r#"<ac:emoticon ac:name="smile" />"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("😊"));
    }

    #[test]
    fn emoticon_tick() {
        let html = r#"<ac:emoticon ac:name="tick" />"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("✅"));
    }

    #[test]
    fn emoticon_warning() {
        let html = r#"<ac:emoticon ac:name="warning" />"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("⚠️"));
    }

    #[test]
    fn task_list_complete() {
        let html = r#"<ac:task-list><ac:task><ac:task-id>1</ac:task-id><ac:task-status>complete</ac:task-status><ac:task-body>Done item</ac:task-body></ac:task></ac:task-list>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("[x]"));
        assert!(result.contains("Done item"));
    }

    #[test]
    fn task_list_incomplete() {
        let html = r#"<ac:task-list><ac:task><ac:task-id>2</ac:task-id><ac:task-status>incomplete</ac:task-status><ac:task-body>Todo item</ac:task-body></ac:task></ac:task-list>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("[ ]"));
        assert!(result.contains("Todo item"));
    }

    #[test]
    fn image_attachment() {
        let html = r#"<ac:image><ri:attachment ri:filename="diagram.png" /></ac:image>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("[Image: diagram.png]"));
    }

    #[test]
    fn confluence_link_with_title() {
        let html = r#"<ac:link><ri:page ri:content-title="My Page" /><ac:link-body>Click here</ac:link-body></ac:link>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("[Click here](confluence:My Page)"));
    }

    #[test]
    fn confluence_link_no_body() {
        let html = r#"<ac:link><ri:page ri:content-title="Target Page" /></ac:link>"#;
        let result = process_confluence_macros(html);
        assert!(result.contains("[Target Page](confluence:Target Page)"));
    }

    #[test]
    fn inline_comment_stripped() {
        let html = r#"<ac:inline-comment-marker ac:ref="abc123">commented text</ac:inline-comment-marker>"#;
        let result = process_confluence_macros(html);
        assert!(!result.contains("ac:inline-comment-marker"));
        assert!(!result.contains("commented text"));
    }

    #[test]
    fn placeholder_stripped() {
        let html = r#"<ac:placeholder>Type something here</ac:placeholder>"#;
        let result = process_confluence_macros(html);
        assert!(!result.contains("Type something here"));
    }

    // ── extract_headings ──

    #[test]
    fn extract_headings_basic() {
        let html = "<h1>Title</h1><p>Content</p><h2>Subtitle</h2>";
        let headings = extract_headings(html);
        assert_eq!(headings.len(), 2);
        assert_eq!(headings[0].level, 1);
        assert_eq!(headings[0].text, "Title");
        assert_eq!(headings[1].level, 2);
        assert_eq!(headings[1].text, "Subtitle");
    }

    #[test]
    fn extract_headings_with_id() {
        let html = r#"<h2 id="my-section">Section</h2>"#;
        let headings = extract_headings(html);
        assert_eq!(headings.len(), 1);
        assert_eq!(headings[0].id, Some("my-section".to_string()));
    }

    #[test]
    fn extract_headings_with_nested_tags() {
        let html = "<h1><strong>Bold Title</strong></h1>";
        let headings = extract_headings(html);
        assert_eq!(headings.len(), 1);
        assert_eq!(headings[0].text, "Bold Title");
    }

    #[test]
    fn extract_headings_empty_heading_skipped() {
        let html = "<h1>  </h1><h2>Valid</h2>";
        let headings = extract_headings(html);
        assert_eq!(headings.len(), 1);
        assert_eq!(headings[0].text, "Valid");
    }

    #[test]
    fn extract_headings_with_entities() {
        let html = "<h1>A &amp; B</h1>";
        let headings = extract_headings(html);
        assert_eq!(headings[0].text, "A & B");
    }

    #[test]
    fn extract_headings_no_headings() {
        let html = "<p>Just a paragraph</p>";
        let headings = extract_headings(html);
        assert!(headings.is_empty());
    }

    // ── extract_section ──

    #[test]
    fn extract_section_basic() {
        let html = "<h1>Intro</h1><p>Intro text</p><h1>Details</h1><p>Detail text</p>";
        let section = extract_section(html, "Intro", None);
        assert!(section.is_some());
        let s = section.unwrap();
        assert!(s.contains("Intro text"));
        assert!(!s.contains("Detail text"));
    }

    #[test]
    fn extract_section_last_section() {
        let html = "<h1>First</h1><p>A</p><h1>Last</h1><p>B</p>";
        let section = extract_section(html, "Last", None);
        assert!(section.is_some());
        assert!(section.unwrap().contains("B"));
    }

    #[test]
    fn extract_section_with_level_filter() {
        let html = "<h1>Intro</h1><p>L1</p><h2>Intro</h2><p>L2</p>";
        let section = extract_section(html, "Intro", Some(2));
        assert!(section.is_some());
        let s = section.unwrap();
        assert!(s.contains("L2"));
    }

    #[test]
    fn extract_section_not_found() {
        let html = "<h1>Existing</h1><p>Content</p>";
        let section = extract_section(html, "Missing", None);
        assert!(section.is_none());
    }

    #[test]
    fn extract_section_partial_match() {
        let html = "<h2>API Reference Guide</h2><p>Content here</p><h2>Other</h2>";
        let section = extract_section(html, "api reference", None);
        assert!(section.is_some());
        assert!(section.unwrap().contains("Content here"));
    }

    #[test]
    fn extract_section_nested_headings() {
        let html = "<h1>Parent</h1><p>P text</p><h2>Child</h2><p>C text</p><h1>Sibling</h1><p>S text</p>";
        let section = extract_section(html, "Parent", None);
        assert!(section.is_some());
        let s = section.unwrap();
        assert!(s.contains("P text"));
        assert!(s.contains("Child"));
        assert!(s.contains("C text"));
        assert!(!s.contains("S text"));
    }

    // ── decode_entities ──

    #[test]
    fn decode_entities_all() {
        assert_eq!(decode_entities("&nbsp;&amp;&lt;&gt;&quot;&#39;"), " &<>\"'");
    }

    #[test]
    fn decode_entities_none() {
        assert_eq!(decode_entities("plain text"), "plain text");
    }
}
