use crate::error::EditorError;
pub use crate::id::{IdGenerator, SequentialIdGenerator, UuidGenerator};
use crate::model::block::{Block, BlockAlignment, BlockId, block_types, prop_keys};
use crate::model::document::Document;
use crate::model::inline::{Mark, next_char_boundary_utf16, prev_char_boundary_utf16, utf16_len};
use crate::model::selection::{Position, Selection};
use crate::op::operation::Operation;
use serde_json::{Map, Value};

use super::command::Command;

/// Transform a command into a list of operations given the current document and selection.
/// `stored_marks`: if Some, InsertText uses these marks instead of inferring from context.
pub fn transform(
    doc: &Document,
    selection: &Selection,
    cmd: &Command,
    id_gen: &mut dyn IdGenerator,
    stored_marks: Option<Vec<Mark>>,
) -> Result<Vec<Operation>, EditorError> {
    match cmd {
        Command::InsertText { text } => transform_insert_text(doc, selection, text, stored_marks),
        Command::Backspace => transform_backspace(doc, selection),
        Command::Delete => transform_delete(doc, selection),
        Command::Enter => transform_enter(doc, selection, id_gen),
        Command::ToggleMark { mark_type } => transform_toggle_mark(doc, selection, mark_type),
        Command::SetMark { mark_type, attrs } => {
            transform_set_mark(doc, selection, mark_type, attrs)
        }
        Command::ClearMark { mark_type } => transform_clear_mark(doc, selection, mark_type),
        Command::SetBlockType { block_type, props } => {
            transform_set_block_type(doc, selection, block_type, props)
        }
        Command::SetBlockAlign { align } => transform_set_block_align(doc, selection, *align),
        Command::ToggleTodo => transform_toggle_todo(doc, selection),
        Command::IndentBlock => transform_indent_block(doc, selection),
        Command::OutdentBlock => transform_outdent_block(doc, selection),
        Command::EnsureTrailingParagraph => {
            transform_ensure_trailing_paragraph(doc, selection, id_gen)
        }

        // --- Table commands ---
        Command::InsertTable { rows, cols } => {
            transform_insert_table(doc, selection, *rows, *cols, id_gen)
        }
        // Phase 2+: row/col operations, merge, split, style, navigate
        Command::InsertTableRow {
            table_id,
            row_index,
            above,
        } => transform_insert_table_row(doc, table_id, *row_index, *above, id_gen),
        Command::DeleteTableRow {
            table_id,
            row_index,
        } => transform_delete_table_row(doc, table_id, *row_index, id_gen),
        Command::InsertTableCol {
            table_id,
            col_index,
            left,
        } => transform_insert_table_col(doc, table_id, *col_index, *left, id_gen),
        Command::DeleteTableCol {
            table_id,
            col_index,
        } => transform_delete_table_col(doc, table_id, *col_index, id_gen),
        Command::MergeCells { table_id } => transform_merge_cells(doc, selection, table_id),
        Command::UnmergeCells { table_id, cell_id } => {
            transform_unmerge_cells(doc, table_id, cell_id)
        }
        Command::SplitCell {
            table_id,
            cell_id,
            direction,
        } => transform_split_cell(doc, table_id, cell_id, direction, id_gen),
        Command::SetCellProps { cell_ids, props } => {
            transform_set_cell_props(doc, selection, cell_ids, props)
        }
        Command::SetTableProps { table_id, props } => {
            transform_set_table_props(doc, table_id, props)
        }
        Command::SetRowProps { row_id, props } => transform_set_row_props(doc, row_id, props),
        Command::ToggleCellMark {
            cell_ids,
            mark_type,
        } => transform_toggle_cell_mark(doc, selection, cell_ids, mark_type),
        Command::ClearCells { cell_ids } => transform_clear_cells(doc, selection, cell_ids),
        Command::TableNavigate { direction } => {
            transform_table_navigate(doc, selection, direction, id_gen)
        }
    }
}

/// Props for a paragraph that preserves the block's indent level.
/// Used when unwrapping heading/todo/list/toggle/quote → paragraph via Backspace.
pub(super) fn preserve_indent_props(block: &crate::model::block::Block) -> Map<String, Value> {
    let mut props = Map::new();
    let indent = block.indent_level();
    if indent > 0 {
        props.insert(prop_keys::INDENT.into(), Value::from(indent));
    }
    props
}

pub(super) fn fenced_code_props(text: &str) -> Option<Map<String, Value>> {
    let language = text.strip_prefix("```")?;
    if language.contains(char::is_whitespace) || language.contains('`') {
        return None;
    }

    let mut props = Map::new();
    props.insert("language".into(), Value::String(language.to_string()));
    Some(props)
}

