mod ai_agent_processes;
pub mod ai_agents;
mod ai_model_tools;
pub mod ai_models;
pub mod antigravity_cli;
mod antigravity_config;
mod antigravity_discovery;
mod app_config;
mod app_icon;
pub mod app_updater;
pub mod claude_cli;
mod claude_invocation;
mod cli_agent_runtime;
pub mod codex_cli;
mod commands;
pub mod copilot_cli;
mod copilot_discovery;
pub mod frontmatter;
pub mod git;
pub mod hermes_cli;
mod hermes_discovery;
pub mod kiro_cli;
mod kiro_discovery;
#[cfg(any(test, all(desktop, target_os = "linux")))]
mod linux_appimage;
pub mod mcp;
#[cfg(desktop)]
pub mod menu;
pub mod opencode_cli;
mod opencode_config;
mod opencode_discovery;
mod opencode_events;
pub mod pi_cli;
mod pi_config;
mod pi_discovery;
mod pi_events;
pub mod search;
pub mod settings;
pub mod telemetry;
pub mod vault;
pub mod vault_list;
pub mod vault_watcher;
#[cfg(desktop)]
mod window_state;

use std::ffi::OsStr;
use std::process::Command;

#[cfg(desktop)]
use std::path::{Path, PathBuf};
#[cfg(desktop)]
use std::process::Child;
#[cfg(desktop)]
use std::sync::Mutex;

#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;

pub(crate) fn hidden_command(program: impl AsRef<OsStr>) -> Command {
    let mut command = Command::new(program);
    suppress_windows_console(&mut command);
    command
}

#[cfg(windows)]
fn suppress_windows_console(command: &mut Command) {
    use std::os::windows::process::CommandExt;
    command.creation_flags(CREATE_NO_WINDOW);
}

#[cfg(not(windows))]
fn suppress_windows_console(_command: &mut Command) {}

#[cfg(desktop)]
struct WsBridgeChild(Mutex<Option<Child>>);

#[cfg(desktop)]
struct AllowedAssetScopeRoots(Mutex<Vec<PathBuf>>);

#[cfg(desktop)]
fn log_startup_result(label: &str, result: Result<usize, String>) {
    match result {
        Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
        Err(e) => log::warn!("{}: {}", label, e),
        _ => {}
    }
}

#[cfg(desktop)]
fn selected_mcp_bridge_vault_paths(vault_list: &vault_list::VaultList) -> Vec<PathBuf> {
    let mut paths = Vec::new();
    if let Some(active_vault) = vault_list
        .active_vault
        .as_deref()
        .map(str::trim)
        .filter(|path| !path.is_empty())
    {
        push_unique_mcp_bridge_vault_path(&mut paths, active_vault);
    }

    for vault in &vault_list.vaults {
        if vault.mounted == Some(false) {
            continue;
        }
        push_unique_mcp_bridge_vault_path(&mut paths, &vault.path);
    }

    paths
}

#[cfg(desktop)]
fn push_unique_mcp_bridge_vault_path(paths: &mut Vec<PathBuf>, path: &str) {
    let trimmed = path.trim();
    if trimmed.is_empty() {
        return;
    }
    let path = PathBuf::from(trimmed);
    if paths.iter().any(|existing| existing == &path) {
        return;
    }
    paths.push(path);
}

#[cfg(desktop)]
fn validate_mcp_bridge_vault_path(vault_path: &Path) -> Result<PathBuf, String> {
    let resolved = std::fs::canonicalize(vault_path).map_err(|e| {
        format!(
            "MCP bridge vault is not available: {} ({e})",
            vault_path.display()
        )
    })?;

    if !resolved.is_dir() {
        return Err(format!(
            "MCP bridge vault is not available: {} is not a directory",
            vault_path.display()
        ));
    }

    Ok(resolved)
}

#[cfg(desktop)]
fn stop_ws_bridge_child(active_child: &mut Option<Child>) {
    if let Some(mut child) = active_child.take() {
        let _ = child.kill();
        let _ = child.wait();
        log::info!("ws-bridge child process stopped");
    }
}

