use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::{
    execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
};
use crate::routes::local_folder_source::{
    save_local_markdown_page, update_local_markdown_title, update_local_page_options,
};
use crate::routes::query_support::{
    execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
    resolve_effective_workspace_id,
};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use bridge_runtime::{
    RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::fs;
use std::time::Duration;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentContentQuery {
    pub document_id: String,
    pub workspace_id: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentMetaQuery {
    pub document_id: String,
    pub workspace_id: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentSaveRequest {
    pub document_id: String,
    pub workspace_id: Option<String>,
    pub source_kind: Option<String>,
    pub root_uri: Option<String>,
    pub revision: Option<u64>,
    pub conflict_detection_key: Option<String>,
    pub editor_document: Option<Value>,
    pub content: Value,
    pub tiptap_document: Option<Value>,
    pub snapshot_captured_at: Option<String>,
    pub block_count: Option<u64>,
}

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

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentOptionsRequest {
    pub document_id: String,
    pub workspace_id: Option<String>,
    pub source_kind: Option<String>,
    pub root_uri: Option<String>,
    #[serde(default)]
    pub options: Value,
    pub command_name: Option<String>,
}

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

const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport";

fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json<Value>) {
    let mut headers = HeaderMap::new();
    stamp_documents_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 execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
    execution
        .artifacts
        .as_ref()
        .and_then(|artifacts| serde_json::to_value(artifacts).ok())
        .unwrap_or(Value::Null)
}

fn stamp_documents_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_DOCUMENTS_TRANSPORT.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("documents-api"));
    }
}

fn read_env_or_dotenv(key: &str) -> Option<String> {
    if let Ok(value) = std::env::var(key) {
        let trimmed = value.trim().trim_matches('"').to_string();
        if !trimmed.is_empty() {
            return Some(trimmed);
        }
    }

    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../..")
        .join(".env.all");
    let content = fs::read_to_string(root).ok()?;
    for line in content.lines() {
        let line = line.trim_end_matches('\r');
        if line.starts_with('#') || line.trim().is_empty() {
            continue;
        }
        let Some((k, v)) = line.split_once('=') else {
            continue;
        };
        if k.trim() != key {
            continue;
        }
        let trimmed = v.trim().trim_matches('"').to_string();
        if !trimmed.is_empty() {
            return Some(trimmed);
        }
    }
    None
}

fn next_documents_base_url() -> String {
    read_env_or_dotenv(NEXT_DOCUMENTS_BASE_URL_ENV)
        .unwrap_or_else(|| "http://127.0.0.1:3000".into())
        .trim()
        .trim_end_matches('/')
        .to_string()
}

fn should_proxy_via_next(_context: &RequestContext) -> bool {
    false
}

fn build_next_proxy_headers(
    context: &RequestContext,
    effective_workspace_id: Option<&str>,
) -> reqwest::header::HeaderMap {
    use reqwest::header::{HeaderMap, HeaderName, HeaderValue};

    fn insert(headers: &mut HeaderMap, name: &'static str, value: &str) {
        let Ok(header_name) = HeaderName::from_lowercase(name.as_bytes()) else {
            return;
        };
        let Ok(header_value) = HeaderValue::from_str(value) else {
            return;
        };
        headers.insert(header_name, header_value);
    }

    let mut headers = HeaderMap::new();
    if let Some(cookie) = context.auth.cookie_header.as_deref() {
        insert(&mut headers, "cookie", cookie);
    }
    if let Some(authorization) = context.auth.authorization.as_deref() {
        insert(&mut headers, "authorization", authorization);
    }
    insert(&mut headers, "x-request-id", &context.trace.request_id);
    insert(&mut headers, "x-trace-id", &context.trace.trace_id);
    insert(
        &mut headers,
        "x-mnote-source-channel",
        &context.source.channel,
    );
    insert(
        &mut headers,
        "x-mnote-source-client",
        &context.source.client,
    );
    insert(&mut headers, "x-mnote-actor-id", &context.auth.actor_id);
    insert(&mut headers, "x-mnote-actor-type", &context.auth.actor_type);
    if let Some(session_id) = context.auth.session_id.as_deref() {
        insert(&mut headers, "x-mnote-session-id", session_id);
    }
    if let Some(workspace_id) = effective_workspace_id {
        insert(&mut headers, "x-mnote-workspace-id", workspace_id);
    }
    headers
}

