use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::routes::web_shell::build_page_aggregate_snapshot;
use axum::http::StatusCode;
use serde_json::{json, Value};
use std::collections::HashSet;

fn file_version_from_aggregate(aggregate: &Value) -> Value {
    [
        "/body/fileVersion",
        "/body/file_version",
        "/body/conflictDetectionKey",
        "/body/conflict_detection_key",
    ]
    .iter()
    .find_map(|pointer| {
        aggregate
            .pointer(pointer)
            .filter(|value| !value.is_null())
            .cloned()
    })
    .unwrap_or(Value::Null)
}

fn conflict_detection_key_from_aggregate(aggregate: &Value) -> Option<&str> {
    [
        "/body/conflictDetectionKey",
        "/body/conflict_detection_key",
        "/body/fileVersion",
        "/body/file_version",
    ]
    .iter()
    .find_map(|pointer| aggregate.pointer(pointer).and_then(Value::as_str))
}

pub async fn doc_fetch(
    state: &AppState,
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<Value, WebError> {
    let document_id = input.effective_document_id().unwrap_or_default();
    let workspace_id = input.effective_workspace_id();
    ensure_ai_scope_resource_allowed(context, input, &document_id)?;

    // 本地文件路径检测：直接读取授权 root 内的 .md 文件，不经过 Convex。
    let is_local_file = document_id.starts_with('/')
        || document_id.starts_with("./")
        || document_id.starts_with("file://");
    if is_local_file {
        use std::fs;
        let root_uri = local_root_uri_for_tool(input).ok_or_else(|| {
            WebError::new(
                StatusCode::FORBIDDEN,
                "ai_scope_root_uri_required",
                "本地文件读取需要授权 rootUri",
            )
            .with_context(context)
        })?;
        let path = crate::routes::ensure_local_path_read_access(context, &root_uri, &document_id)
            .map_err(|error| error.with_context(context))?;
        let content = fs::read_to_string(path).map_err(|error| {
            WebError::bad_request_code(
                "mnote_tool_bad_request",
                format!("无法读取本地文件: {error}"),
            )
            .with_context(context)
        })?;
        let char_count = content.chars().count();
        let max_chars = input
            .arg_value("maxChars")
            .and_then(|v| v.as_u64())
            .unwrap_or(0) as usize;
        let (result_content, truncated) = if max_chars > 0 && char_count > max_chars {
            (content.chars().take(max_chars).collect::<String>(), true)
        } else {
            (content, false)
        };
        return Ok(json!({
            "ok": true,
            "schema": "mnote.page_ai_context.v1",
            "source": "local_fs",
            "documentId": document_id,
            "workspaceId": workspace_id,
            "rootUri": root_uri,
            "format": "markdown",
            "detail": "simple",
            "scope": "full",
            "content": result_content,
            "truncated": truncated,
            "blocks": json!([]),
            "warnings": json!([])
        }));
    }

    let aggregate = aggregate_value(state, context, input).await?;
    let scope = input
        .arg_string("scope")
        .unwrap_or_else(|| "full".into())
        .to_ascii_lowercase();
    let detail = input
        .arg_string("detail")
        .unwrap_or_else(|| "with_ids".into())
        .to_ascii_lowercase();
    let max_blocks = input
        .arg_value("maxBlocks")
        .and_then(|value| value.as_u64())
        .unwrap_or(120)
        .clamp(1, 240) as usize;
    let mut blocks = block_projection_blocks(&aggregate);
    blocks = match scope.as_str() {
        "outline" => blocks
            .into_iter()
            .filter(|block| block.get("type").and_then(Value::as_str) == Some("heading"))
            .collect(),
        "block" => {
            let block_id = input.arg_string("blockId").ok_or_else(|| {
                WebError::bad_request_code(
                    "mnote_tool_bad_request",
                    "mnote.doc.fetch scope=block 缺少 blockId",
                )
                .with_context(context)
            })?;
            blocks
                .into_iter()
                .filter(|block| block_id_of(block).as_deref() == Some(block_id.as_str()))
                .collect()
        }
        "section" => {
            // 提取第一个标题到下一个同级/高级标题之间的节
            let heading_idx = blocks.iter().position(|block| {
                block
                    .get("type")
                    .and_then(Value::as_str)
                    .map(|t| t == "heading")
                    .unwrap_or(false)
            });
            match heading_idx {
                Some(start) => {
                    let level = blocks[start]
                        .pointer("/attrs/level")
                        .and_then(Value::as_u64)
                        .unwrap_or(2);
                    let mut end = blocks.len();
                    for (idx, block) in blocks.iter().enumerate().skip(start + 1) {
                        if block
                            .get("type")
                            .and_then(Value::as_str)
                            .map(|t| t == "heading")
                            .unwrap_or(false)
                        {
                            if let Some(hl) = block.pointer("/attrs/level").and_then(Value::as_u64)
                            {
                                if hl <= level {
                                    end = idx;
                                    break;
                                }
                            }
                        }
                    }
                    blocks.drain(start..end).collect()
                }
                None => blocks, // 没有标题时返回全文
            }
        }
        "keyword" => {
            let query = input.arg_string("query").ok_or_else(|| {
                WebError::bad_request_code(
                    "mnote_tool_bad_request",
                    "mnote.doc.fetch scope=keyword 缺少 query",
                )
                .with_context(context)
            })?;
            filter_blocks_by_query(blocks, &query)
        }
        "selection" => {
            let selected_ids = selected_block_ids(input);
            if selected_ids.is_empty() {
                return Err(WebError::bad_request_code(
                    "mnote_tool_bad_request",
                    "mnote.doc.fetch scope=selection 缺少 selectedBlockIds",
                )
                .with_context(context));
            }
            blocks
                .into_iter()
                .filter(|block| {
                    block_id_of(block)
                        .map(|block_id| selected_ids.contains(&block_id))
                        .unwrap_or(false)
                })
                .collect()
        }
        _ => blocks,
    };
    let max_chars = input
        .arg_value("maxChars")
        .and_then(|v| v.as_u64())
        .unwrap_or(0) as usize;
    let truncated = blocks.len() > max_blocks;
    blocks.truncate(max_blocks);
    let format = input
        .arg_string("format")
        .unwrap_or_else(|| "json".into())
        .to_ascii_lowercase();
    let include_ids = detail == "with_ids" || detail == "full";
    let raw_content = blocks_to_content(&format, &blocks, include_ids, &document_id, &aggregate);
    // maxChars 截断 + 片段包装
    let mut content = raw_content;
    let mut char_truncated = false;
    if max_chars > 0 && content.chars().count() > max_chars {
        content = content.chars().take(max_chars).collect();
        char_truncated = true;
    }
    // 片段包装：非 full scope 时包裹注释标记
    let is_partial = scope != "full";
    if is_partial {
        let marker = match scope.as_str() {
            "section" => format!("<!-- fragment: section -->\n"),
            "outline" => "<!-- fragment: outline -->\n".to_string(),
            "keyword" => {
                let query = input.arg_string("query").unwrap_or_default();
                format!("<!-- fragment: keyword \"{}\" -->\n", query)
            }
            "block" => "<!-- fragment: block -->\n".to_string(),
            "selection" => "<!-- fragment: selection -->\n".to_string(),
            _ => String::new(),
        };
        if !marker.is_empty() {
            content = format!("{}{}\n<!-- /fragment -->", marker, content);
        }
    }
    let truncated_final = truncated || char_truncated;
    let mut warnings_list = Vec::new();
    if truncated {
        warnings_list.push(json!({
            "code": "mnote_doc_fetch_truncated",
            "message": "结果已按 maxBlocks 裁剪",
            "maxBlocks": max_blocks
        }));
    }
    if char_truncated {
        warnings_list.push(json!({
            "code": "mnote_doc_fetch_char_truncated",
            "message": "结果已按 maxChars 裁剪",
            "maxChars": max_chars
        }));
    }
    let source = if document_id.starts_with('/')
        || document_id.starts_with("./")
        || document_id.contains('/')
    {
        "local_fs"
    } else {
        "convex"
    };
    Ok(json!({
        "ok": true,
        "schema": "mnote.page_ai_context.v1",
        "documentId": document_id,
        "workspaceId": workspace_id,
        "source": source,
        "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
        "conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
        "fileVersion": file_version_from_aggregate(&aggregate),
        "format": format,
        "detail": detail,
        "scope": scope,
        "content": content,
        "blocks": blocks,
        "allowedTargetBlockIds": selected_block_ids(input),
        "truncated": truncated_final,
        "continuation": if truncated_final { json!({"maxBlocks": max_blocks, "maxChars": max_chars}) } else { Value::Null },
        "warnings": warnings_list
    }))
}

pub async fn doc_find(
    state: &AppState,
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<Value, WebError> {
    if let Some(document_id) = input.effective_document_id() {
        ensure_ai_scope_resource_allowed(context, input, &document_id)?;
    }
    let aggregate = aggregate_value(state, context, input).await?;
    let query = input.arg_string("query").ok_or_else(|| {
        WebError::bad_request_code("mnote_tool_bad_request", "mnote.doc.find 缺少 query")
            .with_context(context)
    })?;
    let match_kind = input
        .arg_string("match")
        .unwrap_or_else(|| "text".into())
        .to_ascii_lowercase();
    let limit = input
        .arg_value("limit")
        .and_then(|value| value.as_u64())
        .unwrap_or(20)
        .clamp(1, 50) as usize;
    let mut matches = Vec::new();
    for block in block_projection_blocks(&aggregate) {
        let matched = match match_kind.as_str() {
            "type" => block
                .get("type")
                .and_then(Value::as_str)
                .map(|value| value.eq_ignore_ascii_case(&query))
                .unwrap_or(false),
            "block_id" | "blockid" => block_id_of(&block).as_deref() == Some(query.as_str()),
            _ => block
                .get("text")
                .and_then(Value::as_str)
                .map(|text| text.contains(&query))
                .unwrap_or(false),
        };
        if matched {
            matches.push(json!({
                "blockId": block.get("blockId").cloned().unwrap_or(Value::Null),
                "type": block.get("type").cloned().unwrap_or(Value::Null),
                "text": block.get("text").cloned().unwrap_or(Value::Null),
                "path": block.get("path").cloned().unwrap_or(Value::Null),
                "revisionRef": block.get("revisionRef").cloned().unwrap_or(Value::Null),
                "score": 1.0
            }));
            if matches.len() >= limit {
                break;
            }
        }
    }
    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),
        "fileVersion": file_version_from_aggregate(&aggregate),
        "matches": matches
    }))
}