/// Build a SetBlockType operation that bumps a block's indent by `delta`.
/// Preserves all other props.
fn change_indent_op(block: &crate::model::block::Block, delta: i64) -> Operation {
    let new_indent = (block.indent_level() + delta).max(0);
    let mut props = block.props.clone();
    props.insert(prop_keys::INDENT.into(), Value::from(new_indent));
    Operation::SetBlockType {
        block_id: block.id.clone(),
        block_type: block.ty.clone(),
        props,
    }
}

/// After removing the block at `removed_idx`, walk subsequent root blocks and
/// generate ops to clamp any indent that would now exceed (prev block's indent + 1).
/// This enforces the invariant: a block's indent can be at most one deeper
/// than the previous block. Maintains relative depth among descendants.
pub(super) fn normalize_indents_after_removal(
    doc: &Document,
    removed_idx: usize,
) -> Vec<Operation> {
    let root = doc.root_block_ids();
    let mut ops = Vec::new();

    // The "previous" context for the first block AFTER removal is the block
    // BEFORE the removed one (or sentinel -1 if removed_idx == 0).
    let mut prev_indent: i64 = if removed_idx == 0 {
        -1
    } else {
        doc.get_block(&root[removed_idx - 1])
            .map(crate::model::block::Block::indent_level)
            .unwrap_or(-1)
    };

    for block_id in root.iter().skip(removed_idx + 1) {
        let Some(block) = doc.get_block(block_id) else {
            continue;
        };
        let current = block.indent_level();
        let max_allowed = (prev_indent + 1).max(0);
        let new_indent = current.min(max_allowed);
        if new_indent != current {
            let mut props = block.props.clone();
            if new_indent > 0 {
                props.insert(prop_keys::INDENT.into(), Value::from(new_indent));
            } else {
                props.remove("indent");
            }
            ops.push(Operation::SetBlockType {
                block_id: block.id.clone(),
                block_type: block.ty.clone(),
                props,
            });
        }
        prev_indent = new_indent;
    }
    ops
}

fn transform_indent_block(
    doc: &Document,
    selection: &Selection,
) -> Result<Vec<Operation>, EditorError> {
    let block_id = &selection.anchor.id;
    let block = doc.require_block(block_id)?;

    // Inside a fenced code block Tab (dispatched via `IndentBlock`)
    // inserts two literal spaces at the caret instead of changing
    // the block's nesting level. Matches how code editors treat
    // Tab inside code fences. See `Block::is_code` for the rationale.
    if block.is_code() {
        let offset = selection.anchor.offset;
        return Ok(vec![
            Operation::InsertTextAt {
                block_id: block_id.clone(),
                offset,
                text: "  ".into(),
                marks: Vec::new(),
            },
            Operation::SetSelection {
                selection: Some(Selection::collapsed(block_id.clone(), offset + 2)),
            },
        ]);
    }

    let root = doc.root_block_ids();
    let idx = root
        .iter()
        .position(|id| id == block_id)
        .ok_or(EditorError::NoSelection)?;

    if idx == 0 {
        return Ok(vec![]); // No previous sibling
    }

    let prev = doc.require_block(&root[idx - 1])?;

    let base_indent = block.indent_level();
    // Cannot indent deeper than prev sibling's indent + 1
    if base_indent > prev.indent_level() {
        return Ok(vec![]);
    }

    let mut ops = vec![change_indent_op(block, 1)];

    // Walk the real tree (Block.children) to bump every descendant by +1.
    for desc_id in doc.descendants_of(block_id) {
        let desc = doc.require_block(&desc_id)?;
        ops.push(change_indent_op(desc, 1));
    }

    Ok(ops)
}

fn transform_outdent_block(
    doc: &Document,
    selection: &Selection,
) -> Result<Vec<Operation>, EditorError> {
    let block_id = &selection.anchor.id;
    let block = doc.require_block(block_id)?;

    let base_indent = block.indent_level();
    if base_indent <= 0 {
        return Ok(vec![]); // Already at root level
    }

    let mut ops = vec![change_indent_op(block, -1)];

    // Walk the real tree (Block.children) to bump every descendant by -1.
    for desc_id in doc.descendants_of(block_id) {
        let desc = doc.require_block(&desc_id)?;
        ops.push(change_indent_op(desc, -1));
    }

    Ok(ops)
}

