use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::{
    execute_runtime_command_via_legacy_cloud_with_artifacts, runtime_context,
};
use crate::routes::local_folder_source::{
    ensure_local_workspace_access, execute_local_tree_command,
};
use crate::transport::legacy_cloud_guard::{
    execute_retired_mutation_by_name, execute_retired_query_by_name,
    persist_runtime_command_artifacts,
};
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{
    build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
    RuntimeCommandExecutionPlan, RuntimeSourceWire, RuntimeTargetWire,
};
use serde::Deserialize;
use serde_json::{json, Value};
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};

const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_RESOURCE_TRASH_TRANSPORT: &str = "x-mnote-resource-trash-transport";

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaBatchRequest {
    pub action: String,
    #[serde(default)]
    pub asset_ids: Vec<String>,
    pub new_name: Option<String>,
    pub target_document_id: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaPurgeRequest {
    pub asset_id: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceTrashRequest {
    pub workspace_id: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MindmapTrashRequest {
    pub action: String,
    pub source_kind: Option<String>,
    pub root_uri: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MindmapLocalQuery {
    pub source_kind: Option<String>,
    pub root_uri: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableTrashRequest {
    pub table_id: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableCreateRequest {
    pub document_id: String,
    pub title: Option<String>,
    pub schema: Option<Value>,
    pub snapshot: Option<Value>,
}

fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json<Value>) {
    let mut headers = HeaderMap::new();
    stamp_headers(&mut headers);
    (
        StatusCode::OK,
        headers,
        Json(json!({
            "ok": true,
            "requestId": context.trace.request_id,
            "traceId": context.trace.trace_id,
            "owner": "mnote-web",
            "result": result,
        })),
    )
}

fn stamp_headers(headers: &mut HeaderMap) {
    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_RESOURCE_TRASH_TRANSPORT.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("resource-trash-api"));
    }
}

async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
    let user_id = context.auth.actor_id.trim();
    if !user_id.is_empty() && user_id != "anonymous" {
        return user_id.to_string();
    }

    let has_auth_cookie = context
        .auth
        .cookie_header
        .as_deref()
        .map(|value| {
            value.contains("__convexAuthJWT=") || value.contains("mnote_web_convex_token=")
        })
        .unwrap_or(false);
    if has_auth_cookie {
        if let Ok(user) = execute_retired_query_by_name(
            state.config(),
            context,
            "users:currentUser",
            json!({}),
            context.workspace.workspace_id.as_deref(),
            "resource_trash_current_user",
        )
        .await
        {
            for key in ["_id", "id"] {
                if let Some(user_id) = user
                    .get(key)
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                {
                    return user_id.to_string();
                }
            }
        }
    }

    state.config().dev_user_id.clone()
}

fn require_id<'a>(
    context: &RequestContext,
    value: &'a str,
    field: &'static str,
) -> Result<&'a str, WebError> {
    let value = value.trim();
    if value.is_empty() {
        return Err(WebError::bad_request_code(
            "resource_id_required",
            format!("缺少有效 {field}"),
        )
        .with_context(context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
    }
    Ok(value)
}

fn force_expired_at() -> String {
    (OffsetDateTime::now_utc() + Duration::days(365 * 100))
        .format(&Rfc3339)
        .unwrap_or_else(|_| "2126-01-01T00:00:00Z".into())
}

fn now_iso_like() -> String {
    OffsetDateTime::now_utc()
        .format(&Rfc3339)
        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
}

async fn record_media_empty_trash_artifacts(
    state: &AppState,
    context: &RequestContext,
    user_id: &str,
    workspace_id: &str,
    result: &Value,
) -> Result<(), WebError> {
    let stream_delta_hint = json!({
        "family": "tree",
        "kind": "resync_required",
        "args": {
            "reason": "media_empty_trash",
            "workspaceId": workspace_id,
            "resourceKind": "media",
        },
    });
    let command = RuntimeCommandEnvelopeWire {
        name: "mediaAssets.emptyTrashByWorkspace".into(),
        command_id: format!("media_empty_trash_{}", context.trace.request_id),
        idempotency_key: context.source.idempotency_key.clone(),
        actor: RuntimeActorWire {
            actor_type: context.auth.actor_type.clone(),
            actor_id: user_id.to_string(),
            session_id: context.auth.session_id.clone(),
        },
        source: RuntimeSourceWire {
            channel: context.source.channel.clone(),
            client: context.source.client.clone(),
            source_kind: None,
            root_uri: None,
            workspace_id: Some(workspace_id.to_string()),
            capabilities: Vec::new(),
        },
        target: Some(RuntimeTargetWire {
            workspace_id: Some(workspace_id.to_string()),
            page_id: None,
            block_id: None,
        }),
        payload: json!({
            "workspaceId": workspace_id,
            "resourceKind": "media",
        }),
        preflight_data: None,
        reason: Some("mnote-web media empty trash".into()),
        refs: vec!["mnote-web-resource-trash".into()],
        dry_run: false,
        validate_only: false,
    };
    let plan = RuntimeCommandExecutionPlan {
        command_name: command.name.clone(),
        command_id: command.command_id.clone(),
        function_name: command.name.clone(),
        workspace_id: Some(workspace_id.to_string()),
        request_id: context.trace.request_id.clone(),
        trace_id: context.trace.trace_id.clone(),
        actor_id: user_id.to_string(),
        idempotency_key: context.source.idempotency_key.clone(),
        source: json!({
            "channel": context.source.channel.as_str(),
            "client": context.source.client.as_str(),
            "workspaceId": workspace_id,
        }),
        payload_json: command.payload.to_string(),
        args_json: json!({
            "userId": user_id,
            "workspaceId": workspace_id,
            "streamDeltaHint": stream_delta_hint.clone(),
            "domainEventHint": {
                "family": "tree",
                "eventType": "tree.trash.media.emptied",
            },
            "domainEventPlan": {
                "family": "tree",
                "schema": "mnote.tree.domain_event",
                "schemaVersion": 1,
                "eventType": "tree.trash.media.emptied",
                "streamDeltaHint": stream_delta_hint,
            },
        }),
    };
    let runtime_context = runtime_context(context, Some(workspace_id));
    if let Some(artifacts) = build_runtime_command_artifact_plan(
        &runtime_context,
        &command,
        &plan,
        result,
        &now_iso_like(),
    ) {
        persist_runtime_command_artifacts(state.config(), context, &artifacts).await?;
    }
    Ok(())
}

async fn record_resource_resync_artifacts(
    state: &AppState,
    context: &RequestContext,
    actor_id: &str,
    workspace_id: &str,
    document_id: Option<&str>,
    resource_id: Option<&str>,
    command_name: &str,
    event_type: &str,
    reason: &str,
    result: &Value,
) -> Result<(), WebError> {
    let stream_delta_hint = json!({
        "family": "tree",
        "kind": "resync_required",
        "args": {
            "reason": reason,
            "workspaceId": workspace_id,
            "documentId": document_id,
            "blockId": resource_id,
        },
    });
    let command = RuntimeCommandEnvelopeWire {
        name: command_name.to_string(),
        command_id: format!("{}_{}", reason, context.trace.request_id),
        idempotency_key: context.source.idempotency_key.clone(),
        actor: RuntimeActorWire {
            actor_type: context.auth.actor_type.clone(),
            actor_id: actor_id.to_string(),
            session_id: context.auth.session_id.clone(),
        },
        source: RuntimeSourceWire {
            channel: context.source.channel.clone(),
            client: context.source.client.clone(),
            source_kind: None,
            root_uri: None,
            workspace_id: Some(workspace_id.to_string()),
            capabilities: Vec::new(),
        },
        target: Some(RuntimeTargetWire {
            workspace_id: Some(workspace_id.to_string()),
            page_id: document_id.map(ToOwned::to_owned),
            block_id: resource_id.map(ToOwned::to_owned),
        }),
        payload: json!({
            "workspaceId": workspace_id,
            "documentId": document_id,
            "resourceId": resource_id,
        }),
        preflight_data: None,
        reason: Some(format!("mnote-web resource resync {command_name}")),
        refs: vec!["mnote-web-resource-trash".into()],
        dry_run: false,
        validate_only: false,
    };
    let plan = RuntimeCommandExecutionPlan {
        command_name: command.name.clone(),
        command_id: command.command_id.clone(),
        function_name: command_name.replace('.', ":"),
        workspace_id: Some(workspace_id.to_string()),
        request_id: context.trace.request_id.clone(),
        trace_id: context.trace.trace_id.clone(),
        actor_id: actor_id.to_string(),
        idempotency_key: context.source.idempotency_key.clone(),
        source: json!({
            "channel": context.source.channel.as_str(),
            "client": context.source.client.as_str(),
            "workspaceId": workspace_id,
        }),
        payload_json: command.payload.to_string(),
        args_json: json!({
            "workspaceId": workspace_id,
            "documentId": document_id,
            "resourceId": resource_id,
            "streamDeltaHint": stream_delta_hint.clone(),
            "domainEventHint": {
                "family": "tree",
                "eventType": event_type,
            },
            "domainEventPlan": {
                "family": "tree",
                "schema": "mnote.tree.domain_event",
                "schemaVersion": 1,
                "eventType": event_type,
                "streamDeltaHint": stream_delta_hint,
            },
        }),
    };
    let runtime_context = runtime_context(context, Some(workspace_id));
    if let Some(artifacts) = build_runtime_command_artifact_plan(
        &runtime_context,
        &command,
        &plan,
        result,
        &now_iso_like(),
    ) {
        persist_runtime_command_artifacts(state.config(), context, &artifacts).await?;
    }
    Ok(())
}

async fn fetch_document_workspace_id(
    state: &AppState,
    context: &RequestContext,
    document_id: &str,
) -> Option<String> {
    execute_retired_query_by_name(
        state.config(),
        context,
        "documents:getMeta",
        json!({
            "id": document_id,
            "includeDeleted": true,
        }),
        context.workspace.workspace_id.as_deref(),
        "resource_trash_document_workspace",
    )
    .await
    .ok()
    .and_then(|meta| {
        meta.get("workspace_id")
            .or_else(|| meta.get("workspaceId"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned)
    })
}

async fn fetch_table_meta(
    state: &AppState,
    context: &RequestContext,
    user_id: &str,
    table_id: &str,
) -> Option<Value> {
    execute_retired_query_by_name(
        state.config(),
        context,
        "tables:get",
        json!({
            "userId": user_id,
            "tableId": table_id,
        }),
        context.workspace.workspace_id.as_deref(),
        "resource_trash_table_meta",
    )
    .await
    .ok()
    .filter(|value| value.is_object())
}

fn resource_command(
    context: &RequestContext,
    command_name: &str,
    command_id_suffix: &str,
    asset_id: &str,
    actor_id: &str,
    workspace_id: Option<&str>,
    document_id: Option<&str>,
    payload: Value,
) -> RuntimeCommandEnvelopeWire {
    RuntimeCommandEnvelopeWire {
        name: command_name.into(),
        command_id: format!("resource_{command_id_suffix}_{}", context.trace.request_id),
        idempotency_key: context.source.idempotency_key.clone(),
        actor: RuntimeActorWire {
            actor_type: context.auth.actor_type.clone(),
            actor_id: actor_id.to_string(),
            session_id: context.auth.session_id.clone(),
        },
        source: RuntimeSourceWire {
            channel: context.source.channel.clone(),
            client: context.source.client.clone(),
            source_kind: None,
            root_uri: None,
            workspace_id: workspace_id.map(ToOwned::to_owned),
            capabilities: Vec::new(),
        },
        target: Some(RuntimeTargetWire {
            workspace_id: workspace_id.map(ToOwned::to_owned),
            page_id: document_id.map(ToOwned::to_owned),
            block_id: Some(asset_id.to_string()),
        }),
        payload,
        preflight_data: None,
        reason: Some(format!("mnote-web resource compat {command_name}")),
        refs: vec![
            "file-tree-resource-command".into(),
            "resource-trash-compat-alias".into(),
        ],
        dry_run: false,
        validate_only: false,
    }
}

fn annotate_resource_lifecycle_result(
    mut result: Value,
    canonical_command: &str,
    resource_kind: &str,
) -> Value {
    if let Value::Object(map) = &mut result {
        map.insert("canonicalCommand".into(), json!(canonical_command));
        map.insert("resourceKind".into(), json!(resource_kind));
    }
    result
}

async fn fetch_media_asset_meta(
    state: &AppState,
    context: &RequestContext,
    user_id: &str,
    asset_id: &str,
) -> Result<Value, WebError> {
    let assets = execute_retired_query_by_name(
        state.config(),
        context,
        "mediaAssets:listByIds",
        json!({
            "userId": user_id,
            "ids": [asset_id],
        }),
        context.workspace.workspace_id.as_deref(),
        "media_asset_meta",
    )
    .await?;
    Ok(assets
        .as_array()
        .and_then(|items| items.first())
        .cloned()
        .unwrap_or_else(|| json!({})))
}

fn read_asset_workspace_and_document(asset: &Value) -> (Option<String>, Option<String>) {
    let workspace_id = asset
        .get("workspace_id")
        .or_else(|| asset.get("workspaceId"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let document_id = asset
        .get("document_id")
        .or_else(|| asset.get("documentId"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    (workspace_id, document_id)
}

pub async fn media_batch(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(body): Json<MediaBatchRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let user_id = current_user_id(&state, &context).await;
    let action = body.action.trim();
    if action != "restore" && action != "delete" && action != "rename" && action != "move" {
        return Err(WebError::bad_request_code(
            "media_batch_action_unsupported",
            "Rust resource trash 当前仅支持附件 delete / restore / rename / move",
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
    }
    if body.asset_ids.is_empty() {
        return Err(
            WebError::bad_request_code("asset_ids_required", "缺少 assetIds")
                .with_context(&context)
                .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"),
        );
    }

    let mut updated = 0usize;
    for asset_id in body.asset_ids.iter().map(String::as_str) {
        let asset_id = require_id(&context, asset_id, "assetId")?;
        let asset = fetch_media_asset_meta(&state, &context, &user_id, asset_id).await?;
        let (workspace_id, document_id) = read_asset_workspace_and_document(&asset);
        let (command_name, payload) = if action == "restore" {
            (
                "tree.resource.restore",
                json!({
                    "resourceKind": "file",
                    "assetId": asset_id,
                }),
            )
        } else if action == "rename" {
            let new_name = require_id(
                &context,
                body.new_name.as_deref().unwrap_or_default(),
                "newName",
            )?;
            (
                "tree.resource.rename",
                json!({
                    "resourceKind": "file",
                    "assetId": asset_id,
                    "newName": new_name,
                }),
            )
        } else if action == "move" {
            let target_document_id = require_id(
                &context,
                body.target_document_id.as_deref().unwrap_or_default(),
                "targetDocumentId",
            )?;
            (
                "tree.resource.move",
                json!({
                    "resourceKind": "file",
                    "assetId": asset_id,
                    "fromDocumentId": document_id,
                    "targetDocumentId": target_document_id,
                }),
            )
        } else {
            (
                "tree.resource.archive",
                json!({
                    "resourceKind": "file",
                    "assetId": asset_id,
                }),
            )
        };
        let command = resource_command(
            &context,
            command_name,
            action,
            asset_id,
            &user_id,
            workspace_id.as_deref(),
            document_id.as_deref(),
            payload,
        );
        if action == "rename" {
            let new_name = require_id(
                &context,
                body.new_name.as_deref().unwrap_or_default(),
                "newName",
            )?;
            execute_retired_mutation_by_name(
                state.config(),
                &context,
                "mediaAssets:patchById",
                json!({
                    "userId": user_id,
                    "id": asset_id,
                    "patch": {
                        "file_name": new_name,
                    },
                }),
                workspace_id.as_deref(),
                None,
                "media_batch_rename_patch",
            )
            .await?;
        }
        if action == "move" {
            let target_document_id = require_id(
                &context,
                body.target_document_id.as_deref().unwrap_or_default(),
                "targetDocumentId",
            )?;
            execute_retired_mutation_by_name(
                state.config(),
                &context,
                "mediaAssets:patchById",
                json!({
                    "userId": user_id,
                    "id": asset_id,
                    "patch": {
                        "document_id": target_document_id,
                    },
                }),
                workspace_id.as_deref(),
                None,
                "media_batch_move_patch",
            )
            .await?;
        }
        let command_result = execute_runtime_command_via_legacy_cloud_with_artifacts(
            &state,
            &context,
            workspace_id.as_deref(),
            command,
        )
        .await;
        if action == "rename" || action == "move" {
            if let Err(error) = command_result {
                tracing::warn!(
                    error = %error.message(),
                    asset_id = %asset_id,
                    action = %action,
                    "tree.resource artifact command 失败，已保留兼容 patch 结果"
                );
            }
        } else {
            command_result?;
        }
        updated += 1;
    }

    Ok(ok_response(
        &context,
        json!({
            "action": action,
            "updated": updated,
            "restored": if action == "restore" { updated } else { 0 },
            "deleted": if action == "delete" { updated } else { 0 },
            "renamed": if action == "rename" { updated } else { 0 },
            "moved": if action == "move" { updated } else { 0 },
            "canonicalCommand": if action == "restore" {
                "tree.resource.restore"
            } else if action == "rename" {
                "tree.resource.rename"
            } else if action == "move" {
                "tree.resource.move"
            } else {
                "tree.resource.archive"
            },
        }),
    ))
}

pub async fn media_purge(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(body): Json<MediaPurgeRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let user_id = current_user_id(&state, &context).await;
    let asset_id = require_id(&context, &body.asset_id, "assetId")?;
    let asset = fetch_media_asset_meta(&state, &context, &user_id, asset_id).await?;
    let (workspace_id, document_id) = read_asset_workspace_and_document(&asset);
    let command = resource_command(
        &context,
        "tree.resource.purge",
        "purge",
        asset_id,
        &user_id,
        workspace_id.as_deref(),
        document_id.as_deref(),
        json!({
            "resourceKind": "file",
            "assetId": asset_id,
        }),
    );
    let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
        &state,
        &context,
        workspace_id.as_deref(),
        command,
    )
    .await?;
    Ok(ok_response(&context, execution.result))
}

pub async fn media_empty_trash(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(body): Json<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let user_id = current_user_id(&state, &context).await;
    let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
    let result = execute_retired_mutation_by_name(
        state.config(),
        &context,
        "mediaAssets:emptyTrashByWorkspace",
        json!({
            "userId": user_id,
            "workspaceId": workspace_id,
            "expiredDeletedAt": force_expired_at(),
        }),
        Some(workspace_id),
        None,
        "media_empty_trash",
    )
    .await?;
    let _ =
        match record_media_empty_trash_artifacts(&state, &context, &user_id, workspace_id, &result)
            .await
        {
            Ok(()) => Ok(()),
            Err(error) => {
                let message = error.message().to_string();
                tracing::warn!(
                    error = %message,
                    workspace_id = %workspace_id,
                    "media empty trash tree artifacts 记录失败，主清空结果继续返回"
                );
                Err(())
            }
        };
    let mut result = result;
    if let Value::Object(map) = &mut result {
        map.insert(
            "canonicalCommand".into(),
            json!("mediaAssets.emptyTrashByWorkspace"),
        );
        map.insert("compatRoute".into(), json!("/api/media/empty-trash"));
    }
    Ok(ok_response(&context, result))
}

pub async fn mindmap_delete(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Path((doc_id, mindmap_id)): Path<(String, String)>,
    Query(query): Query<MindmapLocalQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let user_id = current_user_id(&state, &context).await;
    let doc_id = require_id(&context, &doc_id, "docId")?;
    let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?;
    if query.source_kind.as_deref() == Some("local_folder") {
        let root_uri = query
            .root_uri
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| {
                WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
                    .with_context(&context)
            })?;
        ensure_local_workspace_access(&context, root_uri)
            .map_err(|error| error.with_context(&context))?;
        let execution = execute_local_tree_command(root_uri, "delete", &mindmap_id, None, None)
            .map_err(|error| error.with_context(&context))?;
        return Ok(ok_response(
            &context,
            annotate_resource_lifecycle_result(execution, "tree.resource.archive", "mindmap"),
        ));
    }
    let workspace_id = fetch_document_workspace_id(&state, &context, doc_id).await;
    let command = resource_command(
        &context,
        "tree.resource.archive",
        "mindmap_archive",
        mindmap_id,
        &user_id,
        workspace_id.as_deref(),
        Some(doc_id),
        json!({
            "resourceKind": "mindmap",
            "documentId": doc_id,
            "mindmapId": mindmap_id,
        }),
    );
    let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
        &state,
        &context,
        workspace_id.as_deref(),
        command,
    )
    .await?;
    Ok(ok_response(
        &context,
        annotate_resource_lifecycle_result(execution.result, "tree.resource.archive", "mindmap"),
    ))
}

pub async fn mindmap_trash_action(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Path((doc_id, mindmap_id)): Path<(String, String)>,
    Query(query): Query<MindmapLocalQuery>,
    Json(body): Json<MindmapTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let action = body.action.trim();
    let command_name = match action {
        "restore" => "tree.resource.restore",
        "purge" => "tree.resource.purge",
        _ => {
            return Err(WebError::bad_request_code(
                "mindmap_trash_action_unsupported",
                "仅支持 restore 或 purge",
            )
            .with_context(&context)
            .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
        }
    };
    let user_id = current_user_id(&state, &context).await;
    let doc_id = require_id(&context, &doc_id, "docId")?;
    let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?;
    let local_source_kind = body
        .source_kind
        .as_deref()
        .or(query.source_kind.as_deref())
        .map(str::trim);
    if local_source_kind == Some("local_folder") {
        let root_uri = query
            .root_uri
            .as_deref()
            .or(body.root_uri.as_deref())
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| {
                WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
                    .with_context(&context)
            })?;
        ensure_local_workspace_access(&context, root_uri)
            .map_err(|error| error.with_context(&context))?;
        let local_action = if action == "restore" {
            "restore"
        } else {
            "purge"
        };
        let execution = execute_local_tree_command(root_uri, local_action, mindmap_id, None, None)
            .map_err(|error| error.with_context(&context))?;
        return Ok(ok_response(
            &context,
            annotate_resource_lifecycle_result(execution, command_name, "mindmap"),
        ));
    }
    let workspace_id = fetch_document_workspace_id(&state, &context, doc_id).await;
    let command = resource_command(
        &context,
        command_name,
        if action == "restore" {
            "mindmap_restore"
        } else {
            "mindmap_purge"
        },
        mindmap_id,
        &user_id,
        workspace_id.as_deref(),
        Some(doc_id),
        json!({
            "resourceKind": "mindmap",
            "documentId": doc_id,
            "mindmapId": mindmap_id,
        }),
    );
    let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
        &state,
        &context,
        workspace_id.as_deref(),
        command,
    )
    .await?;
    Ok(ok_response(
        &context,
        annotate_resource_lifecycle_result(execution.result, command_name, "mindmap"),
    ))
}

pub async fn mindmap_empty_trash(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(body): Json<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
    let result = execute_retired_mutation_by_name(
        state.config(),
        &context,
        "mindmaps:emptyTrashByWorkspace",
        json!({
            "workspaceId": workspace_id,
        }),
        Some(workspace_id),
        None,
        "mindmap_empty_trash",
    )
    .await?;
    let _ = record_resource_resync_artifacts(
        &state,
        &context,
        &context.auth.actor_id,
        workspace_id,
        None,
        None,
        "mindmaps.emptyTrashByWorkspace",
        "tree.trash.mindmap.emptied",
        "mindmap_empty_trash",
        &result,
    )
    .await
    .map_err(|error| {
        tracing::warn!(
            error = %error.message(),
            workspace_id = %workspace_id,
            "mindmap empty trash tree artifacts 记录失败，主清空结果继续返回"
        );
    });
    Ok(ok_response(&context, result))
}

pub async fn table_restore(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(body): Json<TableTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    table_action(
        state,
        context,
        body.table_id,
        "tables:restore",
        "table_restore",
    )
    .await
}

pub async fn table_create(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(body): Json<TableCreateRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let user_id = current_user_id(&state, &context).await;
    let document_id = require_id(&context, &body.document_id, "documentId")?;
    let workspace_id = fetch_document_workspace_id(&state, &context, document_id)
        .await
        .ok_or_else(|| {
            WebError::bad_request_code("workspace_id_required", "无法解析表格所属 workspace")
                .with_context(&context)
                .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        })?;
    let result = execute_retired_mutation_by_name(
        state.config(),
        &context,
        "tables:create",
        json!({
            "userId": user_id,
            "workspaceId": workspace_id,
            "documentId": document_id,
            "title": body.title,
            "schema": body.schema.unwrap_or_else(|| json!({})),
            "snapshot": body.snapshot,
        }),
        Some(&workspace_id),
        None,
        "table_create",
    )
    .await?;
    let table_id = result
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let _ = record_resource_resync_artifacts(
        &state,
        &context,
        &user_id,
        &workspace_id,
        Some(document_id),
        table_id,
        "tables.create",
        "tree.resource.table.created",
        "table_create",
        &result,
    )
    .await
    .map_err(|error| {
        tracing::warn!(
            error = %error.message(),
            table_id = table_id.unwrap_or(""),
            "table create tree artifacts 记录失败，主创建结果继续返回"
        );
    });
    Ok(ok_response(&context, result))
}

pub async fn table_delete(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Path(table_id): Path<String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    table_action(state, context, table_id, "tables:remove", "table_delete").await
}

pub async fn table_purge(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(body): Json<TableTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    table_action(state, context, body.table_id, "tables:purge", "table_purge").await
}

async fn table_action(
    state: AppState,
    context: RequestContext,
    table_id: String,
    function_name: &'static str,
    error_phase: &'static str,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let user_id = current_user_id(&state, &context).await;
    let table_id = require_id(&context, &table_id, "tableId")?;
    let table_meta = fetch_table_meta(&state, &context, &user_id, table_id).await;
    let workspace_id = table_meta
        .as_ref()
        .and_then(|table| {
            table
                .get("workspace_id")
                .or_else(|| table.get("workspaceId"))
        })
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let document_id = table_meta
        .as_ref()
        .and_then(|table| table.get("document_id").or_else(|| table.get("documentId")))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let (command_name, reason) = match function_name {
        "tables:restore" => ("tree.resource.restore", "table_restore"),
        "tables:purge" => ("tree.resource.purge", "table_purge"),
        _ => ("tree.resource.archive", "table_archive"),
    };
    let command = resource_command(
        &context,
        command_name,
        reason,
        table_id,
        &user_id,
        workspace_id.as_deref(),
        document_id.as_deref(),
        json!({
            "resourceKind": "table",
            "tableId": table_id,
        }),
    );
    let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
        &state,
        &context,
        workspace_id.as_deref(),
        command,
    )
    .await?;
    let _ = error_phase;
    Ok(ok_response(
        &context,
        annotate_resource_lifecycle_result(execution.result, command_name, "table"),
    ))
}

pub async fn table_empty_trash(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(body): Json<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let user_id = current_user_id(&state, &context).await;
    let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
    let result = execute_retired_mutation_by_name(
        state.config(),
        &context,
        "tables:emptyTrashByWorkspace",
        json!({
            "userId": user_id,
            "workspaceId": workspace_id,
            "expiredDeletedAt": force_expired_at(),
        }),
        Some(workspace_id),
        None,
        "table_empty_trash",
    )
    .await?;
    let _ = record_resource_resync_artifacts(
        &state,
        &context,
        &user_id,
        workspace_id,
        None,
        None,
        "tables.emptyTrashByWorkspace",
        "tree.trash.table.emptied",
        "table_empty_trash",
        &result,
    )
    .await
    .map_err(|error| {
        tracing::warn!(
            error = %error.message(),
            workspace_id = %workspace_id,
            "table empty trash tree artifacts 记录失败，主清空结果继续返回"
        );
    });
    Ok(ok_response(&context, result))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::{build_app, AppConfig, AppState};
    use axum::body::{to_bytes, Body};
    use axum::http::Request;
    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: false,
            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"},
                  "mediaAssets:listByIds": [{"id": "asset_1", "workspace_id": "ws_demo", "document_id": "doc_1"}],
                  "tables:get": {"id": "table_1", "workspace_id": "ws_demo", "document_id": "doc_1"}
                }"#
                    .into(),
            ),
            mutation_fixtures_json: Some(
                r#"{
                  "mediaAssets:patchById": {"ok": true},
                  "mediaAssets:purgeById": {"ok": true, "deleted": 1},
                  "mediaAssets:emptyTrashByWorkspace": {"ok": true, "deleted": 2},
                  "bridgeLogs:recordCommandLog": {"ok": true},
                  "bridgeLogs:recordDomainEvent": {"ok": true},
                  "mindmaps:softDelete": {"ok": true, "moved": 1, "deleted_at": "2026-05-15T00:00:00Z"},
                  "mindmaps:restore": {"ok": true, "updated_at": "2026-05-15T00:00:00Z"},
                  "mindmaps:purge": {"ok": true},
                  "mindmaps:emptyTrashByWorkspace": {"ok": true, "deletedCount": 3},
                  "tables:create": {"id": "table_1", "workspace_id": "ws_demo", "document_id": "doc_1", "title": "表格"},
                  "tables:remove": {"success": true, "deleted_at": "2026-05-15T00:00:00Z"},
                  "tables:restore": {"success": true},
                  "tables:purge": {"success": true},
                  "tables:emptyTrashByWorkspace": {"ok": true, "deleted": 4}
                }"#
                .into(),
            ),
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
    }

    async fn post_json(uri: &str, body: Value) -> Value {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri(uri)
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(body.to_string()))
                    .expect("request"),
            )
            .await
            .expect("response");
        let status = response.status();
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
        serde_json::from_slice(&body).expect("json")
    }

    async fn patch_json(uri: &str, body: Value) -> Value {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("PATCH")
                    .uri(uri)
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(body.to_string()))
                    .expect("request"),
            )
            .await
            .expect("response");
        let status = response.status();
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
        serde_json::from_slice(&body).expect("json")
    }

    async fn delete_json(uri: &str) -> Value {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("DELETE")
                    .uri(uri)
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        let status = response.status();
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
        serde_json::from_slice(&body).expect("json")
    }

    #[tokio::test]
    async fn local_folder_mindmap_delete_moves_resource_file_to_trash() {
        let root =
            std::env::temp_dir().join(format!("mnote-local-mindmap-trash-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("Page")).expect("create page");
        let markdown_path = root.join("Page").join("Page.md");
        let original_markdown = "# Page\n\n[map](map.mindmap.json)\n";
        std::fs::write(&markdown_path, original_markdown).expect("write md");
        std::fs::write(
            root.join("Page").join("map.mindmap.json"),
            r#"{"data":{"uid":"root","text":"KMIND"},"children":[]}"#,
        )
        .expect("write mindmap");
        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_real",
            &root_uri,
        )
        .expect("init workspace");

        let response = app()
            .oneshot(
                Request::builder()
                    .method("DELETE")
                    .uri(format!(
                        "/api/mindmap/local-md:Page~2FPage.md/local-file:Page%2Fmap.mindmap.json?sourceKind=local_folder&rootUri={root_uri}"
                    ))
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        let status = response.status();
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");

        assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
        assert_eq!(
            payload["result"]["canonicalCommand"],
            "tree.resource.archive"
        );
        assert_eq!(payload["result"]["sourceKind"], "local_folder");
        assert!(!root.join("Page").join("map.mindmap.json").exists());
        assert!(root
            .join(".mnote")
            .join("trash")
            .join("map.mindmap.json")
            .exists());
        assert_eq!(
            std::fs::read_to_string(&markdown_path).expect("read markdown after trash"),
            original_markdown,
            "mindmap archive 只移动资源文件，不应改写正文引用"
        );
        let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
            .expect("trash index");
        assert!(trash_index.contains("local-file:Page/map.mindmap.json"));

        let restored = app()
            .oneshot(
                Request::builder()
                    .method("PATCH")
                    .uri("/api/mindmap/local-md:Page~2FPage.md/local-file:Page%2Fmap.mindmap.json")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "action": "restore",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("restore response");
        let restore_status = restored.status();
        let restore_body = to_bytes(restored.into_body(), usize::MAX)
            .await
            .expect("restore body");
        let restored_payload: Value = serde_json::from_slice(&restore_body).expect("json");
        assert_eq!(
            restore_status,
            StatusCode::OK,
            "{}",
            String::from_utf8_lossy(&restore_body)
        );
        assert_eq!(restored_payload["result"]["sourceKind"], "local_folder");
        assert_eq!(
            restored_payload["result"]["canonicalCommand"],
            "tree.resource.restore"
        );
        assert!(root.join("Page").join("map.mindmap.json").exists());
        assert_eq!(
            std::fs::read_to_string(&markdown_path).expect("read markdown after restore"),
            original_markdown,
            "mindmap restore 只恢复资源文件，不应改写正文引用"
        );

        let delete_again = app()
            .oneshot(
                Request::builder()
                    .method("DELETE")
                    .uri(format!(
                        "/api/mindmap/local-md:Page~2FPage.md/local-file:Page%2Fmap.mindmap.json?sourceKind=local_folder&rootUri={root_uri}"
                    ))
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("delete again response");
        assert_eq!(delete_again.status(), StatusCode::OK);
        let _ = to_bytes(delete_again.into_body(), usize::MAX).await;

        let purged = app()
            .oneshot(
                Request::builder()
                    .method("PATCH")
                    .uri("/api/mindmap/local-md:Page~2FPage.md/local-file:Page%2Fmap.mindmap.json")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        json!({
                            "action": "purge",
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("purge response");
        let purge_status = purged.status();
        let purge_body = to_bytes(purged.into_body(), usize::MAX)
            .await
            .expect("purge body");
        let purged_payload: Value = serde_json::from_slice(&purge_body).expect("json");
        assert_eq!(
            purge_status,
            StatusCode::OK,
            "{}",
            String::from_utf8_lossy(&purge_body)
        );
        assert_eq!(purged_payload["result"]["sourceKind"], "local_folder");
        assert_eq!(
            purged_payload["result"]["canonicalCommand"],
            "tree.resource.purge"
        );
        assert!(!root
            .join(".mnote")
            .join("trash")
            .join("map.mindmap.json")
            .exists());

        let _ = std::fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn media_trash_routes_delete_restore_purge_and_empty() {
        let deleted = post_json(
            "/api/media/batch",
            json!({"action": "delete", "assetIds": ["asset_1"]}),
        )
        .await;
        assert_eq!(deleted["result"]["deleted"], 1);

        let restored = post_json(
            "/api/media/batch",
            json!({"action": "restore", "assetIds": ["asset_1"]}),
        )
        .await;
        assert_eq!(restored["result"]["restored"], 1);

        let purged = post_json("/api/media/purge", json!({"assetId": "asset_1"})).await;
        assert_eq!(purged["result"]["deleted"], 1);

        let emptied = post_json("/api/media/empty-trash", json!({"workspaceId": "ws_demo"})).await;
        assert_eq!(emptied["result"]["deleted"], 2);
    }

    #[tokio::test]
    async fn resource_rename_uses_resource_command_not_document_command() {
        let renamed = post_json(
            "/api/media/batch",
            json!({"action": "rename", "assetIds": ["asset_1"], "newName": "附件.docx"}),
        )
        .await;

        assert_eq!(renamed["result"]["renamed"], 1);
        assert_eq!(
            renamed["result"]["canonicalCommand"],
            "tree.resource.rename"
        );
        assert_ne!(renamed["result"]["canonicalCommand"], "tree.node.rename");
    }

    #[tokio::test]
    async fn resource_move_uses_resource_command_not_document_command() {
        let moved = post_json(
            "/api/media/batch",
            json!({"action": "move", "assetIds": ["asset_1"], "targetDocumentId": "doc_2"}),
        )
        .await;

        assert_eq!(moved["result"]["moved"], 1);
        assert_eq!(moved["result"]["canonicalCommand"], "tree.resource.move");
        assert_ne!(moved["result"]["canonicalCommand"], "documents.move");
    }

    #[tokio::test]
    async fn mindmap_trash_routes_delete_restore_purge_and_empty() {
        let deleted = delete_json("/api/mindmap/doc_1/mind_1").await;
        assert_eq!(deleted["result"]["moved"], 1);
        assert_eq!(
            deleted["result"]["canonicalCommand"],
            "tree.resource.archive"
        );
        assert_eq!(deleted["result"]["resourceKind"], "mindmap");

        let restored = patch_json("/api/mindmap/doc_1/mind_1", json!({"action": "restore"})).await;
        assert_eq!(restored["result"]["updated_at"], "2026-05-15T00:00:00Z");
        assert_eq!(
            restored["result"]["canonicalCommand"],
            "tree.resource.restore"
        );
        assert_eq!(restored["result"]["resourceKind"], "mindmap");

        let purged = patch_json("/api/mindmap/doc_1/mind_1", json!({"action": "purge"})).await;
        assert_eq!(purged["result"]["ok"], true);
        assert_eq!(purged["result"]["canonicalCommand"], "tree.resource.purge");
        assert_eq!(purged["result"]["resourceKind"], "mindmap");

        let emptied = post_json(
            "/api/mindmap-trash/empty",
            json!({"workspaceId": "ws_demo"}),
        )
        .await;
        assert_eq!(emptied["result"]["deletedCount"], 3);
    }

    #[tokio::test]
    async fn mindmap_delete_restore_keeps_markdown_reference_out_of_lifecycle_command() {
        let deleted = delete_json("/api/mindmap/doc_1/mind_1").await;
        assert_eq!(
            deleted["result"]["canonicalCommand"],
            "tree.resource.archive"
        );
        assert_eq!(deleted["result"]["resourceKind"], "mindmap");
        assert_eq!(deleted["result"]["documentId"], Value::Null);

        let restored = patch_json("/api/mindmap/doc_1/mind_1", json!({"action": "restore"})).await;
        assert_eq!(
            restored["result"]["canonicalCommand"],
            "tree.resource.restore"
        );
        assert_eq!(restored["result"]["resourceKind"], "mindmap");
        assert_eq!(restored["result"]["documentId"], Value::Null);
    }

    #[tokio::test]
    async fn table_trash_routes_delete_restore_purge_and_empty() {
        let created = post_json(
            "/api/tables/create",
            json!({
                "documentId": "doc_1",
                "title": "表格",
                "schema": {},
                "snapshot": null,
            }),
        )
        .await;
        assert_eq!(created["result"]["id"], "table_1");

        let deleted = delete_json("/api/tables/table_1").await;
        assert_eq!(deleted["result"]["success"], true);
        assert_eq!(
            deleted["result"]["canonicalCommand"],
            "tree.resource.archive"
        );
        assert_eq!(deleted["result"]["resourceKind"], "table");

        let restored = post_json("/api/tables/restore", json!({"tableId": "table_1"})).await;
        assert_eq!(restored["result"]["success"], true);
        assert_eq!(
            restored["result"]["canonicalCommand"],
            "tree.resource.restore"
        );
        assert_eq!(restored["result"]["resourceKind"], "table");

        let purged = post_json("/api/tables/purge", json!({"tableId": "table_1"})).await;
        assert_eq!(purged["result"]["success"], true);
        assert_eq!(purged["result"]["canonicalCommand"], "tree.resource.purge");
        assert_eq!(purged["result"]["resourceKind"], "table");

        let emptied = post_json("/api/tables/empty-trash", json!({"workspaceId": "ws_demo"})).await;
        assert_eq!(emptied["result"]["deleted"], 4);
    }
}
