use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
use crate::document_buffer_store::{self};
use crate::error::WebError;
use crate::page_aggregate::PageAggregate;
use crate::routes::documents::{
    load_document_content_result, load_document_meta_result, DocumentContentQuery,
    DocumentMetaQuery,
};
use crate::routes::gateway::default_workspace_name_for_context;
use crate::routes::local_folder_source::{
    ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
    load_local_folder_file_tree_children_snapshot_with_reveal,
    load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot,
    load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate,
};
use crate::routes::query_support::execute_runtime_query_against_data;
use crate::routes::snapshot_support::{
    execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
};
use crate::routes::tree::{collect_filetree_render_rows, collect_page_tree_render_rows};
use crate::ssr::pages::document::DocumentPage;
use crate::tree_shell::filetree_renderer::{
    render_initial_filetree_html, FileTreeInitialRenderInput,
};
use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput};
use crate::workspace_shell::{
    apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
    WorkspaceShellProjection,
};
use axum::body::Body;
use axum::extract::{Extension, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode, Uri};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use control_plane::UpsertNavigationRecentInput;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{json, Value};
use std::fs;
use std::path::{Component, Path as FsPath, PathBuf};

const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell";
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";

#[cfg(test)]
static LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
    std::sync::atomic::AtomicU64::new(0);

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentShellQuery {
    pub workspace_id: Option<String>,
    pub source_kind: Option<String>,
    pub root_uri: Option<String>,
    pub tree_view: Option<String>,
    pub file_tree_scope: Option<String>,
    pub secondary_document_id: Option<String>,
    pub secondary_source_kind: Option<String>,
    pub secondary_root_uri: Option<String>,
}

pub async fn document_page_shell(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    uri: Uri,
    Path(document_id): Path<String>,
    Query(query): Query<DocumentShellQuery>,
) -> Result<Response, WebError> {
    if crate::routes::gateway::current_actor_id(&state, &context).is_none() {
        return redirect_response(&format!(
            "/auth?next={}",
            query_escape(
                uri.path_and_query()
                    .map(|value| value.as_str())
                    .unwrap_or("/")
            )
        ));
    }
    let mut primary_source_kind = normalize_source_kind(query.source_kind.as_deref());
    let primary_root_uri = normalize_optional_query_value(query.root_uri.as_deref());
    if primary_source_kind.is_none()
        && document_id.trim().starts_with("local-md:")
        && primary_root_uri.is_some()
    {
        primary_source_kind = Some("local_folder");
    }
    if primary_source_kind.is_none() && document_id.trim().starts_with("local-md:") {
        return redirect_response(&format!(
            "/?routeGuard={}&missingPage={}",
            query_escape("local_folder_source_required"),
            query_escape(&document_id),
        ));
    }
    let aggregate = match build_page_aggregate_snapshot(
        &state,
        &context,
        &document_id,
        query.workspace_id.as_deref(),
        primary_source_kind,
        primary_root_uri,
    )
    .await
    {
        Ok(aggregate) => aggregate,
        Err(error)
            if primary_source_kind == Some("local_folder")
                && error.code() == "local_markdown_not_found" =>
        {
            return redirect_response(&local_folder_navigation_redirect_location(
                primary_root_uri.unwrap_or_default(),
                query.file_tree_scope.as_deref(),
                &document_id,
            ));
        }
        Err(error)
            if primary_source_kind == Some("local_folder")
                && error.code() == "local_folder_root_required" =>
        {
            return redirect_response(&format!(
                "/?routeGuard={}&missingPage={}",
                query_escape("local_folder_root_required"),
                query_escape(&document_id),
            ));
        }
        Err(error)
            if primary_source_kind == Some("local_folder")
                && error.code() == "local_workspace_access_denied" =>
        {
            return redirect_response(&format!(
                "/?routeGuard={}&missingPage={}",
                query_escape("local_workspace_access_denied"),
                query_escape(&document_id),
            ));
        }
        Err(error) => return Err(error),
    };

    // 初始化 BufferStore：local_folder 文档打开时记录 file_version
    if primary_source_kind == Some("local_folder") {
        if let Some(root_uri) = primary_root_uri {
            let relative_path = local_markdown_relative_path_from_document_id(&document_id);
            let file_version = aggregate.body.file_version.as_str().map(|s| s.to_string());
            let ws_path = document_buffer_store::build_local_folder_workspace_path(
                &aggregate.identity.workspace_id,
                root_uri,
                &relative_path,
                &document_id,
            );
            state.buffer_store.init_buffer(&ws_path, file_version, None);
            record_navigation_recent_page(
                &state,
                &context,
                root_uri,
                &relative_path,
                &document_id,
                aggregate.head.title.as_str(),
                Some(&aggregate.identity.workspace_id),
            );
        }
    }

    let title = aggregate.head.title.as_str();
    let workspace_id = aggregate.identity.workspace_id.clone();
    let requested_secondary_document_id =
        normalize_optional_owned(query.secondary_document_id.as_deref());
    let secondary_source_kind = normalize_source_kind(
        query
            .secondary_source_kind
            .as_deref()
            .or(primary_source_kind),
    );
    let secondary_root_uri =
        normalize_optional_query_value(query.secondary_root_uri.as_deref().or(primary_root_uri));
    let mut secondary_requested = false;
    let mut secondary_invalid = false;
    let secondary_aggregate =
        if let Some(secondary_document_id) = requested_secondary_document_id.as_deref() {
            secondary_requested = true;
            match build_page_aggregate_snapshot(
                &state,
                &context,
                secondary_document_id,
                query.workspace_id.as_deref(),
                secondary_source_kind,
                secondary_root_uri,
            )
            .await
            {
                Ok(aggregate) => Some(aggregate),
                Err(_) => {
                    secondary_invalid = true;
                    None
                }
            }
        } else {
            None
        };
    let default_workspace_name = default_workspace_name_for_context(&state, &context);
    let mut workspace_projection = load_workspace_shell_projection(
        Some(&state),
        state.config(),
        &context,
        &workspace_id,
        Some(&document_id),
        &default_workspace_name,
    )
    .await;
    apply_active_page(&mut workspace_projection, Some(&document_id));
    let is_local_folder = query
        .source_kind
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        == Some("local_folder");
    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 (sidebar_tree_html, file_tree_html) = if is_local_folder {
        let root_uri = query.root_uri.as_deref().unwrap_or_default();
        (
            render_local_sidebar_tree_html_scoped(root_uri, Some(&document_id), file_tree_scope)
                .unwrap_or_default(),
            render_local_file_tree_html_scoped(root_uri, Some(&document_id), None, file_tree_scope)
                .unwrap_or_default(),
        )
    } else {
        (
            load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
                .await
                .unwrap_or_default(),
            load_file_tree_html(
                state.config(),
                &context,
                &workspace_id,
                Some(&document_id),
                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()),
        if requests_filetree_first {
            Some("filetree")
        } else {
            None
        },
        file_tree_scope,
    );
    let workspace_name = workspace_projection.workspace_name.clone();
    let page_subtree_json =
        serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string());
    let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
        .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, primary_source_kind, primary_root_uri);
    let secondary_page_subtree_json = secondary_aggregate.as_ref().map(|aggregate| {
        serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string())
    });
    let secondary_page_options_json = secondary_aggregate.as_ref().map(|aggregate| {
        serde_json::to_string(&aggregate.layout.page_options).unwrap_or_else(|_| "null".to_string())
    });
    let secondary_snapshot_json = secondary_aggregate
        .as_ref()
        .map(|aggregate| serde_json::to_string(aggregate).unwrap_or_else(|_| "null".to_string()));
    let secondary_bootstrap_json = secondary_aggregate.as_ref().map(|aggregate| {
        build_editor_bootstrap_json_with_ids(
            aggregate,
            &context,
            secondary_source_kind,
            secondary_root_uri,
            "__MNOTE_SECONDARY_PAGE_AGGREGATE__",
            "secondary",
        )
    });
    let panes_bootstrap_json = build_document_panes_bootstrap_json(
        &aggregate,
        &context,
        primary_source_kind,
        primary_root_uri,
        secondary_aggregate.as_ref(),
        secondary_source_kind,
        secondary_root_uri,
        secondary_requested,
        secondary_invalid,
    );
    let body_content = crate::ssr::render_view(leptos::view! {
        <DocumentPage
            title={title.to_string()}
            document_id={document_id.clone()}
            workspace_id={workspace_id.clone()}
            sidebar_tree_html={sidebar_tree_html}
            workspace_name={workspace_name}
            workspace_sidebar_html={workspace_sidebar_html}
            page_subtree_json={page_subtree_json}
            page_options_json={page_options_json}
            secondary_title={secondary_aggregate.as_ref().map(|aggregate| aggregate.head.title.clone()).unwrap_or_default()}
            secondary_document_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.document_id.clone()).unwrap_or_default()}
            secondary_workspace_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.workspace_id.clone()).unwrap_or_default()}
            secondary_page_subtree_json={secondary_page_subtree_json.unwrap_or_default()}
            secondary_page_options_json={secondary_page_options_json.unwrap_or_default()}
            primary_hide_title_header={aggregate.layout.page_options.hide_title_header}
            secondary_hide_title_header={secondary_aggregate.as_ref().map(|aggregate| aggregate.layout.page_options.hide_title_header).unwrap_or(secondary_source_kind == Some("local_folder"))}
            show_admin_access_policy={is_local_access_policy_admin_context(&context)}
        />
    });
    let hermes_settings_config_script = render_hermes_settings_config_script();
    let 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="document" data-document-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}" data-secondary-requested="{}" data-secondary-invalid="{}">
    {}
    <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>
    {}
    {}
    {}
    {}
    {}
    {}
  </body>
</html>"#,
        escape_html(title),
        render_editor_runtime_preload_links(),
        crate::ssr::MNOTE_CSS,
        escape_html(&document_id),
        escape_html(primary_source_kind.unwrap_or("local_folder")),
        escape_html(primary_root_uri.unwrap_or("")),
        secondary_requested,
        secondary_invalid,
        body_content,
        escape_script_json(&snapshot_json),
        escape_script_json(&bootstrap_json),
        escape_script_json(&panes_bootstrap_json),
        hermes_settings_config_script,
        secondary_snapshot_json
            .as_ref()
            .map(|value| format!(r#"<script id="__MNOTE_SECONDARY_PAGE_AGGREGATE__" type="application/json">{}</script>"#, escape_script_json(value)))
            .unwrap_or_default(),
        secondary_bootstrap_json
            .as_ref()
            .map(|value| format!(r#"<script id="__MNOTE_SECONDARY_EDITOR_BOOTSTRAP__" type="application/json">{}</script>"#, escape_script_json(value)))
            .unwrap_or_default(),
        render_document_title_controller_script(),
        render_editor_island_adapter_script(),
        format!(
            r#"<script type="module" src="{}"></script>"#,
            mnote_browser_runtime_src("document-conflict-panel-runtime.js")
        ),
    );
    let mut response = Html(html).into_response();
    stamp_shell_headers(response.headers_mut(), "document");
    stamp_recent_page_cookie(response.headers_mut(), &document_id);
    Ok(response)
}

fn redirect_response(location: &str) -> Result<Response, WebError> {
    Response::builder()
        .status(StatusCode::SEE_OTHER)
        .header(header::LOCATION, location)
        .body(Body::empty())
        .map_err(|error| WebError::internal(format!("跳转响应构造失败: {error}")))
}

fn local_folder_navigation_redirect_location(
    root_uri: &str,
    file_tree_scope: Option<&str>,
    missing_document_id: &str,
) -> String {
    let scope = file_tree_scope
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .unwrap_or_else(|| local_markdown_parent_scope_from_document_id(missing_document_id));
    let mut location = format!(
        "/?sourceKind=local_folder&rootUri={}&treeView=filetree",
        query_escape(root_uri)
    );
    if !scope.trim().is_empty() {
        location.push_str("&fileTreeScope=");
        location.push_str(&query_escape(&scope));
    }
    location.push_str("&missingPage=");
    location.push_str(&query_escape(missing_document_id));
    location
}

fn local_markdown_parent_scope_from_document_id(document_id: &str) -> String {
    let relative_path = local_markdown_relative_path_from_document_id(document_id);
    relative_path
        .rsplit_once('/')
        .map(|(parent, _)| parent.to_string())
        .unwrap_or_default()
}

fn local_markdown_relative_path_from_document_id(document_id: &str) -> String {
    document_id
        .trim()
        .strip_prefix("local-md:")
        .unwrap_or_default()
        .replace("~2F", "/")
}

fn record_navigation_recent_page(
    state: &AppState,
    context: &RequestContext,
    root_uri: &str,
    relative_path: &str,
    document_id: &str,
    title: &str,
    workspace_id: Option<&str>,
) {
    let Some(actor_id) = crate::routes::gateway::current_actor_id(state, context) else {
        return;
    };
    if actor_id.trim().is_empty()
        || actor_id.trim() == "anonymous"
        || root_uri.trim().is_empty()
        || document_id.trim().is_empty()
    {
        return;
    }
    let fallback_title = relative_path
        .rsplit('/')
        .next()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or("未命名页面");
    let _ = state
        .control_plane()
        .upsert_navigation_recent(UpsertNavigationRecentInput {
            id: None,
            user_id: actor_id,
            workspace_id: workspace_id
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(ToOwned::to_owned),
            kind: "page".to_string(),
            source_kind: "local_folder".to_string(),
            root_uri: root_uri.trim().to_string(),
            relative_path: Some(relative_path.trim().trim_matches('/').to_string())
                .filter(|value| !value.is_empty()),
            document_id: Some(document_id.trim().to_string()),
            title: title
                .trim()
                .is_empty()
                .then(|| fallback_title.to_string())
                .unwrap_or_else(|| title.trim().to_string()),
            metadata_json: "{}".to_string(),
        });
}

fn query_escape(value: &str) -> String {
    value
        .bytes()
        .flat_map(|byte| match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
                vec![byte as char]
            }
            _ => format!("%{byte:02X}").chars().collect::<Vec<_>>(),
        })
        .collect()
}

pub(crate) fn build_editor_bootstrap_json(
    aggregate: &PageAggregate,
    context: &RequestContext,
    source_kind: Option<&str>,
    root_uri: Option<&str>,
) -> String {
    build_editor_bootstrap_json_with_ids(
        aggregate,
        context,
        source_kind,
        root_uri,
        "__MNOTE_PAGE_AGGREGATE__",
        "primary",
    )
}

fn render_hermes_settings_config_script() -> String {
    let Some(base_url) = [
        "MNOTE_WEB_HERMES_UPSTREAM_URL",
        "MNOTE_HERMES_UPSTREAM_URL",
        "MNOTE_HERMES_API_BASE_URL",
    ]
    .into_iter()
    .find_map(env_or_dotenv)
    .map(|value| value.trim().trim_end_matches('/').to_string())
    .filter(|value| !value.is_empty()) else {
        return String::new();
    };
    let settings_url = format!("{base_url}/hermes/settings");
    let encoded = serde_json::to_string(&settings_url).unwrap_or_else(|_| "\"\"".to_string());
    format!(
        r#"<script>window.__mnoteHermesSettingsUrl = {};</script>"#,
        escape_script_json(&encoded)
    )
}

fn env_or_dotenv(key: &str) -> Option<String> {
    if let Ok(value) = std::env::var(key) {
        let trimmed = value.trim().trim_matches('"').to_string();
        if !trimmed.is_empty() {
            return Some(trimmed);
        }
    }
    if cfg!(test) {
        return None;
    }
    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../..")
        .join(".env.all");
    let content = fs::read_to_string(root).ok()?;
    for line in content.lines() {
        let line = line.trim_end_matches('\r');
        if line.starts_with('#') || line.trim().is_empty() {
            continue;
        }
        let Some((candidate_key, value)) = line.split_once('=') else {
            continue;
        };
        if candidate_key.trim() != key {
            continue;
        }
        let trimmed = value.trim().trim_matches('"').to_string();
        if !trimmed.is_empty() {
            return Some(trimmed);
        }
    }
    None
}

pub(crate) fn build_editor_bootstrap_json_with_ids(
    aggregate: &PageAggregate,
    context: &RequestContext,
    source_kind: Option<&str>,
    root_uri: Option<&str>,
    page_aggregate_script_id: &str,
    pane_role: &str,
) -> String {
    let normalized_source_kind = source_kind
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("local_folder");
    let save_endpoint = if normalized_source_kind == "local_folder" {
        "/api/page-body/write"
    } else {
        "/api/documents/save"
    };
    serde_json::to_string(&json!({
        "schema": "mnote.editor_bootstrap.v1",
        "documentId": aggregate.identity.document_id,
        "workspaceId": aggregate.identity.workspace_id,
        "paneRole": pane_role,
        "sourceKind": normalized_source_kind,
        "rootUri": root_uri
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or(""),
        "pageAggregateScriptId": page_aggregate_script_id,
        "saveEndpoint": save_endpoint,
        "titleEndpoint": "/api/documents/title",
        "editorHostKind": "leptos_tiptap_island",
        "assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
        "requestId": context.trace.request_id,
        "traceId": context.trace.trace_id,
    }))
    .unwrap_or_else(|_| "{}".to_string())
}

pub(crate) fn build_document_panes_bootstrap_json(
    aggregate: &PageAggregate,
    context: &RequestContext,
    source_kind: Option<&str>,
    root_uri: Option<&str>,
    secondary_aggregate: Option<&PageAggregate>,
    secondary_source_kind: Option<&str>,
    secondary_root_uri: Option<&str>,
    secondary_requested: bool,
    secondary_invalid: bool,
) -> String {
    let primary = json!({
        "role": "primary",
        "aggregate": aggregate,
        "bootstrap": serde_json::from_str::<serde_json::Value>(&build_editor_bootstrap_json_with_ids(
            aggregate,
            context,
            source_kind,
            root_uri,
            "__MNOTE_PAGE_AGGREGATE__",
            "primary",
        )).unwrap_or_else(|_| json!({})),
    });
    let secondary = secondary_aggregate.map(|aggregate| {
        json!({
            "role": "secondary",
            "aggregate": aggregate,
            "bootstrap": serde_json::from_str::<serde_json::Value>(&build_editor_bootstrap_json_with_ids(
                aggregate,
                context,
                secondary_source_kind,
                secondary_root_uri,
                "__MNOTE_SECONDARY_PAGE_AGGREGATE__",
                "secondary",
            )).unwrap_or_else(|_| json!({})),
        })
    });
    serde_json::to_string(&json!({
        "schema": "mnote.document_panes_bootstrap.v1",
        "secondaryRequested": secondary_requested,
        "secondaryInvalid": secondary_invalid,
        "panes": match secondary {
            Some(secondary) => vec![primary, secondary],
            None => vec![primary],
        },
    }))
    .unwrap_or_else(|_| "{}".to_string())
}

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

fn normalize_source_kind(value: Option<&str>) -> Option<&str> {
    normalize_optional_query_value(value)
}

fn normalize_optional_owned(value: Option<&str>) -> Option<String> {
    normalize_optional_query_value(value).map(ToOwned::to_owned)
}

pub(crate) fn render_document_title_controller_script() -> &'static str {
    r#"<script>
