mod bridge;
pub(crate) mod command_support;
mod compat;
pub(crate) mod dev_hot;
mod documents;
mod editor;
pub(crate) mod evidence;
mod gateway;
mod health;
mod hermes;
mod hermes_client;
mod hermes_tools;
mod kernel;
pub(crate) mod knowledge_rag;
mod local_folder_events;
mod local_folder_source;
mod local_markdown_parser;
#[allow(dead_code)]
mod local_ocr;
mod local_search_index;
mod media;
mod mindmap_api;
mod mindmap_shell;
pub(crate) mod navigation_recent;
mod onlyoffice;
pub(crate) mod onlyoffice_bridge;
mod page_ai_workflow;
mod query_support;
mod resource_trash;
mod search;
mod session;
pub(crate) mod sidebar_shortcuts;
mod snapshot_support;
mod sse;
mod stream_support;
mod tree;
mod tree_view_state;
pub(crate) mod ui_preferences;
pub(crate) mod web_shell;
mod ws;

pub(crate) use gateway::current_actor_id;
pub(crate) use local_folder_source::{
    decode_local_id_segment, ensure_local_path_read_access, ensure_local_workspace_access,
    ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
    local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
    update_local_markdown_title, write_local_markdown_page_body,
};
#[cfg(test)]
pub(crate) use local_search_index::write_local_index_settings;
pub(crate) use local_search_index::{
    effective_local_index_settings_for_root, local_index_status_with_settings,
    preview_user_local_index_settings, read_local_index_settings_or_default,
    read_user_local_index_settings, refresh_local_search_index_for_change_path_with_store,
    refresh_local_search_index_if_scheduled_due_with_store,
    refresh_local_search_index_with_settings, write_user_local_index_settings, LocalIndexSettings,
};

use crate::app::AppState;
use axum::extract::DefaultBodyLimit;
use axum::routing::{any, delete, get, post, put};
use axum::Router;

