use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::doc::{
    aggregate_value, block_id_of, block_not_found, block_projection_blocks, find_block,
    required_arg,
};
use crate::hermes_tools::ToolCallInput;
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
use bridge_runtime::{
    apply_editor_command_to_legacy_content, RuntimeActorWire, RuntimeCommandEnvelopeWire,
    RuntimeSourceWire, RuntimeTargetWire,
};
use core_protocol::{
    BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockType, EditorCommand,
    EditorDeleteBlock, EditorInsertBlockAfter, EditorMoveBlock, EditorReplaceBlock,
};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashSet};

pub async fn block_fetch(
    state: &AppState,
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<Value, WebError> {
    let aggregate = aggregate_value(state, context, input).await?;
    let block_id = required_arg(input, context, "blockId")?;
    let blocks = block_projection_blocks(&aggregate);
    let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
    let context_before = input
        .arg_value("contextBefore")
        .and_then(|value| value.as_u64())
        .unwrap_or(1) as usize;
    let context_after = input
        .arg_value("contextAfter")
        .and_then(|value| value.as_u64())
        .unwrap_or(1) as usize;
    let siblings = same_parent_blocks(&blocks, &block);
    let index = siblings
        .iter()
        .position(|candidate| block_id_of(candidate).as_deref() == Some(block_id.as_str()))
        .unwrap_or(0);
    let before_start = index.saturating_sub(context_before);
    let before = siblings[before_start..index].to_vec();
    let after = siblings
        .iter()
        .skip(index + 1)
        .take(context_after)
        .cloned()
        .collect::<Vec<_>>();
    let format = input
        .arg_string("format")
        .unwrap_or_else(|| "json".into())
        .to_ascii_lowercase();

    Ok(json!({
        "ok": true,
        "documentId": input.effective_document_id(),
        "workspaceId": input.effective_workspace_id(),
        "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
        "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
        "format": format,
        "content": block_to_ai_content(&format, &block, &aggregate, input.effective_document_id().as_deref().unwrap_or_default()),
        "block": block,
        "context": {
            "before": before,
            "after": after
        },
        "warnings": []
    }))
}

pub async fn block_replace(
    state: &AppState,
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<Value, WebError> {
    ensure_write_contract(context, input)?;
    let aggregate = aggregate_value(state, context, input).await?;
    let block_id = required_arg(input, context, "blockId")?;
    let content = input.arg_value("content").ok_or_else(|| {
        WebError::bad_request_code("mnote_tool_bad_request", "mnote.block.replace 缺少 content")
            .with_context(context)
    })?;
    let blocks = block_projection_blocks(&aggregate);
    let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
    ensure_page_write_preconditions(context, input, &aggregate)?;
    ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
    ensure_block_editable(context, &block)?;
    ensure_allowed_target(context, input, &Value::Null, &block_id, "replace")?;
    let replacement_text = content_to_text(&content);
    let diff = json!([{
        "op": "replace",
        "targetBlockId": block_id,
        "before": block.get("text").cloned().unwrap_or(Value::Null),
        "after": replacement_text
    }]);
    if input.dry_run.unwrap_or(false) {
        return Ok(dry_run_result(
            input,
            &aggregate,
            "block_replace",
            diff,
            false,
        ));
    }
    let current_content = current_body_content(&aggregate);
    let command = EditorCommand::ReplaceBlock(EditorReplaceBlock {
        block_id: block_id.clone(),
        block_type: replacement_block_type(&content),
        props: replacement_block_props(&content),
        content_nodes: Some(build_content_nodes(&replacement_text)),
    });
    let (next_content, delta_opt) = compute_next_content_via_actor(
        state,
        &aggregate,
        input,
        &current_content,
        &command,
        "block_replace",
    )?;
    let mut result = execute_page_body_save(
        state,
        context,
        input,
        next_content,
        vec![json!({
            "blockId": block_id,
            "op": "replace"
        })],
    )
    .await?;
    merge_block_delta(&mut result, delta_opt);
    Ok(result)
}

pub async fn block_insert_after(
    state: &AppState,
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<Value, WebError> {
    ensure_write_contract(context, input)?;
    let aggregate = aggregate_value(state, context, input).await?;
    let anchor_block_id = input
        .arg_string("anchorBlockId")
        .or_else(|| input.arg_string("afterBlockId"))
        .ok_or_else(|| {
            WebError::bad_request_code(
                "mnote_tool_bad_request",
                "mnote.block.insert_after 缺少 anchorBlockId",
            )
            .with_context(context)
        })?;
    let blocks = block_projection_blocks(&aggregate);
    let anchor = find_block(&blocks, &anchor_block_id).ok_or_else(|| block_not_found(context))?;
    ensure_page_write_preconditions(context, input, &aggregate)?;
    ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
    ensure_block_editable(context, &anchor)?;
    ensure_allowed_target(
        context,
        input,
        &Value::Null,
        &anchor_block_id,
        "insert_after",
    )?;
    let insert_values = insert_after_values(context, input)?;
    let id_prefix = format!("ai_block_{}", context.trace.request_id.replace('-', "_"));
    let inserted_block_ids = insert_values
        .iter()
        .enumerate()
        .map(|(index, _)| {
            if insert_values.len() == 1 {
                id_prefix.clone()
            } else {
                format!("{id_prefix}_{index}")
            }
        })
        .collect::<Vec<_>>();
    let diff = insert_values
        .iter()
        .zip(inserted_block_ids.iter())
        .map(|(content, block_id)| {
            json!({
                "op": "insert_after",
                "anchorBlockId": anchor_block_id,
                "after": anchor.get("text").cloned().unwrap_or(Value::Null),
                "block": build_insert_block(block_id, content)
            })
        })
        .collect::<Vec<_>>();
    if input.dry_run.unwrap_or(false) {
        let mut result =
            dry_run_result(input, &aggregate, "block_insert_after", json!(diff), false);
        result["insertedBlockIds"] = json!(inserted_block_ids);
        return Ok(result);
    }
    let mut next_content = current_body_content(&aggregate);
    let mut changed_blocks = Vec::with_capacity(insert_values.len());
    let mut delta_opt = None;
    let mut after_block_id = anchor_block_id.clone();
    for (content, block_id) in insert_values.iter().zip(inserted_block_ids.iter()) {
        let command = EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
            after_block_id: after_block_id.clone(),
            block: build_editor_block(block_id, content),
        });
        let (content_after_command, command_delta) = compute_next_content_via_actor(
            state,
            &aggregate,
            input,
            &next_content,
            &command,
            "block_insert_after",
        )?;
        next_content = content_after_command;
        delta_opt = command_delta;
        changed_blocks.push(json!({
            "blockId": block_id,
            "op": "insert_after",
            "anchorBlockId": after_block_id
        }));
        after_block_id = block_id.clone();
    }
    let mut result =
        execute_page_body_save(state, context, input, next_content, changed_blocks).await?;
    result["insertedBlockIds"] = json!(inserted_block_ids);
    merge_block_delta(&mut result, delta_opt);
    Ok(result)
}

pub async fn block_delete(
    state: &AppState,
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<Value, WebError> {
    ensure_write_contract(context, input)?;
    let aggregate = aggregate_value(state, context, input).await?;
    let block_id = required_arg(input, context, "blockId")?;
    let blocks = block_projection_blocks(&aggregate);
    let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
    ensure_page_write_preconditions(context, input, &aggregate)?;
    ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
    ensure_block_editable(context, &block)?;
    ensure_allowed_target(context, input, &Value::Null, &block_id, "delete")?;
    let leaf = block
        .get("children")
        .and_then(Value::as_array)
        .map(|children| children.is_empty())
        .unwrap_or(true);
    let blocked = !leaf;
    let diff = json!([{
        "op": "delete",
        "blockId": block_id,
        "before": block.get("text").cloned().unwrap_or(Value::Null)
    }]);
    if input.dry_run.unwrap_or(false) || blocked {
        return Ok(json!({
            "ok": true,
            "dryRun": input.dry_run.unwrap_or(false),
            "command": "block_delete",
            "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
            "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
            "blocked": blocked,
            "risk": if blocked { "medium" } else { "low" },
            "diff": diff,
            "warnings": if blocked {
                json!([{
                    "code": "block_delete_blocked",
                    "message": "第一阶段仅开放无子块的普通块删除"
                }])
            } else {
                json!([])
            }
        }));
    }
    let current_content = current_body_content(&aggregate);
    let command = EditorCommand::DeleteBlock(EditorDeleteBlock {
        block_id: block_id.clone(),
        preserve_children: false,
    });
    let (next_content, delta_opt) = compute_next_content_via_actor(
        state,
        &aggregate,
        input,
        &current_content,
        &command,
        "block_delete",
    )?;
    let mut result = execute_page_body_save(
        state,
        context,
        input,
        next_content,
        vec![json!({
            "blockId": block_id,
            "op": "delete"
        })],
    )
    .await?;
    merge_block_delta(&mut result, delta_opt);
    Ok(result)
}

pub async fn block_move_after(
    state: &AppState,
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<Value, WebError> {
    ensure_write_contract(context, input)?;
    let aggregate = aggregate_value(state, context, input).await?;
    let block_id = required_arg(input, context, "blockId")?;
    let anchor_block_id = required_arg(input, context, "anchorBlockId")?;
    let blocks = block_projection_blocks(&aggregate);
    let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
    let anchor = find_block(&blocks, &anchor_block_id).ok_or_else(|| block_not_found(context))?;
    ensure_page_write_preconditions(context, input, &aggregate)?;
    ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
    ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
    ensure_allowed_target(context, input, &Value::Null, &block_id, "move_after")?;
    ensure_allowed_target(
        context,
        input,
        &Value::Null,
        &anchor_block_id,
        "move_after_anchor",
    )?;
    let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId");
    let leaf = block
        .get("children")
        .and_then(Value::as_array)
        .map(|children| children.is_empty())
        .unwrap_or(true);
    let movable_type = block
        .get("type")
        .and_then(Value::as_str)
        .map(|block_type| matches!(block_type, "paragraph" | "heading" | "todo" | "task"))
        .unwrap_or(false);
    let editable = block
        .get("editable")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    let blocked =
        !same_parent || !leaf || !movable_type || !editable || block_id == anchor_block_id;
    let diff = json!([{
        "op": "move_after",
        "blockId": block_id,
        "anchorBlockId": anchor_block_id,
        "from": {
            "parentBlockId": block.get("parentBlockId").cloned().unwrap_or(Value::Null),
            "order": block.get("order").cloned().unwrap_or(Value::Null)
        },
        "to": {
            "parentBlockId": anchor.get("parentBlockId").cloned().unwrap_or(Value::Null),
            "afterOrder": anchor.get("order").cloned().unwrap_or(Value::Null)
        }
    }]);
    if input.dry_run.unwrap_or(false) || blocked {
        return Ok(json!({
        "ok": true,
        "dryRun": input.dry_run.unwrap_or(false),
        "command": "block_move_after",
        "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
        "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
        "blocked": blocked,
        "risk": "medium",
        "diff": diff,
        "warnings": if blocked {
            json!([{
                "code": "block_move_after_blocked",
                "message": "第一阶段仅开放同父级普通叶子块移动，且不能移动到自身之后"
            }])
        } else {
            json!([])
        }
        }));
    }
    let current_content = current_body_content(&aggregate);
    let parent_block_id = anchor
        .get("parentBlockId")
        .and_then(Value::as_str)
        .map(str::to_owned);
    let command = EditorCommand::MoveBlock(EditorMoveBlock {
        block_id: block_id.clone(),
        parent_block_id,
        after_block_id: Some(anchor_block_id.clone()),
    });
    let (next_content, delta_opt) = compute_next_content_via_actor(
        state,
        &aggregate,
        input,
        &current_content,
        &command,
        "block_move_after",
    )?;
    let mut result = execute_page_body_save(
        state,
        context,
        input,
        next_content,
        vec![json!({
            "blockId": block_id,
            "op": "move_after",
            "anchorBlockId": anchor_block_id
        })],
    )
    .await?;
    merge_block_delta(&mut result, delta_opt);
    Ok(result)
}

pub async fn doc_apply_block_ops(
    state: &AppState,
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<Value, WebError> {
    ensure_write_contract(context, input)?;
    let aggregate = aggregate_value(state, context, input).await?;
    let operations = input
        .arg_value("operations")
        .and_then(|value| value.as_array().cloned())
        .ok_or_else(|| {
            WebError::bad_request_code(
                "mnote_tool_bad_request",
                "mnote.doc.apply_block_ops 缺少 operations",
            )
            .with_context(context)
        })?;
    if operations.is_empty() {
        return Err(WebError::bad_request_code(
            "mnote_tool_bad_request",
            "mnote.doc.apply_block_ops operations 不能为空",
        )
        .with_context(context));
    }
    if operations.len() > 12 {
        return Err(WebError::bad_request_code(
            "mnote_tool_bad_request",
            "mnote.doc.apply_block_ops 一次最多允许 12 个块操作",
        )
        .with_context(context));
    }
    ensure_page_write_preconditions(context, input, &aggregate)?;

    let blocks = block_projection_blocks(&aggregate);
    let mut next_content = current_body_content(&aggregate);
    let mut changed_blocks = Vec::new();
    let mut diff = Vec::new();
    let document_id = input.effective_document_id().unwrap_or_default();

    for (index, operation) in operations.iter().enumerate() {
        let op = operation
            .get("op")
            .or_else(|| operation.get("operation"))
            .and_then(Value::as_str)
            .map(|value| value.trim().replace('-', "_").to_ascii_lowercase())
            .unwrap_or_default();
        match op.as_str() {
            "replace" | "block_replace" => {
                let block = resolve_target_block(context, &blocks, operation, false)?;
                ensure_block_editable(context, &block)?;
                ensure_operation_block_revision_ref(
                    context,
                    input,
                    operation,
                    &block,
                    "blockRevisionRef",
                )?;
                let block_id = block_id_of(&block).unwrap_or_default();
                let content = operation.get("content").cloned().ok_or_else(|| {
                    WebError::bad_request_code("mnote_tool_bad_request", "replace 操作缺少 content")
                        .with_context(context)
                })?;
                let replacement_text = content_to_text(&content);
                ensure_allowed_target(context, input, operation, &block_id, "replace")?;
                diff.push(json!({
                    "op": "replace",
                    "blockId": block_id,
                    "before": block.get("text").cloned().unwrap_or(Value::Null),
                    "after": replacement_text
                }));
                if input.dry_run == Some(true) {
                    continue;
                }
                next_content = apply_editor_command_to_legacy_content(
                    &document_id,
                    &next_content,
                    EditorCommand::ReplaceBlock(EditorReplaceBlock {
                        block_id: block_id.clone(),
                        block_type: replacement_block_type(&content),
                        props: replacement_block_props(&content),
                        content_nodes: Some(build_content_nodes(&replacement_text)),
                    }),
                )
                .map_err(|error| {
                    WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}"))
                        .with_context(context)
                })?;
                changed_blocks.push(json!({"blockId": block_id, "op": "replace"}));
            }
            "insert_after" | "block_insert_after" => {
                let anchor = resolve_anchor_block(context, &blocks, operation)?;
                ensure_block_editable(context, &anchor)?;
                ensure_operation_block_revision_ref(
                    context,
                    input,
                    operation,
                    &anchor,
                    "anchorRevisionRef",
                )?;
                let anchor_block_id = block_id_of(&anchor).unwrap_or_default();
                ensure_allowed_target(context, input, operation, &anchor_block_id, "insert_after")?;
                let content = operation.get("content").cloned().ok_or_else(|| {
                    WebError::bad_request_code(
                        "mnote_tool_bad_request",
                        "insert_after 操作缺少 content",
                    )
                    .with_context(context)
                })?;
                let new_block_id = format!(
                    "ai_block_{}_{}",
                    context.trace.request_id.replace('-', "_"),
                    index
                );
                diff.push(json!({
                    "op": "insert_after",
                    "anchorBlockId": anchor_block_id,
                    "after": anchor.get("text").cloned().unwrap_or(Value::Null),
                    "blockId": new_block_id,
                    "content": content
                }));
                if input.dry_run == Some(true) {
                    continue;
                }
                next_content = apply_editor_command_to_legacy_content(
                    &document_id,
                    &next_content,
                    EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
                        after_block_id: anchor_block_id.clone(),
                        block: build_editor_block(&new_block_id, &content),
                    }),
                )
                .map_err(|error| {
                    WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}"))
                        .with_context(context)
                })?;
                changed_blocks.push(json!({
                    "blockId": new_block_id,
                    "op": "insert_after",
                    "anchorBlockId": anchor_block_id
                }));
            }
            "delete" | "block_delete" => {
                let block = resolve_target_block(context, &blocks, operation, false)?;
                ensure_block_editable(context, &block)?;
                ensure_operation_block_revision_ref(
                    context,
                    input,
                    operation,
                    &block,
                    "blockRevisionRef",
                )?;
                let block_id = block_id_of(&block).unwrap_or_default();
                ensure_allowed_target(context, input, operation, &block_id, "delete")?;
                ensure_leaf_block(context, &block, "delete")?;
                diff.push(json!({
                    "op": "delete",
                    "blockId": block_id,
                    "before": block.get("text").cloned().unwrap_or(Value::Null)
                }));
                if input.dry_run == Some(true) {
                    continue;
                }
                next_content = apply_editor_command_to_legacy_content(
                    &document_id,
                    &next_content,
                    EditorCommand::DeleteBlock(EditorDeleteBlock {
                        block_id: block_id.clone(),
                        preserve_children: false,
                    }),
                )
                .map_err(|error| {
                    WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}"))
                        .with_context(context)
                })?;
                changed_blocks.push(json!({"blockId": block_id, "op": "delete"}));
            }
            "move_after" | "block_move_after" => {
                let block = resolve_target_block(context, &blocks, operation, false)?;
                let anchor = resolve_anchor_block(context, &blocks, operation)?;
                ensure_block_editable(context, &block)?;
                ensure_operation_block_revision_ref(
                    context,
                    input,
                    operation,
                    &block,
                    "blockRevisionRef",
                )?;
                ensure_operation_block_revision_ref(
                    context,
                    input,
                    operation,
                    &anchor,
                    "anchorRevisionRef",
                )?;
                ensure_leaf_block(context, &block, "move_after")?;
                let block_id = block_id_of(&block).unwrap_or_default();
                let anchor_block_id = block_id_of(&anchor).unwrap_or_default();
                ensure_allowed_target(context, input, operation, &block_id, "move_after")?;
                ensure_allowed_target(
                    context,
                    input,
                    operation,
                    &anchor_block_id,
                    "move_after_anchor",
                )?;
                if block.get("parentBlockId") != anchor.get("parentBlockId")
                    || block_id == anchor_block_id
                {
                    return Err(WebError::bad_request_code(
                        "mnote_block_unsupported",
                        "move_after 批量快路径第一阶段仅支持同父级普通叶子块",
                    )
                    .with_context(context));
                }
                diff.push(json!({
                    "op": "move_after",
                    "blockId": block_id,
                    "anchorBlockId": anchor_block_id
                }));
                if input.dry_run == Some(true) {
                    continue;
                }
                let parent_block_id = anchor
                    .get("parentBlockId")
                    .and_then(Value::as_str)
                    .map(str::to_owned);
                next_content = apply_editor_command_to_legacy_content(
                    &document_id,
                    &next_content,
                    EditorCommand::MoveBlock(EditorMoveBlock {
                        block_id: block_id.clone(),
                        parent_block_id,
                        after_block_id: Some(anchor_block_id.clone()),
                    }),
                )
                .map_err(|error| {
                    WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}"))
                        .with_context(context)
                })?;
                changed_blocks.push(json!({
                    "blockId": block_id,
                    "op": "move_after",
                    "anchorBlockId": anchor_block_id
                }));
            }
            _ => {
                return Err(WebError::bad_request_code(
                    "mnote_tool_bad_request",
                    format!("mnote.doc.apply_block_ops 不支持 op={op}"),
                )
                .with_context(context));
            }
        }
    }

    if input.dry_run == Some(true) {
        return Ok(json!({
            "ok": true,
            "dryRun": true,
            "command": "doc_apply_block_ops",
            "documentId": input.effective_document_id(),
            "workspaceId": input.effective_workspace_id(),
            "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
            "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
            "diff": diff,
            "warnings": [],
            "blocked": false,
            "risk": if operations.len() > 4 { "medium" } else { "low" }
        }));
    }

    execute_page_body_save_from_aggregate(
        state,
        context,
        input,
        &aggregate,
        next_content,
        changed_blocks,
    )
    .await
}

