//! Clipboard-path integration tests for `devup-editor-html`.
//!
//! These mirror `packages/react/test/clipboardHtml.test.ts`: table
//! round-trip, cell styling, colgroup widths, and the regression for
//! `<td style="background-color">` not turning into a text highlight
//! mark. We port them so the React-side tests can shrink to a thin
//! wrapper and the authoritative coverage lives in Rust.

use devup_editor_core::{Block, BlockId, SequentialIdGenerator, TextSpan};
use devup_editor_html::{
    CopiedBlocks, blocks_to_html, clean_html, copied_blocks_to_html, decode_props, encode_props,
    html_to_copied_blocks, slice_content,
};
use serde_json::{Map, Value};
use std::collections::HashMap;

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

fn parse(html: &str) -> CopiedBlocks {
    let mut id_gen = SequentialIdGenerator::new("t");
    html_to_copied_blocks(html, &mut id_gen)
}

fn first_table_row_cell(
    cb: &CopiedBlocks,
    table_idx: usize,
    row_idx: usize,
    cell_idx: usize,
) -> &Block {
    let table = &cb.roots[table_idx];
    let row_id = &table.children[row_idx];
    let row = cb.by_id.get(row_id).expect("row");
    let cell_id = &row.children[cell_idx];
    cb.by_id.get(cell_id).expect("cell")
}

// ── 1. External HTML → blocks (paste from Word / Notion / Google Docs)

#[test]
fn table_cell_background_color_from_inline_style() {
    let cb = parse(
        "<table><tbody><tr><td style=\"background-color: #fef3c7\">x</td></tr></tbody></table>",
    );
    let table = &cb.roots[0];
    assert_eq!(table.ty, "table");
    let cell = first_table_row_cell(&cb, 0, 0, 0);
    assert_eq!(
        cell.props.get("backgroundColor").and_then(Value::as_str),
        Some("#fef3c7")
    );
}

#[test]
fn table_cell_full_border_padding_vertical_align() {
    let cb = parse(
        "<table><tbody><tr><td style=\"border-color: #f59e0b; border-width: 2px; border-style: dashed; padding: 12px; vertical-align: bottom\">x</td></tr></tbody></table>",
    );
    let cell = first_table_row_cell(&cb, 0, 0, 0);
    assert_eq!(
        cell.props.get("borderColor").and_then(Value::as_str),
        Some("#f59e0b")
    );
    assert_eq!(
        cell.props.get("borderWidth").and_then(Value::as_str),
        Some("2px")
    );
    assert_eq!(
        cell.props.get("borderStyle").and_then(Value::as_str),
        Some("dashed")
    );
    assert_eq!(
        cell.props.get("padding").and_then(Value::as_str),
        Some("12px")
    );
    assert_eq!(
        cell.props.get("verticalAlign").and_then(Value::as_str),
        Some("bottom")
    );
}

#[test]
fn table_cell_colspan_rowspan() {
    let cb = parse("<table><tbody><tr><td colspan=\"2\" rowspan=\"3\">x</td></tr></tbody></table>");
    let cell = first_table_row_cell(&cb, 0, 0, 0);
    assert_eq!(cell.props.get("colspan").and_then(Value::as_u64), Some(2));
    assert_eq!(cell.props.get("rowspan").and_then(Value::as_u64), Some(3));
}

#[test]
fn table_level_style_and_row_height() {
    let cb = parse(
        "<table style=\"background-color: #f3e8ff; border-color: #8b5cf6; border-width: 1px; border-style: solid\"><tbody><tr style=\"height: 80px\"><td>x</td></tr></tbody></table>",
    );
    let table = &cb.roots[0];
    assert_eq!(
        table.props.get("backgroundColor").and_then(Value::as_str),
        Some("#f3e8ff")
    );
    assert_eq!(
        table.props.get("borderColor").and_then(Value::as_str),
        Some("#8b5cf6")
    );
    assert_eq!(
        table.props.get("borderWidth").and_then(Value::as_str),
        Some("1px")
    );
    assert_eq!(
        table.props.get("borderStyle").and_then(Value::as_str),
        Some("solid")
    );
    let row = cb.by_id.get(&table.children[0]).unwrap();
    // Row height must be a raw pixel NUMBER (not "80px") so the React
    // renderer's `row.props.height as number` read works.
    assert_eq!(row.props.get("height").and_then(Value::as_i64), Some(80));
}

