//! 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 insert_text_at_cursor() {
    let mut doc = doc_with_text("b1", "hello");
    let sel = Selection::collapsed("b1", 5);
    let mut id_gen = SequentialIdGenerator::new("test");

    let ops = transform(
        &doc,
        &sel,
        &Command::InsertText {
            text: " world".into(),
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    assert_eq!(doc.plain_text(&BlockId::new("b1")), "hello world");
    assert_eq!(sel.unwrap().anchor.offset, 11);
}

#[test]
fn backspace_deletes_char() {
    let mut doc = doc_with_text("b1", "hello");
    let sel = Selection::collapsed("b1", 5);
    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);

    assert_eq!(doc.plain_text(&BlockId::new("b1")), "hell");
    assert_eq!(sel.unwrap().anchor.offset, 4);
}

#[test]
fn backspace_at_start_merges() {
    let mut doc = Document::new();
    let mut b1 = Block::new_paragraph(BlockId::new("b1"));
    b1.content = vec![TextSpan::plain("hello")];
    let mut b2 = Block::new_paragraph(BlockId::new("b2"));
    b2.content = vec![TextSpan::plain("world")];
    doc.insert_block(b1, 0);
    doc.insert_block(b2, 1);

    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();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    assert_eq!(doc.root_block_count(), 1);
    assert_eq!(doc.plain_text(&BlockId::new("b1")), "helloworld");
    assert_eq!(sel.unwrap().anchor.offset, 5);
}

#[test]
fn delete_before_divider_removes_divider_block() {
    let mut doc = Document::new();
    let mut before = Block::new_paragraph(BlockId::new("before"));
    before.content = vec![TextSpan::plain("Above")];
    let divider = Block::new_divider(BlockId::new("divider"));
    let mut after = Block::new_paragraph(BlockId::new("after"));
    after.content = vec![TextSpan::plain("Below")];
    doc.insert_block(before, 0);
    doc.insert_block(divider, 1);
    doc.insert_block(after, 2);

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

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

    assert!(
        ops.iter().any(|op| matches!(
            op,
            Operation::RemoveBlock { block_id } if block_id == &BlockId::new("divider")
        )),
        "Delete before a divider must remove the void block, not merge it into text"
    );
    assert!(
        !ops.iter().any(|op| matches!(
            op,
            Operation::MergeBlock { source_id, .. } if source_id == &BlockId::new("divider")
        )),
        "void dividers must not be represented as MergeBlock operations"
    );
}

#[test]
fn backspace_after_divider_removes_divider_block() {
    let mut doc = Document::new();
    let mut before = Block::new_paragraph(BlockId::new("before"));
    before.content = vec![TextSpan::plain("Above")];
    let divider = Block::new_divider(BlockId::new("divider"));
    let mut after = Block::new_paragraph(BlockId::new("after"));
    after.content = vec![TextSpan::plain("Below")];
    doc.insert_block(before, 0);
    doc.insert_block(divider, 1);
    doc.insert_block(after, 2);

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

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

    assert!(
        ops.iter().any(|op| matches!(
            op,
            Operation::RemoveBlock { block_id } if block_id == &BlockId::new("divider")
        )),
        "Backspace after a divider must remove the void block before merging paragraphs"
    );
    assert!(
        !ops.iter().any(|op| matches!(
            op,
            Operation::MergeBlock { target_id, .. } if target_id == &BlockId::new("divider")
        )),
        "paragraph text must not be merged into a void divider"
    );
}

#[test]
fn undo_restores_divider_removed_by_delete() {
    let mut doc = Document::new();
    let mut before = Block::new_paragraph(BlockId::new("before"));
    before.content = vec![TextSpan::plain("Above")];
    let divider = Block::new_divider(BlockId::new("divider"));
    let mut after = Block::new_paragraph(BlockId::new("after"));
    after.content = vec![TextSpan::plain("Below")];
    doc.insert_block(before, 0);
    doc.insert_block(divider, 1);
    doc.insert_block(after, 2);

    let mut engine = EditorEngine::from_document(doc, Box::new(SequentialIdGenerator::new("test")));
    engine.set_selection(Some(Selection::collapsed("before", 5)));
    engine.execute(Command::Delete).unwrap();
    assert_eq!(
        engine.document().root_block_ids(),
        &[BlockId::new("before"), BlockId::new("after")]
    );

    assert!(engine.undo().unwrap());

    assert_eq!(
        engine.document().root_block_ids(),
        &[
            BlockId::new("before"),
            BlockId::new("divider"),
            BlockId::new("after")
        ]
    );
    assert!(
        engine
            .document()
            .get_block(&BlockId::new("divider"))
            .unwrap()
            .is_divider()
    );
    assert_eq!(
        engine.selection().as_ref().unwrap().anchor,
        Position::new("before", 5)
    );
}

