//! Markdown rendering with syntect-powered syntax highlighting, plus the
//! helper functions that back common content-processing needs: frontmatter
//! extraction, filename-date parsing, slugification, word count, reading
//! time, and excerpt generation.

use crate::error::{BambooError, Result};
use crate::types::{Frontmatter, TocEntry};
use chrono::NaiveDate;
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use syntect::highlighting::ThemeSet;
use syntect::html::highlighted_html_for_string;
use syntect::parsing::SyntaxSet;

/// Renders markdown to HTML with syntect-powered syntax highlighting for
/// fenced code blocks.
pub struct MarkdownRenderer {
    syntax_set: SyntaxSet,
    theme_set: ThemeSet,
    theme_name: String,
}

impl Default for MarkdownRenderer {
    fn default() -> Self {
        Self::new()
    }
}

/// Output of [`MarkdownRenderer::render`]: the rendered HTML plus the
/// heading-based table of contents extracted along the way.
pub struct RenderedMarkdown {
    /// Rendered HTML body.
    pub html: String,
    /// Headings encountered during rendering, in source order.
    pub toc: Vec<TocEntry>,
}

impl MarkdownRenderer {
    /// Creates a renderer using the default syntax theme
    /// (`base16-ocean.dark`).
    pub fn new() -> Self {
        Self {
            syntax_set: SyntaxSet::load_defaults_newlines(),
            theme_set: ThemeSet::load_defaults(),
            theme_name: "base16-ocean.dark".to_string(),
        }
    }

    /// Creates a renderer that uses the named syntect theme. Returns
    /// [`BambooError::ThemeNotFound`] if the theme isn't registered.
    pub fn with_theme(theme_name: &str) -> Result<Self> {
        let theme_set = ThemeSet::load_defaults();
        if !theme_set.themes.contains_key(theme_name) {
            return Err(BambooError::ThemeNotFound {
                name: format!("syntax theme '{}' not found", theme_name),
            });
        }
        Ok(Self {
            syntax_set: SyntaxSet::load_defaults_newlines(),
            theme_set,
            theme_name: theme_name.to_string(),
        })
    }

