mod embedded_assets {
    include!(concat!(env!("OUT_DIR"), "/embedded_web_assets.rs"));
}

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder as AutoBuilder;
use ranvier::http::ingress::RawIngressService;
use ranvier::http::{
    CookieJar, HttpIngress, Ranvier, StaticAssetPolicy, StaticAssetSource, StaticShell,
};
use soma_studio_core::{
    AppConfig, ProviderSummary, SourceRootSummary, WorkspaceFileChangePreviewRequest,
    WorkspaceTaskRunRequest, WorkspaceTaskRunResponse, WorkspaceTaskRunStatus,
    WorkspaceTaskRunSummary,
};
use tokio::net::TcpListener;
use tokio::sync::broadcast;
use tracing::{info, warn};
use uuid::Uuid;

use crate::chat_events::ChatEventEnvelope;
use crate::storage::StudioStorage;
use crate::transitions::{
    RequestHost, RequestOrigin, RequestPath, bootstrap_redirect_axon, chat_send_axon,
    conversation_delete_axon, conversation_messages_axon, conversations_create_axon,
    conversations_list_axon, health_axon, ingest_jobs_axon, ingest_rescan_axon, ingest_status_axon,
    init_axon, notebook_adapters_list_axon, notebook_artifact_axon, notebook_chunks_build_axon,
    notebook_chunks_get_axon, notebook_embeddings_build_axon, notebook_embeddings_get_axon,
    notebook_index_build_axon, notebook_index_get_axon, notebook_note_create_axon,
    notebook_note_read_axon, notebook_note_render_axon, notebook_note_write_axon,
    notebook_notes_list_axon, notebook_notes_search_axon, notebook_retrieve_axon,
    provider_models_axon, provider_models_error_response, provider_selection_get_axon,
    provider_selection_set_axon, provider_test_axon, providers_axon, search_axon,
    search_index_rebuild_axon, search_index_recover_axon, search_index_status_axon,
    search_index_sync_axon, search_open_action_axon, source_roots_create_axon,
    source_roots_list_axon, stream_chat_axon, workspace_file_change_apply_axon,
    workspace_file_change_audits_list_axon, workspace_file_change_preview_axon,
    workspace_file_preview_axon, workspace_files_axon, workspace_source_root_create_axon,
    workspace_task_run_axon, workspace_task_run_cancel_axon, workspace_task_run_get_axon,
    workspace_task_run_start_axon, workspace_task_runs_list_axon,
};
use crate::workspace::WorkspaceError;
use crate::workspace_tasks::WorkspaceTaskError;

#[derive(Debug, Clone)]
pub struct SessionInfo {
    pub id: String,
}

#[derive(Debug, Clone)]
pub struct BootstrapToken {
    pub value: String,
    pub expires_at: SystemTime,
}

#[derive(Debug, Clone)]
pub struct AppContext {
    pub config: AppConfig,
    pub storage: StudioStorage,
    pub bootstrap_tokens: Arc<RwLock<HashMap<String, BootstrapToken>>>,
    pub sessions: Arc<RwLock<HashMap<String, SessionInfo>>>,
    pub source_roots: Arc<RwLock<Vec<SourceRootSummary>>>,
    pub workspace_task_runs: Arc<RwLock<HashMap<Uuid, WorkspaceTaskRunRecord>>>,
    pub workspace_file_change_previews:
        Arc<RwLock<HashMap<Uuid, WorkspaceFileChangePreviewRecord>>>,
    pub chat_events: broadcast::Sender<ChatEventEnvelope>,
}

#[derive(Debug, Clone)]
pub struct WorkspaceTaskRunRecord {
    pub session_id: String,
    pub summary: WorkspaceTaskRunSummary,
    pub cancel_requested: Arc<AtomicBool>,
}

#[derive(Debug, Clone)]
pub struct WorkspaceFileChangePreviewRecord {
    pub session_id: String,
    pub request: WorkspaceFileChangePreviewRequest,
    pub base_modified_at_ms: Option<u64>,
    pub base_size_bytes: Option<u64>,
    pub expires_at_ms: u64,
}

const MAX_ACTIVE_WORKSPACE_TASK_RUNS_PER_SESSION: usize = 1;
const WORKSPACE_TASK_RUN_RETENTION_MS: u64 = 30 * 60 * 1000;
const WORKSPACE_FILE_CHANGE_PREVIEW_TTL_MS: u64 = 10 * 60 * 1000;
const WORKSPACE_TASK_FAILED_EXIT_CODE: &str = "workspace_task_failed_exit";
const WORKSPACE_TASK_TIMED_OUT_CODE: &str = "workspace_task_timed_out";

impl AppContext {
    pub fn new(config: AppConfig, storage: StudioStorage) -> Self {
        let (chat_events, _) = broadcast::channel(128);
        Self {
            config,
            storage,
            bootstrap_tokens: Arc::new(RwLock::new(HashMap::new())),
            sessions: Arc::new(RwLock::new(HashMap::new())),
            source_roots: Arc::new(RwLock::new(Vec::new())),
            workspace_task_runs: Arc::new(RwLock::new(HashMap::new())),
            workspace_file_change_previews: Arc::new(RwLock::new(HashMap::new())),
            chat_events,
        }
    }

    pub fn issue_bootstrap_token(&self) -> String {
        let token = Uuid::new_v4().to_string();
        let entry = BootstrapToken {
            value: token.clone(),
            expires_at: SystemTime::now() + Duration::from_secs(300),
        };
        self.bootstrap_tokens
            .write()
            .expect("bootstrap token store poisoned")
            .insert(token.clone(), entry);
        token
    }

    pub fn consume_bootstrap_token(&self, token: &str) -> bool {
        let mut store = self
            .bootstrap_tokens
            .write()
            .expect("bootstrap token store poisoned");
        let Some(entry) = store.remove(token) else {
            return false;
        };
        entry.value == token && entry.expires_at > SystemTime::now()
    }

    pub async fn issue_session(&self) -> Result<SessionInfo> {
        let session_id = Uuid::new_v4().to_string();
        self.storage
            .persist_session(&session_id)
            .await
            .with_context(|| format!("failed to persist issued session {session_id}"))?;
        self.remember_session(session_id.clone())
            .with_context(|| format!("generated session id should be valid UUID: {session_id}"))
    }

    pub fn lookup_session(&self, session_id: &str) -> Option<SessionInfo> {
        self.sessions
            .read()
            .expect("session store poisoned")
            .get(session_id)
            .cloned()
    }

    pub fn remember_session(&self, session_id: impl Into<String>) -> Option<SessionInfo> {
        let session_id = session_id.into();
        if Uuid::parse_str(&session_id).is_err() {
            return None;
        }
        if let Some(existing) = self.lookup_session(&session_id) {
            return Some(existing);
        }

        let session = SessionInfo {
            id: session_id.clone(),
        };
        self.sessions
            .write()
            .expect("session store poisoned")
            .insert(session_id, session.clone());
        Some(session)
    }

    pub fn provider_summaries(&self) -> Vec<ProviderSummary> {
        vec![
            ProviderSummary {
                id: "ollama".to_string(),
                label: "Ollama".to_string(),
                endpoint_hint: "http://127.0.0.1:11434".to_string(),
                last_test_ok: None,
                last_test_detail: None,
                last_tested_at: None,
            },
            ProviderSummary {
                id: "lmstudio".to_string(),
                label: "LM Studio".to_string(),
                endpoint_hint: "http://127.0.0.1:1234".to_string(),
                last_test_ok: None,
                last_test_detail: None,
                last_tested_at: None,
            },
        ]
    }

    pub fn register_source_root(&self, summary: SourceRootSummary) -> SourceRootSummary {
        let mut roots = self
            .source_roots
            .write()
            .expect("source root store poisoned");
        if let Some(existing) = roots.iter().find(|existing| existing.path == summary.path) {
            return existing.clone();
        }
        roots.push(summary.clone());
        summary
    }

    pub fn publish_chat_event(&self, event: ChatEventEnvelope) {
        let _ = self.chat_events.send(event);
    }

    pub fn create_workspace_task_run(
        &self,
        session_id: &str,
        input: &WorkspaceTaskRunRequest,
        path: String,
    ) -> std::result::Result<(WorkspaceTaskRunSummary, Arc<AtomicBool>), WorkspaceTaskError> {
        let mut runs = self
            .workspace_task_runs
            .write()
            .expect("workspace task run store poisoned");
        prune_finished_workspace_task_runs(&mut runs, current_time_ms());
        let active_count = runs
            .values()
            .filter(|record| {
                record.session_id == session_id
                    && matches!(
                        record.summary.status,
                        WorkspaceTaskRunStatus::Queued | WorkspaceTaskRunStatus::Running
                    )
            })
            .count();
        if active_count >= MAX_ACTIVE_WORKSPACE_TASK_RUNS_PER_SESSION {
            return Err(WorkspaceTaskError::busy(
                "workspace task run must wait for the active run to finish",
            ));
        }

        let run_id = Uuid::new_v4();
        let cancel_requested = Arc::new(AtomicBool::new(false));
        let summary = WorkspaceTaskRunSummary {
            run_id,
            task_id: input.task_id,
            path,
            status: WorkspaceTaskRunStatus::Running,
            command_label: crate::workspace_tasks::workspace_task_command_label(input.task_id)
                .to_string(),
            exit_code: None,
            stdout_tail: String::new(),
            stderr_tail: String::new(),
            stdout_truncated: false,
            stderr_truncated: false,
            timed_out: false,
            cancel_requested: false,
            started_at_ms: current_time_ms(),
            completed_at_ms: None,
            duration_ms: None,
            error: None,
            error_code: None,
            max_output_bytes: crate::workspace_tasks::workspace_task_max_output_bytes(),
        };
        runs.insert(
            run_id,
            WorkspaceTaskRunRecord {
                session_id: session_id.to_string(),
                summary: summary.clone(),
                cancel_requested: cancel_requested.clone(),
            },
        );
        Ok((summary, cancel_requested))
    }

    pub fn list_workspace_task_runs(
        &self,
        session_id: &str,
        error_code: Option<&str>,
    ) -> Vec<WorkspaceTaskRunSummary> {
        let mut runs = self
            .workspace_task_runs
            .write()
            .expect("workspace task run store poisoned");
        prune_finished_workspace_task_runs(&mut runs, current_time_ms());
        let mut summaries: Vec<_> = runs
            .values()
            .filter(|record| {
                record.session_id == session_id
                    && error_code
                        .is_none_or(|code| record.summary.error_code.as_deref() == Some(code))
            })
            .map(|record| record.summary.clone())
            .collect();
        summaries.sort_by_key(|summary| std::cmp::Reverse(summary.started_at_ms));
        summaries
    }

    pub fn get_workspace_task_run(
        &self,
        session_id: &str,
        run_id: Uuid,
    ) -> Option<WorkspaceTaskRunSummary> {
        let mut runs = self
            .workspace_task_runs
            .write()
            .expect("workspace task run store poisoned");
        prune_finished_workspace_task_runs(&mut runs, current_time_ms());
        runs.get(&run_id)
            .filter(|record| record.session_id == session_id)
            .map(|record| record.summary.clone())
    }

    pub fn remove_workspace_task_run(&self, session_id: &str, run_id: Uuid) {
        let mut runs = self
            .workspace_task_runs
            .write()
            .expect("workspace task run store poisoned");
        let Some(record) = runs.get(&run_id) else {
            return;
        };
        if record.session_id == session_id {
            runs.remove(&run_id);
        }
    }

    pub fn cancel_workspace_task_run(
        &self,
        session_id: &str,
        run_id: Uuid,
    ) -> Option<WorkspaceTaskRunSummary> {
        let mut runs = self
            .workspace_task_runs
            .write()
            .expect("workspace task run store poisoned");
        prune_finished_workspace_task_runs(&mut runs, current_time_ms());
        let record = runs.get_mut(&run_id)?;
        if record.session_id != session_id {
            return None;
        }
        if matches!(
            record.summary.status,
            WorkspaceTaskRunStatus::Queued | WorkspaceTaskRunStatus::Running
        ) {
            record.cancel_requested.store(true, Ordering::SeqCst);
            record.summary.cancel_requested = true;
        }
        Some(record.summary.clone())
    }