#[cfg(desktop)]
pub(crate) fn sync_ws_bridge_for_vault(
    app_handle: &tauri::AppHandle,
    vault_path: Option<&Path>,
    active_vault_paths: &[PathBuf],
) -> Result<&'static str, String> {
    use tauri::Manager;

    let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
    let mut active_child = state
        .0
        .lock()
        .map_err(|_| "Failed to lock ws-bridge state".to_string())?;

    let Some(vault_path) = vault_path else {
        stop_ws_bridge_child(&mut active_child);
        return Ok("stopped");
    };

    let resolved_vault_path = match validate_mcp_bridge_vault_path(vault_path) {
        Ok(path) => path,
        Err(e) => {
            stop_ws_bridge_child(&mut active_child);
            return Err(e);
        }
    };

    stop_ws_bridge_child(&mut active_child);

    let resolved_active_vault_paths = active_vault_paths
        .iter()
        .filter_map(|path| validate_mcp_bridge_vault_path(path).ok())
        .collect::<Vec<_>>();
    let child =
        mcp::spawn_ws_bridge_with_paths(&resolved_vault_path, &resolved_active_vault_paths)?;

    *active_child = Some(child);
    Ok("started")
}

fn spawn_background_task<F>(thread_name: &'static str, task: F)
where
    F: FnOnce() + Send + 'static,
{
    if let Err(e) = std::thread::Builder::new()
        .name(thread_name.into())
        .spawn(task)
    {
        log::warn!("Failed to start {thread_name}: {e}");
    }
}

/// Run startup housekeeping on the legacy default vault (migrate legacy frontmatter, seed configs).
#[cfg(desktop)]
fn run_startup_tasks_for_vault(vault_path: &Path) {
    let vp_str = vault_path.to_str().unwrap_or_default();
    log_startup_result(
        "Migrated is_a to type on startup",
        vault::migrate_is_a_to_type(vp_str),
    );
    // Migrate legacy config/agents.md -> root AGENTS.md (one-time, idempotent)
    vault::migrate_agents_md(vp_str);
    // Seed AGENTS.md and starter type definitions at vault root if missing
    vault::seed_config_files(vp_str);
}

#[cfg(desktop)]
fn spawn_startup_tasks_for_vault_with<F>(vault_path: PathBuf, task: F) -> bool
where
    F: FnOnce(PathBuf) + Send + 'static,
{
    if !vault_path.is_dir() {
        return false;
    }

    spawn_background_task("tolaria-startup-tasks", move || task(vault_path));
    true
}

#[cfg(desktop)]
fn spawn_startup_tasks() {
    let Some(vault_path) = dirs::home_dir().map(|h| h.join("Laputa")) else {
        return;
    };
    spawn_startup_tasks_for_vault_with(vault_path, |path| run_startup_tasks_for_vault(&path));
}

#[cfg(desktop)]
fn sync_ws_bridge_for_selected_vault(app_handle: &tauri::AppHandle) {
    let vault_paths = match vault_list::load_vault_list() {
        Ok(vault_list) => selected_mcp_bridge_vault_paths(&vault_list),
        Err(e) => {
            log::warn!("Failed to load active vault for ws-bridge startup: {}", e);
            Vec::new()
        }
    };

    let Some(vault_path) = vault_paths.first() else {
        log::info!("ws-bridge not started: no active vault selected");
        return;
    };

    if let Err(e) = sync_ws_bridge_for_vault(app_handle, Some(vault_path), &vault_paths) {
        log::warn!("Failed to start ws-bridge: {}", e);
    }
}

#[cfg(desktop)]
fn spawn_initial_ws_bridge_sync(app: &tauri::App) {
    let app_handle = app.handle().clone();
    spawn_background_task("tolaria-ws-bridge-startup", move || {
        #[cfg(all(desktop, target_os = "linux"))]
        if linux_appimage::is_running() {
            let app_version = app_handle.package_info().version.to_string();
            if let Err(e) = mcp::extract_mcp_server_to_stable_dir(&app_version) {
                log::warn!("Failed to extract MCP server to stable path: {e}");
            }
        }

        sync_ws_bridge_for_selected_vault(&app_handle);
    });
}

fn setup_common_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
    if cfg!(debug_assertions) {
        app.handle().plugin(
            tauri_plugin_log::Builder::default()
                .level(log::LevelFilter::Info)
                .build(),
        )?;
    }

    app.handle().plugin(tauri_plugin_dialog::init())?;
    Ok(())
}

