//! Integration tests for `devup-editor-html`.
//!
//! Every test uses `SequentialIdGenerator` so block IDs are deterministic.
//! Round-trip tests don't require byte-perfect HTML preservation — they
//! check structural equivalence (block types + content text + marks).

use devup_editor_core::{
    Block, BlockId, Document, DocumentExport, DocumentImport, Mark, SequentialIdGenerator, TextSpan,
};
use devup_editor_html::Html;
use serde_json::{Map, Value};

// ── Helpers ──────────────────────────────────────────────────────

fn doc_with(blocks: Vec<Block>) -> Document {
    let mut doc = Document::new();
    for b in blocks {
        doc.push_root_block(b);
    }
    doc
}

fn styled_mark(mark_type: &str, style_key: &str, style_value: &str) -> Mark {
    let mut style = Map::new();
    style.insert(style_key.into(), Value::String(style_value.into()));
    let mut attrs = Map::new();
    attrs.insert("style".into(), Value::Object(style));
    Mark::with_attrs(mark_type, attrs)
}

fn block_types(doc: &Document) -> Vec<String> {
    doc.root_block_ids()
        .iter()
        .filter_map(|id| doc.get_block(id).map(|b| b.ty.clone()))
        .collect()
}

fn block_texts(doc: &Document) -> Vec<String> {
    doc.root_block_ids()
        .iter()
        .filter_map(|id| doc.get_block(id).map(devup_editor_core::Block::plain_text))
        .collect()
}

// ── Export tests ─────────────────────────────────────────────────

#[test]
fn export_heading_levels() {
    for level in 1u64..=6 {
        let mut b = Block::new(BlockId::new(format!("h{level}")), "heading");
        b.content = vec![TextSpan::plain(format!("Heading {level}"))];
        b.props.insert("level".into(), Value::from(level));
        let out = Html::export(&doc_with(vec![b])).unwrap();
        assert!(
            out.contains(&format!("<h{level}>Heading {level}</h{level}>")),
            "missing h{level}: {out}"
        );
    }
}

#[test]
fn export_paragraph_with_all_marks() {
    let mut b = Block::new_paragraph(BlockId::new("p"));
    b.content = vec![
        TextSpan::with_marks("bold", vec![Mark::bold()]),
        TextSpan::plain(" "),
        TextSpan::with_marks("italic", vec![Mark::italic()]),
        TextSpan::plain(" "),
        TextSpan::with_marks("code", vec![Mark::code()]),
        TextSpan::plain(" "),
        TextSpan::with_marks("under", vec![Mark::underline()]),
        TextSpan::plain(" "),
        TextSpan::with_marks("strike", vec![Mark::strike()]),
    ];
    let out = Html::export(&doc_with(vec![b])).unwrap();
    assert!(out.contains("<strong>bold</strong>"));
    assert!(out.contains("<em>italic</em>"));
    assert!(out.contains("<code>code</code>"));
    assert!(out.contains("<u>under</u>"));
    assert!(out.contains("<s>strike</s>"));
}

#[test]
fn export_color_and_highlight_produce_span_style() {
    let mut b = Block::new_paragraph(BlockId::new("p"));
    b.content = vec![TextSpan::with_marks(
        "rgb",
        vec![
            styled_mark("color", "color", "#ff0000"),
            styled_mark("highlight", "backgroundColor", "#fff000"),
        ],
    )];
    let out = Html::export(&doc_with(vec![b])).unwrap();
    assert!(out.contains("color:#ff0000"), "missing color style: {out}");
    assert!(
        out.contains("background-color:#fff000"),
        "missing highlight: {out}"
    );
}

#[test]
fn export_link_emits_a_with_rel() {
    let mut href_attrs = Map::new();
    href_attrs.insert("href".into(), Value::String("https://example.com".into()));
    let mut b = Block::new_paragraph(BlockId::new("p"));
    b.content = vec![TextSpan::with_marks(
        "click",
        vec![Mark::with_attrs("link", href_attrs)],
    )];
    let out = Html::export(&doc_with(vec![b])).unwrap();
    assert!(
        out.contains("<a href=\"https://example.com\" rel=\"noopener noreferrer\">click</a>"),
        "missing link: {out}"
    );
}