#[test]
fn backspace_deletes_cross_block_text_selection() {
    let mut doc = Document::new();
    let mut a = Block::new_paragraph(BlockId::new("a"));
    a.content = vec![TextSpan::plain("Alpha")];
    let mut b = Block::new_paragraph(BlockId::new("b"));
    b.content = vec![TextSpan::plain("Bravo")];
    let mut c = Block::new_paragraph(BlockId::new("c"));
    c.content = vec![TextSpan::plain("Charlie")];
    doc.insert_block(a, 0);
    doc.insert_block(b, 1);
    doc.insert_block(c, 2);

    let sel = Selection::new(Position::new("a", 2), Position::new("c", 3));
    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);

    let sel = sel.unwrap();
    assert_eq!(doc.root_block_ids(), &[BlockId::new("a")]);
    assert_eq!(doc.plain_text(&BlockId::new("a")), "Alrlie");
    assert_eq!(sel.anchor, Position::new("a", 2));
    assert_eq!(sel.focus, Position::new("a", 2));
}

#[test]
fn delete_handles_reversed_cross_block_text_selection() {
    let mut doc = Document::new();
    let mut a = Block::new_paragraph(BlockId::new("a"));
    a.content = vec![TextSpan::plain("Alpha")];
    let mut b = Block::new_paragraph(BlockId::new("b"));
    b.content = vec![TextSpan::plain("Bravo")];
    let mut c = Block::new_paragraph(BlockId::new("c"));
    c.content = vec![TextSpan::plain("Charlie")];
    doc.insert_block(a, 0);
    doc.insert_block(b, 1);
    doc.insert_block(c, 2);

    let sel = Selection::new(Position::new("c", 3), Position::new("a", 2));
    let mut id_gen = SequentialIdGenerator::new("test");

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

    let sel = sel.unwrap();
    assert_eq!(doc.root_block_ids(), &[BlockId::new("a")]);
    assert_eq!(doc.plain_text(&BlockId::new("a")), "Alrlie");
    assert_eq!(sel.anchor, Position::new("a", 2));
    assert_eq!(sel.focus, Position::new("a", 2));
}

#[test]
fn backspace_at_start_of_indented_block_outdents() {
    // Indented paragraph → Backspace at offset 0 should outdent one
    // level instead of merging with the previous block.
    let mut doc = Document::new();
    let mut b1 = Block::new_paragraph(BlockId::new("b1"));
    b1.content = vec![TextSpan::plain("parent")];
    let mut b2 = Block::new_paragraph(BlockId::new("b2"));
    b2.content = vec![TextSpan::plain("child")];
    b2.props.insert(prop_keys::INDENT.into(), Value::from(1));
    doc.insert_block(b1, 0);
    doc.insert_block(b2, 1);
    doc.rebuild_tree_metadata();

    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();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    // Both blocks still exist, b2's indent is now 0, text unchanged.
    assert_eq!(doc.root_block_count(), 2);
    assert_eq!(doc.plain_text(&BlockId::new("b2")), "child");
    let b2_after = doc.get_block(&BlockId::new("b2")).unwrap();
    assert_eq!(b2_after.indent_level(), 0);
}