pub(crate) fn ensure_write_contract(
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<(), WebError> {
    crate::hermes_tools::ensure_write_authorized(context, input)
}

fn ensure_leaf_block(
    context: &RequestContext,
    block: &Value,
    operation: &'static str,
) -> Result<(), WebError> {
    let leaf = block
        .get("children")
        .and_then(Value::as_array)
        .map(|children| children.is_empty())
        .unwrap_or(true);
    if leaf {
        return Ok(());
    }
    Err(WebError::bad_request_code(
        "mnote_block_unsupported",
        format!("{operation} 第一阶段仅支持无子块普通块"),
    )
    .with_context(context))
}

fn resolve_target_block(
    context: &RequestContext,
    blocks: &[Value],
    operation: &Value,
    allow_anchor_alias: bool,
) -> Result<Value, WebError> {
    let block_id = operation
        .get("blockId")
        .or_else(|| operation.get("block_id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    if let Some(block_id) = block_id {
        return find_block(blocks, &block_id).ok_or_else(|| block_not_found(context));
    }
    let text = operation
        .get("matchText")
        .or_else(|| operation.get("match_text"))
        .or_else(|| operation.get("text"))
        .or_else(|| {
            if allow_anchor_alias {
                operation
                    .get("anchorText")
                    .or_else(|| operation.get("afterText"))
            } else {
                None
            }
        })
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            WebError::bad_request_code(
                "mnote_tool_bad_request",
                "块操作必须提供 blockId 或 matchText",
            )
            .with_context(context)
        })?;
    resolve_unique_block_by_text(context, blocks, text)
}

