use pulldown_cmark::{Parser, Options, Event, Tag};
use serde::{Serialize, Deserialize};
use std::collections::HashMap;

#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(tag = "type")]
pub enum Node {
    #[serde(rename = "element")]
    Element {
        tag: String,
        props: HashMap<String, serde_json::Value>,
        children: Vec<Node>,
    },
    #[serde(rename = "text")]
    Text {
        content: String,
    },
}

pub struct TranspileOptions {
    pub allowed_tags: Vec<String>,
}

fn is_tag_name_char(c: char) -> bool {
    c.is_ascii_alphanumeric() || c == '-'
}

fn parse_html_attrs(attrs_str: &str) -> HashMap<String, serde_json::Value> {
    let mut props = HashMap::new();
    let bytes = attrs_str.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
            i += 1;
        }
        if i >= bytes.len() {
            break;
        }
        let name_start = i;
        while i < bytes.len() && is_tag_name_char(bytes[i] as char) {
            i += 1;
        }
        if i == name_start {
            break;
        }
        let key = &attrs_str[name_start..i];
        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
            i += 1;
        }
        if i < bytes.len() && bytes[i] == b'=' {
            i += 1;
            while i < bytes.len() && bytes[i].is_ascii_whitespace() {
                i += 1;
            }
            let value = if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
                let quote = bytes[i];
                i += 1;
                let val_start = i;
                while i < bytes.len() && bytes[i] != quote {
                    i += 1;
                }
                let s = attrs_str[val_start..i].to_string();
                if i < bytes.len() {
                    i += 1;
                }
                serde_json::Value::String(s)
            } else {
                let val_start = i;
                while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
                    i += 1;
                }
                serde_json::Value::String(attrs_str[val_start..i].to_string())
            };
            props.insert(key.to_string(), value);
        } else {
            props.insert(key.to_string(), serde_json::Value::Bool(true));
        }
    }
    props
}

fn parse_html_tag(html: &str) -> Option<(String, HashMap<String, serde_json::Value>, bool)> {
    let html = html.trim();
    if html.starts_with("</") && html.ends_with('>') {
        let tag_name = html[2..html.len() - 1].trim().to_string();
        return Some((tag_name, HashMap::new(), false));
    }
    if !html.starts_with('<') || !html.ends_with('>') {
        return None;
    }
    let inner = &html[1..html.len() - 1];
    let is_self_closing = inner.ends_with('/');
    let inner = inner.trim_end_matches('/').trim();
    let mut parts = inner.splitn(2, |c: char| c.is_ascii_whitespace());
    let tag_name = parts.next()?.to_string();
    if tag_name.is_empty() || !tag_name.chars().all(is_tag_name_char) {
        return None;
    }
    let attrs_str = parts.next().unwrap_or("");
    Some((tag_name, parse_html_attrs(attrs_str), is_self_closing))
}

const HTML_BLOCK_SENTINEL: &str = "__html_block__";

fn push_node(stack: &mut Vec<Node>, root: &mut Vec<Node>, node: Node) {
    if let Some(Node::Element { children, .. }) = stack.last_mut() {
        children.push(node);
    } else {
        root.push(node);
    }
}

fn close_html_tag(stack: &mut Vec<Node>, root: &mut Vec<Node>, close_tag: &str) {
    while let Some(node) = stack.pop() {
        let is_match = matches!(&node, Node::Element { tag, .. } if tag == close_tag);
        push_node(stack, root, node);
        if is_match {
            return;
        }
    }
}

/// Merge `{"className":"…"}` style JSON from image title/alt into element props.
fn merge_image_json_props(props: &mut HashMap<String, serde_json::Value>, raw: &str) -> bool {
    let trimmed = raw.trim();
    if !(trimmed.starts_with('{') && trimmed.ends_with('}')) {
        return false;
    }
    let Ok(obj) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(trimmed) else {
        return false;
    };
    const ALLOWED: &[&str] = &["className", "w", "id", "loading", "alt", "title"];
    for (k, v) in obj {
        if ALLOWED.contains(&k.as_str()) {
            props.insert(k, v);
        }
    }
    true
}

