//! Part of the `transform` test suite; split off so no file exceeds
//! 1000 lines. See the parent `tests/mod.rs` for the shared helpers
//! (`doc_with_text`, `doc_with_table`, `apply_all`, `table_grid`).

use super::*;

#[test]
fn backspace_merge_normalizes_descendant_indents() {
    // Setup — b is at indent 0 because `transform_backspace` now
    // outdents an indented block at offset 0 *before* merging; this
    // test is specifically about the merge path, so we keep b flat.
    //   a (indent 0)  - paragraph
    //   b (indent 0)  - paragraph (empty → backspace at start merges into a)
    //   c (indent 2)  - paragraph (orphan after merge → should clamp to 1)
    //   d (indent 3)  - paragraph (orphan → clamp to 2)
    //   e (indent 1)  - paragraph (still valid → stays at 1)
    let mut doc = Document::new();
    let make_para = |id: &str, indent: i64, text: &str| {
        let mut b = Block::new_paragraph(BlockId::new(id));
        b.content = vec![TextSpan::plain(text)];
        if indent > 0 {
            b.props
                .insert(prop_keys::INDENT.into(), Value::from(indent));
        }
        b
    };
    doc.insert_block(make_para("a", 0, "A"), 0);
    doc.insert_block(make_para("b", 0, ""), 1);
    doc.insert_block(make_para("c", 2, "C"), 2);
    doc.insert_block(make_para("d", 3, "D"), 3);
    doc.insert_block(make_para("e", 1, "E"), 4);

    // Backspace at start of empty b → merges b into a
    let sel = Selection::collapsed("b", 0);
    let mut id_gen = SequentialIdGenerator::new("test");
    let ops = transform(&doc, &sel, &Command::Backspace, &mut id_gen, None).unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    // After merge: c, d should be clamped
    assert_eq!(doc.root_block_count(), 4); // a, c, d, e
    assert_eq!(doc.get_block(&BlockId::new("a")).unwrap().indent_level(), 0);
    assert_eq!(
        doc.get_block(&BlockId::new("c")).unwrap().indent_level(),
        1,
        "c should be clamped from 2 to 1"
    );
    assert_eq!(
        doc.get_block(&BlockId::new("d")).unwrap().indent_level(),
        2,
        "d should be clamped from 3 to 2"
    );
    assert_eq!(
        doc.get_block(&BlockId::new("e")).unwrap().indent_level(),
        1,
        "e should stay at 1"
    );
}

// ── Toggle-body guard ─────────────────────────────────────────────
// `transform_backspace` blocks the paragraph-into-previous merge step
// when the previous block is a toggle, so the auto-created body block
// (slash-menu + `>` markdown shortcut) survives a start-of-block
// Backspace. See `mod.rs` comment in the paragraph branch.

#[test]
fn backspace_at_offset_0_of_paragraph_after_toggle_is_noop() {
    // Setup mirrors the post-`/toggle` shape:
    //   t  toggle (indent 0, "title")
    //   c  paragraph (indent 0, empty) — the body block we want to keep
    // Cursor at offset 0 of c. Without the guard, Backspace would
    // merge c into t, deleting c.
    let mut doc = Document::new();
    let mut toggle = Block::new(BlockId::new("t"), "toggle");
    toggle.content = vec![TextSpan::plain("title")];
    let child = Block::new_paragraph(BlockId::new("c"));
    doc.insert_block(toggle, 0);
    doc.insert_block(child, 1);

    let sel = Selection::collapsed("c", 0);
    let mut id_gen = SequentialIdGenerator::new("test");
    let ops = transform(&doc, &sel, &Command::Backspace, &mut id_gen, None).unwrap();

    assert!(
        ops.is_empty(),
        "expected Backspace at offset 0 of paragraph-after-toggle to be a no-op, got {ops:?}",
    );
    assert_eq!(doc.root_block_count(), 2, "doc layout must be unchanged");
}