(() => {
  const CONTRACT = 'mnote.document_title_controller.v1';
  const inputs = Array.from(document.querySelectorAll('[data-page-title-input="true"]')).filter((node) => node instanceof HTMLTextAreaElement);
  if (!inputs.length) return;

  const cssEscape = (value) => {
    if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
    return String(value).replace(/["\\]/g, '\\$&');
  };

  const autosize = (input) => {
    input.style.height = 'auto';
    input.style.height = `${Math.max(48, input.scrollHeight)}px`;
  };

  const setStatus = (input, status, message) => {
    input.setAttribute('data-title-save-status', status);
    const shell = input.closest('.document-shell');
    if (shell instanceof HTMLElement) shell.setAttribute('data-title-save-status', status);
    if (message) input.setAttribute('data-title-save-error', message);
    else input.removeAttribute('data-title-save-error');
  };

  const setText = (selector, title) => {
    document.querySelectorAll(selector).forEach((node) => {
      if (node instanceof HTMLElement) node.textContent = title;
    });
  };

  const fileTreePageTitle = (value) => {
    const normalized = String(value || '无标题').trim() || '无标题';
    return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;
  };

  const updateVisibleTitle = (input, title, documentId) => {
    const pane = input.closest('[data-document-pane="true"]');
    const isPrimaryDocument = documentId && document.body?.dataset.documentId === documentId;
    if (isPrimaryDocument) {
      document.title = title;
      const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
      if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
      const pageTabTitle = document.querySelector('[data-mnote-main-tab="page"] .mnote-main-tab-title');
      if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title;
    }
    if (documentId) {
      const escapedId = cssEscape(documentId);
      document.querySelectorAll(`[data-page-title-input="true"][data-document-id="${escapedId}"]`).forEach((node) => {
        if (!(node instanceof HTMLTextAreaElement) || node === input) return;
        node.value = title;
        autosize(node);
        setStatus(node, 'saved');
      });
      document.querySelectorAll(`[data-document-pane="true"][data-pane-document-id="${escapedId}"] [data-page-title-current="true"]`).forEach((node) => {
        if (node instanceof HTMLElement && node !== input) node.textContent = title;
      });
    } else {
      pane?.querySelectorAll('[data-page-title-current]').forEach((node) => {
        if (node instanceof HTMLElement && node !== input) node.textContent = title;
      });
    }
    const current = document.querySelector('.wolai-breadcrumb-current');
    if (current instanceof HTMLElement && isPrimaryDocument) {
      let titleNode = current.querySelector('[data-page-title-current]');
      if (!(titleNode instanceof HTMLElement)) {
        titleNode = document.createElement('span');
        titleNode.setAttribute('data-page-title-current', 'true');
        current.appendChild(titleNode);
      }
      titleNode.textContent = title;
    }
    if (!documentId) return;
    const escapedId = cssEscape(documentId);
    const escapedDocRowId = cssEscape(`doc:${documentId}`);
    setText(`.tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
    setText(`.tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title));
    setText(`.wolai-page-row[data-node-id="${escapedId}"] > .wolai-row-title`, title);
    setText(`a[href="/documents/${escapedId}"] > .wolai-row-title`, title);
    setText(`a[href^="/documents/${escapedId}?"] > .wolai-row-title`, title);
  };

  inputs.forEach((input) => {
    input.setAttribute('data-title-controller', CONTRACT);
    const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
    const resolveTitleTarget = (targetInput) => {
      const paneRole = (targetInput.getAttribute('data-pane-role') || 'primary').trim();
      const rawDocumentId = (targetInput.getAttribute('data-document-id') || '').trim();
      const documentId = rawDocumentId || (
        paneRole === 'primary'
          ? (document.body?.dataset.documentId || '').trim()
          : ''
      );
      const query = new URLSearchParams(window.location.search);
      const paneQueryParams = paneRole === 'secondary'
        ? { sourceKindParam: 'secondarySourceKind', rootUriParam: 'secondaryRootUri' }
        : { sourceKindParam: 'sourceKind', rootUriParam: 'rootUri' };
      return {
        documentId,
        workspaceId: (targetInput.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim(),
        sourceKind: (query.get(paneQueryParams.sourceKindParam) || '').trim(),
        rootUri: (query.get(paneQueryParams.rootUriParam) || '').trim(),
      };
    };
    const readLastSavedTitle = () => input.getAttribute('data-title-last-saved') || '无标题';
    const writeLastSavedTitle = (title) => {
      input.setAttribute('data-title-last-saved', title || '无标题');
    };
    writeLastSavedTitle(input.value.trim() || '无标题');
    let saving = false;

    const saveTitle = async () => {
    const title = input.value.trim() || '无标题';
    const currentTarget = resolveTitleTarget(input);
    autosize(input);
    if (!currentTarget.documentId || saving || title === readLastSavedTitle()) {
      updateVisibleTitle(input, title, currentTarget.documentId);
      setStatus(input, 'saved');
      return;
    }
    saving = true;
    setStatus(input, 'saving');
    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          documentId: currentTarget.documentId,
          workspaceId: currentTarget.workspaceId || null,
          sourceKind: currentTarget.sourceKind || undefined,
          rootUri: currentTarget.rootUri || undefined,
          title,
          commandName: 'page.head.updateTitle',
        }),
      });
      const payload = await response.json().catch(() => null);
      if (!response.ok || !payload || payload.ok !== true) {
        throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`);
      }
      const result = payload?.result || {};
      const nextDocumentId = String(result.documentId || result.id || currentTarget.documentId || '').trim();
      const previousDocumentId = currentTarget.documentId;
      const nextTitle = String(result.title || title || '无标题').trim() || '无标题';
      if (nextDocumentId) {
        input.setAttribute('data-document-id', nextDocumentId);
      }
      writeLastSavedTitle(nextTitle);
      updateVisibleTitle(input, nextTitle, nextDocumentId || currentTarget.documentId);
      setStatus(input, 'saved');
      window.dispatchEvent(new CustomEvent('tree:title-updated', {
        detail: {
          documentId: nextDocumentId || currentTarget.documentId,
          previousDocumentId,
          workspaceId: currentTarget.workspaceId || null,
          title: nextTitle,
          payload,
        },
      }));
    } catch (error) {
      setStatus(input, 'error', error instanceof Error ? error.message : String(error));
    } finally {
      saving = false;
    }
  };

    input.addEventListener('input', () => {
      autosize(input);
      setStatus(input, (input.value.trim() || '无标题') === readLastSavedTitle() ? 'saved' : 'dirty');
    });
    input.addEventListener('keydown', (event) => {
      if (event.key === 'Enter' && !event.shiftKey) {
        event.preventDefault();
        input.blur();
      }
    });
    input.addEventListener('blur', () => { void saveTitle(); });
    autosize(input);
    updateVisibleTitle(input, readLastSavedTitle(), resolveTitleTarget(input).documentId);
    setStatus(input, 'saved');
  });
})();
</script>"#
}

pub(crate) fn mnote_browser_runtime_src(asset: &str) -> String {
    let base = format!("/api/mnote-browser-runtime/{asset}");
    if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() {
        format!("{base}?devHot={cache_buster}")
    } else {
        base
    }
}

pub(crate) fn render_editor_island_adapter_script() -> String {
    format!(
        r#"<script type="module" src="{}"></script>"#,
        mnote_browser_runtime_src("document-editor-adapter-runtime.js")
    )
}

fn leptos_tiptap_runtime_src(asset: &str) -> String {
    let base = format!("/api/leptos-tiptap-runtime/{asset}");
    if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() {
        format!("{base}?devHot={cache_buster}")
    } else {
        base
    }
}

pub(crate) fn render_editor_runtime_preload_links() -> String {
    format!(
        r#"<link rel="modulepreload" href="{}">
    <link rel="preload" href="{}" as="fetch" type="application/wasm" crossorigin>"#,
        leptos_tiptap_runtime_src("mnote-leptos-tiptap-spike-island.js"),
        leptos_tiptap_runtime_src("mnote-leptos-tiptap-spike-island_bg.wasm")
    )
}

fn runtime_asset_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../spikes/leptos-tiptap-spike/generated/island")
}

fn resolve_runtime_asset_path(asset_path: &str) -> Option<PathBuf> {
    let asset_path = asset_path.trim();
    if asset_path.is_empty() || asset_path.starts_with('/') || asset_path.contains('\\') {
        return None;
    }
    let mut resolved = runtime_asset_root();
    for component in FsPath::new(asset_path).components() {
        match component {
            Component::Normal(part) => resolved.push(part),
            _ => return None,
        }
    }
    Some(resolved)
}

fn resolve_lazy_runtime_asset_path(asset_path: &str) -> Option<PathBuf> {
    let asset_path = asset_path.trim();
    if asset_path != "tiptap_mindmap_paragraph_runtime.js" {
        return None;
    }
    Some(
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("../../../reference-code/leptos-tiptap/src/js/generated")
            .join(asset_path),
    )
}

fn runtime_asset_content_type(asset_path: &str) -> &'static str {
    if asset_path.ends_with(".wasm") {
        "application/wasm"
    } else if asset_path.ends_with(".js") {
        "application/javascript; charset=utf-8"
    } else if asset_path.ends_with(".json") {
        "application/json; charset=utf-8"
    } else {
        "application/octet-stream"
    }
}

fn runtime_asset_cache_control() -> &'static str {
    if crate::routes::dev_hot::dev_hot_reload_enabled() {
        "no-store"
    } else {
        "public, max-age=3600, stale-while-revalidate=86400"
    }
}

fn browser_runtime_js_body(js: &'static str) -> Body {
    let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() else {
        return Body::from(js);
    };
    Body::from(rewrite_browser_runtime_imports_for_dev_hot(
        js,
        cache_buster,
    ))
}

fn rewrite_browser_runtime_imports_for_dev_hot(js: &str, cache_buster: &str) -> String {
    let suffix = format!("?devHot={cache_buster}");
    js.replace(".js';", &format!(".js{suffix}';"))
        .replace(".js\";", &format!(".js{suffix}\";"))
        .replace(".js');", &format!(".js{suffix}');"))
        .replace(".js\");", &format!(".js{suffix}\");"))
        .replace(".js'\n", &format!(".js{suffix}'\n"))
        .replace(".js\"\n", &format!(".js{suffix}\"\n"))
}

fn runtime_asset_body(asset_path: &str, bytes: Vec<u8>) -> Body {
    let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() else {
        return Body::from(bytes);
    };
    if !asset_path.ends_with(".js") {
        return Body::from(bytes);
    }
    match String::from_utf8(bytes) {
        Ok(js) => Body::from(rewrite_browser_runtime_imports_for_dev_hot(
            &js,
            cache_buster,
        )),
        Err(error) => Body::from(error.into_bytes()),
    }
}

pub async fn editor_image_placeholder_asset() -> Response {
    const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360" role="img" aria-label="E24 image placeholder">
  <rect width="640" height="360" rx="18" fill="#f3f4f6"/>
  <rect x="42" y="42" width="556" height="276" rx="14" fill="#ffffff" stroke="#d1d5db" stroke-width="2"/>
  <circle cx="168" cy="132" r="34" fill="#93c5fd"/>
  <path d="M98 278 246 174 340 242 410 192 542 278Z" fill="#86efac"/>
  <path d="M98 278 246 174 340 242 410 192 542 278" fill="none" stroke="#16a34a" stroke-width="8" stroke-linejoin="round"/>
  <text x="320" y="322" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" fill="#374151">E24 Image</text>
</svg>"##;
    Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, "image/svg+xml; charset=utf-8")
        .header(header::CACHE_CONTROL, "no-store")
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(Body::from(SVG))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PdfPreviewQuery {
    #[serde(default, alias = "fileUrl")]
    file_url: Option<String>,
    #[serde(default, alias = "fileName")]
    file_name: Option<String>,
    #[serde(default)]
    page: Option<u32>,
    #[serde(default)]
    bbox: Option<String>,
    #[serde(default, alias = "blockId")]
    block_id: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OfficePreviewQuery {
    file_url: Option<String>,
    file_name: Option<String>,
    file_type: Option<String>,
    workspace_id: Option<String>,
    source_kind: Option<String>,
    root_uri: Option<String>,
    document_id: Option<String>,
    #[serde(default)]
    page: Option<u32>,
    #[serde(default)]
    bbox: Option<String>,
    #[serde(default, alias = "sourceMapPath")]
    source_map_path: Option<String>,
    #[serde(default, alias = "blockId")]
    block_id: Option<String>,
    #[serde(default, alias = "paragraphOrdinal")]
    paragraph_ordinal: Option<String>,
    #[serde(default, alias = "paraIdStart")]
    para_id_start: Option<String>,
    #[serde(default, alias = "paraIdEnd")]
    para_id_end: Option<String>,
    #[serde(default, alias = "textFingerprint")]
    text_fingerprint: Option<String>,
    #[serde(default, alias = "evidenceText")]
    evidence_text: Option<String>,
    #[serde(default, alias = "searchQuery")]
    search_query: Option<String>,
}

pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Response {
    let file_url = query.file_url.unwrap_or_default();
    let file_name = query
        .file_name
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("Office 预览");
    let file_type = query
        .file_type
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| {
            file_name
                .rsplit_once('.')
                .map(|(_, ext)| ext)
                .unwrap_or("docx")
        })
        .to_ascii_lowercase();
    let page_width_content_type = office_preview_page_width_content_type(&file_type);
    let workspace_id = query.workspace_id.unwrap_or_default();
    let source_kind = query.source_kind.unwrap_or_default();
    let root_uri = query.root_uri.unwrap_or_default();
    let document_id = query.document_id.unwrap_or_default();
    let target_page = query
        .page
        .map(|value| value.to_string())
        .unwrap_or_default();
    let target_bbox = query.bbox.unwrap_or_default();
    let target_source_map_path = query.source_map_path.unwrap_or_default();
    let target_block_id = query.block_id.unwrap_or_default();
    let target_paragraph_ordinal = query.paragraph_ordinal.unwrap_or_default();
    let target_para_id_start = query.para_id_start.unwrap_or_default();
    let target_para_id_end = query.para_id_end.unwrap_or_default();
    let target_text_fingerprint = query.text_fingerprint.unwrap_or_default();
    let target_evidence_text = query.evidence_text.unwrap_or_default();
    let target_search_query = query.search_query.unwrap_or_default();
    let html = format!(
        r#"<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <title>{title}</title>
  <style>
    :root {{
      color-scheme: light;
      --mnote-office-bg: #f7f7f5;
      --mnote-office-panel: #ffffff;
      --mnote-office-border: #d9d9d3;
      --mnote-office-text: #232321;
      --mnote-office-muted: #696963;
      --mnote-office-accent: #2563eb;
      --mnote-preview-max-width: none;
    }}
    * {{ box-sizing: border-box; }}
    html, body {{ margin: 0; width: 100%; min-height: 100%; background: var(--mnote-office-bg); color: var(--mnote-office-text); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
    .mnote-office-preview {{ min-height: 100vh; }}
    .mnote-office-viewer {{ width: 100%; max-width: var(--mnote-preview-max-width); margin: 0 auto; padding: 12px; overflow: auto; }}
    .mnote-office-message {{ max-width: 720px; margin: 24px auto; padding: 16px; border: 1px solid var(--mnote-office-border); border-radius: 8px; background: var(--mnote-office-panel); color: var(--mnote-office-muted); font-size: 14px; line-height: 1.6; }}
    .mnote-office-sheet-tabs {{ display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 10px; }}
    .mnote-office-sheet-tab {{ min-height: 30px; padding: 0 10px; border: 1px solid var(--mnote-office-border); border-radius: 6px; background: #fff; cursor: pointer; }}
    .mnote-office-sheet-tab[aria-selected="true"] {{ border-color: var(--mnote-office-accent); color: var(--mnote-office-accent); }}
    .mnote-office-table-wrap {{ width: 100%; overflow: auto; border: 1px solid var(--mnote-office-border); background: #fff; }}
    .mnote-office-table {{ width: max-content; min-width: 100%; border-collapse: collapse; font-size: 13px; }}
    .mnote-office-table th, .mnote-office-table td {{ max-width: 360px; padding: 6px 8px; border: 1px solid #ecece7; text-align: left; vertical-align: top; white-space: pre-wrap; }}
    .mnote-office-table th {{ position: sticky; top: 0; background: #f2f2ed; color: var(--mnote-office-muted); font-weight: 600; }}
    .mnote-office-viewer .docx-wrapper,
    .mnote-office-viewer .mnote-docx-wrapper {{ width: 100%; background: transparent !important; padding: 0 !important; align-items: stretch !important; }}
    .mnote-office-viewer .docx-wrapper > section.docx,
    .mnote-office-viewer .mnote-docx-wrapper > section.mnote-docx {{ width: 100% !important; max-width: none !important; margin: 0 0 14px !important; box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
    .mnote-office-viewer {{ position: relative; }}
    .mnote-office-viewer [data-mnote-office-evidence-target="true"] {{ outline: 0; border-radius: 2px; background: #FFE9E6; color: #D83A32; box-shadow: 0 0 0 1px rgba(216, 58, 50, .22); }}
    .mnote-office-evidence-marker {{ position: absolute; z-index: 3; left: 24px; max-width: min(720px, calc(100% - 48px)); padding: 6px 10px; border: 1px solid rgba(216, 58, 50, .45); border-radius: 6px; background: rgba(255, 249, 248, .96); color: #D83A32; font-size: 13px; line-height: 1.5; box-shadow: 0 2px 10px rgba(15, 23, 42, .12); }}
    .mnote-office-evidence-marker[data-mnote-office-evidence-marker-mode="range"] {{ pointer-events: none; background: rgba(255, 233, 230, .72); box-shadow: 0 0 0 1px rgba(216, 58, 50, .28); }}
    .mnote-office-pptx-stage {{ width: 100%; overflow: auto; }}
    .mnote-office-pptx-stage .pptx-preview-wrapper {{ max-width: 100%; background: transparent !important; }}
    .mnote-office-pptx-stage .pptx-preview-slide-wrapper {{ box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
    @media (max-width: 640px) {{
      .mnote-office-viewer {{ padding: 6px; }}
    }}
  </style>
</head>
	<body data-file-url="{file_url}" data-file-name="{file_name}" data-file-type="{file_type}" data-page-width-content-type="{page_width_content_type}" data-workspace-id="{workspace_id}" data-mnote-source-kind="{source_kind}" data-mnote-root-uri="{root_uri}" data-document-id="{document_id}" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-source-map-path="{target_source_map_path}" data-evidence-block-id="{target_block_id}" data-evidence-paragraph-ordinal="{target_paragraph_ordinal}" data-evidence-para-id-start="{target_para_id_start}" data-evidence-para-id-end="{target_para_id_end}" data-evidence-text-fingerprint="{target_text_fingerprint}" data-evidence-text="{target_evidence_text}" data-evidence-search-query="{target_search_query}">
  <main class="mnote-office-preview">
    <section class="mnote-office-viewer" id="mnote-office-viewer"></section>
  </main>
  <script src="/api/office-preview/vendor/jszip.min.js"></script>
  <script src="/api/office-preview/vendor/docx-preview.min.js"></script>
  <script src="/api/office-preview/vendor/xlsx.full.min.js"></script>
  <script src="/api/office-preview/vendor/pptx-preview.umd.js"></script>
  <script>
    const body = document.body;
    const viewer = document.getElementById('mnote-office-viewer');
    const fileUrl = body.dataset.fileUrl || '';
    const fileName = body.dataset.fileName || '';
    const fileType = (body.dataset.fileType || '').toLowerCase();
    const pageWidthContentType = body.dataset.pageWidthContentType || 'word';
    let evidencePage = Number(body.dataset.evidencePage || 0);
    let evidenceBbox = body.dataset.evidenceBbox || '';
    let evidenceSourceMapPath = body.dataset.evidenceSourceMapPath || '';
    let evidenceBlockId = body.dataset.evidenceBlockId || '';
    let evidenceParagraphOrdinal = body.dataset.evidenceParagraphOrdinal || '';
    let evidenceParaIdStart = body.dataset.evidenceParaIdStart || '';
    let evidenceParaIdEnd = body.dataset.evidenceParaIdEnd || '';
    let evidenceTextFingerprint = body.dataset.evidenceTextFingerprint || '';
    let evidenceText = body.dataset.evidenceText || '';
    let evidenceSearchQuery = body.dataset.evidenceSearchQuery || '';
    let currentPptxBuffer = null;
    let pptxRenderToken = 0;
    let pptxResizeTimer = 0;

    function previewCssMaxWidth(mode) {{
      if (mode === 'readable') return '760px';
      if (mode === 'comfortable') return '980px';
      if (mode === 'wide') return '1180px';
      if (mode === 'full') return 'none';
      return pageWidthContentType === 'excel' ? 'none' : '1180px';
    }}

    function applyPreviewWidthPreference(preferences) {{
      const table = preferences && typeof preferences === 'object' ? preferences : {{}};
      const fallbackMode = pageWidthContentType === 'excel' ? 'full' : 'wide';
      const preference = table[pageWidthContentType] && typeof table[pageWidthContentType] === 'object'
        ? table[pageWidthContentType]
        : {{ resolvedMode: fallbackMode, cssMaxWidth: previewCssMaxWidth(fallbackMode), source: 'system' }};
      const resolvedMode = String(preference.resolvedMode || preference.resolved_mode || preference.mode || fallbackMode);
      const cssMaxWidth = String(preference.cssMaxWidth || preference.css_max_width || previewCssMaxWidth(resolvedMode));
      document.documentElement.setAttribute('data-page-width-content-type', pageWidthContentType);
      document.documentElement.setAttribute('data-page-width-resolved-mode', resolvedMode);
      document.documentElement.setAttribute('data-page-width-source', String(preference.source || 'system'));
      document.documentElement.style.setProperty('--mnote-preview-max-width', cssMaxWidth === 'none' ? 'none' : cssMaxWidth);
      if (viewer) viewer.style.maxWidth = cssMaxWidth === 'none' ? 'none' : cssMaxWidth;
    }}

    async function loadPreviewWidthPreferences() {{
      applyPreviewWidthPreference(null);
      try {{
        const params = new URLSearchParams();
        if (body.dataset.workspaceId) params.set('workspaceId', body.dataset.workspaceId);
        if (body.dataset.mnoteSourceKind) params.set('sourceKind', body.dataset.mnoteSourceKind);
        if (body.dataset.mnoteRootUri) params.set('rootUri', body.dataset.mnoteRootUri);
        if (body.dataset.documentId) params.set('documentId', body.dataset.documentId);
        const response = await fetch('/api/ui/preferences/effective' + (params.toString() ? '?' + params.toString() : ''), {{
          credentials: 'same-origin',
          headers: {{ accept: 'application/json' }}
        }});
        const payload = await response.json().catch(() => null);
        if (response.ok && payload && payload.ok === true) {{
          applyPreviewWidthPreference(payload.result && payload.result.pageWidthPreferences);
        }}
      }} catch (_) {{}}
    }}

    function setStatus(text) {{
      document.documentElement.setAttribute('data-mnote-office-preview-status', text || '');
    }}

    function fileReadErrorMessage(status) {{
      if (status === 401 || status === 403) {{
        return '文件读取被拒绝，请先登录并确认已授权当前本地文件夹。';
      }}
      return '文件读取失败：' + status;
    }}

    function showMessage(text) {{
      if (!viewer) return;
      viewer.replaceChildren();
      const message = document.createElement('div');
      message.className = 'mnote-office-message';
      message.textContent = text;
      viewer.append(message);
    }}

    function normalizeEvidenceText(value) {{
      return String(value || '')
        .replace(/<[^>]+>/g, ' ')
        .replace(/&nbsp;|&#160;/gi, ' ')
        .replace(/[#*_`~>\[\](){{}}]+/g, ' ')
        .replace(/[\u200B-\u200D\uFEFF]/g, '')
        .replace(/\s+/g, ' ')
        .trim();
    }}

    function markEvidenceTarget(target) {{
      if (!(target instanceof HTMLElement)) return false;
      viewer.querySelectorAll('[data-mnote-office-evidence-target="true"]').forEach(node => {{
        if (node instanceof HTMLElement) node.removeAttribute('data-mnote-office-evidence-target');
      }});
      target.setAttribute('data-mnote-office-evidence-target', 'true');
      window.setTimeout(() => target.scrollIntoView({{ block: 'center', inline: 'nearest' }}), 0);
      document.documentElement.setAttribute('data-mnote-office-evidence-applied', 'true');
      return true;
    }}

    function normalizedTextWithRawOffsets(value) {{
      const raw = String(value || '');
      let text = '';
      const offsets = [];
      let previousWhitespace = true;
      for (let index = 0; index < raw.length; index += 1) {{
        const ch = raw[index];
        if (/\s/.test(ch)) {{
          if (text && !previousWhitespace) {{
            text += ' ';
            offsets.push(index);
          }}
          previousWhitespace = true;
        }} else {{
          text += ch;
          offsets.push(index);
          previousWhitespace = false;
        }}
      }}
      if (text.endsWith(' ')) {{
        text = text.slice(0, -1);
        offsets.pop();
      }}
      return {{ text, offsets }};
    }}

    function compactTextWithRawOffsets(value) {{
      const raw = String(value || '');
      let text = '';
      const offsets = [];
      for (let index = 0; index < raw.length; index += 1) {{
        const ch = raw[index];
        if (/\s/.test(ch)) continue;
        text += ch;
        offsets.push(index);
      }}
      return {{ text, offsets }};
    }}

    function wrapEvidenceTextNode(node, needle) {{
      if (!(node instanceof Text)) return null;
      const raw = String(node.textContent || '');
      let start = raw.indexOf(needle);
      let end = start >= 0 ? start + needle.length : -1;
      if (start < 0) {{
        const compact = compactTextWithRawOffsets(raw);
        const compactNeedle = normalizeEvidenceText(needle).replace(/\s+/g, '');
        let normalizedStart = compact.text.indexOf(compactNeedle);
        let sourceOffsets = compact.offsets;
        if (normalizedStart < 0) {{
          const mapped = normalizedTextWithRawOffsets(raw);
          const mappedNeedle = normalizeEvidenceText(needle);
          normalizedStart = mapped.text.indexOf(mappedNeedle);
          sourceOffsets = mapped.offsets;
          if (normalizedStart < 0) return null;
          start = sourceOffsets[normalizedStart];
          end = sourceOffsets[normalizedStart + mappedNeedle.length - 1] + 1;
          if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) return null;
          const range = document.createRange();
          range.setStart(node, start);
          range.setEnd(node, end);
          const span = document.createElement('span');
          span.setAttribute('data-mnote-office-evidence-target', 'true');
          try {{
            range.surroundContents(span);
            return span;
          }} catch (_) {{
            return null;
          }}
        }}
        if (normalizedStart < 0) return null;
        start = sourceOffsets[normalizedStart];
        end = sourceOffsets[normalizedStart + compactNeedle.length - 1] + 1;
      }}
      if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) return null;
      const range = document.createRange();
      range.setStart(node, start);
      range.setEnd(node, end);
      const span = document.createElement('span');
      span.setAttribute('data-mnote-office-evidence-target', 'true');
      try {{
        range.surroundContents(span);
        return span;
      }} catch (_) {{
        return null;
      }}
    }}

    function markEvidenceRangeAcrossTextNodes(needle) {{
      if (!viewer) return false;
      const compactNeedle = normalizeEvidenceText(needle).replace(/\s+/g, '');
      if (!compactNeedle) return false;
      if (compactNeedle.length > 160) return false;
      const refs = [];
      let compactText = '';
      const walker = document.createTreeWalker(viewer, NodeFilter.SHOW_TEXT);
      let node = walker.nextNode();
      while (node) {{
        const raw = String(node.textContent || '');
        for (let offset = 0; offset < raw.length; offset += 1) {{
          const ch = raw[offset];
          if (/\s/.test(ch)) continue;
          compactText += ch;
          refs.push({{ node, offset }});
        }}
        node = walker.nextNode();
      }}
      const startIndex = compactText.indexOf(compactNeedle);
      if (startIndex < 0) return false;
      const endIndex = startIndex + compactNeedle.length - 1;
      const startRef = refs[startIndex];
      const endRef = refs[endIndex];
      if (!startRef || !endRef) return false;
      const range = document.createRange();
      range.setStart(startRef.node, startRef.offset);
      range.setEnd(endRef.node, endRef.offset + 1);
      const rect = range.getBoundingClientRect();
      if (!rect || rect.width <= 0 || rect.height <= 0) return false;
      const rects = Array.from(range.getClientRects()).filter(item => item && item.width > 0 && item.height > 0);
      if (rects.length > 6 || rect.height > Math.min(140, window.innerHeight * 0.35)) return false;
      const paragraphTarget = startRef.node?.parentElement?.closest('p, li, td, th, blockquote');
      if (paragraphTarget instanceof HTMLElement && normalizeEvidenceText(paragraphTarget.textContent).length < 4000) {{
        return markEvidenceTarget(paragraphTarget);
      }}
      let marker = viewer.querySelector('[data-mnote-office-evidence-marker="true"]');
      if (!(marker instanceof HTMLElement)) {{
        marker = document.createElement('div');
        marker.className = 'mnote-office-evidence-marker';
        marker.setAttribute('data-mnote-office-evidence-marker', 'true');
        viewer.append(marker);
      }}
      const viewerRect = viewer.getBoundingClientRect();
      marker.textContent = '';
      marker.setAttribute('data-mnote-office-evidence-target-text', normalizeEvidenceText(needle));
      marker.setAttribute('data-mnote-office-evidence-marker-mode', 'range');
      marker.style.left = Math.max(0, Math.round(rect.left - viewerRect.left + viewer.scrollLeft)).toString() + 'px';
      marker.style.top = Math.max(0, Math.round(rect.top - viewerRect.top + viewer.scrollTop)).toString() + 'px';
      marker.style.width = Math.max(8, Math.round(rect.width)).toString() + 'px';
      marker.style.height = Math.max(8, Math.round(rect.height)).toString() + 'px';
      marker.style.maxWidth = 'none';
      marker.style.padding = '0';
      return markEvidenceTarget(marker);
    }}

    function pageForEvidenceBlock(sourceMap, block) {{
      if (!sourceMap || typeof sourceMap !== 'object' || !block) return null;
      const pages = Array.isArray(sourceMap.pages) ? sourceMap.pages : [];
      for (const page of pages) {{
        const blocks = Array.isArray(page && page.blocks) ? page.blocks : [];
        if (blocks.includes(block)) return page;
      }}
      return null;
    }}

    function findEvidenceBlockInSourceMap(sourceMap) {{
      if (!sourceMap || typeof sourceMap !== 'object' || !evidenceBlockId) return null;
      const pages = Array.isArray(sourceMap.pages) ? sourceMap.pages : [];
      for (const page of pages) {{
        const blocks = Array.isArray(page && page.blocks) ? page.blocks : [];
        const block = blocks.find(item => String(item && (item.id || item.blockId || item.block_id) || '') === evidenceBlockId);
        if (block) return block;
      }}
      return null;
    }}

    async function fetchEvidenceSourceMap() {{
      if (!evidenceSourceMapPath || !body.dataset.mnoteRootUri) return null;
      const params = new URLSearchParams();
      params.set('rootUri', body.dataset.mnoteRootUri);
      params.set('path', evidenceSourceMapPath);
      const response = await fetch('/api/local-folder/files/open?' + params.toString(), {{
        credentials: 'same-origin',
        headers: {{ accept: 'application/json, text/plain, */*' }}
      }});
      if (!response.ok) return null;
      return response.json().catch(() => null);
    }}

    function scrollToEvidenceText(text) {{
      const needle = normalizeEvidenceText(text);
      if (!needle || !viewer) return false;
      viewer.querySelectorAll('[data-mnote-office-evidence-target="true"]').forEach(node => {{
        if (node instanceof HTMLElement) {{
          if (node.tagName === 'SPAN' && node.childNodes.length === 1 && node.firstChild instanceof Text) {{
            node.replaceWith(node.firstChild);
          }} else {{
            node.removeAttribute('data-mnote-office-evidence-target');
          }}
        }}
      }});
      const walker = document.createTreeWalker(viewer, NodeFilter.SHOW_TEXT);
      let node = walker.nextNode();
      while (node) {{
        if (normalizeEvidenceText(node.textContent).includes(needle)) {{
          const target = node.parentElement && node.parentElement.closest('p, div, span, table, section') || node.parentElement;
          if (target instanceof HTMLElement) {{
            const rect = target.getBoundingClientRect();
            if (rect.height > window.innerHeight * 1.8 || normalizeEvidenceText(target.textContent).length > 4000) break;
            if (needle.length < 20) {{
              const paragraphTarget = node.parentElement && node.parentElement.closest('p, li, td, th, blockquote') || target;
              if (paragraphTarget instanceof HTMLElement) return markEvidenceTarget(paragraphTarget);
            }}
          }}
          const inlineTarget = wrapEvidenceTextNode(node, needle);
          if (inlineTarget) return markEvidenceTarget(inlineTarget);
          return markEvidenceTarget(target);
        }}
        node = walker.nextNode();
      }}
      if (markEvidenceRangeAcrossTextNodes(needle)) return true;
      return false;
    }}

    function evidenceTextCandidates(text) {{
      const raw = String(text || '');
      const cleaned = raw
        .replace(/<[^>]+>/g, ' ')
        .replace(/[#*_`~>\[\](){{}}]+/g, ' ')
        .replace(/\s+/g, ' ')
        .trim();
      const specific = [];
      function pushSpecificEvidenceTerm(term) {{
        let value = normalizeEvidenceText(term);
        if (!value || value.length < 3) return;
        const anchor = value.search(/[一二三四五六七八九十甲乙丙丁戊己庚辛壬癸叔仲异正特苯][\u3400-\u9fffA-Za-z0-9（）()\\-]{{0,18}}硅/);
        if (anchor > 0) value = value.slice(anchor);
        value = value.split(/[:：。；;，,\n]/)[0];
        if (value.length < 3 || value.length > 48) return;
        specific.push(value);
        if (/[基酯醚]$/.test(value) && value.length > 3) specific.push(value.slice(0, -1));
      }}
      const withoutTags = raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
      const siliconTerms = cleaned.match(/[\u3400-\u9fffA-Za-z0-9（）()\\-]{{0,24}}硅(?:基|酯|醚)?/g) || [];
      siliconTerms.forEach(pushSpecificEvidenceTerm);
      const cjkTerms = cleaned.match(/[\u3400-\u9fff][\u3400-\u9fffA-Za-z0-9（）()\\-]{{2,48}}/g) || [];
      const fallbackTerms = [];
      cjkTerms.forEach(term => {{
        const value = normalizeEvidenceText(term);
        if (value.length < 3 || /^参考文献$/.test(value) || /^保护$/.test(value)) return;
        pushSpecificEvidenceTerm(value);
        const rawIndex = withoutTags.indexOf(value);
        if (rawIndex >= 0) {{
          const rawWindow = withoutTags.slice(rawIndex, rawIndex + value.length + 36).split(/[:：。；;，,\n]/)[0];
          specific.push(normalizeEvidenceText(rawWindow));
        }}
        const cleanedIndex = cleaned.indexOf(value);
        if (cleanedIndex >= 0) {{
          const cleanedWindow = cleaned.slice(cleanedIndex, cleanedIndex + value.length + 36).split(/[:：。；;，,\n]/)[0];
          specific.push(normalizeEvidenceText(cleanedWindow));
        }}
        if (value.endsWith('基') && value.length > 3) specific.push(value.slice(0, -1));
        fallbackTerms.push(value);
      }});
      const candidates = [];
      cleaned.split(/[。；;，,\n]/).forEach(part => {{
        const value = normalizeEvidenceText(part);
        if (value.length >= 8) candidates.push(value);
        if (value.length >= 28) candidates.push(value.slice(0, 28));
      }});
      candidates.push(...fallbackTerms);
      candidates.push(cleaned, raw);
      const seen = new Set();
      return specific.concat(candidates)
        .map(normalizeEvidenceText)
        .filter(value => value.length >= 3 && !seen.has(value) && seen.add(value))
        .sort((left, right) => right.length - left.length);
    }}

    function compactEvidenceText(value) {{
      return normalizeEvidenceText(value).replace(/[\s\p{{P}}\p{{S}}]+/gu, '');
    }}

    function compactEvidenceTextWithoutNumbers(value) {{
      return normalizeEvidenceText(value).replace(/[0-9０-９]+/g, '').replace(/[\s\p{{P}}\p{{S}}]+/gu, '');
    }}

    function evidenceLeadingAnchors(text) {{
      const raw = String(text || '');
      const cleaned = normalizeEvidenceText(raw);
      const anchors = [];
      const queryCompact = compactEvidenceText(evidenceSearchQuery);
      function push(value, options) {{
        const normalized = normalizeEvidenceText(value);
        const allowShort = options && options.allowShort === true;
        if (normalized.length < (allowShort ? 3 : 6)) return;
        anchors.push(normalized.length > 80 ? normalized.slice(0, 80) : normalized);
        if (normalized.length > 24) anchors.push(normalized.slice(0, 24));
      }}
      function pushQueryNearPrefix(value) {{
        const normalized = normalizeEvidenceText(value);
        if (!normalized || !queryCompact || !compactEvidenceText(normalized).includes(queryCompact)) return;
        push(normalized, {{ allowShort: true }});
        const queryIndex = compactEvidenceText(normalized).indexOf(queryCompact);
        if (queryIndex >= 0 && normalized.length > 24) push(normalized.slice(0, 36), {{ allowShort: true }});
      }}
      const prefixWindow = cleaned.slice(0, 260);
      if (queryCompact.length >= 2) {{
        const prefixCompact = compactEvidenceText(prefixWindow);
        const compactIndex = prefixCompact.indexOf(queryCompact);
        if (compactIndex >= 0) {{
          const queryIndex = prefixWindow.indexOf(evidenceSearchQuery);
          const start = queryIndex >= 0 ? queryIndex : 0;
          const queryWindow = prefixWindow.slice(start, start + 96).split(/[。；;]/)[0];
          push(queryWindow, {{ allowShort: true }});
          queryWindow.split(/[，,]/).slice(0, 2).forEach(part => push(part, {{ allowShort: true }}));
        }}
      }}
      const catalogMatches = prefixWindow.match(/[^，,。；;#]{{2,56}}[，,]\s*[0-9０-９]{{1,5}}/g) || [];
      catalogMatches.slice(0, 8).forEach(match => {{
        const value = normalizeEvidenceText(match);
        push(value, {{ allowShort: true }});
        const withoutPage = value.replace(/[，,]\s*[0-9０-９]{{1,5}}\s*$/, '');
        push(withoutPage, {{ allowShort: true }});
        pushQueryNearPrefix(withoutPage);
      }});
      const headingMatches = prefixWindow.match(/[0-9０-９]+(?:\.[0-9０-９]+){{1,5}}\s+[^。；;]{{2,72}}/g) || [];
      headingMatches.slice(0, 4).forEach(match => {{
        const firstPart = normalizeEvidenceText(match).split(/[，,]/)[0];
        push(firstPart, {{ allowShort: true }});
      }});
      raw.split(/[\n。；;]/).slice(0, 4).forEach(part => {{
        const value = normalizeEvidenceText(part);
        if (value) push(value);
      }});
      cleaned.split(/[。；;]/).slice(0, 4).forEach(part => {{
        const value = normalizeEvidenceText(part);
        if (value) push(value);
      }});
      evidenceTextCandidates(text).forEach(candidate => {{
        if (!queryCompact || compactEvidenceText(candidate).includes(queryCompact)) push(candidate);
      }});
      const seen = new Set();
      return anchors
        .map(normalizeEvidenceText)
        .filter(value => {{
          if (!value || seen.has(value)) return false;
          const compactValue = compactEvidenceText(value);
          const shortQueryAnchor = queryCompact.length >= 2
            && compactValue.includes(queryCompact)
            && compactValue.length >= queryCompact.length + 1
            && /[0-9０-９A-Za-z]/.test(value);
          if (value.length < 6 && !shortQueryAnchor) return false;
          seen.add(value);
          return true;
        }});
    }}

    function shortLeadingAnchorTarget(element, elements, index) {{
      const normalized = normalizeEvidenceText(element && element.textContent || '');
      if (normalized.length >= 18) return element;
      for (let offset = 1; offset <= 3; offset += 1) {{
        const next = elements[index + offset];
        if (!(next instanceof HTMLElement)) continue;
        const nextText = normalizeEvidenceText(next.textContent || '');
        if (nextText.length >= 18 && evidenceElementMatchesSearchQuery(next)) return next;
      }}
      return element;
    }}

    function scrollToEvidenceLeadingAnchor(text) {{
      if (!viewer) return false;
      const anchors = evidenceLeadingAnchors(text);
      if (!anchors.length) return false;
      const queryCompact = compactEvidenceText(evidenceSearchQuery);
      const elements = Array.from(viewer.querySelectorAll('p, li, td, th, blockquote'))
        .filter(node => node instanceof HTMLElement)
        .filter(node => {{
          const normalized = normalizeEvidenceText(node.textContent || '');
          return normalized.length >= 3 && normalized.length <= 1200;
        }});
      for (const anchor of anchors) {{
        const compactAnchor = compactEvidenceText(anchor);
        const compactAnchorWithoutNumbers = compactEvidenceTextWithoutNumbers(anchor);
        const shortQueryAnchor = queryCompact.length >= 2
          && compactAnchor.includes(queryCompact)
          && compactAnchor.length >= queryCompact.length + 1
          && /[0-9０-９A-Za-z]/.test(anchor);
        if (!shortQueryAnchor && compactAnchor.length < 6 && compactAnchorWithoutNumbers.length < 6) continue;
        for (const element of elements) {{
          const compactElement = compactEvidenceText(element.textContent || '');
          if ((compactAnchor.length >= 6 || shortQueryAnchor) && compactElement.includes(compactAnchor)) {{
            return markEvidenceTarget(element);
          }}
          if (
            compactAnchorWithoutNumbers.length >= 8
            && /[\u3400-\u9fff]/.test(anchor)
            && compactEvidenceTextWithoutNumbers(element.textContent || '').includes(compactAnchorWithoutNumbers)
          ) {{
            return markEvidenceTarget(element);
          }}
        }}
      }}
      return false;
    }}

    function evidenceParagraphAnchors(text) {{
      const cleaned = normalizeEvidenceText(text);
      const anchors = [];
      function push(value) {{
        const normalized = normalizeEvidenceText(value);
        if (normalized.length < 6) return;
        anchors.push(normalized.length > 140 ? normalized.slice(0, 140) : normalized);
      }}
      evidenceTextCandidates(text).forEach(push);
      cleaned.split(/[。；;，,\n]/).forEach(part => {{
        const value = normalizeEvidenceText(part);
        if (value.length >= 10) push(value);
        if (value.length >= 36) push(value.slice(0, 36));
      }});
      if (cleaned.length >= 24) {{
        for (let index = 0; index < cleaned.length; index += 48) {{
          push(cleaned.slice(index, index + 96));
        }}
      }}
      push(cleaned);
      const seen = new Set();
      return anchors
        .map(normalizeEvidenceText)
        .filter(value => value.length >= 6 && !seen.has(value) && seen.add(value))
        .sort((left, right) => right.length - left.length);
    }}

    function scoreEvidenceParagraphElement(element, anchors) {{
      if (!(element instanceof HTMLElement)) return 0;
      const text = normalizeEvidenceText(element.textContent || '');
      if (!text || text.length < 3 || text.length > 6000) return 0;
      const compactText = compactEvidenceText(text);
      let score = 0;
      for (const anchor of anchors) {{
        const compactAnchor = compactEvidenceText(anchor);
        if (!compactAnchor || compactAnchor.length < 4) continue;
        if (text.includes(anchor)) {{
          score += anchor.length * anchor.length * 4;
          continue;
        }}
        if (compactText.includes(compactAnchor)) {{
          score += compactAnchor.length * compactAnchor.length * 2;
          continue;
        }}
        if (compactAnchor.length >= 14) {{
          const prefix = compactAnchor.slice(0, Math.min(36, compactAnchor.length));
          if (prefix.length >= 8 && compactText.includes(prefix)) score += prefix.length * 20;
        }}
      }}
      return score;
    }}

    function evidenceElementMatchesSearchQuery(element) {{
      if (!(element instanceof HTMLElement)) return false;
      const compactSearchQuery = compactEvidenceText(evidenceSearchQuery);
      if (compactSearchQuery.length < 2) return false;
      return compactEvidenceText(element.textContent || '').includes(compactSearchQuery);
    }}

    function scrollToEvidenceParagraph(text) {{
      if (!viewer) return false;
      if (scrollToEvidenceLeadingAnchor(text)) return true;
      const anchors = evidenceParagraphAnchors(text);
      if (!anchors.length) return false;
      const selector = 'p, li, td, th, blockquote, section.docx, section.mnote-docx, div';
      const elements = Array.from(viewer.querySelectorAll(selector))
        .filter(node => node instanceof HTMLElement)
        .filter(node => {{
          const normalized = normalizeEvidenceText(node.textContent || '');
          if (normalized.length < 3 || normalized.length > 6000) return false;
          const childBlocks = Array.from(node.children || []).filter(child => child instanceof HTMLElement && /^(P|LI|TD|TH|BLOCKQUOTE)$/.test(child.tagName));
          return childBlocks.length === 0 || /^(TD|TH|SECTION)$/.test(node.tagName);
      }});
      let best = null;
      let bestWithSearchQuery = null;
      for (const element of elements) {{
        const score = scoreEvidenceParagraphElement(element, anchors);
        if (score <= 0) continue;
        if (!best || score > best.score) best = {{ element, score }};
        if (evidenceElementMatchesSearchQuery(element) && (!bestWithSearchQuery || score > bestWithSearchQuery.score)) {{
          bestWithSearchQuery = {{ element, score }};
        }}
      }}
      const threshold = Math.max(180, Math.min(800, anchors[0].length * 4));
      if (bestWithSearchQuery && bestWithSearchQuery.score >= threshold) return markEvidenceTarget(bestWithSearchQuery.element);
      if (!best) return false;
      if (best.score < threshold) return false;
      return markEvidenceTarget(best.element);
    }}

    function scrollToEvidenceTextCandidates(text) {{
      for (const candidate of evidenceTextCandidates(text)) {{
        if (scrollToEvidenceText(candidate)) return true;
      }}
      return false;
    }}

    function scrollToEvidenceCoordinate(sourceMap, block) {{
      if (!viewer || !sourceMap || !block) return false;
      const page = pageForEvidenceBlock(sourceMap, block);
      const bbox = block.bbox && typeof block.bbox === 'object' ? block.bbox : null;
      const pageNumber = Number(page && page.page || evidencePage || 0);
      const pageCount = Math.max(1, Number(sourceMap.pageCount || (Array.isArray(sourceMap.pages) ? sourceMap.pages.length : 0)) || 1);
      if (!Number.isFinite(pageNumber) || pageNumber <= 0) return false;
      const renderedPages = Array.from(viewer.querySelectorAll('section.docx, section.mnote-docx, .pptx-preview-slide-wrapper'))
        .filter(node => node instanceof HTMLElement);
      const pageElement = renderedPages[pageNumber - 1];
      let top = 0;
      if (pageElement instanceof HTMLElement) {{
        const pageHeight = Math.max(1, pageElement.scrollHeight || pageElement.getBoundingClientRect().height || 1);
        const sourcePageHeight = Math.max(1, Number(page && page.height || 0) || pageHeight);
        top = pageElement.offsetTop + (bbox ? (Number(bbox.y0) / sourcePageHeight) * pageHeight : pageHeight / 2);
      }} else {{
        const contentHeight = Math.max(1, viewer.scrollHeight || document.documentElement.scrollHeight || 1);
        const estimatedPageHeight = contentHeight / pageCount;
        const sourcePageHeight = Math.max(1, Number(page && page.height || 0) || estimatedPageHeight);
        top = estimatedPageHeight * (pageNumber - 1) + (bbox ? (Number(bbox.y0) / sourcePageHeight) * estimatedPageHeight : estimatedPageHeight / 2);
      }}
      let marker = viewer.querySelector('[data-mnote-office-evidence-marker="true"]');
      if (!(marker instanceof HTMLElement)) {{
        marker = document.createElement('div');
        marker.className = 'mnote-office-evidence-marker';
        marker.setAttribute('data-mnote-office-evidence-marker', 'true');
        viewer.append(marker);
      }}
      marker.textContent = normalizeEvidenceText(block.text || evidenceBlockId || '命中位置');
      marker.removeAttribute('data-mnote-office-evidence-target-text');
      marker.setAttribute('data-mnote-office-evidence-marker-mode', 'estimated');
      marker.style.width = '';
      marker.style.height = '';
      marker.style.padding = '';
      marker.style.top = Math.max(0, Math.round(top)).toString() + 'px';
      return markEvidenceTarget(marker);
    }}

    function evidenceParagraphElements() {{
      if (!viewer) return [];
      const paragraphs = Array.from(viewer.querySelectorAll('p, li, td, th, blockquote'))
        .filter(node => node instanceof HTMLElement)
        .filter(node => normalizeEvidenceText(node.textContent || '').length > 0);
      if (paragraphs.length) return paragraphs;
      return Array.from(viewer.querySelectorAll('div, section.docx, section.mnote-docx'))
        .filter(node => node instanceof HTMLElement)
        .filter(node => normalizeEvidenceText(node.textContent || '').length > 0);
    }}

    function scrollToEvidenceParagraphOrdinal() {{
      const ordinal = Number(evidenceParagraphOrdinal);
      if (!Number.isFinite(ordinal) || ordinal < 0) return false;
      const elements = evidenceParagraphElements();
      if (!elements.length) return false;
      const anchors = evidenceParagraphAnchors(evidenceText || evidenceSearchQuery);
      const indices = [];
      for (let offset = 0; offset <= 4; offset += 1) {{
        if (offset === 0) indices.push(ordinal);
        else {{
          indices.push(ordinal - offset);
          indices.push(ordinal + offset);
        }}
      }}
      let best = null;
      for (const index of indices) {{
        if (index < 0 || index >= elements.length) continue;
        const element = elements[index];
        const score = anchors.length ? scoreEvidenceParagraphElement(element, anchors) : 0;
        if (!best || score > best.score) best = {{ element, score }};
        if (score >= 2400 && evidenceElementMatchesSearchQuery(element)) break;
      }}
      if (!best) return false;
      if (!anchors.length) {{
        return evidenceElementMatchesSearchQuery(best.element) ? markEvidenceTarget(best.element) : false;
      }}
      const threshold = Math.max(180, Math.min(800, anchors[0].length * 4));
      if (best.score >= threshold) return markEvidenceTarget(best.element);
      if (best.score > 0 && evidenceElementMatchesSearchQuery(best.element)) return markEvidenceTarget(best.element);
      return false;
    }}

    function scrollToEvidencePageFallback() {{
      if (!viewer || !Number.isFinite(evidencePage) || evidencePage <= 0) return false;
      const pages = Array.from(viewer.querySelectorAll('section.docx, section.mnote-docx, .pptx-preview-slide-wrapper'));
      const target = pages[Math.max(0, Math.min(pages.length - 1, evidencePage - 1))];
      return markEvidenceTarget(target);
    }}

    async function applyEvidenceLocator() {{
      if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox && !evidenceText && !evidenceParagraphOrdinal && !evidenceTextFingerprint)) return;
      try {{
        const sourceMap = await fetchEvidenceSourceMap();
        const block = findEvidenceBlockInSourceMap(sourceMap);
        if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
        if (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) return;
        if (block && scrollToEvidenceParagraph(block.text)) return;
        if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
        if (block && scrollToEvidenceTextCandidates(block.text)) return;
        if (scrollToEvidenceCoordinate(sourceMap, block)) return;
      }} catch (_) {{}}
      if (evidenceText && scrollToEvidenceParagraph(evidenceText)) return;
      if (evidenceParagraphOrdinal && scrollToEvidenceParagraphOrdinal()) return;
      if (evidenceText && scrollToEvidenceTextCandidates(evidenceText)) return;
      scrollToEvidencePageFallback();
    }}

    function updateEvidenceLocator(locator) {{
      const next = locator && typeof locator === 'object' ? locator : {{}};
      evidencePage = Number(next.page || 0);
      evidenceBbox = String(next.bbox || '');
      evidenceSourceMapPath = String(next.sourceMapPath || '');
      evidenceBlockId = String(next.blockId || '');
      evidenceParagraphOrdinal = String(next.paragraphOrdinal || '');
      evidenceParaIdStart = String(next.paraIdStart || '');
      evidenceParaIdEnd = String(next.paraIdEnd || '');
      evidenceTextFingerprint = String(next.textFingerprint || '');
      evidenceText = String(next.evidenceText || next.query || '');
      evidenceSearchQuery = String(next.searchQuery || '');
      body.dataset.evidencePage = evidencePage ? String(evidencePage) : '';
      body.dataset.evidenceBbox = evidenceBbox;
      body.dataset.evidenceSourceMapPath = evidenceSourceMapPath;
      body.dataset.evidenceBlockId = evidenceBlockId;
      body.dataset.evidenceParagraphOrdinal = evidenceParagraphOrdinal;
      body.dataset.evidenceParaIdStart = evidenceParaIdStart;
      body.dataset.evidenceParaIdEnd = evidenceParaIdEnd;
      body.dataset.evidenceTextFingerprint = evidenceTextFingerprint;
      body.dataset.evidenceText = evidenceText;
      body.dataset.evidenceSearchQuery = evidenceSearchQuery;
      document.documentElement.removeAttribute('data-mnote-office-evidence-applied');
      void applyEvidenceLocator();
    }}

    window.addEventListener('message', (event) => {{
      if (event.origin !== window.location.origin) return;
      const data = event.data && typeof event.data === 'object' ? event.data : {{}};
      if (data.type === 'mnote:office-evidence-locator') updateEvidenceLocator(data);
    }});

    async function fetchArrayBuffer() {{
      const response = await fetch(fileUrl, {{ credentials: fileUrl.startsWith('/') ? 'same-origin' : 'include' }});
      if (!response.ok) throw new Error(fileReadErrorMessage(response.status));
      return response.arrayBuffer();
    }}

    function csvRows(text) {{
      const rows = [];
      let row = [];
      let cell = '';
      let quote = false;
      for (let index = 0; index < text.length; index += 1) {{
        const ch = text[index];
        if (quote) {{
          if (ch === '"' && text[index + 1] === '"') {{
            cell += '"';
            index += 1;
          }} else if (ch === '"') {{
            quote = false;
          }} else {{
            cell += ch;
          }}
        }} else if (ch === '"') {{
          quote = true;
        }} else if (ch === ',') {{
          row.push(cell);
          cell = '';
        }} else if (ch === '\n') {{
          row.push(cell);
          rows.push(row);
          row = [];
          cell = '';
        }} else if (ch !== '\r') {{
          cell += ch;
        }}
      }}
      row.push(cell);
      rows.push(row);
      return rows;
    }}

    function renderRows(rows) {{
      const wrap = document.createElement('div');
      wrap.className = 'mnote-office-table-wrap';
      const table = document.createElement('table');
      table.className = 'mnote-office-table';
      const thead = document.createElement('thead');
      const trh = document.createElement('tr');
      const width = Math.max(1, ...rows.slice(0, 200).map(row => row.length));
      for (let col = 0; col < width; col += 1) {{
        const th = document.createElement('th');
        th.textContent = String.fromCharCode(65 + (col % 26));
        trh.append(th);
      }}
      thead.append(trh);
      table.append(thead);
      const tbody = document.createElement('tbody');
      rows.slice(0, 2000).forEach(row => {{
        const tr = document.createElement('tr');
        for (let col = 0; col < width; col += 1) {{
          const td = document.createElement('td');
          td.textContent = row[col] == null ? '' : String(row[col]);
          tr.append(td);
        }}
        tbody.append(tr);
      }});
      table.append(tbody);
      wrap.append(table);
      if (rows.length > 2000) {{
        const note = document.createElement('div');
        note.className = 'mnote-office-message';
        note.textContent = '当前 POC 仅显示前 2000 行，完整表格请用 OnlyOffice 打开。';
        viewer.append(note);
      }}
      viewer.append(wrap);
    }}

    async function renderDocx() {{
      if (!window.docx || typeof window.docx.renderAsync !== 'function') {{
        throw new Error('DOCX 预览库不可用');
      }}
      const buffer = await fetchArrayBuffer();
      viewer.replaceChildren();
      await window.docx.renderAsync(buffer, viewer, null, {{
        className: 'mnote-docx',
        inWrapper: true,
        breakPages: true,
        renderHeaders: true,
        renderFooters: true
      }});
    }}

    async function renderWorkbook() {{
      if (!window.XLSX || typeof window.XLSX.read !== 'function') {{
        throw new Error('XLSX 预览库不可用');
      }}
      const buffer = await fetchArrayBuffer();
      const workbook = window.XLSX.read(new Uint8Array(buffer), {{ type: 'array' }});
      viewer.replaceChildren();
      const tabs = document.createElement('div');
      tabs.className = 'mnote-office-sheet-tabs';
      const renderSheet = name => {{
        viewer.querySelector('.mnote-office-table-wrap')?.remove();
        tabs.querySelectorAll('button').forEach(button => button.setAttribute('aria-selected', button.dataset.sheet === name ? 'true' : 'false'));
        const rows = window.XLSX.utils.sheet_to_json(workbook.Sheets[name], {{ header: 1, raw: false, blankrows: false }});
        renderRows(rows.length ? rows : [['空工作表']]);
      }};
      workbook.SheetNames.forEach((name, index) => {{
        const button = document.createElement('button');
        button.className = 'mnote-office-sheet-tab';
        button.type = 'button';
        button.dataset.sheet = name;
        button.textContent = name;
        button.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
        button.addEventListener('click', () => renderSheet(name));
        tabs.append(button);
      }});
      viewer.append(tabs);
      renderSheet(workbook.SheetNames[0]);
    }}

    async function renderCsv() {{
      const response = await fetch(fileUrl, {{ credentials: fileUrl.startsWith('/') ? 'same-origin' : 'include' }});
      if (!response.ok) throw new Error(fileReadErrorMessage(response.status));
      viewer.replaceChildren();
      renderRows(csvRows(await response.text()));
    }}

    async function renderPptx() {{
      if (!window.pptxPreview || typeof window.pptxPreview.init !== 'function') {{
        throw new Error('PPTX 预览库不可用');
      }}
      const token = ++pptxRenderToken;
      const buffer = currentPptxBuffer || await fetchArrayBuffer();
      currentPptxBuffer = buffer;
      viewer.replaceChildren();
      const stage = document.createElement('div');
      stage.className = 'mnote-office-pptx-stage';
      viewer.append(stage);
      const width = Math.max(320, Math.floor(stage.clientWidth || viewer.clientWidth || window.innerWidth));
      const height = Math.round(width * 9 / 16);
      const previewer = window.pptxPreview.init(stage, {{
        width,
        height,
        mode: 'list'
      }});
      const result = await previewer.preview(buffer);
      if (token !== pptxRenderToken) return;
      document.documentElement.setAttribute('data-mnote-office-pptx-slide-count', String(result && result.slides ? result.slides.length : ''));
    }}

    async function refreshPreviewWidth() {{
      await loadPreviewWidthPreferences();
      if (fileType === 'pptx' && currentPptxBuffer) await renderPptx();
    }}

    window.addEventListener('message', (event) => {{
      if (event.origin !== window.location.origin) return;
      const data = event.data && typeof event.data === 'object' ? event.data : {{}};
      if (data.type === 'mnote:page-width-preference-changed') void refreshPreviewWidth();
    }});

    window.addEventListener('resize', () => {{
      if (fileType !== 'pptx' || !currentPptxBuffer) return;
      if (pptxResizeTimer) window.clearTimeout(pptxResizeTimer);
      pptxResizeTimer = window.setTimeout(() => {{
        pptxResizeTimer = 0;
        void renderPptx();
      }}, 160);
    }});

    async function main() {{
      if (!fileUrl) {{
        showMessage('Office 文件链接不可用');
        return;
      }}
      setStatus('加载中');
      showMessage('正在打开轻量预览...');
      try {{
        await loadPreviewWidthPreferences();
        if (fileType === 'docx') await renderDocx();
        else if (fileType === 'xlsx' || fileType === 'xls') await renderWorkbook();
        else if (fileType === 'csv') await renderCsv();
        else if (fileType === 'pptx') await renderPptx();
        else showMessage('当前轻量预览 POC 暂不支持 .' + fileType + '，请用 OnlyOffice 打开。');
        await applyEvidenceLocator();
        setStatus('完成');
      }} catch (error) {{
        console.warn('[mnote office preview] render failed', error);
        setStatus('打开失败');
        showMessage(error && error.message ? error.message : 'Office 轻量预览失败，请用 OnlyOffice 打开。');
      }}
    }}

    main();
  </script>
</body>
</html>"#,
        title = escape_html(file_name),
        file_url = escape_html(&file_url),
        file_name = escape_html(file_name),
        file_type = escape_html(&file_type),
        page_width_content_type = escape_html(page_width_content_type),
        workspace_id = escape_html(&workspace_id),
        source_kind = escape_html(&source_kind),
        root_uri = escape_html(&root_uri),
        document_id = escape_html(&document_id),
        target_page = escape_html(&target_page),
        target_bbox = escape_html(&target_bbox),
        target_source_map_path = escape_html(&target_source_map_path),
        target_block_id = escape_html(&target_block_id),
        target_paragraph_ordinal = escape_html(&target_paragraph_ordinal),
        target_para_id_start = escape_html(&target_para_id_start),
        target_para_id_end = escape_html(&target_para_id_end),
        target_text_fingerprint = escape_html(&target_text_fingerprint),
        target_evidence_text = escape_html(&target_evidence_text),
    );
    let mut response = Html(html).into_response();
    stamp_shell_headers(response.headers_mut(), "office-preview");
    response
}