async fn send_next_documents_request(
    context: &RequestContext,
    request: reqwest::RequestBuilder,
    phase: &'static str,
) -> Result<Value, WebError> {
    let response = request.send().await.map_err(|error| {
        let base = if error.is_timeout() {
            WebError::gateway_timeout_code(
                "next_proxy_timeout",
                format!("Next compat 请求超时: {error}"),
            )
        } else {
            WebError::service_unavailable_code(
                "next_proxy_unavailable",
                format!("Next compat 请求失败: {error}"),
            )
        };
        base.with_context(context)
            .with_header("x-error-phase", phase)
            .with_header("x-upstream-service", "next")
    })?;

    let status = response.status();
    let text = response.text().await.map_err(|error| {
        WebError::bad_gateway_code(
            "next_proxy_bad_response",
            format!("Next compat 响应读取失败: {error}"),
        )
        .with_context(context)
        .with_header("x-error-phase", phase)
        .with_header("x-upstream-service", "next")
        .with_header("x-upstream-status", status.as_u16().to_string())
    })?;

    let payload = serde_json::from_str::<Value>(&text).map_err(|_| {
        let snippet: String = text.chars().take(180).collect();
        WebError::bad_gateway_code(
            "next_proxy_bad_response",
            format!("Next compat 返回了非 JSON 内容: {snippet}"),
        )
        .with_context(context)
        .with_header("x-error-phase", phase)
        .with_header("x-upstream-service", "next")
        .with_header("x-upstream-status", status.as_u16().to_string())
    })?;

    if !status.is_success() {
        let message = payload
            .get("error")
            .and_then(Value::as_str)
            .or_else(|| payload.get("message").and_then(Value::as_str))
            .unwrap_or("Next compat 文档接口请求失败");
        let web_error = match status {
            reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
                WebError::new(StatusCode::UNAUTHORIZED, "next_proxy_unauthorized", message)
            }
            reqwest::StatusCode::NOT_FOUND => {
                WebError::new(StatusCode::NOT_FOUND, "next_proxy_not_found", message)
            }
            _ => WebError::bad_gateway_code("next_proxy_error", message),
        };
        return Err(web_error
            .with_context(context)
            .with_header("x-error-phase", phase)
            .with_header("x-upstream-service", "next")
            .with_header("x-upstream-status", status.as_u16().to_string()));
    }

    Ok(payload)
}

async fn proxy_next_documents_meta(
    context: &RequestContext,
    effective_workspace_id: Option<&str>,
    document_id: &str,
) -> Result<Value, WebError> {
    let base_url = next_documents_base_url();
    let mut url =
        reqwest::Url::parse(&format!("{base_url}/api/documents/meta")).map_err(|error| {
            WebError::internal(format!("Next compat meta URL 非法: {error}"))
                .with_context(context)
                .with_header("x-error-phase", "next_proxy_meta_url")
                .with_header("x-upstream-service", "next")
        })?;
    url.query_pairs_mut().append_pair("documentId", document_id);
    if let Some(workspace_id) = effective_workspace_id {
        url.query_pairs_mut()
            .append_pair("workspaceId", workspace_id);
    }

    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(20))
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .map_err(|error| {
            WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
                .with_context(context)
                .with_header("x-error-phase", "next_proxy_meta_client")
                .with_header("x-upstream-service", "next")
        })?;

    let payload = send_next_documents_request(
        context,
        client
            .get(url)
            .headers(build_next_proxy_headers(context, effective_workspace_id)),
        "next_proxy_meta",
    )
    .await?;

    Ok(payload.get("doc").cloned().unwrap_or(Value::Null))
}

