use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use tracing::info;

pub async fn block_edit_workflow(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let started = Instant::now();
    let workspace_id = string_field(&payload, "workspaceId")
        .or_else(|| context.workspace.workspace_id.clone())
        .ok_or_else(|| {
            WebError::bad_request_code("page_ai_workflow_bad_request", "缺少 workspaceId")
                .with_context(&context)
        })?;
    let document_id = string_field(&payload, "documentId").ok_or_else(|| {
        WebError::bad_request_code("page_ai_workflow_bad_request", "缺少 documentId")
            .with_context(&context)
    })?;
    let message = string_field(&payload, "message").ok_or_else(|| {
        WebError::bad_request_code("page_ai_workflow_bad_request", "缺少 message")
            .with_context(&context)
    })?;
    let trace_id =
        string_field(&payload, "traceId").unwrap_or_else(|| context.trace.trace_id.clone());
    let session_id = string_field(&payload, "sessionId")
        .unwrap_or_else(|| format!("page_ai_fast_{}", context.trace.request_id));
    let run_id = string_field(&payload, "runId").unwrap_or_else(|| session_id.clone());
    info!(
        trace_id = %trace_id,
        run_id = %run_id,
        workspace_id = %workspace_id,
        document_id = %document_id,
        "mnote page AI block workflow started"
    );
    if !looks_like_block_edit(&message) {
        return Err(WebError::bad_request_code(
            "page_ai_workflow_not_block_edit",
            "当前请求不像块编辑任务，交给通用页面 AI",
        )
        .with_context(&context));
    }
    let page_context = payload.get("pageContext").cloned().unwrap_or(Value::Null);
    let ai_context = page_context
        .get("aiContext")
        .cloned()
        .or_else(|| payload.get("aiContext").cloned())
        .ok_or_else(|| {
            WebError::bad_request_code(
                "page_ai_workflow_missing_context",
                "块编辑快路径缺少 mnote.page_ai_context.v1",
            )
            .with_context(&context)
        })?;
    let profile = string_field(&payload, "profile").unwrap_or_else(|| "mnoteai".into());
    let model_started = Instant::now();
    // 退役 direct_block_edit_operations：不再走正则抠「」的本地快路径。
    // 所有块编辑请求统一走模型 → search/replace 对 → doc_markdown_edit。
    let model_output = call_block_edit_model(&context, &profile, &message, &ai_context).await?;
    let markdown_plan = extract_markdown_plan_from_model_text(&model_output)?;
    let markdown_operations = markdown_plan.operations.clone();
    info!(
        trace_id = %trace_id,
        run_id = %run_id,
        operations = markdown_operations.len(),
        model_ms = model_started.elapsed().as_millis(),
        "mnote page AI workflow model completed"
    );
    if markdown_operations.is_empty() {
        return Err(WebError::bad_request_code(
            "page_ai_workflow_empty_operations",
            "模型未返回搜索替换操作",
        )
        .with_context(&context));
    }
    let actor_id =
        if context.auth.actor_id.trim().is_empty() || context.auth.actor_id == "anonymous" {
            state.config().dev_user_id.clone()
        } else {
            context.auth.actor_id.clone()
        };
    let allowed_target_block_ids = ai_context
        .get("allowedTargetBlockIds")
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(Value::as_str)
                .map(str::trim)
                .filter(|item| !item.is_empty())
                .map(ToOwned::to_owned)
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    let mut markdown_args = json!({
        "operations": markdown_operations.clone()
    });
    if !allowed_target_block_ids.is_empty() {
        if let Value::Object(map) = &mut markdown_args {
            map.insert(
                "allowedTargetBlockIds".into(),
                json!(allowed_target_block_ids),
            );
        }
    }
    let edit_input = ToolCallInput {
        tool_name: "mnote.doc.markdown_edit".into(),
        workspace_id: Some(workspace_id.clone()),
        document_id: Some(document_id.clone()),
        source_kind: None,
        root_uri: None,
        actor_id: Some(actor_id),
        profile: Some(profile),
        session_id: Some(session_id),
        run_id: Some(run_id.clone()),
        tool_call_id: Some(format!("fast_edit_{}", context.trace.request_id)),
        trace_id: Some(trace_id.clone()),
        idempotency_key: Some(format!("page_ai_fast_edit_{}", context.trace.request_id)),
        dry_run: Some(false),
        capability_scope: Some(vec!["block.write".into(), "page.write".into()]),
        args: Some(markdown_args),
    };
    let apply_started = Instant::now();
    let tool_response =
        crate::routes::hermes_tools::execute_mnote_tool_call(&state, &context, edit_input).await?;
    let apply_result = tool_response.get("result").cloned().unwrap_or(Value::Null);
    let apply_ms = apply_started.elapsed().as_millis();
    info!(
        trace_id = %trace_id,
        run_id = %run_id,
        apply_ms = apply_ms,
        total_ms = started.elapsed().as_millis(),
        "mnote page AI block workflow completed"
    );
    Ok((
        StatusCode::OK,
        HeaderMap::new(),
        Json(json!({
            "ok": true,
            "schema": "mnote.page_ai_block_edit_workflow.v1",
            "fastPath": true,
            "documentId": document_id,
            "workspaceId": workspace_id,
            "runId": run_id,
            "traceId": trace_id,
            "operations": markdown_operations,
            "applyResult": apply_result,
            "toolExecution": tool_response,
            "message": markdown_plan
                .summary
                .unwrap_or_else(|| "已通过页面 markdown 编辑快路径完成写入。".into()),
            "timingsMs": {
                "total": started.elapsed().as_millis(),
                "apply": apply_ms
            }
        })),
    ))
}