pub fn build_router(state: AppState) -> Router {
    let hermes_base_path = state.config().hermes_base_path.clone();
    let enable_debug_shell_routes = state.config().enable_debug_shell_routes;

    let mut router = Router::new()
        .route("/health", get(health::health))
        .route("/", get(gateway::root_entry))
        .route("/trash", get(gateway::trash_entry))
        .route("/favicon.ico", get(gateway::favicon))
        .route(
            "/admin/access-policy",
            get(gateway::admin_access_policy_entry),
        )
        .route(
            "/user/access-policy",
            get(gateway::user_access_policy_entry),
        )
        .route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
        .route("/search", get(search::shell))
        .route("/api/evidence/search", post(evidence::search))
        .route("/api/evidence/read", post(evidence::read))
        .route("/api/evidence/open", post(evidence::open))
        .route("/api/knowledge-rag/status", get(knowledge_rag::status))
        .route(
            "/api/knowledge-rag/pipeline-events",
            get(knowledge_rag::pipeline_events),
        )
        .route("/api/knowledge-rag/ingest", post(knowledge_rag::ingest))
        .route("/api/knowledge-rag/search", post(knowledge_rag::search))
        .route("/api/knowledge-rag/query", post(knowledge_rag::query_rag))
        .route(
            "/api/knowledge-rag/open-reference",
            post(knowledge_rag::open_reference),
        )
        .route(
            "/api/knowledge-rag/delete-source",
            post(knowledge_rag::delete_source),
        )
        .route(
            "/api/knowledge-rag/prune-registry",
            post(knowledge_rag::prune_registry),
        )
        .route(
            "/mindmap/{doc_id}/{mindmap_id}",
            get(mindmap_shell::mindmap_object_shell),
        )
        .route(
            "/api/mindmap/{doc_id}/{mindmap_id}",
            get(mindmap_api::get_mindmap)
                .post(mindmap_api::apply_mindmap_command)
                .delete(resource_trash::mindmap_delete)
                .patch(resource_trash::mindmap_trash_action),
        )
        .route(
            "/api/mindmap-trash/empty",
            post(resource_trash::mindmap_empty_trash),
        )
        .route(
            "/documents/{document_id}",
            get(web_shell::document_page_shell),
        )
        .route(
            "/api/page-aggregate/{document_id}",
            get(web_shell::page_aggregate),
        )
        .route(
            "/api/ui/preferences/effective",
            get(ui_preferences::effective_preferences),
        )
        .route(
            "/api/ui/preferences",
            put(ui_preferences::update_preferences),
        )
        .route(
            "/api/tree/view-state",
            get(tree_view_state::get_tree_view_state).put(tree_view_state::put_tree_view_state),
        )
        .route(
            "/api/leptos-tiptap-runtime/manifest.json",
            get(web_shell::leptos_tiptap_manifest),
        )
        .route(
            "/api/leptos-tiptap-runtime/{*asset_path}",
            get(web_shell::leptos_tiptap_asset),
        )
        .route(
            "/api/editor/image-placeholder.svg",
            get(web_shell::editor_image_placeholder_asset),
        )
        .route("/office-preview", get(web_shell::office_preview_page))
        .route(
            "/api/office-preview/vendor/{*asset_path}",
            get(web_shell::office_preview_vendor_asset),
        )
        .route("/pdf-preview", get(web_shell::pdf_preview_page))
        .route("/api/pdfjs/{*asset_path}", get(web_shell::pdfjs_asset))
        .route(
            "/api/mnote-browser-runtime/resource-open-runtime.js",
            get(web_shell::resource_open_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/local-upload-runtime.js",
            get(web_shell::local_upload_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/filetree-runtime.js",
            get(web_shell::filetree_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/filetree-selection-runtime.js",
            get(web_shell::filetree_selection_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/filetree-context-menu-runtime.js",
            get(web_shell::filetree_context_menu_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/filetree-dnd-runtime.js",
            get(web_shell::filetree_dnd_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/filetree-keyboard-runtime.js",
            get(web_shell::filetree_keyboard_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-shell-runtime.js",
            get(web_shell::sidebar_shell_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-workspace-runtime.js",
            get(web_shell::sidebar_workspace_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-page-tree-runtime.js",
            get(web_shell::sidebar_page_tree_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-tree-live-apply-runtime.js",
            get(web_shell::sidebar_tree_live_apply_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-filetree-open-runtime.js",
            get(web_shell::sidebar_filetree_open_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-filetree-command-runtime.js",
            get(web_shell::sidebar_filetree_command_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js",
            get(web_shell::sidebar_filetree_upload_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
            get(web_shell::sidebar_attachment_open_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
            get(web_shell::sidebar_page_ai_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
            get(web_shell::sidebar_page_ai_markdown_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
            get(web_shell::sidebar_page_ai_render_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
            get(web_shell::sidebar_page_ai_permission_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
            get(web_shell::sidebar_page_ai_profile_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
            get(web_shell::sidebar_page_ai_session_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
            get(web_shell::sidebar_page_ai_skill_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
            get(web_shell::sidebar_page_ai_target_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
            get(web_shell::sidebar_page_settings_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/sidebar-tree-runtime.js",
            get(web_shell::sidebar_tree_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/local-folder-event-bus-runtime.js",
            get(web_shell::local_folder_event_bus_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/tree-live-controller.js",
            get(web_shell::tree_live_controller_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/tree-shell-runtime.js",
            get(web_shell::tree_shell_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/tree-shell-render-runtime.js",
            get(web_shell::tree_shell_render_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/tree-shell-page-runtime.js",
            get(web_shell::tree_shell_page_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/tree-shell-state-runtime.js",
            get(web_shell::tree_shell_state_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/tree-shell-icons-runtime.js",
            get(web_shell::tree_shell_icons_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/tree-shell-filetree-runtime.js",
            get(web_shell::tree_shell_filetree_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/tree-shell-filetree-menu-runtime.js",
            get(web_shell::tree_shell_filetree_menu_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/tree-shell-filetree-dnd-runtime.js",
            get(web_shell::tree_shell_filetree_dnd_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/tree-shell-picker-runtime.js",
            get(web_shell::tree_shell_picker_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/tree-shell-dom-runtime.js",
            get(web_shell::tree_shell_dom_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/document-conflict-panel-runtime.js",
            get(web_shell::document_conflict_panel_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/document-pane-runtime.js",
            get(web_shell::document_pane_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/document-mindmap-host-runtime.js",
            get(web_shell::document_mindmap_host_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/document-resource-tab-runtime.js",
            get(web_shell::document_resource_tab_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/document-session-runtime.js",
            get(web_shell::document_session_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/document-slash-position-runtime.js",
            get(web_shell::document_slash_position_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js",
            get(web_shell::document_tiptap_conversion_runtime_asset),
        )
        .route(
            "/api/mnote-browser-runtime/document-editor-adapter-runtime.js",
            get(web_shell::document_editor_adapter_runtime_asset),
        )
        .route("/api/search/documents", post(search::documents))
        .route(
            "/api/search/local-index/refresh",
            post(search::refresh_local_index),
        )
        .route(
            "/api/search/local-index/status",
            get(search::local_index_status),
        )
        .route(
            "/api/search/local-index/settings",
            put(search::update_local_index_settings),
        )
        .route(
            "/api/search/local-index/backlinks",
            get(search::local_index_backlinks),
        )
        .route(
            "/api/search/local-index/tags",
            get(search::local_index_tags),
        )
        .route("/api/gateway/health", get(gateway::gateway_health))
        .route("/api/dev/hot-reload", get(dev_hot::hot_reload))
        .route("/api/runtime/config", get(session::runtime_config))
        .route("/api/auth", post(gateway::auth_api))
        .route("/api/auth/session", get(session::session))
        .route("/api/auth/whoami", get(session::session))
        .route("/api/auth/mnote-web-token", get(session::session))
        .route("/api/auth/session/refresh", post(session::refresh_session))
        .route(
            "/api/ai/agent-profiles",
            get(hermes_client::list_agent_profiles),
        )
        .route(
            "/api/sidebar/shortcuts",
            get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut),
        )
        .route(
            "/api/sidebar/shortcuts/{shortcut_id}",
            delete(sidebar_shortcuts::delete_shortcut),
        )
        .route(
            "/api/navigation/recent",
            get(navigation_recent::list_recent).post(navigation_recent::upsert_recent),
        )
        .route(
            "/api/admin/access-policy",
            get(local_folder_source::get_local_access_policy),
        )
        .route(
            "/api/admin/access-policy/validate-root",
            post(local_folder_source::validate_local_access_root),
        )
        .route(
            "/api/admin/access-policy/grants",
            post(local_folder_source::create_local_access_grant),
        )
        .route(
            "/api/admin/access-policy/grants/{grant_id}",
            delete(local_folder_source::delete_local_access_grant),
        )
        .route(
            "/api/user/access-policy",
            get(local_folder_source::get_user_access_policy),
        )
        .route(
            "/api/user/access-policy/grants",
            post(local_folder_source::create_user_access_grant),
        )
        .route(
            "/api/user/access-policy/grants/{grant_id}",
            delete(local_folder_source::delete_user_access_grant),
        )
        .route(
            "/api/admin/share-links",
            get(local_folder_source::get_share_links).post(local_folder_source::create_share_link),
        )
        .route(
            "/api/admin/share-links/{link_id}",
            delete(local_folder_source::delete_share_link),
        )
        .route(
            "/api/admin/share-grants",
            get(local_folder_source::get_share_grants)
                .post(local_folder_source::create_share_grant),
        )
        .route(
            "/api/admin/share-grants/{share_id}",
            delete(local_folder_source::delete_share_grant),
        )
        .route(
            "/api/user/share-grants",
            get(local_folder_source::get_user_share_grants)
                .post(local_folder_source::create_user_share_grant),
        )
        .route(
            "/api/user/share-grants/{share_id}",
            delete(local_folder_source::delete_user_share_grant),
        )
        .route(
            "/api/local-folder/shared-cache/record",
            post(local_folder_source::record_shared_cache),
        )
        .route(
            "/api/local-folder/sync/pending-change",
            post(local_folder_source::record_sync_pending_change),
        )
        .route(
            "/api/local-folder/sync/conflict-report",
            post(local_folder_source::write_sync_conflict_report),
        )
        .route("/api/ai-agent/run", post(compat::next_ai_agent_run))
        .route(
            "/api/page-ai/block-edit-workflow",
            post(page_ai_workflow::block_edit_workflow),
        )
        .route("/onlyoffice", get(onlyoffice::page))
        .route(
            "/office/{document_id}/{asset_id}",
            get(onlyoffice::object_shell),
        )
        .route("/onlyoffice-server/{*path}", any(onlyoffice::server_proxy))
        .route("/cache/{*path}", any(onlyoffice::cache_proxy))
        .route("/api/onlyoffice/sign", post(onlyoffice::sign))
        .route("/api/onlyoffice/proxy", get(onlyoffice::proxy))
        .route("/api/onlyoffice/callback", post(onlyoffice::callback))
        .route("/api/onlyoffice/forcesave", post(onlyoffice::forcesave))
        .route(
            "/api/onlyoffice/bridge/plugin/config",
            get(onlyoffice_bridge::plugin_config),
        )
        .route(
            "/api/onlyoffice/bridge/plugin/index",
            get(onlyoffice_bridge::plugin_index),
        )
        .route(
            "/api/onlyoffice/bridge/plugin/index/config.json",
            get(onlyoffice_bridge::plugin_config),
        )
        .route(
            "/api/onlyoffice/bridge/plugin/index/{state}",
            get(onlyoffice_bridge::plugin_index_with_state),
        )
        .route(
            "/api/onlyoffice/bridge/session",
            post(onlyoffice_bridge::register_session),
        )
        .route(
            "/api/onlyoffice/bridge/session/current",
            get(onlyoffice_bridge::current_session),
        )
        .route(
            "/api/onlyoffice/bridge/session/close",
            post(onlyoffice_bridge::close_session),
        )
        .route(
            "/api/onlyoffice/bridge/sessions",
            get(onlyoffice_bridge::list_sessions),
        )
        .route(
            "/api/onlyoffice/bridge/capabilities",
            get(onlyoffice_bridge::capabilities),
        )
        .route(
            "/api/page-ai/agents/descriptors",
            get(hermes_client::list_agent_descriptors),
        )
        .route("/api/page-ai/runs", post(hermes_client::create_page_ai_run))
        .route(
            "/api/page-ai/runs/{host_run_id}",
            get(hermes_client::get_page_ai_run),
        )
        .route(
            "/api/page-ai/runs/{host_run_id}/events",
            get(hermes_client::list_page_ai_run_events),
        )
        .route(
            "/api/page-ai/sessions/{session_id}/active-run",
            get(hermes_client::get_page_ai_session_active_run),
        )
        .route(
            "/api/onlyoffice/bridge/commands",
            post(onlyoffice_bridge::enqueue_command),
        )
        .route(
            "/api/onlyoffice/bridge/commands/next",
            get(onlyoffice_bridge::next_command),
        )
        .route(
            "/api/onlyoffice/bridge/results",
            get(onlyoffice_bridge::get_result).post(onlyoffice_bridge::post_result),
        )
        .route("/api/media/upload", post(media::upload))
        .route("/api/media/sign", get(media::sign))
        .route("/api/media/batch", post(resource_trash::media_batch))
        .route("/api/media/purge", post(resource_trash::media_purge))
        .route(
            "/api/media/empty-trash",
            post(resource_trash::media_empty_trash),
        )
        .route("/api/tables/restore", post(resource_trash::table_restore))
        .route("/api/tables/create", post(resource_trash::table_create))
        .route(
            "/api/tables/{table_id}",
            delete(resource_trash::table_delete),
        )
        .route("/api/tables/purge", post(resource_trash::table_purge))
        .route(
            "/api/tables/empty-trash",
            post(resource_trash::table_empty_trash),
        )
        .route(
            "/api/tree/filetree/upload-target-preflight",
            post(media::filetree_upload_target_preflight),
        )
        .route(
            "/api/tree/filetree/drop-preflight",
            post(tree::filetree_drop_preflight),
        )
        .route("/api/documents/meta", get(documents::meta))
        .route("/api/documents/content", get(documents::content))
        .route("/api/documents/buffer-state", get(documents::buffer_state))
        .route(
            "/api/documents/buffer-state/dirty",
            post(documents::mark_buffer_dirty),
        )
        .route("/api/documents/purge", post(documents::purge))
        .route("/api/documents/empty-trash", post(documents::empty_trash))
        .route("/api/documents/title", post(documents::title))
        .route("/api/documents/options", post(documents::options))
        .route("/api/documents/save", post(documents::save))
        .route("/api/page-body/write", post(documents::page_body_write))
        .route(
            "/api/documents/runtime/transform",
            post(editor::transform_runtime_snapshot),
        )
        .route(
            "/api/tree/projections/sidebar",
            get(kernel::project_tree_sidebar),
        )
        .route("/api/tree/projections/page", get(kernel::project_tree_page))
        .route("/api/tree/projections/file", get(kernel::project_tree_file))
        .route(
            "/api/tree/projections/file/children",
            get(kernel::project_tree_file),
        )
        .route(
            "/api/kernel/projections/sidebar",
            get(kernel::project_sidebar),
        )
        .route("/api/kernel/subtree", get(kernel::subtree))
        .route("/api/kernel/edges", get(kernel::edges))
        .route("/api/kernel/graph", get(kernel::graph))
        .route("/api/tree/commands", post(tree::tree_command))
        .route(
            "/api/tree/local-folder-watch",
            get(tree::local_folder_watch),
        )
        .route(
            "/api/local-folder/events",
            get(local_folder_events::local_folder_events),
        )
        .route(
            "/api/local-folder/ocr/jobs",
            any(knowledge_rag::retired_local_ocr_endpoint),
        )
        .route(
            "/api/local-folder/ocr/status",
            any(knowledge_rag::retired_local_ocr_endpoint),
        )
        .route(
            "/api/local-folder/ocr/read",
            any(knowledge_rag::retired_local_ocr_endpoint),
        )
        .route(
            "/api/local-folder/ocr/insert",
            any(knowledge_rag::retired_local_ocr_endpoint),
        )
        .route(
            "/api/local-folder/ocr/delete",
            any(knowledge_rag::retired_local_ocr_endpoint),
        )
        .route(
            "/api/local-folder/workspaces/default",
            post(local_folder_source::create_default_local_workspace),
        )
        .route(
            "/api/local-folder/assets/upload",
            post(local_folder_source::upload_local_markdown_asset)
                .layer(DefaultBodyLimit::max(128 * 1024 * 1024)),
        )
        .route(
            "/api/local-folder/files/open",
            get(local_folder_source::open_local_file),
        )
        .route(
            "/api/local-folder/files/stat",
            get(local_folder_source::stat_local_file),
        )
        .route(
            "/api/local-folder/resource/read",
            get(local_folder_source::read_local_resource),
        )
        .route(
            "/api/local-folder/resource/write",
            post(local_folder_source::write_local_resource),
        )
        .route(
            "/api/tree/runtime/reduce",
            post(tree::reduce_tree_shell_runtime),
        )
        .route("/api/bridge/workspace", get(bridge::workspace))
        .route("/api/bridge/request", get(bridge::request))
        .route("/api/bridge/trace", get(bridge::trace))
        .route("/api/tree/events", get(sse::tree_events))
        .route("/api/stream/events", get(sse::events))
        .route("/api/realtime/ws", get(ws::socket))
        .nest(
            &hermes_base_path,
            Router::new()
                .route("/health", get(hermes::health))
                .route("/bridge", post(hermes::bridge_runtime))
                .route(
                    "/client/sessions",
                    get(hermes_client::list_sessions).post(hermes_client::create_session),
                )
                .route(
                    "/client/sessions/search",
                    get(hermes_client::search_sessions),
                )
                .route(
                    "/client/sessions/{session_id}",
                    get(hermes_client::get_session).delete(hermes_client::delete_session),
                )
                .route(
                    "/client/sessions/{session_id}/resume",
                    post(hermes_client::resume_session),
                )
                .route(
                    "/client/sessions/{session_id}/rename",
                    post(hermes_client::rename_session),
                )
                .route(
                    "/client/sessions/{session_id}/auto-title",
                    post(hermes_client::auto_title_session),
                )
                .route("/client/gateway/health", get(hermes_client::gateway_health))
                .route("/client/profiles", get(hermes_client::list_profiles))
                .route(
                    "/client/profiles/active",
                    put(hermes_client::switch_active_profile),
                )
                .route(
                    "/client/profiles/{profile_name}",
                    get(hermes_client::get_profile),
                )
                .route(
                    "/client/profile-memory",
                    get(hermes_client::get_profile_memory).post(hermes_client::save_profile_memory),
                )
                .route("/client/skills", get(hermes_client::list_skills))
                .route("/client/skills/toggle", put(hermes_client::toggle_skill))
                .route(
                    "/client/capabilities",
                    get(hermes_client::list_capabilities),
                )
                .route(
                    "/client/capabilities/toggle",
                    put(hermes_client::toggle_capability),
                )
                .route("/client/tools/toggle", put(hermes_client::toggle_tool))
                .route("/client/runs", post(hermes_client::create_run))
                .route(
                    "/client/sessions/{session_id}/queue/{queue_id}",
                    delete(hermes_client::cancel_queued_run),
                )
                .route("/client/events/{run_id}", get(hermes_client::stream_events))
                .route(
                    "/client/runs/{run_id}/abort",
                    post(hermes_client::abort_run),
                )
                .route(
                    "/client/runs/{run_id}/resolve-permission",
                    post(hermes_client::resolve_permission),
                )
                .route("/client/models", get(hermes_client::list_models))
                .route("/client/tools", get(hermes_client::list_tools)),
        )
        .nest(
            "/api/hermes/tools",
            Router::new()
                .route("/mnote/manifest", get(hermes_tools::mnote_manifest))
                .route("/mnote/call", post(hermes_tools::mnote_call))
                .route("/mnote/audit", get(hermes_tools::mnote_audit)),
        );

    if enable_debug_shell_routes {
        router = router
            .route("/tree", get(tree::tree_shell))
            .route("/document-debug", get(editor::document_editor_shell));
    }

    router.with_state(state)
}

#[cfg(test)]
mod tests {
    use super::build_router;
    use crate::app::{AppConfig, AppState};
    use crate::context::RequestContext;
    use axum::body::{to_bytes, Body};
    use axum::http::{Request, StatusCode};
    use axum::Extension;
    use axum::Router;
    use serde_json::{json, Value};
    use std::fs;
    use tower::ServiceExt;

    fn app(enable_debug_shell_routes: bool) -> Router {
        build_router(AppState::new(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: false,
            enable_debug_shell_routes,
            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(),
        }))
    }

    #[tokio::test]
    async fn debug_shell_routes_are_not_mounted_by_default() {
        for path in ["/tree", "/document-debug"] {
            let response = app(false)
                .oneshot(
                    Request::builder()
                        .uri(path)
                        .body(Body::empty())
                        .expect("request"),
                )
                .await
                .expect("response");
            assert_eq!(
                response.status(),
                StatusCode::NOT_FOUND,
                "{path} 默认不应暴露"
            );
        }
    }

    #[tokio::test]
    async fn debug_shell_routes_are_only_available_when_explicitly_enabled() {
        let tree_response = app(true)
            .oneshot(
                Request::builder()
                    .uri("/tree")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_ne!(
            tree_response.status(),
            StatusCode::NOT_FOUND,
            "/tree 显式启用 debug gate 后不应继续返回 404",
        );

        let document_debug_response = app(true)
            .oneshot(
                Request::builder()
                    .uri("/document-debug")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_ne!(
            document_debug_response.status(),
            StatusCode::NOT_FOUND,
            "/document-debug 显式启用 debug gate 后不应继续返回 404",
        );
    }

    #[tokio::test]
    async fn onlyoffice_and_media_transport_routes_are_explicitly_mounted() {
        for (method, path) in [
            ("GET", "/onlyoffice"),
            ("POST", "/api/onlyoffice/sign"),
            ("GET", "/api/onlyoffice/proxy?u=x"),
            ("POST", "/api/onlyoffice/callback"),
            ("POST", "/api/onlyoffice/forcesave"),
            (
                "GET",
                "/office-preview?fileUrl=/api/local-folder/files/open&fileType=docx",
            ),
            ("GET", "/api/office-preview/vendor/jszip.min.js"),
            ("GET", "/api/office-preview/vendor/docx-preview.min.js"),
            ("GET", "/api/office-preview/vendor/xlsx.full.min.js"),
            ("GET", "/api/office-preview/vendor/pptx-preview.umd.js"),
            ("GET", "/pdf-preview?fileUrl=/api/local-folder/files/open"),
            ("GET", "/api/pdfjs/pdf.mjs"),
            ("POST", "/api/media/upload"),
            ("GET", "/api/media/sign?assetId=x"),
            ("POST", "/api/tree/filetree/upload-target-preflight"),
            (
                "GET",
                "/onlyoffice-server/web-apps/apps/api/documents/api.js",
            ),
        ] {
            let response = app(false)
                .oneshot(
                    Request::builder()
                        .method(method)
                        .uri(path)
                        .body(Body::empty())
                        .expect("request"),
                )
                .await
                .expect("response");
            assert_ne!(
                response.status(),
                StatusCode::NOT_FOUND,
                "{method} {path} 应显式挂到 Rust 3000 单入口边界",
            );
        }
    }

    #[tokio::test]
    async fn legacy_media_routes_return_retired_guard() {
        for (method, path, operation) in [
            ("POST", "/api/media/upload", "upload"),
            ("GET", "/api/media/sign?assetId=asset_1", "sign"),
        ] {
            let request = Request::builder()
                .method(method)
                .uri(path)
                .body(Body::empty())
                .expect("request");
            let context =
                RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
            let response = app(false)
                .layer(Extension(context))
                .oneshot(request)
                .await
                .expect("response");
            assert_eq!(response.status(), StatusCode::GONE);
            let body = to_bytes(response.into_body(), usize::MAX)
                .await
                .expect("body bytes");
            let payload: Value = serde_json::from_slice(&body).expect("json body");
            assert_eq!(payload["code"], "mnote_media_convex_retired");
            assert_eq!(payload["operation"], operation);
            assert_eq!(
                payload["replacement"]["upload"],
                "/api/local-folder/assets/upload"
            );
        }
    }

    #[tokio::test]
    async fn office_preview_page_serves_lightweight_viewer_shell() {
        let response = app(false)
            .oneshot(
                Request::builder()
                    .uri("/office-preview?fileUrl=/api/local-folder/files/open&fileName=report.docx&fileType=docx")
                    .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 bytes");
        let html = String::from_utf8(body.to_vec()).expect("utf8 html");
        assert!(html.contains("mnote-office-preview"));
        assert!(html.contains("/api/office-preview/vendor/jszip.min.js"));
        assert!(html.contains("/api/office-preview/vendor/docx-preview.min.js"));
        assert!(html.contains("/api/office-preview/vendor/xlsx.full.min.js"));
        assert!(!html.contains("mnote-office-toolbar"));
        assert!(!html.contains("mnote-office-button"));
        assert!(!html.contains(">下载<"));
        assert!(html.contains(r#"data-page-width-content-type="word""#));
        assert!(html.contains("applyPreviewWidthPreference"));
    }

    #[tokio::test]
    async fn office_preview_page_serves_pptx_lightweight_viewer_shell() {
        let response = app(false)
            .oneshot(
                Request::builder()
                    .uri("/office-preview?fileUrl=/api/local-folder/files/open&fileName=slides.pptx&fileType=pptx")
                    .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 bytes");
        let html = String::from_utf8(body.to_vec()).expect("utf8 html");
        assert!(html.contains(r#"data-page-width-content-type="ppt""#));
        assert!(html.contains("/api/office-preview/vendor/pptx-preview.umd.js"));
        assert!(html.contains("renderPptx"));
        assert!(html.contains("window.pptxPreview"));
        assert!(!html.contains("mnote-office-toolbar"));
    }

    #[tokio::test]
    async fn pdf_preview_page_does_not_render_visible_toolbar() {
        let response = app(false)
            .oneshot(
                Request::builder()
                    .uri("/pdf-preview?fileUrl=/api/local-folder/files/open&fileName=report.pdf&page=3&bbox=1,2,3,4&blockId=p3_b1")
                    .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 bytes");
        let html = String::from_utf8(body.to_vec()).expect("utf8 html");
        assert!(html.contains("<title>report.pdf</title>"));
        assert!(html.contains(r#"data-evidence-page="3""#));
        assert!(html.contains(r#"data-evidence-bbox="1,2,3,4""#));
        assert!(html.contains(r#"data-evidence-block-id="p3_b1""#));
        assert!(html.contains("convertToViewportRectangle"));
        assert!(html.contains("Math.max(2, window.devicePixelRatio"));
        assert!(html.contains("disableWorker: true"));
        assert!(html.contains("__mnotePdfPreviewDispose"));
        assert!(!html.contains("mnote-pdf-toolbar"));
        assert!(!html.contains("mnote-pdf-title"));
        assert!(!html.contains("mnote-pdf-button"));
        assert!(html.contains(r#"data-page-width-content-type="pdf""#));
        assert!(html.contains("applyPreviewWidthPreference"));
    }

    #[tokio::test]
    async fn pdfjs_asset_uses_mobile_compatible_pdfjs_4() {
        let response = app(false)
            .oneshot(
                Request::builder()
                    .uri("/api/pdfjs/pdf.mjs")
                    .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 bytes");
        let source = String::from_utf8(body.to_vec()).expect("utf8 js");
        assert!(
            source.contains("pdfjsVersion = 4.10.38") || source.contains("version = \"4.10.38\"")
        );
        assert!(!source.contains("getOrInsertComputed"));
    }

    #[tokio::test]
    async fn ui_preferences_api_updates_and_returns_effective_page_width_preferences() {
        let root_a =
            std::env::temp_dir().join(format!("mnote-page-width-a-{}", std::process::id()));
        let root_b =
            std::env::temp_dir().join(format!("mnote-page-width-b-{}", std::process::id()));
        let _ = fs::remove_dir_all(&root_a);
        let _ = fs::remove_dir_all(&root_b);
        fs::create_dir_all(root_a.join(".mnote")).expect("root a metadata");
        fs::create_dir_all(root_b.join(".mnote")).expect("root b metadata");
        fs::write(
            root_a.join(".mnote").join("workspace.json"),
            r#"{"workspaceId":"workspace-a","ownerId":"user_page_width","createdAt":"2026-05-28T00:00:00Z","capabilities":["local_files"]}"#,
        )
        .expect("root a manifest");
        fs::write(
            root_b.join(".mnote").join("workspace.json"),
            r#"{"workspaceId":"workspace-b","ownerId":"user_page_width","createdAt":"2026-05-28T00:00:00Z","capabilities":["local_files"]}"#,
        )
        .expect("root b manifest");
        let root_a_uri = format!("file://{}", root_a.display());
        let root_b_uri = format!("file://{}", root_b.display());
        let router = app(false);
        let mut put_request = Request::builder()
            .method("PUT")
            .uri("/api/ui/preferences")
            .header("content-type", "application/json")
            .header("x-mnote-actor-id", "user_page_width")
            .header("x-mnote-actor-type", "user")
            .body(Body::from(
                json!({
                    "workspaceId": "workspace-a",
                    "sourceKind": "local_folder",
                    "rootUri": root_a_uri,
                    "documentId": "local-md:a.md",
                    "updates": {
                        "pageWidth.default": {"mode": "comfortable", "custom": null},
                        "pageWidth.word": {"mode": "wide", "custom": null},
                        "pageWidth.excel": {"mode": "full", "custom": null}
                    }
                })
                .to_string(),
            ))
            .expect("request");
        let put_context = RequestContext::from_http_parts(
            put_request.method(),
            put_request.uri(),
            put_request.headers(),
        );
        put_request.extensions_mut().insert(put_context);
        let put_response = router
            .clone()
            .oneshot(put_request)
            .await
            .expect("put response");
        let put_status = put_response.status();
        let put_body = to_bytes(put_response.into_body(), usize::MAX)
            .await
            .expect("put body bytes");
        let put_text = String::from_utf8(put_body.to_vec()).expect("utf8 put body");
        assert_eq!(put_status, StatusCode::OK, "{put_text}");

        let mut get_request = Request::builder()
            .uri(format!("/api/ui/preferences/effective?workspaceId=workspace-b&sourceKind=local_folder&rootUri={}&documentId=local-md:b.md", root_b_uri))
            .header("x-mnote-actor-id", "user_page_width")
            .header("x-mnote-actor-type", "user")
            .body(Body::empty())
            .expect("request");
        let get_context = RequestContext::from_http_parts(
            get_request.method(),
            get_request.uri(),
            get_request.headers(),
        );
        get_request.extensions_mut().insert(get_context);
        let get_response = router.oneshot(get_request).await.expect("get response");
        assert_eq!(get_response.status(), StatusCode::OK);
        let body = to_bytes(get_response.into_body(), usize::MAX)
            .await
            .expect("body bytes");
        let payload: Value = serde_json::from_slice(&body).expect("json payload");
        assert_eq!(
            payload["result"]["pageWidthPreferences"]["default"]["mode"],
            "comfortable"
        );
        assert_eq!(
            payload["result"]["pageWidthPreferences"]["word"]["mode"],
            "wide"
        );
        assert_eq!(
            payload["result"]["pageWidthPreferences"]["excel"]["mode"],
            "full"
        );
        assert_eq!(
            payload["result"]["pageWidthPreferences"]["word"]["source"],
            "global"
        );
        let _ = fs::remove_dir_all(&root_a);
        let _ = fs::remove_dir_all(&root_b);
    }

    #[tokio::test]
    async fn ui_preferences_api_persists_ai_preferences_per_user_and_workspace() {
        let router = app(false);
        let root =
            std::env::temp_dir().join(format!("mnote-ai-preferences-{}", std::process::id()));
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(root.join(".mnote")).expect("root metadata");
        fs::write(
            root.join(".mnote").join("workspace.json"),
            r#"{"workspaceId":"workspace-ai-a","ownerId":"alice_ai_pref","createdAt":"2026-05-28T00:00:00Z","capabilities":["local_files"]}"#,
        )
        .expect("root manifest");
        let root_uri = format!("file://{}", root.display());
        let mut put_request = Request::builder()
            .method("PUT")
            .uri("/api/ui/preferences")
            .header("content-type", "application/json")
            .header("x-mnote-actor-id", "alice_ai_pref")
            .header("x-mnote-actor-type", "user")
            .body(Body::from(
                json!({
                    "workspaceId": "workspace-ai-a",
                    "sourceKind": "local_folder",
                    "rootUri": root_uri,
                    "documentId": "local-md:ai.md",
                    "updates": {
                        "ai.common.default_agent_id": "reasonix",
                        "ai.common.context_refs.default_selected": {
                            "current_page": true,
                            "folder": true
                        },
                        "ai.agent.hermes.profile_id": "mnoteai",
                        "localOcr.autoEnabled": true
                    }
                })
                .to_string(),
            ))
            .expect("request");
        let put_context = RequestContext::from_http_parts(
            put_request.method(),
            put_request.uri(),
            put_request.headers(),
        );
        put_request.extensions_mut().insert(put_context);
        let put_response = router
            .clone()
            .oneshot(put_request)
            .await
            .expect("put response");
        let put_status = put_response.status();
        let put_body = to_bytes(put_response.into_body(), usize::MAX)
            .await
            .expect("put body");
        let put_text = String::from_utf8(put_body.to_vec()).expect("put utf8");
        assert_eq!(put_status, StatusCode::OK, "{put_text}");

        let mut alice_get = Request::builder()
            .uri(format!("/api/ui/preferences/effective?workspaceId=workspace-ai-a&sourceKind=local_folder&rootUri={root_uri}&documentId=local-md:ai.md"))
            .header("x-mnote-actor-id", "alice_ai_pref")
            .header("x-mnote-actor-type", "user")
            .body(Body::empty())
            .expect("request");
        let alice_context = RequestContext::from_http_parts(
            alice_get.method(),
            alice_get.uri(),
            alice_get.headers(),
        );
        alice_get.extensions_mut().insert(alice_context);
        let alice_response = router
            .clone()
            .oneshot(alice_get)
            .await
            .expect("alice get response");
        assert_eq!(alice_response.status(), StatusCode::OK);
        let alice_body = to_bytes(alice_response.into_body(), usize::MAX)
            .await
            .expect("alice body");
        let alice_payload: Value = serde_json::from_slice(&alice_body).expect("alice json");
        assert_eq!(
            alice_payload["result"]["aiPreferences"]["ai.common.default_agent_id"],
            "reasonix"
        );
        assert_eq!(
            alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"]
                ["folder"],
            true
        );
        assert_eq!(
            alice_payload["result"]["aiPreferences"]["ai.agent.hermes.profile_id"],
            "mnoteai"
        );
        assert_eq!(
            alice_payload["result"]["localOcrPreferences"]["localOcr.autoEnabled"],
            false
        );
        assert!(alice_payload["result"]["sources"]
            .as_object()
            .and_then(|sources| sources.get("localOcr.autoEnabled"))
            .is_none());

        let mut bob_get = Request::builder()
            .uri(format!("/api/ui/preferences/effective?workspaceId=workspace-ai-a&sourceKind=local_folder&rootUri={root_uri}&documentId=local-md:ai.md"))
            .header("x-mnote-actor-id", "bob_ai_pref")
            .header("x-mnote-actor-type", "user")
            .body(Body::empty())
            .expect("request");
        let bob_context =
            RequestContext::from_http_parts(bob_get.method(), bob_get.uri(), bob_get.headers());
        bob_get.extensions_mut().insert(bob_context);
        let bob_response = router.oneshot(bob_get).await.expect("bob get response");
        assert_eq!(bob_response.status(), StatusCode::OK);
        let bob_body = to_bytes(bob_response.into_body(), usize::MAX)
            .await
            .expect("bob body");
        let bob_payload: Value = serde_json::from_slice(&bob_body).expect("bob json");
        assert!(bob_payload["result"]["aiPreferences"]
            .as_object()
            .map(|value| value.is_empty())
            .unwrap_or(false));
        assert_eq!(
            bob_payload["result"]["localOcrPreferences"]["localOcr.autoEnabled"],
            false
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn mnote_browser_runtime_assets_are_explicitly_mounted() {
        for path in [
            "/api/mnote-browser-runtime/resource-open-runtime.js",
            "/api/mnote-browser-runtime/local-upload-runtime.js",
            "/api/mnote-browser-runtime/filetree-runtime.js",
            "/api/mnote-browser-runtime/filetree-selection-runtime.js",
            "/api/mnote-browser-runtime/filetree-context-menu-runtime.js",
            "/api/mnote-browser-runtime/filetree-dnd-runtime.js",
            "/api/mnote-browser-runtime/filetree-keyboard-runtime.js",
            "/api/mnote-browser-runtime/sidebar-shell-runtime.js",
            "/api/mnote-browser-runtime/sidebar-workspace-runtime.js",
            "/api/mnote-browser-runtime/sidebar-page-tree-runtime.js",
            "/api/mnote-browser-runtime/sidebar-tree-live-apply-runtime.js",
            "/api/mnote-browser-runtime/sidebar-filetree-open-runtime.js",
            "/api/mnote-browser-runtime/sidebar-filetree-command-runtime.js",
            "/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js",
            "/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
            "/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
            "/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
            "/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
            "/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
            "/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
            "/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
            "/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
            "/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
            "/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
            "/api/mnote-browser-runtime/sidebar-tree-runtime.js",
            "/api/mnote-browser-runtime/local-folder-event-bus-runtime.js",
            "/api/mnote-browser-runtime/tree-live-controller.js",
            "/api/mnote-browser-runtime/tree-shell-runtime.js",
            "/api/mnote-browser-runtime/tree-shell-render-runtime.js",
            "/api/mnote-browser-runtime/tree-shell-page-runtime.js",
            "/api/mnote-browser-runtime/tree-shell-state-runtime.js",
            "/api/mnote-browser-runtime/tree-shell-icons-runtime.js",
            "/api/mnote-browser-runtime/tree-shell-filetree-runtime.js",
            "/api/mnote-browser-runtime/tree-shell-filetree-menu-runtime.js",
            "/api/mnote-browser-runtime/tree-shell-filetree-dnd-runtime.js",
            "/api/mnote-browser-runtime/tree-shell-picker-runtime.js",
            "/api/mnote-browser-runtime/tree-shell-dom-runtime.js",
            "/api/mnote-browser-runtime/document-conflict-panel-runtime.js",
            "/api/mnote-browser-runtime/document-pane-runtime.js",
            "/api/mnote-browser-runtime/document-mindmap-host-runtime.js",
            "/api/mnote-browser-runtime/document-resource-tab-runtime.js",
            "/api/mnote-browser-runtime/document-session-runtime.js",
            "/api/mnote-browser-runtime/document-slash-position-runtime.js",
            "/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js",
            "/api/mnote-browser-runtime/document-editor-adapter-runtime.js",
        ] {
            let response = app(false)
                .oneshot(
                    Request::builder()
                        .uri(path)
                        .body(Body::empty())
                        .expect("request"),
                )
                .await
                .expect("response");
            assert_eq!(
                response.status(),
                StatusCode::OK,
                "{path} 应返回 runtime asset"
            );
            let content_type = response
                .headers()
                .get(axum::http::header::CONTENT_TYPE)
                .and_then(|value| value.to_str().ok())
                .unwrap_or("");
            assert!(
                content_type.starts_with("application/javascript"),
                "{path} 应声明 JavaScript content-type"
            );
        }
    }

    #[tokio::test]
    async fn dev_hot_reload_endpoint_is_mounted() {
        let response = app(false)
            .oneshot(
                Request::builder()
                    .uri("/api/dev/hot-reload")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);
    }
}