/// Guarantee an empty paragraph immediately AFTER the root block that
/// contains the current cursor. Walks the parent chain so the cursor
/// being in a nested block (e.g. a table cell) still resolves to the
/// containing top-level block (e.g. the table itself).
///
/// Idempotent: if the next root block is already an empty paragraph,
/// emits zero operations so re-dispatching the command doesn't bloat
/// the document or the undo history.
///
/// **Cursor is intentionally left unchanged.** Callers compose this
/// with other commands — e.g. `[InsertTable, EnsureTrailingParagraph]`
/// from the slash menu / toolbar grid picker — where the previous
/// command already parked the caret at the desired position (the
/// first table cell). The paste flow deliberately does NOT include
/// this command, so its `InsertTable` continues to produce exactly
/// one new root block (the cloned table) and the copy-paste round
/// trip stays byte-identical.
fn transform_ensure_trailing_paragraph(
    doc: &Document,
    selection: &Selection,
    id_gen: &mut dyn IdGenerator,
) -> Result<Vec<Operation>, EditorError> {
    // Walk up to find the root ancestor of the cursor's block. The
    // borrow on `block` is released each iteration via `parent.clone()`
    // so the loop can advance to the next id without fighting the
    // borrow checker.
    let mut current_id = selection.anchor.id.clone();
    let root_id = loop {
        let parent_opt = doc.require_block(&current_id)?.parent.clone();
        match parent_opt {
            None => break current_id,
            Some(parent_id) => current_id = parent_id,
        }
    };

    let Some(root_index) = doc.block_root_index(&root_id) else {
        // Defensive: a corrupt document where a parentless block isn't
        // in root.block_ids would land here. Treat as no-op rather
        // than crash — callers can re-dispatch once the structure is
        // repaired.
        return Ok(Vec::new());
    };
    let next_root_index = root_index + 1;

    // Idempotent guard: skip if the slot below is already an empty
    // paragraph. Prevents the command from stacking duplicate trailing
    // paragraphs when, e.g., a user clicks "Insert Table" twice
    // without moving the caret out of the first cell.
    let root_ids = doc.root_block_ids();
    if let Some(next_id) = root_ids.get(next_root_index)
        && let Some(next_block) = doc.get_block(next_id)
        && next_block.is_paragraph()
        && next_block.content.is_empty()
    {
        return Ok(Vec::new());
    }

    let new_id = id_gen.next_id();
    Ok(vec![Operation::InsertBlock {
        block: Block::new_paragraph(new_id),
        index: next_root_index,
    }])
}

fn transform_insert_text(
    doc: &Document,
    selection: &Selection,
    text: &str,
    stored_marks: Option<Vec<Mark>>,
) -> Result<Vec<Operation>, EditorError> {
    let mut ops = Vec::new();

    if let Some((mut delete_ops, insert_pos)) = cross_block_delete_ops(doc, selection)? {
        ops.append(&mut delete_ops);
        let marks =
            stored_marks.unwrap_or_else(|| active_marks_at(doc, &insert_pos.id, insert_pos.offset));
        ops.push(Operation::InsertTextAt {
            block_id: insert_pos.id.clone(),
            offset: insert_pos.offset,
            text: text.to_string(),
            marks,
        });
        ops.push(Operation::SetSelection {
            selection: Some(Selection::collapsed(
                insert_pos.id,
                insert_pos.offset + utf16_len(text),
            )),
        });
        return Ok(ops);
    }

    if !selection.is_collapsed()
        && let Some((start, end)) = selection.ordered_offsets()
    {
        let block_id = selection
            .single_block()
            .ok_or(EditorError::NoSelection)?
            .clone();
        ops.push(Operation::DeleteRange {
            block_id: block_id.clone(),
            start,
            end,
        });
        let marks = stored_marks.unwrap_or_else(|| active_marks_at(doc, &block_id, start));
        ops.push(Operation::InsertTextAt {
            block_id: block_id.clone(),
            offset: start,
            text: text.to_string(),
            marks,
        });
        ops.push(Operation::SetSelection {
            selection: Some(Selection::collapsed(block_id, start + utf16_len(text))),
        });
        return Ok(ops);
    }

    let block_id = &selection.anchor.id;
    let offset = selection.anchor.offset;
    let pending_marks = if stored_marks.is_none() {
        pending_marks_at(doc, block_id)
    } else {
        Vec::new()
    };
    let marks = stored_marks.unwrap_or_else(|| {
        if pending_marks.is_empty() {
            active_marks_at(doc, block_id, offset)
        } else {
            pending_marks.clone()
        }
    });

    ops.push(Operation::InsertTextAt {
        block_id: block_id.clone(),
        offset,
        text: text.to_string(),
        marks,
    });
    if !pending_marks.is_empty() {
        let mut props = Map::new();
        props.insert("pendingMarks".into(), Value::Null);
        ops.push(set_block_props_op(doc, block_id, props)?);
    }
    ops.push(Operation::SetSelection {
        selection: Some(Selection::collapsed(
            block_id.clone(),
            offset + utf16_len(text),
        )),
    });

    Ok(ops)
}