#[cfg(desktop)]
fn focus_main_window(app_handle: &tauri::AppHandle) {
    use tauri::Manager;

    if let Some(window) = app_handle.get_webview_window("main") {
        let _ = window.unminimize();
        let _ = window.show();
        let _ = window.set_focus();
    }
}

#[cfg(desktop)]
fn with_desktop_entry_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
    builder
        .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
            focus_main_window(app);
        }))
        .plugin(tauri_plugin_deep_link::init())
}

#[cfg(desktop)]
fn setup_deep_link_runtime_registration(
    _app: &mut tauri::App,
) -> Result<(), Box<dyn std::error::Error>> {
    #[cfg(any(target_os = "windows", target_os = "linux"))]
    {
        use tauri_plugin_deep_link::DeepLinkExt;

        _app.deep_link().register_all()?;
    }

    Ok(())
}

#[cfg(desktop)]
fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
    setup_macos_webview_shortcut_prevention(app)?;
    setup_deep_link_runtime_registration(app)?;
    // Guanghu keeps in-app updates disabled until it has its own signed feed.
    // Registering the upstream updater plugin without a matching config causes
    // startup-time failure on the forked desktop app.
    app.handle().plugin(tauri_plugin_process::init())?;
    app.handle().plugin(tauri_plugin_opener::init())?;
    if should_use_native_desktop_menu(std::env::consts::OS) {
        menu::setup_menu(app)?;
    }
    setup_custom_window_chrome(app)?;
    window_state::restore_main_window_state(app);
    show_debug_main_window(app);
    Ok(())
}

#[cfg(debug_assertions)]
fn show_debug_main_window(app: &mut tauri::App) {
    use tauri::Manager;

    if let Some(window) = app.get_webview_window("main") {
        let _ = window.unminimize();
        let _ = window.show();
        let _ = window.center();
        let _ = window.set_focus();
    }
}

#[cfg(not(debug_assertions))]
fn show_debug_main_window(_app: &mut tauri::App) {}

fn should_use_native_desktop_menu(target_os: &str) -> bool {
    target_os == "macos"
}

#[cfg(all(desktop, any(target_os = "linux", target_os = "windows")))]
fn setup_custom_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
    use tauri::Manager;

    if let Some(window) = app.get_webview_window("main") {
        let _ = window.set_decorations(false);
    }
    Ok(())
}

#[cfg(not(all(desktop, any(target_os = "linux", target_os = "windows"))))]
fn setup_custom_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
    Ok(())
}

#[cfg(any(test, all(desktop, target_os = "macos")))]
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O", "F"];
#[cfg(any(test, all(desktop, target_os = "macos")))]
const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"];

#[cfg(all(desktop, target_os = "macos"))]
fn setup_macos_webview_shortcut_prevention(
    app: &mut tauri::App,
) -> Result<(), Box<dyn std::error::Error>> {
    use tauri_plugin_prevent_default::ModifierKey::{MetaKey, ShiftKey};
    use tauri_plugin_prevent_default::{Flags, KeyboardShortcut};

    let mut builder = tauri_plugin_prevent_default::Builder::new().with_flags(Flags::empty());

    // WKWebView can swallow some browser-reserved chords before our shared
    // renderer shortcut handler sees them. Keep this list narrow and verify
    // every addition with native QA.
    for key in MACOS_WEBVIEW_RESERVED_COMMAND_KEYS {
        builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey]));
    }
    for key in MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS {
        builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey, ShiftKey]));
    }

    app.handle().plugin(builder.build())?;
    Ok(())
}

#[cfg(not(all(desktop, target_os = "macos")))]
fn setup_macos_webview_shortcut_prevention(
    _app: &mut tauri::App,
) -> Result<(), Box<dyn std::error::Error>> {
    Ok(())
}

fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
    setup_common_plugins(app)?;

    #[cfg(desktop)]
    setup_desktop_plugins(app)?;

    if telemetry::init_sentry_from_settings() {
        log::info!("Sentry initialized (crash reporting enabled)");
    }

    #[cfg(desktop)]
    {
        spawn_startup_tasks();
        spawn_initial_ws_bridge_sync(app);
    }

    Ok(())
}