#[test]
fn backspace_at_start_of_indent2_outdents_to_indent1() {
    // Two-level indent → Backspace outdents to one level, doesn't
    // jump straight to 0.
    let mut doc = Document::new();
    let mut b1 = Block::new_paragraph(BlockId::new("b1"));
    b1.content = vec![TextSpan::plain("parent")];
    let mut b2 = Block::new_paragraph(BlockId::new("b2"));
    b2.content = vec![TextSpan::plain("mid")];
    b2.props.insert(prop_keys::INDENT.into(), Value::from(1));
    let mut b3 = Block::new_paragraph(BlockId::new("b3"));
    b3.content = vec![TextSpan::plain("deep")];
    b3.props.insert(prop_keys::INDENT.into(), Value::from(2));
    doc.insert_block(b1, 0);
    doc.insert_block(b2, 1);
    doc.insert_block(b3, 2);
    doc.rebuild_tree_metadata();

    let sel = Selection::collapsed("b3", 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);

    let b3_after = doc.get_block(&BlockId::new("b3")).unwrap();
    assert_eq!(b3_after.indent_level(), 1);
    assert_eq!(doc.root_block_count(), 3);
}

#[test]
fn enter_splits_block() {
    let mut doc = doc_with_text("b1", "hello world");
    let sel = Selection::collapsed("b1", 5);
    let mut id_gen = SequentialIdGenerator::new("test");

    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_eq!(doc.plain_text(&BlockId::new("b1")), "hello");
    assert_eq!(doc.plain_text(&BlockId::new("test-1")), " world");
}

#[test]
fn enter_at_end_of_heading_creates_paragraph() {
    let mut doc = Document::new();
    let mut block = Block::new_heading(BlockId::new("b1"), 1);
    block.content = vec![TextSpan::plain("Title")];
    doc.insert_block(block, 0);

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

    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);
    let new_block = doc.get_block(&BlockId::new("test-1")).unwrap();
    assert!(new_block.is_paragraph());
}

#[test]
fn enter_at_start_inserts_block_before() {
    let mut doc = doc_with_text("b1", "hello");
    let sel = Selection::collapsed("b1", 0);
    let mut id_gen = SequentialIdGenerator::new("test");

    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);
    // New empty paragraph inserted before b1
    assert_eq!(doc.plain_text(&BlockId::new("test-1")), "");
    assert_eq!(doc.plain_text(&BlockId::new("b1")), "hello");
    // Cursor remains on b1
    assert_eq!(sel.unwrap().anchor.id, BlockId::new("b1"));
}

#[test]
fn enter_at_start_of_todo_inserts_todo_before() {
    let mut doc = Document::new();
    let mut block = Block::new_todo(BlockId::new("b1"), true);
    block.content = vec![TextSpan::plain("task")];
    doc.insert_block(block, 0);

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

    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);
    let new_block = doc.get_block(&BlockId::new("test-1")).unwrap();
    assert!(new_block.is_todo());
    assert!(!new_block.is_checked()); // new todo starts unchecked
    assert!(new_block.content.is_empty());
    // Original b1 still has its content
    assert_eq!(doc.plain_text(&BlockId::new("b1")), "task");
}

#[test]
fn enter_at_start_of_heading_inserts_paragraph_before() {
    let mut doc = Document::new();
    let mut block = Block::new_heading(BlockId::new("b1"), 1);
    block.content = vec![TextSpan::plain("Title")];
    doc.insert_block(block, 0);

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

    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);
    let new_block = doc.get_block(&BlockId::new("test-1")).unwrap();
    assert!(new_block.is_paragraph()); // heading → paragraph before
    let b1 = doc.get_block(&BlockId::new("b1")).unwrap();
    assert!(b1.is_heading()); // original stays as heading
}

#[test]
fn enter_on_empty_todo_unwraps() {
    let mut doc = Document::new();
    let block = Block::new_todo(BlockId::new("b1"), false);
    doc.insert_block(block, 0);

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

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

    let block = doc.get_block(&BlockId::new("b1")).unwrap();
    assert!(block.is_paragraph());
    assert_eq!(doc.root_block_count(), 1);
}

#[test]
fn enter_on_divider_inserts_paragraph_after_and_moves_cursor() {
    // Regression guard: a void block (divider) has no splittable
    // content. Without special handling, Enter would fall through to
    // the default split path and produce two back-to-back dividers
    // with the cursor stuck on the second one. The transform must
    // instead insert a fresh paragraph AFTER the divider and move
    // the cursor there so the user can continue typing.
    let mut doc = Document::new();
    doc.insert_block(Block::new_divider(BlockId::new("d1")), 0);

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

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

    // Two blocks total: the divider (untouched) and a new paragraph.
    assert_eq!(doc.root_block_count(), 2);
    let divider = doc.get_block(&BlockId::new("d1")).unwrap();
    assert!(divider.is_divider(), "original divider must survive");

    let new_block = doc.get_block(&BlockId::new("test-1")).unwrap();
    assert!(new_block.is_paragraph(), "new block must be a paragraph");
    assert!(new_block.content.is_empty(), "new paragraph must be empty");

    // Cursor must land on the new paragraph.
    let cursor = sel.unwrap();
    assert_eq!(cursor.anchor.id, BlockId::new("test-1"));
    assert_eq!(cursor.anchor.offset, 0);
}