fn transform_backspace(
    doc: &Document,
    selection: &Selection,
) -> Result<Vec<Operation>, EditorError> {
    let mut ops = Vec::new();

    if let Some((mut delete_ops, collapse_pos)) = cross_block_delete_ops(doc, selection)? {
        ops.append(&mut delete_ops);
        ops.push(Operation::SetSelection {
            selection: Some(Selection::collapsed(collapse_pos.id, collapse_pos.offset)),
        });
        return Ok(ops);
    }

    if !selection.is_collapsed()
        && let Some((start, end)) = selection.ordered_offsets()
    {
        let block_id = selection
            .single_block()
            .ok_or(EditorError::NoSelection)?
            .clone();
        ops.push(Operation::DeleteRange {
            block_id: block_id.clone(),
            start,
            end,
        });
        ops.push(Operation::SetSelection {
            selection: Some(Selection::collapsed(block_id, start)),
        });
        return Ok(ops);
    }

    let block_id = &selection.anchor.id;
    let offset = selection.anchor.offset;
    let block = doc.require_block(block_id)?;

    if block.is_table_cell() && offset == 0 {
        return Ok(Vec::new());
    }

    let text = block.plain_text();
    let text_len = block.text_len();

    if offset > 0 {
        let end = next_char_boundary_utf16(&text, offset.min(text_len));
        let start = prev_char_boundary_utf16(&text, end.saturating_sub(1));
        ops.push(Operation::DeleteRange {
            block_id: block_id.clone(),
            start,
            end,
        });
        ops.push(Operation::SetSelection {
            selection: Some(Selection::collapsed(block_id.clone(), start)),
        });
    } else {
        // Indented block at offset 0 → outdent one level first. Matches
        // the familiar Notion/Word behaviour where Backspace at the
        // start of an indented line decreases its indent instead of
        // merging it with the previous block (or unwrapping its type).
        // Only once the block is back at indent 0 does the subsequent
        // Backspace fall through to the merge/unwrap logic below.
        if block.indent_level() > 0 {
            // ── Toggle-body guard (outdent step) ─────────────────
            // Only the FIRST body — the block immediately following
            // the toggle — is protected. Any later body sibling
            // (same indent, with a paragraph between it and the
            // toggle) can outdent normally so users can break out
            // of a multi-paragraph toggle body without resorting to
            // drag-handle deletion.
            //
            // Detection: when the immediate prev_sibling is a
            // toggle, the current block is the toggle's first body.
            // If prev_sibling is some other block (same-indent body,
            // heading, list, etc.) we are NOT the first body and
            // normal outdent applies.
            if let Some(prev_id) = doc.prev_sibling(block_id)
                && doc.require_block(prev_id)?.is_toggle()
            {
                return Ok(Vec::new());
            }
            return transform_outdent_block(doc, selection);
        }

        let is_empty = text_len == 0;

        // Empty heading → merge with previous block immediately (no visible prefix).
        // Unlike todo/list which have checkbox/bullet, heading has nothing to "undo"
        // before merging.
        if block.is_heading() && is_empty {
            if let Some(prev_id) = doc.prev_sibling(block_id) {
                let prev_len = doc.text_len(prev_id);
                let prev_id = prev_id.clone();
                let removed_idx = doc.block_root_index(block_id).unwrap_or(0);
                ops.push(Operation::MergeBlock {
                    target_id: prev_id.clone(),
                    source_id: block_id.clone(),
                });
                ops.extend(normalize_indents_after_removal(doc, removed_idx));
                ops.push(Operation::SetSelection {
                    selection: Some(Selection::collapsed(prev_id, prev_len)),
                });
            } else {
                // First block with no previous → convert to paragraph (preserve indent)
                ops.push(Operation::SetBlockType {
                    block_id: block_id.clone(),
                    block_type: block_types::PARAGRAPH.into(),
                    props: preserve_indent_props(block),
                });
            }
        } else if block.is_heading()
            || block.is_todo()
            || block.is_list()
            || block.is_toggle()
            || block.is_quote()
        {
            // Heading-with-text / Todo / List / Toggle / Quote at start → unwrap to paragraph.
            // Preserve the indent so the visual level stays the same.
            ops.push(Operation::SetBlockType {
                block_id: block_id.clone(),
                block_type: block_types::PARAGRAPH.into(),
                props: preserve_indent_props(block),
            });
        } else if block.is_paragraph() {
            // Paragraph at start → merge with previous block
            if let Some(prev_id) = doc.prev_sibling(block_id) {
                // ── Toggle-body guard ─────────────────────────────────
                // The slash-menu Toggle item and the `>` markdown
                // shortcut both auto-insert an indent+1 paragraph
                // beneath a freshly created toggle so the user has
                // somewhere to type the body. A single Backspace at
                // offset 0 of that body block would otherwise merge
                // it INTO the toggle title and delete the body block
                // entirely — which users reported as "토글의 자식이
                // backspace로 제거됨". Skip the merge when the
                // previous block is a toggle so the body stays
                // intact. Explicit deletion paths (Shift+Tab outdent
                // + Delete, drag-handle remove, block-selection +
                // Backspace) still work; only the easy-to-trigger
                // start-of-block merge is blocked.
                if doc.require_block(prev_id)?.is_toggle() {
                    return Ok(Vec::new());
                }
                let prev_len = doc.text_len(prev_id);
                let prev_id = prev_id.clone();
                let prev_is_void = doc.require_block(&prev_id)?.is_void();
                let removed_idx = if prev_is_void {
                    doc.block_root_index(&prev_id).unwrap_or(0)
                } else {
                    doc.block_root_index(block_id).unwrap_or(0)
                };
                if prev_is_void {
                    ops.push(Operation::RemoveBlock {
                        block_id: prev_id.clone(),
                    });
                } else {
                    ops.push(Operation::MergeBlock {
                        target_id: prev_id.clone(),
                        source_id: block_id.clone(),
                    });
                }
                ops.extend(normalize_indents_after_removal(doc, removed_idx));
                ops.push(Operation::SetSelection {
                    selection: Some(if prev_is_void {
                        Selection::collapsed(block_id.clone(), 0)
                    } else {
                        Selection::collapsed(prev_id, prev_len)
                    }),
                });
            }
        } else {
            // Any custom block type at start → convert to paragraph (preserve indent)
            ops.push(Operation::SetBlockType {
                block_id: block_id.clone(),
                block_type: block_types::PARAGRAPH.into(),
                props: preserve_indent_props(block),
            });
        }
    }

    Ok(ops)
}

