use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::{
    control_plane_db_path_display, create_default_local_workspace_for_actor,
    ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
    load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
    load_local_trash_entries,
};
use crate::routes::snapshot_support::load_sidebar_dataset;
use crate::routes::web_shell::{
    attach_sidebar_shortcuts_to_dataset, build_document_panes_bootstrap_json,
    build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json,
    load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
    render_document_title_controller_script, render_editor_island_adapter_script,
    render_editor_runtime_preload_links, render_local_file_tree_html,
    render_local_file_tree_html_scoped, render_local_sidebar_tree_html,
    render_local_sidebar_tree_html_from_snapshot,
};
use crate::transport::convex::execute_convex_mutation_by_name;
use crate::workspace_shell::{
    build_workspace_shell_projection, render_workspace_shell_sidebar_html,
};
use axum::body::Body;
use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State};
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri};
use axum::response::{Html, IntoResponse, Response};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use control_plane::{
    session_token_hash, AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput,
    UpsertUserInput,
};
use futures_util::{SinkExt, StreamExt};
use leptos::prelude::InnerHtmlAttribute;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::time::Duration;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;

const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MNOTE_LEGACY_UPSTREAM: &str = "x-mnote-legacy-upstream";
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
const COOKIE_CONVEX_AUTH_REFRESH_TOKEN: &str = "__convexAuthRefreshToken";
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
const COOKIE_MNOTE_WEB_DEV_SESSION: &str = "mnote_web_dev_session";
const COOKIE_MNOTE_SESSION: &str = "mnote_session";
const COOKIE_MNOTE_ACTOR_ID: &str = "mnote_actor_id";
const COOKIE_MNOTE_ACTOR_TYPE: &str = "mnote_actor_type";
const COOKIE_MNOTE_ACTOR_EMAIL: &str = "mnote_actor_email";
const COOKIE_MNOTE_ACTOR_NAME: &str = "mnote_actor_name";

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GatewayManifest {
    ok: bool,
    owner: &'static str,
    public_entry: String,
    legacy_next_base_url: Option<String>,
    legacy_next_compat_enabled: bool,
    notes: Vec<&'static str>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RootEntryQuery {
    page_id: Option<String>,
    workspace_id: Option<String>,
    source_kind: Option<String>,
    root_uri: Option<String>,
    file_tree_scope: Option<String>,
    tree_view: Option<String>,
    restore_focus_row_id: Option<String>,
}

pub async fn gateway_health(State(state): State<AppState>) -> Response {
    let mut response = axum::Json(GatewayManifest {
        ok: true,
        owner: "mnote-web",
        public_entry: state.config().public_bind_addr.clone(),
        legacy_next_base_url: state.config().legacy_next_base_url.clone(),
        legacy_next_compat_enabled: state.config().enable_legacy_next_compat,
        notes: vec![
            "3000 公开入口默认由 mnote-web gateway 拥有。",
            "Next App Router 只作为 legacy compat upstream 使用。",
        ],
    })
    .into_response();
    stamp_gateway_headers(response.headers_mut(), false);
    response
}

pub async fn favicon() -> Response {
    let mut response = Response::builder()
        .status(StatusCode::NO_CONTENT)
        .body(Body::empty())
        .unwrap_or_else(|_| StatusCode::NO_CONTENT.into_response());
    stamp_gateway_headers(response.headers_mut(), false);
    response
}

pub async fn auth_api(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    request: Request<Body>,
) -> Result<Response, WebError> {
    let body = axum::body::to_bytes(request.into_body(), 256 * 1024)
        .await
        .map_err(|error| WebError::bad_request(format!("读取登录请求失败: {error}")))?;
    let payload: serde_json::Value = serde_json::from_slice(&body).map_err(|error| {
        WebError::bad_request_code(
            "auth_bad_request",
            format!("登录请求不是合法 JSON: {error}"),
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
    })?;
    let action = payload
        .get("action")
        .and_then(|value| value.as_str())
        .unwrap_or_default();
    if action != "auth:signIn" && action != "auth:signOut" {
        return Err(WebError::bad_request_code(
            "auth_action_unsupported",
            "Rust gateway 当前仅支持账号登录、注册与登出动作。",
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
    }
    if action == "auth:signOut" {
        return Ok(build_sqlite_sign_out_response(&state, &context));
    }

    handle_sqlite_auth_action(&state, &context, &payload)
}

pub async fn auth_entry(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    _request: Request<Body>,
) -> Result<Response, WebError> {
    if has_real_auth_context(&state, &context) {
        let mut response = Response::builder()
            .status(StatusCode::SEE_OTHER)
            .header(header::LOCATION, "/")
            .body(Body::empty())
            .map_err(|error| WebError::internal(format!("认证跳转响应构造失败: {error}")))?;
        stamp_gateway_headers(response.headers_mut(), false);
        return Ok(response);
    }

    let content = crate::ssr::render_view(crate::ssr::pages::auth::AuthPage());
    let mut response = Html(format!(
        r#"<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <title>MNOTE Auth</title>
    <style>{}</style>
  </head>
  <body data-mnote-web-owner="mnote-web" data-mnote-shell="auth">
    {}
  </body>
</html>"#,
        crate::ssr::MNOTE_CSS,
        content
    ))
    .into_response();
    stamp_gateway_headers(response.headers_mut(), false);
    Ok(response)
}

pub async fn admin_access_policy_entry(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
    if !has_real_auth_context(&state, &context) {
        let mut response = Response::builder()
            .status(StatusCode::SEE_OTHER)
            .header(header::LOCATION, "/auth")
            .body(Body::empty())
            .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
        stamp_gateway_headers(response.headers_mut(), false);
        return Ok(response);
    }
    if !is_local_access_policy_admin_context(&context) {
        return Err(WebError::new(
            StatusCode::FORBIDDEN,
            "local_access_policy_admin_required",
            "只有管理员可以访问目录授权页面",
        )
        .with_context(&context));
    }
    let workspace_name = default_workspace_name_for_context(&state, &context);
    let share_grants_path = control_plane_db_path_display();
    let content = crate::ssr::render_view(leptos::view! {
        <crate::ssr::pages::admin::AdminAccessPolicyPanel workspace_name={workspace_name} share_grants_path={share_grants_path} is_admin=true />
    });
    let mut response = Html(format!(
        r#"<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <title>授权管理</title>
    <style>{}</style>
  </head>
  <body data-mnote-web-owner="mnote-web" data-mnote-shell="admin" data-mnote-actor-id="{}">
    {}
  </body>
</html>"#,
        crate::ssr::MNOTE_CSS,
        escape_html(context.auth.actor_id.as_str()),
        content
    ))
    .into_response();
    stamp_gateway_headers(response.headers_mut(), false);
    Ok(response)
}

pub async fn user_access_policy_entry(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
    if !has_real_auth_context(&state, &context) {
        let mut response = Response::builder()
            .status(StatusCode::SEE_OTHER)
            .header(header::LOCATION, "/auth")
            .body(Body::empty())
            .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
        stamp_gateway_headers(response.headers_mut(), false);
        return Ok(response);
    }
    let workspace_name = default_workspace_name_for_context(&state, &context);
    let content = crate::ssr::render_view(leptos::view! {
        <crate::ssr::pages::admin::AdminAccessPolicyPanel workspace_name={workspace_name} is_admin=false />
    });
    let mut response = Html(format!(
        r#"<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <title>授权管理</title>
    <style>{}</style>
  </head>
  <body data-mnote-web-owner="mnote-web" data-mnote-shell="user-access-policy" data-mnote-actor-id="{}">
    {}
  </body>
</html>"#,
        crate::ssr::MNOTE_CSS,
        escape_html(context.auth.actor_id.as_str()),
        content
    ))
    .into_response();
    stamp_gateway_headers(response.headers_mut(), false);
    Ok(response)
}

pub async fn root_entry(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
    if !has_real_auth_context(&state, &context) {
        let mut response = Response::builder()
            .status(StatusCode::SEE_OTHER)
            .header(header::LOCATION, "/auth")
            .body(Body::empty())
            .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
        stamp_gateway_headers(response.headers_mut(), false);
        return Ok(response);
    }

    let is_local_folder = query
        .source_kind
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        == Some("local_folder");
    let requested_page_id = normalize_optional_id(query.page_id.as_deref());
    let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
    let recent_page_id = normalize_optional_id(recent_page_id.as_deref());
    let default_workspace_name = default_workspace_name_for_context(&state, &context);
    let actor_id =
        current_actor_id(&state, &context).unwrap_or_else(|| context.auth.actor_id.clone());
    let actor_type = current_actor_type(&state, &context);
    let should_render_local_first_landing = !is_local_folder
        && query
            .source_kind
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .is_none()
        && query
            .workspace_id
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .is_none()
        && requested_page_id.is_none();
    let requests_filetree_first = query
        .tree_view
        .as_deref()
        .map(str::trim)
        .is_some_and(|value| value == "filetree");
    let file_tree_scope = query
        .file_tree_scope
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let (
        workspace_id,
        workspace_projection,
        sidebar_tree_html,
        file_tree_html,
        selected_active_page_id,
        active_source_kind,
        active_root_uri,
    ) = if is_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")
            })?;
        ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
        let page_tree_snapshot = if let Some(scope) = file_tree_scope {
            load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
        } else {
            load_local_folder_page_tree_snapshot(root_uri)?
        };
        let workspace_id = page_tree_snapshot
            .dataset
            .get("workspace")
            .and_then(|workspace| workspace.get("id"))
            .and_then(|value| value.as_str())
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("local-folder")
            .to_string();
        let requested_or_recent_page_id =
            choose_root_entry_active_page_id(requested_page_id.clone(), None, None, None);
        let mut workspace_dataset = page_tree_snapshot.dataset.clone();
        attach_sidebar_shortcuts_to_dataset(
            &state,
            &context,
            &workspace_id,
            &mut workspace_dataset,
        );
        let workspace_projection = build_workspace_shell_projection(
            &workspace_dataset,
            &workspace_id,
            requested_or_recent_page_id.as_deref(),
            "本地文件夹",
        );
        let selected_active_page_id = choose_root_entry_active_page_id(
            requested_page_id.clone(),
            None,
            workspace_projection.active_page_id.as_deref(),
            workspace_projection
                .my_page_items
                .first()
                .map(|item| item.id.as_str()),
        );
        let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot(
            &page_tree_snapshot,
            selected_active_page_id.as_deref(),
        );
        let restore_focus_row_id = query
            .restore_focus_row_id
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty());
        let file_tree_html = render_local_file_tree_html_scoped(
            root_uri,
            selected_active_page_id.as_deref(),
            restore_focus_row_id,
            file_tree_scope,
        )?;
        (
            workspace_id,
            workspace_projection,
            sidebar_tree_html,
            file_tree_html,
            selected_active_page_id,
            Some("local_folder".to_string()),
            Some(root_uri.to_string()),
        )
    } else if should_render_local_first_landing {
        let payload = create_default_local_workspace_for_actor(&actor_id, &actor_type)
            .map_err(|error| error.with_context(&context))?;
        let root_uri = payload
            .get("workspace")
            .and_then(|workspace| workspace.get("rootUri"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| {
                WebError::internal("默认本地工作区初始化未返回 rootUri").with_context(&context)
            })?
            .to_string();
        let snapshot = load_local_folder_page_tree_snapshot(&root_uri)?;
        let workspace_id = snapshot
            .dataset
            .get("workspace")
            .and_then(|workspace| workspace.get("id"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("local-folder")
            .to_string();
        let mut workspace_dataset = snapshot.dataset.clone();
        attach_sidebar_shortcuts_to_dataset(
            &state,
            &context,
            &workspace_id,
            &mut workspace_dataset,
        );
        let workspace_projection = build_workspace_shell_projection(
            &workspace_dataset,
            &workspace_id,
            requested_page_id.as_deref(),
            &default_workspace_name,
        );
        let selected_active_page_id = choose_root_entry_active_page_id(
            requested_page_id.clone(),
            recent_page_id.clone(),
            workspace_projection.active_page_id.as_deref(),
            workspace_projection
                .my_page_items
                .first()
                .map(|item| item.id.as_str()),
        );
        let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot(
            &snapshot,
            selected_active_page_id.as_deref(),
        );
        let file_tree_html =
            render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?;
        (
            workspace_id,
            workspace_projection,
            sidebar_tree_html,
            file_tree_html,
            selected_active_page_id,
            Some("local_folder".to_string()),
            Some(root_uri),
        )
    } else {
        let workspace_id =
            resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
        let requested_or_recent_page_id = choose_root_entry_active_page_id(
            requested_page_id.clone(),
            recent_page_id.clone(),
            None,
            None,
        );
        let workspace_projection = load_workspace_shell_projection(
            Some(&state),
            state.config(),
            &context,
            &workspace_id,
            requested_or_recent_page_id.as_deref(),
            &default_workspace_name,
        )
        .await;
        let selected_active_page_id = choose_root_entry_active_page_id(
            requested_page_id.clone(),
            recent_page_id.clone(),
            workspace_projection.active_page_id.as_deref(),
            workspace_projection
                .my_page_items
                .first()
                .map(|item| item.id.as_str()),
        );
        let sidebar_tree_html = load_sidebar_tree_html(
            state.config(),
            &context,
            &workspace_id,
            selected_active_page_id.as_deref(),
        )
        .await
        .unwrap_or_default();
        let file_tree_html = load_file_tree_html(
            state.config(),
            &context,
            &workspace_id,
            selected_active_page_id.as_deref(),
            None,
        )
        .await
        .unwrap_or_default();
        (
            workspace_id,
            workspace_projection,
            sidebar_tree_html,
            file_tree_html,
            selected_active_page_id,
            None,
            None,
        )
    };
    let workspace_sidebar_html = render_workspace_shell_sidebar_html(
        &workspace_projection,
        Some(sidebar_tree_html.as_str()),
        Some(file_tree_html.as_str()),
        if requests_filetree_first {
            Some("filetree")
        } else {
            None
        },
        file_tree_scope,
    );
    let workspace_name = workspace_projection.workspace_name.clone();
    let active_page_id = selected_active_page_id.unwrap_or_default();
    let active_page_title = workspace_projection
        .active_page_title
        .clone()
        .unwrap_or_default();
    let show_admin_access_policy = is_local_access_policy_admin_context(&context);
    let render_workspace_entry = || {
        crate::ssr::render_view(leptos::view! {
            <crate::ssr::pages::home::HomePage
                sidebar_tree_html={sidebar_tree_html.clone()}
                workspace_name={workspace_name.clone()}
                workspace_id={workspace_id.clone()}
                workspace_sidebar_html={workspace_sidebar_html.clone()}
                active_page_id={active_page_id.clone()}
                active_page_title={active_page_title.clone()}
                show_admin_access_policy={show_admin_access_policy}
            />
        })
    };
    let (html_title, content, body_extra) = if active_page_id.trim().is_empty() {
        let body_extra = if active_source_kind.as_deref() == Some("local_folder") {
            let panes_bootstrap_json = serde_json::to_string(&json!({
                "schema": "mnote.document_panes_bootstrap.v1",
                "secondaryRequested": false,
                "secondaryInvalid": false,
                "panes": [],
            }))
            .unwrap_or_else(|_| "{}".to_string());
            format!(
                r#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
    {}"#,
                escape_script_json(&panes_bootstrap_json),
                render_editor_island_adapter_script(),
            )
        } else {
            String::new()
        };
        ("MNOTE".to_string(), render_workspace_entry(), body_extra)
    } else {
        match build_page_aggregate_snapshot(
            &state,
            &context,
            &active_page_id,
            Some(workspace_id.as_str()),
            active_source_kind.as_deref(),
            active_root_uri.as_deref(),
        )
        .await
        {
            Ok(aggregate) => {
                let title = aggregate.head.title.as_str();
                let page_subtree_json = serde_json::to_string(&aggregate.tree.page_subtree)
                    .unwrap_or_else(|_| "null".to_string());
                let snapshot_json =
                    serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
                let bootstrap_json = build_editor_bootstrap_json(
                    &aggregate,
                    &context,
                    active_source_kind.as_deref(),
                    active_root_uri.as_deref(),
                );
                let panes_bootstrap_json = build_document_panes_bootstrap_json(
                    &aggregate,
                    &context,
                    active_source_kind.as_deref(),
                    active_root_uri.as_deref(),
                    None,
                    None,
                    None,
                    false,
                    false,
                );
                let content = crate::ssr::render_view(leptos::view! {
                    <crate::ssr::pages::document::DocumentPage
                        title={title.to_string()}
                        document_id={active_page_id.clone()}
                        workspace_id={workspace_id.clone()}
                        sidebar_tree_html={sidebar_tree_html.clone()}
                        workspace_name={workspace_name.clone()}
                        workspace_sidebar_html={workspace_sidebar_html.clone()}
                        page_subtree_json={page_subtree_json}
                        show_admin_access_policy={show_admin_access_policy}
                        enable_tree_live={active_source_kind.as_deref() != Some("local_folder")}
                    />
                });
                let body_extra = format!(
                    r#"<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
    <script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
    <script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
    {}
    {}"#,
                    escape_script_json(&snapshot_json),
                    escape_script_json(&bootstrap_json),
                    escape_script_json(&panes_bootstrap_json),
                    render_document_title_controller_script(),
                    render_editor_island_adapter_script(),
                );
                (title.to_string(), content, body_extra)
            }
            Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()),
        }
    };
    let editor_runtime_preload_links = if body_extra.contains("document-editor-adapter-runtime.js")
    {
        render_editor_runtime_preload_links()
    } else {
        ""
    };
    let mut response = Html(format!(
        r#"<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <title>{}</title>
    {}
    <style>{}</style>
  </head>
  <body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}">
    {}
    {}
  </body>
</html>"#,
        escape_html(&html_title),
        editor_runtime_preload_links,
        crate::ssr::MNOTE_CSS,
        escape_html(context.auth.actor_id.as_str()),
        escape_html(active_source_kind.as_deref().unwrap_or("convex_workspace")),
        escape_html(active_root_uri.as_deref().unwrap_or("")),
        content,
        body_extra
    ))
    .into_response();
    stamp_gateway_headers(response.headers_mut(), false);
    Ok(response)
}