fn resolve_anchor_block(
    context: &RequestContext,
    blocks: &[Value],
    operation: &Value,
) -> Result<Value, WebError> {
    if let Some(anchor_id) = operation
        .get("anchorBlockId")
        .or_else(|| operation.get("anchor_block_id"))
        .or_else(|| operation.get("afterBlockId"))
        .or_else(|| operation.get("after_block_id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        return find_block(blocks, anchor_id).ok_or_else(|| block_not_found(context));
    }
    resolve_target_block(context, blocks, operation, true)
}

fn resolve_unique_block_by_text(
    context: &RequestContext,
    blocks: &[Value],
    text: &str,
) -> Result<Value, WebError> {
    let exact = blocks
        .iter()
        .filter(|block| block.get("text").and_then(Value::as_str) == Some(text))
        .cloned()
        .collect::<Vec<_>>();
    if exact.len() == 1 {
        return Ok(exact[0].clone());
    }
    if exact.len() > 1 {
        return Err(WebError::bad_request_code(
            "mnote_block_ambiguous",
            "匹配到多个同文本块，请改用 blockId",
        )
        .with_context(context));
    }
    let contains = blocks
        .iter()
        .filter(|block| {
            block
                .get("text")
                .and_then(Value::as_str)
                .map(|block_text| block_text.contains(text))
                .unwrap_or(false)
        })
        .cloned()
        .collect::<Vec<_>>();
    if contains.len() == 1 {
        return Ok(contains[0].clone());
    }
    if contains.len() > 1 {
        return Err(WebError::bad_request_code(
            "mnote_block_ambiguous",
            "包含匹配命中多个块，请改用 blockId",
        )
        .with_context(context));
    }
    Err(block_not_found(context))
}

fn ensure_page_write_preconditions(
    context: &RequestContext,
    input: &ToolCallInput,
    aggregate: &Value,
) -> Result<(), WebError> {
    if input.dry_run.unwrap_or(false) {
        return Ok(());
    }
    let expected_revision = input
        .arg_value("revision")
        .ok_or_else(|| missing_write_precondition(context, "revision"))?;
    let expected_conflict_key = input
        .arg_string("conflictDetectionKey")
        .ok_or_else(|| missing_write_precondition(context, "conflictDetectionKey"))?;
    let current_revision = aggregate
        .pointer("/body/revision")
        .cloned()
        .unwrap_or(Value::Null);
    let current_conflict_key = aggregate
        .pointer("/body/conflictDetectionKey")
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or("");
    if value_label(&expected_revision).as_deref() != value_label(&current_revision).as_deref()
        || expected_conflict_key != current_conflict_key
    {
        return Err(WebError::bad_request_code(
            "mnote_tool_conflict",
            "页面正文 revision 或 conflictDetectionKey 已变化，请重新读取后再写入",
        )
        .with_context(context));
    }
    Ok(())
}

fn ensure_block_revision_ref(
    context: &RequestContext,
    input: &ToolCallInput,
    block: &Value,
    arg_name: &'static str,
) -> Result<(), WebError> {
    if input.dry_run.unwrap_or(false) {
        return Ok(());
    }
    let expected = input
        .arg_string(arg_name)
        .ok_or_else(|| missing_write_precondition(context, arg_name))?;
    let current = block
        .get("revisionRef")
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or("");
    if expected != current {
        return Err(WebError::bad_request_code(
            "mnote_tool_conflict",
            format!("{arg_name} 已过期，请重新读取 block projection 后再写入"),
        )
        .with_context(context));
    }
    Ok(())
}

fn ensure_operation_block_revision_ref(
    context: &RequestContext,
    input: &ToolCallInput,
    operation: &Value,
    block: &Value,
    arg_name: &'static str,
) -> Result<(), WebError> {
    if input.dry_run.unwrap_or(false) {
        return Ok(());
    }
    let expected = operation
        .get(arg_name)
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| missing_write_precondition(context, arg_name))?;
    let current = block
        .get("revisionRef")
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or("");
    if expected != current {
        return Err(WebError::bad_request_code(
            "mnote_tool_conflict",
            format!("{arg_name} 已过期，请重新读取 block projection 后再写入"),
        )
        .with_context(context));
    }
    Ok(())
}