    pub fn finish_workspace_task_run(
        &self,
        session_id: &str,
        run_id: Uuid,
        result: std::result::Result<WorkspaceTaskRunResponse, WorkspaceTaskError>,
    ) -> Option<WorkspaceTaskRunSummary> {
        let mut runs = self
            .workspace_task_runs
            .write()
            .expect("workspace task run store poisoned");
        let record = runs.get_mut(&run_id)?;
        if record.session_id != session_id {
            return None;
        }
        let completed_at_ms = current_time_ms();
        match result {
            Ok(response) => {
                let failed_exit = !matches!(response.exit_code, Some(0));
                record.summary.status = if response.cancelled {
                    WorkspaceTaskRunStatus::Cancelled
                } else if response.timed_out {
                    WorkspaceTaskRunStatus::TimedOut
                } else if failed_exit {
                    WorkspaceTaskRunStatus::Failed
                } else {
                    WorkspaceTaskRunStatus::Complete
                };
                record.summary.path = response.path;
                record.summary.command_label = response.command_label;
                record.summary.exit_code = response.exit_code;
                record.summary.stdout_tail = response.stdout;
                record.summary.stderr_tail = response.stderr;
                record.summary.stdout_truncated = response.stdout_truncated;
                record.summary.stderr_truncated = response.stderr_truncated;
                record.summary.timed_out = response.timed_out;
                record.summary.cancel_requested =
                    record.summary.cancel_requested || response.cancelled;
                record.summary.completed_at_ms = Some(completed_at_ms);
                record.summary.duration_ms = Some(response.duration_ms);
                let (error, error_code) = if response.cancelled {
                    (None, None)
                } else if response.timed_out {
                    (
                        Some("workspace task timed out".to_string()),
                        Some(WORKSPACE_TASK_TIMED_OUT_CODE.to_string()),
                    )
                } else if failed_exit {
                    (
                        Some(match response.exit_code {
                            Some(code) => format!("workspace task exited with code {code}"),
                            None => "workspace task ended without an exit code".to_string(),
                        }),
                        Some(WORKSPACE_TASK_FAILED_EXIT_CODE.to_string()),
                    )
                } else {
                    (None, None)
                };
                record.summary.error = error;
                record.summary.error_code = error_code;
                record.summary.max_output_bytes = response.max_output_bytes;
            }
            Err(error) => {
                record.summary.status = WorkspaceTaskRunStatus::Failed;
                record.summary.completed_at_ms = Some(completed_at_ms);
                record.summary.duration_ms =
                    Some(completed_at_ms.saturating_sub(record.summary.started_at_ms));
                record.summary.error_code = Some(error.api_code().to_string());
                record.summary.error = Some(error.to_string());
            }
        }
        Some(record.summary.clone())
    }

    pub fn register_workspace_file_change_preview(
        &self,
        session_id: &str,
        preview_token: Uuid,
        expires_at_ms: u64,
        request: WorkspaceFileChangePreviewRequest,
        base_modified_at_ms: Option<u64>,
        base_size_bytes: Option<u64>,
    ) {
        let mut previews = self
            .workspace_file_change_previews
            .write()
            .expect("workspace file change preview store poisoned");
        prune_workspace_file_change_previews(&mut previews, current_time_ms());
        previews.insert(
            preview_token,
            WorkspaceFileChangePreviewRecord {
                session_id: session_id.to_string(),
                request,
                base_modified_at_ms,
                base_size_bytes,
                expires_at_ms,
            },
        );
    }

    pub fn new_workspace_file_change_preview_token(&self) -> (Uuid, u64) {
        (
            Uuid::new_v4(),
            current_time_ms().saturating_add(WORKSPACE_FILE_CHANGE_PREVIEW_TTL_MS),
        )
    }

    pub fn consume_workspace_file_change_preview(
        &self,
        session_id: &str,
        preview_token: Uuid,
    ) -> std::result::Result<WorkspaceFileChangePreviewRecord, WorkspaceError> {
        let mut previews = self
            .workspace_file_change_previews
            .write()
            .expect("workspace file change preview store poisoned");
        prune_workspace_file_change_previews(&mut previews, current_time_ms());
        let Some(record) = previews.remove(&preview_token) else {
            return Err(WorkspaceError::preview_missing(
                "workspace file change preview missing or stale",
            ));
        };
        if record.session_id != session_id {
            previews.insert(preview_token, record);
            return Err(WorkspaceError::preview_missing(
                "workspace file change preview missing or stale",
            ));
        }
        Ok(record)
    }
}

fn current_time_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis() as u64)
        .unwrap_or(0)
}

fn prune_finished_workspace_task_runs(
    runs: &mut HashMap<Uuid, WorkspaceTaskRunRecord>,
    now_ms: u64,
) {
    runs.retain(|_, record| {
        if matches!(
            record.summary.status,
            WorkspaceTaskRunStatus::Queued | WorkspaceTaskRunStatus::Running
        ) {
            return true;
        }
        let Some(completed_at_ms) = record.summary.completed_at_ms else {
            return true;
        };
        now_ms.saturating_sub(completed_at_ms) <= WORKSPACE_TASK_RUN_RETENTION_MS
    });
}

fn prune_workspace_file_change_previews(
    previews: &mut HashMap<Uuid, WorkspaceFileChangePreviewRecord>,
    now_ms: u64,
) {
    previews.retain(|_, record| record.expires_at_ms > now_ms);
}

pub struct PreparedServer {
    config: AppConfig,
    bootstrap_url: String,
    listener: TcpListener,
    raw_service: RawIngressService<()>,
}

impl PreparedServer {
    pub fn bootstrap_url(&self) -> &str {
        &self.bootstrap_url
    }

    pub async fn run(self) -> Result<()> {
        let bind_addr = self.config.bind_addr.clone();
        let listener = self.listener;
        let raw_service = self.raw_service;

        info!("starting Soma Studio server on {}", bind_addr);

        loop {
            tokio::select! {
                accept_result = listener.accept() => {
                    let (stream, _) = accept_result.context("failed to accept TCP connection")?;
                    let io = TokioIo::new(stream);
                    let service = raw_service.clone();
                    tokio::spawn(async move {
                        let builder = AutoBuilder::new(TokioExecutor::new());
                        if let Err(error) = builder.serve_connection(io, service).await {
                            warn!("connection handling failed: {error}");
                        }
                    });
                }
                signal = tokio::signal::ctrl_c() => {
                    if let Err(error) = signal {
                        warn!("failed to listen for Ctrl+C: {error}");
                    }
                    break;
                }
            }
        }

        Ok(())
    }
}

pub async fn prepare_server(mut config: AppConfig) -> Result<PreparedServer> {
    config.ensure_directories()?;
    if !config.web_shell_file.exists()
        && let Some(extracted_dir) = ensure_embedded_web_assets(&config)?
    {
        config = config.with_web_build_dir(extracted_dir);
    }

    if !config.web_shell_file.exists() {
        anyhow::bail!(
            "web shell not found at {}. Set SOMA_STUDIO_WEB_DIR or build the web app first.",
            config.web_shell_file.display()
        );
    }

    let bind_addr = config.bind_socket_addr()?;
    let listener = TcpListener::bind(bind_addr)
        .await
        .with_context(|| format!("failed to bind {}", config.bind_addr))?;
    let local_addr = listener
        .local_addr()
        .context("failed to inspect bound local address")?;
    config = config.with_bind_addr(local_addr.to_string());

    let storage = StudioStorage::open(&config).await?;
    let persisted_source_roots = storage.list_source_roots().await?;
    let context = AppContext::new(config.clone(), storage);
    *context
        .source_roots
        .write()
        .expect("source root store poisoned") = persisted_source_roots;
    let bootstrap_token = context.issue_bootstrap_token();
    let bootstrap_url = format!(
        "http://{}/bootstrap?token={}",
        config.bind_addr, bootstrap_token
    );
    let raw_service = build_raw_service(&context);

    Ok(PreparedServer {
        config,
        bootstrap_url,
        listener,
        raw_service,
    })
}

pub fn embedded_web_asset_count() -> usize {
    embedded_assets::EMBEDDED_WEB_ASSETS.len()
}

pub fn embedded_web_shell_available() -> bool {
    embedded_assets::EMBEDDED_WEB_ASSETS
        .iter()
        .any(|asset| asset.path == "spa.html")
}

fn build_raw_service(context: &AppContext) -> RawIngressService<()> {
    build_ingress(context).into_raw_service(())
}

fn build_ingress(context: &AppContext) -> HttpIngress<()> {
    let build_dir = context.config.web_build_dir.clone();
    let shell_file = context.config.web_shell_file.clone();
    let user_assets_dir = context.config.user_assets_dir.clone();
    let app_context = context.clone();

    Ranvier::http::<()>()
        .bus_injector(move |parts, bus| {
            let origin = parts
                .headers
                .get("origin")
                .and_then(|value| value.to_str().ok())
                .map(|value| value.to_string());
            let host = parts
                .headers
                .get("host")
                .and_then(|value| value.to_str().ok())
                .map(|value| value.to_string());
            let path = parts.uri.path().to_string();
            bus.insert(app_context.clone());
            bus.insert(CookieJar::from_parts(parts));
            bus.insert(RequestOrigin(origin));
            bus.insert(RequestHost(host));
            bus.insert(RequestPath(path));
        })
        .serve_assets(
            "/assets",
            StaticAssetSource::directory(user_assets_dir.to_string_lossy().to_string()),
            StaticAssetPolicy::public_assets()
                .compression()
                .enable_range_requests(),
        )
        .serve_assets(
            "/",
            StaticAssetSource::directory(build_dir.to_string_lossy().to_string()),
            StaticAssetPolicy::public_assets()
                .compression()
                .serve_precompressed()
                .enable_range_requests()
                .directory_index("index.html"),
        )
        .serve_spa_shell(
            StaticShell::file(shell_file.to_string_lossy().to_string())
                .cache_control("no-store")
                .compression()
                .exclude_prefix("/api")
                .exclude_prefix("/assets")
                .exclude_prefix("/bootstrap"),
        )
        .get("/bootstrap", bootstrap_redirect_axon())
        .get_json_out("/api/health", health_axon())
        .get_with_error(
            "/api/providers/models",
            provider_models_axon(),
            provider_models_error_response,
        )
        .group("/api", |g| {
            g.guard(crate::transitions::RequireSessionGuard)
                .get_json_out("/app/init", init_axon())
                .get_json_out("/providers", providers_axon())
                .get_json_out("/providers/selection", provider_selection_get_axon())
                .get_json_out("/source-roots", source_roots_list_axon())
                .get("/workspace/files", workspace_files_axon())
                .get("/workspace/file-preview", workspace_file_preview_axon())
                .get_json_out(
                    "/workspace/file-change-audits",
                    workspace_file_change_audits_list_axon(),
                )
                .get_json_out("/workspace/task-runs", workspace_task_runs_list_axon())
                .get("/workspace/task-runs/:id", workspace_task_run_get_axon())
                .get_json_out("/ingest/jobs", ingest_jobs_axon())
                .get_json_out("/ingest/status", ingest_status_axon())
                .get("/search", search_axon())
                .get_json_out("/search/index/status", search_index_status_axon())
                .get_json_out("/search/open-action", search_open_action_axon())
                .get_json_out("/conversations", conversations_list_axon())
                .get_json_out("/conversations/:id/messages", conversation_messages_axon())
                .get("/notebook/tree", notebook_notes_list_axon())
                .get("/notebook/note", notebook_note_read_axon())
                .get("/notebook/search", notebook_notes_search_axon())
                .get("/notebook/adapters", notebook_adapters_list_axon())
                .get("/notebook/index", notebook_index_get_axon())
                .get("/notebook/chunks", notebook_chunks_get_axon())
                .get("/notebook/embeddings", notebook_embeddings_get_axon())
                .get("/notebook/retrieve", notebook_retrieve_axon())
                .get("/notebook/artifact", notebook_artifact_axon())
                .get("/stream/chat", stream_chat_axon())
                .group("", |g| {
                    g.guard(crate::transitions::RequireSameOriginGuard)
                        .post_typed("/providers/selection", provider_selection_set_axon())
                        .post_typed("/source-roots", source_roots_create_axon())
                        .post_typed(
                            "/workspace/source-root",
                            workspace_source_root_create_axon(),
                        )
                        .post_typed(
                            "/workspace/file-change-preview",
                            workspace_file_change_preview_axon(),
                        )
                        .post_typed(
                            "/workspace/file-change-apply",
                            workspace_file_change_apply_axon(),
                        )
                        .post_typed("/workspace/task", workspace_task_run_axon())
                        .post_typed("/workspace/task-runs", workspace_task_run_start_axon())
                        .post(
                            "/workspace/task-runs/:id/cancel",
                            workspace_task_run_cancel_axon(),
                        )
                        .post_typed("/ingest/rescan", ingest_rescan_axon())
                        .post("/search/index/rebuild", search_index_rebuild_axon())
                        .post("/search/index/sync", search_index_sync_axon())
                        .post("/search/index/recover", search_index_recover_axon())
                        .post_typed_json_out("/conversations", conversations_create_axon())
                        .delete_json_out("/conversations/:id", conversation_delete_axon())
                        .post_typed("/notebook/note", notebook_note_create_axon())
                        .put_typed("/notebook/note", notebook_note_write_axon())
                        .post_typed("/notebook/render", notebook_note_render_axon())
                        .post("/notebook/index", notebook_index_build_axon())
                        .post("/notebook/chunks", notebook_chunks_build_axon())
                        .post("/notebook/embeddings", notebook_embeddings_build_axon())
                        .post_typed_json_out("/chat/send", chat_send_axon())
                        .post_typed("/providers/test", provider_test_axon())
                })
        })
}