#[test]
fn backspace_at_offset_0_of_nonempty_paragraph_after_toggle_is_noop() {
    // Same shape as above but the body is non-empty. The merge path
    // would otherwise concat "body" onto the toggle title; the guard
    // must still no-op so the body block survives.
    let mut doc = Document::new();
    let mut toggle = Block::new(BlockId::new("t"), "toggle");
    toggle.content = vec![TextSpan::plain("title")];
    let mut child = Block::new_paragraph(BlockId::new("c"));
    child.content = vec![TextSpan::plain("body")];
    doc.insert_block(toggle, 0);
    doc.insert_block(child, 1);

    let sel = Selection::collapsed("c", 0);
    let mut id_gen = SequentialIdGenerator::new("test");
    let ops = transform(&doc, &sel, &Command::Backspace, &mut id_gen, None).unwrap();

    assert!(
        ops.is_empty(),
        "expected Backspace at offset 0 of nonempty paragraph-after-toggle to be a no-op, got {ops:?}",
    );

    // Confirm the body text is still intact in c (no merge).
    let body = doc.get_block(&BlockId::new("c")).unwrap();
    assert_eq!(body.plain_text(), "body");
    let title = doc.get_block(&BlockId::new("t")).unwrap();
    assert_eq!(title.plain_text(), "title");
}

#[test]
fn backspace_at_offset_0_of_indented_paragraph_directly_under_toggle_is_noop() {
    // Slash-menu / `>` markdown auto-create puts the body at indent 1.
    // The OUTDENT guard means even the first Backspace must no-op so
    // the body never escapes the toggle's visual scope (which is what
    // users reported as "토글 자식이 일반 블록으로 분리됨").
    let mut doc = Document::new();
    let mut toggle = Block::new(BlockId::new("t"), "toggle");
    toggle.content = vec![TextSpan::plain("title")];
    let mut child = Block::new_paragraph(BlockId::new("c"));
    child.props.insert(prop_keys::INDENT.into(), Value::from(1));
    doc.insert_block(toggle, 0);
    doc.insert_block(child, 1);

    let sel = Selection::collapsed("c", 0);
    let mut id_gen = SequentialIdGenerator::new("test");
    let ops = transform(&doc, &sel, &Command::Backspace, &mut id_gen, None).unwrap();
    assert!(
        ops.is_empty(),
        "expected Backspace at offset 0 of indent-1 body directly under a toggle to be a no-op, got {ops:?}",
    );
    assert_eq!(
        doc.get_block(&BlockId::new("c")).unwrap().indent_level(),
        1,
        "indent must stay at 1 — the body is still visually inside the toggle",
    );
}

#[test]
fn backspace_at_offset_0_of_second_body_outdents_normally() {
    // Only the FIRST body (immediate next sibling of the toggle) is
    // protected. The 2nd body sits between two other bodies (or the
    // 1st body) at the same indent, so Backspace at offset 0 should
    // outdent it like any other indented paragraph — the user
    // explicitly added it via Enter, so they explicitly own removing
    // it.
    let mut doc = Document::new();
    let mut toggle = Block::new(BlockId::new("t"), "toggle");
    toggle.content = vec![TextSpan::plain("title")];
    let mut body1 = Block::new_paragraph(BlockId::new("b1"));
    body1.props.insert(prop_keys::INDENT.into(), Value::from(1));
    let mut body2 = Block::new_paragraph(BlockId::new("b2"));
    body2.props.insert(prop_keys::INDENT.into(), Value::from(1));
    doc.insert_block(toggle, 0);
    doc.insert_block(body1, 1);
    doc.insert_block(body2, 2);

    // Cursor on body2 at offset 0. Immediate prev sibling is body1
    // (NOT the toggle) → normal outdent kicks in.
    let sel = Selection::collapsed("b2", 0);
    let mut id_gen = SequentialIdGenerator::new("test");
    let ops = transform(&doc, &sel, &Command::Backspace, &mut id_gen, None).unwrap();
    assert!(
        !ops.is_empty(),
        "second body must outdent normally; only the first body is protected",
    );
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);
    assert_eq!(
        doc.get_block(&BlockId::new("b2")).unwrap().indent_level(),
        0,
        "second body should outdent from 1 to 0",
    );
}