#[test]
fn enter_on_divider_between_two_blocks_preserves_order() {
    // The paragraph must be inserted at `divider_index + 1`, not at
    // the end of the root list. If this test fails, it means the
    // void-block branch is using the wrong insertion index.
    let mut doc = Document::new();
    let mut before = Block::new_paragraph(BlockId::new("before"));
    before.content = vec![TextSpan::plain("above")];
    doc.insert_block(before, 0);
    doc.insert_block(Block::new_divider(BlockId::new("d1")), 1);
    let mut after = Block::new_paragraph(BlockId::new("after"));
    after.content = vec![TextSpan::plain("below")];
    doc.insert_block(after, 2);

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

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

    // Expected order: before, divider, NEW paragraph, after.
    assert_eq!(doc.root_block_count(), 4);
    let ids: Vec<String> = doc
        .root_block_ids()
        .iter()
        .map(|id| id.to_string())
        .collect();
    assert_eq!(
        ids,
        vec![
            "before".to_string(),
            "d1".to_string(),
            "test-1".to_string(),
            "after".to_string(),
        ]
    );

    // Cursor on the new paragraph, not on "after".
    assert_eq!(sel.unwrap().anchor.id, BlockId::new("test-1"));
}

#[test]
fn toggle_todo() {
    let mut doc = doc_with_text("b1", "hello");
    let sel = Selection::collapsed("b1", 0);
    let mut id_gen = SequentialIdGenerator::new("test");

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

    let block = doc.get_block(&BlockId::new("b1")).unwrap();
    assert!(block.is_todo());
    assert!(!block.is_checked());

    // Toggle again → paragraph
    let ops = transform(
        &doc,
        sel.as_ref().unwrap(),
        &Command::ToggleTodo,
        &mut id_gen,
        None,
    )
    .unwrap();
    apply_all(&mut doc, &mut sel, &ops);
    let block = doc.get_block(&BlockId::new("b1")).unwrap();
    assert!(block.is_paragraph());
}

#[test]
fn insert_korean_text() {
    let mut doc = doc_with_text("b1", "");
    let sel = Selection::collapsed("b1", 0);
    let mut id_gen = SequentialIdGenerator::new("test");

    let ops = transform(
        &doc,
        &sel,
        &Command::InsertText {
            text: "한글".into(),
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    assert_eq!(doc.plain_text(&BlockId::new("b1")), "한글");
    assert_eq!(sel.unwrap().anchor.offset, 2); // 2 chars, not 6 bytes
}

#[test]
fn backspace_korean_text() {
    let mut doc = doc_with_text("b1", "안녕하세요");
    let sel = Selection::collapsed("b1", 5); // after "요"
    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);

    assert_eq!(doc.plain_text(&BlockId::new("b1")), "안녕하세");
    assert_eq!(sel.unwrap().anchor.offset, 4);
}

#[test]
fn backspace_mixed_ascii_korean() {
    let mut doc = doc_with_text("b1", "Hello 한글");
    let sel = Selection::collapsed("b1", 8); // after "글"
    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);

    assert_eq!(doc.plain_text(&BlockId::new("b1")), "Hello 한");
    assert_eq!(sel.unwrap().anchor.offset, 7);
}