fn set_image_string_prop(props: &mut HashMap<String, serde_json::Value>, key: &str, raw: &str) {
    if raw.is_empty() {
        return;
    }
    if merge_image_json_props(props, raw) {
        return;
    }
    props.insert(key.to_string(), serde_json::Value::String(raw.to_string()));
}

pub fn parse(markdown: &str, options: &TranspileOptions) -> Vec<Node> {
    let mut p_options = Options::empty();
    p_options.insert(Options::ENABLE_TABLES);
    p_options.insert(Options::ENABLE_STRIKETHROUGH);
    p_options.insert(Options::ENABLE_TASKLISTS);
    p_options.insert(Options::ENABLE_FOOTNOTES);
    p_options.insert(Options::ENABLE_SMART_PUNCTUATION);
    
    let parser = Parser::new_ext(markdown, p_options);
    let mut stack: Vec<Node> = Vec::new();
    let mut root: Vec<Node> = Vec::new();

    for event in parser {
        match event {
            Event::Start(tag) => {
                let node = match tag {
                    Tag::Heading { level, .. } => Node::Element {
                        tag: format!("h{}", level as u32),
                        props: HashMap::new(),
                        children: Vec::new(),
                    },
                    Tag::Paragraph => Node::Element {
                        tag: "p".to_string(),
                        props: HashMap::new(),
                        children: Vec::new(),
                    },
                    Tag::Emphasis => Node::Element {
                        tag: "em".to_string(),
                        props: HashMap::new(),
                        children: Vec::new(),
                    },
                    Tag::Strong => Node::Element {
                        tag: "strong".to_string(),
                        props: HashMap::new(),
                        children: Vec::new(),
                    },
                    Tag::Link { dest_url, .. } => {
                        let mut props = HashMap::new();
                        props.insert("href".to_string(), serde_json::Value::String(dest_url.to_string()));
                        Node::Element {
                            tag: "a".to_string(),
                            props,
                            children: Vec::new(),
                        }
                    },
                    Tag::Image { dest_url, title, .. } => {
                        let mut props = HashMap::new();
                        props.insert("src".to_string(), serde_json::Value::String(dest_url.to_string()));
                        props.insert("alt".to_string(), serde_json::Value::String(String::new()));
                        if !title.is_empty() {
                            set_image_string_prop(&mut props, "title", &title);
                        }
                        Node::Element {
                            tag: "img".to_string(),
                            props,
                            children: Vec::new(),
                        }
                    },
                    Tag::List(first) => Node::Element {
                        tag: if first.is_some() { "ol".to_string() } else { "ul".to_string() },
                        props: HashMap::new(),
                        children: Vec::new(),
                    },
                    Tag::Item => Node::Element {
                        tag: "li".to_string(),
                        props: HashMap::new(),
                        children: Vec::new(),
                    },
                    Tag::Table(_) => Node::Element {
                        tag: "table".to_string(),
                        props: HashMap::new(),
                        children: Vec::new(),
                    },
                    Tag::TableHead => Node::Element {
                        tag: "thead".to_string(),
                        props: HashMap::new(),
                        children: Vec::new(),
                    },
                    Tag::TableRow => Node::Element {
                        tag: "tr".to_string(),
                        props: HashMap::new(),
                        children: Vec::new(),
                    },
                    Tag::TableCell => Node::Element {
                        tag: "td".to_string(),
                        props: HashMap::new(),
                        children: Vec::new(),
                    },
                    Tag::Strikethrough => Node::Element {
                        tag: "del".to_string(),
                        props: HashMap::new(),
                        children: Vec::new(),
                    },
                    Tag::HtmlBlock => Node::Element {
                        tag: HTML_BLOCK_SENTINEL.to_string(),
                        props: HashMap::new(),
                        children: Vec::new(),
                    },
                    Tag::FootnoteDefinition(label) => {
                        let mut props = HashMap::new();
                        props.insert("id".to_string(), serde_json::Value::String(format!("fn-{}", label)));
                        props.insert("className".to_string(), serde_json::Value::String("footnote-definition".to_string()));
                        Node::Element {
                            tag: "div".to_string(),
                            props,
                            children: Vec::new(),
                        }
                    },
                    _ => Node::Element {
                        tag: "div".to_string(),
                        props: HashMap::new(),
                        children: Vec::new(),
                    },
                };
                stack.push(node);
            }
            Event::End(tag_end) => {
                if matches!(tag_end, pulldown_cmark::TagEnd::HtmlBlock) {
                    if let Some(Node::Element { tag: block_tag, children, .. }) = stack.pop() {
                        if block_tag == HTML_BLOCK_SENTINEL {
                            for child in children {
                                push_node(&mut stack, &mut root, child);
                            }
                        } else {
                            push_node(
                                &mut stack,
                                &mut root,
                                Node::Element {
                                    tag: block_tag,
                                    props: HashMap::new(),
                                    children,
                                },
                            );
                        }
                    }
                } else if let Some(node) = stack.pop() {
                    push_node(&mut stack, &mut root, node);
                }
            }
            Event::Text(text) => {
                if let Some(Node::Element { tag, props, .. }) = stack.last_mut() {
                    if tag == "img" {
                        let alt = props
                            .get("alt")
                            .and_then(|v| v.as_str())
                            .unwrap_or("")
                            .to_string();
                        props.insert(
                            "alt".to_string(),
                            serde_json::Value::String(format!("{alt}{text}")),
                        );
                        continue;
                    }
                }
                let node = Node::Text { content: text.to_string() };
                if stack.is_empty() {
                    root.push(node);
                } else {
                    let parent = stack.last_mut().unwrap();
                    if let Node::Element { children, .. } = parent {
                        children.push(node);
                    }
                }
            }
            Event::Code(code) => {
                let node = Node::Element {
                    tag: "code".to_string(),
                    props: HashMap::new(),
                    children: vec![Node::Text { content: code.to_string() }],
                };
                if stack.is_empty() {
                    root.push(node);
                } else {
                    let parent = stack.last_mut().unwrap();
                    if let Node::Element { children, .. } = parent {
                        children.push(node);
                    }
                }
            }
            Event::FootnoteReference(label) => {
                let mut props = HashMap::new();
                props.insert("href".to_string(), serde_json::Value::String(format!("#fn-{}", label)));
                props.insert("className".to_string(), serde_json::Value::String("footnote-ref".to_string()));
                let node = Node::Element {
                    tag: "sup".to_string(),
                    props: HashMap::new(),
                    children: vec![Node::Element {
                        tag: "a".to_string(),
                        props,
                        children: vec![Node::Text { content: label.to_string() }],
                    }],
                };
                if stack.is_empty() {
                    root.push(node);
                } else {
                    let parent = stack.last_mut().unwrap();
                    if let Node::Element { children, .. } = parent {
                        children.push(node);
                    }
                }
            }
            Event::Html(html) | Event::InlineHtml(html) => {
                let trimmed = html.trim();
                if let Some((tag_name, props, is_self_closing)) = parse_html_tag(&html) {
                    if options.allowed_tags.contains(&tag_name) {
                        if trimmed.starts_with("</") {
                            close_html_tag(&mut stack, &mut root, &tag_name);
                        } else {
                            let node = Node::Element {
                                tag: tag_name,
                                props,
                                children: Vec::new(),
                            };
                            if is_self_closing {
                                push_node(&mut stack, &mut root, node);
                            } else {
                                stack.push(node);
                            }
                        }
                    } else {
                        push_node(
                            &mut stack,
                            &mut root,
                            Node::Text {
                                content: html.to_string(),
                            },
                        );
                    }
                } else {
                    push_node(
                        &mut stack,
                        &mut root,
                        Node::Text {
                            content: html.to_string(),
                        },
                    );
                }
            }
            Event::SoftBreak | Event::HardBreak => {
                let node = Node::Text { content: "\n".to_string() };
                if !stack.is_empty() {
                    let parent = stack.last_mut().unwrap();
                    if let Node::Element { children, .. } = parent {
                        children.push(node);
                    }
                }
            }
            _ => {}
        }
    }
    
    root
}