    /// Renders `content` as markdown and collects heading information for
    /// the table of contents.
    pub fn render(&self, content: &str) -> RenderedMarkdown {
        let mut options = Options::empty();
        options.insert(Options::ENABLE_TABLES);
        options.insert(Options::ENABLE_FOOTNOTES);
        options.insert(Options::ENABLE_STRIKETHROUGH);
        options.insert(Options::ENABLE_TASKLISTS);
        options.insert(Options::ENABLE_HEADING_ATTRIBUTES);

        let parser = Parser::new_ext(content, options);
        let mut html_output = String::new();
        let mut in_code_block = false;
        let mut code_block_lang: Option<String> = None;
        let mut code_block_content = String::new();
        let mut toc = Vec::new();
        let mut in_heading = false;
        let mut heading_level: u32 = 0;
        let mut heading_plain_text = String::new();
        let mut heading_events: Vec<Event<'_>> = Vec::new();
        let mut used_heading_ids: HashSet<String> = HashSet::new();

        let theme = &self.theme_set.themes[&self.theme_name];

        for event in parser {
            match event {
                Event::Start(Tag::Heading { level, .. }) => {
                    in_heading = true;
                    heading_level = heading_level_to_u32(level);
                    heading_plain_text.clear();
                    heading_events.clear();
                }
                Event::End(TagEnd::Heading(..)) => {
                    in_heading = false;
                    let base_id = slugify(&heading_plain_text);
                    let heading_id = if used_heading_ids.contains(&base_id) {
                        let mut suffix = 1;
                        loop {
                            let candidate = format!("{}-{}", base_id, suffix);
                            if !used_heading_ids.contains(&candidate) {
                                break candidate;
                            }
                            suffix += 1;
                        }
                    } else {
                        base_id
                    };
                    used_heading_ids.insert(heading_id.clone());

                    let mut heading_html = String::new();
                    pulldown_cmark::html::push_html(&mut heading_html, heading_events.drain(..));

                    toc.push(TocEntry {
                        level: heading_level,
                        id: heading_id.clone(),
                        title: heading_plain_text.clone(),
                    });
                    html_output.push_str(&format!(
                        "<h{level} id=\"{id}\"><a class=\"anchor\" href=\"#{id}\">#</a>{text}</h{level}>\n",
                        level = heading_level,
                        id = escape_html(&heading_id),
                        text = heading_html,
                    ));
                }
                Event::Start(Tag::CodeBlock(kind)) => {
                    in_code_block = true;
                    code_block_lang = match kind {
                        CodeBlockKind::Fenced(lang) => {
                            let lang_str = lang.as_ref();
                            if lang_str.is_empty() {
                                None
                            } else {
                                Some(lang_str.to_string())
                            }
                        }
                        CodeBlockKind::Indented => None,
                    };
                    code_block_content.clear();
                }
                Event::End(TagEnd::CodeBlock) => {
                    in_code_block = false;
                    let highlighted = if let Some(ref lang) = code_block_lang {
                        self.syntax_set
                            .find_syntax_by_token(lang)
                            .map(|syntax| {
                                highlighted_html_for_string(
                                    &code_block_content,
                                    &self.syntax_set,
                                    syntax,
                                    theme,
                                )
                                .unwrap_or_else(|_| escape_html(&code_block_content))
                            })
                            .unwrap_or_else(|| {
                                format!(
                                    "<pre><code class=\"language-{}\">{}</code></pre>",
                                    escape_html(lang),
                                    escape_html(&code_block_content)
                                )
                            })
                    } else {
                        format!(
                            "<pre><code>{}</code></pre>",
                            escape_html(&code_block_content)
                        )
                    };
                    html_output.push_str(&highlighted);
                    code_block_lang = None;
                }
                Event::Text(ref text) if in_heading => {
                    heading_plain_text.push_str(text);
                    heading_events.push(event);
                }
                Event::Code(ref code) if in_heading => {
                    heading_plain_text.push_str(code);
                    heading_events.push(event);
                }
                _ if in_heading => {
                    heading_events.push(event);
                }
                Event::Text(text) => {
                    if in_code_block {
                        code_block_content.push_str(&text);
                    } else {
                        let mut temp = String::new();
                        pulldown_cmark::html::push_html(
                            &mut temp,
                            std::iter::once(Event::Text(text)),
                        );
                        html_output.push_str(&temp);
                    }
                }
                Event::Code(code) => {
                    html_output.push_str("<code>");
                    html_output.push_str(&escape_html(&code));
                    html_output.push_str("</code>");
                }
                other => {
                    let mut temp = String::new();
                    pulldown_cmark::html::push_html(&mut temp, std::iter::once(other));
                    html_output.push_str(&temp);
                }
            }
        }

        RenderedMarkdown {
            html: html_output,
            toc,
        }
    }
}

fn heading_level_to_u32(level: HeadingLevel) -> u32 {
    match level {
        HeadingLevel::H1 => 1,
        HeadingLevel::H2 => 2,
        HeadingLevel::H3 => 3,
        HeadingLevel::H4 => 4,
        HeadingLevel::H5 => 5,
        HeadingLevel::H6 => 6,
    }
}

/// Converts arbitrary text into a URL-safe slug: lowercased, with
/// non-alphanumeric runs replaced by a single `-`.
pub fn slugify(text: &str) -> String {
    text.to_lowercase()
        .chars()
        .map(|character| {
            if character.is_alphanumeric() {
                character
            } else {
                '-'
            }
        })
        .collect::<String>()
        .split('-')
        .filter(|segment| !segment.is_empty())
        .collect::<Vec<_>>()
        .join("-")
}

fn escape_html(input: &str) -> String {
    crate::xml::escape(input)
}

/// Counts whitespace-separated words in `text`.
pub fn word_count(text: &str) -> usize {
    text.split_whitespace().count()
}

/// Estimated reading time in minutes at ~200 WPM. Rounds up to 1 minute for
/// any non-empty content so short posts don't show "0 min read".
pub fn reading_time(word_count: usize) -> usize {
    let minutes = word_count / 200;
    if minutes == 0 && word_count > 0 {
        1
    } else {
        minutes
    }
}