#[test]
fn export_link_strips_javascript_scheme() {
    let mut href_attrs = Map::new();
    href_attrs.insert("href".into(), Value::String("javascript:alert(1)".into()));
    let mut b = Block::new_paragraph(BlockId::new("p"));
    b.content = vec![TextSpan::with_marks(
        "evil",
        vec![Mark::with_attrs("link", href_attrs)],
    )];
    let out = Html::export(&doc_with(vec![b])).unwrap();
    assert!(
        !out.contains("javascript:"),
        "should drop unsafe href: {out}"
    );
    assert!(
        out.contains(">evil</"),
        "text content should still render: {out}"
    );
}

#[test]
fn export_todo_checked_and_unchecked() {
    let checked = Block::new_todo(BlockId::new("t1"), true);
    let mut unchecked = Block::new_todo(BlockId::new("t2"), false);
    unchecked.content = vec![TextSpan::plain("task")];

    let mut checked = checked;
    checked.content = vec![TextSpan::plain("done")];

    let out = Html::export(&doc_with(vec![checked, unchecked])).unwrap();
    // Todos round-trip through <p data-type="todo" data-checked="…">
    // so external clipboards see a plain paragraph but devup→devup
    // pastes re-parse them as todos.
    assert!(
        out.contains("<p data-type=\"todo\" data-checked=\"true\">"),
        "checked todo: {out}"
    );
    assert!(
        out.contains("<p data-type=\"todo\" data-checked=\"false\">"),
        "unchecked todo: {out}"
    );
    assert!(out.contains(">done</p>"));
    assert!(out.contains(">task</p>"));
}

#[test]
fn export_code_block_with_language() {
    let mut b = Block::new(BlockId::new("c"), "code");
    b.content = vec![TextSpan::plain("fn main() {}")];
    b.props
        .insert("language".into(), Value::String("rust".into()));
    let out = Html::export(&doc_with(vec![b])).unwrap();
    assert!(
        out.contains("<pre><code class=\"language-rust\">fn main() {}</code></pre>"),
        "got: {out}"
    );
}

#[test]
fn export_escapes_html_in_text() {
    let mut b = Block::new_paragraph(BlockId::new("p"));
    b.content = vec![TextSpan::plain("<script>alert(1)</script>")];
    let out = Html::export(&doc_with(vec![b])).unwrap();
    assert!(!out.contains("<script>"));
    assert!(out.contains("&lt;script&gt;"));
}

#[test]
fn export_empty_document_is_empty_string() {
    let out = Html::export(&Document::new()).unwrap();
    assert!(out.is_empty());
}

// ── Import tests ─────────────────────────────────────────────────

#[test]
fn import_h1_to_h6() {
    for level in 1u64..=6 {
        let mut id_gen = SequentialIdGenerator::new("t");
        let src = format!("<h{level}>Title</h{level}>");
        let doc = Html::import(src, &mut id_gen).unwrap();
        assert_eq!(block_types(&doc), vec!["heading".to_string()]);
        let block = doc.get_block(&BlockId::new("t-1")).unwrap();
        assert_eq!(
            block.props.get("level").and_then(Value::as_u64),
            Some(level),
            "h{level} produced wrong level"
        );
        assert_eq!(block.plain_text(), "Title");
    }
}

#[test]
fn import_todo_via_explicit_marker() {
    let mut id_gen = SequentialIdGenerator::new("t");
    let src = r#"<ul data-devup-type="todo"><li><label><input type="checkbox" checked> done</label></li></ul>"#;
    let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
    assert_eq!(block_types(&doc), vec!["todo".to_string()]);
    let block = doc.get_block(&BlockId::new("t-1")).unwrap();
    assert_eq!(
        block.props.get("checked").and_then(Value::as_bool),
        Some(true)
    );
    assert!(block.plain_text().contains("done"));
}

#[test]
fn import_todo_via_checkbox_heuristic() {
    // Even without data-devup-type, presence of a checkbox in an <li>
    // should be treated as a todo item (matches TS clipboard importer).
    let mut id_gen = SequentialIdGenerator::new("t");
    let src = r#"<ul><li><input type="checkbox"> open</li></ul>"#;
    let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
    assert_eq!(block_types(&doc), vec!["todo".to_string()]);
    let block = doc.get_block(&BlockId::new("t-1")).unwrap();
    assert_eq!(
        block.props.get("checked").and_then(Value::as_bool),
        Some(false)
    );
}