fn transform_delete(doc: &Document, selection: &Selection) -> Result<Vec<Operation>, EditorError> {
    let mut ops = Vec::new();

    if let Some((mut delete_ops, collapse_pos)) = cross_block_delete_ops(doc, selection)? {
        ops.append(&mut delete_ops);
        ops.push(Operation::SetSelection {
            selection: Some(Selection::collapsed(collapse_pos.id, collapse_pos.offset)),
        });
        return Ok(ops);
    }

    if !selection.is_collapsed()
        && let Some((start, end)) = selection.ordered_offsets()
    {
        let block_id = selection
            .single_block()
            .ok_or(EditorError::NoSelection)?
            .clone();
        ops.push(Operation::DeleteRange {
            block_id: block_id.clone(),
            start,
            end,
        });
        ops.push(Operation::SetSelection {
            selection: Some(Selection::collapsed(block_id, start)),
        });
        return Ok(ops);
    }

    let block_id = &selection.anchor.id;
    let offset = selection.anchor.offset;
    let block = doc.require_block(block_id)?;
    let text = block.plain_text();
    let text_len = block.text_len();
    let start = prev_char_boundary_utf16(&text, offset.min(text_len));

    if start < text_len {
        let end = next_char_boundary_utf16(&text, start + 1);
        ops.push(Operation::DeleteRange {
            block_id: block_id.clone(),
            start,
            end,
        });
    } else if let Some(next_id) = doc.next_sibling(block_id) {
        let next_id = next_id.clone();
        // The next block (at current_idx + 1) is being removed.
        let removed_idx = doc.block_root_index(block_id).map(|i| i + 1).unwrap_or(0);
        if doc.require_block(&next_id)?.is_void() {
            ops.push(Operation::RemoveBlock { block_id: next_id });
            ops.push(Operation::SetSelection {
                selection: Some(Selection::collapsed(block_id.clone(), text_len)),
            });
        } else {
            ops.push(Operation::MergeBlock {
                target_id: block_id.clone(),
                source_id: next_id,
            });
        }
        ops.extend(normalize_indents_after_removal(doc, removed_idx));
    }

    Ok(ops)
}

fn cross_block_delete_ops(
    doc: &Document,
    selection: &Selection,
) -> Result<Option<(Vec<Operation>, Position)>, EditorError> {
    if selection.is_collapsed() || selection.single_block().is_some() {
        return Ok(None);
    }

    let (start, end) = selection.normalized(doc);
    let start_idx = doc
        .block_root_index(&start.id)
        .ok_or_else(|| EditorError::BlockNotFound(start.id.clone()))?;
    let end_idx = doc
        .block_root_index(&end.id)
        .ok_or_else(|| EditorError::BlockNotFound(end.id.clone()))?;

    if start_idx >= end_idx {
        return Ok(None);
    }

    let start_len = doc.text_len(&start.id);
    let end_len = doc.text_len(&end.id);
    if start.offset > start_len {
        return Err(EditorError::OffsetOutOfRange {
            block_id: start.id,
            offset: start.offset,
            len: start_len,
        });
    }
    if end.offset > end_len {
        return Err(EditorError::OffsetOutOfRange {
            block_id: end.id,
            offset: end.offset,
            len: end_len,
        });
    }

    let mut ops = Vec::new();
    if end.offset > 0 {
        ops.push(Operation::DeleteRange {
            block_id: end.id.clone(),
            start: 0,
            end: end.offset,
        });
    }
    if start.offset < start_len {
        ops.push(Operation::DeleteRange {
            block_id: start.id.clone(),
            start: start.offset,
            end: start_len,
        });
    }

    for block_id in doc.root_block_ids()[(start_idx + 1)..end_idx]
        .iter()
        .cloned()
    {
        ops.push(Operation::RemoveBlock { block_id });
    }

    ops.push(Operation::MergeBlock {
        target_id: start.id.clone(),
        source_id: end.id,
    });

    Ok(Some((ops, Position::new(start.id, start.offset))))
}