async fn proxy_next_documents_content(
    context: &RequestContext,
    effective_workspace_id: Option<&str>,
    document_id: &str,
) -> Result<Value, WebError> {
    let base_url = next_documents_base_url();
    let mut url =
        reqwest::Url::parse(&format!("{base_url}/api/documents/content")).map_err(|error| {
            WebError::internal(format!("Next compat content URL 非法: {error}"))
                .with_context(context)
                .with_header("x-error-phase", "next_proxy_content_url")
                .with_header("x-upstream-service", "next")
        })?;
    url.query_pairs_mut().append_pair("documentId", document_id);
    if let Some(workspace_id) = effective_workspace_id {
        url.query_pairs_mut()
            .append_pair("workspaceId", workspace_id);
    }

    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(20))
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .map_err(|error| {
            WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
                .with_context(context)
                .with_header("x-error-phase", "next_proxy_content_client")
                .with_header("x-upstream-service", "next")
        })?;

    let payload = send_next_documents_request(
        context,
        client
            .get(url)
            .headers(build_next_proxy_headers(context, effective_workspace_id)),
        "next_proxy_content",
    )
    .await?;

    Ok(json!({
        "content": payload.get("content").cloned().unwrap_or(Value::Null),
        "revision": payload.get("revision").cloned().unwrap_or(Value::Null),
        "conflictDetectionKey": payload.get("conflictDetectionKey").cloned().unwrap_or(Value::Null),
        "pageSubtree": payload.get("pageSubtree").cloned().unwrap_or(Value::Null),
    }))
}

async fn proxy_next_documents_save(
    context: &RequestContext,
    effective_workspace_id: Option<&str>,
    body: &DocumentSaveRequest,
) -> Result<Value, WebError> {
    let base_url = next_documents_base_url();
    let url = reqwest::Url::parse(&format!("{base_url}/api/documents/save")).map_err(|error| {
        WebError::internal(format!("Next compat save URL 非法: {error}"))
            .with_context(context)
            .with_header("x-error-phase", "next_proxy_save_url")
            .with_header("x-upstream-service", "next")
    })?;

    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(20))
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .map_err(|error| {
            WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
                .with_context(context)
                .with_header("x-error-phase", "next_proxy_save_client")
                .with_header("x-upstream-service", "next")
        })?;

    let payload = send_next_documents_request(
        context,
        client
            .post(url)
            .headers(build_next_proxy_headers(context, effective_workspace_id))
            .json(&json!({
                "documentId": body.document_id,
                "workspaceId": effective_workspace_id,
                "revision": body.revision,
                "conflictDetectionKey": body.conflict_detection_key,
                "editorDocument": body.editor_document,
                "content": body.content,
                "tiptapDocument": body.tiptap_document,
                "snapshotCapturedAt": body.snapshot_captured_at,
                "blockCount": body.block_count,
            })),
        "next_proxy_save",
    )
    .await?;

    Ok(json!({
        "revision": payload.get("revision").cloned().unwrap_or(Value::Null),
        "conflictDetectionKey": payload
            .get("conflictDetectionKey")
            .cloned()
            .unwrap_or(Value::Null),
        "ok": payload.get("ok").cloned().unwrap_or(Value::Bool(true)),
    }))
}

pub async fn load_document_meta_result(
    state: &AppState,
    context: &RequestContext,
    query: DocumentMetaQuery,
) -> Result<Value, WebError> {
    let document_id_owned = query.document_id.trim().to_string();
    if document_id_owned.is_empty() {
        return Err(
            WebError::bad_request_code("document_id_required", "缺少有效 documentId")
                .with_context(context),
        );
    }
    let effective_workspace_id =
        resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?;
    if should_proxy_via_next(context) {
        return proxy_next_documents_meta(
            context,
            effective_workspace_id.as_deref(),
            &document_id_owned,
        )
        .await;
    }
    fetch_documents_meta_via_convex(
        state.config(),
        context,
        effective_workspace_id.as_deref(),
        &document_id_owned,
    )
    .await
}

pub async fn load_document_content_result(
    state: &AppState,
    context: &RequestContext,
    query: DocumentContentQuery,
) -> Result<Value, WebError> {
    let document_id_owned = query.document_id.trim().to_string();
    if document_id_owned.is_empty() {
        return Err(
            WebError::bad_request_code("document_id_required", "缺少有效 documentId")
                .with_context(context),
        );
    }
    let effective_workspace_id =
        resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?;
    if should_proxy_via_next(context) {
        return proxy_next_documents_content(
            context,
            effective_workspace_id.as_deref(),
            &document_id_owned,
        )
        .await;
    }
    execute_runtime_query_via_convex(
        state.config(),
        context,
        effective_workspace_id.as_deref(),
        RuntimeQueryEnvelopeWire {
            name: "documents.content.get".into(),
            payload: json!({
                "documentId": document_id_owned,
                "workspaceId": effective_workspace_id,
            }),
        },
    )
    .await
}