pub async fn plan_update(
    state: &AppState,
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<Value, WebError> {
    if !input.has_idempotency_key() {
        return Err(WebError::bad_request_code(
            "mnote_tool_idempotency_required",
            "写入计划型 mnote Hermes tool 必须携带 idempotencyKey",
        )
        .with_context(context));
    }
    if input.dry_run != Some(true) {
        return Err(WebError::bad_request_code(
            "mnote_tool_dry_run_required",
            "mnote.doc.plan_update 第一阶段只允许 dryRun=true",
        )
        .with_context(context));
    }
    let aggregate = aggregate_value(state, context, input).await?;
    let command = input
        .arg_string("command")
        .unwrap_or_else(|| "block_replace".into())
        .to_ascii_lowercase();
    let blocks = block_projection_blocks(&aggregate);
    let diff = match command.as_str() {
        "block_replace" => {
            let block_id = required_arg(input, context, "blockId")?;
            let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
            vec![json!({
                "op": "replace",
                "targetBlockId": block_id,
                "before": block.get("text").cloned().unwrap_or(Value::Null),
                "after": input.arg_value("content").unwrap_or(Value::Null)
            })]
        }
        "block_insert_after" => {
            let anchor = input
                .arg_string("anchorBlockId")
                .or_else(|| input.arg_string("afterBlockId"))
                .ok_or_else(|| {
                    WebError::bad_request_code(
                        "mnote_tool_bad_request",
                        "mnote.doc.plan_update block_insert_after 缺少 anchorBlockId",
                    )
                    .with_context(context)
                })?;
            let block = find_block(&blocks, &anchor).ok_or_else(|| block_not_found(context))?;
            vec![json!({
                "op": "insert_after",
                "anchorBlockId": anchor,
                "after": block.get("text").cloned().unwrap_or(Value::Null),
                "content": input.arg_value("content").unwrap_or(Value::Null)
            })]
        }
        "block_move_after" => {
            let block_id = required_arg(input, context, "blockId")?;
            let anchor = required_arg(input, context, "anchorBlockId")?;
            let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
            let anchor_block =
                find_block(&blocks, &anchor).ok_or_else(|| block_not_found(context))?;
            let blocked = block_move_after_blocked(&block, &anchor_block, &block_id, &anchor);
            vec![json!({
                "op": "move_after",
                "blockId": block_id,
                "anchorBlockId": anchor,
                "supportedForWrite": !blocked,
                "blocked": blocked
            })]
        }
        "block_delete" => {
            let block_id = required_arg(input, context, "blockId")?;
            let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
            let blocked = block
                .get("children")
                .and_then(Value::as_array)
                .map(|children| !children.is_empty())
                .unwrap_or(false);
            vec![json!({
                "op": "delete",
                "blockId": block_id,
                "before": block.get("text").cloned().unwrap_or(Value::Null),
                "supportedForWrite": !blocked,
                "blocked": blocked
            })]
        }
        "str_replace" => vec![json!({
            "op": "str_replace",
            "query": input.arg_value("query").unwrap_or(Value::Null),
            "replacement": input.arg_value("content").unwrap_or(Value::Null)
        })],
        other => {
            return Err(WebError::bad_request_code(
                "mnote_tool_bad_request",
                format!("mnote.doc.plan_update 不支持 command={other}"),
            )
            .with_context(context));
        }
    };
    let plan_blocked = matches!(command.as_str(), "block_move_after" | "block_delete")
        && diff
            .first()
            .and_then(|item| item.get("blocked"))
            .and_then(Value::as_bool)
            .unwrap_or(false);
    Ok(json!({
        "ok": true,
        "dryRun": true,
        "planId": format!("plan_{}", context.trace.request_id),
        "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),
        "fileVersion": file_version_from_aggregate(&aggregate),
        "command": command,
        "diff": diff,
        "warnings": if plan_blocked {
            json!([{
                "code": "block_move_after_blocked",
                "message": "第一阶段仅开放同父级普通叶子块移动，且不能移动到自身之后"
            }])
        } else {
            json!([])
        },
        "risk": if command == "block_move_after" { "medium" } else { "low" },
        "blocked": plan_blocked
    }))
}

fn block_move_after_blocked(
    block: &Value,
    anchor: &Value,
    block_id: &str,
    anchor_id: &str,
) -> bool {
    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);
    !same_parent || !leaf || !movable_type || !editable || block_id == anchor_id
}