fn transform_toggle_mark(
    doc: &Document,
    selection: &Selection,
    mark_type: &str,
) -> Result<Vec<Operation>, EditorError> {
    if selection.is_collapsed() && selection.cell_selection.is_none() {
        return Ok(Vec::new());
    }

    let ranges = selection_ranges(doc, selection)?;
    let empty_cell_ids = if selection.cell_selection.is_some() {
        selected_cell_ids(doc, selection, &[])
            .into_iter()
            .filter(|cell_id| {
                doc.get_block(cell_id)
                    .map(|block| block.text_len() == 0)
                    .unwrap_or(false)
            })
            .collect::<Vec<_>>()
    } else {
        Vec::new()
    };

    if ranges.is_empty() && empty_cell_ids.is_empty() {
        return Ok(Vec::new());
    }

    let filled_ranges_should_clear = ranges.iter().all(|(block_id, start, end)| {
        doc.get_block(block_id)
            .map(|block| {
                crate::model::inline::all_have_mark(&block.content, *start, *end, mark_type)
            })
            .unwrap_or(false)
    });
    let empty_cells_should_clear = empty_cell_ids.iter().all(|block_id| {
        pending_marks_at(doc, block_id)
            .iter()
            .any(|mark| mark.ty == mark_type)
    });
    let should_clear = filled_ranges_should_clear && empty_cells_should_clear;

    let mut ops: Vec<Operation> = ranges
        .into_iter()
        .map(|(block_id, start, end)| {
            if should_clear {
                Operation::ClearMark {
                    block_id,
                    start,
                    end,
                    mark_type: mark_type.to_string(),
                }
            } else {
                Operation::SetMark {
                    block_id,
                    start,
                    end,
                    mark: Mark::new(mark_type),
                }
            }
        })
        .collect();

    for block_id in empty_cell_ids {
        if let Some(op) = pending_marks_props_op(doc, &block_id, mark_type, should_clear)? {
            ops.push(op);
        }
    }

    Ok(ops)
}

fn transform_set_mark(
    doc: &Document,
    selection: &Selection,
    mark_type: &str,
    attrs: &Map<String, Value>,
) -> Result<Vec<Operation>, EditorError> {
    if selection.is_collapsed() && selection.cell_selection.is_none() {
        return Ok(Vec::new());
    }

    Ok(selection_ranges(doc, selection)?
        .into_iter()
        .map(|(block_id, start, end)| Operation::SetMark {
            block_id,
            start,
            end,
            mark: Mark::with_attrs(mark_type, attrs.clone()),
        })
        .collect())
}

fn transform_clear_mark(
    doc: &Document,
    selection: &Selection,
    mark_type: &str,
) -> Result<Vec<Operation>, EditorError> {
    if selection.is_collapsed() && selection.cell_selection.is_none() {
        return Ok(Vec::new());
    }

    Ok(selection_ranges(doc, selection)?
        .into_iter()
        .map(|(block_id, start, end)| Operation::ClearMark {
            block_id,
            start,
            end,
            mark_type: mark_type.to_string(),
        })
        .collect())
}

fn selection_ranges(
    doc: &Document,
    selection: &Selection,
) -> Result<Vec<(BlockId, usize, usize)>, EditorError> {
    if let Some(cell_selection) = &selection.cell_selection {
        let grid = doc.table_cell_grid(&cell_selection.table_id);
        let (top_left, bottom_right) = cell_selection.normalized();
        let mut ranges = Vec::new();

        for row in top_left.row..=bottom_right.row {
            for col in top_left.col..=bottom_right.col {
                let Some(cell_id) = grid.get(row).and_then(|cells| cells.get(col)).cloned() else {
                    continue;
                };
                let block = doc.require_block(&cell_id)?;
                let text_len = block.text_len();
                if text_len > 0 {
                    ranges.push((cell_id, 0, text_len));
                }
            }
        }

        return Ok(ranges);
    }

    let (start_pos, end_pos) = selection.normalized(doc);

    if start_pos.id == end_pos.id {
        let start = start_pos.offset.min(end_pos.offset);
        let end = start_pos.offset.max(end_pos.offset);
        if start >= end {
            return Ok(Vec::new());
        }
        return Ok(vec![(start_pos.id, start, end)]);
    }

    let start_idx = doc
        .block_root_index(&start_pos.id)
        .ok_or(EditorError::NoSelection)?;
    let end_idx = doc
        .block_root_index(&end_pos.id)
        .ok_or(EditorError::NoSelection)?;
    let mut ranges = Vec::new();

    for (idx, block_id) in doc.root_block_ids()[start_idx..=end_idx].iter().enumerate() {
        let root_idx = start_idx + idx;
        let block = doc.require_block(block_id)?;
        let start = if root_idx == start_idx {
            start_pos.offset
        } else {
            0
        };
        let end = if root_idx == end_idx {
            end_pos.offset
        } else {
            block.text_len()
        };
        if start < end {
            ranges.push((block_id.clone(), start, end));
        }
    }

    Ok(ranges)
}