#[cfg(feature = "wasm")]
mod wasm {
    use super::*;
    use wasm_bindgen::prelude::*;

    #[wasm_bindgen]
    pub fn transpile(markdown: &str, allowed_tags: Vec<String>) -> Result<String, JsValue> {
        let options = TranspileOptions { allowed_tags };
        let ast = parse(markdown, &options);
        serde_json::to_string(&ast).map_err(|e| JsValue::from_str(&e.to_string()))
    }
}

#[cfg(feature = "android")]
mod android {
    use super::*;
    use jni::JNIEnv;
    use jni::objects::{JClass, JString};
    use jni::sys::jstring;

    #[no_mangle]
    pub extern "system" fn Java_com_clevertree_md2ast_MarkdownParser_nativeParse(
        mut env: JNIEnv,
        _class: JClass,
        input: JString,
        allowed_tags_json: JString,
    ) -> jstring {
        let input: String = env.get_string(&input).expect("Couldn't get java string!").into();
        let allowed_tags_json: String = env.get_string(&allowed_tags_json).expect("Couldn't get java string!").into();
        let allowed_tags: Vec<String> = serde_json::from_str(&allowed_tags_json).unwrap_or_default();
        
        let options = TranspileOptions { allowed_tags };
        let ast = parse(&input, &options);
        let result_json = serde_json::to_string(&ast).unwrap();
        
        env.new_string(result_json).expect("Couldn't create java string!").into_raw()
    }
}

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

    fn find_node<'a>(nodes: &'a [Node], tag_name: &str) -> Option<&'a Node> {
        for node in nodes {
            match node {
                Node::Element { tag, children, .. } => {
                    if tag == tag_name {
                        return Some(node);
                    }
                    if let Some(found) = find_node(children, tag_name) {
                        return Some(found);
                    }
                }
                _ => {}
            }
        }
        None
    }

    #[test]
    fn test_gfm_footnotes() {
        let markdown = "Here is a footnote[^1]\n\n[^1]: This is the footnote content.";
        let options = TranspileOptions { allowed_tags: vec![] };
        let ast = parse(markdown, &options);
        println!("AST: {}", serde_json::to_string_pretty(&ast).unwrap());
        
        // Footnotes are rendered as <sup><a href=\"#fn-1\" className=\"footnote-ref\">1</a></sup>
        // and a <div class=\"footnote-definition\" id=\"fn-1\">...</div>
        
        let sup = find_node(&ast, "sup").expect("Should find sup for footnote ref");
        if let Node::Element { children, .. } = sup {
            let a = children.first().expect("Should have link child");
            if let Node::Element { tag, props, .. } = a {
                assert_eq!(tag, "a");
                let href = props.get("href").unwrap().as_str().unwrap();
                assert!(href.contains("#fn-1"));
            }
        }

        let div = find_node(&ast, "div").expect("Should find footnote definition");
        if let Node::Element { props, .. } = div {
            assert_eq!(props.get("className").unwrap().as_str().unwrap(), "footnote-definition");
        }
    }

    #[test]
    fn test_basic_markdown() {
        let markdown = "# Hello\nThis is **bold**";
        let options = TranspileOptions { allowed_tags: vec![] };
        let ast = parse(markdown, &options);
        
        assert_eq!(ast.len(), 2);
        if let Node::Element { tag, children, .. } = &ast[0] {
            assert_eq!(tag, "h1");
            assert_eq!(children[0], Node::Text { content: "Hello".to_string() });
        } else {
            panic!("Expected h1 element");
        }
    }

    #[test]
    fn test_html_tags() {
        let markdown = "Hello <VideoPlayer src=\"test.mp4\" /> world";
        let options = TranspileOptions { allowed_tags: vec!["VideoPlayer".to_string()] };
        let ast = parse(markdown, &options);
        
        let node = find_node(&ast, "VideoPlayer").expect("Should find VideoPlayer node");
        if let Node::Element { props, .. } = node {
            assert_eq!(props.get("src").unwrap(), "test.mp4");
        }
    }
    
    #[test]
    fn test_nested_html() {
        let markdown = "<div>\n\n# Inside\n\n</div>";
        let options = TranspileOptions { allowed_tags: vec!["div".to_string()] };
        let ast = parse(markdown, &options);
        
        assert!(find_node(&ast, "div").is_some());
    }

    #[test]
    fn test_allowed_tags_filtering() {
        let markdown = "<Allowed>Keep</Allowed><Forbidden>Drop</Forbidden>";
        let options = TranspileOptions { allowed_tags: vec!["Allowed".to_string()] };
        let ast = parse(markdown, &options);
        
        assert!(find_node(&ast, "Allowed").is_some());
        assert!(find_node(&ast, "Forbidden").is_none());
    }

    #[test]
    fn test_corpus_image_json_title() {
        let markdown = r#"![Evidence](/cosmos/mars/massacre.jpg?w=360 '{"className":"rounded-md"}')"#;
        let options = TranspileOptions { allowed_tags: vec!["img".to_string()] };
        let ast = parse(markdown, &options);
        let img = find_node(&ast, "img").expect("img");
        if let Node::Element { props, .. } = img {
            assert_eq!(
                props.get("src").and_then(|v| v.as_str()),
                Some("/cosmos/mars/massacre.jpg?w=360")
            );
            assert_eq!(
                props.get("className").and_then(|v| v.as_str()),
                Some("rounded-md")
            );
            assert!(!props.contains_key("title"));
        }
    }

    #[test]
    fn test_images() {
        let markdown = "![alt text](/path/img.jpg)";
        let options = TranspileOptions { allowed_tags: vec!["img".to_string()] };
        let ast = parse(markdown, &options);
        let img = find_node(&ast, "img").expect("img node");
        if let Node::Element { props, children, .. } = img {
            assert_eq!(props.get("src").unwrap().as_str().unwrap(), "/path/img.jpg");
            assert_eq!(props.get("alt").unwrap().as_str().unwrap(), "alt text");
            assert!(children.is_empty());
        } else {
            panic!("expected element");
        }
    }

    fn count_tag(nodes: &[Node], tag_name: &str) -> usize {
        let mut n = 0;
        for node in nodes {
            if let Node::Element { tag, children, .. } = node {
                if tag == tag_name {
                    n += 1;
                }
                n += count_tag(children, tag_name);
            }
        }
        n
    }

    #[test]
    fn test_multiline_paragraph_then_figure() {
        let markdown = "# H1\n\nline1\nline2\nline3\n\n<figure>\n  <iframe src=\"x\"/>\n</figure>\n\n## H2\n";
        let allowed: Vec<String> = ["h1", "h2", "p", "figure", "iframe"]
            .into_iter()
            .map(String::from)
            .collect();
        let ast = parse(markdown, &TranspileOptions { allowed_tags: allowed });
        assert!(find_node(&ast, "figure").is_some(), "{:?}", ast);
        assert!(count_tag(&ast, "h2") >= 1);
    }

    #[test]
    fn test_smart_apostrophe_paragraph() {
        let markdown = "# H1\n\nbut now I believe that the book\u{2019}s text was not redacted.\n\n## H2\n";
        let allowed: Vec<String> = ["h1", "h2", "p"].into_iter().map(String::from).collect();
        let ast = parse(markdown, &TranspileOptions { allowed_tags: allowed });
        assert!(count_tag(&ast, "h2") >= 1, "{:?}", ast);
    }

    #[test]
    fn test_minimal_figure_block() {
        let markdown = "# H1\n\npara\n\n<figure>\n  <iframe src=\"x\"/>\n</figure>\n\n## H2\n";
        let allowed: Vec<String> = ["h1", "h2", "p", "figure", "iframe"]
            .into_iter()
            .map(String::from)
            .collect();
        let ast = parse(markdown, &TranspileOptions { allowed_tags: allowed });
        eprintln!("{}", serde_json::to_string_pretty(&ast).unwrap());
        assert!(find_node(&ast, "figure").is_some());
        assert!(count_tag(&ast, "h2") >= 1);
    }

    #[test]
    fn test_pulldown_mars_events() {
        use pulldown_cmark::{Event, Options, Parser, Tag};
        let md = r#"# The Deep State is on Mars

This article has been rewritten with the following updates.
Originally I said the Martian appearances in the War of the Worlds book must have been a redaction by H.G. Wells,
but now I believe that the book's text was not redacted.

<figure className="sm:float-right clear-right m-auto sm:m-1 sm:ml-4 sm:max-w-sm">
  <iframe width="360" height="200" src="https://www.youtube.com/embed/FWp9TfS8ny4" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen="true" className="max-w-full m-auto hidden sm:block"/>
  <figcaption className='flex justify-center'>
    Listen to an auto-generated version of this article
  </figcaption>
</figure>

## What is the Deep State?
"#;
        let mut opts = Options::empty();
        opts.insert(Options::ENABLE_SMART_PUNCTUATION);
        let parser = Parser::new_ext(md, opts);
        let mut n = 0;
        for e in parser {
            n += 1;
            if n > 80 { break; }
            println!("{:?}", e);
        }
    }

    #[test]
    fn test_pulldown_html_block_events() {
        use pulldown_cmark::{Event, Options, Parser};
        let md = r#"# Title

para

<figure className="sm:float-right">
  <iframe width="360" src="https://www.youtube.com/embed/x" />
  <figcaption>cap</figcaption>
</figure>

## Next
"#;
        let parser = Parser::new_ext(md, Options::empty());
        for e in parser {
            if let Event::Html(h) | Event::InlineHtml(h) = e {
                println!("HTML: {:?}", h);
            }
        }
    }

    #[test]
    fn test_figcaption_multiline() {
        let markdown = "# H1\n\n<figure>\n  <iframe src=\"x\"/>\n  <figcaption className='flex'>\n    caption text\n  </figcaption>\n</figure>\n\n## H2\n";
        let allowed: Vec<String> = ["h1", "h2", "figure", "figcaption", "iframe"].into_iter().map(String::from).collect();
        let ast = parse(markdown, &TranspileOptions { allowed_tags: allowed });
        eprintln!("{}", serde_json::to_string_pretty(&ast).unwrap_or_default());
        assert!(find_node(&ast, "figure").is_some());
        assert!(count_tag(&ast, "h2") >= 1);
    }

    #[test]
    fn test_iframe_allow_semicolons() {
        let markdown = "# H1\n\n<figure>\n  <iframe allow=\"accelerometer; autoplay\"/>\n</figure>\n\n## H2\n";
        let allowed: Vec<String> = ["h1", "h2", "figure", "iframe"].into_iter().map(String::from).collect();
        let ast = parse(markdown, &TranspileOptions { allowed_tags: allowed });
        eprintln!("{}", serde_json::to_string_pretty(&ast).unwrap_or_default());
        assert!(find_node(&ast, "figure").is_some());
        assert!(count_tag(&ast, "h2") >= 1);
    }

    #[test]
    fn test_pulldown_mars_iframe_events() {
        use pulldown_cmark::{Event, Options, Parser};
        let md = r#"# H1

para

<figure className="sm:float-right">
  <iframe width="360" src="https://www.youtube.com/embed/x" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen="true" className="max-w-full"/>
</figure>

## H2
"#;
        let parser = Parser::new_ext(md, Options::empty());
        for e in parser {
            println!("{:?}", e);
        }
    }

    #[test]
    fn test_mars_exact_iframe_attrs() {
        let markdown = r#"# H1

para

<figure className="sm:float-right clear-right m-auto sm:m-1 sm:ml-4 sm:max-w-sm">
  <iframe width="360" height="200" src="https://www.youtube.com/embed/FWp9TfS8ny4" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen="true" className="max-w-full m-auto hidden sm:block"/>
  <figcaption className='flex justify-center'>
    Listen to an auto-generated version of this article
  </figcaption>
</figure>

## H2
"#;
        let allowed: Vec<String> = ["h1", "h2", "p", "figure", "figcaption", "iframe"]
            .into_iter()
            .map(String::from)
            .collect();
        let ast = parse(markdown, &TranspileOptions { allowed_tags: allowed });
        if find_node(&ast, "figure").is_none() {
            eprintln!("{}", serde_json::to_string_pretty(&ast).unwrap());
        }
        assert!(find_node(&ast, "figure").is_some());
        assert!(count_tag(&ast, "h2") >= 1);
    }

    #[test]
    fn test_mars_lead_block_and_figure() {
        let markdown = r#"# The Deep State is on Mars

This article has been rewritten with the following updates.
Originally I said the Martian appearances in the War of the Worlds book must have been a redaction by H.G. Wells,
but now I believe that the book's text was not redacted.

<figure className="sm:float-right clear-right m-auto sm:m-1 sm:ml-4 sm:max-w-sm">
  <iframe width="360" height="200" src="https://www.youtube.com/embed/FWp9TfS8ny4" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen="true" className="max-w-full m-auto hidden sm:block"/>
  <iframe width="360" height="200" src="https://www.youtube.com/embed/9GyTNIh9Ffk" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen="true" className="max-w-full m-auto sm:hidden"/>
  <figcaption className='flex justify-center'>
    Listen to an auto-generated version of this article
  </figcaption>
</figure>

![Evidence of planet-wide massacre on Mars](/cosmos/mars/massacre.jpg?w=360 '{"className":"rounded-md sm:float-right clear-right m-auto sm:m-1 sm:ml-4"}')

## What is the Deep State?

The **Deep State** is an international technocratic cabal.
"#;
        let allowed: Vec<String> = [
            "h1", "h2", "p", "figure", "figcaption", "iframe", "img", "strong", "em", "a",
        ]
        .into_iter()
        .map(String::from)
        .collect();
        let options = TranspileOptions { allowed_tags: allowed };
        let ast = parse(markdown, &options);
        if find_node(&ast, "figure").is_none() {
            eprintln!("{}", serde_json::to_string_pretty(&ast).unwrap_or_default());
        }
        assert!(find_node(&ast, "figure").is_some(), "expected figure");
        assert!(find_node(&ast, "iframe").is_some(), "expected iframe");
        assert!(find_node(&ast, "img").is_some(), "expected img");
        assert!(count_tag(&ast, "h2") >= 1, "expected h2 after figure");
    }

    #[test]
    fn test_gfm_table() {
        let markdown = "| Header |\n| --- |\n| Cell |";
        let options = TranspileOptions { allowed_tags: vec![] };
        let ast = parse(markdown, &options);
        
        assert!(find_node(&ast, "table").is_some());
        assert!(find_node(&ast, "thead").is_some());
        assert!(find_node(&ast, "td").is_some());
    }

    #[test]
    fn test_strikethrough() {
        let markdown = "~~deleted~~";
        let options = TranspileOptions { allowed_tags: vec![] };
        let ast = parse(markdown, &options);
        
        assert!(find_node(&ast, "del").is_some());
    }

    #[test]
    fn test_relay_readme() {
        let markdown = r#"# Test Movie Repository
`v. 1.0.1`

Welcome! This is a sample content repository used to exercise the Relay server, client, and commit hooks.
"#;
        let options = TranspileOptions { allowed_tags: vec![] };
        let ast = parse(markdown, &options);
        assert!(!ast.is_empty());
    }
}