pub async fn trash_entry(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
    if !has_real_auth_context(&state, &context) {
        let mut response = Response::builder()
            .status(StatusCode::SEE_OTHER)
            .header(header::LOCATION, "/auth")
            .body(Body::empty())
            .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
        stamp_gateway_headers(response.headers_mut(), false);
        return Ok(response);
    }

    let source_kind = query
        .source_kind
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty());
    if source_kind == 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")
            })?;
        ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
            .map_err(|error| error.with_context(&context))?;
        let workspace_id =
            crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
        let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
        let file_tree_html = render_local_file_tree_html(root_uri, None, None).unwrap_or_default();
        let mut workspace_dataset = json!({
            "active_workspace_id": workspace_id,
            "workspaces": [{ "id": workspace_id, "name": "我的空间" }],
            "documents": [],
        });
        attach_sidebar_shortcuts_to_dataset(
            &state,
            &context,
            &workspace_id,
            &mut workspace_dataset,
        );
        let workspace_projection =
            build_workspace_shell_projection(&workspace_dataset, &workspace_id, None, "我的空间");
        let workspace_sidebar_html = render_workspace_shell_sidebar_html(
            &workspace_projection,
            Some(sidebar_tree_html.as_str()),
            Some(file_tree_html.as_str()),
            None,
            None,
        );
        let trash_workbench_html = render_local_trash_workbench_html(
            &workspace_id,
            root_uri,
            &load_local_trash_entries(root_uri)?,
        );
        let content = crate::ssr::render_view(leptos::view! {
            <crate::ssr::pages::layout::PageLayout
                current_nav="trash"
                sidebar_tree_html={sidebar_tree_html.clone()}
                workspace_name={"我的空间".to_string()}
                workspace_sidebar_html={workspace_sidebar_html.clone()}
                topbar_title={"垃圾箱".to_string()}
                enable_tree_live={false}
            >
                <div inner_html={trash_workbench_html}></div>
            </crate::ssr::pages::layout::PageLayout>
        });

        let mut response = Html(format!(
            r#"<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <title>本地文件夹垃圾箱</title>
    <style>{}</style>
  </head>
  <body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-source-kind="local_folder" data-mnote-root-uri="{}">
    {}
  </body>
</html>"#,
            crate::ssr::MNOTE_CSS,
            escape_html(root_uri),
            content,
        ))
        .into_response();
        stamp_gateway_headers(response.headers_mut(), false);
        context.apply_response_headers(response.headers_mut());
        return Ok(response);
    }

    let default_workspace_name = default_workspace_name_for_context(&state, &context);
    let workspace_id =
        resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
    let workspace_projection = load_workspace_shell_projection(
        Some(&state),
        state.config(),
        &context,
        &workspace_id,
        None,
        &default_workspace_name,
    )
    .await;
    let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, &workspace_id, None)
        .await
        .unwrap_or_default();
    let file_tree_html = load_file_tree_html(state.config(), &context, &workspace_id, None, None)
        .await
        .unwrap_or_default();
    let workspace_sidebar_html = render_workspace_shell_sidebar_html(
        &workspace_projection,
        Some(sidebar_tree_html.as_str()),
        Some(file_tree_html.as_str()),
        None,
        None,
    );
    let dataset = load_sidebar_dataset(state.config(), &context, &workspace_id)
        .await
        .unwrap_or_else(|_| {
            json!({
                "active_workspace_id": workspace_id,
                "workspaces": [{ "id": workspace_id, "name": workspace_projection.workspace_name }],
                "documents": [],
                "trashed_documents": [],
                "trashed_media_assets": [],
                "trashed_mindmap_assets": [],
                "trashed_table_assets": [],
                "degraded": true
            })
        });
    let trash_workbench_html = render_trash_workbench_html(&workspace_id, &dataset);
    let workspace_name = workspace_projection.workspace_name.clone();
    let content = crate::ssr::render_view(leptos::view! {
        <crate::ssr::pages::layout::PageLayout
            current_nav="trash"
            sidebar_tree_html={sidebar_tree_html.clone()}
            workspace_name={workspace_name.clone()}
            workspace_sidebar_html={workspace_sidebar_html.clone()}
            topbar_title={"垃圾箱".to_string()}
        >
            <div inner_html={trash_workbench_html}></div>
        </crate::ssr::pages::layout::PageLayout>
    });

    let mut response = Html(format!(
        r#"<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <title>垃圾箱</title>
    <style>{}</style>
  </head>
  <body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
    {}
  </body>
</html>"#,
        crate::ssr::MNOTE_CSS,
        content,
    ))
    .into_response();
    stamp_gateway_headers(response.headers_mut(), false);
    context.apply_response_headers(response.headers_mut());
    Ok(response)
}

fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
    let documents = json_array(dataset, "trashed_documents");
    let media_assets = json_array(dataset, "trashed_media_assets");
    let mindmap_assets = json_array(dataset, "trashed_mindmap_assets");
    let table_assets = json_array(dataset, "trashed_table_assets");
    let resource_count = media_assets.len() + mindmap_assets.len() + table_assets.len();
    let document_rows = render_trashed_document_rows(workspace_id, documents);
    let mut resource_rows = String::new();
    resource_rows.push_str(&render_trashed_resource_rows("media", "附件", media_assets));
    resource_rows.push_str(&render_trashed_resource_rows(
        "mindmap",
        "思维导图",
        mindmap_assets,
    ));
    resource_rows.push_str(&render_trashed_resource_rows("table", "表格", table_assets));
    let resource_body = if resource_count == 0 {
        r#"<div class="mnote-trash-empty">暂无已删除资源</div>"#.to_string()
    } else {
        resource_rows
    };

    format!(
        r#"<section class="mnote-trash-workbench" data-testid="mnote-trash-workbench" data-workspace-id="{workspace_id}" data-trash-fetch-path="/trash">
  <header class="mnote-trash-header">
    <h1>垃圾箱</h1>
    <p>默认删除的页面会先进入这里；彻底删除会永久移除。</p>
    <p class="mnote-trash-status" data-trash-status role="status" aria-live="polite"></p>
  </header>
  <section class="mnote-trash-section" data-testid="mnote-trash-documents">
    <div class="mnote-trash-section-title">
      <h2>页面 <span data-trash-document-count>{document_count}</span></h2>
      <button type="button" data-trash-action="empty-documents"{empty_disabled}>清空页面垃圾箱</button>
    </div>
    {document_rows}
  </section>
  <section class="mnote-trash-section" data-testid="mnote-trash-resources">
    <div class="mnote-trash-section-title">
      <h2>资源 <span data-trash-resource-count>{resource_count}</span></h2>
      <button type="button" data-trash-action="empty-resources"{resource_empty_disabled}>清空资源垃圾箱</button>
    </div>
    {resource_body}
    <p class="mnote-trash-note">资源恢复、彻底删除和清空当前走 Rust 兼容入口；正式 tree.resource.* 命令仍在后续阶段收口。</p>
  </section>
</section>
<script>
(function() {{
  var root = document.querySelector('[data-testid="mnote-trash-workbench"]');
  if (!root) return;
  var workspaceId = root.getAttribute('data-workspace-id') || '';
  function setStatus(message, failed) {{
    var status = root.querySelector('[data-trash-status]');
    if (!status) return;
    status.textContent = message || '';
    status.setAttribute('data-type', failed ? 'error' : 'success');
  }}
  function refreshTrashWorkbenchFromServer(reason) {{
    if (!workspaceId) return Promise.resolve(false);
    var url = new URL(root.getAttribute('data-trash-fetch-path') || '/trash', window.location.origin);
    url.searchParams.set('workspaceId', workspaceId);
    url.searchParams.set('liveRefreshReason', reason || 'tree-event');
    return fetch(url.toString(), {{
      method: 'GET',
      headers: {{ 'x-mnote-trash-live-refresh': '1' }}
    }}).then(function(response) {{
      return response.text().then(function(html) {{
        if (!response.ok) throw new Error('trash_live_refresh_failed_' + response.status);
        var parsed = new DOMParser().parseFromString(html, 'text/html');
        var nextRoot = parsed.querySelector('[data-testid="mnote-trash-workbench"]');
        if (!nextRoot) throw new Error('trash_live_refresh_missing_workbench');
        root.innerHTML = nextRoot.innerHTML;
        root.setAttribute('data-live-refresh-reason', reason || 'tree-event');
        root.setAttribute('data-live-refresh-at', String(Date.now()));
        return true;
      }});
    }}).catch(function(error) {{
      setStatus(error && error.message ? error.message : String(error), true);
      return false;
    }});
  }}
  function startTrashWorkbenchLiveRefresh() {{
    if (!workspaceId || !('EventSource' in window)) return;
    var url = new URL('/api/tree/events', window.location.origin);
    url.searchParams.set('workspaceId', workspaceId);
    var source = new EventSource(url.toString());
    root.__mnoteTrashEventSource = source;
    ['snapshot', 'delta', 'resync'].forEach(function(kind) {{
      source.addEventListener(kind, function() {{
        refreshTrashWorkbenchFromServer(kind);
      }});
    }});
    source.onerror = function() {{
      root.setAttribute('data-live-refresh-error', 'eventsource_error');
    }};
  }}
  function readJson(response) {{
    return response.json().catch(function() {{ return null; }}).then(function(payload) {{
      if (!response.ok) throw new Error((payload && payload.message) || 'trash_request_failed_' + response.status);
      return payload;
    }});
  }}
  function decrement(selector) {{
    var count = root.querySelector(selector);
    if (!count) return;
    var nextCount = Math.max(0, Number(count.textContent || '0') - 1);
    count.textContent = String(nextCount);
  }}
  function postJson(url, body) {{
    return fetch(url, {{
      method: 'POST',
      headers: {{ 'content-type': 'application/json' }},
      body: JSON.stringify(body || {{}})
    }}).then(readJson);
  }}
  function patchJson(url, body) {{
    return fetch(url, {{
      method: 'PATCH',
      headers: {{ 'content-type': 'application/json' }},
      body: JSON.stringify(body || {{}})
    }}).then(readJson);
  }}
  function runResourceAction(kind, action, resourceId, documentId) {{
    if (kind === 'media') {{
      return action === 'restore'
        ? postJson('/api/media/batch', {{ action: 'restore', assetIds: [resourceId] }})
        : postJson('/api/media/purge', {{ assetId: resourceId }});
    }}
    if (kind === 'mindmap') {{
      if (!documentId) return Promise.reject(new Error('mindmap_document_id_required'));
      return patchJson('/api/mindmap/' + encodeURIComponent(documentId) + '/' + encodeURIComponent(resourceId), {{ action: action }});
    }}
    if (kind === 'table') {{
      return postJson(action === 'restore' ? '/api/tables/restore' : '/api/tables/purge', {{ tableId: resourceId }});
    }}
    return Promise.reject(new Error('resource_kind_unsupported'));
  }}
  root.addEventListener('click', function(event) {{
    var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
    if (!button || button.disabled) return;
    var action = button.getAttribute('data-trash-action');
    var documentId = button.getAttribute('data-document-id') || '';
    if (action === 'empty-documents') {{
      if (!window.confirm('清空页面垃圾箱后无法恢复，确定继续吗？')) return;
      button.disabled = true;
      fetch('/api/documents/empty-trash', {{
        method: 'POST',
        headers: {{ 'content-type': 'application/json' }},
        body: JSON.stringify({{ workspaceId: workspaceId }})
      }}).then(function(response) {{
        return response.json().catch(function() {{ return null; }}).then(function(payload) {{
          if (!response.ok) throw new Error((payload && payload.message) || 'trash_empty_failed_' + response.status);
          root.querySelectorAll('[data-trash-row="document"]').forEach(function(row) {{ row.remove(); }});
          var count = root.querySelector('[data-trash-document-count]');
          if (count) count.textContent = '0';
          setStatus('已清空页面垃圾箱', false);
        }});
      }}).catch(function(error) {{
        button.disabled = false;
        setStatus(error && error.message ? error.message : String(error), true);
      }});
      return;
    }}
    if (action === 'empty-resources') {{
      if (!window.confirm('清空资源垃圾箱后无法恢复，确定继续吗？')) return;
      button.disabled = true;
      Promise.all([
        postJson('/api/media/empty-trash', {{ workspaceId: workspaceId }}),
        postJson('/api/mindmap-trash/empty', {{ workspaceId: workspaceId }}),
        postJson('/api/tables/empty-trash', {{ workspaceId: workspaceId }})
      ]).then(function() {{
        root.querySelectorAll('[data-trash-row="resource"]').forEach(function(row) {{ row.remove(); }});
        var count = root.querySelector('[data-trash-resource-count]');
        if (count) count.textContent = '0';
        setStatus('已清空资源垃圾箱', false);
      }}).catch(function(error) {{
        button.disabled = false;
        setStatus(error && error.message ? error.message : String(error), true);
      }});
      return;
    }}
    if (action === 'resource-restore' || action === 'resource-purge') {{
      var resourceId = button.getAttribute('data-resource-id') || '';
      var kind = button.getAttribute('data-resource-kind') || '';
      var resourceDocumentId = button.getAttribute('data-document-id') || '';
      if (!resourceId) return;
      if (action === 'resource-purge' && !window.confirm('彻底删除资源后无法恢复，确定继续吗？')) return;
      button.disabled = true;
      runResourceAction(kind, action === 'resource-restore' ? 'restore' : 'purge', resourceId, resourceDocumentId).then(function() {{
        var row = button.closest('[data-trash-row]');
        if (row) row.remove();
        decrement('[data-trash-resource-count]');
        setStatus(action === 'resource-restore' ? '已恢复资源' : '已彻底删除资源', false);
      }}).catch(function(error) {{
        button.disabled = false;
        setStatus(error && error.message ? error.message : String(error), true);
      }});
      return;
    }}
    if (!documentId) return;
    if (action === 'purge' && !window.confirm('彻底删除后无法恢复，确定继续吗？')) return;
    button.disabled = true;
    fetch('/api/tree/commands', {{
      method: 'POST',
      headers: {{ 'content-type': 'application/json' }},
      body: JSON.stringify({{
        action: action,
        workspaceId: workspaceId,
        documentId: documentId
      }})
    }}).then(function(response) {{
      return response.json().catch(function() {{ return null; }}).then(function(payload) {{
        if (!response.ok) throw new Error((payload && payload.message) || 'trash_action_failed_' + response.status);
        var row = button.closest('[data-trash-row]');
        if (row) row.remove();
        decrement('[data-trash-document-count]');
        setStatus(action === 'restore' ? '已恢复页面' : '已彻底删除页面', false);
      }});
    }}).catch(function(error) {{
      button.disabled = false;
      setStatus(error && error.message ? error.message : String(error), true);
    }});
  }});
  startTrashWorkbenchLiveRefresh();
}})();
</script>"#,
        workspace_id = escape_html(workspace_id),
        document_count = documents.len(),
        empty_disabled = if documents.is_empty() {
            " disabled"
        } else {
            ""
        },
        resource_empty_disabled = if resource_count == 0 { " disabled" } else { "" },
        resource_count = resource_count,
        document_rows = document_rows,
        resource_body = resource_body,
    )
}