pub async fn meta(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Query(query): Query<DocumentMetaQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let result = load_document_meta_result(&state, &context, query).await?;
    Ok(ok_response(&context, result))
}

pub async fn content(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Query(query): Query<DocumentContentQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let result = load_document_content_result(&state, &context, query).await?;
    Ok(ok_response(&context, result))
}

pub async fn save(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(body): Json<DocumentSaveRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let document_id = body.document_id.trim();
    if document_id.is_empty() {
        return Err(
            WebError::bad_request_code("document_id_required", "缺少有效 documentId")
                .with_context(&context),
        );
    }
    if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
        let root_uri = 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)
            })?;
        let result = save_local_markdown_page(
            root_uri,
            document_id,
            body.conflict_detection_key.as_deref(),
            &body.content,
        )?;
        return Ok(ok_response(&context, result));
    }
    let effective_workspace_id =
        resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
    if should_proxy_via_next(&context) {
        let result =
            proxy_next_documents_save(&context, effective_workspace_id.as_deref(), &body).await?;
        return Ok(ok_response(&context, result));
    }
    let command = RuntimeCommandEnvelopeWire {
        name: "page.body.save".into(),
        command_id: format!("document_save_{}", context.trace.request_id),
        idempotency_key: context.source.idempotency_key.clone(),
        actor: RuntimeActorWire {
            actor_type: context.auth.actor_type.clone(),
            actor_id: context.auth.actor_id.clone(),
            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: None,
            capabilities: Vec::new(),
        },
        target: Some(RuntimeTargetWire {
            workspace_id: effective_workspace_id.clone(),
            page_id: Some(document_id.to_string()),
            block_id: None,
        }),
        payload: json!({
            "documentId": document_id,
            "workspaceId": effective_workspace_id,
            "revision": body.revision,
            "conflictDetectionKey": body.conflict_detection_key,
            "editorDocument": body.editor_document,
            "content": body.content,
            "tiptapDocument": body.tiptap_document,
            "snapshotCapturedAt": body.snapshot_captured_at,
            "blockCount": body.block_count,
        }),
        preflight_data: None,
        reason: Some("mnote-web human editor save".into()),
        refs: vec!["mnote-web-editor-runtime".into()],
        dry_run: false,
        validate_only: false,
    };
    let execution = execute_runtime_command_via_convex_with_artifacts(
        state.config(),
        &context,
        effective_workspace_id.as_deref(),
        command,
    )
    .await?;
    let mut result = execution.result;
    if let Value::Object(map) = &mut result {
        map.insert("executedCommand".into(), json!("page.body.save"));
        map.insert("canonicalCommand".into(), json!("page.body.save"));
        map.insert("compatRoute".into(), json!("/api/documents/save"));
        if let Some(artifact_error) = execution.artifact_error {
            map.insert("artifactError".into(), json!(artifact_error));
        }
    }
    Ok(ok_response(&context, result))
}

pub async fn purge(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(body): Json<DocumentPurgeRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let document_id = body.document_id.trim();
    if document_id.is_empty() {
        return Err(
            WebError::bad_request_code("document_id_required", "缺少有效 documentId")
                .with_context(&context),
        );
    }
    let command = RuntimeCommandEnvelopeWire {
        name: "documents.purge".into(),
        command_id: format!("document_purge_{}", context.trace.request_id),
        idempotency_key: context.source.idempotency_key.clone(),
        actor: RuntimeActorWire {
            actor_type: context.auth.actor_type.clone(),
            actor_id: context.auth.actor_id.clone(),
            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: None,
            capabilities: Vec::new(),
        },
        target: Some(RuntimeTargetWire {
            workspace_id: None,
            page_id: Some(document_id.to_string()),
            block_id: None,
        }),
        payload: json!({
            "documentId": document_id,
        }),
        preflight_data: None,
        reason: Some("mnote-web documents purge compat".into()),
        refs: vec!["mnote-web-documents-compat".into()],
        dry_run: false,
        validate_only: false,
    };
    let result =
        execute_runtime_command_via_convex(state.config(), &context, None, command).await?;
    Ok(ok_response(&context, result))
}

