use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{artifact, manifest, page, ToolCallInput};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::env;
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use tracing::{info, warn};

const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_TOOL_OWNER: &str = "x-mnote-hermes-tool-owner";

pub async fn mnote_audit(
    Extension(context): Extension<RequestContext>,
    Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let trace_id = query.get("traceId").map(String::as_str);
    let tool_call_id = query.get("toolCallId").map(String::as_str);
    let persisted_only = query
        .get("persistedOnly")
        .map(|value| value == "true" || value == "1")
        .unwrap_or(false);
    let events = if persisted_only {
        audit_persisted_events(trace_id, tool_call_id)
    } else {
        audit_events(trace_id, tool_call_id)
    };
    Ok((
        StatusCode::OK,
        stamp_tool_headers(),
        Json(json!({
            "ok": true,
            "traceId": context.trace.trace_id,
            "auditStore": if persisted_only { "jsonl" } else { "memory" },
            "events": events
        })),
    ))
}

pub async fn mnote_manifest(
    Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    Ok((
        StatusCode::OK,
        stamp_tool_headers(),
        Json(json!({
            "ok": true,
            "traceId": context.trace.trace_id,
            "manifest": manifest::manifest()
        })),
    ))
}

pub async fn mnote_call(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(input): Json<ToolCallInput>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    ensure_authenticated(&context)?;
    let trace_id = input
        .effective_trace_id(&context.trace.trace_id)
        .to_string();
    let tool_call_id = input.effective_tool_call_id();
    let workspace_id = input.effective_workspace_id();
    let document_id = input.effective_document_id();
    let dry_run = input.dry_run.unwrap_or(false);
    let effect = if dry_run {
        "dry_run"
    } else if input.tool_name == "mnote.page.get" {
        "read"
    } else {
        "write"
    };
    let idempotency_key = idempotency_cache_key(
        &input,
        workspace_id.as_deref(),
        document_id.as_deref(),
        dry_run,
    );
    info!(
        trace_id = %trace_id,
        session_id = input.session_id.as_deref().unwrap_or(""),
        run_id = input.run_id.as_deref().unwrap_or(""),
        tool_call_id = %tool_call_id,
        tool_name = %input.tool_name,
        workspace_id = workspace_id.as_deref().unwrap_or(""),
        document_id = document_id.as_deref().unwrap_or(""),
        actor_id = input.actor_id.as_deref().unwrap_or(""),
        dry_run,
        "mnote Hermes tool call started"
    );
    audit_push(json!({
        "phase": "started",
        "traceId": trace_id,
        "sessionId": input.session_id,
        "runId": input.run_id,
        "toolCallId": tool_call_id,
        "toolName": input.tool_name,
        "workspaceId": workspace_id,
        "documentId": document_id,
        "actorId": input.actor_id,
        "dryRun": dry_run
    }));
    if let Err(error) = ensure_workspace_context(&context, workspace_id.as_deref()) {
        audit_push(json!({
            "phase": "failed",
            "traceId": trace_id,
            "sessionId": input.session_id,
            "runId": input.run_id,
            "toolCallId": tool_call_id,
            "toolName": input.tool_name,
            "workspaceId": workspace_id,
            "documentId": document_id,
            "actorId": input.actor_id,
            "status": error.status().as_u16(),
            "message": error.message()
        }));
        return Err(error);
    }
    if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) {
        info!(
            trace_id = %trace_id,
            session_id = input.session_id.as_deref().unwrap_or(""),
            run_id = input.run_id.as_deref().unwrap_or(""),
            tool_call_id = %tool_call_id,
            tool_name = %input.tool_name,
            workspace_id = workspace_id.as_deref().unwrap_or(""),
            document_id = document_id.as_deref().unwrap_or(""),
            actor_id = input.actor_id.as_deref().unwrap_or(""),
            "mnote Hermes tool call idempotency replay"
        );
        audit_push(json!({
            "phase": "idempotency_replay",
            "traceId": trace_id,
            "sessionId": cached.get("sessionId").cloned().unwrap_or(Value::Null),
            "runId": cached.get("runId").cloned().unwrap_or(Value::Null),
            "toolCallId": cached.get("toolCallId").cloned().unwrap_or(Value::Null),
            "toolName": cached.get("toolName").cloned().unwrap_or(Value::Null),
            "audit": cached.get("audit").cloned().unwrap_or(Value::Null)
        }));
        return Ok((StatusCode::OK, stamp_tool_headers(), Json(cached)));
    }
    let result = match input.tool_name.as_str() {
        "mnote.page.get" => page::page_get(&state, &context, &input).await,
        "mnote.page.save" => page::page_save(&state, &context, &input).await,
        "mnote.page.update_title" => page::update_title(&state, &context, &input).await,
        "mnote.page.update_options" => page::update_options(&state, &context, &input).await,
        "mnote.artifact.create_summary" => artifact::create_summary(&state, &context, &input).await,
        "mnote.artifact.create_ai_note" => artifact::create_ai_note(&state, &context, &input).await,
        _ => Err(
            WebError::bad_request_code("mnote_tool_unknown", "未知 mnote Hermes tool")
                .with_context(&context)
                .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
                .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"),
        ),
    };
    if let Err(error) = &result {
        warn!(
            trace_id = %trace_id,
            session_id = input.session_id.as_deref().unwrap_or(""),
            run_id = input.run_id.as_deref().unwrap_or(""),
            tool_call_id = %tool_call_id,
            tool_name = %input.tool_name,
            workspace_id = workspace_id.as_deref().unwrap_or(""),
            document_id = document_id.as_deref().unwrap_or(""),
            actor_id = input.actor_id.as_deref().unwrap_or(""),
            status = %error.status(),
            message = %error.message(),
            "mnote Hermes tool call failed"
        );
        audit_push(json!({
            "phase": "failed",
            "traceId": trace_id,
            "sessionId": input.session_id,
            "runId": input.run_id,
            "toolCallId": tool_call_id,
            "toolName": input.tool_name,
            "workspaceId": workspace_id,
            "documentId": document_id,
            "actorId": input.actor_id,
            "status": error.status().as_u16(),
            "message": error.message()
        }));
    }
    let result = result?;
    let command_id = result.get("commandId").cloned().unwrap_or(Value::Null);
    info!(
        trace_id = %trace_id,
        session_id = input.session_id.as_deref().unwrap_or(""),
        run_id = input.run_id.as_deref().unwrap_or(""),
        tool_call_id = %tool_call_id,
        tool_name = %input.tool_name,
        workspace_id = workspace_id.as_deref().unwrap_or(""),
        document_id = document_id.as_deref().unwrap_or(""),
        actor_id = input.actor_id.as_deref().unwrap_or(""),
        effect,
        "mnote Hermes tool call completed"
    );
    let response_body = json!({
            "ok": true,
            "toolName": input.tool_name,
            "toolCallId": tool_call_id,
            "traceId": trace_id,
            "sessionId": input.session_id,
            "runId": input.run_id,
            "result": result,
            "audit": {
                "effect": effect,
                "commandId": command_id,
                "workspaceId": workspace_id,
                "documentId": document_id,
                "actorId": input.actor_id,
                "dryRun": dry_run,
                "idempotencyKey": input.idempotency_key,
                "capabilityScope": input.capability_scope
            },
            "error": null
    });
    if let Some(key) = idempotency_key {
        idempotency_cache_put(key, response_body.clone());
    }
    audit_push(json!({
        "phase": "completed",
        "traceId": response_body.get("traceId").cloned().unwrap_or(Value::Null),
        "sessionId": response_body.get("sessionId").cloned().unwrap_or(Value::Null),
        "runId": response_body.get("runId").cloned().unwrap_or(Value::Null),
        "toolCallId": response_body.get("toolCallId").cloned().unwrap_or(Value::Null),
        "toolName": response_body.get("toolName").cloned().unwrap_or(Value::Null),
        "audit": response_body.get("audit").cloned().unwrap_or(Value::Null)
    }));
    Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body)))
}