#[cfg(desktop)]
fn vault_asset_scope_roots(vault_path: &Path) -> Result<Vec<PathBuf>, String> {
    let canonical_vault_path = std::fs::canonicalize(vault_path).map_err(|e| {
        format!(
            "Failed to resolve asset scope for {}: {e}",
            vault_path.display()
        )
    })?;
    let mut roots = vec![canonical_vault_path.clone()];
    let requested_vault_path = vault_path.to_path_buf();
    if requested_vault_path != canonical_vault_path {
        roots.push(requested_vault_path);
    }
    Ok(roots)
}

#[cfg(desktop)]
fn missing_asset_scope_roots(
    allowed_roots: &[PathBuf],
    requested_roots: &[PathBuf],
) -> Vec<PathBuf> {
    requested_roots
        .iter()
        .filter(|root| !allowed_roots.contains(root))
        .cloned()
        .collect()
}

#[cfg(desktop)]
pub(crate) fn sync_vault_asset_scope(
    app_handle: &tauri::AppHandle,
    vault_path: &Path,
) -> Result<(), String> {
    use tauri::Manager;

    let requested_roots = vault_asset_scope_roots(vault_path)?;
    let scope = app_handle.asset_protocol_scope();
    let state: tauri::State<'_, AllowedAssetScopeRoots> = app_handle.state();
    let mut allowed_roots = state
        .0
        .lock()
        .map_err(|_| "Failed to lock asset scope state".to_string())?;
    let roots_to_allow = missing_asset_scope_roots(&allowed_roots, &requested_roots);

    for root in &roots_to_allow {
        scope
            .allow_directory(root, true)
            .map_err(|e| format!("Failed to allow asset access for {}: {e}", root.display()))?;
    }

    allowed_roots.extend(roots_to_allow);
    Ok(())
}

macro_rules! app_invoke_handler {
    () => {
        tauri::generate_handler![
            commands::list_vault,
            commands::list_vault_folders,
            commands::get_note_content,
            commands::validate_note_content,
            commands::create_note_content,
            commands::save_note_content,
            commands::update_frontmatter,
            commands::delete_frontmatter_property,
            commands::rename_note,
            commands::rename_note_filename,
            commands::move_note_to_folder,
            commands::move_note_to_workspace,
            commands::auto_rename_untitled,
            commands::detect_renames,
            commands::update_wikilinks_for_renames,
            commands::get_file_history,
            commands::get_modified_files,
            commands::get_file_diff,
            commands::get_file_diff_at_commit,
            commands::get_vault_pulse,
            commands::git_commit,
            commands::git_author_identity,
            commands::get_build_number,
            commands::get_last_commit_info,
            commands::git_pull,
            commands::git_push,
            commands::git_remote_status,
            commands::git_file_url,
            commands::git_provider_status,
            commands::test_git_provider,
            commands::git_add_remote,
            commands::get_conflict_files,
            commands::get_conflict_mode,
            commands::git_resolve_conflict,
            commands::git_commit_conflict_resolution,
            commands::git_discard_file,
            commands::is_git_repo,
            commands::init_git_repo,
            commands::check_claude_cli,
            commands::get_ai_agents_status,
            commands::get_agent_docs_path,
            commands::get_vault_ai_guidance_status,
            commands::restore_vault_ai_guidance,
            commands::stream_claude_chat,
            commands::stream_ai_agent,
            commands::abort_ai_agent_stream,
            commands::stream_ai_model,
            commands::save_ai_model_provider_api_key,
            commands::delete_ai_model_provider_api_key,
            commands::test_ai_model_provider,
            commands::reload_vault,
            commands::reload_vault_entry,
            commands::sync_vault_asset_scope_for_window,
            commands::open_vault_file_external,
            commands::sync_note_title,
            commands::save_image,
            commands::copy_image_to_vault,
            commands::delete_note,
            commands::batch_delete_notes,
            commands::batch_delete_notes_async,
            commands::migrate_is_a_to_type,
            commands::create_vault_folder,
            commands::rename_vault_folder,
            commands::delete_vault_folder,
            commands::batch_archive_notes,
            commands::get_settings,
            commands::get_ai_workspace_sessions,
            commands::check_for_app_update,
            commands::update_menu_state,
            commands::update_app_icon,
            commands::trigger_menu_command,
            commands::update_current_window_min_size,
            commands::perform_current_window_titlebar_double_click,
            commands::save_settings,
            commands::save_ai_workspace_sessions,
            commands::download_and_install_app_update,
            commands::load_vault_list,
            commands::save_vault_list,
            commands::git_clone::clone_git_repo,
            commands::search_vault,
            commands::create_empty_vault,
            commands::create_getting_started_vault,
            commands::check_vault_exists,
            commands::get_default_vault_path,
            commands::register_mcp_tools,
            commands::remove_mcp_tools,
            commands::check_mcp_status,
            commands::get_mcp_config_snippet,
            commands::get_opencode_mcp_config_snippet,
            commands::copy_text_to_clipboard,
            commands::read_text_from_clipboard,
            commands::sync_mcp_bridge_vault,
            commands::get_process_memory_snapshot,
            commands::repair_vault,
            commands::reinit_telemetry,
            commands::should_use_external_media_preview,
            commands::print_current_webview,
            commands::can_export_current_webview_pdf,
            commands::export_current_webview_pdf,
            commands::resolve_sheet_external_formula_inputs,
            commands::list_views,
            commands::save_view_cmd,
            commands::delete_view_cmd,
            vault_watcher::start_vault_watcher,
            vault_watcher::stop_vault_watcher
        ]
    };
}