struct MarkdownEditPlan {
    operations: Vec<Value>,
    summary: Option<String>,
}

fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan, WebError> {
    let parsed = parse_model_json(text)?;
    if let Some(content) = parsed
        .pointer("/choices/0/message/content")
        .and_then(Value::as_str)
    {
        return extract_markdown_plan_from_model_text(content);
    }
    let summary = parsed
        .get("summary")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    if let Some(operations) = parsed.get("operations").and_then(Value::as_array) {
        // 新格式：直接是 search/replace 对
        if operations
            .iter()
            .any(|op| op.get("search").is_some() || op.get("replace").is_some())
        {
            return Ok(MarkdownEditPlan {
                operations: operations.clone(),
                summary,
            });
        }
        // 旧格式（block ops）：转换为 search/replace 对
        let converted: Vec<Value> = operations
            .iter()
            .filter_map(|op| {
                let op_type = op.get("op").and_then(Value::as_str).unwrap_or("");
                match op_type {
                    "replace" => {
                        let match_text = op.get("matchText").or_else(|| op.get("search")).and_then(Value::as_str)?;
                        let content = op.get("content").or_else(|| op.get("replace")).and_then(Value::as_str)?;
                        Some(json!({"search": match_text, "replace": content}))
                    }
                    "insert_after" => {
                        let anchor = op.get("anchorText").or_else(|| op.get("matchText")).and_then(Value::as_str)?;
                        let content = op.get("content").or_else(|| op.get("replace")).and_then(Value::as_str)?;
                        let anchor_md = format!("{}\n\n", anchor);
                        Some(json!({"search": anchor_md, "replace": format!("{}\n\n{}\n\n", anchor, content)}))
                    }
                    "delete" => {
                        let match_text = op.get("matchText").and_then(Value::as_str)?;
                        Some(json!({"search": match_text, "replace": ""}))
                    }
                    _ => None,
                }
            })
            .collect();
        if !converted.is_empty() {
            return Ok(MarkdownEditPlan {
                operations: converted,
                summary,
            });
        }
    }
    Err(WebError::bad_request_code(
        "page_ai_workflow_bad_model_output",
        "模型输出未包含 search/replace operations",
    ))
}