fn office_preview_page_width_content_type(file_type: &str) -> &'static str {
    match file_type.trim().to_ascii_lowercase().as_str() {
        "xls" | "xlsx" | "ods" | "csv" => "excel",
        "ppt" | "pptx" | "odp" => "ppt",
        _ => "word",
    }
}

pub async fn office_preview_vendor_asset(
    Path(asset_path): Path<String>,
) -> Result<Response, WebError> {
    let asset_path = asset_path.trim();
    let relative = match asset_path {
        "jszip.min.js" => "node_modules/jszip/dist/jszip.min.js",
        "docx-preview.min.js" => "node_modules/docx-preview/dist/docx-preview.min.js",
        "xlsx.full.min.js" => "node_modules/@e965/xlsx/dist/xlsx.full.min.js",
        "pptx-preview.umd.js" => "node_modules/pptx-preview/dist/pptx-preview.umd.js",
        _ => {
            return Err(WebError::new(
                StatusCode::NOT_FOUND,
                "office_preview_vendor_asset_not_found",
                format!("Office preview vendor asset 不存在: {asset_path}"),
            ));
        }
    };
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../..")
        .join(relative);
    let bytes = std::fs::read(&path).map_err(|error| {
        WebError::new(
            StatusCode::SERVICE_UNAVAILABLE,
            "office_preview_vendor_asset_unavailable",
            format!("Office preview vendor asset 不可用，请先运行 npm install: {error}"),
        )
    })?;
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(Body::from(bytes))
        .map_err(|error| {
            WebError::internal(format!("Office preview vendor asset 响应构造失败: {error}"))
        })
}

pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response {
    let file_url = query.file_url.unwrap_or_default();
    let file_name = query
        .file_name
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("PDF 预览");
    let target_page = query.page.unwrap_or_default();
    let target_bbox = query.bbox.unwrap_or_default();
    let target_block_id = query.block_id.unwrap_or_default();
    let html = format!(
        r#"<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <title>{title}</title>
  <style>
    :root {{
      color-scheme: light;
      --mnote-pdf-bg: #f7f7f5;
      --mnote-pdf-panel: #ffffff;
      --mnote-pdf-border: #d8d8d2;
      --mnote-pdf-text: #242422;
      --mnote-pdf-muted: #6b6b64;
      --mnote-preview-max-width: 1180px;
    }}
    * {{ box-sizing: border-box; }}
    html, body {{ margin: 0; min-height: 100%; background: var(--mnote-pdf-bg); color: var(--mnote-pdf-text); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
    .mnote-pdf-viewer {{ width: 100%; max-width: var(--mnote-preview-max-width); margin: 0 auto; padding: 8px 12px 28px; }}
    .mnote-pdf-page {{ display: block; max-width: 100%; margin: 0 auto 14px; background: #fff; border: 1px solid var(--mnote-pdf-border); box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
    .mnote-pdf-page[data-mnote-evidence-page="true"] {{ outline: 2px solid #2563eb; outline-offset: 2px; }}
    .mnote-pdf-message {{ max-width: 720px; margin: 24px auto; padding: 16px; border: 1px solid var(--mnote-pdf-border); border-radius: 8px; background: var(--mnote-pdf-panel); color: var(--mnote-pdf-muted); font-size: 14px; line-height: 1.6; }}
    @media (max-width: 640px) {{
      .mnote-pdf-viewer {{ padding: 0 6px 18px; }}
      .mnote-pdf-page {{ margin-bottom: 8px; }}
    }}
  </style>
</head>
<body data-file-url="{file_url}" data-file-name="{file_name}" data-page-width-content-type="pdf" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-block-id="{target_block_id}">
  <main class="mnote-pdf-viewer" id="mnote-pdf-viewer"></main>
  <script type="module">
    import * as pdfjsLib from '/api/pdfjs/pdf.mjs';
    pdfjsLib.GlobalWorkerOptions.workerSrc = '/api/pdfjs/pdf.worker.mjs';
    const body = document.body;
    const viewer = document.getElementById('mnote-pdf-viewer');
    const fileUrl = body.dataset.fileUrl || '';
    const pageWidthContentType = 'pdf';
    const evidencePage = Number(body.dataset.evidencePage || 0);
    const evidenceBBox = parseEvidenceBBox(body.dataset.evidenceBbox || '');
    const activeRenderTasks = new Set();
    let pdfDocument = null;
    let disposed = false;

    function parseEvidenceBBox(value) {{
      const parts = String(value || '').split(',').map((item) => Number(item.trim()));
      if (parts.length < 4 || parts.slice(0, 4).some((item) => !Number.isFinite(item))) return null;
      return {{ x0: parts[0], y0: parts[1], x1: parts[2], y1: parts[3] }};
    }}

    function previewCssMaxWidth(mode) {{
      if (mode === 'readable') return '760px';
      if (mode === 'comfortable') return '980px';
      if (mode === 'wide') return '1180px';
      if (mode === 'full') return 'none';
      return '1180px';
    }}

    function applyPreviewWidthPreference(preferences) {{
      const table = preferences && typeof preferences === 'object' ? preferences : {{}};
      const preference = table.pdf && typeof table.pdf === 'object'
        ? table.pdf
        : {{ resolvedMode: 'wide', cssMaxWidth: '1180px', source: 'system' }};
      const resolvedMode = String(preference.resolvedMode || preference.resolved_mode || preference.mode || 'wide');
      const cssMaxWidth = String(preference.cssMaxWidth || preference.css_max_width || previewCssMaxWidth(resolvedMode));
      document.documentElement.setAttribute('data-page-width-content-type', pageWidthContentType);
      document.documentElement.setAttribute('data-page-width-resolved-mode', resolvedMode);
      document.documentElement.setAttribute('data-page-width-source', String(preference.source || 'system'));
      document.documentElement.style.setProperty('--mnote-preview-max-width', cssMaxWidth === 'none' ? 'none' : cssMaxWidth);
      if (viewer) viewer.style.maxWidth = cssMaxWidth === 'none' ? 'none' : cssMaxWidth;
    }}

    async function loadPreviewWidthPreferences() {{
      applyPreviewWidthPreference(null);
      try {{
        const response = await fetch('/api/ui/preferences/effective', {{
          credentials: 'same-origin',
          headers: {{ accept: 'application/json' }}
        }});
        const payload = await response.json().catch(() => null);
        if (response.ok && payload && payload.ok === true) {{
          applyPreviewWidthPreference(payload.result && payload.result.pageWidthPreferences);
        }}
      }} catch (_) {{}}
    }}

    function setStatus(text) {{
      document.documentElement.setAttribute('data-mnote-pdf-status', text || '');
    }}

    function showMessage(text) {{
      if (!viewer) return;
      viewer.replaceChildren();
      const message = document.createElement('div');
      message.className = 'mnote-pdf-message';
      message.textContent = text;
      viewer.append(message);
    }}

    async function renderPage(pdf, pageNumber) {{
      if (disposed || !pdf || pdf !== pdfDocument) return;
      const page = await pdf.getPage(pageNumber);
      if (disposed || pdf !== pdfDocument) return;
      const baseViewport = page.getViewport({{ scale: 1 }});
      const availableWidth = Math.max(280, (viewer ? viewer.clientWidth : window.innerWidth) - 20);
      const scale = Math.max(0.6, Math.min(2.4, availableWidth / baseViewport.width));
      const viewport = page.getViewport({{ scale }});
      const outputScale = Math.min(2.5, Math.max(2, window.devicePixelRatio || 1));
      const canvas = document.createElement('canvas');
      canvas.className = 'mnote-pdf-page';
      canvas.setAttribute('data-page-number', String(pageNumber));
      canvas.width = Math.floor(viewport.width * outputScale);
      canvas.height = Math.floor(viewport.height * outputScale);
      canvas.style.width = Math.floor(viewport.width) + 'px';
      canvas.style.height = Math.floor(viewport.height) + 'px';
      const context = canvas.getContext('2d', {{ alpha: false }});
      if (!context) return;
      if (disposed || pdf !== pdfDocument) return;
      const renderTask = page.render({{
        canvasContext: context,
        viewport,
        transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null
      }});
      activeRenderTasks.add(renderTask);
      try {{
        await renderTask.promise;
      }} finally {{
        activeRenderTasks.delete(renderTask);
      }}
      if (disposed || pdf !== pdfDocument) return;
      if (viewer) viewer.append(canvas);
      if (evidencePage === pageNumber) {{
        canvas.setAttribute('data-mnote-evidence-page', 'true');
        let evidenceTargetY = null;
        if (evidenceBBox) {{
          const normalizedMineruBox = evidenceBBox.x0 >= 0 && evidenceBBox.y0 >= 0 && evidenceBBox.x1 <= 1000 && evidenceBBox.y1 <= 1000;
          let x;
          let y;
          let width;
          let height;
          if (normalizedMineruBox) {{
            x = Math.min(evidenceBBox.x0, evidenceBBox.x1) / 1000 * viewport.width;
            y = Math.min(evidenceBBox.y0, evidenceBBox.y1) / 1000 * viewport.height;
            width = Math.max(1, Math.abs(evidenceBBox.x1 - evidenceBBox.x0) / 1000 * viewport.width);
            height = Math.max(1, Math.abs(evidenceBBox.y1 - evidenceBBox.y0) / 1000 * viewport.height);
          }} else {{
            const rect = viewport.convertToViewportRectangle([evidenceBBox.x0, evidenceBBox.y0, evidenceBBox.x1, evidenceBBox.y1]);
            x = Math.min(rect[0], rect[2]);
            y = Math.min(rect[1], rect[3]);
            width = Math.max(1, Math.abs(rect[2] - rect[0]));
            height = Math.max(1, Math.abs(rect[3] - rect[1]));
          }}
          context.save();
          context.scale(outputScale, outputScale);
          context.fillStyle = 'rgba(37, 99, 235, 0.18)';
          context.strokeStyle = 'rgba(37, 99, 235, 0.9)';
          context.lineWidth = 2;
          context.fillRect(x, y, width, height);
          context.strokeRect(x, y, width, height);
          context.restore();
          evidenceTargetY = y + height / 2;
        }}
        window.setTimeout(() => {{
          if (Number.isFinite(evidenceTargetY)) {{
            const rect = canvas.getBoundingClientRect();
            const absoluteTargetTop = rect.top + window.scrollY + evidenceTargetY;
            window.scrollTo({{ top: Math.max(0, absoluteTargetTop - window.innerHeight / 2), behavior: 'auto' }});
            return;
          }}
          canvas.scrollIntoView({{ block: 'center', inline: 'nearest' }});
        }}, 0);
      }}
    }}

    function disposePreview() {{
      disposed = true;
      for (const task of Array.from(activeRenderTasks)) {{
        try {{ task.cancel(); }} catch (_) {{}}
      }}
      activeRenderTasks.clear();
      const doomedDocument = pdfDocument;
      if (doomedDocument && typeof doomedDocument.destroy === 'function') {{
        try {{ void doomedDocument.destroy(); }} catch (_) {{}}
      }}
      pdfDocument = null;
    }}

    window.__mnotePdfPreviewDispose = disposePreview;
    window.addEventListener('pagehide', () => {{
      void disposePreview();
    }}, {{ once: true }});

    async function main() {{
      disposed = false;
      if (!fileUrl) {{
        setStatus('不可用');
        showMessage('PDF 链接不可用');
        return;
      }}
      try {{
        await loadPreviewWidthPreferences();
        const sameOrigin = fileUrl.startsWith('/') || fileUrl.startsWith(location.origin);
        const pdf = await pdfjsLib.getDocument({{ url: fileUrl, withCredentials: sameOrigin, disableWorker: true }}).promise;
        pdfDocument = pdf;
        if (viewer) viewer.replaceChildren();
        setStatus('0 / ' + pdf.numPages);
        for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {{
          if (disposed || pdf !== pdfDocument) return;
          await renderPage(pdf, pageNumber);
          setStatus(pageNumber + ' / ' + pdf.numPages);
        }}
      }} catch (error) {{
        console.warn('[mnote pdf preview] render failed', error);
        setStatus('打开失败');
        showMessage(error && error.message ? error.message : 'PDF 打开失败');
      }}
    }}

    main();
  </script>
</body>
</html>"#,
        title = escape_html(file_name),
        file_url = escape_html(&file_url),
        file_name = escape_html(file_name),
        target_page = target_page,
        target_bbox = escape_html(&target_bbox),
        target_block_id = escape_html(&target_block_id),
    );
    let mut response = Html(html).into_response();
    stamp_shell_headers(response.headers_mut(), "pdf-preview");
    response
}

pub async fn pdfjs_asset(Path(asset_path): Path<String>) -> Result<Response, WebError> {
    let asset_path = asset_path.trim();
    let relative = match asset_path {
        "pdf.mjs" => "build/pdf.mjs",
        "pdf.worker.mjs" => "build/pdf.worker.mjs",
        _ => {
            return Err(WebError::new(
                StatusCode::NOT_FOUND,
                "pdfjs_asset_not_found",
                format!("PDF.js asset 不存在: {asset_path}"),
            ));
        }
    };
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../..")
        .join("node_modules/pdfjs-dist")
        .join(relative);
    let bytes = std::fs::read(&path).map_err(|error| {
        WebError::new(
            StatusCode::SERVICE_UNAVAILABLE,
            "pdfjs_asset_unavailable",
            format!("PDF.js asset 不可用，请先运行 npm install: {error}"),
        )
    })?;
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(Body::from(bytes))
        .map_err(|error| WebError::internal(format!("PDF.js asset 响应构造失败: {error}")))
}

pub async fn resource_open_runtime_asset() -> Response {
    // include_str! 路径相对于当前源文件 (src/routes/web_shell.rs -> ../../browser/)
    const JS: &str = include_str!("../../browser/resource-open-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn local_upload_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/local-upload-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_tree_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-tree-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_page_ai_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-page-ai-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_page_ai_markdown_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-page-ai-markdown-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_page_ai_render_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-page-ai-render-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_page_ai_permission_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-page-ai-permission-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_page_ai_profile_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-page-ai-profile-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_page_ai_session_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-page-ai-session-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_page_ai_skill_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-page-ai-skill-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_page_ai_target_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-page-ai-target-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_page_settings_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-page-settings-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_shell_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-shell-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_workspace_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-workspace-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_page_tree_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-page-tree-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_tree_live_apply_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-tree-live-apply-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_filetree_open_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-filetree-open-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_filetree_command_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-filetree-command-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_filetree_upload_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-filetree-upload-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn sidebar_attachment_open_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/sidebar-attachment-open-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn filetree_keyboard_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/filetree-keyboard-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn filetree_dnd_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/filetree-dnd-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn filetree_context_menu_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/filetree-context-menu-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn filetree_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/filetree-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn filetree_selection_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/filetree-selection-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn local_folder_event_bus_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/local-folder-event-bus-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn tree_live_controller_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/tree-live-controller.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn tree_shell_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/tree-shell-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn tree_shell_render_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/tree-shell-render-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn tree_shell_page_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/tree-shell-page-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn tree_shell_state_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/tree-shell-state-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn tree_shell_icons_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/tree-shell-icons-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn tree_shell_filetree_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/tree-shell-filetree-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn tree_shell_filetree_menu_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/tree-shell-filetree-menu-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn tree_shell_filetree_dnd_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/tree-shell-filetree-dnd-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn tree_shell_picker_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/tree-shell-picker-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn tree_shell_dom_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/tree-shell-dom-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn document_conflict_panel_runtime_asset() -> Response {
    // include_str! 路径相对于当前源文件 (src/routes/web_shell.rs -> ../../browser/)
    const JS: &str = include_str!("../../browser/document-conflict-panel-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn document_pane_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/document-pane-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn document_mindmap_host_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/document-mindmap-host-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn document_resource_tab_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/document-resource-tab-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn document_session_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/document-session-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn document_slash_position_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/document-slash-position-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn document_tiptap_conversion_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/document-tiptap-conversion-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn document_editor_adapter_runtime_asset() -> Response {
    const JS: &str = include_str!("../../browser/document-editor-adapter-runtime.js");
    Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(browser_runtime_js_body(JS))
        .unwrap_or_else(|_| Response::new(Body::empty()))
}

pub async fn leptos_tiptap_manifest() -> Response {
    let manifest = json!({
        "entryAssetPath": "mnote-leptos-tiptap-spike-island.js",
        "wasmAssetPath": "mnote-leptos-tiptap-spike-island_bg.wasm",
        "assetPaths": [
            "mnote-leptos-tiptap-spike-island.js",
            "mnote-leptos-tiptap-spike-island_bg.wasm",
            "tiptap_mindmap_paragraph_runtime.js"
        ],
        "generatedRootPath": "rust/spikes/leptos-tiptap-spike/generated/island"
    });
    let mut response = Json(manifest).into_response();
    stamp_shell_headers(response.headers_mut(), "leptos-tiptap-runtime");
    response.headers_mut().insert(
        header::CACHE_CONTROL,
        HeaderValue::from_static(runtime_asset_cache_control()),
    );
    response
}

pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Response, WebError> {
    let Some(resolved) = resolve_runtime_asset_path(&asset_path) else {
        return Err(WebError::bad_request_code(
            "runtime_asset_path_invalid",
            "leptos-tiptap runtime asset 路径非法",
        ));
    };
    let bytes = match std::fs::read(&resolved) {
        Ok(bytes) => bytes,
        Err(_) => {
            let Some(lazy_resolved) = resolve_lazy_runtime_asset_path(&asset_path) else {
                return Err(WebError::new(
                    StatusCode::NOT_FOUND,
                    "runtime_asset_not_found",
                    format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
                ));
            };
            std::fs::read(&lazy_resolved).map_err(|_| {
                WebError::new(
                    StatusCode::NOT_FOUND,
                    "runtime_asset_not_found",
                    format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
                )
            })?
        }
    };
    let response = Response::builder()
        .status(StatusCode::OK)
        .header(
            header::CONTENT_TYPE,
            runtime_asset_content_type(&asset_path),
        )
        .header(header::CACHE_CONTROL, runtime_asset_cache_control())
        .header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .body(runtime_asset_body(&asset_path, bytes))
        .map_err(|error| WebError::internal(format!("runtime asset 响应构造失败: {error}")))?;
    Ok(response)
}

pub async fn page_aggregate(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Path(document_id): Path<String>,
    Query(query): Query<DocumentShellQuery>,
) -> Result<Response, WebError> {
    let aggregate = build_page_aggregate_snapshot(
        &state,
        &context,
        &document_id,
        query.workspace_id.as_deref(),
        query.source_kind.as_deref(),
        query.root_uri.as_deref(),
    )
    .await?;
    let projection_owner = aggregate.source_label();
    let mut response = (
        StatusCode::OK,
        Json(json!({
            "ok": true,
            "owner": "mnote-web",
            "schema": "mnote.page_aggregate.v1",
            "result": aggregate,
            "requestId": context.trace.request_id,
            "traceId": context.trace.trace_id,
        })),
    )
        .into_response();
    stamp_shell_headers(response.headers_mut(), "page-aggregate");
    if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") {
        if let Ok(value) = HeaderValue::from_str(projection_owner) {
            response.headers_mut().insert(name, value);
        }
    }
    Ok(response)
}

pub(crate) async fn build_page_aggregate_snapshot(
    state: &AppState,
    context: &RequestContext,
    document_id: &str,
    workspace_id: Option<&str>,
    source_kind: Option<&str>,
    root_uri: Option<&str>,
) -> Result<PageAggregate, WebError> {
    if source_kind.map(str::trim).filter(|value| !value.is_empty()) == Some("local_folder") {
        let root_uri = root_uri
            .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 root_uri_for_build = root_uri.to_string();
        let root_uri_for_preferences = root_uri_for_build.clone();
        let document_id = document_id.to_string();
        let mut aggregate = tokio::task::spawn_blocking(move || {
            #[cfg(test)]
            block_local_page_aggregate_for_test();
            resolve_local_markdown_page_aggregate(&root_uri_for_build, &document_id)
        })
        .await
        .map_err(|error| {
            WebError::internal(format!("本地 Page Aggregate 构建任务失败: {error}"))
        })??;
        annotate_local_attachment_refs_authorization(state, context, &mut aggregate)?;
        super::ui_preferences::apply_effective_page_preferences(
            state,
            context,
            &mut aggregate,
            source_kind,
            Some(root_uri_for_preferences.as_str()),
        )?;
        return Ok(aggregate);
    }

    let meta = load_document_meta_result(
        state,
        context,
        DocumentMetaQuery {
            document_id: document_id.to_string(),
            workspace_id: workspace_id.map(str::to_string),
        },
    )
    .await?;
    let content = load_document_content_result(
        state,
        context,
        DocumentContentQuery {
            document_id: document_id.to_string(),
            workspace_id: workspace_id.map(str::to_string),
        },
    )
    .await?;

    let projection = execute_runtime_query_against_data(
        context,
        workspace_id,
        RuntimeQueryEnvelopeWire {
            name: "page.aggregate.get".into(),
            payload: json!({
                "documentId": document_id,
                "workspaceId": workspace_id,
            }),
        },
        json!({
            "meta": meta,
            "content": content,
        }),
    )?;

    serde_json::from_value::<PageAggregate>(projection).map_err(|error| {
        WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}"))
    })
}

fn annotate_local_attachment_refs_authorization(
    state: &AppState,
    context: &RequestContext,
    aggregate: &mut PageAggregate,
) -> Result<(), WebError> {
    let Some(refs) = aggregate.body.attachment_refs.as_array_mut() else {
        return Ok(());
    };
    for item in refs {
        let Some(object) = item.as_object_mut() else {
            continue;
        };
        let resolved_uri = object
            .get("resolvedUri")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| value.starts_with("file://"));
        let Some(resolved_uri) = resolved_uri else {
            continue;
        };
        let kind = object
            .get("kind")
            .and_then(Value::as_str)
            .unwrap_or_default();
        let owner_root_uri = object
            .get("ownerRootUri")
            .and_then(Value::as_str)
            .map(str::trim)
            .unwrap_or_default();
        if kind == "pageLocal"
            && !owner_root_uri.is_empty()
            && (resolved_uri == owner_root_uri
                || resolved_uri
                    .strip_prefix(owner_root_uri)
                    .is_some_and(|suffix| suffix.starts_with('/')))
        {
            object.insert("authorized".to_string(), Value::Bool(true));
            continue;
        }
        let access = state
            .control_plane()
            .resolve_access(&context.auth.actor_id, resolved_uri)
            .map_err(|error| {
                WebError::internal(format!("SQLite 控制面附件授权解析失败: {error}"))
            })?;
        object.insert(
            "authorized".to_string(),
            Value::Bool(access.permission != "none"),
        );
    }
    Ok(())
}

fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) {
    let value = document_id
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
                ch
            } else {
                '_'
            }
        })
        .collect::<String>();
    if value.is_empty() {
        return;
    }
    let cookie = format!("{COOKIE_RECENT_PAGE_ID}={value}; Path=/; SameSite=Lax");
    if let Ok(value) = HeaderValue::from_str(&cookie) {
        headers.append(header::SET_COOKIE, value);
    }
}

fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
    if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("mnote-web"));
    }
    if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) {
        headers.insert(name, HeaderValue::from_static(shell));
    }
    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}