fn audit_log() -> &'static Mutex<Vec<Value>> {
    static LOG: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
    LOG.get_or_init(|| Mutex::new(Vec::new()))
}

fn audit_push(event: Value) {
    if let Ok(mut log) = audit_log().lock() {
        log.push(event.clone());
        let overflow = log.len().saturating_sub(500);
        if overflow > 0 {
            log.drain(0..overflow);
        }
    }
    if let Err(error) = audit_append_persistent(&event) {
        warn!(message = %error, "mnote Hermes tool audit 持久化失败");
    }
}

fn audit_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec<Value> {
    let Ok(log) = audit_log().lock() else {
        return Vec::new();
    };
    log.iter()
        .filter(|event| {
            trace_id
                .map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected))
                .unwrap_or(true)
        })
        .filter(|event| {
            tool_call_id
                .map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected))
                .unwrap_or(true)
        })
        .cloned()
        .collect()
}

fn audit_log_path() -> PathBuf {
    env::var("MNOTE_HERMES_TOOL_AUDIT_LOG")
        .ok()
        .filter(|value| !value.trim().is_empty())
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("tmp").join("mnote-hermes-tool-audit.jsonl"))
}

fn audit_append_persistent(event: &Value) -> Result<(), String> {
    let path = audit_log_path();
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|error| error.to_string())?;
    }
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .map_err(|error| error.to_string())?;
    let line = serde_json::to_string(event).map_err(|error| error.to_string())?;
    writeln!(file, "{line}").map_err(|error| error.to_string())
}