#[allow(dead_code)]
fn extract_operations_from_model_text(text: &str) -> Result<Vec<Value>, WebError> {
    let parsed = parse_model_json(text)?;
    if let Some(content) = parsed
        .pointer("/choices/0/message/content")
        .and_then(Value::as_str)
    {
        return extract_operations_from_model_text(content);
    }
    if let Some(operations) = parsed.get("operations").and_then(Value::as_array) {
        return Ok(operations.clone());
    }
    if let Some(operations) = parsed
        .get("arguments")
        .and_then(|value| value.get("operations"))
        .and_then(Value::as_array)
    {
        return Ok(operations.clone());
    }
    if let Some(operations) = parsed.as_array() {
        return Ok(operations.clone());
    }
    Err(WebError::bad_request_code(
        "page_ai_workflow_bad_model_output",
        "模型输出未包含 operations",
    ))
}

fn parse_model_json(text: &str) -> Result<Value, WebError> {
    let trimmed = strip_code_fence(text.trim());
    if let Ok(value) = serde_json::from_str::<Value>(&trimmed) {
        return Ok(value);
    }
    if let Some(slice) = first_json_slice(&trimmed) {
        if let Ok(value) = serde_json::from_str::<Value>(slice) {
            return Ok(value);
        }
    }
    Err(WebError::bad_request_code(
        "page_ai_workflow_bad_model_json",
        "模型输出不是可解析 JSON",
    ))
}

fn strip_code_fence(text: &str) -> String {
    let trimmed = text.trim();
    if !trimmed.starts_with("```") {
        return trimmed.to_string();
    }
    let without_open = trimmed.lines().skip(1).collect::<Vec<_>>().join("\n");
    without_open
        .trim()
        .strip_suffix("```")
        .unwrap_or(without_open.trim())
        .trim()
        .to_string()
}

fn first_json_slice(text: &str) -> Option<&str> {
    let start = text.find('{').or_else(|| text.find('['))?;
    let open = text.as_bytes()[start] as char;
    let close = if open == '{' { '}' } else { ']' };
    let mut depth = 0usize;
    let mut in_string = false;
    let mut escaped = false;
    for (offset, ch) in text[start..].char_indices() {
        if in_string {
            if escaped {
                escaped = false;
            } else if ch == '\\' {
                escaped = true;
            } else if ch == '"' {
                in_string = false;
            }
            continue;
        }
        if ch == '"' {
            in_string = true;
        } else if ch == open {
            depth += 1;
        } else if ch == close {
            depth = depth.saturating_sub(1);
            if depth == 0 {
                return Some(&text[start..start + offset + ch.len_utf8()]);
            }
        }
    }
    None
}

async fn call_block_edit_model(
    context: &RequestContext,
    profile: &str,
    message: &str,
    ai_context: &Value,
) -> Result<String, WebError> {
    let model = workflow_model_config(profile);
    let page_xml = ai_context
        .get("pageXml")
        .and_then(Value::as_str)
        .unwrap_or_default();
    let page_text = ai_context
        .get("pageText")
        .and_then(Value::as_str)
        .unwrap_or_default();
    let _allowed_block_ids = ai_context
        .get("allowedTargetBlockIds")
        .cloned()
        .unwrap_or_else(|| json!([]));
    let body = json!({
        "model": model.model,
        "temperature": 0,
        "max_tokens": 900,
        "response_format": {"type": "json_object"},
        "messages": [
            {
                "role": "system",
                "content": "你是 mnote 页面编辑 workflow。只输出 JSON：{\"operations\":[...] ,\"summary\":\"...\"}。每个 operation 包含 search（要搜索替换的原文片段，从 page_text 中精确复制）和 replace（替换后的新文本）。summary 要简短回答用户的读取/检查要求和写入结果；如果用户要求读取某段，summary 必须包含你从 page_text 读取到的原文。禁止输出解释文字。\n\n示例：用户说\"检查第一段并把第二段改成测试123\"，若 page_text 第一段是\"第一段\"，则输出：{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"已读取第一段：第一段；已修改第二段为：测试123\"}"
            },
            {
                "role": "user",
                "content": format!(
                    "用户指令：{}\n\npage_xml（含 block id 参考）：\n{}\n\npage_text（用于 search 精确复制）：\n{}",
                    message,
                    page_xml,
                    page_text
                )
            }
        ]
    });
    let url = format!("{}/chat/completions", model.base_url.trim_end_matches('/'));
    let response = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(20))
        .build()
        .map_err(|error| {
            WebError::internal(format!("页面 AI workflow HTTP client 构造失败: {error}"))
        })?
        .post(url)
        .bearer_auth(model.api_key)
        .json(&body)
        .send()
        .await
        .map_err(|error| {
            WebError::bad_gateway_code(
                "page_ai_workflow_model_unavailable",
                format!("页面 AI workflow 模型请求失败: {error}"),
            )
            .with_context(context)
        })?;
    let status = response.status();
    let text = response.text().await.unwrap_or_default();
    if !status.is_success() {
        return Err(WebError::bad_gateway_code(
            "page_ai_workflow_model_failed",
            format!("页面 AI workflow 模型返回失败: {status}"),
        )
        .with_context(context));
    }
    let payload = parse_model_json(&text)?;
    payload
        .pointer("/choices/0/message/content")
        .and_then(Value::as_str)
        .map(ToOwned::to_owned)
        .ok_or_else(|| {
            WebError::bad_gateway_code(
                "page_ai_workflow_model_no_content",
                "页面 AI workflow 模型响应缺少 message.content",
            )
            .with_context(context)
        })
}