#[test]
fn backspace_at_offset_0_of_second_body_after_outdent_merges_into_first_body() {
    // Follow-up to the test above: once body2 has outdented to indent
    // 0, its immediate prev sibling is body1 (still at indent 1).
    // `doc.prev_sibling()` returns the previous root block regardless
    // of indent, so the toggle-body merge guard does NOT trip and the
    // second body collapses into body1 as the user expects.
    let mut doc = Document::new();
    let mut toggle = Block::new(BlockId::new("t"), "toggle");
    toggle.content = vec![TextSpan::plain("title")];
    let mut body1 = Block::new_paragraph(BlockId::new("b1"));
    body1.props.insert(prop_keys::INDENT.into(), Value::from(1));
    body1.content = vec![TextSpan::plain("first")];
    let mut body2 = Block::new_paragraph(BlockId::new("b2"));
    // Already at indent 0 — simulate the post-outdent state.
    body2.content = vec![TextSpan::plain("second")];
    doc.insert_block(toggle, 0);
    doc.insert_block(body1, 1);
    doc.insert_block(body2, 2);

    let sel = Selection::collapsed("b2", 0);
    let mut id_gen = SequentialIdGenerator::new("test");
    let ops = transform(&doc, &sel, &Command::Backspace, &mut id_gen, None).unwrap();
    assert!(
        !ops.is_empty(),
        "merge into body1 (not into the toggle) must still produce ops",
    );
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);
    assert_eq!(
        doc.root_block_count(),
        2,
        "b2 should have merged into b1 — only the toggle + body1 remain",
    );
    let merged = doc.get_block(&BlockId::new("b1")).unwrap().plain_text();
    assert_eq!(merged, "firstsecond");
}

#[test]
fn backspace_at_offset_0_of_indent_1_under_non_toggle_still_outdents() {
    // Regression: the outdent guard must NOT trip when the first
    // lower-indent block is something other than a toggle (heading,
    // paragraph, list, …). Otherwise normal indented prose loses
    // its Backspace-to-outdent ergonomics.
    let mut doc = Document::new();
    let mut head = Block::new(BlockId::new("h"), "heading");
    head.content = vec![TextSpan::plain("Section")];
    let mut child = Block::new_paragraph(BlockId::new("c"));
    child.props.insert(prop_keys::INDENT.into(), Value::from(1));
    child.content = vec![TextSpan::plain("body")];
    doc.insert_block(head, 0);
    doc.insert_block(child, 1);

    let sel = Selection::collapsed("c", 0);
    let mut id_gen = SequentialIdGenerator::new("test");
    let ops = transform(&doc, &sel, &Command::Backspace, &mut id_gen, None).unwrap();
    assert!(
        !ops.is_empty(),
        "outdent must still fire under non-toggle parents",
    );
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);
    assert_eq!(
        doc.get_block(&BlockId::new("c")).unwrap().indent_level(),
        0,
        "child should outdent normally from 1 to 0",
    );
}

#[test]
fn backspace_at_offset_0_of_paragraph_after_non_toggle_still_merges() {
    // Regression guard: the toggle-body guard must NOT affect the
    // normal "paragraph-after-paragraph" merge path. Two adjacent
    // empty paragraphs collapse on Backspace as before.
    let mut doc = Document::new();
    let mut a = Block::new_paragraph(BlockId::new("a"));
    a.content = vec![TextSpan::plain("hello")];
    let b = Block::new_paragraph(BlockId::new("b"));
    doc.insert_block(a, 0);
    doc.insert_block(b, 1);

    let sel = Selection::collapsed("b", 0);
    let mut id_gen = SequentialIdGenerator::new("test");
    let ops = transform(&doc, &sel, &Command::Backspace, &mut id_gen, None).unwrap();
    assert!(
        !ops.is_empty(),
        "merge into a non-toggle previous block must still produce ops",
    );
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);
    assert_eq!(
        doc.root_block_count(),
        1,
        "b should have merged into a — only one block left",
    );
    assert_eq!(
        doc.get_block(&BlockId::new("a")).unwrap().plain_text(),
        "hello"
    );
}

// --- Table tests ---