pub(crate) fn escape_html(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

pub(crate) fn escape_script_json(value: &str) -> String {
    value.replace("</script", "<\\/script")
}

pub(crate) async fn load_workspace_shell_projection(
    state: Option<&AppState>,
    config: &AppConfig,
    context: &RequestContext,
    workspace_id: &str,
    active_document_id: Option<&str>,
    default_workspace_name: &str,
) -> WorkspaceShellProjection {
    let spec = ProjectionSnapshotSpec {
        workspace_id,
        root_node_id: None,
        depth: Some(99),
        projection: KernelProjectionKind::SidebarTree,
        query: None,
        max_results: None,
    };
    let mut dataset = match load_projection_snapshot(config, context, &spec).await {
        Ok(snapshot) => snapshot.dataset,
        Err(_) if config.allow_dev_fixtures => {
            let documents = active_document_id
                .map(|document_id| {
                    json!([{
                        "id": document_id,
                        "workspace_id": workspace_id,
                        "title": "个人",
                        "parent_id": null,
                        "sort_order": 0,
                        "is_starred": false
                    }])
                })
                .unwrap_or_else(|| json!([]));
            json!({
                "active_workspace_id": workspace_id,
                "active_page_id": active_document_id,
                "workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
                "documents": documents,
                "dev_fixture": true
            })
        }
        Err(_) => json!({
            "active_workspace_id": workspace_id,
            "active_page_id": active_document_id,
            "workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
            "documents": [],
            "degraded": true,
            "degraded_reason": "projection_unavailable"
        }),
    };
    if let Some(state) = state {
        attach_sidebar_shortcuts_to_dataset(state, context, workspace_id, &mut dataset);
    }

    build_workspace_shell_projection(
        &dataset,
        workspace_id,
        active_document_id,
        default_workspace_name,
    )
}

pub(crate) fn attach_sidebar_shortcuts_to_dataset(
    state: &AppState,
    context: &RequestContext,
    workspace_id: &str,
    dataset: &mut Value,
) {
    let shortcuts = crate::routes::sidebar_shortcuts::load_sidebar_shortcut_dataset(
        state,
        context,
        workspace_id,
    );
    if shortcuts.is_empty() {
        return;
    }
    if let Some(object) = dataset.as_object_mut() {
        object.insert("sidebarShortcuts".to_string(), Value::Array(shortcuts));
    }
}

/// 加载侧栏页面树 HTML（SSR）
///
/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。
/// 如果加载失败（如 cloud/compat source 不可用），返回空字符串，侧栏静默降级为无树状态。
/// 当 allow_dev_fixtures 启用且 cloud/compat source 不可用时，使用内建示例数据展示页面树。
pub(crate) async fn load_sidebar_tree_html(
    config: &AppConfig,
    context: &RequestContext,
    workspace_id: &str,
    active_document_id: Option<&str>,
) -> Option<String> {
    let spec = ProjectionSnapshotSpec {
        workspace_id,
        root_node_id: None,
        depth: Some(99),
        projection: KernelProjectionKind::SidebarTree,
        query: None,
        max_results: None,
    };
    let result = match load_projection_snapshot(config, context, &spec).await {
        Ok(snapshot) => Some((snapshot.projection, false)),
        Err(_) if config.allow_dev_fixtures => {
            // Dev 模式降级：如果调用方已经有 active 页面，优先保留这条真实选择链。
            let documents = active_document_id
                .map(|document_id| {
                    serde_json::json!([
                        { "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 }
                    ])
                })
                .unwrap_or_else(|| {
                    serde_json::json!([
                        { "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
                        { "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 },
                        { "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 }
                    ])
                });
            let dev_dataset = serde_json::json!({
                "active_workspace_id": workspace_id,
                "documents": documents,
                "trashed_documents": [],
                "media_assets": [],
                "trashed_media_assets": [],
                "mindmap_assets": [],
                "trashed_mindmap_assets": [],
                "table_assets": [],
                "trashed_table_assets": [],
                "mindmap_docs": [],
                "mindmap_asset_children": {}
            });
            execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
                .ok()
                .map(|projection| (projection, true))
        }
        Err(_) => None,
    };
    result.map(|(projection, dev_fixture)| {
        let rows = collect_page_tree_render_rows(&projection);
        let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
            rows,
            active_node_id: active_document_id.map(ToOwned::to_owned),
            focused_node_id: None,
        });
        mark_dev_fixture_html(html, dev_fixture, "sidebar-tree")
    })
}

/// 加载文件树 HTML（SSR）
///
/// 文件树与页面树共用同一份 sidebar dataset，再由 Rust kernel 输出 file_tree projection。
pub(crate) async fn load_file_tree_html(
    config: &AppConfig,
    context: &RequestContext,
    workspace_id: &str,
    active_document_id: Option<&str>,
    active_row_id: Option<&str>,
) -> Option<String> {
    let spec = ProjectionSnapshotSpec {
        workspace_id,
        root_node_id: None,
        depth: Some(99),
        projection: KernelProjectionKind::FileTree,
        query: None,
        max_results: None,
    };
    let result = match load_projection_snapshot(config, context, &spec).await {
        Ok(snapshot) => Some((snapshot.projection, false)),
        Err(_) if config.allow_dev_fixtures => {
            let documents = active_document_id
                .map(|document_id| {
                    serde_json::json!([
                        { "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 }
                    ])
                })
                .unwrap_or_else(|| {
                    serde_json::json!([
                        { "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 },
                        { "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 }
                    ])
                });
            let dev_dataset = serde_json::json!({
                "active_workspace_id": workspace_id,
                "documents": documents,
                "trashed_documents": [],
                "media_assets": [],
                "trashed_media_assets": [],
                "mindmap_assets": [],
                "trashed_mindmap_assets": [],
                "table_assets": [],
                "trashed_table_assets": [],
                "mindmap_docs": [],
                "mindmap_asset_children": {}
            });
            execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset)
                .ok()
                .map(|projection| (projection, true))
        }
        Err(_) => None,
    };
    result.map(|(projection, dev_fixture)| {
        let rows = collect_filetree_render_rows(&projection, active_document_id, active_row_id);
        let html = render_initial_filetree_html(&FileTreeInitialRenderInput { rows });
        mark_dev_fixture_html(html, dev_fixture, "file-tree")
    })
}

fn mark_dev_fixture_html(html: String, dev_fixture: bool, kind: &'static str) -> String {
    if !dev_fixture {
        return html;
    }
    format!(
        r#"<span hidden data-mnote-dev-fixture="true" data-mnote-dev-fixture-kind="{kind}"></span>{html}"#
    )
}

pub(crate) fn render_local_sidebar_tree_html(
    root_uri: &str,
    active_document_id: Option<&str>,
) -> Result<String, WebError> {
    render_local_sidebar_tree_html_scoped(root_uri, active_document_id, None)
}

pub(crate) fn render_local_sidebar_tree_html_scoped(
    root_uri: &str,
    active_document_id: Option<&str>,
    file_tree_scope: Option<&str>,
) -> Result<String, WebError> {
    let snapshot = if let Some(scope) = file_tree_scope
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
    } else {
        load_local_folder_page_tree_snapshot(root_uri)?
    };
    Ok(render_local_sidebar_tree_html_from_snapshot(
        &snapshot,
        active_document_id,
    ))
}

pub(crate) fn render_local_sidebar_tree_html_from_snapshot(
    snapshot: &crate::routes::snapshot_support::ProjectionSnapshot,
    active_document_id: Option<&str>,
) -> String {
    let rows = collect_page_tree_render_rows(&snapshot.projection);
    render_initial_page_tree_html(&PageTreeInitialRenderInput {
        rows,
        active_node_id: active_document_id.map(ToOwned::to_owned),
        focused_node_id: None,
    })
}

pub(crate) fn render_local_file_tree_html(
    root_uri: &str,
    active_document_id: Option<&str>,
    active_row_id: Option<&str>,
) -> Result<String, WebError> {
    render_local_file_tree_html_scoped(root_uri, active_document_id, active_row_id, None)
}

pub(crate) fn render_local_file_tree_html_scoped(
    root_uri: &str,
    active_document_id: Option<&str>,
    active_row_id: Option<&str>,
    file_tree_scope: Option<&str>,
) -> Result<String, WebError> {
    let snapshot = if let Some(scope) = file_tree_scope
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        load_local_folder_file_tree_children_snapshot_with_reveal(
            root_uri,
            scope,
            active_document_id,
        )?
    } else {
        load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?
    };
    let rows =
        collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id);
    Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
        rows,
    }))
}