fn audit_persisted_events(trace_id: Option<&str>, tool_call_id: Option<&str>) -> Vec<Value> {
    let path = audit_log_path();
    let Ok(file) = File::open(path) else {
        return Vec::new();
    };
    BufReader::new(file)
        .lines()
        .map_while(Result::ok)
        .filter_map(|line| serde_json::from_str::<Value>(&line).ok())
        .filter(|event| {
            trace_id
                .map(|expected| event.get("traceId").and_then(Value::as_str) == Some(expected))
                .unwrap_or(true)
        })
        .filter(|event| {
            tool_call_id
                .map(|expected| event.get("toolCallId").and_then(Value::as_str) == Some(expected))
                .unwrap_or(true)
        })
        .collect()
}

fn idempotency_cache() -> &'static Mutex<HashMap<String, Value>> {
    static CACHE: OnceLock<Mutex<HashMap<String, Value>>> = OnceLock::new();
    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

fn idempotency_cache_key(
    input: &ToolCallInput,
    workspace_id: Option<&str>,
    document_id: Option<&str>,
    dry_run: bool,
) -> Option<String> {
    if dry_run || input.tool_name == "mnote.page.get" {
        return None;
    }
    let idempotency_key = input.idempotency_key.as_deref()?.trim();
    if idempotency_key.is_empty() {
        return None;
    }
    Some(format!(
        "{}|{}|{}|{}",
        input.tool_name,
        workspace_id.unwrap_or(""),
        document_id.unwrap_or(""),
        idempotency_key
    ))
}

fn idempotency_cache_get(key: &str) -> Option<Value> {
    idempotency_cache().lock().ok()?.get(key).cloned()
}

fn idempotency_cache_put(key: String, response: Value) {
    if let Ok(mut cache) = idempotency_cache().lock() {
        cache.insert(key, response);
    }
}

fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
    let has_actor = context.auth.actor_id.trim() != "anonymous";
    if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
        return Ok(());
    }
    Err(WebError::new(
        StatusCode::UNAUTHORIZED,
        "mnote_tool_unauthorized",
        "mnote Hermes tool 需要登录后访问",
    )
    .with_context(context)
    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
    .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
}

fn ensure_workspace_context(
    context: &RequestContext,
    input_workspace_id: Option<&str>,
) -> Result<(), WebError> {
    let Some(input_workspace_id) = input_workspace_id
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return Ok(());
    };
    let Some(header_workspace_id) = context
        .workspace
        .workspace_id
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return Ok(());
    };
    if input_workspace_id == header_workspace_id {
        return Ok(());
    }
    Err(WebError::new(
        StatusCode::FORBIDDEN,
        "workspace_context_conflict",
        "mnote Hermes tool 请求的 workspaceId 与请求上下文不一致",
    )
    .with_context(context)
    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
    .with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
}

fn stamp_tool_headers() -> HeaderMap {
    let mut headers = HeaderMap::new();
    if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("mnote-web"));
    }
    if let Ok(name) = HeaderName::from_lowercase(HEADER_HERMES_TOOL_OWNER.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("mnote-web-hermes-tools"));
    }
    headers
}