fn selected_root_block_ids(
    doc: &Document,
    selection: &Selection,
) -> Result<Vec<BlockId>, EditorError> {
    if selection.cell_selection.is_some() {
        return Ok(vec![selection.anchor.id.clone()]);
    }

    // Cells and rows live under a `table` root block, not in `doc.root`. The
    // React-side `updateBlockProps` pokes `setSelectionSilent` at the target
    // block id before dispatching so every branch here can resolve — but for
    // child blocks `block_root_index()` is `None`. Fall back to the anchor id
    // directly so row-height / cell-prop edits reach the applier instead of
    // erroring with `NoSelection`.
    if let Some(block) = doc.get_block(&selection.anchor.id)
        && (block.is_table_cell() || block.is_table_row())
        && doc.block_root_index(&selection.anchor.id).is_none()
    {
        return Ok(vec![selection.anchor.id.clone()]);
    }

    let root = doc.root_block_ids();
    let anchor_idx = root
        .iter()
        .position(|id| id == &selection.anchor.id)
        .ok_or(EditorError::NoSelection)?;
    let focus_idx = root
        .iter()
        .position(|id| id == &selection.focus.id)
        .unwrap_or(anchor_idx);

    let (start, end) = if anchor_idx <= focus_idx {
        (anchor_idx, focus_idx)
    } else {
        (focus_idx, anchor_idx)
    };

    Ok(root[start..=end].to_vec())
}

fn transform_set_block_type(
    doc: &Document,
    selection: &Selection,
    block_type: &str,
    props: &Map<String, Value>,
) -> Result<Vec<Operation>, EditorError> {
    selected_root_block_ids(doc, selection)?
        .into_iter()
        .map(|block_id| {
            let block = doc.require_block(&block_id)?;

            if block.ty == block_type && block.props == *props {
                Ok(None)
            } else {
                Ok(Some(Operation::SetBlockType {
                    block_id,
                    block_type: block_type.to_string(),
                    props: props.clone(),
                }))
            }
        })
        .filter_map(Result::transpose)
        .collect()
}

/// Emit a `SetBlockAlign` op for every root block that is intersected by the
/// current selection. Blocks whose alignment already matches the requested one
/// are skipped so that re-applying the same shortcut stays a no-op (no empty
/// transactions in undo history).
fn transform_set_block_align(
    doc: &Document,
    selection: &Selection,
    align: BlockAlignment,
) -> Result<Vec<Operation>, EditorError> {
    if selection.cell_selection.is_some() {
        let mut ops = Vec::new();
        for cell_id in selected_cell_ids(doc, selection, &[]) {
            let Some(block) = doc.get_block(&cell_id) else {
                continue;
            };
            if block.alignment() == align {
                continue;
            }
            ops.push(Operation::SetBlockAlign {
                block_id: cell_id,
                align,
            });
        }
        return Ok(ops);
    }

    if let Some(block) = doc.get_block(&selection.anchor.id)
        && block.is_table_cell()
        && doc.block_root_index(&selection.anchor.id).is_none()
    {
        return if block.alignment() == align {
            Ok(Vec::new())
        } else {
            Ok(vec![Operation::SetBlockAlign {
                block_id: selection.anchor.id.clone(),
                align,
            }])
        };
    }

    let root = doc.root_block_ids();
    let anchor_idx = root
        .iter()
        .position(|id| id == &selection.anchor.id)
        .ok_or(EditorError::NoSelection)?;
    let focus_idx = root
        .iter()
        .position(|id| id == &selection.focus.id)
        .unwrap_or(anchor_idx);

    let (start, end) = if anchor_idx <= focus_idx {
        (anchor_idx, focus_idx)
    } else {
        (focus_idx, anchor_idx)
    };

    let mut ops = Vec::new();
    for block_id in &root[start..=end] {
        let Some(block) = doc.get_block(block_id) else {
            continue;
        };
        if block.alignment() == align {
            continue;
        }
        ops.push(Operation::SetBlockAlign {
            block_id: block_id.clone(),
            align,
        });
    }
    Ok(ops)
}