fn ensure_block_editable(context: &RequestContext, block: &Value) -> Result<(), WebError> {
    if block
        .get("editable")
        .and_then(Value::as_bool)
        .unwrap_or(false)
    {
        return Ok(());
    }
    Err(WebError::bad_request_code(
        "mnote_block_unsupported",
        block
            .get("unsupportedReason")
            .and_then(Value::as_str)
            .unwrap_or("目标块暂不支持 AI 精确写入"),
    )
    .with_context(context))
}

fn insert_after_values(
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<Vec<Value>, WebError> {
    if let Some(blocks) = input.arg_value("blocks") {
        let values = blocks.as_array().ok_or_else(|| {
            WebError::bad_request_code(
                "mnote_tool_bad_request",
                "mnote.block.insert_after 的 blocks 必须是数组",
            )
            .with_context(context)
        })?;
        if values.is_empty() || values.len() > 20 {
            return Err(WebError::bad_request_code(
                "mnote_tool_bad_request",
                "mnote.block.insert_after 的 blocks 数量必须在 1 到 20 之间",
            )
            .with_context(context));
        }
        return Ok(values.clone());
    }
    input
        .arg_value("content")
        .or_else(|| input.arg_value("block"))
        .map(|value| vec![value])
        .ok_or_else(|| {
            WebError::bad_request_code(
                "mnote_tool_bad_request",
                "mnote.block.insert_after 缺少 content、block 或 blocks",
            )
            .with_context(context)
        })
}

fn missing_write_precondition(context: &RequestContext, name: &'static str) -> WebError {
    WebError::bad_request_code(
        "mnote_tool_write_precondition_required",
        format!("真实写入必须携带 {name}；缺少时只能 dryRun=true"),
    )
    .with_context(context)
}

fn value_label(value: &Value) -> Option<String> {
    if let Some(number) = value.as_i64() {
        return Some(number.to_string());
    }
    if let Some(number) = value.as_u64() {
        return Some(number.to_string());
    }
    value
        .as_str()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}

fn revision_number_value(value: Value) -> Option<Value> {
    if let Some(number) = value.as_u64() {
        return Some(json!(number));
    }
    value
        .as_str()
        .map(str::trim)
        .and_then(|value| value.parse::<u64>().ok())
        .map(|number| json!(number))
}

fn same_parent_blocks(blocks: &[Value], block: &Value) -> Vec<Value> {
    let parent = block.get("parentBlockId").cloned().unwrap_or(Value::Null);
    blocks
        .iter()
        .filter(|candidate| {
            candidate
                .get("parentBlockId")
                .cloned()
                .unwrap_or(Value::Null)
                == parent
        })
        .cloned()
        .collect()
}

fn dry_run_result(
    input: &ToolCallInput,
    aggregate: &Value,
    command: &str,
    diff: Value,
    blocked: bool,
) -> Value {
    json!({
        "ok": true,
        "dryRun": true,
        "command": command,
        "documentId": input.effective_document_id(),
        "workspaceId": input.effective_workspace_id(),
        "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
        "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
        "diff": diff,
        "warnings": [],
        "blocked": blocked,
        "risk": "low"
    })
}

/// 如果 editor_actor 已启用，通过它 apply 并获取 legacy content；否则用旧路径计算。
/// 返回 `(content, block_delta_opt)`，其中 block_delta_opt 为序列化后的 JSON（Phase B）。
fn compute_next_content_via_actor(
    state: &AppState,
    aggregate: &Value,
    input: &ToolCallInput,
    current_content: &Value,
    command: &EditorCommand,
    command_name: &str,
) -> Result<(Value, Option<Value>), WebError> {
    if !state.config().enable_editor_actor {
        let content = apply_editor_command_to_legacy_content(
            input.effective_document_id().as_deref().unwrap_or_default(),
            current_content,
            command.clone(),
        )
        .map_err(|error| {
            WebError::bad_request_code("mnote_block_command_failed", format!("{error:?}"))
        })?;
        return Ok((content, None));
    }

    let document_id = input.effective_document_id().ok_or_else(|| {
        WebError::bad_request_code("mnote_tool_bad_request", "actor 路径缺少 documentId")
    })?;
    let workspace_id = input.effective_workspace_id();

    // 确保 actor 已加载此文档
    if !state.editor_actor.is_loaded(&document_id) {
        state
            .editor_actor
            .load_or_init(&document_id, workspace_id.as_deref(), aggregate)?;
    }

    // 在内存中 apply
    let _apply_result =
        state
            .editor_actor
            .apply_command(&document_id, command.clone(), command_name)?;

    // 从 actor 获取 legacy content（用于 Convex save 的 payload）
    let content = state.editor_actor.legacy_content_for_save(&document_id)?;

    // 构建 BlockDelta（Phase B）并序列化为 JSON
    let delta = state
        .editor_actor
        .build_block_delta(&document_id, command)
        .ok()
        .and_then(|bd| serde_json::to_value(bd).ok());

    // Phase C：将 block.delta 推送到 broadcast 广播（SSE 事件 stream）
    if let Some(ref delta_json) = delta {
        state.editor_actor.try_push_block_delta(delta_json);
    }

    Ok((content, delta))
}

async fn execute_page_body_save(
    state: &AppState,
    context: &RequestContext,
    input: &ToolCallInput,
    content: Value,
    changed_blocks: Vec<Value>,
) -> Result<Value, WebError> {
    let document_id = input.effective_document_id().ok_or_else(|| {
        WebError::bad_request_code("mnote_tool_bad_request", "块写工具缺少 documentId")
            .with_context(context)
    })?;
    let workspace_id = input.effective_workspace_id();
    let command_id = format!("page_body_save_{}", context.trace.request_id);
    let payload = json!({
        "documentId": document_id,
        "workspaceId": workspace_id,
        "content": content,
        "mode": "replace",
        "revision": input.arg_value("revision").and_then(revision_number_value),
        "conflictDetectionKey": input.arg_value("conflictDetectionKey")
    });
    let command = RuntimeCommandEnvelopeWire {
        name: "page.body.save".into(),
        command_id: command_id.clone(),
        idempotency_key: Some(input.idempotency_key_or_default(&command_id)),
        actor: RuntimeActorWire {
            actor_type: context.auth.actor_type.clone(),
            actor_id: context.auth.actor_id.clone(),
            session_id: input
                .session_id
                .clone()
                .or_else(|| context.auth.session_id.clone()),
        },
        source: RuntimeSourceWire {
            channel: "hermes".into(),
            client: "mnote-hermes-plugin".into(),
            source_kind: None,
            root_uri: None,
            workspace_id: workspace_id.clone(),
            capabilities: input.capability_scope.clone().unwrap_or_default(),
        },
        target: Some(RuntimeTargetWire {
            workspace_id: workspace_id.clone(),
            page_id: Some(document_id.clone()),
            block_id: None,
        }),
        payload,
        preflight_data: None,
        reason: Some("Hermes block tool page.body.save".into()),
        refs: vec!["page.body.save".into(), "hermes-block-tool-call".into()],
        dry_run: false,
        validate_only: false,
    };
    let execution = execute_runtime_command_via_convex_with_artifacts(
        &state,
        context,
        workspace_id.as_deref(),
        command,
    )
    .await?;
    Ok(json!({
        "commandName": "page.body.save",
        "commandId": command_id,
        "changedBlocks": changed_blocks,
        "result": execution.result,
        "artifacts": execution.artifacts,
        "artifactError": execution.artifact_error
    }))
}

pub(crate) async fn execute_page_body_save_from_aggregate(
    state: &AppState,
    context: &RequestContext,
    input: &ToolCallInput,
    aggregate: &Value,
    content: Value,
    changed_blocks: Vec<Value>,
) -> Result<Value, WebError> {
    let document_id = input.effective_document_id().ok_or_else(|| {
        WebError::bad_request_code("mnote_tool_bad_request", "块写工具缺少 documentId")
            .with_context(context)
    })?;
    let workspace_id = input.effective_workspace_id();
    let command_id = format!("page_body_save_{}", context.trace.request_id);
    let payload = json!({
        "documentId": document_id,
        "workspaceId": workspace_id,
        "content": content,
        "mode": "replace",
        "revision": aggregate.pointer("/body/revision").cloned().and_then(revision_number_value),
        "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned()
    });
    let command = RuntimeCommandEnvelopeWire {
        name: "page.body.save".into(),
        command_id: command_id.clone(),
        idempotency_key: Some(input.idempotency_key_or_default(&command_id)),
        actor: RuntimeActorWire {
            actor_type: context.auth.actor_type.clone(),
            actor_id: context.auth.actor_id.clone(),
            session_id: input
                .session_id
                .clone()
                .or_else(|| context.auth.session_id.clone()),
        },
        source: RuntimeSourceWire {
            channel: "hermes".into(),
            client: "mnote-hermes-plugin".into(),
            source_kind: None,
            root_uri: None,
            workspace_id: workspace_id.clone(),
            capabilities: input.capability_scope.clone().unwrap_or_default(),
        },
        target: Some(RuntimeTargetWire {
            workspace_id: workspace_id.clone(),
            page_id: Some(document_id.clone()),
            block_id: None,
        }),
        payload,
        preflight_data: None,
        reason: Some("Hermes batch block tool page.body.save".into()),
        refs: vec![
            "page.body.save".into(),
            "hermes-batch-block-tool-call".into(),
        ],
        dry_run: false,
        validate_only: false,
    };
    let execution = execute_runtime_command_via_convex_with_artifacts(
        &state,
        context,
        workspace_id.as_deref(),
        command,
    )
    .await?;
    Ok(json!({
        "commandName": "page.body.save",
        "commandId": command_id,
        "changedBlocks": changed_blocks,
        "result": execution.result,
        "artifacts": execution.artifacts,
        "artifactError": execution.artifact_error
    }))
}

fn content_to_text(value: &Value) -> String {
    if let Some(text) = value.as_str() {
        return text.to_string();
    }
    if let Some(text) = value.get("text").and_then(Value::as_str) {
        return text.to_string();
    }
    if let Some(payload) = value.get("payload") {
        return content_to_text(payload);
    }
    if let Some(content_nodes) = value.get("contentNodes") {
        return content_to_text(content_nodes);
    }
    if let Some(content) = value.get("content") {
        return content_to_text(content);
    }
    if let Some(items) = value.as_array() {
        return items
            .iter()
            .map(content_to_text)
            .collect::<Vec<_>>()
            .join("");
    }
    String::new()
}

fn ensure_allowed_target(
    context: &RequestContext,
    input: &ToolCallInput,
    operation: &Value,
    block_id: &str,
    op: &str,
) -> Result<(), WebError> {
    let allowed = allowed_target_block_ids(input, operation);
    if allowed.is_empty() || allowed.contains(block_id) {
        return Ok(());
    }
    Err(WebError::bad_request_code(
        "mnote_block_target_out_of_scope",
        format!("{op} 目标块不在当前 AI selection 允许范围内"),
    )
    .with_context(context))
}

fn allowed_target_block_ids(input: &ToolCallInput, operation: &Value) -> HashSet<String> {
    let mut allowed = HashSet::new();
    if let Some(Value::Array(values)) = input.arg_value("allowedTargetBlockIds") {
        for value in values {
            if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
                allowed.insert(id.to_string());
            }
        }
    }
    if let Some(values) = operation
        .get("allowedTargetBlockIds")
        .and_then(Value::as_array)
    {
        for value in values {
            if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
                allowed.insert(id.to_string());
            }
        }
    }
    allowed
}