pub async fn title(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(body): Json<DocumentTitleRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let document_id = body.document_id.trim();
    if document_id.is_empty() {
        return Err(
            WebError::bad_request_code("document_id_required", "缺少有效 documentId")
                .with_context(&context),
        );
    }
    let title = body.title.trim();
    if title.is_empty() {
        return Err(
            WebError::bad_request_code("title_required", "缺少有效页面标题").with_context(&context),
        );
    }
    if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
        let root_uri = 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)
            })?;
        let result = update_local_markdown_title(root_uri, document_id, title)?;
        return Ok(ok_response(&context, result));
    }
    let effective_workspace_id =
        resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
    let command = RuntimeCommandEnvelopeWire {
        name: "page.head.updateTitle".into(),
        command_id: format!("page_title_{}", context.trace.request_id),
        idempotency_key: context.source.idempotency_key.clone(),
        actor: RuntimeActorWire {
            actor_type: context.auth.actor_type.clone(),
            actor_id: context.auth.actor_id.clone(),
            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: None,
            capabilities: Vec::new(),
        },
        target: Some(RuntimeTargetWire {
            workspace_id: effective_workspace_id.clone(),
            page_id: Some(document_id.to_string()),
            block_id: None,
        }),
        payload: json!({
            "documentId": document_id,
            "workspaceId": effective_workspace_id,
            "title": title,
        }),
        preflight_data: None,
        reason: Some("mnote-web page title update".into()),
        refs: vec![body
            .command_name
            .unwrap_or_else(|| "page.head.updateTitle".into())],
        dry_run: false,
        validate_only: false,
    };
    let execution = execute_runtime_command_via_convex_with_artifacts(
        state.config(),
        &context,
        effective_workspace_id.as_deref(),
        command,
    )
    .await?;
    let artifacts = execution_artifacts_json(&execution);
    let artifact_error = execution.artifact_error.clone();
    let result = execution.result;
    let mut headers = HeaderMap::new();
    stamp_documents_headers(&mut headers);
    Ok((
        StatusCode::OK,
        headers,
        Json(json!({
            "ok": true,
            "requestId": context.trace.request_id,
            "traceId": context.trace.trace_id,
            "owner": "mnote-web",
            "meta": {
                "commandName": "page.head.updateTitle",
                "canonicalCommand": "page.head.updateTitle",
                "artifacts": artifacts,
                "artifactError": artifact_error,
            },
            "result": result,
        })),
    ))
}

pub async fn options(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(body): Json<DocumentOptionsRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let document_id = body.document_id.trim();
    if document_id.is_empty() {
        return Err(
            WebError::bad_request_code("document_id_required", "缺少有效 documentId")
                .with_context(&context),
        );
    }
    if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
        let root_uri = 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)
            })?;
        let result = update_local_page_options(root_uri, document_id, &body.options)?;
        return Ok(ok_response(&context, result));
    }
    let effective_workspace_id =
        resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
    let command = RuntimeCommandEnvelopeWire {
        name: "page.layout.updateOptions".into(),
        command_id: format!("page_options_{}", context.trace.request_id),
        idempotency_key: context.source.idempotency_key.clone(),
        actor: RuntimeActorWire {
            actor_type: context.auth.actor_type.clone(),
            actor_id: context.auth.actor_id.clone(),
            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: None,
            capabilities: Vec::new(),
        },
        target: Some(RuntimeTargetWire {
            workspace_id: effective_workspace_id.clone(),
            page_id: Some(document_id.to_string()),
            block_id: None,
        }),
        payload: json!({
            "documentId": document_id,
            "workspaceId": effective_workspace_id,
            "options": body.options,
        }),
        preflight_data: None,
        reason: Some("mnote-web page layout update".into()),
        refs: vec![body
            .command_name
            .unwrap_or_else(|| "page.layout.updateOptions".into())],
        dry_run: false,
        validate_only: false,
    };
    let execution = execute_runtime_command_via_convex_with_artifacts(
        state.config(),
        &context,
        effective_workspace_id.as_deref(),
        command,
    )
    .await?;
    let artifacts = execution_artifacts_json(&execution);
    let artifact_error = execution.artifact_error.clone();
    let result = execution.result;
    let mut headers = HeaderMap::new();
    stamp_documents_headers(&mut headers);
    Ok((
        StatusCode::OK,
        headers,
        Json(json!({
            "ok": true,
            "requestId": context.trace.request_id,
            "traceId": context.trace.trace_id,
            "owner": "mnote-web",
            "meta": {
                "commandName": "page.layout.updateOptions",
                "canonicalCommand": "page.layout.updateOptions",
                "artifacts": artifacts,
                "artifactError": artifact_error,
            },
            "result": result,
        })),
    ))
}