fn render_local_trash_workbench_html(
    workspace_id: &str,
    root_uri: &str,
    entries: &BTreeMap<String, crate::routes::local_folder_source::LocalTrashEntry>,
) -> String {
    let mut document_rows = Vec::new();
    let mut resource_rows = Vec::new();
    for (entry_id, entry) in entries {
        let deleted_at = entry.deleted_at_ms.to_string();
        let original = escape_html(&entry.original_relative_path);
        let trash_path = escape_html(&entry.trash_relative_path);
        let kind = escape_html(&entry.resource_kind);
        let filetree_row_id =
            if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" {
                format!("doc:{}", entry.document_id)
            } else {
                format!("local:asset:{}", entry.original_relative_path)
            };
        let row = format!(
            r#"<article class="mnote-trash-row" data-trash-row="local" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-filetree-row-id="{filetree_row_id}">
  <div class="mnote-trash-row-main">
    <span class="mnote-trash-kind">{kind}</span>
    <span class="mnote-trash-title">{original}</span>
    <span class="mnote-trash-meta">回收站：{trash_path} · {deleted_at}</span>
  </div>
  <div class="mnote-trash-actions">
    <button type="button" data-trash-action="local-restore" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-document-id="{command_id}">恢复</button>
    <button type="button" data-trash-action="local-purge" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-document-id="{command_id}">彻底删除</button>
  </div>
</article>"#,
            entry_id = escape_html(entry_id),
            kind = kind,
            filetree_row_id = escape_html(&filetree_row_id),
            original = original,
            trash_path = trash_path,
            deleted_at = escape_html(&deleted_at),
            command_id = escape_html(local_trash_command_id(entry_id, entry).as_str()),
        );
        if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" {
            document_rows.push(row);
        } else {
            resource_rows.push(row);
        }
    }
    let document_body = if document_rows.is_empty() {
        r#"<div class="mnote-trash-empty">暂无已删除页面</div>"#.to_string()
    } else {
        document_rows.join("")
    };
    let resource_body = if resource_rows.is_empty() {
        r#"<div class="mnote-trash-empty">暂无已删除资源</div>"#.to_string()
    } else {
        resource_rows.join("")
    };
    format!(
        r#"<section class="mnote-trash-workbench" data-testid="mnote-trash-workbench" data-trash-source-kind="local_folder" data-workspace-id="{workspace_id}" data-root-uri="{root_uri}" data-trash-fetch-path="/trash">
  <header class="mnote-trash-header">
    <h1>本地文件夹垃圾箱</h1>
    <p>本地删除项保存在当前目录的 <code>.mnote/trash</code> 与 <code>.mnote/trash-index.json</code> 中。</p>
    <p class="mnote-trash-status" data-trash-status role="status" aria-live="polite"></p>
  </header>
  <section class="mnote-trash-section" data-testid="mnote-trash-documents">
    <div class="mnote-trash-section-title">
      <h2>页面 <span data-trash-document-count>{document_count}</span></h2>
      <button type="button" data-trash-action="local-empty-documents"{document_empty_disabled}>清空页面垃圾箱</button>
    </div>
    {document_body}
  </section>
  <section class="mnote-trash-section" data-testid="mnote-trash-resources">
    <div class="mnote-trash-section-title">
      <h2>资源 <span data-trash-resource-count>{resource_count}</span></h2>
      <button type="button" data-trash-action="local-empty-resources"{resource_empty_disabled}>清空资源垃圾箱</button>
    </div>
    {resource_body}
  </section>
</section>
<script>
(function() {{
  var root = document.querySelector('[data-testid="mnote-trash-workbench"]');
  if (!root) return;
  var workspaceId = root.getAttribute('data-workspace-id') || '';
  var rootUri = root.getAttribute('data-root-uri') || '';
  function setStatus(message, failed) {{
    var status = root.querySelector('[data-trash-status]');
    if (!status) return;
    status.textContent = message || '';
    status.setAttribute('data-type', failed ? 'error' : 'success');
  }}
  function readJson(response) {{
    return response.json().catch(function() {{ return null; }}).then(function(payload) {{
      if (!response.ok) throw new Error((payload && payload.message) || 'trash_request_failed_' + response.status);
      return payload;
    }});
  }}
  function postJson(url, body) {{
    return fetch(url, {{
      method: 'POST',
      headers: {{ 'content-type': 'application/json' }},
      body: JSON.stringify(body || {{}})
    }}).then(readJson);
  }}
  function refresh() {{
    var url = new URL('/trash', window.location.origin);
    url.searchParams.set('sourceKind', 'local_folder');
    url.searchParams.set('rootUri', rootUri);
    return fetch(url.toString(), {{ headers: {{ 'x-mnote-trash-live-refresh': '1' }} }})
      .then(function(response) {{ return response.text().then(function(html) {{ return {{ response: response, html: html }}; }}); }})
      .then(function(result) {{
        if (!result.response.ok) throw new Error('trash_live_refresh_failed_' + result.response.status);
        var parsed = new DOMParser().parseFromString(result.html, 'text/html');
        var nextRoot = parsed.querySelector('[data-testid="mnote-trash-workbench"]');
        if (!nextRoot) throw new Error('trash_live_refresh_missing_workbench');
        root.innerHTML = nextRoot.innerHTML;
        return true;
      }})
      .catch(function(error) {{
        setStatus(error && error.message ? error.message : String(error), true);
        return false;
      }});
  }}
  root.addEventListener('click', function(event) {{
    var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
    if (!button || button.disabled) return;
    var action = button.getAttribute('data-trash-action');
    var entryId = button.getAttribute('data-trash-entry-id') || '';
    var documentId = button.getAttribute('data-document-id') || entryId;
    var kind = button.getAttribute('data-resource-kind') || '';
    if (action === 'local-restore' || action === 'local-purge') {{
      if (action === 'local-purge' && !window.confirm('彻底删除后无法恢复，确定继续吗？')) return;
      button.disabled = true;
      postJson('/api/tree/commands', {{
        action: action === 'local-restore' ? 'restore' : 'purge',
        sourceKind: 'local_folder',
        rootUri: rootUri,
        workspaceId: workspaceId,
        documentId: entryId
      }}).then(function() {{
        if (action === 'local-restore') {{
          var row = button.closest('[data-trash-row="local"]');
          var filetreeRowId = row ? (row.getAttribute('data-filetree-row-id') || '') : '';
          if (filetreeRowId && window.localStorage) {{
            window.localStorage.setItem('mnote.pendingLocalFolderRestoreFiletreeRowId', filetreeRowId);
          }}
          if (filetreeRowId) {{
            window.name = 'mnote.pendingLocalFolderRestoreFiletreeRowId=' + encodeURIComponent(filetreeRowId);
          }}
        }}
        return refresh();
      }}).then(function() {{
        setStatus(action === 'local-restore' ? '已恢复项目' : '已彻底删除项目', false);
      }}).catch(function(error) {{
        button.disabled = false;
        setStatus(error && error.message ? error.message : String(error), true);
      }});
    }}
    if (action === 'local-empty-documents' || action === 'local-empty-resources') {{
      var selector = action === 'local-empty-documents'
        ? '[data-trash-row="local"][data-resource-kind="markdown"], [data-trash-row="local"][data-resource-kind="markdown_bundle"]'
        : '[data-trash-row="local"]:not([data-resource-kind="markdown"]):not([data-resource-kind="markdown_bundle"])';
      var rows = Array.prototype.slice.call(root.querySelectorAll(selector));
      if (rows.length === 0) return;
      if (!window.confirm('清空后无法恢复，确定继续吗？')) return;
      button.disabled = true;
      rows.reduce(function(chain, row) {{
        return chain.then(function() {{
          var entryId = row.getAttribute('data-trash-entry-id') || '';
          return postJson('/api/tree/commands', {{
            action: 'purge',
            sourceKind: 'local_folder',
            rootUri: rootUri,
            workspaceId: workspaceId,
            documentId: entryId
          }});
        }});
      }}, Promise.resolve()).then(function() {{
        return refresh();
      }}).then(function() {{
        setStatus(action === 'local-empty-documents' ? '已清空页面垃圾箱' : '已清空资源垃圾箱', false);
      }}).catch(function(error) {{
        button.disabled = false;
        setStatus(error && error.message ? error.message : String(error), true);
      }});
    }}
  }});
}})();
</script>"#,
        workspace_id = escape_html(workspace_id),
        root_uri = escape_html(root_uri),
        document_count = document_rows.len(),
        resource_count = resource_rows.len(),
        document_empty_disabled = if document_rows.is_empty() {
            " disabled"
        } else {
            ""
        },
        resource_empty_disabled = if resource_rows.is_empty() {
            " disabled"
        } else {
            ""
        },
        document_body = document_body,
        resource_body = resource_body,
    )
}

fn local_trash_command_id(
    entry_id: &str,
    entry: &crate::routes::local_folder_source::LocalTrashEntry,
) -> String {
    if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" {
        return entry.document_id.clone();
    }
    entry_id.to_string()
}

fn json_array<'a>(dataset: &'a Value, key: &str) -> &'a [Value] {
    dataset
        .get(key)
        .and_then(Value::as_array)
        .map(Vec::as_slice)
        .unwrap_or(&[])
}

fn render_trashed_document_rows(workspace_id: &str, documents: &[Value]) -> String {
    if documents.is_empty() {
        return r#"<div class="mnote-trash-empty">暂无已删除页面</div>"#.to_string();
    }

    documents
        .iter()
        .filter_map(|document| {
            let id = document.get("id").and_then(Value::as_str)?;
            let title = document
                .get("title")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .unwrap_or("无标题");
            let deleted_at = document
                .get("deleted_at")
                .or_else(|| document.get("deletedAt"))
                .and_then(Value::as_str)
                .unwrap_or("");
            Some(format!(
                r#"<article class="mnote-trash-row" data-trash-row="document" data-document-id="{id}">
  <div class="mnote-trash-row-main">
    <a href="/documents/{id}?workspaceId={workspace_id}" class="mnote-trash-title">{title}</a>
    <span class="mnote-trash-meta">{deleted_at}</span>
  </div>
  <div class="mnote-trash-actions">
    <button type="button" data-trash-action="restore" data-document-id="{id}">恢复</button>
    <button type="button" data-trash-action="purge" data-document-id="{id}">彻底删除</button>
  </div>
</article>"#,
                id = escape_html(id),
                workspace_id = escape_html(workspace_id),
                title = escape_html(title),
                deleted_at = escape_html(deleted_at),
            ))
        })
        .collect::<Vec<_>>()
        .join("")
}

fn render_trashed_resource_rows(kind: &str, label: &str, resources: &[Value]) -> String {
    resources
        .iter()
        .filter_map(|resource| {
            let id = resource.get("id").and_then(Value::as_str)?;
            let document_id = resource
                .get("document_id")
                .or_else(|| resource.get("documentId"))
                .and_then(Value::as_str)
                .unwrap_or("");
            let title = resource
                .get("file_name")
                .or_else(|| resource.get("title"))
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .unwrap_or("未命名资源");
            let deleted_at = resource
                .get("deleted_at")
                .or_else(|| resource.get("deletedAt"))
                .and_then(Value::as_str)
                .unwrap_or("");
            Some(format!(
                r#"<article class="mnote-trash-row" data-trash-row="resource" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">
  <div class="mnote-trash-row-main">
    <span class="mnote-trash-kind">{label}</span>
    <span class="mnote-trash-title">{title}</span>
    <span class="mnote-trash-meta">{deleted_at}</span>
  </div>
  <div class="mnote-trash-actions">
    <button type="button" data-trash-action="resource-restore" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">恢复</button>
    <button type="button" data-trash-action="resource-purge" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">彻底删除</button>
  </div>
</article>"#,
                kind = escape_html(kind),
                id = escape_html(id),
                document_id = escape_html(document_id),
                label = escape_html(label),
                title = escape_html(title),
                deleted_at = escape_html(deleted_at),
            ))
        })
        .collect::<Vec<_>>()
        .join("")
}