#[cfg(test)]
mod tests {
    use crate::app::{build_app, AppConfig, AppState};
    use axum::body::{to_bytes, Body};
    use axum::http::{Request, StatusCode};
    use serde_json::{json, Value};
    use tower::util::ServiceExt;

    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,
            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": 1
                  },
                  "documents:getContent": {
                    "title": "服务端页面",
                    "content": [
                      {
                        "id": "heading_1",
                        "type": "heading",
                        "props": { "level": 1 },
                        "content": [{ "type": "text", "text": "章节一" }]
                      }
                    ],
                    "revision": 7,
                    "conflict_detection_key": "doc_1:7"
                  }
                }"#
                .into(),
            ),
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
    }

    #[tokio::test]
    async fn hermes_tools_manifest_returns_first_batch_tools() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/hermes/tools/mnote/manifest")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::empty())
                    .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");
        let tools = payload["manifest"]["tools"].as_array().expect("tools");
        assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.get"));
        assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.save"));
        assert_eq!(
            payload["manifest"]["schemaVersion"],
            "mnote.hermes_tool_manifest.v1"
        );
    }

    #[tokio::test]
    async fn hermes_tools_page_get_requires_auth() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        json!({"toolName":"mnote.page.get","documentId":"doc_1"}).to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn hermes_tools_write_tools_require_auth() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.save",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "idempotencyKey": "idem_1",
                            "dryRun": false,
                            "args": {"content": []}
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn hermes_tools_page_get_returns_page_aggregate_summary() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.get",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "capabilityScope": ["page.read"]
                        })
                        .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["toolName"], "mnote.page.get");
        assert_eq!(payload["toolCallId"], "call_1");
        assert_eq!(payload["result"]["title"], "服务端页面");
        assert!(payload["result"]["bodySummary"]
            .as_str()
            .unwrap_or_default()
            .contains("章节一"));
        assert_eq!(payload["audit"]["effect"], "read");
    }

    #[tokio::test]
    async fn hermes_tools_reject_workspace_context_conflict_without_content_leak() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .header("x-mnote-workspace-id", "ws_other")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.get",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "capabilityScope": ["page.read"]
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("workspace_context_conflict")
        );
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let text = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(!text.contains("服务端页面"));
        assert!(!text.contains("章节一"));
    }

    #[tokio::test]
    async fn hermes_tools_write_tools_require_idempotency_and_dry_run_flag() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.save",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "args": {"content": []}
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("mnote_tool_idempotency_required")
        );
    }

    #[tokio::test]
    async fn hermes_tools_page_save_dry_run_returns_diff_without_write() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.save",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "idempotencyKey": "idem_save_1",
                            "dryRun": true,
                            "args": {"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]}
                        })
                        .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["audit"]["effect"], "dry_run");
        assert_eq!(payload["result"]["dryRun"], true);
        assert_eq!(payload["result"]["commandName"], "page.body.save");
    }

    #[tokio::test]
    async fn hermes_tools_update_options_dry_run_filters_unwired_fields() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.page.update_options",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "idempotencyKey": "idem_options_1",
                            "dryRun": true,
                            "args": {"options": {"wideLayout": true, "pageFont": "serif"}}
                        })
                        .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");
        let options = &payload["result"]["diff"][0]["payload"]["options"];
        assert_eq!(options["wideLayout"], true);
        assert!(options.get("pageFont").is_none());
        assert_eq!(payload["result"]["ignoredOptions"][0], "pageFont");
        assert_eq!(
            payload["result"]["warnings"][0]["code"],
            "page_option_not_wired"
        );
    }

    #[tokio::test]
    async fn hermes_tools_artifact_dry_run_returns_artifact_plan() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/hermes/tools/mnote/call")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_1")
                    .body(Body::from(
                        json!({
                            "toolName": "mnote.artifact.create_summary",
                            "workspaceId": "ws_demo",
                            "documentId": "doc_1",
                            "sessionId": "sess_1",
                            "runId": "run_1",
                            "toolCallId": "call_1",
                            "traceId": "trace_1",
                            "idempotencyKey": "idem_summary_1",
                            "dryRun": true,
                            "args": {"summary": "摘要内容"}
                        })
                        .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["result"]["dryRun"], true);
        assert_eq!(payload["result"]["artifactType"], "summary");
    }
}