pub(crate) async fn aggregate_value(
    state: &AppState,
    context: &RequestContext,
    input: &ToolCallInput,
) -> 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)
    })?;
    ensure_ai_scope_resource_allowed(context, input, &document_id)?;
    let workspace_id = input.effective_workspace_id();
    let source_kind = input.effective_source_kind();
    let root_uri = input.effective_root_uri();
    let aggregate = build_page_aggregate_snapshot(
        state,
        context,
        &document_id,
        workspace_id.as_deref(),
        source_kind.as_deref(),
        root_uri.as_deref(),
    )
    .await?;
    serde_json::to_value(&aggregate).map_err(|error| WebError::internal(error.to_string()))
}

pub(crate) fn ensure_ai_scope_resource_allowed(
    context: &RequestContext,
    input: &ToolCallInput,
    document_id: &str,
) -> Result<(), WebError> {
    let Some(scope) = input.arg_value("aiAccessScope") else {
        return Ok(());
    };
    let allowed = scope
        .get("allowedResourceIds")
        .or_else(|| scope.get("allowed_resource_ids"))
        .and_then(Value::as_array)
        .map(|values| {
            values
                .iter()
                .filter_map(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .collect::<HashSet<_>>()
        })
        .unwrap_or_default();
    if allowed.is_empty() || allowed.contains(document_id) {
        return Ok(());
    }
    Err(WebError::new(
        StatusCode::FORBIDDEN,
        "mnote_tool_ai_scope_read_forbidden",
        "当前 AI scope 不允许读取该资源",
    )
    .with_context(context))
}

fn local_root_uri_for_tool(input: &ToolCallInput) -> Option<String> {
    input.effective_root_uri().or_else(|| {
        input
            .arg_value("aiAccessScope")
            .and_then(|scope| {
                scope
                    .get("allowedRoots")
                    .or_else(|| scope.get("allowed_roots"))
                    .cloned()
            })
            .and_then(|allowed_roots| {
                allowed_roots.as_array().and_then(|roots| {
                    roots
                        .iter()
                        .filter_map(|root| {
                            root.get("rootUri")
                                .or_else(|| root.get("root_uri"))
                                .and_then(Value::as_str)
                        })
                        .map(str::trim)
                        .find(|root_uri| !root_uri.is_empty())
                        .map(ToOwned::to_owned)
                })
            })
    })
}

pub(crate) fn block_projection_blocks(aggregate: &Value) -> Vec<Value> {
    aggregate
        .pointer("/body/blockDocument/blocks")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default()
}

pub(crate) fn block_id_of(block: &Value) -> Option<String> {
    block
        .get("blockId")
        .or_else(|| block.get("id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}

pub(crate) fn find_block(blocks: &[Value], block_id: &str) -> Option<Value> {
    blocks
        .iter()
        .find(|block| block_id_of(block).as_deref() == Some(block_id))
        .cloned()
}

pub(crate) fn required_arg(
    input: &ToolCallInput,
    context: &RequestContext,
    key: &'static str,
) -> Result<String, WebError> {
    input.arg_string(key).ok_or_else(|| {
        WebError::bad_request_code("mnote_tool_bad_request", format!("工具调用缺少 {key}"))
            .with_context(context)
    })
}

pub(crate) fn block_not_found(context: &RequestContext) -> WebError {
    WebError::bad_request_code("mnote_block_not_found", "块不存在").with_context(context)
}

fn filter_blocks_by_query(blocks: Vec<Value>, query: &str) -> Vec<Value> {
    blocks
        .into_iter()
        .filter(|block| {
            block
                .get("text")
                .and_then(Value::as_str)
                .map(|text| text.contains(query))
                .unwrap_or(false)
        })
        .collect()
}

// ── 7-27: Markdown → Block Content 直接写回 ────────────────────────────

/// 解析后的单个块信息，用作 final_md → page.body.save 的中间表示
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct ParsedBlockInfo {
    /// 原始 block_id；None 表示新块（full_content 或无注释的新 paragraph）
    block_id: Option<String>,
    block_type: String,
    text: String,
    block_revision_ref: Option<String>,
    /// 标记为新建块——写入层分配新 ID
    is_new: bool,
    /// 从原始 block projection 中复制的完整块对象
    original: Option<Value>,
    /// 原始块的 props（如 heading level）
    props: Option<Value>,
    /// 复杂块（resource/mindmap/table）的原始内容——只保留，不改动
    is_complex: bool,
}

/// 将搜索替换后的最终 markdown 解析为块列表，保留原始块的元数据
///
/// * `final_md` — `search_replace` 应用后的最终 markdown
/// * `originals` — 从 `aggregate.body.blockDocument.blocks` 读取的原始块
fn parse_final_markdown_to_blocks(final_md: &str, originals: &[Value]) -> Vec<ParsedBlockInfo> {
    let originals_by_id: std::collections::HashMap<String, &Value> = originals
        .iter()
        .filter_map(|block| block_id_of(block).map(|id| (id, block)))
        .collect();

    let mut blocks = Vec::new();

    for line in final_md.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }

        // 尝试提取 <!-- block:id:type:rev[:level=N] --> 注释
        let (text_part, meta) = extract_block_comment(line);

        // 检测复杂块标记 [mnote-raw-block:id]
        if let Some(raw_id) = extract_raw_block_marker(line) {
            if let Some(original) = originals_by_id.get(&raw_id) {
                blocks.push(ParsedBlockInfo {
                    block_id: Some(raw_id.clone()),
                    block_type: original
                        .get("type")
                        .and_then(Value::as_str)
                        .unwrap_or("paragraph")
                        .to_string(),
                    text: block_text(original),
                    block_revision_ref: original
                        .get("revisionRef")
                        .and_then(Value::as_str)
                        .map(str::to_string),
                    is_new: false,
                    original: Some((*original).clone()),
                    props: original.get("props").cloned(),
                    is_complex: true,
                });
            }
            continue;
        }

        if let Some((block_id, block_type, _level)) = meta {
            // 匹配到注释的块——从 originals 继承元数据
            if let Some(original) = originals_by_id.get(&block_id) {
                let raw_text = text_part.unwrap_or_else(|| block_text(original));
                blocks.push(ParsedBlockInfo {
                    block_id: Some(block_id.clone()),
                    block_type,
                    text: strip_prefix(&raw_text),
                    block_revision_ref: original
                        .get("revisionRef")
                        .and_then(Value::as_str)
                        .map(str::to_string),
                    is_new: false,
                    original: Some((*original).clone()),
                    props: original.get("props").cloned(),
                    is_complex: false,
                });
            } else {
                // 注释中的 block_id 不在 originals 中（不应该发生）
                let raw_text = text_part.unwrap_or_default();
                blocks.push(ParsedBlockInfo {
                    block_id: Some(block_id),
                    block_type,
                    text: strip_prefix(&raw_text),
                    block_revision_ref: None,
                    is_new: true,
                    original: None,
                    props: None,
                    is_complex: false,
                });
            }
        } else {
            // 无注释的纯文本行 → 新 paragraph 块
            let block_type = detect_block_type_from_prefix(line);
            let text = strip_prefix(line);
            blocks.push(ParsedBlockInfo {
                block_id: None,
                block_type,
                text,
                block_revision_ref: None,
                is_new: true,
                original: None,
                props: None,
                is_complex: false,
            });
        }
    }

    blocks
}

/// 从行中提取 `<!-- block:id:type[:level=N] -->` 注释及其前的文本
fn extract_block_comment(line: &str) -> (Option<String>, Option<(String, String, Option<u64>)>) {
    let comment_start = line.rfind("<!-- block:");
    let comment_end = line.rfind("-->");
    if let (Some(start), Some(end)) = (comment_start, comment_end) {
        let before = line[..start].trim();
        // 完整注释区间: "<!-- block:... -->" (3 chars for "-->")
        let comment = &line[start..end + 3];
        let inner = comment
            .strip_prefix("<!--")
            .and_then(|s| s.strip_suffix("-->"))
            .map(str::trim)
            .unwrap_or("");

        // 期望格式: block:ID:TYPE 或 block:ID:TYPE:level=N
        // TYPE 中不含冒号（paragraph/heading/todo/resource/mindmap/table/image）
        let parts: Vec<&str> = inner.splitn(3, ':').collect();
        if parts.len() >= 2 && parts[0] == "block" {
            let id = parts[1].to_string();
            let rest = if parts.len() >= 3 { parts[2] } else { "" };

            // 尝试从 rest 中分割 type 和 level
            let (typ, level) = if let Some(level_idx) = rest.find(":level=") {
                let typ = rest[..level_idx].to_string();
                let level_str = &rest[level_idx + 7..]; // ":level=" is 7 chars
                let level = level_str.parse::<u64>().ok();
                (typ, level)
            } else {
                (rest.to_string(), None)
            };

            let typ = if typ.is_empty() {
                "paragraph".to_string()
            } else {
                typ
            };
            let text = if before.is_empty() {
                None
            } else {
                Some(before.to_string())
            };
            return (text, Some((id, typ, level)));
        }

        // 向后兼容旧格式: <!-- block:ID -->
        let parts_old: Vec<&str> = inner.splitn(2, ':').collect();
        if parts_old.len() == 2 && parts_old[0] == "block" {
            let id = parts_old[1].to_string();
            let text = if before.is_empty() {
                None
            } else {
                Some(before.to_string())
            };
            return (text, Some((id, "paragraph".to_string(), None)));
        }
    }
    (None, None)
}

/// 提取 `[mnote-raw-block:ID]` 标记
fn extract_raw_block_marker(line: &str) -> Option<String> {
    let start = line.find("[mnote-raw-block:");
    let end = line.find(']');
    if let (Some(start), Some(end)) = (start, end) {
        let id = &line[start + 18..end];
        return Some(id.to_string());
    }
    None
}

/// 从行前缀推断块类型
fn detect_block_type_from_prefix(line: &str) -> String {
    let trimmed = line.trim();
    if trimmed.starts_with("## ") {
        "heading".to_string()
    } else if trimmed.starts_with("- [ ] ")
        || trimmed.starts_with("- [x] ")
        || trimmed.starts_with("- [X] ")
    {
        "todo".to_string()
    } else {
        "paragraph".to_string()
    }
}

/// 去掉 markdown 前缀（`## ` / `- [ ] `），返回纯文本
fn strip_prefix(line: &str) -> String {
    let trimmed = line.trim();
    if let Some(rest) = trimmed.strip_prefix("## ") {
        rest.to_string()
    } else if let Some(rest) = trimmed.strip_prefix("- [ ] ") {
        rest.to_string()
    } else if let Some(rest) = trimmed.strip_prefix("- [x] ") {
        rest.to_string()
    } else if let Some(rest) = trimmed.strip_prefix("- [X] ") {
        rest.to_string()
    } else {
        trimmed.to_string()
    }
}

/// 将解析后的块列表与原始 `body/content` 合并，生成最终写回 blocks 数组
fn build_page_content(original_content: &Value, parsed: &[ParsedBlockInfo]) -> Value {
    // 为 original_content 建立 block_id → index + full_block 映射
    let original_blocks: Vec<Value> = original_content.as_array().cloned().unwrap_or_default();
    let _original_index_by_id: std::collections::HashMap<String, usize> = original_blocks
        .iter()
        .enumerate()
        .filter_map(|(i, block)| block_id_of(block).map(|id| (id, i)))
        .collect();
    let original_by_id: std::collections::HashMap<String, &Value> = original_blocks
        .iter()
        .filter_map(|block| block_id_of(block).map(|id| (id, block)))
        .collect();

    let mut result = Vec::new();
    let mut processed_ids = std::collections::HashSet::new();
    let parsed_references_original = parsed.iter().any(|parsed_block| {
        parsed_block
            .block_id
            .as_ref()
            .map(|block_id| original_by_id.contains_key(block_id))
            .unwrap_or(false)
    });

    for parsed_block in parsed {
        if let Some(ref block_id) = parsed_block.block_id {
            processed_ids.insert(block_id.clone());

            if parsed_block.is_complex {
                // 复杂块：原样保留
                if let Some(original) = original_by_id.get(block_id) {
                    result.push((*original).clone());
                }
            } else if let Some(original) = original_by_id.get(block_id) {
                let updated =
                    update_legacy_block_text_for_markdown_edit(original, block_id, parsed_block);
                result.push(updated);
            }
        } else if parsed_block.is_new {
            // 新块：生成 ID 并构造 legacy 格式的 paragraph 结构
            use std::sync::atomic::{AtomicU64, Ordering};
            static COUNTER: AtomicU64 = AtomicU64::new(0);
            let next = COUNTER.fetch_add(1, Ordering::Relaxed);
            let new_id = format!("ai_block_md_{}", next);
            result.push(json!({
                "id": new_id,
                "type": parsed_block.block_type,
                "content": parsed_block.text,
            }));
        }
    }

    // 追加未被处理的原始块（保留顺序）
    for original in &original_blocks {
        if let Some(id) = block_id_of(original) {
            if !processed_ids.contains(&id)
                && (parsed_references_original || is_complex_markdown_original_block(original))
            {
                result.push(original.clone());
            }
        }
    }

    Value::Array(result)
}

fn update_legacy_block_text_for_markdown_edit(
    original: &Value,
    block_id: &str,
    parsed_block: &ParsedBlockInfo,
) -> Value {
    let original_text = legacy_block_text(original);
    if original_text == parsed_block.text {
        return original.clone();
    }

    let mut updated = original.clone();
    if let Some(obj) = updated.as_object_mut() {
        obj.remove("blockId");
        if !obj.contains_key("id") {
            obj.insert("id".into(), json!(block_id));
        }
        if let Some(content) = obj.get_mut("content") {
            if replace_single_inline_text_value(content, &parsed_block.text) {
                obj.remove("text");
                return updated;
            }
        }
        if let Some(content_nodes) = obj.get_mut("contentNodes") {
            if replace_single_inline_text_value(content_nodes, &parsed_block.text) {
                obj.remove("text");
                return updated;
            }
        }
        obj.insert("content".into(), json!(parsed_block.text));
        obj.remove("contentNodes");
        obj.remove("text");
    }
    updated
}

fn replace_single_inline_text_value(value: &mut Value, text: &str) -> bool {
    if value.is_string() {
        *value = json!(text);
        return true;
    }
    if let Some(items) = value.as_array_mut() {
        if items.len() != 1 {
            return false;
        }
        return replace_single_inline_text_value(&mut items[0], text);
    }
    if let Some(object) = value.as_object_mut() {
        if object.get("text").and_then(Value::as_str).is_some() {
            object.insert("text".into(), json!(text));
            return true;
        }
        if let Some(payload) = object.get_mut("payload") {
            return replace_single_inline_text_value(payload, text);
        }
    }
    false
}

fn legacy_block_text(block: &Value) -> String {
    if let Some(text) = block.get("text").and_then(Value::as_str) {
        return text.to_string();
    }
    if let Some(content) = block.get("content") {
        return inline_text_value(content);
    }
    if let Some(content_nodes) = block.get("contentNodes") {
        return inline_text_value(content_nodes);
    }
    String::new()
}

fn inline_text_value(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 inline_text_value(payload);
    }
    if let Some(content) = value.get("content") {
        return inline_text_value(content);
    }
    if let Some(items) = value.as_array() {
        return items
            .iter()
            .map(inline_text_value)
            .collect::<Vec<_>>()
            .join("");
    }
    String::new()
}

fn is_complex_markdown_original_block(block: &Value) -> bool {
    let block_type = block
        .get("type")
        .and_then(Value::as_str)
        .unwrap_or("paragraph")
        .to_ascii_lowercase();
    matches!(
        block_type.as_str(),
        "resource" | "mindmap" | "table" | "image" | "embed" | "attachment"
    ) || block
        .get("editable")
        .and_then(Value::as_bool)
        .map(|editable| !editable)
        .unwrap_or(false)
        || block.get("unsupportedReason").is_some()
        || block
            .get("children")
            .and_then(Value::as_array)
            .map(|children| !children.is_empty())
            .unwrap_or(false)
}

/// 生成 changedBlocks 摘要（格式与 `doc_apply_block_ops` 输出兼容）
fn build_changed_blocks_summary(
    original_blocks: &[Value],
    parsed: &[ParsedBlockInfo],
) -> Vec<Value> {
    let original_by_id: std::collections::HashMap<String, &Value> = original_blocks
        .iter()
        .filter_map(|block| block_id_of(block).map(|id| (id, block)))
        .collect();
    let parsed_by_id: std::collections::HashMap<String, &ParsedBlockInfo> = parsed
        .iter()
        .filter_map(|p| p.block_id.as_ref().map(|id| (id.clone(), p)))
        .collect();

    let mut changed = Vec::new();

    for (id, _original) in &original_by_id {
        if let Some(parsed_block) = parsed_by_id.get(id) {
            if !parsed_block.is_complex {
                let original_text = original_by_id
                    .get(id)
                    .map(|block| block_text(block))
                    .unwrap_or_default();
                if original_text != parsed_block.text {
                    changed.push(json!({
                        "op": "replace",
                        "blockId": id,
                        "blockRevisionRef": parsed_block.block_revision_ref,
                        "content": parsed_block.text
                    }));
                }
            }
        }
    }

    // 新块标记为 insert（无原始 block_id）
    for parsed_block in parsed {
        if parsed_block.is_new {
            changed.push(json!({
                "op": "insert",
                "content": parsed_block.text,
                "type": parsed_block.block_type
            }));
        }
    }

    changed
}

fn ensure_markdown_changed_blocks_within_allowed(
    context: &RequestContext,
    changed_blocks: &[Value],
    allowed_block_ids: &[String],
) -> Result<(), WebError> {
    if allowed_block_ids.is_empty() {
        return Ok(());
    }
    let allowed: HashSet<&str> = allowed_block_ids.iter().map(String::as_str).collect();
    for change in changed_blocks {
        let Some(block_id) = change.get("blockId").and_then(Value::as_str) else {
            return Err(WebError::bad_request_code(
                "mnote_markdown_edit_target_out_of_scope",
                "selection 范围内的 markdown_edit 不允许写入无法归属到 blockId 的变更",
            )
            .with_context(context));
        };
        if !allowed.contains(block_id) {
            return Err(WebError::bad_request_code(
                "mnote_markdown_edit_target_out_of_scope",
                format!("markdown_edit 目标块 {block_id} 不在当前 AI selection 允许范围内"),
            )
            .with_context(context));
        }
    }
    Ok(())
}

fn selected_block_ids(input: &ToolCallInput) -> Vec<String> {
    let mut seen = HashSet::new();
    let mut ids = Vec::new();
    for key in ["selectedBlockIds", "allowedTargetBlockIds"] {
        if let Some(Value::Array(values)) = input.arg_value(key) {
            for value in values {
                if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
                    if seen.insert(id.to_string()) {
                        ids.push(id.to_string());
                    }
                }
            }
        }
    }
    for key in ["selectedBlockId", "blockId"] {
        if let Some(id) = input.arg_string(key) {
            if seen.insert(id.clone()) {
                ids.push(id);
            }
        }
    }
    ids
}