#[allow(dead_code)]
pub async fn legacy_next_proxy(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    request: Request<Body>,
) -> Result<Response, WebError> {
    if !state.config().enable_legacy_next_compat {
        return Err(WebError::service_unavailable_code(
            "legacy_next_compat_disabled",
            "Next App Router legacy compat 已关闭，当前路径未迁到 Rust Web gateway。",
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
    }

    let Some(base_url) = state.config().legacy_next_base_url.as_deref() else {
        return Err(WebError::service_unavailable_code(
            "legacy_next_upstream_missing",
            "未配置 MNOTE_WEB_LEGACY_NEXT_BASE_URL，无法代理 legacy Next 路径。",
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
    };

    let path_and_query = request
        .uri()
        .path_and_query()
        .map(|value| value.as_str())
        .unwrap_or("/");
    let upstream_url = reqwest::Url::parse(&format!("{base_url}{path_and_query}"))
        .map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?;

    let method = request.method().clone();
    let headers = request.headers().clone();
    let body = axum::body::to_bytes(request.into_body(), 10 * 1024 * 1024)
        .await
        .map_err(|error| WebError::internal(format!("读取 legacy proxy 请求体失败: {error}")))?;
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(30))
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .map_err(|error| WebError::internal(format!("legacy Next HTTP 客户端创建失败: {error}")))?;

    let upstream_origin = upstream_origin(&upstream_url);
    let mut upstream_request = client.request(method, upstream_url);
    for (name, value) in headers.iter() {
        if is_hop_by_hop_header(name.as_str()) || name == header::HOST {
            continue;
        }
        if name == header::ORIGIN {
            upstream_request = upstream_request.header(name, upstream_origin.as_str());
            continue;
        }
        if name == header::REFERER {
            let normalized_referer = normalize_legacy_referer(value, &upstream_origin);
            upstream_request = upstream_request.header(name, normalized_referer);
            continue;
        }
        upstream_request = upstream_request.header(name, value);
    }

    let upstream_response = upstream_request
        .body(body.to_vec())
        .send()
        .await
        .map_err(|error| {
            WebError::bad_gateway_code(
                "legacy_next_proxy_error",
                format!("legacy Next 请求失败: {error}"),
            )
            .with_context(&context)
            .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
            .with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router")
        })?;

    let status = upstream_response.status();
    let upstream_headers = upstream_response.headers().clone();
    let body = upstream_response.bytes().await.map_err(|error| {
        WebError::bad_gateway_code(
            "legacy_next_proxy_body_error",
            format!("legacy Next 响应读取失败: {error}"),
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router")
    })?;

    let mut response = Response::builder()
        .status(StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY))
        .body(Body::from(body))
        .map_err(|error| WebError::internal(format!("legacy proxy 响应构造失败: {error}")))?;
    for (name, value) in upstream_headers.iter() {
        if is_hop_by_hop_header(name.as_str()) {
            continue;
        }
        response.headers_mut().append(name, value.clone());
    }
    stamp_gateway_headers(response.headers_mut(), true);
    Ok(response)
}

#[allow(dead_code)]
pub async fn legacy_next_websocket_proxy(
    ws: WebSocketUpgrade,
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    uri: Uri,
) -> Result<Response, WebError> {
    if !state.config().enable_legacy_next_compat {
        return Err(WebError::service_unavailable_code(
            "legacy_next_compat_disabled",
            "Next App Router legacy compat 已关闭，当前路径未迁到 Rust Web gateway。",
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
    }

    let Some(base_url) = state.config().legacy_next_base_url.as_deref() else {
        return Err(WebError::service_unavailable_code(
            "legacy_next_upstream_missing",
            "未配置 MNOTE_WEB_LEGACY_NEXT_BASE_URL，无法代理 legacy Next WebSocket。",
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
    };

    let upstream_url = build_legacy_next_ws_url(base_url, &uri)?;
    Ok(ws.on_upgrade(move |socket| async move {
        if let Err(error) = proxy_legacy_next_websocket(socket, upstream_url).await {
            tracing::warn!(error = %error, "legacy Next WebSocket 代理已断开");
        }
    }))
}

#[allow(dead_code)]
fn build_legacy_next_ws_url(base_url: &str, uri: &Uri) -> Result<String, WebError> {
    let upstream = reqwest::Url::parse(base_url)
        .map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?;
    let scheme = match upstream.scheme() {
        "https" => "wss",
        _ => "ws",
    };
    let host = upstream
        .host_str()
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| WebError::internal("legacy Next upstream URL 缺少 host"))?;
    let host_with_port = match upstream.port() {
        Some(port) => format!("{host}:{port}"),
        None => host.to_string(),
    };
    let path_and_query = uri
        .path_and_query()
        .map(|value| value.as_str())
        .unwrap_or("/");
    Ok(format!("{scheme}://{host_with_port}{path_and_query}"))
}

#[allow(dead_code)]
async fn proxy_legacy_next_websocket(
    socket: WebSocket,
    upstream_url: String,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let (upstream, _) = tokio_tungstenite::connect_async(upstream_url.as_str()).await?;
    let (mut client_tx, mut client_rx) = socket.split();
    let (mut upstream_tx, mut upstream_rx) = upstream.split();

    let client_to_upstream = async {
        while let Some(message) = client_rx.next().await {
            let Ok(message) = message else {
                break;
            };
            if upstream_tx
                .send(axum_ws_to_tungstenite(message))
                .await
                .is_err()
            {
                break;
            }
        }
    };

    let upstream_to_client = async {
        while let Some(message) = upstream_rx.next().await {
            let Ok(message) = message else {
                break;
            };
            if client_tx
                .send(tungstenite_to_axum_ws(message))
                .await
                .is_err()
            {
                break;
            }
        }
    };

    tokio::select! {
        _ = client_to_upstream => {}
        _ = upstream_to_client => {}
    }

    Ok(())
}

#[allow(dead_code)]
fn axum_ws_to_tungstenite(message: AxumWsMessage) -> TungsteniteMessage {
    match message {
        AxumWsMessage::Text(value) => TungsteniteMessage::Text(value.to_string().into()),
        AxumWsMessage::Binary(value) => TungsteniteMessage::Binary(value),
        AxumWsMessage::Ping(value) => TungsteniteMessage::Ping(value),
        AxumWsMessage::Pong(value) => TungsteniteMessage::Pong(value),
        AxumWsMessage::Close(_) => TungsteniteMessage::Close(None),
    }
}

#[allow(dead_code)]
fn tungstenite_to_axum_ws(message: TungsteniteMessage) -> AxumWsMessage {
    match message {
        TungsteniteMessage::Text(value) => AxumWsMessage::Text(value.to_string().into()),
        TungsteniteMessage::Binary(value) => AxumWsMessage::Binary(value),
        TungsteniteMessage::Ping(value) => AxumWsMessage::Ping(value),
        TungsteniteMessage::Pong(value) => AxumWsMessage::Pong(value),
        TungsteniteMessage::Close(_) => AxumWsMessage::Close(None),
        TungsteniteMessage::Frame(_) => AxumWsMessage::Close(None),
    }
}

async fn resolve_root_workspace_id(
    state: &AppState,
    context: &RequestContext,
    requested_workspace_id: Option<&str>,
) -> Result<String, WebError> {
    if let Some(workspace_id) = normalize_optional_id(requested_workspace_id)
        .or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref()))
    {
        return Ok(workspace_id.to_string());
    }

    let bootstrap = execute_convex_mutation_by_name(
        state.config(),
        context,
        "workspaces:ensureDefaultWorkspace",
        json!({
            "fallbackName": current_actor_display_name(state, context)
                .unwrap_or_else(|| state.config().dev_user_name.clone()),
            "workspaceIdIfCreate": format!("ws_{}", context.trace.request_id),
        }),
        None,
        None,
        "root_workspace_bootstrap",
    )
    .await?;

    bootstrap
        .get("activeWorkspaceId")
        .and_then(serde_json::Value::as_str)
        .and_then(|value| normalize_optional_id(Some(value)))
        .map(ToOwned::to_owned)
        .ok_or_else(|| {
            WebError::bad_gateway_code(
                "root_workspace_bootstrap_bad_response",
                "workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId",
            )
            .with_context(context)
            .with_header("x-error-phase", "root_workspace_bootstrap")
        })
}

pub(crate) fn default_workspace_name_for_context(
    state: &AppState,
    context: &RequestContext,
) -> String {
    let display_name = current_actor_display_name(state, context)
        .unwrap_or_else(|| state.config().dev_user_name.clone());
    format!("{} 的空间", display_name.trim())
}

pub(crate) fn current_actor_display_name(
    state: &AppState,
    context: &RequestContext,
) -> Option<String> {
    if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
        let token_hash = session_token_hash(&raw_token);
        if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
            return Some(resolved.user.display_name);
        }
    }
    extract_encoded_cookie_value(context, COOKIE_MNOTE_ACTOR_NAME).or_else(|| {
        let actor_id = context.auth.actor_id.trim();
        if actor_id.is_empty() || actor_id == "anonymous" {
            None
        } else {
            Some(actor_id.to_string())
        }
    })
}

pub(crate) fn current_actor_id(state: &AppState, context: &RequestContext) -> Option<String> {
    if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
        let token_hash = session_token_hash(&raw_token);
        if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
            return Some(resolved.user.id);
        }
    }
    let actor_id = context.auth.actor_id.trim();
    if actor_id.is_empty() || actor_id == "anonymous" {
        None
    } else {
        Some(actor_id.to_string())
    }
}

pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) -> String {
    if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
        let token_hash = session_token_hash(&raw_token);
        if matches!(
            state.control_plane().get_session_by_token_hash(&token_hash),
            Ok(Some(_))
        ) {
            return "user".to_string();
        }
    }
    context.auth.actor_type.trim().to_string()
}

fn normalize_optional_id(value: Option<&str>) -> Option<&str> {
    value.map(str::trim).filter(|value| !value.is_empty())
}

fn has_real_auth_context(state: &AppState, context: &RequestContext) -> bool {
    let actor_id = context.auth.actor_id.trim();
    if !actor_id.is_empty() && actor_id != "anonymous" {
        return true;
    }

    if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
        let token_hash = session_token_hash(&raw_token);
        if matches!(
            state.control_plane().get_session_by_token_hash(&token_hash),
            Ok(Some(_))
        ) {
            return true;
        }
    }

    extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT).is_some()
        || extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN).is_some()
}

fn handle_sqlite_auth_action(
    state: &AppState,
    context: &RequestContext,
    payload: &serde_json::Value,
) -> Result<Response, WebError> {
    let params = payload
        .pointer("/args/params")
        .and_then(Value::as_object)
        .ok_or_else(|| {
            WebError::bad_request_code("auth_bad_request", "登录请求缺少 args.params")
                .with_context(context)
                .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        })?;
    let flow = params
        .get("flow")
        .and_then(Value::as_str)
        .unwrap_or("signIn");
    let password = params
        .get("password")
        .and_then(Value::as_str)
        .map(str::to_string)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            WebError::bad_request_code("auth_password_required", "请填写密码")
                .with_context(context)
                .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        })?;
    let session_token = new_session_token();
    let token_hash = session_token_hash(&session_token);
    let resolved = if flow == "signUp" {
        let email = params
            .get("email")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| {
                WebError::bad_request_code(
                    "auth_signup_email_required",
                    "注册账号时请填写邮箱；登录时可以使用用户名。",
                )
                .with_context(context)
                .with_header("x-error-phase", "auth_signup_payload")
            })?;
        let username = params
            .get("name")
            .or_else(|| params.get("username"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or_else(|| email.split('@').next().unwrap_or("user"));
        let user = state
            .control_plane()
            .upsert_user(UpsertUserInput {
                id: Some(username.to_string()),
                email: Some(email.to_string()),
                username: username.to_string(),
                display_name: username.to_string(),
                role: None,
                password_hash: None,
            })
            .map_err(|error| sqlite_auth_error(context, error))?;
        state
            .control_plane()
            .create_password_identity(CreatePasswordIdentityInput {
                user_id: user.id.clone(),
                email: user.email.clone(),
                username: user.username.clone(),
                password: password.clone(),
            })
            .map_err(|error| sqlite_auth_error(context, error))?;
        state
            .control_plane()
            .ensure_default_workspace(&user.id)
            .map_err(|error| sqlite_auth_error(context, error))?;
        state
            .control_plane()
            .authenticate_password(AuthenticatePasswordInput {
                account: email.to_string(),
                password,
                session_id: None,
                token_hash,
                user_agent: None,
                ip_hash: None,
                expires_at: None,
            })
            .map_err(|error| sqlite_auth_error(context, error))?
    } else {
        let account = params
            .get("account")
            .or_else(|| params.get("email"))
            .or_else(|| params.get("name"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| {
                WebError::bad_request_code("auth_account_required", "请填写邮箱或用户名")
                    .with_context(context)
                    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
            })?;
        state
            .control_plane()
            .authenticate_password(AuthenticatePasswordInput {
                account: account.to_string(),
                password,
                session_id: None,
                token_hash,
                user_agent: None,
                ip_hash: None,
                expires_at: None,
            })
            .map_err(|error| sqlite_auth_error(context, error))?
    };

    let audit_action = if flow == "signUp" {
        "control.auth.signed_up"
    } else {
        "control.auth.signed_in"
    };
    let _ = state.control_plane().append_audit(AppendAuditInput {
        actor_user_id: Some(resolved.user.id.clone()),
        action: audit_action.to_string(),
        target_kind: "auth_session".to_string(),
        target_id: Some(resolved.session.id.clone()),
        metadata_json: serde_json::to_string(&json!({
            "email": resolved.user.email,
            "name": resolved.user.display_name,
            "flow": flow,
        }))
        .unwrap_or_else(|_| "{}".to_string()),
    });

    Ok(build_sqlite_auth_response(
        context,
        &session_token,
        &resolved.user.id,
        resolved.user.email.as_deref().unwrap_or_default(),
        &resolved.user.display_name,
        &effective_sqlite_auth_actor_type(&resolved.user.id, &resolved.user.role),
    ))
}

fn sqlite_auth_error(
    context: &RequestContext,
    error: control_plane::ControlPlaneError,
) -> WebError {
    match error {
        control_plane::ControlPlaneError::Unauthorized(message) => {
            WebError::bad_request_code("auth_invalid_credentials", message)
        }
        control_plane::ControlPlaneError::Conflict(message) => {
            WebError::bad_request_code("auth_conflict", message)
        }
        control_plane::ControlPlaneError::InvalidInput(message) => {
            WebError::bad_request_code("auth_bad_request", message)
        }
        other => WebError::internal(format!("SQLite Auth 失败: {other}")),
    }
    .with_context(context)
    .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
}

fn build_sqlite_auth_response(
    context: &RequestContext,
    session_token: &str,
    user_id: &str,
    email: &str,
    name: &str,
    actor_type: &str,
) -> Response {
    let mut response = axum::Json(json!({
        "ok": true,
        "userId": user_id,
        "email": email,
        "name": name,
        "authMode": "sqliteSession",
        "actorType": actor_type
    }))
    .into_response();
    set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_SESSION, session_token);
    set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, user_id);
    set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, actor_type);
    if !email.trim().is_empty() {
        set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_EMAIL, email);
    }
    if !name.trim().is_empty() {
        set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_NAME, name);
    }
    expire_cookie(response.headers_mut(), COOKIE_CONVEX_AUTH_JWT);
    expire_cookie(response.headers_mut(), COOKIE_CONVEX_AUTH_REFRESH_TOKEN);
    expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION);
    stamp_gateway_headers(response.headers_mut(), false);
    apply_trace_response_headers(context, response.headers_mut());
    response
}