#[cfg(test)]
fn block_local_page_aggregate_for_test() {
    let delay_ms = LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS.load(std::sync::atomic::Ordering::SeqCst);
    if delay_ms > 0 {
        std::thread::sleep(std::time::Duration::from_millis(delay_ms));
    }
}

#[cfg(test)]
mod tests {
    use crate::app::{build_app, AppConfig, AppState};
    use crate::context::RequestContext;
    use axum::body::{to_bytes, Body};
    use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
    use control_plane::{DirectoryGrantInput, UpsertUserInput, UpsertUserUiPreferenceInput};
    use serde_json::Value;
    use std::time::{Duration, Instant};
    use tower::util::ServiceExt;

    const DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS: &str =
        include_str!("../../browser/document-editor-adapter-runtime.js");
    const DOCUMENT_MINDMAP_HOST_RUNTIME_JS: &str =
        include_str!("../../browser/document-mindmap-host-runtime.js");
    const DOCUMENT_PANE_RUNTIME_JS: &str = include_str!("../../browser/document-pane-runtime.js");
    const DOCUMENT_RESOURCE_TAB_RUNTIME_JS: &str =
        include_str!("../../browser/document-resource-tab-runtime.js");
    const DOCUMENT_SESSION_RUNTIME_JS: &str =
        include_str!("../../browser/document-session-runtime.js");
    const DOCUMENT_SLASH_POSITION_RUNTIME_JS: &str =
        include_str!("../../browser/document-slash-position-runtime.js");
    const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str =
        include_str!("../../browser/sidebar-page-settings-runtime.js");
    const SIDEBAR_TREE_RUNTIME_JS: &str = include_str!("../../browser/sidebar-tree-runtime.js");
    const DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS: &str =
        include_str!("../../browser/document-tiptap-conversion-runtime.js");

    fn app() -> axum::Router {
        build_app(AppState::new(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json: Some(
                r#"{
                  "documents:getMeta": {
                    "id": "doc_1",
                    "workspace_id": "ws_demo",
                    "title": "服务端页面",
                    "updated_at": "2026-04-18T09:30:00Z",
                    "can_edit": true,
                    "word_count": 42,
                    "character_count": 128,
                    "block_count": 3
                  },
                  "documents:getContent": {
                    "content": [{"id": "block_1", "type": "paragraph", "content": []}],
                    "editorDocument": {
                      "documentId": "doc_1",
                      "rootBlockIds": ["editor_1"],
                      "blocks": [{
                        "blockId": "editor_1",
                        "blockType": "paragraph",
                        "contentNodes": [{
                          "payload": {"type": "text", "text": "来自 editorDocument 的正文"},
                          "attrs": {}
                        }],
                        "childBlockIds": []
                      }]
                    },
                    "revision": 7,
                    "conflict_detection_key": "doc_1:7",
                    "pageSubtree": {"rootNodeId": "doc_1", "outline": []}
                  }
                }"#
                .into(),
            ),
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
        .layer(axum::middleware::from_fn(inject_test_actor))
    }

    fn app_without_test_actor() -> axum::Router {
        build_app(AppState::new(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json: Some(
                r#"{
                  "documents:getMeta": {
                    "id": "doc_1",
                    "workspace_id": "ws_demo",
                    "title": "服务端页面",
                    "updated_at": "2026-04-18T09:30:00Z",
                    "can_edit": true,
                    "word_count": 42,
                    "character_count": 128,
                    "block_count": 3
                  },
                  "documents:getContent": {
                    "content": [{"id": "block_1", "type": "paragraph", "content": []}],
                    "revision": 7,
                    "conflict_detection_key": "doc_1:7",
                    "pageSubtree": {"rootNodeId": "doc_1", "outline": []}
                  }
                }"#
                .into(),
            ),
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
    }

    fn temp_root(name: &str) -> std::path::PathBuf {
        let root = std::env::temp_dir().join(format!("{name}-{}", uuid::Uuid::new_v4().simple()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).expect("create temp root");
        root
    }

    async fn inject_test_actor(
        mut request: axum::extract::Request,
        next: axum::middleware::Next,
    ) -> axum::response::Response {
        request
            .headers_mut()
            .entry("x-mnote-actor-id")
            .or_insert(HeaderValue::from_static("user_test"));
        request
            .headers_mut()
            .entry("x-mnote-actor-type")
            .or_insert(HeaderValue::from_static("user"));
        next.run(request).await
    }

    fn init_local_workspace(root: &std::path::Path, actor_id: &str) {
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            actor_id,
            &format!("file://{}", root.display()),
        )
        .expect("init local workspace");
    }

    fn request_context(actor_id: &str, actor_type: &str) -> RequestContext {
        let mut headers = HeaderMap::new();
        headers.insert("x-mnote-actor-id", actor_id.parse().unwrap());
        headers.insert("x-mnote-actor-type", actor_type.parse().unwrap());
        RequestContext::from_http_parts(
            &Method::GET,
            &"/api/page-aggregate/local-md:test.md".parse().expect("uri"),
            &headers,
        )
    }

    fn grant_local_workspace_read_access(
        state: &AppState,
        actor_id: &str,
        root_uri: &str,
        root: &std::path::Path,
    ) {
        state
            .control_plane()
            .upsert_user(UpsertUserInput {
                id: Some(actor_id.into()),
                email: Some(format!("{actor_id}@example.com")),
                username: actor_id.into(),
                display_name: actor_id.into(),
                role: None,
                password_hash: None,
            })
            .expect("upsert sqlite reader");
        state
            .control_plane()
            .grant_directory_access(DirectoryGrantInput {
                user_id: actor_id.into(),
                workspace_id: None,
                root_uri: root_uri.into(),
                root_path: root
                    .canonicalize()
                    .expect("canonical root")
                    .display()
                    .to_string(),
                permission: "read".into(),
                recursive: true,
                capabilities: vec![],
                source: "unit-test".into(),
                created_by: None,
            })
            .expect("grant sqlite read");
    }

    fn app_with_unreachable_convex_without_fixture() -> 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: None,
            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: Some("http://127.0.0.1:9".into()),
            convex_admin_key: Some("test-admin-key".into()),
            allow_dev_fixtures: false,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
    }

    #[tokio::test]
    async fn document_shell_unauthenticated_redirects_to_auth_with_next() {
        let response = app_without_test_actor()
            .oneshot(
                Request::builder()
                    .uri("/documents/doc_1?workspaceId=ws_demo")
                    .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?next=%2Fdocuments%2Fdoc_1%3FworkspaceId%3Dws_demo")
        );
    }

    #[tokio::test]
    async fn document_shell_missing_local_markdown_redirects_to_folder_navigation() {
        let root = temp_root("mnote-document-shell-missing-local-md");
        std::fs::create_dir_all(root.join("docs")).expect("create docs");
        std::fs::write(root.join("docs").join("Existing.md"), "# Existing\n")
            .expect("write existing");
        let root_uri = format!("file://{}", root.display());
        init_local_workspace(&root, "user_test");

        let response = app()
            .oneshot(
                Request::builder()
                    .uri(format!(
                        "/documents/local-md:docs~2FMissing.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=docs"
                    ))
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

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

        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        let location = response
            .headers()
            .get(header::LOCATION)
            .and_then(|value| value.to_str().ok())
            .unwrap_or_default()
            .to_string();
        assert!(location.starts_with("/?"));
        assert!(location.contains("sourceKind=local_folder"));
        assert!(location.contains("fileTreeScope=docs"));
        assert!(location.contains("missingPage=local-md%3Adocs%7E2FMissing.md"));
    }

    #[tokio::test]
    async fn document_shell_local_folder_without_root_uri_redirects_to_navigation_home() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/documents/local-md:docs~2FPlan.md?sourceKind=local_folder")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        let location = response
            .headers()
            .get(header::LOCATION)
            .and_then(|value| value.to_str().ok())
            .unwrap_or_default();
        assert!(location.starts_with("/?"));
        assert!(location.contains("routeGuard=local_folder_root_required"));
        assert!(location.contains("missingPage=local-md%3Adocs%7E2FPlan.md"));
    }

    #[tokio::test]
    async fn document_shell_local_markdown_without_source_kind_does_not_fall_back_to_convex() {
        let response = app_with_unreachable_convex_without_fixture()
            .oneshot(
                Request::builder()
                    .uri("/documents/local-md:docs~2FPlan.md?resourceTab=primary%3A%3Aresource%3Afile%3Afile%3A%2F%2F%2Ftmp%3Adocs%2FPlan.pdf")
                    .header("x-mnote-actor-id", "user_test")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        assert_ne!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("convex_retired")
        );
        let location = response
            .headers()
            .get(header::LOCATION)
            .and_then(|value| value.to_str().ok())
            .unwrap_or_default();
        assert!(location.starts_with("/?"));
        assert!(location.contains("routeGuard=local_folder_source_required"));
        assert!(location.contains("missingPage=local-md%3Adocs%7E2FPlan.md"));
    }

    #[tokio::test]
    async fn document_shell_records_local_markdown_page_recent() {
        let root = temp_root("mnote-document-shell-record-page-recent");
        std::fs::create_dir_all(root.join("docs")).expect("create docs");
        std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n正文\n").expect("write plan");
        let root_uri = format!("file://{}", root.display());
        init_local_workspace(&root, "user_test");
        let 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: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        });
        state
            .control_plane()
            .upsert_user(UpsertUserInput {
                id: Some("user_test".to_string()),
                email: Some("user_test@example.com".to_string()),
                username: "user_test".to_string(),
                display_name: "user_test".to_string(),
                role: None,
                password_hash: None,
            })
            .expect("upsert user");
        let app = build_app(state.clone());

        let response = app
            .oneshot(
                Request::builder()
                    .uri(format!(
                        "/documents/local-md:docs~2FPlan.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=docs"
                    ))
                    .header("x-mnote-actor-id", "user_test")
                    .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 recent = state
            .control_plane()
            .list_navigation_recent("user_test", Some("page"), 10)
            .expect("list recent pages");
        assert_eq!(recent.len(), 1);
        assert_eq!(
            recent[0].document_id.as_deref(),
            Some("local-md:docs~2FPlan.md")
        );
        assert_eq!(recent[0].relative_path.as_deref(), Some("docs/Plan.md"));
        assert_eq!(recent[0].title, "Plan");
    }