fn ensure_embedded_web_assets(config: &AppConfig) -> Result<Option<PathBuf>> {
    if embedded_assets::EMBEDDED_WEB_ASSETS.is_empty() {
        return Ok(None);
    }

    let runtime_web_dir = config.data_dir.join("runtime-web");
    for asset in embedded_assets::EMBEDDED_WEB_ASSETS {
        let target = runtime_web_dir.join(asset.path);
        if let Some(parent) = target.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("failed to create {}", parent.display()))?;
        }
        std::fs::write(&target, asset.bytes)
            .with_context(|| format!("failed to write {}", target.display()))?;
    }

    Ok(Some(runtime_web_dir))
}

#[cfg(test)]
mod tests {
    use crate::storage::StudioStorage;
    use http::StatusCode;
    use ranvier::http::{TestApp, TestRequest};
    use serde_json::json;
    use soma_studio_core::AppConfig;
    use soma_studio_core::{
        ApiErrorResponse, ChatSendResponse, ConversationDeleteResponse, ConversationMessage,
        ConversationSummary, IngestJobSummary, IngestStatusResponse, NotebookAdapterStatus,
        NotebookChunkResponse, NotebookIndexResponse, NotebookNoteContent, NotebookNoteFormat,
        NotebookNoteSummary, NotebookRenderResponse, NotebookRetrievalResponse,
        NotebookSearchResult, ProviderSelectionResponse, ProviderTestResponse, SearchFieldScope,
        SearchIndexRebuildResponse, SearchIndexStatusResponse, SearchOpenActionResponse,
        SearchResponse, SearchSort, SearchSourceType, WorkspaceEntryKind,
        WorkspaceFileChangeAction, WorkspaceFileChangeApplyResponse, WorkspaceFileChangeAuditEntry,
        WorkspaceFileChangeAuditStatus, WorkspaceFileChangePreviewRequest,
        WorkspaceFileChangePreviewResponse, WorkspaceFileListResponse,
        WorkspaceFilePreviewResponse, WorkspaceTaskRunResponse, WorkspaceTaskRunStatus,
        WorkspaceTaskRunSummary,
    };
    use uuid::Uuid;

    use super::{AppContext, SourceRootSummary, build_ingress};

    #[test]
    fn bootstrap_token_is_single_use() {
        let config = AppConfig::from_env().expect("config");
        let runtime = tokio::runtime::Runtime::new().expect("runtime");
        let storage = runtime.block_on(async {
            let temp_dir =
                std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
            std::fs::create_dir_all(&temp_dir).expect("temp dir");
            let config = AppConfig {
                app_name: "Soma Studio".to_string(),
                bind_addr: "127.0.0.1:0".to_string(),
                project_root: temp_dir.clone(),
                data_dir: temp_dir.clone(),
                derived_dir: temp_dir.join("derived"),
                notebook_dir: temp_dir.join("notebook"),
                user_assets_dir: temp_dir.join("assets"),
                db_path: temp_dir.join("test.db"),
                web_build_dir: temp_dir.join("web"),
                web_shell_file: temp_dir.join("web").join("spa.html"),
            };
            StudioStorage::open(&config).await.expect("storage")
        });
        let context = AppContext::new(config, storage);
        let token = context.issue_bootstrap_token();

        assert!(context.consume_bootstrap_token(&token));
        assert!(!context.consume_bootstrap_token(&token));
    }

    #[tokio::test]
    async fn bootstrap_route_sets_persistent_session_cookie() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-bootstrap-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = AppConfig {
            app_name: "Soma Studio".to_string(),
            bind_addr: "127.0.0.1:0".to_string(),
            project_root: temp_dir.clone(),
            data_dir: temp_dir.clone(),
            derived_dir: temp_dir.join("derived"),
            notebook_dir: temp_dir.join("notebook"),
            user_assets_dir: temp_dir.join("assets"),
            db_path: temp_dir.join("test.db"),
            web_build_dir: temp_dir.join("web"),
            web_shell_file: temp_dir.join("web").join("spa.html"),
        };
        let storage = StudioStorage::open(&config).await.expect("storage");
        let context = AppContext::new(config, storage);
        let token = context.issue_bootstrap_token();
        let app = TestApp::new(build_ingress(&context), ());

        let response = app
            .send(TestRequest::get(format!("/bootstrap?token={token}")))
            .await
            .expect("bootstrap response");