fn effective_sqlite_auth_actor_type(user_id: &str, stored_role: &str) -> String {
    let role = stored_role.trim();
    let fallback_role = if role.is_empty() { "user" } else { role };
    if crate::routes::local_folder_source::is_local_access_policy_admin_actor(
        user_id,
        fallback_role,
    ) {
        "admin".to_string()
    } else {
        fallback_role.to_string()
    }
}

fn build_sqlite_sign_out_response(state: &AppState, context: &RequestContext) -> Response {
    if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
        let token_hash = session_token_hash(&raw_token);
        if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
            let _ = state.control_plane().revoke_session(&resolved.session.id);
            let _ = state.control_plane().append_audit(AppendAuditInput {
                actor_user_id: Some(resolved.user.id.clone()),
                action: "control.auth.signed_out".to_string(),
                target_kind: "auth_session".to_string(),
                target_id: Some(resolved.session.id.clone()),
                metadata_json: serde_json::to_string(&json!({
                    "email": resolved.user.email,
                    "name": resolved.user.display_name,
                }))
                .unwrap_or_else(|_| "{}".to_string()),
            });
        }
    }
    build_sign_out_response(context)
}

fn new_session_token() -> String {
    uuid::Uuid::new_v4().simple().to_string()
}

fn build_sign_out_response(context: &RequestContext) -> Response {
    let mut response = axum::Json(json!({ "ok": true, "signedOut": true })).into_response();
    clear_auth_cookies(response.headers_mut());
    stamp_gateway_headers(response.headers_mut(), false);
    apply_trace_response_headers(context, response.headers_mut());
    response
}

fn apply_trace_response_headers(context: &RequestContext, headers: &mut axum::http::HeaderMap) {
    insert_response_header(headers, "x-request-id", &context.trace.request_id);
    insert_response_header(headers, "x-trace-id", &context.trace.trace_id);
    if let Some(workspace_id) = &context.workspace.workspace_id {
        insert_response_header(headers, "x-mnote-workspace-id", workspace_id);
    }
}

fn insert_response_header(headers: &mut axum::http::HeaderMap, name: &str, value: &str) {
    let Ok(name) = HeaderName::from_lowercase(name.as_bytes()) else {
        return;
    };
    let Ok(value) = HeaderValue::from_str(value) else {
        return;
    };
    headers.insert(name, value);
}

fn set_literal_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) {
    let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
    if let Ok(value) = HeaderValue::from_str(&cookie) {
        headers.append(header::SET_COOKIE, value);
    }
}

fn set_encoded_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) {
    let encoded = URL_SAFE_NO_PAD.encode(value.as_bytes());
    set_literal_cookie(headers, name, &encoded);
}

fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) {
    expire_cookie(headers, COOKIE_MNOTE_SESSION);
    expire_cookie(headers, COOKIE_CONVEX_AUTH_JWT);
    expire_cookie(headers, COOKIE_CONVEX_AUTH_REFRESH_TOKEN);
    expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION);
    expire_cookie(headers, COOKIE_MNOTE_ACTOR_ID);
    expire_cookie(headers, COOKIE_MNOTE_ACTOR_TYPE);
    expire_cookie(headers, COOKIE_MNOTE_ACTOR_EMAIL);
    expire_cookie(headers, COOKIE_MNOTE_ACTOR_NAME);
}

fn expire_cookie(headers: &mut axum::http::HeaderMap, name: &'static str) {
    let cookie = format!("{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
    if let Ok(value) = HeaderValue::from_str(&cookie) {
        headers.append(header::SET_COOKIE, value);
    }
}

fn choose_root_entry_active_page_id(
    requested_page_id: Option<&str>,
    recent_page_id: Option<&str>,
    projection_active_page_id: Option<&str>,
    first_page_id: Option<&str>,
) -> Option<String> {
    normalize_optional_id(requested_page_id)
        .or_else(|| normalize_optional_id(recent_page_id))
        .or_else(|| normalize_optional_id(projection_active_page_id))
        .or_else(|| normalize_optional_id(first_page_id))
        .map(ToOwned::to_owned)
}

fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
    context
        .auth
        .cookie_header
        .as_deref()?
        .split(';')
        .find_map(|part| {
            let (cookie_name, cookie_value) = part.trim().split_once('=')?;
            if cookie_name.trim() == name {
                let value = cookie_value.trim();
                if value.is_empty() {
                    None
                } else {
                    Some(value.to_string())
                }
            } else {
                None
            }
        })
}

fn extract_encoded_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
    let encoded = extract_cookie_value(context, name)?;
    let decoded = URL_SAFE_NO_PAD.decode(encoded.as_bytes()).ok()?;
    String::from_utf8(decoded)
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

fn stamp_gateway_headers(headers: &mut axum::http::HeaderMap, legacy: bool) {
    if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("mnote-web"));
    }
    if legacy {
        if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_LEGACY_UPSTREAM.as_bytes()) {
            headers.insert(name, HeaderValue::from_static("next-app-router"));
        }
    }
}

fn is_hop_by_hop_header(name: &str) -> bool {
    matches!(
        name.to_ascii_lowercase().as_str(),
        "connection"
            | "keep-alive"
            | "proxy-authenticate"
            | "proxy-authorization"
            | "te"
            | "trailers"
            | "transfer-encoding"
            | "upgrade"
    )
}

fn upstream_origin(url: &reqwest::Url) -> String {
    let host = url.host_str().unwrap_or("127.0.0.1");
    match url.port() {
        Some(port) => format!("{}://{}:{}", url.scheme(), host, port),
        None => format!("{}://{}", url.scheme(), host),
    }
}

fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> String {
    let referer = value.to_str().unwrap_or_default();
    let Ok(parsed) = reqwest::Url::parse(referer) else {
        return upstream_origin.to_string();
    };
    let path = parsed.path();
    let query = parsed
        .query()
        .map(|query| format!("?{query}"))
        .unwrap_or_default();
    format!("{upstream_origin}{path}{query}")
}