#[test]
fn import_ordered_vs_unordered_list() {
    let mut id_gen = SequentialIdGenerator::new("t");
    let src = "<ul><li>a</li><li>b</li></ul><ol><li>c</li></ol>";
    let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
    let types = block_types(&doc);
    assert_eq!(types, vec!["list", "list", "list"]);
    let styles: Vec<_> = doc
        .root_block_ids()
        .iter()
        .filter_map(|id| {
            doc.get_block(id).and_then(|b| {
                b.props
                    .get("style")
                    .and_then(Value::as_str)
                    .map(String::from)
            })
        })
        .collect();
    assert_eq!(styles, vec!["unordered", "unordered", "ordered"]);
}

#[test]
fn import_blockquote() {
    let mut id_gen = SequentialIdGenerator::new("t");
    let src = "<blockquote>quoted</blockquote>";
    let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
    assert_eq!(block_types(&doc), vec!["quote".to_string()]);
    assert_eq!(block_texts(&doc), vec!["quoted".to_string()]);
}

#[test]
fn import_pre_code_with_language_class() {
    let mut id_gen = SequentialIdGenerator::new("t");
    let src = "<pre><code class=\"language-python\">print('hi')\n</code></pre>";
    let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
    assert_eq!(block_types(&doc), vec!["code".to_string()]);
    let block = doc.get_block(&BlockId::new("t-1")).unwrap();
    assert_eq!(
        block.props.get("language").and_then(Value::as_str),
        Some("python")
    );
    assert!(block.plain_text().contains("print('hi')"));
}

#[test]
fn import_hr_produces_divider() {
    let mut id_gen = SequentialIdGenerator::new("t");
    let doc = Html::import("<hr>".to_string(), &mut id_gen).unwrap();
    assert_eq!(block_types(&doc), vec!["divider".to_string()]);
}

#[test]
fn import_inline_marks_round_trip() {
    let mut id_gen = SequentialIdGenerator::new("t");
    let src =
        "<p><strong>bold</strong> <em>italic</em> <u>under</u> <s>strike</s> <code>c</code></p>";
    let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
    let block = doc.get_block(&BlockId::new("t-1")).unwrap();

    let collect_marks = |mark_type: &str| -> Vec<&str> {
        block
            .content
            .iter()
            .filter(|s| s.has_mark(mark_type))
            .map(|s| s.text.as_str())
            .collect()
    };
    assert_eq!(collect_marks("bold"), vec!["bold"]);
    assert_eq!(collect_marks("italic"), vec!["italic"]);
    assert_eq!(collect_marks("underline"), vec!["under"]);
    assert_eq!(collect_marks("strike"), vec!["strike"]);
    assert_eq!(collect_marks("code"), vec!["c"]);
}

#[test]
fn import_span_color_and_highlight() {
    let mut id_gen = SequentialIdGenerator::new("t");
    let src = r#"<p><span style="color:#ff0000;background-color:#fff000">rgb</span></p>"#;
    let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
    let block = doc.get_block(&BlockId::new("t-1")).unwrap();
    let span = &block.content[0];
    assert!(span.has_mark("color"));
    assert!(span.has_mark("highlight"));
}

#[test]
fn import_link_captures_href() {
    let mut id_gen = SequentialIdGenerator::new("t");
    let src = r#"<p><a href="https://example.com">click</a></p>"#;
    let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
    let block = doc.get_block(&BlockId::new("t-1")).unwrap();
    let span = &block.content[0];
    let link_mark = span.marks.iter().find(|m| m.ty == "link").unwrap();
    assert_eq!(
        link_mark.attrs.get("href").and_then(Value::as_str),
        Some("https://example.com")
    );
}

#[test]
fn import_unknown_tag_flattens_to_paragraph() {
    let mut id_gen = SequentialIdGenerator::new("t");
    let src = "<article>mystery content</article>";
    let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
    assert_eq!(block_types(&doc), vec!["paragraph".to_string()]);
    assert!(
        block_texts(&doc)[0].contains("mystery content"),
        "got: {:?}",
        block_texts(&doc)
    );
}