#[test]
fn colgroup_col_widths_with_default_fallback() {
    let cb = parse(
        "<table><colgroup><col style=\"width: 200px\"><col style=\"width: 60px\"><col></colgroup><tbody><tr><td>a</td><td>b</td><td>c</td></tr></tbody></table>",
    );
    let table = &cb.roots[0];
    let cols = table
        .props
        .get("columns")
        .and_then(Value::as_array)
        .unwrap();
    assert_eq!(cols.len(), 3);
    assert_eq!(
        cols[0].get("width").and_then(Value::as_f64),
        Some(200.0),
        "first col: {cols:?}"
    );
    assert_eq!(cols[1].get("width").and_then(Value::as_f64), Some(60.0));
    // Third col has no width → default 120.
    assert_eq!(cols[2].get("width").and_then(Value::as_f64), Some(120.0));
}

#[test]
fn td_style_does_not_leak_into_text_highlight() {
    // Regression: <td style="background-color:#fef3c7"> used to paint
    // the cell text with a highlight mark because extend_marks picked
    // up the inline style.
    let cb = parse(
        "<table><tbody><tr><td style=\"background-color: #fef3c7\">plain</td></tr></tbody></table>",
    );
    let cell = first_table_row_cell(&cb, 0, 0, 0);
    let mark_count: usize = cell.content.iter().map(|s| s.marks.len()).sum();
    assert_eq!(mark_count, 0, "cell content marks: {:?}", cell.content);
}

// ── 2. blocks → HTML → blocks round-trip

fn build_single_cell_table() -> (CopiedBlocks, String) {
    let mut cell_props = Map::new();
    cell_props.insert("backgroundColor".into(), Value::String("#fef3c7".into()));
    cell_props.insert("borderColor".into(), Value::String("#f59e0b".into()));
    cell_props.insert("borderWidth".into(), Value::String("2px".into()));
    cell_props.insert("borderStyle".into(), Value::String("dashed".into()));
    cell_props.insert("padding".into(), Value::String("12px".into()));
    cell_props.insert("verticalAlign".into(), Value::String("bottom".into()));
    let cell_id = BlockId::new("c1");
    let mut cell = Block::with_props(cell_id.clone(), "table_cell", cell_props);
    cell.content = vec![TextSpan::plain("hi")];
    cell.parent = Some(BlockId::new("r1"));

    let row_id = BlockId::new("r1");
    let mut row = Block::new(row_id.clone(), "table_row");
    row.children = vec![cell_id.clone()];
    row.parent = Some(BlockId::new("t1"));

    let table_id = BlockId::new("t1");
    let mut columns = Vec::new();
    let mut col = Map::new();
    col.insert("width".into(), Value::from(120u64));
    columns.push(Value::Object(col));
    let mut table_props = Map::new();
    table_props.insert("columns".into(), Value::Array(columns));
    let mut table = Block::with_props(table_id.clone(), "table", table_props);
    table.children = vec![row_id.clone()];

    let mut by_id = HashMap::new();
    by_id.insert(table_id, table.clone());
    by_id.insert(row_id, row);
    by_id.insert(cell_id, cell);

    let html = blocks_to_html(&[table.clone()], &by_id);
    (
        CopiedBlocks {
            roots: vec![table],
            by_id,
        },
        html,
    )
}

#[test]
fn roundtrip_cell_background_border_padding() {
    let (_built, html) = build_single_cell_table();
    let parsed = parse(&html);
    let cell = first_table_row_cell(&parsed, 0, 0, 0);
    assert_eq!(
        cell.props.get("backgroundColor").and_then(Value::as_str),
        Some("#fef3c7")
    );
    assert_eq!(
        cell.props.get("borderColor").and_then(Value::as_str),
        Some("#f59e0b")
    );
    assert_eq!(
        cell.props.get("borderWidth").and_then(Value::as_str),
        Some("2px")
    );
    assert_eq!(
        cell.props.get("borderStyle").and_then(Value::as_str),
        Some("dashed")
    );
    assert_eq!(
        cell.props.get("padding").and_then(Value::as_str),
        Some("12px")
    );
    assert_eq!(
        cell.props.get("verticalAlign").and_then(Value::as_str),
        Some("bottom")
    );
}