#[test]
fn emoji_is_counted_as_two_utf16_units() {
    let mut doc = doc_with_text("b1", "");
    let sel = Selection::collapsed("b1", 0);
    let mut id_gen = SequentialIdGenerator::new("test");

    let ops = transform(
        &doc,
        &sel,
        &Command::InsertText {
            text: "a😀b".into(),
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    assert_eq!(doc.plain_text(&BlockId::new("b1")), "a😀b");
    assert_eq!(doc.text_len(&BlockId::new("b1")), 4);
    assert_eq!(sel.unwrap().anchor.offset, 4);
}

#[test]
fn backspace_removes_full_emoji() {
    let mut doc = doc_with_text("b1", "a😀b");
    let sel = Selection::collapsed("b1", 3); // after "😀"
    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);

    assert_eq!(doc.plain_text(&BlockId::new("b1")), "ab");
    assert_eq!(sel.unwrap().anchor.offset, 1);
}

#[test]
fn delete_removes_full_emoji() {
    let mut doc = doc_with_text("b1", "a😀b");
    let sel = Selection::collapsed("b1", 1); // before "😀"
    let mut id_gen = SequentialIdGenerator::new("test");

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

    assert_eq!(doc.plain_text(&BlockId::new("b1")), "ab");
    assert_eq!(sel.unwrap().anchor.offset, 1);
}

#[test]
fn delete_range_korean() {
    let mut doc = doc_with_text("b1", "가나다라마");
    let sel = Selection::new(Position::new("b1", 1), Position::new("b1", 3)); // "나다" selected
    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);

    assert_eq!(doc.plain_text(&BlockId::new("b1")), "가라마");
    assert_eq!(sel.unwrap().anchor.offset, 1);
}

#[test]
fn backspace_on_heading_converts_to_paragraph() {
    let mut doc = Document::new();
    let mut block = Block::new_heading(BlockId::new("b1"), 2);
    block.content = vec![TextSpan::plain("Title")];
    doc.insert_block(block, 0);

    let sel = Selection::collapsed("b1", 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);

    let block = doc.get_block(&BlockId::new("b1")).unwrap();
    assert!(block.is_paragraph());
    assert_eq!(block.plain_text(), "Title");
}