#[test]
fn import_transparent_containers_descend() {
    let mut id_gen = SequentialIdGenerator::new("t");
    let src = "<div><section><p>inside nested wrappers</p></section></div>";
    let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
    assert_eq!(block_types(&doc), vec!["paragraph".to_string()]);
    assert_eq!(block_texts(&doc)[0], "inside nested wrappers");
}

#[test]
fn import_details_becomes_toggle() {
    let mut id_gen = SequentialIdGenerator::new("t");
    let src = "<details><summary>click me</summary><p>nested</p></details>";
    let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
    let types = block_types(&doc);
    // v1: toggle block + nested block both at root (see crate docs).
    assert!(types.contains(&"toggle".to_string()), "types: {types:?}");
}

#[test]
fn import_malformed_html_does_not_panic() {
    let mut id_gen = SequentialIdGenerator::new("t");
    // html5ever is tolerant — just make sure we don't panic on garbage.
    let src = "<p>unclosed <strong>text";
    let doc = Html::import(src.to_string(), &mut id_gen).unwrap();
    assert!(!block_types(&doc).is_empty());
}

#[test]
fn import_empty_document() {
    let mut id_gen = SequentialIdGenerator::new("t");
    let doc = Html::import(String::new(), &mut id_gen).unwrap();
    assert_eq!(doc.root_block_count(), 0);
}

// ── Round-trip ───────────────────────────────────────────────────

#[test]
fn roundtrip_preserves_block_types_and_text() {
    let mut doc = Document::new();

    let mut h1 = Block::new(BlockId::new("h"), "heading");
    h1.props.insert("level".into(), Value::from(1u64));
    h1.content = vec![TextSpan::plain("Title")];
    doc.push_root_block(h1);

    let mut p = Block::new_paragraph(BlockId::new("p"));
    p.content = vec![
        TextSpan::plain("Hello, "),
        TextSpan::with_marks("world", vec![Mark::bold()]),
        TextSpan::plain("."),
    ];
    doc.push_root_block(p);

    let mut li = Block::new(BlockId::new("l"), "list");
    li.props
        .insert("style".into(), Value::String("unordered".into()));
    li.content = vec![TextSpan::plain("item one")];
    doc.push_root_block(li);

    let exported = Html::export(&doc).unwrap();

    let mut id_gen = SequentialIdGenerator::new("r");
    let re = Html::import(exported, &mut id_gen).unwrap();

    assert_eq!(
        block_types(&re),
        vec!["heading", "paragraph", "list"],
        "block types drifted"
    );
    let texts = block_texts(&re);
    assert_eq!(texts[0], "Title");
    assert_eq!(texts[1], "Hello, world.");
    assert_eq!(texts[2], "item one");
}

// ──────────────────────────────────────────────────────────────────────
// Hardening: clipboard HTML is untrusted input. Importing pathological
// `style` values must NEVER panic the Rust engine — a panic inside
// WASM aborts the whole editor, which would be a paste-based DoS.
// ──────────────────────────────────────────────────────────────────────

/// Regression for the `from_f64(v).unwrap()` panic path in
/// `extract_row_props`. Several height strings hit f64 edge cases:
/// `"1e400px"` parses to `f64::INFINITY`, `"NaNpx"` to `NaN`, and a
/// negative height is semantically invalid. All three must be handled
/// without panicking; the row should just drop its `height` prop.
#[test]
fn import_tolerates_pathological_row_height_styles() {
    for raw_height in [
        // f64 parse-overflow → positive infinity
        "1e400px",
        // NaN literal
        "NaNpx",
        // Negative → semantically invalid for a row height
        "-48px",
        // Zero → same
        "0px",
        // Unparseable garbage
        "forty-eight-px",
        // Empty
        "",
    ] {
        let html = format!(
            r#"<table><tbody><tr style="height: {raw_height}"><td>cell</td></tr></tbody></table>"#
        );
        let mut id_gen = SequentialIdGenerator::new("p");
        // The whole point of the test: `Html::import` must return an
        // `Ok` (or a recoverable `Err`) — never panic.
        let result = Html::import(html, &mut id_gen);
        assert!(
            result.is_ok(),
            "import panicked or erred on row height {raw_height:?}"
        );
    }
}