#[cfg(test)]
mod tests {
    use crate::app::{build_app, AppConfig, AppState};
    use axum::body::{to_bytes, Body};
    use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
    use serde_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": "服务端页面",
                    "updated_at": "2026-04-18T09:30:00Z",
                    "can_edit": true,
                    "disable_download": false,
                    "disable_copy": false,
                    "wide_layout": false,
                    "use_small_text": false,
                    "show_heading_numbers": true,
                    "show_toc": true,
                    "show_structure": true,
                    "protect_editing": false,
                    "show_word_count": true,
                    "collapse_backlinks": false,
                    "page_font": "default",
                    "layout_density": "normal",
                    "hide_child_pages": false,
                    "show_block_ref_count": true,
                    "embed_default_block_id": "heading_1",
                    "word_count": 42,
                    "character_count": 128,
                    "block_count": 3,
                    "todo_total": 1,
                    "todo_done": 0
                  },
                  "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: Some(
                r#"{
                  "documents:updateTitle": {
                    "ok": true,
                    "title": "服务端页面（改名）"
                  },
                  "documents:updateOptions": {
                    "ok": true,
                    "show_toc": false
                  },
                  "documents:updateContent": {
                    "ok": true,
                    "updated_at": "2026-04-18T09:45:00Z",
                    "revision": 8,
                    "conflict_detection_key": "doc_1:8"
                  },
                  "bridgeLogs:recordCommandLog": {
                    "ok": true,
                    "id": "clog_fixture"
                  },
                  "bridgeLogs:recordDomainEvent": {
                    "ok": true,
                    "id": "evt_fixture"
                  }
                }"#
                .into(),
            ),
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
    }

    #[test]
    fn convex_auth_cookie_does_not_force_next_proxy() {
        let mut headers = HeaderMap::new();
        headers.insert(
            "cookie",
            HeaderValue::from_static("__convexAuthJWT=jwt-demo; foo=bar"),
        );
        let context = crate::context::RequestContext::from_http_parts(
            &Method::GET,
            &"/api/documents/meta".parse::<Uri>().expect("uri"),
            &headers,
        );

        assert!(!super::should_proxy_via_next(&context));
    }

    #[tokio::test]
    async fn documents_api_meta_route_returns_document_metadata() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/documents/meta?documentId=doc_1&workspaceId=ws_demo")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response
                .headers()
                .get("x-mnote-web-owner")
                .and_then(|value| value.to_str().ok()),
            Some("mnote-web")
        );
        assert_eq!(
            response
                .headers()
                .get("x-mnote-documents-transport")
                .and_then(|value| value.to_str().ok()),
            Some("documents-api")
        );
        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"]["id"], "doc_1");
        assert_eq!(payload["result"]["workspace_id"], "ws_demo");
        assert_eq!(payload["result"]["title"], "服务端页面");
        assert_eq!(payload["result"]["show_structure"], true);
    }

    #[tokio::test]
    async fn documents_api_content_route_returns_page_subtree() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/documents/content?documentId=doc_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");
        assert_eq!(payload["result"]["revision"], 7);
        assert_eq!(payload["result"]["pageSubtree"]["rootNodeId"], "doc_1");
        assert_eq!(
            payload["result"]["pageSubtree"]["outline"][0]["title"],
            "章节一"
        );
    }

    #[tokio::test]
    async fn documents_save_route_executes_page_body_save_command() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/documents/save")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        serde_json::json!({
                            "documentId": "doc_1",
                            "workspaceId": "ws_demo",
                            "revision": 7,
                            "conflictDetectionKey": "doc_1:7",
                            "content": [
                                {
                                    "id": "heading_1",
                                    "type": "heading",
                                    "props": { "level": 1 },
                                    "content": [{ "type": "text", "text": "章节一（已编辑）" }]
                                }
                            ],
                            "blockCount": 1
                        })
                        .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"]["revision"], 8);
        assert_eq!(payload["result"]["conflict_detection_key"], "doc_1:8");
        assert_eq!(payload["result"]["executedCommand"], "page.body.save");
        assert_eq!(payload["result"]["canonicalCommand"], "page.body.save");
    }

    #[tokio::test]
    async fn documents_title_route_executes_page_head_update_title() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/documents/title")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        serde_json::json!({
                            "documentId": "doc_1",
                            "workspaceId": "ws_demo",
                            "title": "服务端页面（改名）",
                            "commandName": "page.head.updateTitle"
                        })
                        .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["meta"]["commandName"], "page.head.updateTitle");
        assert_eq!(payload["meta"]["canonicalCommand"], "page.head.updateTitle");
        assert_eq!(
            payload["meta"]["artifacts"]["commandLog"]["commandName"],
            "page.head.updateTitle"
        );
        assert_eq!(
            payload["meta"]["artifacts"]["domainEvent"]["eventType"],
            "tree.node.renamed"
        );
        assert_eq!(
            payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
            "upsert_document"
        );
        assert_eq!(
            payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]
                ["title"],
            "服务端页面（改名）"
        );
        assert_eq!(payload["meta"]["artifactError"], Value::Null);
    }

    #[tokio::test]
    async fn documents_options_route_executes_page_layout_update_options() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/documents/options")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        serde_json::json!({
                            "documentId": "doc_1",
                            "workspaceId": "ws_demo",
                            "options": {
                                "showToc": false
                            },
                            "commandName": "page.layout.updateOptions"
                        })
                        .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["meta"]["commandName"], "page.layout.updateOptions");
        assert_eq!(
            payload["meta"]["canonicalCommand"],
            "page.layout.updateOptions"
        );
        assert_eq!(
            payload["meta"]["artifacts"]["commandLog"]["commandName"],
            "page.layout.updateOptions"
        );
        assert_eq!(
            payload["meta"]["artifacts"]["domainEvent"]["eventType"],
            "page.layout.options_updated"
        );
        assert_eq!(
            payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
            "resync_required"
        );
        assert_eq!(payload["meta"]["artifactError"], Value::Null);
    }

    #[tokio::test]
    async fn local_folder_documents_save_title_and_options_write_to_disk() {
        let root = std::env::temp_dir().join(format!(
            "mnote-local-documents-write-{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).expect("create local root");
        std::fs::write(
            root.join("README.md"),
            "---\nmnote_id: local-stable\ntitle: Old Title\n---\n# Old\n",
        )
        .expect("write md");
        let root_uri = format!("file://{}", root.display());
        let document_id = "local-mdid:local-stable";

        let title_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/documents/title")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        serde_json::json!({
                            "documentId": document_id,
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "title": "New Local Title"
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("title response");
        assert_eq!(title_response.status(), StatusCode::OK);

        let save_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/documents/save")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        serde_json::json!({
                            "documentId": document_id,
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "content": [
                                {
                                    "id": "heading_1",
                                    "type": "heading",
                                    "props": { "level": 2 },
                                    "content": [{ "type": "text", "text": "Saved Heading" }]
                                },
                                {
                                    "id": "paragraph_1",
                                    "type": "paragraph",
                                    "content": [{ "type": "text", "text": "Saved body" }]
                                }
                            ],
                            "blockCount": 2
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("save response");
        assert_eq!(save_response.status(), StatusCode::OK);

        let options_response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/documents/options")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        serde_json::json!({
                            "documentId": document_id,
                            "sourceKind": "local_folder",
                            "rootUri": root_uri,
                            "options": {
                                "wideLayout": true,
                                "showToc": false
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("options response");
        assert_eq!(options_response.status(), StatusCode::OK);

        let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
        assert!(markdown.contains("mnote_id: local-stable"));
        assert!(markdown.contains("title: New Local Title"));
        assert!(markdown.contains("## Saved Heading"));
        assert!(markdown.contains("Saved body"));

        let options = std::fs::read_to_string(root.join(".mnote").join("page-options.json"))
            .expect("read page options");
        let options_json: Value = serde_json::from_str(&options).expect("options json");
        assert_eq!(options_json["pages"][document_id]["wideLayout"], true);
        assert_eq!(options_json["pages"][document_id]["showToc"], false);

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