/// Derives a plain-text excerpt from the first paragraph of markdown
/// `content`, truncated to at most `max_chars` characters on a word
/// boundary. Returns `None` for empty input.
pub fn extract_excerpt(content: &str, max_chars: usize) -> Option<String> {
    if content.trim().is_empty() {
        return None;
    }

    let first_paragraph = content
        .split("\n\n")
        .next()
        .map(|paragraph| paragraph.trim())
        .filter(|paragraph| !paragraph.is_empty())?;

    let text = strip_markdown_syntax(first_paragraph);
    let text = text.trim();

    if text.chars().count() <= max_chars {
        Some(text.to_string())
    } else {
        let truncated: String = text.chars().take(max_chars).collect();
        let last_space = truncated.rfind(' ').unwrap_or(truncated.len());
        Some(format!("{}...", &truncated[..last_space]))
    }
}

/// Protects `$...$` and `$$...$$` math blocks from the markdown parser by
/// wrapping them in HTML placeholders that KaTeX can render client-side.
/// Math inside fenced or inline code is left untouched.
pub fn preprocess_math(content: &str) -> String {
    let mut output = String::with_capacity(content.len());
    let chars: Vec<char> = content.chars().collect();
    let length = chars.len();
    let mut position = 0;
    let mut in_code_fence = false;
    let mut fence_marker: Option<char> = None;
    let mut in_inline_code = false;

    while position < length {
        if !in_code_fence && !in_inline_code && chars[position] == '`' {
            if position + 2 < length && chars[position + 1] == '`' && chars[position + 2] == '`' {
                in_code_fence = true;
                fence_marker = Some('`');
                output.push(chars[position]);
                output.push(chars[position + 1]);
                output.push(chars[position + 2]);
                position += 3;
                while position < length && chars[position] != '\n' {
                    output.push(chars[position]);
                    position += 1;
                }
                continue;
            } else {
                in_inline_code = true;
                output.push(chars[position]);
                position += 1;
                continue;
            }
        }

        if !in_code_fence
            && position + 2 < length
            && chars[position] == '~'
            && chars[position + 1] == '~'
            && chars[position + 2] == '~'
        {
            in_code_fence = true;
            fence_marker = Some('~');
            output.push(chars[position]);
            output.push(chars[position + 1]);
            output.push(chars[position + 2]);
            position += 3;
            while position < length && chars[position] != '\n' {
                output.push(chars[position]);
                position += 1;
            }
            continue;
        }

        if in_code_fence {
            if chars[position] == '\n' {
                output.push(chars[position]);
                position += 1;
                if let Some(marker) = fence_marker
                    && position + 2 < length
                    && chars[position] == marker
                    && chars[position + 1] == marker
                    && chars[position + 2] == marker
                {
                    output.push(chars[position]);
                    output.push(chars[position + 1]);
                    output.push(chars[position + 2]);
                    position += 3;
                    in_code_fence = false;
                    fence_marker = None;
                }
                continue;
            }
            output.push(chars[position]);
            position += 1;
            continue;
        }

        if in_inline_code {
            output.push(chars[position]);
            if chars[position] == '`' {
                in_inline_code = false;
            }
            position += 1;
            continue;
        }

        if chars[position] == '$' && position + 1 < length && chars[position + 1] == '$' {
            position += 2;
            let start = position;
            while position + 1 < length && !(chars[position] == '$' && chars[position + 1] == '$') {
                position += 1;
            }
            if position + 1 < length {
                let math_content: String = chars[start..position].iter().collect();
                output.push_str("<div class=\"math-display\">$$");
                output.push_str(&math_content);
                output.push_str("$$</div>");
                position += 2;
            } else {
                output.push('$');
                output.push('$');
                let rest: String = chars[start..].iter().collect();
                output.push_str(&rest);
                position = length;
            }
            continue;
        }

        if chars[position] == '$' {
            let prev_char = if position > 0 {
                Some(chars[position - 1])
            } else {
                None
            };
            let next_char = if position + 1 < length {
                Some(chars[position + 1])
            } else {
                None
            };

            if next_char.is_some_and(|character| !character.is_whitespace() && character != '$') {
                let is_after_alphanumeric =
                    prev_char.is_some_and(|character| character.is_alphanumeric());
                if is_after_alphanumeric {
                    output.push(chars[position]);
                    position += 1;
                    continue;
                }

                position += 1;
                let start = position;
                while position < length && chars[position] != '$' && chars[position] != '\n' {
                    position += 1;
                }
                if position < length && chars[position] == '$' {
                    let math_content: String = chars[start..position].iter().collect();
                    if !math_content.is_empty() && !math_content.ends_with(char::is_whitespace) {
                        output.push_str("<span class=\"math-inline\">$");
                        output.push_str(&math_content);
                        output.push_str("$</span>");
                        position += 1;
                    } else {
                        output.push('$');
                        let content_str: String = chars[start..position].iter().collect();
                        output.push_str(&content_str);
                        output.push('$');
                        position += 1;
                    }
                } else {
                    output.push('$');
                    let rest: String = chars[start..position].iter().collect();
                    output.push_str(&rest);
                }
                continue;
            }
        }

        output.push(chars[position]);
        position += 1;
    }

    output
}