#[test]
fn roundtrip_row_height_as_number_and_column_widths() {
    // Two cells so columns=[200,60] survives max(td count per row).
    let mut cell_a = Block::new(BlockId::new("cA"), "table_cell");
    cell_a.content = vec![TextSpan::plain("a")];
    cell_a.parent = Some(BlockId::new("r1"));
    let mut cell_b = Block::new(BlockId::new("cB"), "table_cell");
    cell_b.content = vec![TextSpan::plain("b")];
    cell_b.parent = Some(BlockId::new("r1"));

    let mut row_props = Map::new();
    row_props.insert("height".into(), Value::from(80u64));
    let mut row = Block::with_props(BlockId::new("r1"), "table_row", row_props);
    row.children = vec![BlockId::new("cA"), BlockId::new("cB")];
    row.parent = Some(BlockId::new("t1"));

    let mut cols = Vec::new();
    for w in [200u64, 60u64] {
        let mut col = Map::new();
        col.insert("width".into(), Value::from(w));
        cols.push(Value::Object(col));
    }
    let mut tprops = Map::new();
    tprops.insert("columns".into(), Value::Array(cols));
    let mut table = Block::with_props(BlockId::new("t1"), "table", tprops);
    table.children = vec![BlockId::new("r1")];

    let mut by_id = HashMap::new();
    by_id.insert(BlockId::new("t1"), table.clone());
    by_id.insert(BlockId::new("r1"), row);
    by_id.insert(BlockId::new("cA"), cell_a);
    by_id.insert(BlockId::new("cB"), cell_b);

    let html = blocks_to_html(&[table], &by_id);
    let parsed = parse(&html);

    let table = &parsed.roots[0];
    let cols = table
        .props
        .get("columns")
        .and_then(Value::as_array)
        .unwrap();
    assert_eq!(cols.len(), 2);
    assert_eq!(cols[0].get("width").and_then(Value::as_f64), Some(200.0));
    assert_eq!(cols[1].get("width").and_then(Value::as_f64), Some(60.0));
    let row = parsed.by_id.get(&table.children[0]).unwrap();
    assert_eq!(row.props.get("height").and_then(Value::as_i64), Some(80));
}

#[test]
fn roundtrip_colspan_rowspan() {
    let mut cell_props = Map::new();
    cell_props.insert("colspan".into(), Value::from(2u64));
    cell_props.insert("rowspan".into(), Value::from(3u64));
    let mut cell = Block::with_props(BlockId::new("c1"), "table_cell", cell_props);
    cell.content = vec![TextSpan::plain("m")];
    cell.parent = Some(BlockId::new("r1"));

    let mut row = Block::new(BlockId::new("r1"), "table_row");
    row.children = vec![BlockId::new("c1")];
    row.parent = Some(BlockId::new("t1"));

    let mut cols = Vec::new();
    for _ in 0..2 {
        let mut col = Map::new();
        col.insert("width".into(), Value::from(120u64));
        cols.push(Value::Object(col));
    }
    let mut tprops = Map::new();
    tprops.insert("columns".into(), Value::Array(cols));
    let mut table = Block::with_props(BlockId::new("t1"), "table", tprops);
    table.children = vec![BlockId::new("r1")];

    let mut by_id = HashMap::new();
    by_id.insert(BlockId::new("t1"), table.clone());
    by_id.insert(BlockId::new("r1"), row);
    by_id.insert(BlockId::new("c1"), cell);

    let html = blocks_to_html(&[table], &by_id);
    let parsed = parse(&html);

    let cell = first_table_row_cell(&parsed, 0, 0, 0);
    assert_eq!(cell.props.get("colspan").and_then(Value::as_u64), Some(2));
    assert_eq!(cell.props.get("rowspan").and_then(Value::as_u64), Some(3));
}

// ── 3. data-devup-props marker round-trip

#[test]
fn roundtrip_preserves_arbitrary_marker_props() {
    // Props the renderer cares about but aren't native HTML attributes
    // (e.g. a custom `fontFamily`) must round-trip via the base64
    // marker — inline style alone would lose them.
    let mut props = Map::new();
    props.insert("fontFamily".into(), Value::String("serif".into()));
    props.insert("customFoo".into(), Value::from(42u64));

    let encoded = encode_props(Some(&props));
    assert!(!encoded.is_empty());
    let decoded = decode_props(&encoded).expect("marker decode");
    assert_eq!(
        decoded.get("fontFamily").and_then(Value::as_str),
        Some("serif")
    );
    assert_eq!(decoded.get("customFoo").and_then(Value::as_u64), Some(42));
}

// ── 4. Notion v3 heuristic

#[test]
fn notion_v3_toggle_li_with_multiple_block_children() {
    // `<li>` containing a `<p>` title + additional block → toggle.
    let cb = parse("<ul><li><p>Title</p><p>Child paragraph</p></li></ul>");
    // Expect a toggle root + a child paragraph at indent 1.
    let types: Vec<String> = cb.roots.iter().map(|b| b.ty.clone()).collect();
    assert!(types.contains(&"toggle".to_string()), "roots: {types:?}");
    // Child paragraph should be emitted at indent=1.
    let paragraph = cb
        .roots
        .iter()
        .find(|b| b.ty == "paragraph")
        .expect("child paragraph");
    assert_eq!(
        paragraph.props.get("indent").and_then(Value::as_i64),
        Some(1)
    );
}

