//! Enter / block-split command transforms.
//!
//! Split out of `transform/mod.rs` so no file exceeds 1000 lines.
//! Both `transform_enter` and `transform_enter_collapsed` are called from
//! the `transform()` dispatcher in `super`.

use serde_json::{Map, Value};

use super::{
    Block, BlockId, Document, EditorError, IdGenerator, Operation, Selection, active_marks_at,
    block_types, fenced_code_props, preserve_indent_props,
};

pub(super) fn transform_enter(
    doc: &Document,
    selection: &Selection,
    id_gen: &mut dyn IdGenerator,
) -> Result<Vec<Operation>, EditorError> {
    let mut ops = Vec::new();

    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,
        });
        return transform_enter_collapsed(doc, &block_id, start, id_gen, ops);
    }

    let block_id = &selection.anchor.id;
    let offset = selection.anchor.offset;
    transform_enter_collapsed(doc, block_id, offset, id_gen, ops)
}

pub(super) fn transform_enter_collapsed(
    doc: &Document,
    block_id: &BlockId,
    offset: usize,
    id_gen: &mut dyn IdGenerator,
    mut ops: Vec<Operation>,
) -> Result<Vec<Operation>, EditorError> {
    let block = doc.require_block(block_id)?;

    if block.is_table_cell() {
        ops.push(Operation::InsertTextAt {
            block_id: block_id.clone(),
            offset,
            text: "\n".into(),
            marks: active_marks_at(doc, block_id, offset),
        });
        ops.push(Operation::SetSelection {
            selection: Some(Selection::collapsed(block_id.clone(), offset + 1)),
        });
        return Ok(ops);
    }

    // Fenced code block — Enter inserts a literal newline into the
    // code text, never splits the block. Matches VS Code / GitHub /
    // Notion fenced-code UX. The block ends only when the user
    // explicitly converts it back via the block-type toolbar.
    //
    // Living in the engine (not a React-side function handler)
    // keeps the behaviour consistent across every entry point,
    // including the IME composition-end replay path which can only
    // queue payload-style commands.
    if block.is_code() {
        ops.push(Operation::InsertTextAt {
            block_id: block_id.clone(),
            offset,
            text: "\n".into(),
            marks: Vec::new(),
        });
        ops.push(Operation::SetSelection {
            selection: Some(Selection::collapsed(block_id.clone(), offset + 1)),
        });
        return Ok(ops);
    }

    if block.is_paragraph() {
        let text = block.plain_text();
        if let Some(props) = fenced_code_props(&text) {
            ops.push(Operation::DeleteRange {
                block_id: block_id.clone(),
                start: 0,
                end: block.text_len(),
            });
            ops.push(Operation::SetBlockType {
                block_id: block_id.clone(),
                block_type: "code".into(),
                props,
            });
            ops.push(Operation::SetSelection {
                selection: Some(Selection::collapsed(block_id.clone(), 0)),
            });
            return Ok(ops);
        }
    }

    let text_len = block.text_len();
    let new_id = id_gen.next_id();

    // Void block (no editable text content, e.g. divider) → Enter inserts
    // a new empty paragraph AFTER the void block and moves the cursor
    // there. Without this the cursor would be stuck on a non-editable
    // block with no obvious way to continue writing.
    //
    // This applies whether Enter is triggered by:
    //   • the keyboard (user pressed Enter while the void block is focused), OR
    //   • the `---` markdown input rule, which batches
    //     `Backspace` + `SetBlockType(divider)` + `Enter`. Handling it here
    //     keeps input-rule code trivial and guarantees the keyboard path
    //     and the shortcut path produce identical documents.
    if block.is_void() {
        let index = doc
            .block_root_index(block_id)
            .ok_or_else(|| EditorError::BlockNotFound(block_id.clone()))?;
        ops.push(Operation::InsertBlock {
            block: Block::new_paragraph(new_id.clone()),
            index: index + 1,
        });
        ops.push(Operation::SetSelection {
            selection: Some(Selection::collapsed(new_id, 0)),
        });
        return Ok(ops);
    }

    // Empty todo/list/toggle/quote/heading → unwrap to paragraph (preserve indent)
    if block.is_empty()
        && (block.is_todo()
            || block.is_list()
            || block.is_toggle()
            || block.is_quote()
            || block.is_heading())
    {
        ops.push(Operation::SetBlockType {
            block_id: block_id.clone(),
            block_type: block_types::PARAGRAPH.into(),
            props: preserve_indent_props(block),
        });
        return Ok(ops);
    }

    // At start of non-empty block → insert an empty block of same type BEFORE.
    // Heading is an exception: insert paragraph instead (don't want empty headings).
    if offset == 0 && text_len > 0 {
        let index = doc
            .block_root_index(block_id)
            .ok_or_else(|| EditorError::BlockNotFound(block_id.clone()))?;
        let new_block = if block.is_heading() {
            Block::new_paragraph(new_id.clone())
        } else {
            let mut b = Block::new(new_id.clone(), block.ty.clone());
            // Preserve indent but reset type-specific state
            let mut props = block.props.clone();
            if block.is_todo() {
                props.insert("checked".into(), Value::Bool(false));
            }
            if block.is_toggle() {
                props.insert("collapsed".into(), Value::Bool(false));
            }
            b.props = props;
            b
        };
        ops.push(Operation::InsertBlock {
            block: new_block,
            index,
        });
        // Cursor stays on the current block at offset 0
        ops.push(Operation::SetSelection {
            selection: Some(Selection::collapsed(block_id.clone(), 0)),
        });
        return Ok(ops);
    }

    // At end of heading → new paragraph
    if block.is_heading() && offset == text_len {
        let index = doc
            .block_root_index(block_id)
            .ok_or_else(|| EditorError::BlockNotFound(block_id.clone()))?;
        ops.push(Operation::InsertBlock {
            block: Block::new_paragraph(new_id.clone()),
            index: index + 1,
        });
        ops.push(Operation::SetSelection {
            selection: Some(Selection::collapsed(new_id, 0)),
        });
        return Ok(ops);
    }

    // Default: split block in the middle
    ops.push(Operation::SplitBlock {
        block_id: block_id.clone(),
        offset,
        new_block_id: new_id.clone(),
    });
    // If original was heading, new block becomes paragraph
    if block.is_heading() {
        ops.push(Operation::SetBlockType {
            block_id: new_id.clone(),
            block_type: block_types::PARAGRAPH.into(),
            props: Map::new(),
        });
    }
    ops.push(Operation::SetSelection {
        selection: Some(Selection::collapsed(new_id, 0)),
    });

    Ok(ops)
}