#[cfg(test)]
mod tests {
    use crate::app::{build_app, AppConfig, AppState};
    use axum::body::{to_bytes, Body};
    use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode};
    use axum::response::{Html, IntoResponse};
    use axum::routing::{get, post};
    use tokio::net::TcpListener;
    use tower::util::ServiceExt;

    fn temp_root(name: &str) -> std::path::PathBuf {
        let stamp = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos();
        let path = std::env::temp_dir().join(format!("{name}-{stamp}"));
        std::fs::create_dir_all(&path).expect("temp root");
        path
    }

    fn app() -> axum::Router {
        app_with_legacy_next_base_url("http://127.0.0.1:3100".into())
    }

    fn app_with_legacy_next_base_url(legacy_next_base_url: String) -> axum::Router {
        app_with_config(legacy_next_base_url, true)
    }

    fn app_with_config(
        legacy_next_base_url: String,
        enable_legacy_next_compat: bool,
    ) -> axum::Router {
        app_with_config_and_convex_url(legacy_next_base_url, enable_legacy_next_compat, None)
    }

    fn app_with_config_and_convex_url(
        legacy_next_base_url: String,
        enable_legacy_next_compat: bool,
        convex_url: Option<String>,
    ) -> axum::Router {
        app_with_query_fixtures(
            legacy_next_base_url,
            enable_legacy_next_compat,
            convex_url,
            None,
        )
    }

    fn app_with_query_fixtures(
        legacy_next_base_url: String,
        enable_legacy_next_compat: bool,
        convex_url: Option<String>,
        query_fixtures_json: Option<String>,
    ) -> 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(legacy_next_base_url),
            enable_legacy_next_compat,
            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,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json,
            mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()),
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
    }

    async fn spawn_legacy_auth_upstream() -> String {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("legacy listener");
        let addr = listener.local_addr().expect("legacy addr");
        let app = axum::Router::new().route(
            "/auth",
            get(|| async {
                Html(r#"<html><body><button>测试账号快速登录</button></body></html>"#)
            })
            .post(|| async { "auth-post-ok" }),
        );
        tokio::spawn(async move {
            axum::serve(listener, app).await.expect("legacy server");
        });
        format!("http://{addr}")
    }

    async fn spawn_legacy_origin_checked_upstream() -> String {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("legacy listener");
        let addr = listener.local_addr().expect("legacy addr");
        let expected_origin = format!("http://{addr}");
        let app = axum::Router::new().route(
            "/api/auth",
            post(move |headers: HeaderMap| {
                let expected_origin = expected_origin.clone();
                async move {
                    let origin = headers
                        .get("origin")
                        .and_then(|value| value.to_str().ok())
                        .unwrap_or_default();
                    if origin != expected_origin {
                        return (StatusCode::FORBIDDEN, "Invalid origin");
                    }
                    (StatusCode::OK, "ok")
                }
            }),
        );
        tokio::spawn(async move {
            axum::serve(listener, app).await.expect("legacy server");
        });
        format!("http://{addr}")
    }

    async fn spawn_legacy_cookie_upstream() -> String {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("legacy listener");
        let addr = listener.local_addr().expect("legacy addr");
        let app = axum::Router::new().route(
            "/api/auth",
            post(|| async {
                let mut response = "ok".into_response();
                response.headers_mut().append(
                    header::SET_COOKIE,
                    HeaderValue::from_static("__convexAuthJWT=jwt-demo; Path=/; HttpOnly"),
                );
                response.headers_mut().append(
                    header::SET_COOKIE,
                    HeaderValue::from_static(
                        "__convexAuthRefreshToken=refresh-demo; Path=/; HttpOnly",
                    ),
                );
                response
            }),
        );
        tokio::spawn(async move {
            axum::serve(listener, app).await.expect("legacy server");
        });
        format!("http://{addr}")
    }

    async fn spawn_legacy_unmatched_upstream() -> String {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("legacy listener");
        let addr = listener.local_addr().expect("legacy addr");
        let app = axum::Router::new().route("/unmigrated", get(|| async { "legacy-ok" }));
        tokio::spawn(async move {
            axum::serve(listener, app).await.expect("legacy server");
        });
        format!("http://{addr}")
    }

    #[tokio::test]
    async fn gateway_health_declares_mnote_web_owner() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/gateway/health")
                    .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")
        );

        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["owner"], "mnote-web");
        assert_eq!(payload["publicEntry"], "127.0.0.1:3000");
        assert_eq!(payload["legacyNextBaseUrl"], "http://127.0.0.1:3100");
    }

    #[tokio::test]
    async fn root_entry_returns_wolai_workspace_layout_contract() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .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")
        );
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains(r#"<body data-mnote-web-owner="mnote-web""#));
        assert!(html.contains(r#"data-mnote-shell="workspace""#));
        assert!(html.contains(r#"data-mnote-actor-id="user_real""#));
        assert!(html.contains(r#"data-testid="wolai-sidebar""#));
        assert!(html.contains(r#"data-testid="wolai-topbar""#));
        assert!(html.contains(r#"data-testid="wolai-floating-ai""#));
        assert!(html.contains("星标置顶"));
        assert!(html.contains("我的页面"));
        assert!(html.contains("垃圾箱"));
        assert!(html.contains("模板中心"));
        assert!(!html.contains("欢迎使用 MNOTE 知识管理平台"));
        assert!(!html.contains(r#"<a href="/documents">文档</a>"#));
    }

    #[tokio::test]
    async fn trash_entry_renders_real_workspace_trash_workbench() {
        let response = app_with_query_fixtures(
            "http://127.0.0.1:3100".into(),
            false,
            None,
            Some(
                r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[{"id":"ws_demo","name":"我的空间"}],"documents":[{"id":"page_alive","workspace_id":"ws_demo","title":"保留页面","parent_id":null,"sort_order":0,"is_starred":false}],"trashed_documents":[{"id":"page_trash","workspace_id":"ws_demo","title":"已删页面","parent_id":null,"sort_order":1,"deleted_at":"2026-05-14T00:00:00Z","deleted_by":"user_real"}],"media_assets":[],"trashed_media_assets":[{"id":"asset_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删附件.png","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_assets":[],"trashed_mindmap_assets":[{"id":"mind_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删思维导图.json","deleted_at":"2026-05-14T00:00:00Z"}],"table_assets":[],"trashed_table_assets":[{"id":"table_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删表格.luckysheet","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_docs":[],"mindmap_asset_children":{}}}"#.into(),
            ),
        )
        .oneshot(
            Request::builder()
                .uri("/trash?workspaceId=ws_demo")
                .header("x-mnote-actor-id", "user_real")
                .header("x-mnote-actor-type", "user")
                .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 html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains(r#"data-testid="mnote-trash-workbench""#));
        assert!(html.contains("已删页面"));
        assert!(html.contains(r#"data-trash-action="restore""#));
        assert!(html.contains(r#"data-trash-action="purge""#));
        assert!(html.contains(r#"data-trash-action="empty-documents""#));
        assert!(html.contains(r#"data-document-id="page_trash""#));
        assert!(html.contains(r#"data-trash-action="empty-resources""#));
        assert!(html.contains(r#"data-trash-action="resource-restore""#));
        assert!(html.contains(r#"data-trash-action="resource-purge""#));
        assert!(html.contains(r#"data-resource-kind="media""#));
        assert!(html.contains(r#"data-resource-kind="mindmap""#));
        assert!(html.contains(r#"data-resource-kind="table""#));
        assert!(html.contains("已删附件.png"));
        assert!(html.contains("已删思维导图.json"));
        assert!(html.contains("已删表格.luckysheet"));
        assert!(html.contains("new EventSource"));
        assert!(html.contains("/api/tree/events"));
        assert!(!html.contains("pollMs"));
        assert!(html.contains("refreshTrashWorkbenchFromServer"));
        assert!(!html.contains("window.location.reload"));
    }

    #[tokio::test]
    async fn trash_entry_renders_local_folder_trash_workbench() {
        let root = temp_root("mnote-local-folder-trash-entry");
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_real",
            &format!("file://{}", root.display()),
        )
        .expect("init local workspace");
        std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir");
        std::fs::create_dir_all(root.join(".mnote").join("trash")).expect("create trash dir");
        std::fs::write(
            root.join(".mnote").join("trash-index.json"),
            serde_json::json!({
                "entries": {
                    "local-md:Deleted~2FDeleted.md": {
                        "documentId": "local-md:Deleted~2FDeleted.md",
                        "resourceKind": "markdown_bundle",
                        "resourceScope": "local_folder",
                        "originalRelativePath": "Deleted",
                        "trashRelativePath": ".mnote/trash/Deleted",
                        "deletedAtMs": 1770000000000u64
                    }
                }
            })
            .to_string(),
        )
        .expect("write trash index");
        let root_uri = format!("file://{}", root.display());
        let uri = format!(
            "/trash?sourceKind=local_folder&rootUri={}",
            root_uri.replace(':', "%3A").replace('/', "%2F")
        );

        let response = app()
            .oneshot(
                Request::builder()
                    .uri(uri)
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .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 html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains(r#"data-trash-source-kind="local_folder""#));
        assert!(html.contains("Deleted"));
        assert!(html.contains(r#"data-trash-action="local-restore""#));
        assert!(html.contains(r#"data-trash-action="local-purge""#));
        assert!(html.contains(r#"data-filetree-row-id="doc:local-md:Deleted~2FDeleted.md""#));
        assert!(html.contains("mnote.pendingLocalFolderRestoreFiletreeRowId"));
        assert!(html.contains(r#"data-trash-action="local-empty-documents""#));
        assert!(html.contains(r#"data-trash-action="local-empty-resources" disabled"#));
        assert!(html.contains("rows.reduce(function(chain, row)"));
        assert!(!html.contains("Promise.all(rows.map(function(row)"));

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

    #[test]
    fn root_entry_active_selection_prefers_page_id_over_recent_projection_and_first_page() {
        let selected = super::choose_root_entry_active_page_id(
            Some("page_query"),
            Some("page_recent"),
            Some("page_projection"),
            Some("page_first"),
        );
        assert_eq!(selected.as_deref(), Some("page_query"));
    }

    #[test]
    fn root_entry_active_selection_falls_back_to_recent_projection_first_then_empty() {
        let from_recent = super::choose_root_entry_active_page_id(
            None,
            Some("page_recent"),
            Some("page_projection"),
            Some("page_first"),
        );
        assert_eq!(from_recent.as_deref(), Some("page_recent"));

        let from_projection = super::choose_root_entry_active_page_id(
            Some(" "),
            None,
            Some("page_projection"),
            Some("page_first"),
        );
        assert_eq!(from_projection.as_deref(), Some("page_projection"));

        let from_first =
            super::choose_root_entry_active_page_id(None, None, None, Some("page_first"));
        assert_eq!(from_first.as_deref(), Some("page_first"));

        let empty = super::choose_root_entry_active_page_id(None, None, None, None);
        assert_eq!(empty, None);
    }

    #[tokio::test]
    async fn root_entry_uses_recent_page_cookie_as_active_page() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/?workspaceId=ws_demo")
                    .header("cookie", "mnote_recent_page_id=page_child")
                    .header("x-mnote-workspace-id", "ws_demo")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .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 html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains(r#"data-node-id="page_child""#));
        assert!(html.contains(r#"href="/documents/page_child?workspaceId=ws_demo""#));
        assert!(html.contains(r#"data-active="true""#));
        assert!(html.contains(r#"data-root-active-page-id="page_child""#));
    }

    #[tokio::test]
    async fn root_entry_active_page_includes_document_panes_bootstrap() {
        let response = app_with_query_fixtures(
            "http://127.0.0.1:3100".into(),
            false,
            None,
            Some(
                r#"{
                  "documents:getMeta": {
                    "id": "doc_1",
                    "workspace_id": "ws_demo",
                    "title": "服务端页面",
                    "updated_at": "2026-04-18T09:30:00Z",
                    "can_edit": true,
                    "word_count": 42,
                    "character_count": 128,
                    "block_count": 1
                  },
                  "documents:getContent": {
                    "content": [{"id": "block_1", "type": "paragraph", "content": []}],
                    "revision": 7,
                    "conflict_detection_key": "doc_1:7",
                    "pageSubtree": {"rootNodeId": "doc_1", "outline": []}
                  }
                }"#
                .into(),
            ),
        )
        .oneshot(
            Request::builder()
                .uri("/?pageId=doc_1&workspaceId=ws_demo")
                .header("x-mnote-actor-id", "user_real")
                .header("x-mnote-actor-type", "user")
                .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 html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains("__MNOTE_PAGE_AGGREGATE__"));
        assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
        assert!(html.contains("mnote.document_panes_bootstrap.v1"));
    }

    #[tokio::test]
    async fn root_entry_renders_local_first_landing_without_convex() {
        let _guard = crate::test_support::hermes_env_lock()
            .lock()
            .expect("env lock");
        let base = temp_root("mnote-root-local-first-landing");
        std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base);
        let response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
            .oneshot(
                Request::builder()
                    .uri("/")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        std::env::remove_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR");

        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(!html.contains("初始化的新页面"));
        assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
        assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
        assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
        assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
        assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
        assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
        assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
        let _ = std::fs::remove_dir_all(&base);
    }

    #[tokio::test]
    async fn root_entry_renders_access_policy_dialog_templates_for_all_actors() {
        let admin_response =
            app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
                .oneshot(
                    Request::builder()
                        .uri("/")
                        .header("x-mnote-actor-id", "admin_real")
                        .header("x-mnote-actor-type", "admin")
                        .body(Body::empty())
                        .expect("request"),
                )
                .await
                .expect("admin response");

        assert_eq!(admin_response.status(), StatusCode::OK);
        let admin_body = to_bytes(admin_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let admin_html = String::from_utf8(admin_body.to_vec()).expect("utf8");
        assert!(!admin_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#));
        assert!(admin_html.contains(r#"data-mnote-action="open-account-menu""#));
        assert!(admin_html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js"));
        assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#));
        assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#));

        let user_response =
            app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
                .oneshot(
                    Request::builder()
                        .uri("/")
                        .header("x-mnote-actor-id", "user_real")
                        .header("x-mnote-actor-type", "user")
                        .body(Body::empty())
                        .expect("request"),
                )
                .await
                .expect("user response");

        assert_eq!(user_response.status(), StatusCode::OK);
        let user_body = to_bytes(user_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let user_html = String::from_utf8(user_body.to_vec()).expect("utf8");
        assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#));
        assert!(user_html.contains(r#"data-mnote-action="open-account-menu""#));
        assert!(user_html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js"));
        assert!(user_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#));
        assert!(user_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#));
    }

    #[tokio::test]
    async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() {
        use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};

        let app_state = 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: None,
            mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()),
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        });
        app_state
            .control_plane()
            .upsert_user(UpsertUserInput {
                id: Some("shujuan".into()),
                email: Some("shujuan@example.com".into()),
                username: "shujuan".into(),
                display_name: "shujuan".into(),
                role: None,
                password_hash: None,
            })
            .expect("upsert sqlite user");
        app_state
            .control_plane()
            .create_session(CreateSessionInput {
                id: None,
                user_id: "shujuan".into(),
                token_hash: session_token_hash("session-shujuan"),
                user_agent: None,
                ip_hash: None,
                expires_at: None,
            })
            .expect("create sqlite session");

        let response = build_app(app_state)
            .oneshot(
                Request::builder()
                    .uri("/")
                    .header("cookie", "mnote_session=session-shujuan")
                    .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 html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains("shujuan 的空间"));
        assert!(!html.contains("开发用户 的空间"));
    }

    #[tokio::test]
    async fn admin_access_policy_entry_requires_admin_actor() {
        let user_response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/admin/access-policy")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("user response");
        assert_eq!(user_response.status(), StatusCode::FORBIDDEN);

        let admin_response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/admin/access-policy")
                    .header("x-mnote-actor-id", "admin_real")
                    .header("x-mnote-actor-type", "admin")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("admin response");

        assert_eq!(admin_response.status(), StatusCode::OK);
        let body = to_bytes(admin_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains(r#"data-testid="mnote-admin-access-policy-page""#));
        assert!(html.contains("文件夹授权"));
        assert!(html.contains("管理员可以授权任意本地文件夹"));
        assert!(html.contains(r#"data-testid="mnote-admin-create-share-grant-submit""#));
        assert!(!html.contains(r#"data-testid="mnote-admin-validate-root-submit""#));
        assert!(!html.contains(r#"data-testid="mnote-admin-create-grant-submit""#));
        assert!(!html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#));
    }

    #[tokio::test]
    async fn user_access_policy_entry_renders_user_share_management() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/user/access-policy")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("user access policy response");

        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains(r#"data-mnote-shell="user-access-policy""#));
        assert!(html.contains("文件夹授权"));
        assert!(html.contains("普通用户只能授权自己空间下的文件夹"));
        assert!(html.contains(r#"data-testid="mnote-admin-create-share-grant-submit""#));
        assert!(!html.contains("仅管理员可见"));
        assert!(!html.contains(r#"data-testid="mnote-admin-validate-root-submit""#));
        assert!(!html.contains(r#"data-testid="mnote-admin-create-grant-submit""#));
        assert!(!html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#));
    }

    #[tokio::test]
    async fn root_entry_renders_local_folder_without_debug_tree_route() {
        let root =
            std::env::temp_dir().join(format!("mnote-root-local-folder-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("docs")).expect("create local docs");
        std::fs::write(root.join("README.md"), "# Local Root\n正文\n").expect("write root md");
        std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md");
        std::fs::write(root.join("plain.txt"), "plain\n").expect("write asset");

        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_real",
            &root_uri,
        )
        .expect("init local workspace");
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri(format!(
                        "/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
                    ))
                    .header("cookie", "mnote_recent_page_id=local-md:Other~2FOther.md")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

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

        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains(r#"data-mnote-shell="workspace""#));
        assert!(html.contains("local_folder"));
        assert!(html.contains("README.md"));
        assert!(html.contains(r#"id="sidebar-tree-root""#));
        assert!(html.contains(r#"data-shell-mode="page""#));
        assert!(html.contains(r#"data-node-id="local-md:README.md""#));
        assert!(html.contains(r#"data-row-id="local:folder:docs""#));
        assert!(!html.contains(r#"data-row-id="local:markdown:docs/child.md""#));
        assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#));
        assert!(!html.contains("legacy_next_compat_disabled"));
        assert!(!html.contains(r#"href="/tree"#));
    }

    #[tokio::test]
    async fn root_entry_local_folder_reuses_page_tree_snapshot_for_sidebar_html() {
        let root = temp_root("mnote-root-local-folder-page-tree-snapshot-once");
        std::fs::create_dir_all(root.join("docs")).expect("create local docs");
        std::fs::write(root.join("README.md"), "# Local Root\n正文\n").expect("write root md");
        std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md");

        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_real",
            &root_uri,
        )
        .expect("init local workspace");
        crate::routes::local_folder_source::reset_local_page_tree_snapshot_test_loads();
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri(format!(
                        "/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
                    ))
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

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

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            crate::routes::local_folder_source::local_page_tree_snapshot_test_loads(),
            1,
            "root entry should reuse the already loaded local page tree snapshot instead of scanning it twice",
        );
    }

    #[tokio::test]
    async fn root_entry_local_folder_filetree_scope_renders_scoped_page_tree() {
        let root = temp_root("mnote-root-local-folder-scoped-page-tree");
        std::fs::create_dir_all(root.join("design").join("05-editor-mainline"))
            .expect("create scoped design tree");
        std::fs::write(root.join("Home.md"), "# Home\n").expect("write root page");
        std::fs::write(
            root.join("design")
                .join("05-editor-mainline")
                .join("Target.md"),
            "# Target\n",
        )
        .expect("write scoped page");

        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_real",
            &root_uri,
        )
        .expect("init local workspace");
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri(format!(
                        "/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
                    ))
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

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

        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains(r#"data-node-id="local-dir:design~2F05-editor-mainline""#));
        assert!(html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#));
        assert!(
            !html.contains(r#"data-node-id="local-md:Home.md""#),
            "星标 scoped folder 入口的 PageTree 不应回退到 workspace root 页面树"
        );
    }

    #[tokio::test]
    async fn root_entry_local_folder_without_active_page_keeps_resource_tab_host() {
        let root = temp_root("mnote-root-local-folder-no-active-page");
        std::fs::create_dir_all(root.join("attachments")).expect("create attachments");
        std::fs::write(root.join("attachments").join("report-a.pdf"), b"%PDF-1.4\n")
            .expect("write pdf");

        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_real",
            &root_uri,
        )
        .expect("init local workspace");
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri(format!(
                        "/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
                    ))
                    .header("cookie", "mnote_recent_page_id=local-md:Other~2FOther.md")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

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

        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains(r#"data-mnote-shell="workspace""#));
        assert!(html.contains(r#"data-row-id="local:folder:attachments""#));
        assert!(!html.contains(r#"data-row-id="local:asset:attachments/report-a.pdf""#));
        assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
        assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
        assert!(html.contains(r#"data-testid="mnote-leptos-tiptap-island-editor-root""#));
        assert!(html.contains(r#"data-pane-role="primary""#));
        assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
        assert!(html.contains(
            r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#
        ));
        assert!(html.contains(
            r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">"#
        ));
        assert!(html.contains(
            r#"<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
        ));
        assert!(
            include_str!("../../browser/document-resource-tab-runtime.js")
                .contains("openResourceInActiveTab")
        );
        assert!(!html.contains(r#"<script id="__MNOTE_PAGE_AGGREGATE__""#));
    }

    #[tokio::test]
    async fn root_entry_redirects_anonymous_viewer_to_auth() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        assert_eq!(
            response
                .headers()
                .get(header::LOCATION)
                .and_then(|value| value.to_str().ok()),
            Some("/auth")
        );
        assert_eq!(
            response
                .headers()
                .get("x-mnote-web-owner")
                .and_then(|value| value.to_str().ok()),
            Some("mnote-web")
        );
    }

    #[tokio::test]
    async fn root_entry_allows_forwarded_actor_to_enter_workspace() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .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 html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains(r#"data-mnote-shell="workspace""#));
    }

    #[tokio::test]
    async fn root_entry_initializes_default_local_workspace_page() {
        let _guard = crate::test_support::hermes_env_lock()
            .lock()
            .expect("env lock");
        let base = temp_root("mnote-root-default-local-workspace");
        std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base);
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        std::env::remove_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR");

        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        let expected_root = base
            .join("users")
            .join("user_real")
            .join("workspaces")
            .join("my-space");
        assert!(
            !expected_root.join("初始化的新页面").exists(),
            "默认工作区不应创建'初始化的新页面'目录"
        );
        assert!(!html.contains("初始化的新页面"));
        assert!(html.contains("local_folder"));
        assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
        assert!(html.contains(r#"data-mnote-root-uri="file://"#));
        assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
        assert!(!html.contains("当前还没有可显示的本地工作区"));
        assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
        assert!(html.contains(r#""transport":"disabled""#));

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

    #[tokio::test]
    async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/auth")
                    .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!(response
            .headers()
            .get("content-type")
            .and_then(|value| value.to_str().ok())
            .unwrap_or_default()
            .contains("text/html"));
    }

    #[tokio::test]
    async fn favicon_is_handled_by_gateway_when_compat_disabled() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/favicon.ico")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::NO_CONTENT);
        assert_eq!(
            response
                .headers()
                .get("x-mnote-web-owner")
                .and_then(|value| value.to_str().ok()),
            Some("mnote-web")
        );
    }

    #[tokio::test]
    async fn auth_api_signup_sets_sqlite_session_cookie_when_compat_disabled() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"new-user@example.com","name":"new-user","password":"MnoteE2E123!","flow":"signUp"}}}"#,
                    ))
                    .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")
        );
        let cookies = response.headers().get_all(header::SET_COOKIE);
        let values = cookies
            .iter()
            .map(|value| value.to_str().unwrap_or_default())
            .collect::<Vec<_>>();
        assert!(values.iter().any(|value| value.contains("mnote_session=")));
        assert!(values
            .iter()
            .any(|value| value.contains("mnote_actor_id=new-user")));
        assert!(values
            .iter()
            .any(|value| value.contains("mnote_actor_type=user")));
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["userId"], "new-user");
        assert_eq!(payload["email"], "new-user@example.com");
        assert_eq!(payload["authMode"], "sqliteSession");
    }

    #[tokio::test]
    async fn auth_api_signin_accepts_sqlite_username_after_signup() {
        let app = app_with_config("http://127.0.0.1:3100".into(), false);
        let signup = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"mnote.e2e@example.com","name":"mnote-e2e","password":"MnoteE2E123!","flow":"signUp"}}}"#,
                    ))
                    .expect("request"),
            )
            .await
            .expect("signup response");
        assert_eq!(signup.status(), StatusCode::OK);

        let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/auth")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"action":"auth:signIn","args":{"provider":"password","params":{"account":"mnote-e2e","password":"MnoteE2E123!","flow":"signIn"}}}"#,
                ))
                .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: serde_json::Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["userId"], "mnote-e2e");
        assert_eq!(payload["email"], "mnote.e2e@example.com");
        assert_eq!(payload["authMode"], "sqliteSession");
    }

    #[tokio::test]
    async fn auth_api_requires_email_for_signup() {
        let response = app_with_config_and_convex_url(
            "http://127.0.0.1:3100".into(),
            false,
            Some("http://127.0.0.1:9".into()),
        )
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/auth")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"action":"auth:signIn","args":{"provider":"password","params":{"flow":"signUp","name":"new-user","password":"MnoteE2E123!"}}}"#,
                ))
                .expect("request"),
        )
        .await
        .expect("response");

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["code"], "auth_signup_email_required");
        assert!(payload["message"]
            .as_str()
            .unwrap_or_default()
            .contains("注册账号时请填写邮箱"));
    }

    #[tokio::test]
    async fn auth_api_signout_clears_sqlite_session_cookie() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth")
                    .header("content-type", "application/json")
                    .header("cookie", "mnote_session=raw-session-token")
                    .body(Body::from(r#"{"action":"auth:signOut"}"#))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        let cookies = response.headers().get_all(header::SET_COOKIE);
        let values = cookies
            .iter()
            .map(|value| value.to_str().unwrap_or_default())
            .collect::<Vec<_>>();
        assert!(values
            .iter()
            .any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0")));
    }

    #[tokio::test]
    async fn auth_entry_uses_mnote_web_login_ui_when_compat_enabled() {
        let legacy_base_url = spawn_legacy_auth_upstream().await;
        let response = app_with_legacy_next_base_url(legacy_base_url)
            .oneshot(
                Request::builder()
                    .uri("/auth")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response
                .headers()
                .get("x-mnote-legacy-upstream")
                .and_then(|value| value.to_str().ok()),
            None
        );
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains(r#"data-mnote-shell="auth""#));
        assert!(html.contains("账号登录"));
        assert!(html.contains("邮箱或用户名"));
        assert!(!html.contains(r#"<span>"用户名"</span>"#));
        assert!(html.contains("测试账号快速登录"));
    }

    #[tokio::test]
    async fn auth_entry_redirects_authenticated_viewer_to_root() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/auth")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        assert_eq!(
            response
                .headers()
                .get(header::LOCATION)
                .and_then(|value| value.to_str().ok()),
            Some("/")
        );
    }

    #[tokio::test]
    async fn auth_api_no_longer_proxies_to_legacy_next_for_origin_normalization() {
        let legacy_base_url = spawn_legacy_origin_checked_upstream().await;
        let response = app_with_legacy_next_base_url(legacy_base_url)
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth")
                    .header("origin", "http://127.0.0.1:3000")
                    .header("referer", "http://127.0.0.1:3000/auth")
                    .body(Body::from(r#"{"action":"auth:signIn"}"#))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            response
                .headers()
                .get("x-mnote-web-owner")
                .and_then(|value| value.to_str().ok()),
            Some("mnote-web")
        );
        assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
    }

    #[tokio::test]
    async fn auth_api_no_longer_uses_legacy_next_cookie_proxy() {
        let legacy_base_url = spawn_legacy_cookie_upstream().await;
        let response = app_with_legacy_next_base_url(legacy_base_url)
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        let cookies = response.headers().get_all("set-cookie");
        let values = cookies
            .iter()
            .map(|value| value.to_str().unwrap_or_default())
            .collect::<Vec<_>>();
        assert!(!values
            .iter()
            .any(|value| value.contains("__convexAuthJWT=")));
        assert!(!values
            .iter()
            .any(|value| value.contains("__convexAuthRefreshToken=")));
        assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
    }

    #[tokio::test]
    async fn unmigrated_route_returns_not_found_instead_of_proxying_to_legacy_next() {
        let legacy_base_url = spawn_legacy_unmatched_upstream().await;
        let response = app_with_legacy_next_base_url(legacy_base_url)
            .oneshot(
                Request::builder()
                    .uri("/unmigrated")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::NOT_FOUND);
        assert_eq!(
            response
                .headers()
                .get("x-mnote-legacy-upstream")
                .and_then(|value| value.to_str().ok()),
            None
        );
    }
}