fn transform_toggle_todo(
    doc: &Document,
    selection: &Selection,
) -> Result<Vec<Operation>, EditorError> {
    selected_root_block_ids(doc, selection)?
        .into_iter()
        .map(|block_id| {
            let block = doc.require_block(&block_id)?;

            if block.is_todo() {
                Ok(Operation::SetBlockType {
                    block_id,
                    block_type: block_types::PARAGRAPH.into(),
                    props: preserve_indent_props(block),
                })
            } else {
                let mut props = preserve_indent_props(block);
                props.insert("checked".into(), Value::Bool(false));
                Ok(Operation::SetBlockType {
                    block_id,
                    block_type: "todo".into(),
                    props,
                })
            }
        })
        .collect()
}

/// Get the active marks at a given offset in a block (public for engine stored_marks).
pub fn active_marks_at_pub(doc: &Document, block_id: &BlockId, offset: usize) -> Vec<Mark> {
    active_marks_at(doc, block_id, offset)
}

pub(super) fn active_marks_at(doc: &Document, block_id: &BlockId, offset: usize) -> Vec<Mark> {
    let Some(block) = doc.get_block(block_id) else {
        return vec![];
    };

    if block.content.is_empty() {
        return vec![];
    }

    let mut cumulative = 0;
    for span in &block.content {
        let span_end = cumulative + span.len();
        if offset <= span_end {
            return span.marks.clone();
        }
        cumulative = span_end;
    }

    block
        .content
        .last()
        .map(|s| s.marks.clone())
        .unwrap_or_default()
}

pub(super) fn pending_marks_at(doc: &Document, block_id: &BlockId) -> Vec<Mark> {
    let Some(block) = doc.get_block(block_id) else {
        return vec![];
    };

    block
        .props
        .get("pendingMarks")
        .and_then(Value::as_array)
        .map(|marks| {
            marks
                .iter()
                .filter_map(Value::as_str)
                .map(Mark::new)
                .collect()
        })
        .unwrap_or_default()
}

pub(super) fn pending_marks_props_op(
    doc: &Document,
    block_id: &BlockId,
    mark_type: &str,
    should_clear: bool,
) -> Result<Option<Operation>, EditorError> {
    let current = pending_marks_at(doc, block_id);
    let has_mark = current.iter().any(|mark| mark.ty == mark_type);

    if should_clear {
        if !has_mark {
            return Ok(None);
        }
        let remaining: Vec<Mark> = current
            .into_iter()
            .filter(|mark| mark.ty != mark_type)
            .collect();
        let mut props = Map::new();
        props.insert(
            "pendingMarks".into(),
            if remaining.is_empty() {
                Value::Null
            } else {
                Value::Array(
                    remaining
                        .into_iter()
                        .map(|mark| Value::String(mark.ty))
                        .collect(),
                )
            },
        );
        return Ok(Some(set_block_props_op(doc, block_id, props)?));
    }

    if has_mark {
        return Ok(None);
    }

    let mut next = current;
    next.push(Mark::new(mark_type));
    let mut props = Map::new();
    props.insert(
        "pendingMarks".into(),
        Value::Array(
            next.into_iter()
                .map(|mark| Value::String(mark.ty))
                .collect(),
        ),
    );
    Ok(Some(set_block_props_op(doc, block_id, props)?))
}

// ---------------------------------------------------------------------------
// Table commands
// ---------------------------------------------------------------------------

/// Shared helper used by both non-table (`InsertText` pending-marks flow)
/// and every table prop command. Keeps the `set_block_props` op construction
/// + `prev_props` snapshot in one place.
pub(super) fn set_block_props_op(
    doc: &Document,
    block_id: &BlockId,
    props: Map<String, Value>,
) -> Result<Operation, EditorError> {
    let block = doc.require_block(block_id)?;
    let prev_props = props
        .keys()
        .map(|key| {
            (
                key.clone(),
                block.props.get(key).cloned().unwrap_or(Value::Null),
            )
        })
        .collect();

    Ok(Operation::SetBlockProps {
        block_id: block_id.clone(),
        props,
        prev_props,
    })
}

mod enter;
use enter::transform_enter;

mod tables;
use tables::{
    selected_cell_ids, transform_clear_cells, transform_delete_table_col,
    transform_delete_table_row, transform_insert_table, transform_insert_table_col,
    transform_insert_table_row, transform_merge_cells, transform_set_cell_props,
    transform_set_row_props, transform_set_table_props, transform_split_cell,
    transform_table_navigate, transform_toggle_cell_mark, transform_unmerge_cells,
};

#[cfg(test)]
mod tests;