#[test]
fn insert_table_creates_nested_structure() {
    let mut doc = Document::new();
    let p = Block::new_paragraph(BlockId::new("p1"));
    doc.insert_block(p, 0);

    let sel = Selection::collapsed("p1", 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::InsertTable { rows: 2, cols: 3 },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    // Table should be inserted at root index 1 (after p1)
    assert_eq!(doc.root_block_count(), 2);

    let table_id = &doc.root_block_ids()[1];
    let table = doc.get_block(table_id).unwrap();
    assert!(table.is_table());
    assert_eq!(table.children.len(), 2); // 2 rows

    // Check rows and cells
    for row_id in &table.children {
        let row = doc.get_block(row_id).unwrap();
        assert!(row.is_table_row());
        assert_eq!(row.children.len(), 3); // 3 cells per row
        assert_eq!(row.parent.as_ref(), Some(table_id));

        for cell_id in &row.children {
            let cell = doc.get_block(cell_id).unwrap();
            assert!(cell.is_table_cell());
            assert_eq!(cell.colspan(), 1);
            assert_eq!(cell.rowspan(), 1);
            assert_eq!(cell.parent.as_ref(), Some(row_id));
        }
    }

    // Selection should be in first cell
    let sel = sel.unwrap();
    let first_row = &table.children[0];
    let first_cell = &doc.get_block(first_row).unwrap().children[0];
    assert_eq!(sel.anchor.id, *first_cell);
    assert_eq!(sel.anchor.offset, 0);
}

#[test]
fn insert_table_not_in_root() {
    // Verify rows and cells are NOT in root
    let mut doc = Document::new();
    let p = Block::new_paragraph(BlockId::new("p1"));
    doc.insert_block(p, 0);

    let sel = Selection::collapsed("p1", 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::InsertTable { rows: 2, cols: 2 },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    // Only 2 blocks in root: p1 + table
    assert_eq!(doc.root_block_count(), 2);

    // But many more blocks exist in the blocks map
    let table_id = &doc.root_block_ids()[1];
    let table = doc.get_block(table_id).unwrap();

    // Rows should be findable but not in root
    for row_id in &table.children {
        assert!(doc.get_block(row_id).is_some());
        assert!(doc.is_nested_block(row_id));
    }
}

#[test]
fn insert_table_rebuild_tree_preserves_nested() {
    // Verify rebuild_tree_metadata doesn't destroy table structure
    let mut doc = Document::new();
    let p = Block::new_paragraph(BlockId::new("p1"));
    doc.insert_block(p, 0);

    let sel = Selection::collapsed("p1", 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::InsertTable { rows: 2, cols: 2 },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    let table_id = doc.root_block_ids()[1].clone();
    let row_count_before = doc.get_block(&table_id).unwrap().children.len();

    // This is the critical test: rebuild should NOT destroy table children
    doc.rebuild_tree_metadata();

    let row_count_after = doc.get_block(&table_id).unwrap().children.len();
    assert_eq!(row_count_before, row_count_after);

    // Verify cells are still intact
    let table = doc.get_block(&table_id).unwrap();
    for row_id in &table.children {
        let row = doc.get_block(row_id).unwrap();
        assert_eq!(row.children.len(), 2);
        assert_eq!(row.parent.as_ref(), Some(&table_id));
    }
}

#[test]
fn table_cell_grid_returns_correct_grid() {
    let mut doc = Document::new();
    let p = Block::new_paragraph(BlockId::new("p1"));
    doc.insert_block(p, 0);

    let sel = Selection::collapsed("p1", 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::InsertTable { rows: 3, cols: 2 },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    let table_id = &doc.root_block_ids()[1];
    let grid = doc.table_cell_grid(table_id);

    assert_eq!(grid.len(), 3); // 3 rows
    for row in &grid {
        assert_eq!(row.len(), 2); // 2 cols each
    }
}

#[test]
fn backspace_in_cell_at_offset_0_is_noop() {
    let (doc, table_id) = doc_with_table(1, 1);
    let cell_id = table_grid(&doc, &table_id)[0][0].clone();
    let sel = Selection::collapsed(cell_id, 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(&doc, &sel, &Command::Backspace, &mut id_gen, None).unwrap();
    assert!(ops.is_empty());
}

#[test]
fn enter_in_cell_inserts_newline() {
    let (mut doc, table_id) = doc_with_table(1, 1);
    let cell_id = table_grid(&doc, &table_id)[0][0].clone();
    doc.get_block_mut(&cell_id).unwrap().content = vec![TextSpan::plain("ab")];
    let sel = Selection::collapsed(cell_id.clone(), 1);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(&doc, &sel, &Command::Enter, &mut id_gen, None).unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    assert_eq!(doc.plain_text(&cell_id), "a\nb");
    assert_eq!(sel.unwrap().anchor.offset, 2);
}

// ── Fenced code block: Enter / Tab keep the block open ──

#[test]
fn enter_in_code_inserts_newline_without_splitting() {
    let mut doc = Document::new();
    let mut code = Block::new(BlockId::new("c1"), "code");
    code.content = vec![TextSpan::plain("fn main() {}")];
    doc.insert_block(code, 0);

    let sel = Selection::collapsed("c1", 11); // between "{" and "}"
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(&doc, &sel, &Command::Enter, &mut id_gen, None).unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    // Block count unchanged — no split.
    assert_eq!(doc.root_block_count(), 1);
    // Newline inserted at the caret.
    assert_eq!(doc.plain_text(&BlockId::new("c1")), "fn main() {\n}");
    // Caret advanced past the inserted '\n'.
    assert_eq!(sel.unwrap().anchor.offset, 12);
}

#[test]
fn enter_on_fenced_code_opener_converts_paragraph_to_code_block() {
    let mut doc = Document::new();
    let mut paragraph = Block::new(BlockId::new("p1"), "paragraph");
    paragraph.content = vec![TextSpan::plain("```ts")];
    doc.insert_block(paragraph, 0);

    let sel = Selection::collapsed("p1", 5);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(&doc, &sel, &Command::Enter, &mut id_gen, None).unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    assert_eq!(doc.root_block_count(), 1);
    let block = doc.get_block(&BlockId::new("p1")).unwrap();
    assert!(block.is_code());
    assert_eq!(block.prop_str("language"), Some("ts"));
    assert_eq!(doc.plain_text(&BlockId::new("p1")), "");
    assert_eq!(sel.unwrap().anchor.offset, 0);
}

#[test]
fn enter_on_non_fence_paragraph_still_splits_normally() {
    let mut doc = Document::new();
    let mut paragraph = Block::new(BlockId::new("p1"), "paragraph");
    paragraph.content = vec![TextSpan::plain("abc```")];
    doc.insert_block(paragraph, 0);

    let sel = Selection::collapsed("p1", 6);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(&doc, &sel, &Command::Enter, &mut id_gen, None).unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    assert_eq!(doc.root_block_count(), 2);
    assert!(doc.get_block(&BlockId::new("p1")).unwrap().is_paragraph());
    assert_eq!(doc.plain_text(&BlockId::new("p1")), "abc```");
}

#[test]
fn enter_at_end_of_code_block_still_stays_inside() {
    let mut doc = Document::new();
    let mut code = Block::new(BlockId::new("c1"), "code");
    code.content = vec![TextSpan::plain("hi")];
    doc.insert_block(code, 0);

    let sel = Selection::collapsed("c1", 2); // at end
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(&doc, &sel, &Command::Enter, &mut id_gen, None).unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    // Still exactly one block — even at end, Enter inside code
    // does NOT split; that's what makes fenced-code behave like a
    // multi-line code editor.
    assert_eq!(doc.root_block_count(), 1);
    assert_eq!(doc.plain_text(&BlockId::new("c1")), "hi\n");
}

#[test]
fn enter_with_range_in_code_replaces_with_newline() {
    let mut doc = Document::new();
    let mut code = Block::new(BlockId::new("c1"), "code");
    code.content = vec![TextSpan::plain("abcde")];
    doc.insert_block(code, 0);

    // Select "bcd" (offsets 1..4) and press Enter.
    let sel = Selection {
        anchor: Position {
            id: BlockId::new("c1"),
            offset: 1,
        },
        focus: Position {
            id: BlockId::new("c1"),
            offset: 4,
        },
        cell_selection: None,
    };
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(&doc, &sel, &Command::Enter, &mut id_gen, None).unwrap();
    let mut sel_opt = Some(sel);
    apply_all(&mut doc, &mut sel_opt, &ops);

    // Range removed, newline inserted in its place, block stays.
    assert_eq!(doc.root_block_count(), 1);
    assert_eq!(doc.plain_text(&BlockId::new("c1")), "a\ne");
}

#[test]
fn tab_in_code_inserts_two_spaces_instead_of_indenting() {
    let mut doc = Document::new();
    let mut code = Block::new(BlockId::new("c1"), "code");
    code.content = vec![TextSpan::plain("line")];
    doc.insert_block(code, 0);

    let sel = Selection::collapsed("c1", 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(&doc, &sel, &Command::IndentBlock, &mut id_gen, None).unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    // No indent prop change — two-space string prepended instead.
    assert_eq!(doc.plain_text(&BlockId::new("c1")), "  line");
    assert_eq!(sel.unwrap().anchor.offset, 2);
    let block = doc.get_block(&BlockId::new("c1")).unwrap();
    // Still a code block.
    assert!(block.is_code());
    // No `indent` prop accidentally set.
    assert!(!block.props.contains_key("indent"));
}

#[test]
fn align_in_table_cell() {
    let (mut doc, table_id) = doc_with_table(1, 1);
    let cell_id = table_grid(&doc, &table_id)[0][0].clone();
    let sel = Selection::collapsed(cell_id.clone(), 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::SetBlockAlign {
            align: BlockAlignment::Center,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    assert_eq!(
        doc.get_block(&cell_id).unwrap().alignment(),
        BlockAlignment::Center
    );
    assert_eq!(
        doc.get_block(&table_id).unwrap().alignment(),
        BlockAlignment::Left
    );
}

#[test]
fn align_applies_to_all_selected_table_cells() {
    let (mut doc, table_id) = doc_with_table(2, 2);
    let grid = table_grid(&doc, &table_id);
    let sel = Selection::with_cell_selection(
        Position::new(grid[0][0].clone(), 0),
        Position::new(grid[1][1].clone(), 0),
        CellSelection::new(table_id.clone(), CellCoord::new(0, 0), CellCoord::new(1, 1)),
    );
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::SetBlockAlign {
            align: BlockAlignment::Center,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    for row in &grid {
        for cell_id in row {
            assert_eq!(
                doc.get_block(cell_id).unwrap().alignment(),
                BlockAlignment::Center,
            );
        }
    }
    assert_eq!(
        doc.get_block(&table_id).unwrap().alignment(),
        BlockAlignment::Left
    );
}

#[test]
fn table_navigate_next_cell() {
    let (mut doc, table_id) = doc_with_table(2, 2);
    let grid = table_grid(&doc, &table_id);
    let sel = Selection::collapsed(grid[0][0].clone(), 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::TableNavigate {
            direction: TableNavDirection::NextCell,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    assert_eq!(sel.unwrap().anchor.id, grid[0][1]);
}

#[test]
fn table_navigate_last_cell_adds_row() {
    let (mut doc, table_id) = doc_with_table(1, 2);
    let grid = table_grid(&doc, &table_id);
    let sel = Selection::collapsed(grid[0][1].clone(), 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::TableNavigate {
            direction: TableNavDirection::NextCell,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    let grid = table_grid(&doc, &table_id);
    assert_eq!(grid.len(), 2);
    assert_eq!(sel.unwrap().anchor.id, grid[1][0]);
}

#[test]
fn insert_table_row_above() {
    let (mut doc, table_id) = doc_with_table(2, 2);
    let first_row_before = doc.get_block(&table_id).unwrap().children[0].clone();
    let sel = Selection::collapsed(first_row_before.clone(), 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::InsertTableRow {
            table_id: table_id.clone(),
            row_index: 0,
            above: true,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    let table = doc.get_block(&table_id).unwrap();
    assert_eq!(table.children.len(), 3);
    assert_ne!(table.children[0], first_row_before);
    assert_eq!(doc.get_block(&table.children[0]).unwrap().children.len(), 2);
}

#[test]
fn insert_table_row_below() {
    let (mut doc, table_id) = doc_with_table(2, 2);
    let rows_before = doc.get_block(&table_id).unwrap().children.clone();
    let sel = Selection::collapsed(rows_before[0].clone(), 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::InsertTableRow {
            table_id: table_id.clone(),
            row_index: 0,
            above: false,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    let table = doc.get_block(&table_id).unwrap();
    assert_eq!(table.children.len(), 3);
    assert_eq!(table.children[0], rows_before[0]);
    assert_eq!(table.children[2], rows_before[1]);
}

#[test]
fn delete_table_row() {
    let (mut doc, table_id) = doc_with_table(2, 2);
    let rows_before = doc.get_block(&table_id).unwrap().children.clone();
    let deleted_row = doc.get_block(&rows_before[0]).unwrap().children.clone();
    let sel = Selection::collapsed(rows_before[0].clone(), 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::DeleteTableRow {
            table_id: table_id.clone(),
            row_index: 0,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    let table = doc.get_block(&table_id).unwrap();
    assert_eq!(table.children.len(), 1);
    assert_eq!(table.children[0], rows_before[1]);
    assert!(doc.get_block(&rows_before[0]).is_none());
    for cell_id in deleted_row {
        assert!(doc.get_block(&cell_id).is_none());
    }
}

#[test]
fn delete_last_row_deletes_table() {
    let (mut doc, table_id) = doc_with_table(1, 2);
    let row_id = doc.get_block(&table_id).unwrap().children[0].clone();
    let sel = Selection::collapsed(row_id, 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::DeleteTableRow {
            table_id: table_id.clone(),
            row_index: 0,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    assert!(doc.get_block(&table_id).is_none());
    assert_eq!(doc.root_block_count(), 2);
    assert!(
        doc.get_block(&doc.root_block_ids()[1])
            .unwrap()
            .is_paragraph()
    );
}

#[test]
fn insert_table_col() {
    let (mut doc, table_id) = doc_with_table(2, 2);
    let sel = Selection::collapsed(table_grid(&doc, &table_id)[0][0].clone(), 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::InsertTableCol {
            table_id: table_id.clone(),
            col_index: 0,
            left: false,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    let grid = table_grid(&doc, &table_id);
    assert_eq!(grid[0].len(), 3);
    assert_eq!(grid[1].len(), 3);
    let columns = doc
        .get_block(&table_id)
        .unwrap()
        .props
        .get("columns")
        .unwrap();
    assert_eq!(columns.as_array().unwrap().len(), 3);
}

#[test]
fn delete_table_col() {
    let (mut doc, table_id) = doc_with_table(2, 2);
    let removed_cells: Vec<BlockId> = table_grid(&doc, &table_id)
        .iter()
        .map(|row| row[1].clone())
        .collect();
    let sel = Selection::collapsed(table_grid(&doc, &table_id)[0][0].clone(), 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::DeleteTableCol {
            table_id: table_id.clone(),
            col_index: 1,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    let grid = table_grid(&doc, &table_id);
    assert_eq!(grid[0].len(), 1);
    assert_eq!(grid[1].len(), 1);
    for cell_id in removed_cells {
        assert!(doc.get_block(&cell_id).is_none());
    }
}

#[test]
fn merge_cells_2x2() {
    let (mut doc, table_id) = doc_with_table(2, 2);
    let grid = table_grid(&doc, &table_id);
    doc.get_block_mut(&grid[0][0]).unwrap().content = vec![TextSpan::plain("a")];
    doc.get_block_mut(&grid[0][1]).unwrap().content = vec![TextSpan::plain("b")];
    doc.get_block_mut(&grid[1][0]).unwrap().content = vec![TextSpan::plain("c")];
    doc.get_block_mut(&grid[1][1]).unwrap().content = vec![TextSpan::plain("d")];
    let sel = Selection::with_cell_selection(
        Position::new(grid[0][0].clone(), 0),
        Position::new(grid[1][1].clone(), 0),
        CellSelection::new(table_id.clone(), CellCoord::new(0, 0), CellCoord::new(1, 1)),
    );
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::MergeCells {
            table_id: table_id.clone(),
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    let master = doc.get_block(&grid[0][0]).unwrap();
    assert_eq!(master.colspan(), 2);
    assert_eq!(master.rowspan(), 2);
    assert_eq!(master.plain_text(), "a\nb\nc\nd");
    assert_eq!(
        doc.get_block(&grid[0][1]).unwrap().covered_by(),
        Some(grid[0][0].0.as_str())
    );
    assert_eq!(
        doc.get_block(&grid[1][0]).unwrap().covered_by(),
        Some(grid[0][0].0.as_str())
    );
    assert_eq!(
        doc.get_block(&grid[1][1]).unwrap().covered_by(),
        Some(grid[0][0].0.as_str())
    );
}

#[test]
fn unmerge_cells() {
    let (mut doc, table_id) = doc_with_table(2, 2);
    let grid = table_grid(&doc, &table_id);
    let master_id = grid[0][0].clone();
    doc.get_block_mut(&master_id)
        .unwrap()
        .props
        .insert("colspan".into(), Value::from(2));
    doc.get_block_mut(&master_id)
        .unwrap()
        .props
        .insert("rowspan".into(), Value::from(2));
    for cell_id in [&grid[0][1], &grid[1][0], &grid[1][1]] {
        doc.get_block_mut(cell_id).unwrap().props.insert(
            prop_keys::COVERED_BY.into(),
            Value::String(master_id.0.clone()),
        );
    }
    let sel = Selection::collapsed(master_id.clone(), 0);
    let mut id_gen = SequentialIdGenerator::new("cmd");

    let ops = transform(
        &doc,
        &sel,
        &Command::UnmergeCells {
            table_id: table_id.clone(),
            cell_id: master_id.clone(),
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    let master = doc.get_block(&master_id).unwrap();
    assert_eq!(master.colspan(), 1);
    assert_eq!(master.rowspan(), 1);
    for cell_id in [&grid[0][1], &grid[1][0], &grid[1][1]] {
        assert!(!doc.get_block(cell_id).unwrap().is_covered_cell());
    }
}

#[test]
fn set_cell_props() {
    let (mut doc, table_id) = doc_with_table(2, 2);
    let grid = table_grid(&doc, &table_id);
    let sel = Selection::with_cell_selection(
        Position::new(grid[0][0].clone(), 0),
        Position::new(grid[0][1].clone(), 0),
        CellSelection::new(table_id.clone(), CellCoord::new(0, 0), CellCoord::new(0, 1)),
    );
    let mut props = Map::new();
    props.insert("background".into(), Value::String("yellow".into()));
    let mut id_gen = SequentialIdGenerator::new("t");

    let ops = transform(
        &doc,
        &sel,
        &Command::SetCellProps {
            cell_ids: vec![grid[1][1].clone()],
            props: props.clone(),
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    for cell_id in [&grid[0][0], &grid[0][1], &grid[1][1]] {
        assert_eq!(
            doc.get_block(cell_id).unwrap().prop_str("background"),
            Some("yellow")
        );
    }
    assert_eq!(
        doc.get_block(&grid[1][0]).unwrap().prop_str("background"),
        None
    );
}

#[test]
fn set_table_props_applies_to_table_block() {
    let (mut doc, table_id) = doc_with_table(2, 2);
    let grid = table_grid(&doc, &table_id);
    let sel = Selection::collapsed(grid[0][0].clone(), 0);
    let mut props = Map::new();
    props.insert("backgroundColor".into(), Value::String("#fef3c7".into()));
    props.insert("borderColor".into(), Value::String("#f59e0b".into()));
    props.insert("borderWidth".into(), Value::String("2px".into()));
    props.insert("borderStyle".into(), Value::String("dashed".into()));
    let mut id_gen = SequentialIdGenerator::new("t");

    let ops = transform(
        &doc,
        &sel,
        &Command::SetTableProps {
            table_id: table_id.clone(),
            props: props.clone(),
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    let table = doc.get_block(&table_id).unwrap();
    assert_eq!(table.prop_str("backgroundColor"), Some("#fef3c7"));
    assert_eq!(table.prop_str("borderColor"), Some("#f59e0b"));
    assert_eq!(table.prop_str("borderWidth"), Some("2px"));
    assert_eq!(table.prop_str("borderStyle"), Some("dashed"));

    // Cells are left alone — cell-level overrides are the user's business.
    for row in &grid {
        for cell_id in row {
            assert_eq!(
                doc.get_block(cell_id).unwrap().prop_str("backgroundColor"),
                None
            );
        }
    }
}

#[test]
fn set_table_props_filters_unchanged_keys() {
    let (mut doc, table_id) = doc_with_table(2, 2);
    // Pre-populate the table with an existing background color.
    doc.get_block_mut(&table_id)
        .unwrap()
        .props
        .insert("backgroundColor".into(), Value::String("#fef3c7".into()));
    let sel = Selection::collapsed(table_id.clone(), 0);
    let mut id_gen = SequentialIdGenerator::new("t");

    // Dispatch the SAME color — should produce zero ops (no-op).
    let mut same = Map::new();
    same.insert("backgroundColor".into(), Value::String("#fef3c7".into()));
    let ops = transform(
        &doc,
        &sel,
        &Command::SetTableProps {
            table_id: table_id.clone(),
            props: same,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    assert!(ops.is_empty(), "expected no-op, got {ops:?}");

    // Now dispatch a mix: one changed, one unchanged. Only the changed
    // one should end up in the generated SetBlockProps op.
    let mut mixed = Map::new();
    mixed.insert("backgroundColor".into(), Value::String("#fef3c7".into()));
    mixed.insert("borderColor".into(), Value::String("#111827".into()));
    let ops = transform(
        &doc,
        &sel,
        &Command::SetTableProps {
            table_id,
            props: mixed,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    assert_eq!(ops.len(), 1);
    match &ops[0] {
        Operation::SetBlockProps { props, .. } => {
            assert_eq!(props.len(), 1, "expected only changed key, got {props:?}");
            assert!(props.contains_key("borderColor"));
        }
        other => panic!("expected SetBlockProps, got {other:?}"),
    }
}