fn block_to_ai_content(
    format: &str,
    block: &Value,
    aggregate: &Value,
    document_id: &str,
) -> String {
    let text = block
        .get("text")
        .and_then(Value::as_str)
        .unwrap_or_default();
    match format {
        "page_xml" | "xml" => {
            let revision = aggregate
                .pointer("/body/revision")
                .and_then(Value::as_u64)
                .map(|value| value.to_string())
                .unwrap_or_else(|| {
                    aggregate
                        .pointer("/body/revision")
                        .and_then(Value::as_str)
                        .unwrap_or_default()
                        .to_string()
                });
            let block_id = block_id_of(block).unwrap_or_default();
            let block_type = block
                .get("type")
                .and_then(Value::as_str)
                .unwrap_or("paragraph");
            let revision_ref = block
                .get("revisionRef")
                .and_then(Value::as_str)
                .unwrap_or_default();
            format!(
                "<page id=\"{}\" revision=\"{}\">\n  <block id=\"{}\" type=\"{}\" revisionRef=\"{}\">{}</block>\n</page>",
                escape_xml(document_id),
                escape_xml(&revision),
                escape_xml(&block_id),
                escape_xml(block_type),
                escape_xml(revision_ref),
                escape_xml(text)
            )
        }
        "text" | "plain" => format!("[{}] {text}", block_id_of(block).unwrap_or_default()),
        _ => format!(
            "{text} <!-- block:{} -->",
            block_id_of(block).unwrap_or_default()
        ),
    }
}