fn strip_markdown_syntax(text: &str) -> String {
    let mut output = String::with_capacity(text.len());
    let mut chars = text.chars().peekable();
    let mut previous_character: Option<char> = None;

    while let Some(&current) = chars.peek() {
        if current == '!' {
            chars.next();
            if chars.peek() == Some(&'[') {
                chars.next();
                while let Some(&character) = chars.peek() {
                    if character == ']' {
                        chars.next();
                        break;
                    }
                    output.push(character);
                    chars.next();
                }
                skip_paren_link(&mut chars);
                previous_character = Some(')');
                continue;
            }
            output.push('!');
            previous_character = Some('!');
            continue;
        }

        if current == '[' {
            chars.next();
            while let Some(&character) = chars.peek() {
                if character == ']' {
                    chars.next();
                    break;
                }
                output.push(character);
                chars.next();
            }
            skip_paren_link(&mut chars);
            previous_character = Some(')');
            continue;
        }

        if current == '#' && (previous_character.is_none() || previous_character == Some('\n')) {
            while chars.peek() == Some(&'#') {
                chars.next();
            }
            if chars.peek() == Some(&' ') {
                chars.next();
            }
            previous_character = Some('#');
            continue;
        }

        if current == '*' || current == '_' {
            let prev_is_alnum = previous_character
                .map(|character| character.is_alphanumeric())
                .unwrap_or(false);
            chars.next();
            let next_is_alnum = chars
                .peek()
                .map(|character| character.is_alphanumeric())
                .unwrap_or(false);
            if prev_is_alnum && next_is_alnum {
                output.push(current);
            }
            previous_character = Some(current);
            continue;
        }

        if current == '`' {
            chars.next();
            previous_character = Some('`');
            continue;
        }

        output.push(current);
        previous_character = Some(current);
        chars.next();
    }

    output
}

fn skip_paren_link(chars: &mut std::iter::Peekable<std::str::Chars>) {
    if chars.peek() == Some(&'(') {
        chars.next();
        let mut paren_depth = 1;
        while let Some(&character) = chars.peek() {
            if character == '(' {
                paren_depth += 1;
            } else if character == ')' {
                paren_depth -= 1;
            }
            chars.next();
            if paren_depth == 0 {
                break;
            }
        }
    }
}

/// Splits a content file into its TOML (`+++`) or YAML (`---`) frontmatter
/// block and the remaining body. Returns an empty [`Frontmatter`] plus the
/// full content if no frontmatter is present.
pub fn extract_frontmatter(content: &str, path: &Path) -> Result<(Frontmatter, String)> {
    let content = content.replace("\r\n", "\n");
    let content = content.trim_start();

    if content.starts_with("+++") {
        parse_toml_frontmatter(content, path)
    } else if content.starts_with("---") {
        parse_yaml_frontmatter(content, path)
    } else {
        Ok((Frontmatter::default(), content.to_string()))
    }
}

fn parse_toml_frontmatter(content: &str, path: &Path) -> Result<(Frontmatter, String)> {
    let rest = &content[3..];

    let end_index =
        find_closing_delimiter(rest, "+++").ok_or_else(|| BambooError::InvalidFrontmatter {
            path: path.to_path_buf(),
        })?;

    let frontmatter_str = &rest[..end_index];
    let raw: HashMap<String, Value> =
        toml::from_str(frontmatter_str).map_err(|error| BambooError::TomlParse {
            path: path.to_path_buf(),
            message: error.to_string(),
        })?;

    let body = &rest[end_index + 3..];
    Ok((Frontmatter { raw }, body.trim().to_string()))
}