struct WorkflowModelConfig {
    model: String,
    base_url: String,
    api_key: String,
}

fn workflow_model_config(profile: &str) -> WorkflowModelConfig {
    let config = fs::read_to_string(profile_config_path(profile)).unwrap_or_default();
    let provider =
        yaml_path_value(&config, &["model", "provider"]).unwrap_or_else(|| "deepseek".into());
    let model = yaml_path_value(&config, &["model", "default"])
        .or_else(|| yaml_path_value(&config, &["providers", &provider, "model"]))
        .unwrap_or_else(|| "deepseek-v4-flash".into());
    let base_url = yaml_path_value(&config, &["model", "base_url"])
        .or_else(|| yaml_path_value(&config, &["providers", &provider, "base_url"]))
        .unwrap_or_else(|| "https://api.deepseek.com/v1".into());
    let api_key = yaml_path_value(&config, &["model", "api_key"])
        .or_else(|| yaml_path_value(&config, &["providers", &provider, "api_key"]))
        .or_else(|| {
            yaml_path_value(&config, &["model", "key_env"])
                .or_else(|| yaml_path_value(&config, &["providers", &provider, "key_env"]))
                .and_then(|env_key| std::env::var(env_key).ok())
        })
        .unwrap_or_default();
    WorkflowModelConfig {
        model,
        base_url,
        api_key,
    }
}

fn string_field(payload: &Value, key: &str) -> Option<String> {
    payload
        .get(key)
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}

fn looks_like_block_edit(message: &str) -> bool {
    [
        "新增", "添加", "插入", "删除", "删掉", "修改", "替换", "改成", "移动", "移到", "move",
        "replace", "delete", "insert",
    ]
    .iter()
    .any(|needle| message.contains(needle))
}

#[allow(dead_code)]
fn direct_block_edit_operations(message: &str) -> Option<Vec<Value>> {
    let mut operations = Vec::new();
    for clause in message
        .split(|ch| matches!(ch, '；' | ';' | '\n'))
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        let quoted = quoted_segments(clause);
        if (clause.contains("替换") || clause.contains("改成")) && quoted.len() >= 2 {
            operations.push(json!({
                "op": "replace",
                "matchText": quoted[0],
                "content": quoted[1]
            }));
        } else if (clause.contains("插入") || clause.contains("新增") || clause.contains("添加"))
            && quoted.len() >= 2
        {
            operations.push(json!({
                "op": "insert_after",
                "matchText": quoted[0],
                "content": quoted[1]
            }));
        } else if (clause.contains("删除") || clause.contains("删掉")) && !quoted.is_empty() {
            operations.push(json!({
                "op": "delete",
                "matchText": quoted[0]
            }));
        }
    }
    if operations.is_empty() {
        None
    } else {
        Some(operations)
    }
}