fn with_invoke_handler(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
    builder.invoke_handler(app_invoke_handler!())
}

#[cfg(desktop)]
fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
    use tauri::Manager;

    window_state::handle_run_event(app_handle, event);

    if let tauri::RunEvent::Exit = event {
        let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
        let mut guard = state.0.lock().unwrap();
        stop_ws_bridge_child(&mut guard);
    }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    #[cfg(all(desktop, target_os = "linux"))]
    linux_appimage::apply_startup_env_overrides();

    let builder = tauri::Builder::default();

    #[cfg(desktop)]
    let builder = with_desktop_entry_plugins(builder);

    #[cfg(desktop)]
    let builder = builder
        .manage(WsBridgeChild(Mutex::new(None)))
        .manage(AllowedAssetScopeRoots(Mutex::new(Vec::new())))
        .manage(window_state::MainWindowFrameState::default())
        .manage(vault_watcher::VaultWatcherState::new());

    with_invoke_handler(builder)
        .setup(setup_app)
        .build(tauri::generate_context!())
        .expect("error while building tauri application")
        .run(|app_handle, event| {
            #[cfg(desktop)]
            handle_run_event(app_handle, &event);
        });
}

#[cfg(test)]
mod tests {
    use super::should_use_native_desktop_menu;
    use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
    use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;

    #[cfg(desktop)]
    use super::{
        missing_asset_scope_roots, selected_mcp_bridge_vault_paths,
        spawn_startup_tasks_for_vault_with, validate_mcp_bridge_vault_path,
    };
    #[cfg(desktop)]
    use crate::vault_list::{VaultEntry, VaultList};
    #[cfg(desktop)]
    use std::path::PathBuf;

    #[cfg(all(desktop, unix))]
    use super::vault_asset_scope_roots;