fn parse_yaml_frontmatter(content: &str, path: &Path) -> Result<(Frontmatter, String)> {
    let rest = &content[3..];

    let end_index =
        find_closing_delimiter(rest, "---").ok_or_else(|| BambooError::InvalidFrontmatter {
            path: path.to_path_buf(),
        })?;

    let frontmatter_str = &rest[..end_index];
    let body = &rest[end_index + 3..];

    let raw: HashMap<String, Value> =
        serde_yml::from_str(frontmatter_str).map_err(|error| BambooError::YamlParse {
            path: path.to_path_buf(),
            message: error.to_string(),
        })?;

    Ok((Frontmatter { raw }, body.trim().to_string()))
}

fn find_closing_delimiter(content: &str, delimiter: &str) -> Option<usize> {
    let mut search_start = 0;
    while let Some(newline_position) = content[search_start..].find('\n') {
        let line_start = search_start;
        search_start += newline_position + 1;
        let line = &content[line_start..line_start + newline_position];
        if line.trim_end() == delimiter {
            return Some(line_start);
        }
    }
    if content[search_start..].trim_end() == delimiter {
        return Some(search_start);
    }
    None
}

/// Parses a `YYYY-MM-DD-slug` filename prefix, returning `(date, slug)`
/// strings. Returns `None` if the filename doesn't match the pattern.
pub fn parse_date_from_filename(filename: &str) -> Option<(String, String)> {
    let name = filename.strip_suffix(".md").unwrap_or(filename);

    let date_part = name.get(..10)?;
    let parts: Vec<&str> = date_part.split('-').collect();

    if parts.len() < 3 {
        return None;
    }

    if parts[0].len() != 4 || parts[1].len() != 2 || parts[2].len() != 2 {
        return None;
    }

    if NaiveDate::parse_from_str(date_part, "%Y-%m-%d").is_err() {
        return None;
    }

    let slug = if let Some(rest) = name.get(11..) {
        if name.as_bytes().get(10) == Some(&b'-') {
            rest.to_string()
        } else {
            name.to_string()
        }
    } else {
        name.to_string()
    };

    Some((date_part.to_string(), slug))
}

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

    fn render(input: &str) -> RenderedMarkdown {
        MarkdownRenderer::new().render(input)
    }

    #[test]
    fn test_parse_markdown() {
        let input = "# Hello\n\nThis is **bold**.";
        let output = render(input);
        assert!(output.html.contains("<h1"));
        assert!(output.html.contains("Hello"));
        assert!(output.html.contains("<strong>bold</strong>"));
    }

    #[test]
    fn test_parse_markdown_with_code() {
        let input = "```rust\nfn main() {}\n```";
        let output = render(input);
        assert!(output.html.contains("fn"));
        assert!(output.html.contains("main"));
    }

    #[test]
    fn test_heading_anchors() {
        let input = "## My Heading";
        let output = render(input);
        assert!(output.html.contains("id=\"my-heading\""));
        assert!(output.html.contains("href=\"#my-heading\""));
    }

    #[test]
    fn test_toc_generation() {
        let input = "# Title\n## Section One\n### Subsection\n## Section Two";
        let output = render(input);
        assert_eq!(output.toc.len(), 4);
        assert_eq!(output.toc[0].level, 1);
        assert_eq!(output.toc[0].title, "Title");
        assert_eq!(output.toc[1].level, 2);
        assert_eq!(output.toc[1].title, "Section One");
        assert_eq!(output.toc[2].level, 3);
        assert_eq!(output.toc[3].level, 2);
    }

    #[test]
    fn test_word_count_and_reading_time() {
        let text = "one two three four five";
        assert_eq!(word_count(text), 5);
        assert_eq!(reading_time(5), 1);
        assert_eq!(reading_time(400), 2);
        assert_eq!(reading_time(0), 0);
    }

    #[test]
    fn test_slugify() {
        assert_eq!(slugify("Hello World"), "hello-world");
        assert_eq!(slugify("  Multiple   Spaces  "), "multiple-spaces");
        assert_eq!(slugify("Special!@#Characters"), "special-characters");
    }

    #[test]
    fn test_parse_date_from_filename() {
        assert_eq!(
            parse_date_from_filename("2024-01-15-hello-world.md"),
            Some(("2024-01-15".to_string(), "hello-world".to_string()))
        );
        assert_eq!(parse_date_from_filename("about.md"), None);
    }

    #[test]
    fn test_extract_excerpt() {
        let content = "This is the first paragraph.\n\nThis is the second.";
        let excerpt = extract_excerpt(content, 100);
        assert_eq!(excerpt, Some("This is the first paragraph.".to_string()));
    }

    #[test]
    fn test_extract_excerpt_truncation() {
        let content = "This is a very long paragraph that should be truncated at some point.";
        let excerpt = extract_excerpt(content, 30);
        assert!(excerpt.unwrap().ends_with("..."));
    }

    #[test]
    fn test_extract_excerpt_strips_links() {
        let content = "Check out [my site](https://example.com) for more.\n\nSecond paragraph.";
        let excerpt = extract_excerpt(content, 200);
        assert_eq!(excerpt, Some("Check out my site for more.".to_string()));
    }

    #[test]
    fn test_extract_excerpt_strips_images() {
        let content = "Here is ![alt text](https://example.com/img.png) inline.\n\nSecond.";
        let excerpt = extract_excerpt(content, 200);
        assert_eq!(excerpt, Some("Here is alt text inline.".to_string()));
    }

    #[test]
    fn test_yaml_frontmatter_with_dashes_in_content() {
        let content = "---\ntitle: Test\n---\n\nContent with --- dashes";
        let path = PathBuf::from("test.md");
        let (fm, body) = extract_frontmatter(content, &path).unwrap();
        assert_eq!(fm.get_string("title"), Some("Test".to_string()));
        assert!(body.contains("---"));
    }

    #[test]
    fn test_toml_frontmatter() {
        let content = "+++\ntitle = \"Test\"\n+++\n\nBody content";
        let path = PathBuf::from("test.md");
        let (fm, body) = extract_frontmatter(content, &path).unwrap();
        assert_eq!(fm.get_string("title"), Some("Test".to_string()));
        assert_eq!(body, "Body content");
    }

    #[test]
    fn test_preprocess_math_inline() {
        let input = "The formula $E = mc^2$ is famous.";
        let output = preprocess_math(input);
        assert_eq!(
            output,
            "The formula <span class=\"math-inline\">$E = mc^2$</span> is famous."
        );
    }

    #[test]
    fn test_preprocess_math_display() {
        let input = "Here is a formula:\n\n$$\nx = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}\n$$\n\nThat was it.";
        let output = preprocess_math(input);
        assert!(output.contains("<div class=\"math-display\">$$"));
        assert!(output.contains("$$</div>"));
        assert!(output.contains("That was it."));
    }

    #[test]
    fn test_preprocess_math_inline_display_on_one_line() {
        let input = "Display: $$x^2 + y^2 = z^2$$";
        let output = preprocess_math(input);
        assert!(output.contains("<div class=\"math-display\">$$x^2 + y^2 = z^2$$</div>"));
    }

    #[test]
    fn test_preprocess_math_skips_code_fence() {
        let input = "```\n$x^2$\n```\n\nOutside $y^2$ here.";
        let output = preprocess_math(input);
        assert!(output.contains("$x^2$"));
        assert!(!output.contains("<span class=\"math-inline\">$x^2$</span>"));
        assert!(output.contains("<span class=\"math-inline\">$y^2$</span>"));
    }

    #[test]
    fn test_preprocess_math_skips_inline_code() {
        let input = "Use `$variable` in shell. Also $a + b$ math.";
        let output = preprocess_math(input);
        assert!(output.contains("`$variable`"));
        assert!(output.contains("<span class=\"math-inline\">$a + b$</span>"));
    }

    #[test]
    fn test_preprocess_math_no_math() {
        let input = "No math here, just text.";
        let output = preprocess_math(input);
        assert_eq!(output, input);
    }

    #[test]
    fn test_preprocess_math_dollar_after_alphanumeric() {
        let input = "This costs 5$ and 10$ total.";
        let output = preprocess_math(input);
        assert_eq!(output, input);
    }

    #[test]
    fn test_preprocess_math_backtick_followed_by_tildes() {
        let input = "`~~something~~`";
        let output = preprocess_math(input);
        assert_eq!(output, input);
    }

    #[test]
    fn test_toml_frontmatter_malformed_returns_error() {
        let content = "+++\ntitle = \n+++\n\nBody content";
        let path = PathBuf::from("test.md");
        let result = extract_frontmatter(content, &path);
        assert!(result.is_err());
    }
}