fn escape_xml(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}

/// 将 BlockDelta JSON 合并到工具响应中（Phase B）
fn merge_block_delta(response: &mut Value, delta_opt: Option<Value>) {
    if let (Some(delta), Some(obj)) = (delta_opt, response.as_object_mut()) {
        obj.insert("blockDelta".into(), delta);
    }
}

pub(crate) fn current_body_content(aggregate: &Value) -> Value {
    aggregate
        .pointer("/body/content")
        .cloned()
        .unwrap_or_else(|| json!([]))
}

fn build_content_nodes(text: &str) -> Vec<ContentNode> {
    if text.trim().is_empty() {
        return vec![];
    }
    vec![ContentNode {
        payload: ContentNodePayload::Text {
            text: text.to_string(),
            marks: vec![],
        },
        attrs: BTreeMap::new(),
    }]
}

fn editor_block_type_from_value(value: &Value) -> EditorBlockType {
    match value
        .get("type")
        .and_then(Value::as_str)
        .unwrap_or("paragraph")
        .to_ascii_lowercase()
        .as_str()
    {
        "heading" => EditorBlockType::Heading,
        "todo" | "task" => EditorBlockType::Todo,
        "quote" | "blockquote" => EditorBlockType::Quote,
        "code" | "code_block" | "code-block" => EditorBlockType::CodeBlock,
        _ => EditorBlockType::Paragraph,
    }
}