    #[test]
    fn macos_webview_shortcut_prevention_includes_ai_panel_shortcut() {
        assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_KEYS, ["O", "F"]);
        assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
    }

    #[cfg(desktop)]
    #[test]
    fn selected_mcp_bridge_vault_paths_puts_persisted_active_vault_first() {
        let list = VaultList {
            vaults: vec![
                VaultEntry {
                    label: "Secondary".to_string(),
                    path: "/tmp/Secondary Vault".to_string(),
                    mounted: Some(true),
                    ..VaultEntry::default()
                },
                VaultEntry {
                    label: "Hidden".to_string(),
                    path: "/tmp/Hidden Vault".to_string(),
                    mounted: Some(false),
                    ..VaultEntry::default()
                },
                VaultEntry {
                    label: "Selected".to_string(),
                    path: "/tmp/Selected Vault".to_string(),
                    mounted: Some(true),
                    ..VaultEntry::default()
                },
            ],
            active_vault: Some("/tmp/Selected Vault".to_string()),
            default_workspace_path: None,
            hidden_defaults: Vec::new(),
        };

        assert_eq!(
            selected_mcp_bridge_vault_paths(&list),
            vec![
                PathBuf::from("/tmp/Selected Vault"),
                PathBuf::from("/tmp/Secondary Vault"),
            ]
        );
    }

    #[cfg(desktop)]
    #[test]
    fn selected_mcp_bridge_vault_paths_ignores_blank_active_vault() {
        let list = VaultList {
            vaults: Vec::new(),
            active_vault: Some("  ".to_string()),
            default_workspace_path: None,
            hidden_defaults: Vec::new(),
        };

        assert!(selected_mcp_bridge_vault_paths(&list).is_empty());
    }

    #[cfg(desktop)]
    #[test]
    fn startup_tasks_skip_missing_legacy_vault() {
        let missing_vault = tempfile::tempdir().unwrap().path().join("missing");
        let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
        let called_from_task = called.clone();

        let spawned = spawn_startup_tasks_for_vault_with(missing_vault, move |_| {
            called_from_task.store(true, std::sync::atomic::Ordering::SeqCst);
        });

        assert!(!spawned);
        assert!(!called.load(std::sync::atomic::Ordering::SeqCst));
    }

    #[cfg(desktop)]
    #[test]
    fn startup_tasks_run_in_background() {
        let dir = tempfile::tempdir().unwrap();
        let (entered_tx, entered_rx) = std::sync::mpsc::channel();
        let (release_tx, release_rx) = std::sync::mpsc::channel();

        let spawned = spawn_startup_tasks_for_vault_with(dir.path().to_path_buf(), move |_| {
            entered_tx.send(()).unwrap();
            release_rx
                .recv_timeout(std::time::Duration::from_secs(1))
                .unwrap();
        });

        assert!(spawned);
        entered_rx
            .recv_timeout(std::time::Duration::from_secs(1))
            .unwrap();
        release_tx.send(()).unwrap();
    }

    #[cfg(desktop)]
    #[test]
    fn validate_mcp_bridge_vault_path_requires_existing_directory() {
        let dir = tempfile::tempdir().unwrap();
        let vault = dir.path().join("Vault With Spaces");
        std::fs::create_dir(&vault).unwrap();

        let resolved = validate_mcp_bridge_vault_path(&vault).unwrap();
        assert_eq!(resolved, vault.canonicalize().unwrap());

        let missing = dir.path().join("Missing Vault");
        let err = validate_mcp_bridge_vault_path(&missing).unwrap_err();
        assert!(err.contains("MCP bridge vault is not available"));
    }

    #[cfg(all(desktop, unix))]
    #[test]
    fn vault_asset_scope_roots_include_requested_symlink_path() {
        let dir = tempfile::tempdir().unwrap();
        let canonical_vault = dir.path().join("Getting Started");
        let symlinked_vault = dir.path().join("Symlinked Getting Started");
        std::fs::create_dir(&canonical_vault).unwrap();
        std::os::unix::fs::symlink(&canonical_vault, &symlinked_vault).unwrap();

        let roots = vault_asset_scope_roots(&symlinked_vault).unwrap();

        assert_eq!(roots[0], canonical_vault.canonicalize().unwrap());
        assert!(roots.contains(&symlinked_vault));
    }

    #[cfg(desktop)]
    #[test]
    fn missing_asset_scope_roots_keeps_previously_allowed_vaults() {
        let vault_a = PathBuf::from("/vault-a");
        let vault_b = PathBuf::from("/vault-b");
        let allowed_roots = vec![vault_a.clone()];

        assert_eq!(
            missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_b)),
            vec![vault_b]
        );
        assert!(
            missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_a)).is_empty()
        );
    }

    #[test]
    fn native_desktop_menu_is_macos_only() {
        assert!(should_use_native_desktop_menu("macos"));
        assert!(!should_use_native_desktop_menu("windows"));
        assert!(!should_use_native_desktop_menu("linux"));
    }
}