fn blocks_to_content(
    format: &str,
    blocks: &[Value],
    include_ids: bool,
    document_id: &str,
    aggregate: &Value,
) -> String {
    match format {
        "page_xml" | "xml" => blocks_to_page_xml(blocks, document_id, aggregate),
        "text" | "plain" => blocks_to_text(blocks, include_ids),
        "markdown" | "md" => blocks_to_markdown(blocks, include_ids),
        _ => blocks_to_markdown(blocks, include_ids),
    }
}

fn blocks_to_text(blocks: &[Value], include_ids: bool) -> String {
    blocks
        .iter()
        .map(|block| {
            let text = block_text(block);
            if include_ids {
                format!("[{}] {text}", block_id_of(block).unwrap_or_default())
            } else {
                text
            }
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn blocks_to_markdown(blocks: &[Value], include_ids: bool) -> String {
    blocks
        .iter()
        .map(|block| {
            let text = block_text(block);
            let block_type = block
                .get("type")
                .and_then(Value::as_str)
                .unwrap_or("paragraph");
            let prefix = match block_type {
                "heading" => "## ",
                "todo" | "task" => "- [ ] ",
                _ => "",
            };
            if include_ids {
                let id = block_id_of(block).unwrap_or_default();
                let editable = block
                    .get("editable")
                    .and_then(Value::as_bool)
                    .unwrap_or(true);
                let unsupported = block.get("unsupportedReason").and_then(Value::as_str);
                if !editable || unsupported.is_some() {
                    // 复杂块（resource/mindmap/table/image）：特殊标记供解析器原样保留
                    format!("[mnote-raw-block:{id}] <!-- block:{id}:{block_type} -->")
                } else if block_type == "heading" {
                    let level = block
                        .pointer("/props/level")
                        .and_then(Value::as_u64)
                        .unwrap_or(2);
                    format!("{prefix}{text} <!-- block:{id}:{block_type}:level={level} -->")
                } else {
                    format!("{prefix}{text} <!-- block:{id}:{block_type} -->")
                }
            } else {
                format!("{prefix}{text}")
            }
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn blocks_to_page_xml(blocks: &[Value], document_id: &str, aggregate: &Value) -> String {
    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 mut output = format!(
        "<page id=\"{}\" revision=\"{}\">",
        escape_xml(document_id),
        escape_xml(&revision)
    );
    for block in blocks {
        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();
        output.push_str(&format!(
            "\n  <block id=\"{}\" type=\"{}\" revisionRef=\"{}\"",
            escape_xml(&block_id),
            escape_xml(block_type),
            escape_xml(revision_ref)
        ));
        if let Some(level) = block
            .pointer("/attrs/level")
            .or_else(|| block.pointer("/props/level"))
        {
            if let Some(level) = level.as_u64() {
                output.push_str(&format!(" level=\"{}\"", level));
            }
        }
        output.push_str(&format!(">{}</block>", escape_xml(&block_text(block))));
    }
    output.push_str("\n</page>");
    output
}

fn block_text(block: &Value) -> String {
    block
        .get("text")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string()
}

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

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

    #[test]
    fn test_search_replace_exact() {
        assert_eq!(
            search_replace("第一段内容。\n第二段内容。", "第一段内容", "替换后").unwrap(),
            "替换后。\n第二段内容。"
        );
    }

    #[test]
    fn test_search_replace_normalized_chinese_byte_boundaries() {
        assert_eq!(
            search_replace("前缀：第一段内容", "一 段", "二段").unwrap(),
            "前缀：第二段内容"
        );
    }

    #[test]
    fn test_search_replace_not_found() {
        let result = search_replace("第一段内容。", "不存在的文本", "替换");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("无法匹配"));
    }

    #[test]
    fn test_search_replace_full_content() {
        // 全文替换：search 等于全文
        let result = search_replace("全文内容", "全文内容", "新全文").unwrap();
        assert_eq!(result, "新全文");
    }

    #[test]
    fn test_blocks_to_markdown_with_ids() {
        let blocks = json!([
            {"blockId": "b1", "text": "第一段", "type": "paragraph"},
            {"blockId": "b2", "text": "第二段", "type": "paragraph"}
        ]);
        let blocks: Vec<Value> = blocks.as_array().unwrap().clone();
        let md = blocks_to_markdown(&blocks, true);
        assert!(md.contains("第一段 <!-- block:b1:paragraph -->"));
        assert!(md.contains("第二段 <!-- block:b2:paragraph -->"));
    }

    #[test]
    fn test_blocks_to_markdown_heading() {
        let blocks = json!([
            {"blockId": "h1", "text": "标题", "type": "heading", "props": {"level": 2}},
            {"blockId": "p1", "text": "正文", "type": "paragraph"}
        ]);
        let blocks: Vec<Value> = blocks.as_array().unwrap().clone();
        let md = blocks_to_markdown(&blocks, true);
        assert!(md.contains("标题 <!-- block:h1:heading:level=2 -->"));
        let md2 = blocks_to_markdown(&blocks, false);
        assert!(md2.contains("## 标题"));
        assert!(md2.contains("正文"));
    }

    #[test]
    fn test_blocks_to_markdown_complex_block_uses_raw_marker() {
        let blocks = json!([
            {"blockId": "rsc_1", "text": "资源", "type": "resource", "editable": false, "unsupportedReason": "复杂块暂不开放 AI 精确写入"}
        ]);
        let blocks: Vec<Value> = blocks.as_array().unwrap().clone();
        let md = blocks_to_markdown(&blocks, true);
        assert!(md.contains("[mnote-raw-block:rsc_1]"));
        assert!(md.contains("<!-- block:rsc_1:resource -->"));
    }

    #[test]
    fn test_build_page_content_full_content_replaces_old_text_blocks_but_keeps_complex_blocks() {
        let original_content = json!([
            {"id": "heading_1", "type": "heading", "content": "章节一"},
            {"id": "p_1", "type": "paragraph", "content": "第一段"},
            {"id": "resource_1", "type": "resource", "content": "资源块", "editable": false}
        ]);
        let parsed = vec![
            ParsedBlockInfo {
                block_id: None,
                block_type: "paragraph".into(),
                text: "新章节".into(),
                block_revision_ref: None,
                is_new: true,
                original: None,
                props: None,
                is_complex: false,
            },
            ParsedBlockInfo {
                block_id: None,
                block_type: "paragraph".into(),
                text: "新第一段".into(),
                block_revision_ref: None,
                is_new: true,
                original: None,
                props: None,
                is_complex: false,
            },
        ];
        let next = build_page_content(&original_content, &parsed);
        let next_blocks = next.as_array().expect("array");
        assert_eq!(next_blocks.len(), 3);
        assert_eq!(next_blocks[0]["content"], "新章节");
        assert_eq!(next_blocks[1]["content"], "新第一段");
        assert_eq!(next_blocks[2]["id"], "resource_1");
    }

    #[test]
    fn test_build_page_content_preserves_missing_original_text_block_when_some_ids_remain() {
        let original_content = json!([
            {"id": "heading_1", "type": "heading", "content": "章节一"},
            {"id": "p_1", "type": "paragraph", "content": "第一段"},
            {"id": "p_2", "type": "paragraph", "content": "第二段"}
        ]);
        let parsed = vec![
            ParsedBlockInfo {
                block_id: Some("heading_1".into()),
                block_type: "heading".into(),
                text: "新章节".into(),
                block_revision_ref: None,
                is_new: false,
                original: None,
                props: None,
                is_complex: false,
            },
            ParsedBlockInfo {
                block_id: Some("p_1".into()),
                block_type: "paragraph".into(),
                text: "新第一段".into(),
                block_revision_ref: None,
                is_new: false,
                original: None,
                props: None,
                is_complex: false,
            },
        ];
        let next = build_page_content(&original_content, &parsed);
        let next_blocks = next.as_array().expect("array");
        assert!(next_blocks.iter().any(|block| block["id"] == "p_2"));
        assert!(next_blocks
            .iter()
            .any(|block| block["id"] == "heading_1" && block["content"] == "新章节"));
        assert_eq!(next_blocks.len(), 3);
    }

    #[test]
    fn test_build_page_content_preserves_unchanged_inline_content_nodes() {
        let original_content = json!([
            {
                "id": "p_1",
                "type": "paragraph",
                "content": [
                    {"type": "text", "text": "第一段", "marks": [{"type": "bold"}]}
                ]
            },
            {"id": "p_2", "type": "paragraph", "content": "第二段"}
        ]);
        let parsed = vec![
            ParsedBlockInfo {
                block_id: Some("p_1".into()),
                block_type: "paragraph".into(),
                text: "第一段".into(),
                block_revision_ref: None,
                is_new: false,
                original: None,
                props: None,
                is_complex: false,
            },
            ParsedBlockInfo {
                block_id: Some("p_2".into()),
                block_type: "paragraph".into(),
                text: "第二段已改".into(),
                block_revision_ref: None,
                is_new: false,
                original: None,
                props: None,
                is_complex: false,
            },
        ];
        let next = build_page_content(&original_content, &parsed);
        let p1 = &next.as_array().expect("array")[0];
        assert_eq!(p1["content"][0]["text"], "第一段");
        assert_eq!(p1["content"][0]["marks"][0]["type"], "bold");
    }

    #[test]
    fn test_build_page_content_preserves_single_inline_node_marks_when_text_changes() {
        let original_content = json!([
            {
                "id": "p_1",
                "type": "paragraph",
                "content": [
                    {"type": "text", "text": "第一段", "marks": [{"type": "bold"}]}
                ]
            }
        ]);
        let parsed = vec![ParsedBlockInfo {
            block_id: Some("p_1".into()),
            block_type: "paragraph".into(),
            text: "第一段已改".into(),
            block_revision_ref: None,
            is_new: false,
            original: None,
            props: None,
            is_complex: false,
        }];
        let next = build_page_content(&original_content, &parsed);
        let p1 = &next.as_array().expect("array")[0];
        assert_eq!(p1["content"][0]["text"], "第一段已改");
        assert_eq!(p1["content"][0]["marks"][0]["type"], "bold");
    }
}

// ── mnote.doc.markdown_edit ──────────────────────────────────────────

pub async fn doc_markdown_edit(
    state: &AppState,
    context: &RequestContext,
    input: &ToolCallInput,
) -> Result<Value, WebError> {
    let document_id = input.effective_document_id().unwrap_or_default();
    let workspace_id = input.effective_workspace_id();
    let source_kind = input.effective_source_kind();
    let root_uri = input.effective_root_uri();
    let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
    let is_local_workspace =
        source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some();
    crate::hermes_tools::block::ensure_write_contract(context, input)?;

    // 1. 读取当前文档内容（markdown 形式）
    let (current_md, source) = if is_local_file {
        use std::fs;
        // full_content 模式时允许文件不存在（创建新文件）
        let has_full = input.arg_value("full_content").is_some();
        let content = match fs::read_to_string(&document_id) {
            Ok(c) => c,
            Err(_) if has_full => String::new(), // 创建模式：空内容
            Err(error) => {
                return Err(WebError::bad_request_code(
                    "mnote_tool_bad_request",
                    format!("无法读取本地文件 {document_id}: {error}"),
                )
                .with_context(context));
            }
        };
        (content, "local_fs")
    } else {
        let aggregate = aggregate_value(state, context, input).await?;
        let blocks = block_projection_blocks(&aggregate);
        (
            blocks_to_markdown(&blocks, true),
            if is_local_workspace {
                "local_folder"
            } else {
                "convex"
            },
        )
    };

    // 2. 解析 operations
    let use_full_content = input.arg_string("full_content");
    let operations: Vec<Value> = if let Some(full) = &use_full_content {
        // 全文替换模式
        vec![json!({"search": current_md.trim(), "replace": full.trim()})]
    } else {
        input
            .arg_value("operations")
            .and_then(|v| v.as_array().cloned())
            .ok_or_else(|| {
                WebError::bad_request_code(
                    "mnote_tool_bad_request",
                    "mnote.doc.markdown_edit 缺少 operations 或 full_content",
                )
                .with_context(context)
            })?
    };

    if operations.is_empty() {
        return Err(WebError::bad_request_code(
            "mnote_tool_bad_request",
            "mnote.doc.markdown_edit operations 不能为空",
        )
        .with_context(context));
    }
    if operations.len() > 20 {
        return Err(WebError::bad_request_code(
            "mnote_tool_bad_request",
            "mnote.doc.markdown_edit 一次最多允许 20 个操作",
        )
        .with_context(context));
    }

    // 3. 逐条执行 search_replace
    let mut applied = 0usize;
    let mut failed = Vec::new();
    let mut md = current_md.clone();

    for (idx, op) in operations.iter().enumerate() {
        let search = op
            .get("search")
            .and_then(Value::as_str)
            .map(str::to_string)
            .unwrap_or_default();
        let replace = op
            .get("replace")
            .and_then(Value::as_str)
            .map(str::to_string)
            .unwrap_or_default();

        if search.is_empty() {
            // full_content 模式且当前内容为空：直接使用替换文本
            if use_full_content.is_some() && current_md.trim().is_empty() {
                md = replace.clone();
                applied += 1;
            } else {
                failed.push(json!({
                    "index": idx,
                    "reason": "search 不能为空",
                    "search": search
                }));
            }
            continue;
        }

        match search_replace(&md, &search, &replace) {
            Ok(new_md) => {
                md = new_md;
                applied += 1;
            }
            Err(reason) => {
                failed.push(json!({
                    "index": idx,
                    "reason": reason,
                    "search": search
                }));
            }
        }
    }

    if applied == 0 {
        return Err(WebError::bad_request_code(
            "mnote_markdown_edit_no_operations_applied",
            "markdown_edit 没有任何 search/replace 操作命中，未执行写入",
        )
        .with_context(context));
    }

    // 4. 构建 changedText 摘要
    let changed_text = if applied > 0 {
        operations
            .iter()
            .take(applied)
            .map(|op| {
                let s = op.get("search").and_then(Value::as_str).unwrap_or("");
                let r = op.get("replace").and_then(Value::as_str).unwrap_or("");
                format!("「{}」→「{}」", s, r)
            })
            .collect::<Vec<_>>()
            .join("\n")
    } else {
        String::from("无操作已应用")
    };

    // 5. 写回（本地文件直接 fs::write，Convex 文档通过 block ops apply）
    let apply_result = if is_local_file {
        if input.dry_run.unwrap_or(false) {
            json!({"written": false, "dryRun": true, "path": document_id.clone()})
        } else {
            use std::fs;
            fs::write(&document_id, &md).map_err(|error| {
                WebError::bad_request_code(
                    "mnote_tool_bad_request",
                    format!("无法写入本地文件 {document_id}: {error}"),
                )
                .with_context(context)
            })?;
            json!({"written": true, "path": document_id.clone()})
        }
    } else {
        // 7-27: 在线写回以最终 markdown 为真源，直接生成 block content
        // 与 /api/documents/save 共用同一个 RuntimeCommandEnvelopeWire 路径
        let (aggregate, blocks, original_content) =
            match aggregate_value(state, context, input).await {
                Ok(agg) => {
                    let blocks = block_projection_blocks(&agg);
                    let original_content = crate::hermes_tools::block::current_body_content(&agg);
                    (agg, blocks, original_content)
                }
                Err(_) if use_full_content.is_some() => {
                    // 空文档 + full_content：跳过读取
                    (Value::Null, vec![], json!([]))
                }
                Err(e) => return Err(e),
            };

        let parsed = parse_final_markdown_to_blocks(&md, &blocks);
        let next_content = build_page_content(&original_content, &parsed);
        let changed_blocks = build_changed_blocks_summary(&blocks, &parsed);
        ensure_markdown_changed_blocks_within_allowed(
            context,
            &changed_blocks,
            &selected_block_ids(input),
        )?;

        if input.dry_run == Some(true) {
            // dryRun：返回 diff 预览，不真实写入
            json!({
                "written": false,
                "dryRun": true,
                "documentId": document_id,
                "diff": changed_blocks
            })
        } else {
            // 直接构造 RuntimeCommandEnvelopeWire（与 /api/documents/save 相同）
            let command_id = format!("markdown_edit_{}", context.trace.request_id);
            let file_version = file_version_from_aggregate(&aggregate);
            let conflict_detection_key = conflict_detection_key_from_aggregate(&aggregate);
            if is_local_workspace {
                let root_uri = root_uri.as_deref().ok_or_else(|| {
                    WebError::bad_request_code(
                        "local_folder_root_required",
                        "缺少本地文件夹 rootUri",
                    )
                    .with_context(context)
                })?;
                crate::routes::ensure_local_workspace_access(context, root_uri)
                    .map_err(|error| error.with_context(context))?;
                let expected_file_version = file_version
                    .as_str()
                    .or(conflict_detection_key)
                    .map(|value| value.to_string());
                let result = crate::routes::write_local_markdown_page_body(
                    &core_protocol::PageBodyWriteRequest {
                        document_id: document_id.clone(),
                        workspace_id: workspace_id.clone().unwrap_or_default(),
                        source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
                        root_uri: root_uri.to_string(),
                        expected_file_version,
                        base_content_hash: None,
                        content_format: "editorBlocks".into(),
                        content: next_content,
                        editor_source: Some("mnote.doc.markdown_edit".into()),
                    },
                    Some(&state.buffer_store),
                )?;
                return Ok(json!({
                    "ok": true,
                    "schema": "mnote.doc.markdown_edit.v1",
                    "source": "local_folder",
                    "documentId": document_id,
                    "workspaceId": workspace_id,
                    "operationsApplied": applied,
                    "operationsFailed": failed.len(),
                    "failedOperations": failed,
                    "changedText": changed_text,
                    "fileVersion": file_version,
                    "applyResult": {
                        "commandName": "page.body.write",
                        "commandId": command_id,
                        "changedBlocks": changed_blocks,
                        "result": result
                    }
                }));
            }
            let payload = json!({
                "documentId": document_id,
                "workspaceId": workspace_id,
                "content": next_content,
                "mode": "replace",
                "revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
                "conflictDetectionKey": conflict_detection_key.map(Value::from).unwrap_or(Value::Null),
                "fileVersion": file_version,
            });
            use bridge_runtime::{
                RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
            };
            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: "mnote-hermes".into(),
                    client: "mnote-hermes-plugin".into(),
                    source_kind,
                    root_uri,
                    workspace_id: None,
                    capabilities: Vec::new(),
                },
                target: Some(RuntimeTargetWire {
                    workspace_id: workspace_id.clone(),
                    page_id: Some(document_id.clone()),
                    block_id: None,
                }),
                payload,
                preflight_data: None,
                reason: Some("mnote.doc.markdown_edit (7-27)".into()),
                refs: vec!["page.body.save".into(), "mnote-hermes-tool-call".into()],
                dry_run: false,
                validate_only: false,
            };
            let execution =
                crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts(
                    state,
                    context,
                    workspace_id.as_deref(),
                    command,
                )
                .await?;
            json!({
                "commandName": "page.body.save",
                "commandId": command_id,
                "changedBlocks": changed_blocks,
                "result": execution.result,
                "artifacts": execution.artifacts,
                "artifactError": execution.artifact_error
            })
        }
    };

    Ok(json!({
        "ok": true,
        "schema": "mnote.doc.markdown_edit.v1",
        "source": source,
        "documentId": document_id,
        "workspaceId": workspace_id,
        "operationsApplied": applied,
        "operationsFailed": failed.len(),
        "failedOperations": failed,
        "changedText": changed_text,
        "applyResult": apply_result
    }))
}

/// 四级搜索替换：精确 → 忽略空白 → 段落 fuzzy → 失败
fn search_replace(text: &str, search: &str, replace: &str) -> Result<String, String> {
    if let Some(replaced) = search_replace_exact_or_normalized(text, search, replace) {
        return Ok(replaced);
    }

    // Level 3: 按段落 fuzzy（30% 字符差异容限）
    for para in text.split("\n\n") {
        if fuzzy_match(para, search, 0.3) {
            let idx = text.find(para).unwrap();
            let replaced = format!("{}{}{}", &text[..idx], replace, &text[idx + para.len()..]);
            return Ok(replaced);
        }
    }
    // Level 4: 失败
    Err(format!(
        "无法匹配 \"{}\"",
        if search.len() > 60 {
            format!("{}...", &search[..60])
        } else {
            search.to_string()
        }
    ))
}

fn search_replace_exact_or_normalized(text: &str, search: &str, replace: &str) -> Option<String> {
    // Level 1: 精确匹配
    if text.contains(search) {
        return Some(text.replacen(search, replace, 1));
    }

    // Level 2: 忽略空白和全角/半角差异，同时保留原文 byte 边界。
    let norm_search = normalize_for_search(search);
    if norm_search.is_empty() {
        return None;
    }
    for line in text.lines() {
        let (norm_line, byte_map) = normalize_line_with_byte_map(line);
        let Some(start_byte_in_norm) = norm_line.find(&norm_search) else {
            continue;
        };
        let start = norm_line[..start_byte_in_norm].chars().count();
        let end = start + norm_search.chars().count();
        let start_byte = byte_map.get(start).copied().unwrap_or(0);
        let end_byte = byte_map.get(end).copied().unwrap_or(line.len());
        let replaced = format!("{}{}{}", &line[..start_byte], replace, &line[end_byte..]);
        return Some(text.replacen(line, &replaced, 1));
    }
    None
}

fn normalize_for_search(value: &str) -> String {
    value.chars().filter_map(normalize_search_char).collect()
}

fn normalize_line_with_byte_map(value: &str) -> (String, Vec<usize>) {
    let mut normalized = String::new();
    let mut byte_map = Vec::new();
    for (byte_index, ch) in value.char_indices() {
        if let Some(next) = normalize_search_char(ch) {
            normalized.push(next);
            byte_map.push(byte_index);
        }
    }
    (normalized, byte_map)
}

fn normalize_search_char(ch: char) -> Option<char> {
    if ch.is_whitespace() || ch == '\u{3000}' {
        return None;
    }
    Some(match ch {
        'Ａ'..='Ｚ' => ((ch as u32).saturating_sub('Ａ' as u32) + 'A' as u32)
            .try_into()
            .unwrap_or(ch),
        'ａ'..='ｚ' => ((ch as u32).saturating_sub('ａ' as u32) + 'a' as u32)
            .try_into()
            .unwrap_or(ch),
        '０'..='９' => ((ch as u32).saturating_sub('０' as u32) + '0' as u32)
            .try_into()
            .unwrap_or(ch),
        _ => ch,
    })
}

fn fuzzy_match(text: &str, pattern: &str, max_diff_ratio: f64) -> bool {
    let text_chars: Vec<char> = text.chars().collect();
    let pat_chars: Vec<char> = pattern.chars().collect();
    let max_dist = (pat_chars.len() as f64 * max_diff_ratio).ceil() as usize;
    // 简单的滑动窗口匹配
    for window in text_chars.windows(pat_chars.len().min(text_chars.len())) {
        let dist = window
            .iter()
            .zip(pat_chars.iter())
            .filter(|(a, b)| a != b)
            .count();
        if dist <= max_dist {
            return true;
        }
    }
    false
}
// 退役：7-27 改为 final_md → blocks → execute_page_body_save_from_aggregate 直接写回