fn block_props_from_value(value: &Value) -> BlockProps {
    let mut props = BlockProps::default();
    let raw_props = value.get("props").and_then(Value::as_object);
    if let Some(level) = raw_props
        .and_then(|map| map.get("level").or_else(|| map.get("headingLevel")))
        .and_then(Value::as_u64)
        .and_then(|value| u8::try_from(value).ok())
    {
        props.heading_level = Some(level.clamp(1, 6));
    }
    if let Some(checked) = raw_props
        .and_then(|map| map.get("checked"))
        .and_then(Value::as_bool)
    {
        props.checked = Some(checked);
    }
    if let Some(language) = raw_props
        .and_then(|map| map.get("language"))
        .and_then(Value::as_str)
        .map(str::to_owned)
    {
        props.language = Some(language);
    }
    props
}

fn replacement_block_type(value: &Value) -> Option<EditorBlockType> {
    value
        .get("type")
        .map(|_| editor_block_type_from_value(value))
}

fn replacement_block_props(value: &Value) -> Option<BlockProps> {
    value.get("props").map(|_| block_props_from_value(value))
}

fn build_editor_block(block_id: &str, value: &Value) -> EditorBlock {
    EditorBlock {
        block_id: block_id.to_string(),
        block_type: editor_block_type_from_value(value),
        props: block_props_from_value(value),
        content_nodes: build_content_nodes(&content_to_text(value)),
        child_block_ids: vec![],
    }
}