#[test]
fn set_custom_block_type() {
    let mut doc = doc_with_text("b1", "note");
    let sel = Selection::collapsed("b1", 0);
    let mut id_gen = SequentialIdGenerator::new("test");

    let mut props = Map::new();
    props.insert("variant".into(), Value::String("warning".into()));
    props.insert("tag".into(), Value::String("aside".into()));

    let ops = transform(
        &doc,
        &sel,
        &Command::SetBlockType {
            block_type: "callout".into(),
            props: props.clone(),
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    let block = doc.get_block(&BlockId::new("b1")).unwrap();
    assert!(block.is_type("callout"));
    assert_eq!(block.prop_str("variant"), Some("warning"));
    assert_eq!(block.tag(), Some("aside"));
}

fn list_block(id: &str, indent: i64) -> Block {
    let mut block = Block::new(BlockId::new(id), "list");
    block.content = vec![TextSpan::plain("item")];
    block
        .props
        .insert(prop_keys::INDENT.into(), Value::from(indent));
    block
}

fn doc_with_lists(blocks: Vec<Block>) -> Document {
    let mut doc = Document::new();
    for (i, b) in blocks.into_iter().enumerate() {
        doc.insert_block(b, i);
    }
    // Tree metadata is required by descendants_of() — keep test fixtures
    // in sync with what the engine guarantees after every command.
    doc.rebuild_tree_metadata();
    doc
}

#[test]
fn indent_block_requires_prev_list() {
    // First block: no previous sibling → no indent
    let mut doc = doc_with_lists(vec![list_block("b1", 0), list_block("b2", 0)]);
    let sel = Selection::collapsed("b1", 0);
    let mut id_gen = SequentialIdGenerator::new("test");

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

    // Second block with first as list sibling → indent
    let sel = Selection::collapsed("b2", 0);
    let ops = transform(&doc, &sel, &Command::IndentBlock, &mut id_gen, None).unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    let b2 = doc.get_block(&BlockId::new("b2")).unwrap();
    assert_eq!(b2.indent_level(), 1);
}

#[test]
fn indent_block_works_on_any_type() {
    // Paragraph can indent under another paragraph
    let mut doc = Document::new();
    let mut p1 = Block::new_paragraph(BlockId::new("p1"));
    p1.content = vec![TextSpan::plain("first")];
    let mut p2 = Block::new_paragraph(BlockId::new("p2"));
    p2.content = vec![TextSpan::plain("second")];
    doc.insert_block(p1, 0);
    doc.insert_block(p2, 1);

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

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

    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);
    assert_eq!(
        doc.get_block(&BlockId::new("p2")).unwrap().indent_level(),
        1
    );
}

#[test]
fn indent_block_moves_descendants() {
    let mut doc = doc_with_lists(vec![
        list_block("b1", 0),
        list_block("b2", 0),
        list_block("b3", 1), // child of b2
        list_block("b4", 2), // grandchild of b2
        list_block("b5", 0),
    ]);
    let sel = Selection::collapsed("b2", 0);
    let mut id_gen = SequentialIdGenerator::new("test");

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

    assert_eq!(
        doc.get_block(&BlockId::new("b2")).unwrap().indent_level(),
        1
    );
    assert_eq!(
        doc.get_block(&BlockId::new("b3")).unwrap().indent_level(),
        2
    );
    assert_eq!(
        doc.get_block(&BlockId::new("b4")).unwrap().indent_level(),
        3
    );
    // b5 should not be affected (different chain)
    assert_eq!(
        doc.get_block(&BlockId::new("b5")).unwrap().indent_level(),
        0
    );
}

#[test]
fn outdent_block_moves_descendants() {
    let mut doc = doc_with_lists(vec![
        list_block("b1", 0),
        list_block("b2", 1),
        list_block("b3", 2),
    ]);
    let sel = Selection::collapsed("b2", 0);
    let mut id_gen = SequentialIdGenerator::new("test");

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

    assert_eq!(
        doc.get_block(&BlockId::new("b2")).unwrap().indent_level(),
        0
    );
    assert_eq!(
        doc.get_block(&BlockId::new("b3")).unwrap().indent_level(),
        1
    );
}

#[test]
fn outdent_block_at_zero_no_op() {
    let doc = doc_with_lists(vec![list_block("b1", 0)]);
    let sel = Selection::collapsed("b1", 0);
    let mut id_gen = SequentialIdGenerator::new("test");

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

#[test]
fn indent_block_walks_real_children_tree() {
    // Verifies the tree-based migration: indent uses Block.children
    // (rebuilt from indent metadata) instead of scanning the root array.
    //
    //   a (0)
    //   b (0) ← indent this
    //     c (1) — child of b
    //       d (2) — grandchild of b
    //     e (1) — child of b
    //   f (0)
    let mut doc = doc_with_lists(vec![
        list_block("a", 0),
        list_block("b", 0),
        list_block("c", 1),
        list_block("d", 2),
        list_block("e", 1),
        list_block("f", 0),
    ]);

    // Sanity check: real tree metadata is populated
    let b_block = doc.get_block(&BlockId::new("b")).unwrap();
    assert_eq!(b_block.children, vec![BlockId::new("c"), BlockId::new("e")]);
    let descendants = doc.descendants_of(&BlockId::new("b"));
    assert_eq!(
        descendants,
        vec![BlockId::new("c"), BlockId::new("d"), BlockId::new("e"),]
    );

    // Indent b → all of c, d, e should also bump
    let sel = Selection::collapsed("b", 0);
    let mut id_gen = SequentialIdGenerator::new("test");
    let ops = transform(&doc, &sel, &Command::IndentBlock, &mut id_gen, None).unwrap();
    let mut sel = Some(sel);
    apply_all(&mut doc, &mut sel, &ops);

    assert_eq!(doc.get_block(&BlockId::new("a")).unwrap().indent_level(), 0);
    assert_eq!(doc.get_block(&BlockId::new("b")).unwrap().indent_level(), 1);
    assert_eq!(doc.get_block(&BlockId::new("c")).unwrap().indent_level(), 2);
    assert_eq!(doc.get_block(&BlockId::new("d")).unwrap().indent_level(), 3);
    assert_eq!(doc.get_block(&BlockId::new("e")).unwrap().indent_level(), 2);
    assert_eq!(doc.get_block(&BlockId::new("f")).unwrap().indent_level(), 0);
}

#[test]
fn set_block_align_single_block() {
    let mut doc = doc_with_text("b1", "hello");
    let sel = Selection::collapsed("b1", 2);
    let mut id_gen = SequentialIdGenerator::new("test");

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

    assert_eq!(ops.len(), 1);
    let mut sel_opt = Some(sel);
    apply_all(&mut doc, &mut sel_opt, &ops);

    let block = doc.get_block(&BlockId::new("b1")).unwrap();
    assert_eq!(block.alignment(), BlockAlignment::Center);
    assert_eq!(block.prop_str("align"), Some("center"));
}

#[test]
fn set_block_align_left_clears_prop() {
    let mut doc = Document::new();
    let mut block = Block::new_paragraph(BlockId::new("b1"));
    block
        .props
        .insert("align".into(), Value::String("right".into()));
    block.content = vec![TextSpan::plain("hi")];
    doc.insert_block(block, 0);

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

    let ops = transform(
        &doc,
        &sel,
        &Command::SetBlockAlign {
            align: BlockAlignment::Left,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    assert_eq!(ops.len(), 1);

    let mut sel_opt = Some(sel);
    apply_all(&mut doc, &mut sel_opt, &ops);

    let block = doc.get_block(&BlockId::new("b1")).unwrap();
    assert_eq!(block.alignment(), BlockAlignment::Left);
    // The prop should be removed — Left is the default.
    assert!(block.prop("align").is_none());
}

#[test]
fn set_block_align_no_op_when_already_aligned() {
    let mut doc = Document::new();
    let mut block = Block::new_paragraph(BlockId::new("b1"));
    block
        .props
        .insert("align".into(), Value::String("center".into()));
    block.content = vec![TextSpan::plain("hi")];
    doc.insert_block(block, 0);

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

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

#[test]
fn set_block_align_multi_block_selection() {
    let mut doc = Document::new();
    let mut b1 = Block::new_paragraph(BlockId::new("b1"));
    b1.content = vec![TextSpan::plain("one")];
    let mut b2 = Block::new_paragraph(BlockId::new("b2"));
    b2.content = vec![TextSpan::plain("two")];
    let mut b3 = Block::new_paragraph(BlockId::new("b3"));
    b3.content = vec![TextSpan::plain("three")];
    doc.insert_block(b1, 0);
    doc.insert_block(b2, 1);
    doc.insert_block(b3, 2);

    let sel = Selection::new(Position::new("b1", 1), Position::new("b3", 2));
    let mut id_gen = SequentialIdGenerator::new("test");

    let ops = transform(
        &doc,
        &sel,
        &Command::SetBlockAlign {
            align: BlockAlignment::Right,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    assert_eq!(ops.len(), 3);

    let mut sel_opt = Some(sel);
    apply_all(&mut doc, &mut sel_opt, &ops);

    assert_eq!(
        doc.get_block(&BlockId::new("b1")).unwrap().alignment(),
        BlockAlignment::Right
    );
    assert_eq!(
        doc.get_block(&BlockId::new("b2")).unwrap().alignment(),
        BlockAlignment::Right
    );
    assert_eq!(
        doc.get_block(&BlockId::new("b3")).unwrap().alignment(),
        BlockAlignment::Right
    );
}

#[test]
fn set_block_align_handles_reversed_selection() {
    let mut doc = Document::new();
    let b1 = Block::new_paragraph(BlockId::new("b1"));
    let b2 = Block::new_paragraph(BlockId::new("b2"));
    doc.insert_block(b1, 0);
    doc.insert_block(b2, 1);

    // Focus comes BEFORE anchor in the document.
    let sel = Selection::new(Position::new("b2", 0), Position::new("b1", 0));
    let mut id_gen = SequentialIdGenerator::new("test");

    let ops = transform(
        &doc,
        &sel,
        &Command::SetBlockAlign {
            align: BlockAlignment::Justify,
        },
        &mut id_gen,
        None,
    )
    .unwrap();
    assert_eq!(ops.len(), 2);
}

#[test]
fn set_block_align_preserves_other_props() {
    let mut doc = Document::new();
    let mut block = Block::new_heading(BlockId::new("b1"), 2);
    block.content = vec![TextSpan::plain("Title")];
    doc.insert_block(block, 0);

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

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

    let block = doc.get_block(&BlockId::new("b1")).unwrap();
    // Block type and heading level remain unchanged.
    assert!(block.is_heading());
    assert_eq!(block.heading_level(), 2);
    assert_eq!(block.alignment(), BlockAlignment::Center);
}