// ── 5. Nested list indent tracking

#[test]
fn nested_list_carries_indent_prop() {
    let cb = parse("<ul><li>a<ul><li>b</li></ul></li></ul>");
    // Two roots: "a" at indent 0, "b" at indent 1.
    let indents: Vec<i64> = cb
        .roots
        .iter()
        .map(|b| b.props.get("indent").and_then(Value::as_i64).unwrap_or(0))
        .collect();
    assert_eq!(indents, vec![0, 1], "indents: {indents:?}");
}

// ── 6. Notion todo-list selector

#[test]
fn notion_todo_list_with_checkbox_classes() {
    let cb = parse(
        r#"<ul class="to-do-list"><li><div class="checkbox checkbox-on"></div><span class="to-do-children-checked">Pick up milk</span></li></ul>"#,
    );
    let todo = cb
        .roots
        .iter()
        .find(|b| b.ty == "todo")
        .expect("todo block");
    assert_eq!(
        todo.props.get("checked").and_then(Value::as_bool),
        Some(true)
    );
    let text = todo.plain_text();
    assert!(text.contains("Pick up milk"), "text: {text:?}");
}

// ── 7. Unknown mark fallback

#[test]
fn unknown_mark_survives_as_span_data_mark() {
    use devup_editor_core::{Mark, TextSpan};

    let mut cell = Block::new(BlockId::new("p"), "paragraph");
    cell.content = vec![TextSpan::with_marks("ghost", vec![Mark::new("mystery")])];
    let mut by_id = HashMap::new();
    by_id.insert(BlockId::new("p"), cell.clone());
    let html = blocks_to_html(&[cell], &by_id);
    assert!(
        html.contains("data-mark=\"mystery\""),
        "unknown mark fallback missing: {html}"
    );
}

// ── 8. \n ↔ <br> inline conversion

#[test]
fn newline_in_span_becomes_br_on_export() {
    use devup_editor_core::TextSpan;
    let mut para = Block::new(BlockId::new("p"), "paragraph");
    para.content = vec![TextSpan::plain("line 1\nline 2")];
    let mut by_id = HashMap::new();
    by_id.insert(BlockId::new("p"), para.clone());
    let html = blocks_to_html(&[para], &by_id);
    assert!(html.contains("line 1<br>line 2"), "html: {html}");

    let parsed = parse(&html);
    let p = parsed
        .roots
        .iter()
        .find(|b| b.ty == "paragraph")
        .expect("paragraph");
    assert!(
        p.content.iter().any(|s| s.text.contains('\n')),
        "br did not round-trip to \\n: {:?}",
        p.content
    );
}

// ── 9. cleanHtml preprocessing

#[test]
fn clean_html_strips_word_artifacts() {
    let cleaned = clean_html("<!--StartFragment--><p>real<o:p>junk</o:p></p><!--EndFragment-->");
    assert!(!cleaned.contains("StartFragment"));
    assert!(!cleaned.contains("EndFragment"));
    assert!(!cleaned.contains("<o:p"));
    assert!(cleaned.contains("<p>real</p>"));
}

// ── 10. slice_content sanity

#[test]
fn slice_content_offsets_match_utf16_semantics() {
    use devup_editor_core::TextSpan;
    let spans = vec![
        TextSpan::plain("abc"),
        TextSpan::plain(" "),
        TextSpan::plain("def"),
    ];
    let out = slice_content(&spans, 1, 6);
    // Normalisation merges same-mark adjacent spans → single "bc de".
    assert_eq!(out.len(), 1);
    assert_eq!(out[0].text, "bc de");
}

// ── 11. copied_blocks_to_html convenience wrapper

#[test]
fn copied_blocks_to_html_matches_blocks_to_html() {
    let mut para = Block::new(BlockId::new("p"), "paragraph");
    para.content = vec![devup_editor_core::TextSpan::plain("hi")];
    let mut by_id = HashMap::new();
    by_id.insert(BlockId::new("p"), para.clone());
    let a = blocks_to_html(&[para.clone()], &by_id);
    let b = copied_blocks_to_html(&CopiedBlocks {
        roots: vec![para],
        by_id,
    });
    assert_eq!(a, b);
}