    #[tokio::test]
    async fn document_shell_returns_page_aggregate_snapshot() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/documents/doc_1?workspaceId=ws_demo")
                    .header("x-mnote-actor-id", "user_test")
                    .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")
        );
        assert_eq!(
            response
                .headers()
                .get("x-mnote-web-shell")
                .and_then(|value| value.to_str().ok()),
            Some("document")
        );
        assert!(response
            .headers()
            .get_all("set-cookie")
            .iter()
            .any(|value| value
                .to_str()
                .unwrap_or_default()
                .contains("mnote_recent_page_id=doc_1")));
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("html");
        let runtime = DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS;
        assert!(html.contains("data-testid=\"wolai-sidebar\""));
        assert!(html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\""));
        assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\""));
        assert!(html.contains("data-mnote-dev-fixture-kind=\"file-tree\""));
        assert!(html.contains("data-testid=\"wolai-topbar\""));
        assert!(html.contains("data-testid=\"wolai-floating-ai\""));
        assert!(html.contains("星标置顶"));
        assert!(html.contains("我的页面"));
        assert!(html.contains("垃圾箱"));
        assert!(html.contains("模板中心"));
        assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
        assert!(html.contains("data-testid=\"mnote-page-subtree\""));
        assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\""));
        assert!(html.contains("data-editor-host=\"leptos_tiptap_island\""));
        assert!(html.contains("aria-label=\"页面标题\""));
        assert!(html.contains("data-page-title-input=\"true\""));
        assert!(html.contains("data-title-endpoint=\"/api/documents/title\""));
        assert!(html.contains("mnote.document_title_controller.v1"));
        assert!(html.contains("const currentTarget = resolveTitleTarget(input);"));
        assert!(html.contains("documentId: currentTarget.documentId"));
        assert!(html.contains(
            r#".tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title"#
        ));
        assert!(html.contains("const fileTreePageTitle = (value) => {"));
        assert!(html.contains(
            "return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;"
        ));
        assert!(html.contains(
            r#".tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title)"#
        ));
        assert!(runtime.contains("row.getAttribute('data-row-id') === `doc:${documentId}`"));
        assert!(!runtime.contains("row.getAttribute('data-row-id') === `index:${documentId}`"));
        assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title"));
        assert!(html.contains("data-testid=\"wolai-page-settings-trigger\""));
        assert!(html.contains("data-mnote-action=\"open-page-settings\""));
        assert!(html.contains("data-mnote-action=\"open-page-ai\""));
        assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\""));
        assert!(html.contains("data-testid=\"mnote-document-workspace\""));
        assert!(html.contains("data-document-pane=\"true\""));
        assert!(html.contains("data-pane-role=\"primary\""));
        assert!(html.contains("data-document-pane-resizer=\"true\""));
        assert!(html.contains("data-mnote-main-tab-strip"));
        assert!(html.contains("class=\"mnote-main-tab-badge\""));
        assert!(!html.contains(">description</span><span class=\"mnote-main-tab-title\""));
        assert!(html.contains("[data-mnote-main-tab=\"page\"] .mnote-main-tab-title"));
        assert!(runtime.contains("pageTab.setAttribute('data-document-id', documentId);"));
        assert!(html.contains("data-mnote-tab-badge-kind"));
        assert!(html.contains(r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#));
        let resource_runtime = DOCUMENT_RESOURCE_TAB_RUNTIME_JS;
        assert!(runtime.contains("document-resource-tab-runtime.js"));
        assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
        assert!(resource_runtime.contains("后台任务"));
        assert!(resource_runtime.contains("data-mnote-knowledge-rag-task-tab"));
        assert!(resource_runtime.contains("data-mnote-knowledge-rag-task-clear-completed"));
        assert!(resource_runtime.contains("role=\"progressbar\""));
        assert!(resource_runtime.contains("knowledgeRagTaskCategory(job)"));
        assert!(resource_runtime.contains("knowledgeRagTaskProgress(job)"));
        assert!(resource_runtime.contains("mnote.open_editors_snapshot.v1"));
        assert!(runtime.contains("getOpenEditorsSnapshot"));
        assert!(resource_runtime.contains("bindMainEditorTabStrip"));
        assert!(resource_runtime.contains("data-mnote-tab-strip-bound"));
        assert!(runtime.contains("currentWebShellDocumentId"));
        assert!(resource_runtime.contains(
            "const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim();"
        ));
        assert!(resource_runtime.contains(
            "if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;"
        ));
        assert!(!html
            .contains("nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId()"));
        assert!(runtime.contains("runtime.default({ module_or_path: wasmUrl })"));
        let slash_runtime = DOCUMENT_SLASH_POSITION_RUNTIME_JS;
        assert!(slash_runtime.contains("positionSlashMenuForRoot"));
        assert!(slash_runtime.contains("setSlashMenuStyle(menu, 'position', 'fixed');"));
        assert!(runtime.contains("installGlobalSlashMenuPositioning();"));
        assert!(slash_runtime.contains("data-mnote-slash-positioned', 'host'"));
        assert!(slash_runtime.contains(
            "const menu = root.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]');"
        ));
        assert!(slash_runtime.contains("const setSlashMenuInactive = (menu, inactive) =>"));
        assert!(slash_runtime.contains("const hideSlashMenusOutsideRoot = (activeRoot) =>"));
        assert!(slash_runtime.contains("data-mnote-slash-inactive"));
        assert!(
            slash_runtime.contains("if (activeRoot instanceof HTMLElement && root !== activeRoot)")
        );
        assert!(!runtime.contains(
            "|| document.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]')"
        ));
        assert!(slash_runtime.contains("data-mnote-side-target-unsupported') !== 'true'"));
        assert!(
            runtime.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);")
        );
        assert!(html.contains("data-mnote-resource-tab-host"));
        assert!(runtime.contains("openPrimaryMindmap"));
        assert!(runtime.contains("openResourceInActiveTab"));
        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!(resource_runtime.contains("/api/local-folder/resource/read"));
        assert!(resource_runtime.contains("/api/local-folder/resource/write"));
        assert!(resource_runtime.contains("normalizeEvidenceLocatorInput"));
        assert!(resource_runtime.contains("applyEvidenceLocatorToEntry"));
        assert!(resource_runtime.contains("data-mnote-evidence-bbox"));
        assert!(resource_runtime.contains("data-mnote-evidence-text-highlight"));
        assert!(runtime.contains("applyDocumentEvidenceLocatorFromUrl"));
        assert!(runtime.contains("sourceMapPath: String(url.searchParams.get('sourceMapPath')"));
        assert!(runtime.contains("blockId: String(url.searchParams.get('blockId')"));
        let sidebar_runtime = SIDEBAR_TREE_RUNTIME_JS;
        assert!(sidebar_runtime.contains("openEvidenceSearchResult"));
        assert!(sidebar_runtime.contains("data-evidence-locator"));
        let mindmap_runtime = DOCUMENT_MINDMAP_HOST_RUNTIME_JS;
        assert!(mindmap_runtime.contains("replacePrimaryPaneMindmap"));
        assert!(mindmap_runtime.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
        assert!(mindmap_runtime.contains("data-mnote-object-identity"));
        assert!(runtime.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
        assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
        assert!(html.contains("mnote.tree_live_bootstrap.v1"));
        assert!(html.contains("/api/tree/events"));
        assert!(html.contains("/api/realtime/ws"));
        let session_runtime = DOCUMENT_SESSION_RUNTIME_JS;
        assert!(session_runtime.contains("syncPageAggregateScript(session, nextAggregate);"));
        let conversion_runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS;
        assert!(conversion_runtime.contains(
            "const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {"
        ));
        assert!(conversion_runtime.contains("body?.blockDocument || body?.block_document"));
        let block_document_source_index = conversion_runtime
            .find("if (blockDocument) return 'page_aggregate.block_document';")
            .expect("blockDocument source should be explicit");
        let local_markdown_source_index = conversion_runtime
            .find("return 'local_markdown.content';")
            .expect("local markdown legacy fallback should remain explicit");
        assert!(
            block_document_source_index < local_markdown_source_index,
            "local-first 浏览器转换应优先消费 blockDocument，再降级到 body.content"
        );
        assert!(
            conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body?.content")
        );
        assert!(conversion_runtime.contains("localAttachmentClassForTiptapHref"));
        assert!(conversion_runtime.contains("mnote-uploaded-attachment-code"));
        assert!(conversion_runtime
            .contains("class: mergeClassNames(mark.attrs.class, attachmentClass)"));
        assert!(session_runtime
            .contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);"));
        assert!(session_runtime.contains(
            "const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);"
        ));
        assert!(session_runtime.contains("mnote.localFolder.selfChangeSuppressions.v1"));
        assert!(session_runtime.contains("ensureLocalFolderSelfChangeSuppressions()"));
        assert!(session_runtime.contains("markLocalFolderSelfChangeSuppression(session);"));
        assert!(session_runtime.contains("data-mnote-page-body-local-compat-fallback"));
        assert!(session_runtime.contains("data-mnote-page-body-hard-guard"));
        assert!(session_runtime.contains("local_compat_fallback"));
        assert!(runtime.contains("data-mnote-page-body-local-compat-fallback"));
        assert!(runtime.contains("data-mnote-page-body-hard-guard"));
        assert!(session_runtime.contains("const suppressibleSelfWrite = kind.includes('Create')"));
        assert!(session_runtime.contains("|| kind.includes('Modify(Data')"));
        assert!(session_runtime.contains("|| kind.includes('Modify(Any')"));
        assert!(!runtime.contains("mnote-web-document-shell"));

        // Resource open resolver contract
        assert!(resource_runtime.contains("normalizeResourceTabKind"));
        assert!(resource_runtime.contains("data-resource-tab-error"));
        assert!(resource_runtime.contains("resourceTabCloseGuardReason"));
        assert!(resource_runtime.contains("closeResourceTab"));
        assert!(resource_runtime.contains("resourceTabRegistry.delete(key)"));
        assert!(runtime.contains("data-testid=\"mnote-secondary-editor-tab-host\""));
        assert!(html.contains("data-testid=\"mnote-secondary-resource-tab-host\""));
        assert!(resource_runtime.contains("resourceTabRegistryKey(paneRole, objectIdentity)"));
        assert!(runtime.contains("openResourceInActiveTab({ ...input, paneRole: 'secondary'"));
        assert!(resource_runtime.contains("data-mnote-editor-kind=\"resource\""));
        assert!(resource_runtime.contains("const paneRole = normalizePaneRole(entry.paneRole);"));
        assert!(resource_runtime.contains("paneRole,"));
        assert!(resource_runtime.contains("markIntendedSlashRoot(entry);"));
        assert!(resource_runtime.contains("if (activeEntry) markIntendedSlashRoot(activeEntry);"));
        assert!(resource_runtime.contains("const nextKey = lastActiveResourceTabKey(role);"));
        assert!(resource_runtime.contains("if (nextTab instanceof HTMLElement) nextTab.focus();"));
        assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
        assert!(resource_runtime.contains("openInlinePdfResourceTab"));
        assert!(resource_runtime.contains("data-mnote-inline-pdf-viewer"));
        assert!(resource_runtime.contains("refreshExistingPdfResourceTab(existing, input);"));
        assert!(resource_runtime.contains("releaseInlinePdfResource"));
        assert!(
            resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")
        );
        assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;"));
        assert!(resource_runtime.contains(
            "if (officePreviewBaseHref(currentHref) !== officePreviewBaseHref(nextHref)) void openPassiveResourceTab(entry, input);"
        ));
        assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);"));
        assert!(resource_runtime.contains("syncActiveResourceWidthType(activeEntry, role);"));
        assert!(resource_runtime.contains("data-mnote-active-resource-width-type"));
        assert!(resource_runtime.contains("mnote:active-resource-tab-changed"));
        assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("activeResourceWidthPreference(options)"));
        assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote:active-resource-tab-changed"));
    }

    #[tokio::test]
    async fn page_aggregate_endpoint_returns_snapshot_contract() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response
                .headers()
                .get("x-mnote-page-aggregate-owner")
                .and_then(|value| value.to_str().ok()),
            Some("compat-join")
        );
        assert_eq!(
            response
                .headers()
                .get(header::CACHE_CONTROL)
                .and_then(|value| value.to_str().ok()),
            Some("no-store")
        );
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["owner"], "mnote-web");
        assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
        assert_eq!(payload["result"]["source"], "CompatMetaContentJoin");
        assert_eq!(payload["result"]["projectionVersion"], 1);
        assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
        assert_eq!(payload["result"]["body"]["revision"], 7);
        assert_eq!(
            payload["result"]["body"]["projectionSource"],
            "editorDocument"
        );
        assert_eq!(
            payload["result"]["body"]["blockDocument"]["rootBlockIds"][0],
            "editor_1"
        );
        assert_eq!(
            payload["result"]["body"]["blockDocument"]["blocks"][0]["text"],
            "来自 editorDocument 的正文"
        );
    }

    #[tokio::test]
    async fn mnote_browser_runtime_assets_are_cacheable() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/mnote-browser-runtime/sidebar-tree-runtime.js")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        let cache_control = response
            .headers()
            .get(header::CACHE_CONTROL)
            .and_then(|value| value.to_str().ok())
            .expect("cache-control");
        assert_ne!(cache_control, "no-store");
        assert!(
            cache_control.contains("max-age"),
            "runtime 资产应允许浏览器缓存，避免远程高延迟时每次重拉"
        );
    }

    #[tokio::test]
    async fn mnote_browser_runtime_assets_are_not_cached_during_dev_hot() {
        let _guard = crate::test_support::hermes_env_lock()
            .lock()
            .expect("env lock");
        std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/mnote-browser-runtime/tree-live-controller.js")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");

        assert_eq!(response.status(), StatusCode::OK);
        let cache_control = response
            .headers()
            .get(header::CACHE_CONTROL)
            .and_then(|value| value.to_str().ok())
            .expect("cache-control");
        assert_eq!(
            cache_control, "no-store",
            "dev:hot 下 browser runtime 不能缓存，否则热更新后仍会执行旧 JS"
        );
    }

    #[tokio::test]
    async fn mnote_browser_runtime_module_imports_carry_dev_hot_buster() {
        let _guard = crate::test_support::hermes_env_lock()
            .lock()
            .expect("env lock");
        std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/mnote-browser-runtime/sidebar-tree-runtime.js")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");

        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let js = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(
            js.contains("from './sidebar-workspace-runtime.js?devHot="),
            "dev:hot 下 browser runtime 的静态子模块 import 必须跟随同一个 cache buster"
        );
        assert!(
            js.contains("from './sidebar-filetree-command-runtime.js?devHot="),
            "文件树命令 runtime 子模块也不能复用旧缓存"
        );
    }

    #[tokio::test]
    async fn editor_runtime_preload_links_use_dev_hot_buster() {
        let _guard = crate::test_support::hermes_env_lock()
            .lock()
            .expect("env lock");
        std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
        let html = super::render_editor_runtime_preload_links();
        std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");

        assert!(
            html.contains("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js?devHot=")
        );
        assert!(html.contains(
            "/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm?devHot="
        ));
    }

    #[test]
    fn document_editor_adapter_propagates_dev_hot_to_island_runtime() {
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("new URL(import.meta.url)"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
            .contains("fetch(withDevHot('/api/leptos-tiptap-runtime/manifest.json'))"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
            .contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`)"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
            .contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}`)"));
    }

    #[tokio::test]
    async fn leptos_tiptap_entry_imports_carry_dev_hot_buster() {
        let _guard = crate::test_support::hermes_env_lock()
            .lock()
            .expect("env lock");
        std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");

        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let js = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(
            js.contains("from './snippets/leptos-tiptap-")
                && js.contains("/bridge_runtime.js?devHot="),
            "dev:hot 下 wasm-bindgen snippets 子模块不能继续使用旧缓存"
        );
        assert!(
            js.contains("from \"./snippets/mnote-leptos-tiptap-spike-")
                && js.contains("/inline0.js?devHot="),
            "无分号的 wasm-bindgen import 也必须带 cache buster"
        );
    }

    #[tokio::test]
    async fn leptos_tiptap_lazy_mindmap_runtime_asset_is_served() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/leptos-tiptap-runtime/tiptap_mindmap_paragraph_runtime.js")
                    .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 js = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(js.contains("simple-mind-map"));
        assert!(js.contains("createMindmapParagraphNodeView"));
    }

    #[tokio::test]
    async fn leptos_tiptap_runtime_assets_are_cacheable() {
        let manifest_response = app()
            .clone()
            .oneshot(
                Request::builder()
                    .uri("/api/leptos-tiptap-runtime/manifest.json")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(manifest_response.status(), StatusCode::OK);
        let manifest_cache_control = manifest_response
            .headers()
            .get(header::CACHE_CONTROL)
            .and_then(|value| value.to_str().ok())
            .expect("manifest cache-control");
        assert_ne!(manifest_cache_control, "no-store");
        assert!(
            manifest_cache_control.contains("max-age"),
            "leptos-tiptap manifest 应允许浏览器缓存，避免每次重新发现 runtime 入口"
        );

        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        let cache_control = response
            .headers()
            .get(header::CACHE_CONTROL)
            .and_then(|value| value.to_str().ok())
            .expect("cache-control");
        assert_ne!(cache_control, "no-store");
        assert!(
            cache_control.contains("max-age"),
            "leptos-tiptap runtime 资产应允许浏览器缓存，避免远程高延迟时每次重拉"
        );
    }

    #[tokio::test]
    async fn page_aggregate_endpoint_errors_without_convex_or_fixture() {
        let response = app_with_unreachable_convex_without_fixture()
            .oneshot(
                Request::builder()
                    .uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        if response.status() != StatusCode::SERVICE_UNAVAILABLE {
            let status = response.status();
            let body = to_bytes(response.into_body(), usize::MAX)
                .await
                .expect("body");
            panic!(
                "expected SERVICE_UNAVAILABLE, got {status}: {}",
                String::from_utf8_lossy(&body)
            );
        }
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("convex_retired")
        );
        assert_eq!(
            response
                .headers()
                .get("x-error-phase")
                .and_then(|value| value.to_str().ok()),
            Some("convex_query_retired")
        );
        assert_eq!(
            response
                .headers()
                .get("x-upstream-service")
                .and_then(|value| value.to_str().ok()),
            Some("legacy-cloud-retired")
        );
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["ok"], false);
        assert_eq!(payload["code"], "convex_retired");
        assert!(payload.get("schema").is_none());
        assert!(payload.get("result").is_none());
    }

    #[tokio::test]
    async fn document_shell_errors_without_convex_or_fixture() {
        let response = app_with_unreachable_convex_without_fixture()
            .oneshot(
                Request::builder()
                    .uri("/documents/doc_1?workspaceId=ws_demo")
                    .header("x-mnote-actor-id", "user_test")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        if response.status() != StatusCode::SERVICE_UNAVAILABLE {
            let status = response.status();
            let body = to_bytes(response.into_body(), usize::MAX)
                .await
                .expect("body");
            panic!(
                "expected SERVICE_UNAVAILABLE, got {status}: {}",
                String::from_utf8_lossy(&body)
            );
        }
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("convex_retired")
        );
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let text = String::from_utf8(body.to_vec()).expect("utf8");
        let payload: Value = serde_json::from_str(&text).expect("json");
        assert_eq!(payload["ok"], false);
        assert_eq!(payload["code"], "convex_retired");
        assert!(!text.contains("mnote.page_aggregate.v1"));
        assert!(!text.contains("data-mnote-dev-fixture"));
        assert!(!text.contains("data-page-aggregate-snapshot"));
    }

    #[tokio::test]
    async fn page_aggregate_endpoint_returns_local_markdown_readonly_snapshot() {
        let root =
            std::env::temp_dir().join(format!("mnote-local-page-aggregate-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("Local Aggregate")).expect("create local page bundle");
        std::fs::write(
            root.join("Local Aggregate").join("Local Aggregate.md"),
            "# Local Heading\n正文内容\n",
        )
        .expect("write local md");

        let root_uri = format!("file://{}", root.display());
        init_local_workspace(&root, "user_test");
        let response = app()
            .oneshot(
                Request::builder()
                    .uri(format!(
                        "/api/page-aggregate/local-md:Local~20Aggregate~2FLocal~20Aggregate.md?sourceKind=local_folder&rootUri={root_uri}"
                    ))
                    .header("x-mnote-actor-id", "user_test")
                    .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!(
            response
                .headers()
                .get(header::CACHE_CONTROL)
                .and_then(|value| value.to_str().ok()),
            Some("no-store")
        );
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
        assert_eq!(
            payload["result"]["identity"]["documentId"],
            "local-md:Local~20Aggregate~2FLocal~20Aggregate.md"
        );
        // 本地 Markdown 标题来自文件名；正文 H1 只作为正文内容。
        assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
        assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
        assert_eq!(payload["result"]["body"]["revision"], 0);
        assert!(payload["result"]["body"]["content"]
            .to_string()
            .contains("Local Heading"));
    }

    #[tokio::test]
    async fn page_aggregate_endpoint_allows_sqlite_granted_local_folder_read_access() {
        let root = std::env::temp_dir().join(format!(
            "mnote-local-page-aggregate-grant-{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("Grant Root")).expect("create local page bundle");
        std::fs::write(
            root.join("Grant Root").join("Grant Root.md"),
            "# Grant Heading\n授权正文\n",
        )
        .expect("write local md");

        let root_uri = format!("file://{}", root.display());
        let 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: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        });
        init_local_workspace(&root, "owner_user");
        grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);

        let context = request_context("user_test", "user");
        let aggregate = super::build_page_aggregate_snapshot(
            &state,
            &context,
            "local-md:Grant~20Root~2FGrant~20Root.md",
            None,
            Some("local_folder"),
            Some(&root_uri),
        )
        .await
        .expect("sqlite read grant can open local page aggregate");

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

        assert_eq!(
            aggregate.identity.document_id,
            "local-md:Grant~20Root~2FGrant~20Root.md"
        );
        assert_eq!(aggregate.head.title, "Grant Root");
        assert!(aggregate.body.content.to_string().contains("Grant Heading"));
    }

    #[tokio::test]
    async fn local_page_aggregate_marks_attachment_refs_authorization_from_sqlite_grants() {
        let root = temp_root("mnote-local-page-aggregate-attachment-auth-root");
        let allowed_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-allowed");
        let denied_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-denied");
        std::fs::create_dir_all(root.join("Page")).expect("create page dir");
        std::fs::write(root.join("Page").join("local.pdf"), b"local").expect("local pdf");
        let allowed_external = allowed_external_root.join("allowed.pdf");
        let denied_external = denied_external_root.join("denied.pdf");
        std::fs::write(&allowed_external, b"allowed").expect("allowed external");
        std::fs::write(&denied_external, b"denied").expect("denied external");
        std::fs::write(
            root.join("Page").join("Page.md"),
            format!(
                "[本页](./local.pdf)\n[授权外部](file://{})\n[未授权外部](file://{})\n",
                allowed_external.display(),
                denied_external.display()
            ),
        )
        .expect("write md");

        let root_uri = format!("file://{}", root.display());
        let allowed_root_uri = format!("file://{}", allowed_external_root.display());
        let 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: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        });
        init_local_workspace(&root, "owner_user");
        grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
        grant_local_workspace_read_access(&state, "user_other", &root_uri, &root);
        grant_local_workspace_read_access(
            &state,
            "user_test",
            &allowed_root_uri,
            &allowed_external_root,
        );

        let context = request_context("user_test", "user");
        let aggregate = super::build_page_aggregate_snapshot(
            &state,
            &context,
            "local-md:Page~2FPage.md",
            None,
            Some("local_folder"),
            Some(&root_uri),
        )
        .await
        .expect("aggregate with attachment refs");
        let refs = aggregate.body.attachment_refs.as_array().expect("refs");
        let by_label = |label: &str| {
            refs.iter()
                .find(|item| item["label"].as_str() == Some(label))
                .expect("attachment ref")
        };
        assert_eq!(by_label("本页")["authorized"], true);
        assert_eq!(by_label("授权外部")["authorized"], true);
        assert_eq!(by_label("未授权外部")["authorized"], false);

        let other_context = request_context("user_other", "user");
        let other_aggregate = super::build_page_aggregate_snapshot(
            &state,
            &other_context,
            "local-md:Page~2FPage.md",
            None,
            Some("local_folder"),
            Some(&root_uri),
        )
        .await
        .expect("other user aggregate with attachment refs");
        let other_refs = other_aggregate
            .body
            .attachment_refs
            .as_array()
            .expect("other refs");
        let other_allowed_external = other_refs
            .iter()
            .find(|item| item["label"].as_str() == Some("授权外部"))
            .expect("other allowed external ref");
        assert_eq!(other_allowed_external["authorized"], false);

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

    #[tokio::test]
    async fn local_page_aggregate_does_not_block_leptos_runtime_asset_request() {
        let root = std::env::temp_dir().join(format!(
            "mnote-local-page-aggregate-runtime-asset-{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("Cold Start")).expect("create local page bundle");
        std::fs::write(
            root.join("Cold Start").join("Cold Start.md"),
            "# Cold Start\n\nEditor cold start target.\n",
        )
        .expect("write local md");

        let root_uri = format!("file://{}", root.display());
        init_local_workspace(&root, "user_test");
        let app = app();
        let aggregate_app = app.clone();
        let aggregate_uri = format!(
            "/api/page-aggregate/local-md:Cold~20Start~2FCold~20Start.md?sourceKind=local_folder&rootUri={root_uri}"
        );
        super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS.store(400, std::sync::atomic::Ordering::SeqCst);
        struct ResetLocalAggregateBlock;
        impl Drop for ResetLocalAggregateBlock {
            fn drop(&mut self) {
                super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS
                    .store(0, std::sync::atomic::Ordering::SeqCst);
            }
        }
        let _reset_block = ResetLocalAggregateBlock;
        let aggregate_task = tokio::spawn(async move {
            aggregate_app
                .oneshot(
                    Request::builder()
                        .uri(aggregate_uri)
                        .header("x-mnote-actor-id", "user_test")
                        .header("x-mnote-actor-type", "user")
                        .body(Body::empty())
                        .expect("aggregate request"),
                )
                .await
                .expect("aggregate response")
        });

        let started = Instant::now();
        tokio::task::yield_now().await;
        let asset_response = app
            .oneshot(
                Request::builder()
                    .uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
                    .body(Body::empty())
                    .expect("asset request"),
            )
            .await
            .expect("asset response");
        let asset_elapsed = started.elapsed();
        let aggregate_response = aggregate_task.await.expect("aggregate task");

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

        assert_eq!(asset_response.status(), StatusCode::OK);
        assert_eq!(aggregate_response.status(), StatusCode::OK);
        assert!(
            asset_elapsed < Duration::from_millis(150),
            "leptos-tiptap runtime asset 不应被本地 Page Aggregate 冷构建阻塞，实际等待 {asset_elapsed:?}"
        );
    }

    #[tokio::test]
    async fn local_folder_page_aggregate_prefers_sqlite_ui_preference_over_default_title_header() {
        let root = std::env::temp_dir().join(format!(
            "mnote-local-page-aggregate-ui-pref-{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).expect("create local root");
        std::fs::write(root.join("README.md"), "# Readme Heading\n正文\n").expect("write local md");

        let root_uri = format!("file://{}", root.display());
        let 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: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        });
        grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
        let workspace_id =
            crate::routes::local_folder_source::local_workspace_id_from_root_uri(&root_uri)
                .expect("workspace id");
        state
            .control_plane()
            .upsert_user_ui_preference(UpsertUserUiPreferenceInput {
                id: None,
                user_id: "user_test".into(),
                workspace_id: Some(workspace_id),
                source_kind: Some("local_folder".into()),
                scope_kind: "source_family".into(),
                scope_id: "external_local_folder".into(),
                key: "hideTitleHeader".into(),
                value_json: "false".into(),
            })
            .expect("upsert title header preference");

        let context = request_context("user_test", "user");
        let aggregate = super::build_page_aggregate_snapshot(
            &state,
            &context,
            "local-md:README.md",
            None,
            Some("local_folder"),
            Some(&root_uri),
        )
        .await
        .expect("local page aggregate");

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

        assert!(!aggregate.layout.page_options.hide_title_header);
    }

    #[tokio::test]
    async fn ui_preferences_api_updates_and_returns_effective_page_options() {
        let app = app();
        let response = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("PUT")
                    .uri("/api/ui/preferences")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        serde_json::json!({
                            "workspaceId": "ws_demo",
                            "sourceKind": "convex_workspace",
                            "documentId": "doc_1",
                            "updates": {
                                "showHeadingNumbers": true,
                                "layoutDensity": "compact"
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);

        let response = app
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri("/api/ui/preferences/effective?workspaceId=ws_demo&sourceKind=convex_workspace&documentId=doc_1")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["result"]["pageOptions"]["showHeadingNumbers"], true);
        assert_eq!(payload["result"]["pageOptions"]["layoutDensity"], "compact");
        assert_eq!(payload["result"]["sources"]["showHeadingNumbers"], "global");
        assert_eq!(payload["result"]["sources"]["layoutDensity"], "workspace");
    }

    #[tokio::test]
    async fn tree_view_state_api_is_user_scoped_and_tree_scope_isolated() {
        let app = app();
        let put_response = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("PUT")
                    .uri("/api/tree/view-state")
                    .header("content-type", "application/json")
                    .header("x-mnote-actor-id", "alice")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(
                        serde_json::json!({
                            "userId": "mallory",
                            "workspaceId": "default",
                            "sourceKind": "local_folder",
                            "treeKind": "filetree",
                            "rootUri": "file:///mnt/Data1T/mnote",
                            "scope": "design",
                            "state": {
                                "schemaVersion": 1,
                                "treeKind": "filetree",
                                "rootUri": "file:///mnt/Data1T/mnote",
                                "scope": "design",
                                "expandedRelativePaths": ["design/05-editor-mainline"],
                                "expandedIds": [],
                                "selectedId": "local:folder:design/05-editor-mainline",
                                "focusedId": "",
                                "activeId": "",
                                "scrollTop": 12
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(put_response.status(), StatusCode::OK);
        let put_body = to_bytes(put_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let put_payload: Value = serde_json::from_slice(&put_body).expect("json");
        assert_eq!(put_payload["result"]["userId"], "alice");
        assert_ne!(put_payload["result"]["workspaceId"], "default");
        assert!(put_payload["result"]["scopeId"]
            .as_str()
            .expect("scope id")
            .starts_with("filetree:"));

        let get_response = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=filetree&rootUri=file:///mnt/Data1T/mnote&scope=design")
                    .header("x-mnote-actor-id", "alice")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(get_response.status(), StatusCode::OK);
        let get_body = to_bytes(get_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let get_payload: Value = serde_json::from_slice(&get_body).expect("json");
        assert_eq!(get_payload["result"]["source"], "sqlite");
        assert_eq!(
            get_payload["result"]["state"]["expandedRelativePaths"][0],
            "design/05-editor-mainline"
        );

        let page_tree_response = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=pagetree&rootUri=file:///mnt/Data1T/mnote&scope=design")
                    .header("x-mnote-actor-id", "alice")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(page_tree_response.status(), StatusCode::OK);
        let page_tree_body = to_bytes(page_tree_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let page_tree_payload: Value = serde_json::from_slice(&page_tree_body).expect("json");
        assert_eq!(page_tree_payload["result"]["source"], "default");
        assert_eq!(
            page_tree_payload["result"]["state"]["expandedRelativePaths"]
                .as_array()
                .expect("array")
                .len(),
            0
        );

        let bob_response = app
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri("/api/tree/view-state?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&treeKind=filetree&rootUri=file:///mnt/Data1T/mnote&scope=design")
                    .header("x-mnote-actor-id", "bob")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(bob_response.status(), StatusCode::OK);
        let bob_body = to_bytes(bob_response.into_body(), usize::MAX)
            .await
            .expect("body");
        let bob_payload: Value = serde_json::from_slice(&bob_body).expect("json");
        assert_eq!(bob_payload["result"]["source"], "default");
    }

    #[tokio::test]
    async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() {
        let root =
            std::env::temp_dir().join(format!("mnote-local-document-shell-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("Local Shell")).expect("create local page bundle");
        std::fs::create_dir_all(root.join("docs").join("Child Page"))
            .expect("create local child bundle");
        std::fs::write(
            root.join("Local Shell").join("Local Shell.md"),
            "# Local Shell\n正文\n",
        )
        .expect("write root md");
        std::fs::write(
            root.join("docs").join("Child Page").join("Child Page.md"),
            "# Child Page\n",
        )
        .expect("write child md");
        std::fs::write(root.join("asset.png"), b"png").expect("write asset");

        let root_uri = format!("file://{}", root.display());
        init_local_workspace(&root, "user_test");
        let response = app()
            .oneshot(
                Request::builder()
                    .uri(format!(
                        "/documents/local-md:Local~20Shell~2FLocal~20Shell.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
                    ))
                    .header("x-mnote-actor-id", "user_test")
                    .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!(
            response
                .headers()
                .get(header::CACHE_CONTROL)
                .and_then(|value| value.to_str().ok()),
            Some("no-store")
        );
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("html");
        assert!(html.contains("Local Shell"));
        assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
        assert!(html.contains("\"saveEndpoint\":\"/api/page-body/write\""));
        assert!(html.contains("Child Page"));
        assert!(html.contains("asset.png"));
        assert!(html.contains("data-row-kind="));
        assert!(html.contains("data-page-openable=\"false\""));
        assert!(html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js"));
        assert!(html.contains("/api/mnote-browser-runtime/filetree-runtime.js"));
        assert!(html.contains(r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#));
        assert!(html.contains("data-mnote-action=\"open-local-folder\""));
        let session_runtime = DOCUMENT_SESSION_RUNTIME_JS;
        assert!(session_runtime.contains("refreshSessionFromExternalFileChange"));
        assert!(session_runtime.contains("refreshSessionFromExternalChange"));
        assert!(session_runtime.contains("treeExternalConflictMessage"));
        assert!(session_runtime.contains("tree:delta"));
        assert!(session_runtime.contains("tree:resync"));
        assert!(session_runtime.contains("mnote-web-tree-live"));
        assert!(session_runtime.contains("refreshMindmapRuntimesFromTreePayload"));
        assert!(session_runtime.contains("__MNOTE_LEPTOS_MINDMAP_BRIDGES__"));
        assert!(session_runtime.contains("/api/local-folder/events"));
        assert!(session_runtime.contains("localFolderEventChannelKey"));
        assert!(session_runtime.contains("url.searchParams.set('documentId', session.documentId);"));
        assert!(session_runtime.contains(
            "if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null;"
        ));
        assert!(session_runtime.contains("new EventSource(url.toString())"));
        assert!(session_runtime.contains("localFolderEventRegistry"));
        assert!(session_runtime
            .contains("if (!response.ok) {\n        if (session.sourceKind === 'local_folder')"));
        assert!(session_runtime.contains("shouldSuppressLocalFolderSelfChange"));
        assert!(session_runtime.contains("kind.includes('Modify(Name')"));
        assert!(session_runtime.contains("targetSession.views.size === 0"));
        assert!(session_runtime.contains("session.views.size === 0"));
        assert!(session_runtime.contains("targetSession.saving"));
        assert!(session_runtime.contains("lastSelfSaveSignalAt"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
            .contains("clearSessionConflictSurface(view.session);"));
        assert!(session_runtime.contains("command: 'replaceContent'"));
        assert!(session_runtime.contains("external-change-conflict"));
        assert!(session_runtime.contains("mnote-editor-conflict-panel"));
        assert!(session_runtime.contains("mnote-conflict-accept-disk"));
        assert!(session_runtime.contains("mnote-conflict-keep-current"));
        assert!(session_runtime.contains("mnote-conflict-open-diff"));
        assert!(session_runtime.contains("mnote-conflict-merge-text"));
        assert!(session_runtime.contains("mnote-conflict-merge-save"));
        assert!(session_runtime
            .contains("runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)"));
        assert!(session_runtime.contains("agent run"));
        assert!(!session_runtime.contains(
            "setInterval(() => {\n        void pollLocalMarkdownExternalChange();\n      }, 1200);"
        ));
    }

    #[tokio::test]
    async fn document_shell_local_folder_filetree_scope_renders_scoped_page_tree() {
        let root = std::env::temp_dir().join(format!(
            "mnote-local-document-shell-scoped-{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("design").join("done")).expect("create design done");
        std::fs::write(root.join("Home.md"), "# Home\n").expect("write home");
        std::fs::write(
            root.join("design").join("done").join("Target.md"),
            "# Target\n",
        )
        .expect("write target");

        let root_uri = format!("file://{}", root.display());
        init_local_workspace(&root, "user_test");
        let response = app()
            .oneshot(
                Request::builder()
                    .uri(format!(
                        "/documents/local-md:design~2Fdone~2FTarget.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
                    ))
                    .header("x-mnote-actor-id", "user_test")
                    .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("html");
        assert!(html.contains(r#"data-node-id="local-md:design~2Fdone~2FTarget.md""#));
        assert!(
            !html.contains(r#"data-node-id="local-md:Home.md""#),
            "文档页带 fileTreeScope 时 PageTree 不应回退到 workspace root 全量扫描"
        );
    }

    #[tokio::test]
    async fn document_shell_local_folder_filetree_scope_reveals_active_file_parent_chain() {
        let root = std::env::temp_dir().join(format!(
            "mnote-local-document-shell-scoped-filetree-reveal-{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("design").join("07-ai").join("done"))
            .expect("create design done");
        std::fs::create_dir_all(root.join("design").join("05-editor-mainline"))
            .expect("create unrelated scope sibling");
        std::fs::write(
            root.join("design")
                .join("07-ai")
                .join("done")
                .join("Target.md"),
            "# Target\n",
        )
        .expect("write target");
        std::fs::write(
            root.join("design")
                .join("05-editor-mainline")
                .join("Other.md"),
            "# Other\n",
        )
        .expect("write unrelated page");

        let root_uri = format!("file://{}", root.display());
        init_local_workspace(&root, "user_test");
        let response = app()
            .oneshot(
                Request::builder()
                    .uri(format!(
                        "/documents/local-md:design~2F07-ai~2Fdone~2FTarget.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
                    ))
                    .header("x-mnote-actor-id", "user_test")
                    .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("html");
        assert!(
            html.contains(r#"data-local-relative-path="design/07-ai""#),
            "scoped FileTree 应保留 active 文档父级 07-ai"
        );
        assert!(
            html.contains(r#"data-local-relative-path="design/07-ai/done""#),
            "scoped FileTree 应只 reveal active 文档命中的 done 父链"
        );
        assert!(
            html.contains(r#"data-local-relative-path="design/07-ai/done/Target.md""#),
            "active Markdown 文件应在 scoped FileTree 首屏可见"
        );
        assert!(
            !html.contains(r#"data-local-relative-path="design/05-editor-mainline/Other.md""#),
            "不相关 sibling 目录不应被 reveal 扫入 scoped FileTree"
        );
    }

    #[test]
    fn document_conflict_panel_runtime_contains_dom_helpers() {
        const DOCUMENT_CONFLICT_PANEL_RUNTIME_JS: &str =
            include_str!("../../browser/document-conflict-panel-runtime.js");
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function clearSessionConflictSurface"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function createSessionConflictPanel"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
            .contains("data-testid', 'mnote-editor-conflict-panel"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
            .contains("data-testid', 'mnote-conflict-accept-disk"));
        assert!(
            DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("data-testid', 'mnote-conflict-open-diff")
        );
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onAcceptDisk"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onKeepCurrent"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onOpenDiff"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
            .contains("function populateSessionConflictDiffPanel"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
            .contains("data-testid', 'mnote-conflict-current-text"));
        assert!(
            DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("data-testid', 'mnote-conflict-disk-text")
        );
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
            .contains("data-testid', 'mnote-conflict-merge-use-current"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
            .contains("data-testid', 'mnote-conflict-merge-use-disk"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("currentMergeText"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onUseCurrent"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onUseDisk"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onSaveMerge"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function mountSessionConflictPanel"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
            .contains("runSessionConflictAction: runSessionConflictAction"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function runSessionConflictAction"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function acceptDiskVersion"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function keepCurrentEditorVersion"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function writeMergedConflictResult"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("acceptDiskVersion: acceptDiskVersion"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
            .contains("keepCurrentEditorVersion: keepCurrentEditorVersion"));
        assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS
            .contains("writeMergedConflictResult: writeMergedConflictResult"));
    }

    #[test]
    fn document_editor_adapter_runtime_contains_host_contracts() {
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("PANES_BOOTSTRAP_ID"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("ROOT_SELECTOR"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("BRIDGE_PROTOCOL"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-mindmap-host-runtime.js"));
        assert!(DOCUMENT_MINDMAP_HOST_RUNTIME_JS.contains("replacePrimaryPaneMindmap"));
        assert!(DOCUMENT_MINDMAP_HOST_RUNTIME_JS.contains("openMindmapResourceTab"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-pane-runtime.js"));
        assert!(DOCUMENT_PANE_RUNTIME_JS.contains("openDocumentInSecondaryPane"));
        assert!(DOCUMENT_PANE_RUNTIME_JS.contains("buildPaneRuntime"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-resource-tab-runtime.js"));
        assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("openResourceInActiveTab"));
        assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("buildOpenEditorsSnapshot"));
        assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("bindMainEditorTabStrip"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-session-runtime.js"));
        assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("createDocumentSessionRuntime"));
        assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("getOrCreateDocumentSession"));
        assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("persistSession"));
        assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
        assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("mnote:local-folder:document-changed"));
        assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
        assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:local-folder:resource-changed"));
        assert!(
            DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("data-mnote-local-ocr-event-stream-retired")
        );
        assert!(!DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("local_ocr.job.updated"));
        assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:knowledge-rag-job-updated"));
        assert!(!DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:local-ocr-job-updated"));
        assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS
            .contains("data-mnote-resource-watch-ready', 'event-bus'"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document.createElement('script')"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
            .contains("syncPageAggregateScript({ pageAggregateScriptId"));
        assert!(
            DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("data-mnote-page-options-local-write-at")
        );
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("mnote:page-aggregate-synced"));
        assert!(
            DOCUMENT_SESSION_RUNTIME_JS.contains("syncPageAggregateScript(session, nextAggregate)")
        );
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-slash-position-runtime.js"));
        assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("observeSlashMenuPosition"));
        assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("scheduleSlashMenuPosition"));
        assert!(
            DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-tiptap-conversion-runtime.js")
        );
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("enhanceEditorAttachmentLinksSoon"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
            .contains("window.__mnoteEnhanceEditorAttachmentLinks"));
        assert!(DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS.contains("legacyInlineContentToTiptap"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openSecondaryDocument"));
        assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openPrimaryMindmap"));
    }

    #[tokio::test]
    async fn sidebar_and_filetree_do_not_return_dev_fixtures_by_default() {
        let config = AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: false,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        };
        let headers = HeaderMap::new();
        let context = RequestContext::from_http_parts(
            &Method::GET,
            &"/documents/doc_1?workspaceId=ws_demo"
                .parse::<Uri>()
                .expect("uri"),
            &headers,
        );

        let sidebar_html =
            super::load_sidebar_tree_html(&config, &context, "ws_demo", Some("doc_1")).await;
        let filetree_html =
            super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1"), None).await;
        let workspace_projection = super::load_workspace_shell_projection(
            None,
            &config,
            &context,
            "ws_demo",
            Some("doc_1"),
            "个人空间",
        )
        .await;

        assert!(sidebar_html.is_none());
        assert!(filetree_html.is_none());
        assert!(workspace_projection.degraded);
        assert!(workspace_projection.my_page_items.is_empty());
        assert!(!workspace_projection.dev_fixture);
    }

    #[tokio::test]
    async fn document_shell_renders_local_markdown_attachment_name_in_html() {
        let root = std::env::temp_dir().join(format!(
            "mnote-local-document-shell-media-{}",
            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("docs").join("blocks.md"),
            "---\ntitle: Complex Title\n---\n[Spec](assets/spec.pdf)\n",
        )
        .expect("write local md");

        let root_uri = format!("file://{}", root.display());
        init_local_workspace(&root, "user_test");
        let response = app()
            .oneshot(
                Request::builder()
                    .uri(format!(
                        "/documents/local-md:docs~2Fblocks.md?sourceKind=local_folder&rootUri={root_uri}"
                    ))
                    .header("x-mnote-actor-id", "user_test")
                    .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("html");
        assert!(html.contains("Spec"));
        assert!(html.contains("assets/spec.pdf"));
        assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
        assert!(html.contains(r#"data-mnote-root-uri="file://"#));
    }

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

        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("html");
        assert!(html.contains("data-has-secondary-pane=\"true\""));
        assert!(html.contains("data-pane-role=\"secondary\""));
        assert!(html.contains("data-mnote-pane-close=\"secondary\""));
        assert!(html.contains("__MNOTE_SECONDARY_PAGE_AGGREGATE__"));
        assert!(html.contains("__MNOTE_SECONDARY_EDITOR_BOOTSTRAP__"));
        assert!(html.contains("\"paneRole\":\"secondary\""));
        assert!(html.contains("\"secondaryRequested\":true"));
        assert!(html.contains("\"secondaryInvalid\":false"));
    }

    #[tokio::test]
    async fn document_shell_bootstrap_preserves_inline_mark_conversion() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/documents/doc_1?workspaceId=ws_demo")
                    .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("html");
        assert!(html.contains(r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#));
        let runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS;
        assert!(runtime.contains("legacyInlineContentToTiptap"));
        assert!(runtime.contains("legacyStylesToTiptapMarks"));
        assert!(runtime.contains("legacyMarkArrayToTiptapMarks"));
        assert!(runtime.contains("firstNonEmptyText(block?.props?.sourcePath"));
        assert!(runtime.contains("marks.push({ type: 'bold' })"));
        assert!(runtime.contains("marks.push({ type: 'italic' })"));
        assert!(runtime.contains("marks.push({ type: 'underline' })"));
        assert!(runtime.contains("marks.push({ type: 'strike' })"));
        assert!(runtime.contains("marks.push({ type: 'code' })"));
        assert!(runtime.contains("marks.push({ type: 'link', attrs: { href } })"));
        assert!(runtime.contains("styles.link = href"));
        assert!(runtime.contains("contentNodes.map((node) => {"));
        assert!(runtime.contains("payload: { type: 'text', text"));
        assert!(runtime.contains("typeof payload.text === 'string'"));
        assert!(runtime.contains("payload.type === 'hard_break'"));
        assert!(runtime.contains("typeof body?.fileVersion === 'string'"));
        assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("expectedFileVersion: expectedFileVersion"));
        assert!(runtime.contains("node?.attrs?.mnoteBlockType === 'mindmap'"));
        assert!(runtime.contains("blockType: 'mindmap'"));
        assert!(runtime.contains("...mindmapPropsFromAttrs(node?.attrs, blockId)"));
        assert!(runtime.contains("mindmapPropsFromAttrs(block.props || {}, block.blockId)"));
        assert!(runtime.contains("mnoteBlockType: 'mindmap'"));
        assert!(runtime.contains("block.blockType === 'mindmap'"));
        assert!(runtime.contains("content: block.blockType === 'mindmap'"));
        assert!(runtime.contains("? ''"));
        assert!(!runtime.contains(
            "block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
        ));
    }

    #[test]
    fn mindmap_resize_runtime_contract_includes_dimension_attrs() {
        let runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS;
        assert!(runtime.contains("mindmapWidth"));
        assert!(runtime.contains("mindmapHeight"));
        assert!(runtime.contains("data?.mindmap_width"));
        assert!(runtime.contains("dataset.mnoteMindmapWidth"));
        assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("--mnote-mindmap-block-max-width"));
        assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("currentPageWidthPreferences().mindmap"));
    }
}