        assert_eq!(response.status(), StatusCode::FOUND);
        assert_eq!(
            response
                .header("location")
                .and_then(|value| value.to_str().ok()),
            Some("/")
        );
        let set_cookie = response
            .header("set-cookie")
            .and_then(|value| value.to_str().ok())
            .expect("set-cookie");
        assert!(set_cookie.contains("soma_studio_session="));
        assert!(set_cookie.contains("Max-Age=2592000"));
        assert!(set_cookie.contains("HttpOnly"));
    }

    #[test]
    fn register_source_root_returns_existing_summary_for_duplicates() {
        let config = AppConfig::from_env().expect("config");
        let runtime = tokio::runtime::Runtime::new().expect("runtime");
        let storage = runtime.block_on(async {
            let temp_dir =
                std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
            std::fs::create_dir_all(&temp_dir).expect("temp dir");
            let config = AppConfig {
                app_name: "Soma Studio".to_string(),
                bind_addr: "127.0.0.1:0".to_string(),
                project_root: temp_dir.clone(),
                data_dir: temp_dir.clone(),
                derived_dir: temp_dir.join("derived"),
                notebook_dir: temp_dir.join("notebook"),
                user_assets_dir: temp_dir.join("assets"),
                db_path: temp_dir.join("test.db"),
                web_build_dir: temp_dir.join("web"),
                web_shell_file: temp_dir.join("web").join("spa.html"),
            };
            StudioStorage::open(&config).await.expect("storage")
        });
        let context = AppContext::new(config, storage);
        let first = SourceRootSummary {
            id: Uuid::new_v4(),
            path: "F:/docs".to_string(),
            read_only: true,
        };
        let second = SourceRootSummary {
            id: Uuid::new_v4(),
            path: "F:/docs".to_string(),
            read_only: true,
        };

        let inserted = context.register_source_root(first.clone());
        let duplicate = context.register_source_root(second);

        assert_eq!(inserted.id, duplicate.id);
        assert_eq!(context.source_roots.read().expect("source roots").len(), 1);
    }

    #[test]
    fn remember_session_rehydrates_valid_uuid() {
        let config = AppConfig::from_env().expect("config");
        let runtime = tokio::runtime::Runtime::new().expect("runtime");
        let storage = runtime.block_on(async {
            let temp_dir =
                std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
            std::fs::create_dir_all(&temp_dir).expect("temp dir");
            let config = AppConfig {
                app_name: "Soma Studio".to_string(),
                bind_addr: "127.0.0.1:0".to_string(),
                project_root: temp_dir.clone(),
                data_dir: temp_dir.clone(),
                derived_dir: temp_dir.join("derived"),
                notebook_dir: temp_dir.join("notebook"),
                user_assets_dir: temp_dir.join("assets"),
                db_path: temp_dir.join("test.db"),
                web_build_dir: temp_dir.join("web"),
                web_shell_file: temp_dir.join("web").join("spa.html"),
            };
            StudioStorage::open(&config).await.expect("storage")
        });
        let context = AppContext::new(config, storage);
        let session_id = Uuid::new_v4().to_string();

        assert!(context.lookup_session(&session_id).is_none());
        let restored = context
            .remember_session(session_id.clone())
            .expect("valid session id");

        assert_eq!(restored.id, session_id);
        assert_eq!(
            context
                .lookup_session(&session_id)
                .expect("session should exist")
                .id,
            session_id
        );
    }

    #[test]
    fn remember_session_rejects_invalid_identifier() {
        let config = AppConfig::from_env().expect("config");
        let runtime = tokio::runtime::Runtime::new().expect("runtime");
        let storage = runtime.block_on(async {
            let temp_dir =
                std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
            std::fs::create_dir_all(&temp_dir).expect("temp dir");
            let config = AppConfig {
                app_name: "Soma Studio".to_string(),
                bind_addr: "127.0.0.1:0".to_string(),
                project_root: temp_dir.clone(),
                data_dir: temp_dir.clone(),
                derived_dir: temp_dir.join("derived"),
                notebook_dir: temp_dir.join("notebook"),
                user_assets_dir: temp_dir.join("assets"),
                db_path: temp_dir.join("test.db"),
                web_build_dir: temp_dir.join("web"),
                web_shell_file: temp_dir.join("web").join("spa.html"),
            };
            StudioStorage::open(&config).await.expect("storage")
        });
        let context = AppContext::new(config, storage);

        assert!(context.remember_session("not-a-uuid").is_none());
        assert!(context.lookup_session("not-a-uuid").is_none());
    }

    #[test]
    fn workspace_file_change_preview_token_survives_wrong_session_attempt() {
        let config = AppConfig::from_env().expect("config");
        let runtime = tokio::runtime::Runtime::new().expect("runtime");
        let storage = runtime.block_on(async {
            let temp_dir =
                std::env::temp_dir().join(format!("soma-studio-test-{}", Uuid::new_v4()));
            std::fs::create_dir_all(&temp_dir).expect("temp dir");
            let config = AppConfig {
                app_name: "Soma Studio".to_string(),
                bind_addr: "127.0.0.1:0".to_string(),
                project_root: temp_dir.clone(),
                data_dir: temp_dir.clone(),
                derived_dir: temp_dir.join("derived"),
                notebook_dir: temp_dir.join("notebook"),
                user_assets_dir: temp_dir.join("assets"),
                db_path: temp_dir.join("test.db"),
                web_build_dir: temp_dir.join("web"),
                web_shell_file: temp_dir.join("web").join("spa.html"),
            };
            StudioStorage::open(&config).await.expect("storage")
        });
        let context = AppContext::new(config, storage);
        let session_id = Uuid::new_v4().to_string();
        let other_session_id = Uuid::new_v4().to_string();
        let (preview_token, expires_at_ms) = context.new_workspace_file_change_preview_token();
        let request = WorkspaceFileChangePreviewRequest {
            action: WorkspaceFileChangeAction::WriteText,
            path: "docs/new.md".to_string(),
            target_path: None,
            content: Some("# New\n".to_string()),
            expected_modified_at_ms: None,
        };

        context.register_workspace_file_change_preview(
            &session_id,
            preview_token,
            expires_at_ms,
            request,
            None,
            None,
        );

        assert!(
            context
                .consume_workspace_file_change_preview(&other_session_id, preview_token)
                .is_err()
        );
        let record = context
            .consume_workspace_file_change_preview(&session_id, preview_token)
            .expect("original session should still own the preview");

        assert_eq!(record.session_id, session_id);
    }

    #[tokio::test]
    async fn workspace_files_route_lists_project_root_with_escape_guard() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-workspace-http-{}", Uuid::new_v4()));
        std::fs::create_dir_all(temp_dir.join("docs")).expect("docs dir");
        std::fs::create_dir_all(temp_dir.join("src")).expect("src dir");
        std::fs::write(temp_dir.join("README.md"), "# Readme").expect("readme");
        std::fs::write(temp_dir.join("docs").join("guide.md"), "# Guide").expect("guide");
        std::fs::write(
            temp_dir.join("Cargo.toml"),
            "[package]\nname = \"soma-studio-route-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
        )
        .expect("cargo manifest");
        std::fs::write(
            temp_dir.join("src").join("lib.rs"),
            "pub fn ok() -> bool { true }\n",
        )
        .expect("cargo lib");
        let git_init = std::process::Command::new("git")
            .arg("init")
            .arg("--quiet")
            .current_dir(&temp_dir)
            .output()
            .expect("run git init");
        assert!(
            git_init.status.success(),
            "git init failed: {}",
            String::from_utf8_lossy(&git_init.stderr)
        );
        let config = AppConfig {
            app_name: "Soma Studio".to_string(),
            bind_addr: "127.0.0.1:0".to_string(),
            project_root: temp_dir.clone(),
            data_dir: temp_dir.clone(),
            derived_dir: temp_dir.join("derived"),
            notebook_dir: temp_dir.join("notebook"),
            user_assets_dir: temp_dir.join("assets"),
            db_path: temp_dir.join("test.db"),
            web_build_dir: temp_dir.join("web"),
            web_shell_file: temp_dir.join("web").join("spa.html"),
        };
        let storage = StudioStorage::open(&config).await.expect("storage");
        let context = AppContext::new(config, storage);
        let session = context.issue_session().await.expect("session");
        let cookie = format!("soma_studio_session={}", session.id);
        let app = TestApp::new(build_ingress(&context), ());

        let root_response = app
            .send(TestRequest::get("/api/workspace/files").header("cookie", &cookie))
            .await
            .expect("workspace root response");
        assert_eq!(root_response.status(), StatusCode::OK);
        let root: WorkspaceFileListResponse = root_response.json().expect("workspace root payload");
        assert_eq!(root.path, "");
        assert_eq!(root.parent_path, None);
        assert!(
            root.entries
                .iter()
                .any(|entry| entry.name == "docs" && entry.kind == WorkspaceEntryKind::Directory)
        );
        assert!(
            root.entries
                .iter()
                .any(|entry| entry.name == "README.md" && entry.kind == WorkspaceEntryKind::File)
        );

        let docs_response = app
            .send(TestRequest::get("/api/workspace/files?path=docs").header("cookie", &cookie))
            .await
            .expect("workspace docs response");
        assert_eq!(docs_response.status(), StatusCode::OK);
        let docs: WorkspaceFileListResponse = docs_response.json().expect("workspace docs payload");
        assert_eq!(docs.path, "docs");
        assert_eq!(docs.parent_path.as_deref(), Some(""));
        assert_eq!(docs.entries.len(), 1);
        assert_eq!(docs.entries[0].path, "docs/guide.md");

        let preview_response = app
            .send(
                TestRequest::get("/api/workspace/file-preview?path=docs%2Fguide.md")
                    .header("cookie", &cookie),
            )
            .await
            .expect("workspace preview response");
        assert_eq!(preview_response.status(), StatusCode::OK);
        let preview: WorkspaceFilePreviewResponse =
            preview_response.json().expect("workspace preview payload");
        assert_eq!(preview.path, "docs/guide.md");
        assert_eq!(preview.content, "# Guide");
        assert!(!preview.truncated);

        let change_preview_response = app
            .send(
                TestRequest::post("/api/workspace/file-change-preview")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "action": "write_text",
                        "path": "docs/new.md",
                        "content": "# New\n",
                        "expected_modified_at_ms": null
                    }))
                    .expect("workspace file change preview request"),
            )
            .await
            .expect("workspace file change preview response");
        assert_eq!(change_preview_response.status(), StatusCode::OK);
        let change_preview: WorkspaceFileChangePreviewResponse = change_preview_response
            .json()
            .expect("workspace file change preview payload");
        assert_eq!(change_preview.path, "docs/new.md");
        assert!(!change_preview.exists_before);
        assert_eq!(change_preview.size_bytes_after, Some(6));
        assert!(change_preview.diff_preview.contains("+# New"));
        assert!(!temp_dir.join("docs").join("new.md").exists());

        let change_apply_response = app
            .send(
                TestRequest::post("/api/workspace/file-change-apply")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "preview_token": change_preview.preview_token }))
                    .expect("workspace file change apply request"),
            )
            .await
            .expect("workspace file change apply response");
        assert_eq!(change_apply_response.status(), StatusCode::OK);
        let change_apply: WorkspaceFileChangeApplyResponse = change_apply_response
            .json()
            .expect("workspace file change apply payload");
        assert!(change_apply.applied);
        assert!(change_apply.exists_after);
        assert_eq!(
            std::fs::read_to_string(temp_dir.join("docs").join("new.md")).expect("new file"),
            "# New\n"
        );

        let stale_apply_response = app
            .send(
                TestRequest::post("/api/workspace/file-change-apply")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "preview_token": change_preview.preview_token }))
                    .expect("stale workspace file change apply request"),
            )
            .await
            .expect("stale workspace file change apply response");
        assert_eq!(stale_apply_response.status(), StatusCode::NOT_FOUND);
        let stale_apply_error: ApiErrorResponse = stale_apply_response
            .json()
            .expect("stale workspace file change apply error");
        assert_eq!(
            stale_apply_error.code,
            "workspace_file_change_preview_missing"
        );
        assert_eq!(stale_apply_error.status, StatusCode::NOT_FOUND.as_u16());

        let rename_preview_response = app
            .send(
                TestRequest::post("/api/workspace/file-change-preview")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "action": "rename_path",
                        "path": "docs/new.md",
                        "target_path": "docs/renamed.md",
                        "content": null,
                        "expected_modified_at_ms": change_apply.modified_at_ms_after
                    }))
                    .expect("workspace file rename preview request"),
            )
            .await
            .expect("workspace file rename preview response");
        assert_eq!(rename_preview_response.status(), StatusCode::OK);
        let rename_preview: WorkspaceFileChangePreviewResponse = rename_preview_response
            .json()
            .expect("workspace file rename preview payload");
        assert_eq!(rename_preview.path, "docs/new.md");
        assert_eq!(
            rename_preview.target_path.as_deref(),
            Some("docs/renamed.md")
        );
        assert_eq!(rename_preview.size_bytes_after, Some(6));
        assert!(
            rename_preview
                .diff_preview
                .contains("rename from docs/new.md")
        );
        assert!(
            rename_preview
                .diff_preview
                .contains("rename to docs/renamed.md")
        );
        assert!(!temp_dir.join("docs").join("renamed.md").exists());

        std::fs::write(temp_dir.join("docs").join("renamed.md"), "# Existing\n")
            .expect("race rename target");
        let rename_race_apply_response = app
            .send(
                TestRequest::post("/api/workspace/file-change-apply")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "preview_token": rename_preview.preview_token }))
                    .expect("workspace file rename race apply request"),
            )
            .await
            .expect("workspace file rename race apply response");
        assert_eq!(rename_race_apply_response.status(), StatusCode::CONFLICT);
        let rename_race_error: ApiErrorResponse = rename_race_apply_response
            .json()
            .expect("workspace file rename race error");
        assert_eq!(rename_race_error.code, "workspace_conflict");
        assert_eq!(rename_race_error.status, StatusCode::CONFLICT.as_u16());
        assert_eq!(
            std::fs::read_to_string(temp_dir.join("docs").join("new.md")).expect("new file"),
            "# New\n"
        );
        assert_eq!(
            std::fs::read_to_string(temp_dir.join("docs").join("renamed.md"))
                .expect("existing rename target"),
            "# Existing\n"
        );
        std::fs::remove_file(temp_dir.join("docs").join("renamed.md"))
            .expect("remove race rename target");

        let rename_preview_response = app
            .send(
                TestRequest::post("/api/workspace/file-change-preview")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "action": "rename_path",
                        "path": "docs/new.md",
                        "target_path": "docs/renamed.md",
                        "content": null,
                        "expected_modified_at_ms": change_apply.modified_at_ms_after
                    }))
                    .expect("workspace file rename preview request"),
            )
            .await
            .expect("workspace file rename preview response");
        assert_eq!(rename_preview_response.status(), StatusCode::OK);
        let rename_preview: WorkspaceFileChangePreviewResponse = rename_preview_response
            .json()
            .expect("workspace file rename preview payload");

        let rename_apply_response = app
            .send(
                TestRequest::post("/api/workspace/file-change-apply")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "preview_token": rename_preview.preview_token }))
                    .expect("workspace file rename apply request"),
            )
            .await
            .expect("workspace file rename apply response");
        assert_eq!(rename_apply_response.status(), StatusCode::OK);
        let rename_apply: WorkspaceFileChangeApplyResponse = rename_apply_response
            .json()
            .expect("workspace file rename apply payload");
        assert!(rename_apply.applied);
        assert!(rename_apply.exists_after);
        assert_eq!(rename_apply.target_path.as_deref(), Some("docs/renamed.md"));
        assert!(!temp_dir.join("docs").join("new.md").exists());
        assert_eq!(
            std::fs::read_to_string(temp_dir.join("docs").join("renamed.md"))
                .expect("renamed file"),
            "# New\n"
        );

        let rename_overwrite_response = app
            .send(
                TestRequest::post("/api/workspace/file-change-preview")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "action": "rename_path",
                        "path": "docs/renamed.md",
                        "target_path": "docs/guide.md",
                        "content": null,
                        "expected_modified_at_ms": rename_apply.modified_at_ms_after
                    }))
                    .expect("workspace file rename overwrite preview request"),
            )
            .await
            .expect("workspace file rename overwrite preview response");
        assert_eq!(rename_overwrite_response.status(), StatusCode::CONFLICT);

        let delete_preview_response = app
            .send(
                TestRequest::post("/api/workspace/file-change-preview")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "action": "delete_file",
                        "path": "docs/renamed.md",
                        "content": null,
                        "expected_modified_at_ms": rename_apply.modified_at_ms_after
                    }))
                    .expect("workspace file delete preview request"),
            )
            .await
            .expect("workspace file delete preview response");
        assert_eq!(delete_preview_response.status(), StatusCode::OK);
        let delete_preview: WorkspaceFileChangePreviewResponse = delete_preview_response
            .json()
            .expect("workspace file delete preview payload");
        assert!(delete_preview.exists_before);
        assert!(delete_preview.diff_preview.contains("-# New"));

        let delete_apply_response = app
            .send(
                TestRequest::post("/api/workspace/file-change-apply")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "preview_token": delete_preview.preview_token }))
                    .expect("workspace file delete apply request"),
            )
            .await
            .expect("workspace file delete apply response");
        assert_eq!(delete_apply_response.status(), StatusCode::OK);
        let delete_apply: WorkspaceFileChangeApplyResponse = delete_apply_response
            .json()
            .expect("workspace file delete apply payload");
        assert!(delete_apply.applied);
        assert!(!delete_apply.exists_after);
        assert!(!temp_dir.join("docs").join("renamed.md").exists());

        let file_change_audits_response = app
            .send(TestRequest::get("/api/workspace/file-change-audits").header("cookie", &cookie))
            .await
            .expect("workspace file change audits response");
        assert_eq!(file_change_audits_response.status(), StatusCode::OK);
        let file_change_audits: Vec<WorkspaceFileChangeAuditEntry> = file_change_audits_response
            .json()
            .expect("workspace file change audits payload");
        let audit_json = serde_json::to_string(&file_change_audits).expect("audit json");
        assert_eq!(file_change_audits.len(), 4);
        assert_eq!(
            file_change_audits
                .iter()
                .filter(|audit| audit.status == WorkspaceFileChangeAuditStatus::Complete)
                .count(),
            3
        );
        assert_eq!(
            file_change_audits
                .iter()
                .filter(|audit| audit.status == WorkspaceFileChangeAuditStatus::Failed)
                .count(),
            1
        );
        assert!(file_change_audits.iter().any(|audit| {
            audit.action == WorkspaceFileChangeAction::RenamePath
                && audit.status == WorkspaceFileChangeAuditStatus::Failed
                && audit.error_code.as_deref() == Some("workspace_conflict")
                && audit
                    .error
                    .as_deref()
                    .is_some_and(|error| error.contains("already exists"))
        }));
        let conflict_file_change_audits_response = app
            .send(
                TestRequest::get("/api/workspace/file-change-audits?error_code=workspace_conflict")
                    .header("cookie", &cookie),
            )
            .await
            .expect("workspace conflict file change audits response");
        assert_eq!(
            conflict_file_change_audits_response.status(),
            StatusCode::OK
        );
        let conflict_file_change_audits: Vec<WorkspaceFileChangeAuditEntry> =
            conflict_file_change_audits_response
                .json()
                .expect("workspace conflict file change audits payload");
        assert_eq!(conflict_file_change_audits.len(), 1);
        assert_eq!(
            conflict_file_change_audits[0].error_code.as_deref(),
            Some("workspace_conflict")
        );
        assert!(!audit_json.contains("# New"));
        assert!(!audit_json.contains("diff_preview"));

        let unsafe_change_preview_response = app
            .send(
                TestRequest::post("/api/workspace/file-change-preview")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "action": "write_text",
                        "path": "..",
                        "content": "outside",
                        "expected_modified_at_ms": null
                    }))
                    .expect("unsafe workspace file change preview request"),
            )
            .await
            .expect("unsafe workspace file change preview response");
        assert_eq!(
            unsafe_change_preview_response.status(),
            StatusCode::BAD_REQUEST
        );
        let unsafe_change_error: ApiErrorResponse = unsafe_change_preview_response
            .json()
            .expect("unsafe workspace file change preview error");
        assert_eq!(unsafe_change_error.code, "invalid_request");
        assert_eq!(unsafe_change_error.status, StatusCode::BAD_REQUEST.as_u16());

        let directory_preview_response = app
            .send(
                TestRequest::get("/api/workspace/file-preview?path=docs").header("cookie", &cookie),
            )
            .await
            .expect("workspace directory preview response");
        assert_eq!(directory_preview_response.status(), StatusCode::BAD_REQUEST);

        let source_root_response = app
            .send(
                TestRequest::post("/api/workspace/source-root")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "path": "docs" }))
                    .expect("workspace source root request"),
            )
            .await
            .expect("workspace source root response");
        assert_eq!(source_root_response.status(), StatusCode::OK);
        let source_root: SourceRootSummary = source_root_response
            .json()
            .expect("workspace source root payload");
        assert!(source_root.read_only);
        assert!(source_root.path.replace('\\', "/").ends_with("/docs"));

        let source_root_rescan_response = app
            .send(
                TestRequest::post("/api/ingest/rescan")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "source_root_id": source_root.id }))
                    .expect("workspace source root rescan request"),
            )
            .await
            .expect("workspace source root rescan response");
        assert_eq!(source_root_rescan_response.status(), StatusCode::OK);
        let source_root_rescan: IngestJobSummary = source_root_rescan_response
            .json()
            .expect("workspace source root rescan payload");
        assert_eq!(source_root_rescan.status, "complete");
        assert_eq!(source_root_rescan.file_count, 1);

        let workspace_task_response = app
            .send(
                TestRequest::post("/api/workspace/task")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "task_id": "git_status" }))
                    .expect("workspace task request"),
            )
            .await
            .expect("workspace task response");
        assert_eq!(workspace_task_response.status(), StatusCode::OK);
        let workspace_task: WorkspaceTaskRunResponse = workspace_task_response
            .json()
            .expect("workspace task payload");
        assert_eq!(workspace_task.path, ".");
        assert_eq!(workspace_task.command_label, "git status --short --branch");
        assert!(!workspace_task.timed_out);
        assert!(!workspace_task.cancelled);
        assert_eq!(workspace_task.exit_code, Some(0));
        assert!(workspace_task.stdout.contains("?? README.md"));
        assert_eq!(workspace_task.max_output_bytes, 64 * 1024);

        let inline_long_task_response = app
            .send(
                TestRequest::post("/api/workspace/task")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "task_id": "cargo_check" }))
                    .expect("inline long workspace task request"),
            )
            .await
            .expect("inline long workspace task response");
        assert_eq!(inline_long_task_response.status(), StatusCode::BAD_REQUEST);
        let inline_long_task_error: ApiErrorResponse = inline_long_task_response
            .json()
            .expect("inline long workspace task error");
        assert_eq!(inline_long_task_error.code, "invalid_request");
        assert_eq!(
            inline_long_task_error.status,
            StatusCode::BAD_REQUEST.as_u16()
        );

        let task_run_start_response = app
            .send(
                TestRequest::post("/api/workspace/task-runs")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "task_id": "git_status" }))
                    .expect("workspace task run start request"),
            )
            .await
            .expect("workspace task run start response");
        assert_eq!(task_run_start_response.status(), StatusCode::OK);
        let task_run: WorkspaceTaskRunSummary = task_run_start_response
            .json()
            .expect("workspace task run start payload");
        assert_eq!(task_run.path, ".");
        assert_eq!(task_run.status, WorkspaceTaskRunStatus::Running);
        assert_eq!(task_run.command_label, "git status --short --branch");
        assert_eq!(task_run.exit_code, None);

        let mut completed_task_run = None;
        for _ in 0..50 {
            let task_run_response = app
                .send(
                    TestRequest::get(format!("/api/workspace/task-runs/{}", task_run.run_id))
                        .header("cookie", &cookie),
                )
                .await
                .expect("workspace task run get response");
            assert_eq!(task_run_response.status(), StatusCode::OK);
            let latest: WorkspaceTaskRunSummary = task_run_response
                .json()
                .expect("workspace task run get payload");
            if matches!(
                latest.status,
                WorkspaceTaskRunStatus::Complete
                    | WorkspaceTaskRunStatus::Failed
                    | WorkspaceTaskRunStatus::Cancelled
                    | WorkspaceTaskRunStatus::TimedOut
            ) {
                completed_task_run = Some(latest);
                break;
            }
            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
        }
        let completed_task_run = completed_task_run.expect("workspace task run should finish");
        assert_eq!(completed_task_run.status, WorkspaceTaskRunStatus::Complete);
        assert_eq!(completed_task_run.exit_code, Some(0));
        assert!(completed_task_run.stdout_tail.contains("?? README.md"));
        assert!(completed_task_run.completed_at_ms.is_some());
        assert_eq!(completed_task_run.max_output_bytes, 64 * 1024);

        let task_runs_response = app
            .send(TestRequest::get("/api/workspace/task-runs").header("cookie", &cookie))
            .await
            .expect("workspace task runs list response");
        assert_eq!(task_runs_response.status(), StatusCode::OK);
        let task_runs: Vec<WorkspaceTaskRunSummary> = task_runs_response
            .json()
            .expect("workspace task runs list payload");
        assert!(task_runs.iter().any(|run| run.run_id == task_run.run_id));

        context
            .workspace_task_runs
            .write()
            .expect("workspace task runs")
            .clear();
        let persisted_task_run_response = app
            .send(
                TestRequest::get(format!("/api/workspace/task-runs/{}", task_run.run_id))
                    .header("cookie", &cookie),
            )
            .await
            .expect("persisted workspace task run get response");
        assert_eq!(persisted_task_run_response.status(), StatusCode::OK);
        let persisted_task_run: WorkspaceTaskRunSummary = persisted_task_run_response
            .json()
            .expect("persisted workspace task run payload");
        assert_eq!(persisted_task_run.run_id, task_run.run_id);
        assert_eq!(persisted_task_run.status, WorkspaceTaskRunStatus::Complete);

        let persisted_task_runs_response = app
            .send(TestRequest::get("/api/workspace/task-runs").header("cookie", &cookie))
            .await
            .expect("persisted workspace task runs list response");
        assert_eq!(persisted_task_runs_response.status(), StatusCode::OK);
        let persisted_task_runs: Vec<WorkspaceTaskRunSummary> = persisted_task_runs_response
            .json()
            .expect("persisted workspace task runs list payload");
        assert!(
            persisted_task_runs
                .iter()
                .any(|run| run.run_id == task_run.run_id)
        );
        let filtered_task_runs_response = app
            .send(
                TestRequest::get("/api/workspace/task-runs?error_code=workspace_task_failed_exit")
                    .header("cookie", &cookie),
            )
            .await
            .expect("filtered workspace task runs list response");
        assert_eq!(filtered_task_runs_response.status(), StatusCode::OK);
        let filtered_task_runs: Vec<WorkspaceTaskRunSummary> = filtered_task_runs_response
            .json()
            .expect("filtered workspace task runs list payload");
        assert!(filtered_task_runs.is_empty());

        let cancel_completed_response = app
            .send(
                TestRequest::post(format!(
                    "/api/workspace/task-runs/{}/cancel",
                    task_run.run_id
                ))
                .header("cookie", &cookie)
                .header("origin", "http://test.local"),
            )
            .await
            .expect("workspace task run cancel response");
        assert_eq!(cancel_completed_response.status(), StatusCode::OK);
        let cancel_completed: WorkspaceTaskRunSummary = cancel_completed_response
            .json()
            .expect("workspace task run cancel payload");
        assert_eq!(cancel_completed.status, WorkspaceTaskRunStatus::Complete);

        let cargo_check_start_response = app
            .send(
                TestRequest::post("/api/workspace/task-runs")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "task_id": "cargo_check" }))
                    .expect("workspace cargo check start request"),
            )
            .await
            .expect("workspace cargo check start response");
        assert_eq!(cargo_check_start_response.status(), StatusCode::OK);
        let cargo_check: WorkspaceTaskRunSummary = cargo_check_start_response
            .json()
            .expect("workspace cargo check start payload");
        assert_eq!(cargo_check.path, ".");
        assert_eq!(cargo_check.status, WorkspaceTaskRunStatus::Running);
        assert_eq!(cargo_check.command_label, "cargo check --workspace");

        let mut completed_cargo_check = None;
        for _ in 0..100 {
            let cargo_check_response = app
                .send(
                    TestRequest::get(format!("/api/workspace/task-runs/{}", cargo_check.run_id))
                        .header("cookie", &cookie),
                )
                .await
                .expect("workspace cargo check get response");
            assert_eq!(cargo_check_response.status(), StatusCode::OK);
            let latest: WorkspaceTaskRunSummary = cargo_check_response
                .json()
                .expect("workspace cargo check get payload");
            if matches!(
                latest.status,
                WorkspaceTaskRunStatus::Complete
                    | WorkspaceTaskRunStatus::Failed
                    | WorkspaceTaskRunStatus::Cancelled
                    | WorkspaceTaskRunStatus::TimedOut
            ) {
                completed_cargo_check = Some(latest);
                break;
            }
            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
        }
        let completed_cargo_check =
            completed_cargo_check.expect("workspace cargo check should finish");
        assert_eq!(
            completed_cargo_check.status,
            WorkspaceTaskRunStatus::Complete
        );
        assert_eq!(completed_cargo_check.exit_code, Some(0));
        assert_eq!(completed_cargo_check.path, ".");

        let scoped_cargo_check_response = app
            .send(
                TestRequest::post("/api/workspace/task-runs")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "task_id": "cargo_check", "path": "docs" }))
                    .expect("workspace scoped cargo check request"),
            )
            .await
            .expect("workspace scoped cargo check response");
        assert_eq!(
            scoped_cargo_check_response.status(),
            StatusCode::BAD_REQUEST
        );
        let scoped_cargo_check_error: ApiErrorResponse = scoped_cargo_check_response
            .json()
            .expect("workspace scoped cargo check error");
        assert_eq!(scoped_cargo_check_error.code, "invalid_request");

        let git_add = std::process::Command::new("git")
            .arg("add")
            .arg("README.md")
            .current_dir(&temp_dir)
            .output()
            .expect("run git add");
        assert!(
            git_add.status.success(),
            "git add failed: {}",
            String::from_utf8_lossy(&git_add.stderr)
        );
        std::fs::write(temp_dir.join("README.md"), "# Readme\n\nchanged").expect("edit readme");

        let workspace_diff_response = app
            .send(
                TestRequest::post("/api/workspace/task")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "task_id": "git_diff", "path": "README.md" }))
                    .expect("workspace diff request"),
            )
            .await
            .expect("workspace diff response");
        assert_eq!(workspace_diff_response.status(), StatusCode::OK);
        let workspace_diff: WorkspaceTaskRunResponse = workspace_diff_response
            .json()
            .expect("workspace diff payload");
        assert_eq!(workspace_diff.path, "README.md");
        assert_eq!(workspace_diff.command_label, "git diff --no-ext-diff");
        assert_eq!(workspace_diff.exit_code, Some(0));
        assert!(!workspace_diff.cancelled);
        assert!(workspace_diff.stdout.contains("-# Readme"));
        assert!(workspace_diff.stdout.contains("+changed"));

        let unsafe_workspace_task_response = app
            .send(
                TestRequest::post("/api/workspace/task")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "task_id": "git_status", "path": ".." }))
                    .expect("unsafe workspace task request"),
            )
            .await
            .expect("unsafe workspace task response");
        assert_eq!(
            unsafe_workspace_task_response.status(),
            StatusCode::BAD_REQUEST
        );
        let unsafe_workspace_task_error: ApiErrorResponse = unsafe_workspace_task_response
            .json()
            .expect("unsafe workspace task error");
        assert_eq!(unsafe_workspace_task_error.code, "invalid_request");

        let unsafe_task_run_response = app
            .send(
                TestRequest::post("/api/workspace/task-runs")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "task_id": "git_status", "path": ".." }))
                    .expect("unsafe workspace task run request"),
            )
            .await
            .expect("unsafe workspace task run response");
        assert_eq!(unsafe_task_run_response.status(), StatusCode::BAD_REQUEST);
        let unsafe_task_run_error: ApiErrorResponse = unsafe_task_run_response
            .json()
            .expect("unsafe workspace task run error");
        assert_eq!(unsafe_task_run_error.code, "invalid_request");

        let missing_task_run_response = app
            .send(
                TestRequest::get(format!("/api/workspace/task-runs/{}", Uuid::new_v4()))
                    .header("cookie", &cookie),
            )
            .await
            .expect("missing workspace task run response");
        assert_eq!(missing_task_run_response.status(), StatusCode::NOT_FOUND);
        let missing_task_run_error: ApiErrorResponse = missing_task_run_response
            .json()
            .expect("missing workspace task run error");
        assert_eq!(missing_task_run_error.code, "workspace_task_run_not_found");

        let unsafe_source_root_response = app
            .send(
                TestRequest::post("/api/workspace/source-root")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "path": ".." }))
                    .expect("unsafe workspace source root request"),
            )
            .await
            .expect("unsafe workspace source root response");
        assert_eq!(
            unsafe_source_root_response.status(),
            StatusCode::BAD_REQUEST
        );

        let escape_response = app
            .send(TestRequest::get("/api/workspace/files?path=..").header("cookie", &cookie))
            .await
            .expect("workspace escape response");
        assert_eq!(escape_response.status(), StatusCode::BAD_REQUEST);

        let missing_response = app
            .send(TestRequest::get("/api/workspace/files?path=missing").header("cookie", &cookie))
            .await
            .expect("workspace missing response");
        assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);

        let _ = std::fs::remove_dir_all(temp_dir);
    }

    #[tokio::test]
    async fn conversation_routes_roundtrip_and_rehydrate_on_restart() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-http-test-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = AppConfig {
            app_name: "Soma Studio".to_string(),
            bind_addr: "127.0.0.1:0".to_string(),
            project_root: temp_dir.clone(),
            data_dir: temp_dir.clone(),
            derived_dir: temp_dir.join("derived"),
            notebook_dir: temp_dir.join("notebook"),
            user_assets_dir: temp_dir.join("assets"),
            db_path: temp_dir.join("test.db"),
            web_build_dir: temp_dir.join("web"),
            web_shell_file: temp_dir.join("web").join("spa.html"),
        };
        let storage = StudioStorage::open(&config).await.expect("storage");
        let context = AppContext::new(config.clone(), storage.clone());
        let session = context.issue_session().await.expect("session");
        let cookie = format!("soma_studio_session={}", session.id);
        let app = TestApp::new(build_ingress(&context), ());

        let unauthorized_response = app
            .send(
                TestRequest::get("/api/conversations")
                    .header("cookie", format!("soma_studio_session={}", Uuid::new_v4())),
            )
            .await
            .expect("unauthorized response");
        assert_eq!(unauthorized_response.status(), StatusCode::UNAUTHORIZED);

        let create_response = app
            .send(
                TestRequest::post("/api/conversations")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({}))
                    .expect("conversation create request"),
            )
            .await
            .expect("create response");
        assert_eq!(create_response.status(), StatusCode::OK);
        let conversation: ConversationSummary =
            create_response.json().expect("conversation payload");

        let chat_response = app
            .send(
                TestRequest::post("/api/chat/send")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "conversation_id": conversation.id,
                        "message": "restore this conversation after restart"
                    }))
                    .expect("chat request"),
            )
            .await
            .expect("chat response");
        assert_eq!(chat_response.status(), StatusCode::OK);
        let chat_payload: ChatSendResponse = chat_response.json().expect("chat payload");
        assert_eq!(chat_payload.conversation_id, conversation.id);
        assert!(!chat_payload.accepted);
        assert!(chat_payload.warning.is_some());
        assert_eq!(chat_payload.retrieval_strategy, "none");
        assert_eq!(chat_payload.retrieval_result_count, 0);

        let list_response = app
            .send(TestRequest::get("/api/conversations").header("cookie", &cookie))
            .await
            .expect("list response");
        assert_eq!(list_response.status(), StatusCode::OK);
        let listed: Vec<ConversationSummary> = list_response.json().expect("conversation list");
        assert_eq!(listed.len(), 1);

        let messages_response = app
            .send(
                TestRequest::get(format!("/api/conversations/{}/messages", conversation.id))
                    .header("cookie", &cookie),
            )
            .await
            .expect("messages response");
        assert_eq!(
            messages_response.status(),
            StatusCode::OK,
            "{}",
            messages_response.text().unwrap_or("<non-utf8>")
        );
        let messages: Vec<ConversationMessage> =
            messages_response.json().expect("messages payload");
        assert_eq!(messages.len(), 2);
        assert_eq!(messages[0].role, "user");
        assert_eq!(messages[1].role, "assistant");

        let restarted_context = AppContext::new(config, storage);
        let restarted_app = TestApp::new(build_ingress(&restarted_context), ());
        let restarted_init_response = restarted_app
            .send(TestRequest::get("/api/app/init").header("cookie", &cookie))
            .await
            .expect("restarted init response");
        assert_eq!(restarted_init_response.status(), StatusCode::OK);
        let restarted_init: serde_json::Value = restarted_init_response
            .json()
            .expect("restarted init payload");
        assert_eq!(
            restarted_init
                .get("authenticated")
                .and_then(|value| value.as_bool()),
            Some(true)
        );
        assert_eq!(
            restarted_init
                .get("session_id")
                .and_then(|value| value.as_str()),
            Some(session.id.as_str())
        );

        let restarted_list_response = restarted_app
            .send(TestRequest::get("/api/conversations").header("cookie", &cookie))
            .await
            .expect("restarted list response");
        assert_eq!(restarted_list_response.status(), StatusCode::OK);
        let restarted_list: Vec<ConversationSummary> = restarted_list_response
            .json()
            .expect("restarted conversation list");
        assert_eq!(restarted_list.len(), 1);
        assert_eq!(restarted_list[0].id, conversation.id);

        let delete_response = restarted_app
            .send(
                TestRequest::delete(format!("/api/conversations/{}", conversation.id))
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local"),
            )
            .await
            .expect("delete response");
        assert_eq!(delete_response.status(), StatusCode::OK);
        let delete_payload: ConversationDeleteResponse =
            delete_response.json().expect("delete payload");
        assert!(delete_payload.deleted);
    }

    #[tokio::test]
    async fn provider_mutation_routes_reject_unsupported_providers_as_client_errors() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-provider-http-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = AppConfig {
            app_name: "Soma Studio".to_string(),
            bind_addr: "127.0.0.1:0".to_string(),
            project_root: temp_dir.clone(),
            data_dir: temp_dir.clone(),
            derived_dir: temp_dir.join("derived"),
            notebook_dir: temp_dir.join("notebook"),
            user_assets_dir: temp_dir.join("assets"),
            db_path: temp_dir.join("test.db"),
            web_build_dir: temp_dir.join("web"),
            web_shell_file: temp_dir.join("web").join("spa.html"),
        };
        let storage = StudioStorage::open(&config).await.expect("storage");
        let context = AppContext::new(config, storage);
        let session = context.issue_session().await.expect("session");
        let cookie = format!("soma_studio_session={}", session.id);
        let app = TestApp::new(build_ingress(&context), ());

        let selection_response = app
            .send(
                TestRequest::post("/api/providers/selection")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "provider": "custom",
                        "model_id": "model-a"
                    }))
                    .expect("provider selection request"),
            )
            .await
            .expect("provider selection response");
        assert_eq!(selection_response.status(), StatusCode::BAD_REQUEST);

        let test_response = app
            .send(
                TestRequest::post("/api/providers/test")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "provider": "custom"
                    }))
                    .expect("provider test request"),
            )
            .await
            .expect("provider test response");
        assert_eq!(test_response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn provider_mutation_routes_normalize_provider_identifiers() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-provider-normalize-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = AppConfig {
            app_name: "Soma Studio".to_string(),
            bind_addr: "127.0.0.1:0".to_string(),
            project_root: temp_dir.clone(),
            data_dir: temp_dir.clone(),
            derived_dir: temp_dir.join("derived"),
            notebook_dir: temp_dir.join("notebook"),
            user_assets_dir: temp_dir.join("assets"),
            db_path: temp_dir.join("test.db"),
            web_build_dir: temp_dir.join("web"),
            web_shell_file: temp_dir.join("web").join("spa.html"),
        };
        let storage = StudioStorage::open(&config).await.expect("storage");
        let context = AppContext::new(config, storage);
        let session = context.issue_session().await.expect("session");
        let cookie = format!("soma_studio_session={}", session.id);
        let app = TestApp::new(build_ingress(&context), ());

        let selection_response = app
            .send(
                TestRequest::post("/api/providers/selection")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "provider": " Ollama ",
                        "model_id": " model-a "
                    }))
                    .expect("provider selection request"),
            )
            .await
            .expect("provider selection response");
        assert_eq!(selection_response.status(), StatusCode::OK);
        let selection: ProviderSelectionResponse = selection_response
            .json()
            .expect("provider selection payload");
        assert_eq!(selection.selected_provider.as_deref(), Some("ollama"));
        assert_eq!(selection.selected_model_id.as_deref(), Some("model-a"));

        let test_response = app
            .send(
                TestRequest::post("/api/providers/test")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "provider": " LMSTUDIO "
                    }))
                    .expect("provider test request"),
            )
            .await
            .expect("provider test response");
        assert_eq!(test_response.status(), StatusCode::OK);
        let provider_test: ProviderTestResponse =
            test_response.json().expect("provider test payload");
        assert_eq!(provider_test.provider, "lmstudio");
        assert_eq!(provider_test.endpoint, "http://127.0.0.1:1234");
    }

    #[tokio::test]
    async fn notebook_routes_roundtrip_markdown_and_typst_notes() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-notebook-http-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = AppConfig {
            app_name: "Soma Studio".to_string(),
            bind_addr: "127.0.0.1:0".to_string(),
            project_root: temp_dir.clone(),
            data_dir: temp_dir.clone(),
            derived_dir: temp_dir.join("derived"),
            notebook_dir: temp_dir.join("notebook"),
            user_assets_dir: temp_dir.join("assets"),
            db_path: temp_dir.join("test.db"),
            web_build_dir: temp_dir.join("web"),
            web_shell_file: temp_dir.join("web").join("spa.html"),
        };
        let storage = StudioStorage::open(&config).await.expect("storage");
        let context = AppContext::new(config, storage);
        let session = context.issue_session().await.expect("session");
        let cookie = format!("soma_studio_session={}", session.id);
        let app = TestApp::new(build_ingress(&context), ());

        let create_typst_response = app
            .send(
                TestRequest::post("/api/notebook/note")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "path": "reports/weekly.typ",
                        "format": "typst"
                    }))
                    .expect("typst create request"),
            )
            .await
            .expect("typst create response");
        assert_eq!(create_typst_response.status(), StatusCode::OK);
        let typst_note: NotebookNoteContent = create_typst_response.json().expect("typst note");
        assert_eq!(typst_note.format, NotebookNoteFormat::Typst);
        assert_eq!(typst_note.render_status, "not_rendered");

        let write_response = app
            .send(
                TestRequest::put("/api/notebook/note")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "path": "reports/weekly.typ",
                        "content": "= Weekly Report\n\nTypst artifact planning"
                    }))
                    .expect("typst write request"),
            )
            .await
            .expect("typst write response");
        assert_eq!(write_response.status(), StatusCode::OK);

        let list_response = app
            .send(TestRequest::get("/api/notebook/tree").header("cookie", &cookie))
            .await
            .expect("tree response");
        assert_eq!(list_response.status(), StatusCode::OK);
        let listed: Vec<NotebookNoteSummary> = list_response.json().expect("tree payload");
        assert_eq!(listed.len(), 1);
        assert_eq!(listed[0].path, "reports/weekly.typ");

        let read_response = app
            .send(
                TestRequest::get("/api/notebook/note?path=reports%2Fweekly.typ")
                    .header("cookie", &cookie),
            )
            .await
            .expect("encoded note read response");
        assert_eq!(read_response.status(), StatusCode::OK);
        let read_note: NotebookNoteContent = read_response.json().expect("encoded note payload");
        assert_eq!(read_note.path, "reports/weekly.typ");
        assert_eq!(
            read_note.content,
            "= Weekly Report\n\nTypst artifact planning"
        );

        let search_response = app
            .send(TestRequest::get("/api/notebook/search?query=artifact").header("cookie", &cookie))
            .await
            .expect("search response");
        assert_eq!(search_response.status(), StatusCode::OK);
        let results: Vec<NotebookSearchResult> = search_response.json().expect("search payload");
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].format, NotebookNoteFormat::Typst);

        let index_response = app
            .send(
                TestRequest::post("/api/notebook/index")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local"),
            )
            .await
            .expect("index response");
        assert_eq!(index_response.status(), StatusCode::OK);
        let index: NotebookIndexResponse = index_response.json().expect("index payload");
        assert_eq!(index.indexed, 1);
        assert_eq!(index.items[0].path, "reports/weekly.typ");
        assert_eq!(
            index.items[0].index_path,
            "notebook-index/reports/weekly.txt"
        );

        let index_status_response = app
            .send(TestRequest::get("/api/notebook/index").header("cookie", &cookie))
            .await
            .expect("index status response");
        assert_eq!(index_status_response.status(), StatusCode::OK);
        let index_status: NotebookIndexResponse =
            index_status_response.json().expect("index status payload");
        assert_eq!(index_status.indexed, 1);

        let chunks_response = app
            .send(
                TestRequest::post("/api/notebook/chunks")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local"),
            )
            .await
            .expect("chunks response");
        assert_eq!(chunks_response.status(), StatusCode::OK);
        let chunks: NotebookChunkResponse = chunks_response.json().expect("chunks payload");
        assert_eq!(chunks.chunked, 1);
        assert_eq!(
            chunks.items[0].chunk_path,
            "notebook-chunks/reports/weekly.json"
        );

        let chunks_status_response = app
            .send(TestRequest::get("/api/notebook/chunks").header("cookie", &cookie))
            .await
            .expect("chunks status response");
        assert_eq!(chunks_status_response.status(), StatusCode::OK);
        let chunks_status: NotebookChunkResponse = chunks_status_response
            .json()
            .expect("chunks status payload");
        assert_eq!(chunks_status.chunked, 1);

        let embeddings_response = app
            .send(
                TestRequest::post("/api/notebook/embeddings")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local"),
            )
            .await
            .expect("embeddings response");
        assert_eq!(embeddings_response.status(), StatusCode::BAD_REQUEST);

        let embeddings_status_response = app
            .send(TestRequest::get("/api/notebook/embeddings").header("cookie", &cookie))
            .await
            .expect("embeddings status response");
        assert_eq!(embeddings_status_response.status(), StatusCode::BAD_REQUEST);

        let retrieval_response = app
            .send(
                TestRequest::get("/api/notebook/retrieve?query=artifact").header("cookie", &cookie),
            )
            .await
            .expect("retrieval response");
        assert_eq!(retrieval_response.status(), StatusCode::OK);
        let retrieval: NotebookRetrievalResponse =
            retrieval_response.json().expect("retrieval payload");
        assert_eq!(retrieval.query, "artifact");
        assert_eq!(retrieval.results.len(), 1);
        assert_eq!(retrieval.results[0].path, "reports/weekly.typ");
        assert!(retrieval.results[0].score > 0);

        let retrieval_chat_response = app
            .send(
                TestRequest::post("/api/chat/send")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "message": "artifact planning"
                    }))
                    .expect("retrieval chat request"),
            )
            .await
            .expect("retrieval chat response");
        assert_eq!(retrieval_chat_response.status(), StatusCode::OK);
        let retrieval_chat: ChatSendResponse = retrieval_chat_response
            .json()
            .expect("retrieval chat payload");
        assert_eq!(retrieval_chat.retrieval_strategy, "lexical");
        assert_eq!(retrieval_chat.retrieval_result_count, 1);
        assert_eq!(retrieval_chat.retrieval_sources.len(), 1);
        assert_eq!(
            retrieval_chat.retrieval_sources[0].path,
            "reports/weekly.typ"
        );

        let adapters_response = app
            .send(TestRequest::get("/api/notebook/adapters").header("cookie", &cookie))
            .await
            .expect("adapters response");
        assert_eq!(adapters_response.status(), StatusCode::OK);
        let adapters: Vec<NotebookAdapterStatus> =
            adapters_response.json().expect("adapters payload");
        let typst_adapter = adapters
            .iter()
            .find(|adapter| adapter.id == "typst")
            .expect("typst adapter");

        let render_response = app
            .send(
                TestRequest::post("/api/notebook/render")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "path": "reports/weekly.typ",
                        "target": "pdf"
                    }))
                    .expect("render request"),
            )
            .await
            .expect("render response");
        if typst_adapter.status == "available" {
            assert_eq!(render_response.status(), StatusCode::OK);
            let render: NotebookRenderResponse = render_response.json().expect("render payload");
            assert_eq!(render.status, "complete");
            assert_eq!(
                render.artifact_url.as_deref(),
                Some("/api/notebook/artifact?path=reports/weekly.pdf")
            );
        } else {
            assert_eq!(render_response.status(), StatusCode::OK);
            let render: NotebookRenderResponse = render_response.json().expect("render payload");
            assert_eq!(render.status, typst_adapter.status);
        }

        let artifact = context
            .config
            .derived_dir
            .join("notebook-artifacts")
            .join("reports")
            .join("weekly.pdf");
        std::fs::create_dir_all(artifact.parent().expect("artifact parent")).expect("artifact dir");
        std::fs::write(&artifact, b"%PDF-1.7").expect("artifact");
        let artifact_response = app
            .send(
                TestRequest::get("/api/notebook/artifact?path=reports%2Fweekly.pdf")
                    .header("cookie", &cookie),
            )
            .await
            .expect("artifact response");
        assert_eq!(artifact_response.status(), StatusCode::OK);
        assert_eq!(artifact_response.body(), b"%PDF-1.7");

        let unauthenticated_artifact_response = app
            .send(TestRequest::get(
                "/api/notebook/artifact?path=reports/weekly.pdf",
            ))
            .await
            .expect("unauthenticated artifact response");
        assert_eq!(
            unauthenticated_artifact_response.status(),
            StatusCode::UNAUTHORIZED
        );

        let missing_artifact_response = app
            .send(
                TestRequest::get("/api/notebook/artifact?path=reports/missing.pdf")
                    .header("cookie", &cookie),
            )
            .await
            .expect("missing artifact response");
        assert_eq!(missing_artifact_response.status(), StatusCode::NOT_FOUND);

        let escape_response = app
            .send(
                TestRequest::post("/api/notebook/note")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "path": "../escape.md",
                        "format": "markdown"
                    }))
                    .expect("escape create request"),
            )
            .await
            .expect("escape response");
        assert_eq!(escape_response.status(), StatusCode::BAD_REQUEST);

        let _ = std::fs::remove_dir_all(temp_dir);
    }

    #[tokio::test]
    async fn ingest_routes_scan_source_roots_and_report_jobs() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-ingest-http-{}", Uuid::new_v4()));
        let source_root = temp_dir.join("source");
        std::fs::create_dir_all(&source_root).expect("source dir");
        std::fs::write(source_root.join("alpha.md"), "# Alpha\n\nhello").expect("alpha");
        std::fs::write(source_root.join("beta.txt"), "plain text").expect("beta");
        let config = AppConfig {
            app_name: "Soma Studio".to_string(),
            bind_addr: "127.0.0.1:0".to_string(),
            project_root: temp_dir.clone(),
            data_dir: temp_dir.clone(),
            derived_dir: temp_dir.join("derived"),
            notebook_dir: temp_dir.join("notebook"),
            user_assets_dir: temp_dir.join("assets"),
            db_path: temp_dir.join("test.db"),
            web_build_dir: temp_dir.join("web"),
            web_shell_file: temp_dir.join("web").join("spa.html"),
        };
        let storage = StudioStorage::open(&config).await.expect("storage");
        let context = AppContext::new(config, storage);
        let session = context.issue_session().await.expect("session");
        let cookie = format!("soma_studio_session={}", session.id);
        let app = TestApp::new(build_ingress(&context), ());

        let created_root = app
            .send(
                TestRequest::post("/api/source-roots")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "path": source_root.to_string_lossy() }))
                    .expect("root create request"),
            )
            .await
            .expect("root create response")
            .json::<SourceRootSummary>()
            .expect("root payload");

        let rescan_response = app
            .send(
                TestRequest::post("/api/ingest/rescan")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "source_root_id": created_root.id }))
                    .expect("rescan request"),
            )
            .await
            .expect("rescan response");
        assert_eq!(rescan_response.status(), StatusCode::OK);
        let job: IngestJobSummary = rescan_response.json().expect("rescan payload");
        assert_eq!(job.status, "complete");
        assert_eq!(job.file_count, 2);

        let jobs_response = app
            .send(TestRequest::get("/api/ingest/jobs").header("cookie", &cookie))
            .await
            .expect("jobs response");
        assert_eq!(jobs_response.status(), StatusCode::OK);
        let jobs: Vec<IngestJobSummary> = jobs_response.json().expect("jobs payload");
        assert_eq!(jobs.len(), 1);

        let status_response = app
            .send(TestRequest::get("/api/ingest/status").header("cookie", &cookie))
            .await
            .expect("status response");
        assert_eq!(status_response.status(), StatusCode::OK);
        let status: IngestStatusResponse = status_response.json().expect("status payload");
        assert!(!status.running);
        assert_eq!(status.total_source_files, 2);
        assert_eq!(status.indexed_text_files, 2);
        assert!(
            context
                .config
                .derived_dir
                .join("source-root-text")
                .join(created_root.id.to_string())
                .join("alpha.txt")
                .exists()
        );
        assert!(
            context
                .config
                .derived_dir
                .join("source-root-chunks")
                .join(created_root.id.to_string())
                .join("alpha.json")
                .exists()
        );

        let retrieval_response = app
            .send(TestRequest::get("/api/notebook/retrieve?query=hello").header("cookie", &cookie))
            .await
            .expect("source-root retrieval response");
        assert_eq!(retrieval_response.status(), StatusCode::OK);
        let retrieval: NotebookRetrievalResponse = retrieval_response
            .json()
            .expect("source-root retrieval payload");
        assert_eq!(retrieval.strategy, "lexical");
        assert_eq!(retrieval.results.len(), 1);
        assert_eq!(
            retrieval.results[0].path,
            format!("source-root/{}/alpha.md", created_root.id)
        );
        assert_eq!(
            retrieval.results[0].chunk_path,
            format!("source-root-chunks/{}/alpha.json", created_root.id)
        );

        let _ = std::fs::remove_dir_all(temp_dir);
    }

    #[tokio::test]
    async fn search_route_returns_notebook_and_source_root_results_without_provider() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-search-http-{}", Uuid::new_v4()));
        let source_root = temp_dir.join("source");
        std::fs::create_dir_all(&source_root).expect("source dir");
        std::fs::write(source_root.join("alpha.md"), "# Alpha\n\nsource artifact")
            .expect("source file");
        let config = AppConfig {
            app_name: "Soma Studio".to_string(),
            bind_addr: "127.0.0.1:0".to_string(),
            project_root: temp_dir.clone(),
            data_dir: temp_dir.clone(),
            derived_dir: temp_dir.join("derived"),
            notebook_dir: temp_dir.join("notebook"),
            user_assets_dir: temp_dir.join("assets"),
            db_path: temp_dir.join("test.db"),
            web_build_dir: temp_dir.join("web"),
            web_shell_file: temp_dir.join("web").join("spa.html"),
        };
        let storage = StudioStorage::open(&config).await.expect("storage");
        let context = AppContext::new(config, storage);
        let session = context.issue_session().await.expect("session");
        let cookie = format!("soma_studio_session={}", session.id);
        let app = TestApp::new(build_ingress(&context), ());

        let note_response = app
            .send(
                TestRequest::post("/api/notebook/note")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "path": "notes/search.md",
                        "format": "markdown"
                    }))
                    .expect("note create request"),
            )
            .await
            .expect("note create response");
        assert_eq!(note_response.status(), StatusCode::OK);
        let write_response = app
            .send(
                TestRequest::put("/api/notebook/note")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({
                        "path": "notes/search.md",
                        "content": "# Search\n\nnotebook artifact"
                    }))
                    .expect("note write request"),
            )
            .await
            .expect("note write response");
        assert_eq!(write_response.status(), StatusCode::OK);

        let created_root = app
            .send(
                TestRequest::post("/api/source-roots")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "path": source_root.to_string_lossy() }))
                    .expect("root create request"),
            )
            .await
            .expect("root create response")
            .json::<SourceRootSummary>()
            .expect("root payload");
        let rescan_response = app
            .send(
                TestRequest::post("/api/ingest/rescan")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local")
                    .json(&json!({ "source_root_id": created_root.id }))
                    .expect("rescan request"),
            )
            .await
            .expect("rescan response");
        assert_eq!(rescan_response.status(), StatusCode::OK);

        let missing_index_response = app
            .send(TestRequest::get("/api/search/index/status").header("cookie", &cookie))
            .await
            .expect("missing search index status response");
        assert_eq!(missing_index_response.status(), StatusCode::OK);
        let missing_index: SearchIndexStatusResponse = missing_index_response
            .json()
            .expect("missing search index status payload");
        assert!(!missing_index.ready);
        assert!(!missing_index.exists);

        let missing_search_response = app
            .send(TestRequest::get("/api/search?q=artifact").header("cookie", &cookie))
            .await
            .expect("missing index search response");
        assert_eq!(missing_search_response.status(), StatusCode::BAD_REQUEST);

        let rebuild_response = app
            .send(
                TestRequest::post("/api/search/index/rebuild")
                    .header("cookie", &cookie)
                    .header("origin", "http://test.local"),
            )
            .await
            .expect("search index rebuild response");
        assert_eq!(rebuild_response.status(), StatusCode::OK);
        let rebuild: SearchIndexRebuildResponse = rebuild_response
            .json()
            .expect("search index rebuild payload");
        assert_eq!(rebuild.indexed_documents, 2);
        assert!(rebuild.indexed_chunks >= 2);
        assert!(rebuild.status.ready);

        for endpoint in ["/api/search/index/sync", "/api/search/index/recover"] {
            let maintenance_response = app
                .send(
                    TestRequest::post(endpoint)
                        .header("cookie", &cookie)
                        .header("origin", "http://test.local"),
                )
                .await
                .expect("search index maintenance response");
            assert_eq!(maintenance_response.status(), StatusCode::OK);
            let maintenance: SearchIndexRebuildResponse = maintenance_response
                .json()
                .expect("search index maintenance payload");
            assert_eq!(maintenance.indexed_documents, 2);
            assert!(maintenance.indexed_chunks >= 2);
            assert!(maintenance.status.ready);
        }

        let ready_index_response = app
            .send(TestRequest::get("/api/search/index/status").header("cookie", &cookie))
            .await
            .expect("ready search index status response");
        assert_eq!(ready_index_response.status(), StatusCode::OK);
        let ready_index: SearchIndexStatusResponse = ready_index_response
            .json()
            .expect("ready search index status payload");
        assert!(ready_index.ready);
        assert_eq!(ready_index.document_count, 2);

        let search_response = app
            .send(TestRequest::get("/api/search?q=artifact").header("cookie", &cookie))
            .await
            .expect("search response");
        assert_eq!(search_response.status(), StatusCode::OK);
        let search: SearchResponse = search_response.json().expect("search payload");
        assert_eq!(search.query, "artifact");
        assert_eq!(search.field, SearchFieldScope::All);
        assert_eq!(search.sort, SearchSort::Relevance);
        assert_eq!(search.limit, 25);
        assert_eq!(search.offset, 0);
        assert_eq!(search.total_results, 2);
        assert_eq!(search.results.len(), 2);
        assert!(
            search
                .results
                .iter()
                .any(|result| result.source_type == SearchSourceType::Notebook)
        );
        assert!(
            search
                .results
                .iter()
                .any(|result| result.source_type == SearchSourceType::SourceRoot)
        );
        assert!(
            search
                .results
                .iter()
                .all(|result| result.highlights == vec!["artifact".to_string()])
        );
        assert!(
            search
                .results
                .iter()
                .all(|result| { result.status == soma_studio_core::SearchDocumentStatus::Ready })
        );

        let paged_response = app
            .send(
                TestRequest::get("/api/search?q=artifact&limit=1&offset=1")
                    .header("cookie", &cookie),
            )
            .await
            .expect("paged search response");
        assert_eq!(paged_response.status(), StatusCode::OK);
        let paged: SearchResponse = paged_response.json().expect("paged search payload");
        assert_eq!(paged.field, SearchFieldScope::All);
        assert_eq!(paged.limit, 1);
        assert_eq!(paged.offset, 1);
        assert_eq!(paged.total_results, 2);
        assert_eq!(paged.previous_cursor.as_deref(), Some("offset:0"));
        assert!(paged.next_cursor.is_none());
        assert_eq!(paged.results.len(), 1);

        let cursor_response = app
            .send(
                TestRequest::get("/api/search?q=artifact&limit=1&cursor=offset:1")
                    .header("cookie", &cookie),
            )
            .await
            .expect("cursor search response");
        assert_eq!(cursor_response.status(), StatusCode::OK);
        let cursor_page: SearchResponse = cursor_response.json().expect("cursor search payload");
        assert_eq!(cursor_page.offset, 1);
        assert_eq!(cursor_page.previous_cursor.as_deref(), Some("offset:0"));
        assert_eq!(cursor_page.results.len(), 1);

        let filtered_response = app
            .send(
                TestRequest::get("/api/search?q=artifact&source_type=source_root")
                    .header("cookie", &cookie),
            )
            .await
            .expect("filtered search response");
        assert_eq!(filtered_response.status(), StatusCode::OK);
        let filtered: SearchResponse = filtered_response.json().expect("filtered payload");
        assert_eq!(filtered.results.len(), 1);
        assert_eq!(
            filtered.results[0].source_type,
            SearchSourceType::SourceRoot
        );

        let title_field_response = app
            .send(TestRequest::get("/api/search?q=artifact&field=title").header("cookie", &cookie))
            .await
            .expect("title field search response");
        assert_eq!(title_field_response.status(), StatusCode::OK);
        let title_field: SearchResponse = title_field_response.json().expect("title field payload");
        assert_eq!(title_field.field, SearchFieldScope::Title);

        let sorted_response = app
            .send(
                TestRequest::get("/api/search?q=artifact&sort=updated_at")
                    .header("cookie", &cookie),
            )
            .await
            .expect("sorted search response");
        assert_eq!(sorted_response.status(), StatusCode::OK);
        let sorted: SearchResponse = sorted_response.json().expect("sorted payload");
        assert_eq!(sorted.sort, SearchSort::UpdatedAt);
        assert!(sorted.results.iter().all(|result| result.updated_at_ms > 0));

        let invalid_field_response = app
            .send(
                TestRequest::get("/api/search?q=artifact&field=unknown").header("cookie", &cookie),
            )
            .await
            .expect("invalid field search response");
        assert_eq!(invalid_field_response.status(), StatusCode::BAD_REQUEST);

        let invalid_sort_response = app
            .send(TestRequest::get("/api/search?q=artifact&sort=size").header("cookie", &cookie))
            .await
            .expect("invalid sort search response");
        assert_eq!(invalid_sort_response.status(), StatusCode::BAD_REQUEST);

        let invalid_cursor_response = app
            .send(TestRequest::get("/api/search?q=artifact&cursor=bad").header("cookie", &cookie))
            .await
            .expect("invalid cursor search response");
        assert_eq!(invalid_cursor_response.status(), StatusCode::BAD_REQUEST);

        let mixed_cursor_offset_response = app
            .send(
                TestRequest::get("/api/search?q=artifact&cursor=offset:1&offset=1")
                    .header("cookie", &cookie),
            )
            .await
            .expect("mixed cursor offset search response");
        assert_eq!(
            mixed_cursor_offset_response.status(),
            StatusCode::BAD_REQUEST
        );

        let encoded_open_path = filtered.results[0].path.replace('/', "%2F");
        let open_action_response = app
            .send(
                TestRequest::get(format!(
                    "/api/search/open-action?action=copy_path&path={}",
                    encoded_open_path
                ))
                .header("cookie", &cookie),
            )
            .await
            .expect("source open action response");
        assert_eq!(open_action_response.status(), StatusCode::OK);
        let open_action: SearchOpenActionResponse = open_action_response
            .json()
            .expect("source open action payload");
        assert!(open_action.allowed);
        assert_eq!(open_action.action, "copy_path");
        assert!(open_action.canonical_path.ends_with("alpha.md"));

        let invalid_filter_response = app
            .send(
                TestRequest::get("/api/search?q=artifact&source_type=external")
                    .header("cookie", &cookie),
            )
            .await
            .expect("invalid filtered search response");
        assert_eq!(invalid_filter_response.status(), StatusCode::BAD_REQUEST);

        let _ = std::fs::remove_dir_all(temp_dir);
    }
}