#[allow(dead_code)]
fn quoted_segments(value: &str) -> Vec<String> {
    let mut segments = Vec::new();
    let mut start: Option<char> = None;
    let mut current = String::new();
    for ch in value.chars() {
        match (start, ch) {
            (None, '「' | '“' | '"') => {
                start = Some(ch);
                current.clear();
            }
            (Some('「'), '」') | (Some('“'), '”') | (Some('"'), '"') => {
                if !current.trim().is_empty() {
                    segments.push(current.trim().to_string());
                }
                current.clear();
                start = None;
            }
            (Some(_), _) => current.push(ch),
            (None, _) => {}
        }
    }
    segments
}

fn hermes_home() -> PathBuf {
    std::env::var("HERMES_HOME")
        .ok()
        .filter(|value| !value.trim().is_empty())
        .map(PathBuf::from)
        .or_else(|| {
            std::env::var("HOME")
                .ok()
                .map(|home| PathBuf::from(home).join(".hermes"))
        })
        .unwrap_or_else(|| PathBuf::from(".hermes"))
}

fn profile_config_path(profile: &str) -> PathBuf {
    let home = hermes_home();
    let profile = profile.trim();
    if profile.is_empty() || profile == "default" {
        return home.join("config.yaml");
    }
    let candidate = home.join("profiles").join(profile);
    if candidate.exists() {
        candidate.join("config.yaml")
    } else {
        home.join("config.yaml")
    }
}

fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
    let mut stack: Vec<(usize, String)> = Vec::new();
    for raw_line in content.lines() {
        let line = raw_line.trim_end_matches('\r');
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') || !line.contains(':') {
            continue;
        }
        let indent = line.chars().take_while(|ch| ch.is_whitespace()).count();
        while stack
            .last()
            .map(|(level, _)| *level >= indent)
            .unwrap_or(false)
        {
            stack.pop();
        }
        let Some((key, value)) = trimmed.split_once(':') else {
            continue;
        };
        let key = key.trim().trim_matches('"').trim_matches('\'').to_string();
        let value = value
            .trim()
            .trim_matches('"')
            .trim_matches('\'')
            .to_string();
        stack.push((indent, key));
        if stack.len() == path.len()
            && stack
                .iter()
                .zip(path.iter())
                .all(|((_, key), expected)| key == expected)
            && !value.is_empty()
        {
            return Some(value);
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::{direct_block_edit_operations, extract_operations_from_model_text};
    use crate::app::{build_app, AppConfig, AppState};
    use axum::body::{to_bytes, Body};
    use axum::http::{Request, StatusCode};
    use axum::routing::post;
    use axum::{Json, Router};
    use serde_json::{json, Value};
    use std::fs;
    use std::sync::Mutex;
    use tower::util::ServiceExt;

    fn env_lock() -> &'static Mutex<()> {
        crate::test_support::hermes_env_lock()
    }

    fn app() -> axum::Router {
        build_app(AppState::new(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json: Some(
                r#"{
                  "documents:getMeta": {
                    "id": "doc_1",
                    "workspace_id": "ws_demo",
                    "title": "服务端页面",
                    "can_edit": true,
                    "wide_layout": false,
                    "use_small_text": false,
                    "show_toc": true,
                    "block_count": 2
                  },
                  "documents:getContent": {
                    "title": "服务端页面",
                    "content": [
                      {
                        "id": "p_1",
                        "type": "paragraph",
                        "content": [{ "type": "text", "text": "第一段" }]
                      },
                      {
                        "id": "p_2",
                        "type": "paragraph",
                        "content": [{ "type": "text", "text": "第二段" }]
                      }
                    ],
                    "revision": 7,
                    "conflict_detection_key": "doc_1:7"
                  }
                }"#
                .into(),
            ),
            mutation_fixtures_json: Some(
                r#"{
                  "documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
                  "bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
                  "bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
                }"#
                .into(),
            ),
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
    }

    async fn spawn_mock_model_server() -> String {
        async fn completions() -> Json<Value> {
            Json(json!({
                "choices": [{
                    "message": {
                        "content": "{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"ok\"}"
                    }
                }]
            }))
        }

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind mock model");
        let addr = listener.local_addr().expect("mock model addr");
        let server = Router::new().route("/chat/completions", post(completions));
        tokio::spawn(async move {
            let _ = axum::serve(listener, server).await;
        });
        format!("http://{addr}")
    }

    async fn spawn_out_of_scope_mock_model_server() -> String {
        async fn completions() -> Json<Value> {
            Json(json!({
                "choices": [{
                    "message": {
                        "content": "{\"operations\":[{\"search\":\"第一段\",\"replace\":\"越权修改\"}],\"summary\":\"out_of_scope\"}"
                    }
                }]
            }))
        }

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind out-of-scope mock model");
        let addr = listener.local_addr().expect("mock model addr");
        let server = Router::new().route("/chat/completions", post(completions));
        tokio::spawn(async move {
            let _ = axum::serve(listener, server).await;
        });
        format!("http://{addr}")
    }

    async fn spawn_read_and_edit_mock_model_server() -> String {
        async fn completions() -> Json<Value> {
            Json(json!({
                "choices": [{
                    "message": {
                        "content": "{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"已读取第一段：第一段；已修改第二段为：测试123\"}"
                    }
                }]
            }))
        }

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind read-and-edit mock model");
        let addr = listener.local_addr().expect("mock model addr");
        let server = Router::new().route("/chat/completions", post(completions));
        tokio::spawn(async move {
            let _ = axum::serve(listener, server).await;
        });
        format!("http://{addr}")
    }

    #[test]
    fn extracts_operations_from_fenced_model_json() {
        let operations = extract_operations_from_model_text(
            r#"```json
{"operations":[{"op":"replace","matchText":"旧文本","content":"新文本"}],"summary":"ok"}
```"#,
        )
        .expect("operations");
        assert_eq!(operations.len(), 1);
        assert_eq!(operations[0]["op"], "replace");
        assert_eq!(operations[0]["matchText"], "旧文本");
    }

    #[test]
    fn parses_direct_chinese_block_operations() {
        let operations = direct_block_edit_operations(
            "把「第二段」替换为「第二段已修改」；在「第一段」后插入「插入段」；删除「第三段」。只简短回复结果。",
        )
        .expect("operations");
        assert_eq!(operations.len(), 3);
        assert_eq!(operations[0]["op"], "replace");
        assert_eq!(operations[0]["matchText"], "第二段");
        assert_eq!(operations[0]["content"], "第二段已修改");
        assert_eq!(operations[1]["op"], "insert_after");
        assert_eq!(operations[1]["matchText"], "第一段");
        assert_eq!(operations[1]["content"], "插入段");
        assert_eq!(operations[2]["op"], "delete");
        assert_eq!(operations[2]["matchText"], "第三段");
    }

    #[tokio::test]
    async fn block_edit_workflow_respects_disabled_markdown_edit_tool() {
        let _guard = env_lock().lock().expect("env lock");
        let base_url = spawn_mock_model_server().await;
        let hermes_home = std::env::temp_dir().join(format!(
            "mnote-page-ai-workflow-disabled-tool-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&hermes_home);
        let profile_dir = hermes_home.join("profiles").join("mnoteai");
        fs::create_dir_all(&profile_dir).expect("profile dir");
        fs::write(
            profile_dir.join("config.yaml"),
            format!(
                "model:\n  provider: mock\n  default: mock-model\n  base_url: {base_url}\n  api_key: test-key\nmnote:\n  tools:\n    disabled:\n      - mnote.doc.markdown_edit\n"
            ),
        )
        .expect("profile config");
        std::env::set_var("HERMES_HOME", &hermes_home);

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/page-ai/block-edit-workflow")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "message": "把第二段改成测试123",
                            "profile": "mnoteai",
                            "sessionId": "sess_page_ai_disabled",
                            "runId": "run_page_ai_disabled",
                            "traceId": "trace_page_ai_disabled",
                            "pageContext": {
                                "aiContext": {
                                    "schema": "mnote.page_ai_context.v1",
                                    "pageText": "第一段\n\n第二段",
                                    "pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
                                    "contextBlocks": [
                                        {"blockId": "p_1", "text": "第一段"},
                                        {"blockId": "p_2", "text": "第二段"}
                                    ]
                                }
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["code"], "mnote_tool_disabled");

        std::env::remove_var("HERMES_HOME");
        let _ = fs::remove_dir_all(&hermes_home);
    }

    #[tokio::test]
    async fn block_edit_workflow_forwards_allowed_target_blocks_to_markdown_edit() {
        let _guard = env_lock().lock().expect("env lock");
        let base_url = spawn_out_of_scope_mock_model_server().await;
        let hermes_home = std::env::temp_dir().join(format!(
            "mnote-page-ai-workflow-selection-scope-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&hermes_home);
        let profile_dir = hermes_home.join("profiles").join("mnoteai");
        fs::create_dir_all(&profile_dir).expect("profile dir");
        fs::write(
            profile_dir.join("config.yaml"),
            format!(
                "model:\n  provider: mock\n  default: mock-model\n  base_url: {base_url}\n  api_key: test-key\n"
            ),
        )
        .expect("profile config");
        std::env::set_var("HERMES_HOME", &hermes_home);

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/page-ai/block-edit-workflow")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "message": "把选中段落改成测试123",
                            "profile": "mnoteai",
                            "sessionId": "sess_page_ai_scope",
                            "runId": "run_page_ai_scope",
                            "traceId": "trace_page_ai_scope",
                            "pageContext": {
                                "aiContext": {
                                    "schema": "mnote.page_ai_context.v1",
                                    "allowedTargetBlockIds": ["p_2"],
                                    "pageText": "第一段\n\n第二段",
                                    "pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
                                    "contextBlocks": [
                                        {"blockId": "p_1", "text": "第一段"},
                                        {"blockId": "p_2", "text": "第二段"}
                                    ]
                                }
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["code"], "mnote_markdown_edit_target_out_of_scope");

        std::env::remove_var("HERMES_HOME");
        let _ = fs::remove_dir_all(&hermes_home);
    }

    #[tokio::test]
    async fn block_edit_workflow_surfaces_model_summary_for_read_and_edit_request() {
        let _guard = env_lock().lock().expect("env lock");
        let base_url = spawn_read_and_edit_mock_model_server().await;
        let hermes_home = std::env::temp_dir().join(format!(
            "mnote-page-ai-workflow-read-summary-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&hermes_home);
        let profile_dir = hermes_home.join("profiles").join("mnoteai");
        fs::create_dir_all(&profile_dir).expect("profile dir");
        fs::write(
            profile_dir.join("config.yaml"),
            format!(
                "model:\n  provider: mock\n  default: mock-model\n  base_url: {base_url}\n  api_key: test-key\n"
            ),
        )
        .expect("profile config");
        std::env::set_var("HERMES_HOME", &hermes_home);

        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/page-ai/block-edit-workflow")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "message": "检查你是否能读取到本页第一段，同时请修改第二段为：测试123",
                            "profile": "mnoteai",
                            "sessionId": "sess_page_ai_read_summary",
                            "runId": "run_page_ai_read_summary",
                            "traceId": "trace_page_ai_read_summary",
                            "pageContext": {
                                "aiContext": {
                                    "schema": "mnote.page_ai_context.v1",
                                    "pageText": "第一段\n\n第二段",
                                    "pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
                                    "contextBlocks": [
                                        {"blockId": "p_1", "text": "第一段"},
                                        {"blockId": "p_2", "text": "第二段"}
                                    ]
                                }
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["ok"], true);
        assert!(payload["message"]
            .as_str()
            .unwrap_or_default()
            .contains("已读取第一段：第一段"));
        assert!(payload["message"]
            .as_str()
            .unwrap_or_default()
            .contains("测试123"));

        std::env::remove_var("HERMES_HOME");
        let _ = fs::remove_dir_all(&hermes_home);
    }
}