fn content_to_legacy(value: &Value) -> Value {
    if value.is_object() && value.get("type").is_some() {
        return value.clone();
    }
    json!({
        "type": "paragraph",
        "content": content_to_text(value)
    })
}

fn build_insert_block(block_id: &str, value: &Value) -> Value {
    let mut block = content_to_legacy(value);
    if let Value::Object(map) = &mut block {
        map.insert("id".into(), json!(block_id));
        map.entry("type").or_insert_with(|| json!("paragraph"));
        map.entry("children").or_insert_with(|| json!([]));
    }
    block
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ensure_write_contract_rejects_read_only_ai_scope() {
        let context = RequestContext::from_http_parts(
            &axum::http::Method::POST,
            &"/api/hermes/tools".parse().expect("uri"),
            &axum::http::HeaderMap::new(),
        );
        let input = ToolCallInput {
            tool_name: "mnote.block.replace".into(),
            workspace_id: Some("ws_1".into()),
            document_id: Some("doc_1".into()),
            source_kind: Some("local_folder".into()),
            root_uri: Some("file:///tmp/mnote-readonly".into()),
            actor_id: Some("user_1".into()),
            profile: None,
            session_id: Some("sess_1".into()),
            run_id: Some("run_1".into()),
            tool_call_id: Some("tool_1".into()),
            trace_id: Some("trace_1".into()),
            idempotency_key: Some("idem_1".into()),
            dry_run: Some(false),
            capability_scope: None,
            args: Some(json!({
                "aiAccessScope": {
                    "permissionLevel": "read_only",
                    "allowedRoots": ["file:///tmp/mnote-readonly"]
                }
            })),
        };

        let error = ensure_write_contract(&context, &input).expect_err("read only rejected");
        assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
        assert_eq!(
            error.message(),
            "当前 AI scope 是只读权限，禁止执行写入型 mnote tool"
        );
    }

    #[test]
    fn content_to_text_reads_projection_content_nodes() {
        let value = json!([
            {
                "attrs": {},
                "payload": {
                    "marks": [],
                    "text": "Hermes 插件替换第二段",
                    "type": "text"
                }
            }
        ]);

        assert_eq!(content_to_text(&value), "Hermes 插件替换第二段");
    }

    #[test]
    fn content_to_text_reads_block_with_content_nodes() {
        let value = json!([
            {
                "type": "paragraph",
                "contentNodes": [
                    {
                        "attrs": {},
                        "payload": {
                            "marks": [],
                            "text": "Hermes 插件插入段",
                            "type": "text"
                        }
                    }
                ]
            }
        ]);

        assert_eq!(content_to_text(&value), "Hermes 插件插入段");
    }
}
