use std::collections::HashSet;
use std::ffi::OsStr;
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};

use soma_studio_core::{
    AppConfig, NotebookAdapterStatus, NotebookChunkItem, NotebookChunkResponse,
    NotebookEmbeddingItem, NotebookEmbeddingResponse, NotebookIndexItem, NotebookIndexResponse,
    NotebookNoteContent, NotebookNoteCreateRequest, NotebookNoteFormat, NotebookNoteSummary,
    NotebookNoteWriteRequest, NotebookRenderRequest, NotebookRenderResponse,
    NotebookRetrievalResponse, NotebookRetrievalResult, NotebookSearchResult, SearchProfile,
    SearchQuery, lexical_score, search_snippet,
};

const TYPST_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);

pub fn adapter_statuses() -> Vec<NotebookAdapterStatus> {
    vec![typst_adapter_status()]
}

pub fn list_notes(config: &AppConfig) -> Result<Vec<NotebookNoteSummary>, String> {
    ensure_notebook_root(config)?;
    let mut notes = Vec::new();
    collect_notes(
        config,
        &config.notebook_dir,
        &config.notebook_dir,
        &mut notes,
    )?;
    notes.sort_by(|left, right| left.path.cmp(&right.path));
    Ok(notes)
}

pub fn read_note(config: &AppConfig, raw_path: &str) -> Result<NotebookNoteContent, String> {
    let relative = validate_note_path(raw_path)?;
    let resolved = existing_note_path(config, &relative)?;
    let content = std::fs::read_to_string(&resolved)
        .map_err(|error| format!("failed to read note {}: {error}", display_path(&relative)))?;
    let format = format_from_path(&relative)?;
    Ok(content_response(config, relative, format, content))
}

pub fn read_artifact(config: &AppConfig, raw_path: &str) -> Result<Vec<u8>, String> {
    let relative = validate_artifact_path(raw_path)?;
    let mut source_relative = relative.clone();
    source_relative.set_extension("typ");
    if !artifact_is_current(config, &source_relative, "pdf") {
        return Err("notebook artifact is missing or stale".to_string());
    }
    let artifact = existing_artifact_path(config, &relative)?;
    std::fs::read(&artifact).map_err(|error| {
        format!(
            "failed to read notebook artifact {}: {error}",
            display_path(&relative)
        )
    })
}

pub fn create_note(
    config: &AppConfig,
    input: NotebookNoteCreateRequest,
) -> Result<NotebookNoteContent, String> {
    ensure_notebook_root(config)?;
    let relative = validate_note_path(&input.path)?;
    let format = format_from_path(&relative)?;
    if format != input.format {
        return Err(format!(
            "note path extension does not match requested format: {}",
            display_path(&relative)
        ));
    }

    let target = creatable_note_path(config, &relative)?;
    if target.exists() {
        return Err(format!("note already exists: {}", display_path(&relative)));
    }
    if let Some(parent) = target.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|error| format!("failed to create note directory: {error}"))?;
        ensure_within_root(parent, &config.notebook_dir)?;
    }

    let content = starter_template(&relative, format, input.template_id.as_deref());
    std::fs::write(&target, &content)
        .map_err(|error| format!("failed to create note {}: {error}", display_path(&relative)))?;

    Ok(content_response(config, relative, format, content))
}

pub fn write_note(
    config: &AppConfig,
    input: NotebookNoteWriteRequest,
) -> Result<NotebookNoteContent, String> {
    let relative = validate_note_path(&input.path)?;
    let target = existing_note_path(config, &relative)?;
    let format = format_from_path(&relative)?;
    std::fs::write(&target, &input.content)
        .map_err(|error| format!("failed to write note {}: {error}", display_path(&relative)))?;
    if format == NotebookNoteFormat::Typst {
        let artifact = artifact_file_path(config, &relative, "pdf");
        if artifact.exists() {
            std::fs::remove_file(&artifact)
                .map_err(|error| format!("failed to remove stale Typst artifact: {error}"))?;
        }
    }
    remove_stale_index(config, &relative)?;
    Ok(content_response(config, relative, format, input.content))
}

pub fn search_notes(config: &AppConfig, query: &str) -> Result<Vec<NotebookSearchResult>, String> {
    let query = query.trim();
    if query.is_empty() {
        return Ok(Vec::new());
    }

    let query_lower = query.to_lowercase();
    let mut results = Vec::new();
    for note in list_notes(config)? {
        let content = read_note(config, &note.path)?.content;
        let indexable =
            load_or_normalize_indexable_text(config, &note.path, note.format, &content)?;
        let content_lower = indexable.to_lowercase();
        let path_lower = note.path.to_lowercase();
        if !content_lower.contains(&query_lower) && !path_lower.contains(&query_lower) {
            continue;
        }

        results.push(NotebookSearchResult {
            path: note.path,
            title: note.title,
            format: note.format,
            snippet: build_snippet(&indexable, query),
            render_status: note.render_status,
            artifact_available: note.artifact_url.is_some(),
        });
    }

    Ok(results)
}

pub fn index_notes(config: &AppConfig) -> Result<NotebookIndexResponse, String> {
    let mut items = Vec::new();
    for note in list_notes(config)? {
        let content = read_note(config, &note.path)?.content;
        let indexable = normalize_indexable_text(note.format, &content);
        let index_path = write_indexable_text(config, &note.path, &indexable)?;
        items.push(NotebookIndexItem {
            path: note.path.clone(),
            format: note.format,
            index_path,
            provenance: index_provenance(&note.path, note.format),
            bytes: indexable.len(),
        });
    }

    Ok(NotebookIndexResponse {
        indexed: items.len(),
        items,
    })
}

pub fn chunk_notes(config: &AppConfig) -> Result<NotebookChunkResponse, String> {
    let mut items = Vec::new();
    for note in list_notes(config)? {
        let content = read_note(config, &note.path)?.content;
        let indexable =
            load_or_normalize_indexable_text(config, &note.path, note.format, &content)?;
        let chunks = chunk_text(&indexable);
        let chunk_path = write_chunks(config, &note.path, &chunks)?;
        items.push(NotebookChunkItem {
            path: note.path.clone(),
            format: note.format,
            chunk_path,
            chunks: chunks.len(),
            provenance: index_provenance(&note.path, note.format),
        });
    }

    Ok(NotebookChunkResponse {
        chunked: items.len(),
        items,
    })
}

pub fn chunk_status(config: &AppConfig) -> Result<NotebookChunkResponse, String> {
    let mut items = Vec::new();
    for note in list_notes(config)? {
        let artifact = chunk_artifact_path(config, Path::new(&note.path));
        if !chunk_is_current(config, Path::new(&note.path)) {
            continue;
        }
        let chunks = std::fs::read_to_string(&artifact)
            .ok()
            .and_then(|content| serde_json::from_str::<Vec<serde_json::Value>>(&content).ok())
            .map(|items| items.len())
            .unwrap_or_default();
        items.push(NotebookChunkItem {
            path: note.path.clone(),
            format: note.format,
            chunk_path: artifact
                .strip_prefix(&config.derived_dir)
                .map(display_path)
                .unwrap_or_else(|_| artifact.to_string_lossy().to_string()),
            chunks,
            provenance: index_provenance(&note.path, note.format),
        });
    }

    Ok(NotebookChunkResponse {
        chunked: items.len(),
        items,
    })
}

pub fn retrieve_notes(
    config: &AppConfig,
    query: &str,
) -> Result<NotebookRetrievalResponse, String> {
    retrieve_notes_with_profile(config, query, SearchProfile::RagContext)
}

pub fn retrieve_notes_with_profile(
    config: &AppConfig,
    query: &str,
    profile: SearchProfile,
) -> Result<NotebookRetrievalResponse, String> {
    let query = SearchQuery::new(query, profile);
    if query.is_empty() {
        return Ok(NotebookRetrievalResponse {
            query: String::new(),
            strategy: "none".to_string(),
            results: Vec::new(),
        });
    }

    let mut results = Vec::new();
    for note in list_notes(config)? {
        let relative = Path::new(&note.path);
        let chunk_path = chunk_artifact_path(config, relative)
            .strip_prefix(&config.derived_dir)
            .map(display_path)
            .unwrap_or_else(|_| {
                chunk_artifact_path(config, relative)
                    .to_string_lossy()
                    .to_string()
            });
        let chunks = load_or_build_chunks(config, &note.path, note.format)?;
        for (chunk_index, chunk) in chunks.into_iter().enumerate() {
            let score = lexical_score(&chunk, &query);
            if score == 0 {
                continue;
            }
            results.push(NotebookRetrievalResult {
                path: note.path.clone(),
                format: note_format_string(note.format),
                chunk_path: chunk_path.clone(),
                chunk_index,
                score,
                snippet: search_snippet(&chunk, 240),
                provenance: index_provenance(&note.path, note.format),
            });
        }
    }

    results.sort_by(|left, right| {
        right
            .score
            .cmp(&left.score)
            .then_with(|| left.path.cmp(&right.path))
            .then_with(|| left.chunk_index.cmp(&right.chunk_index))
    });
    results.truncate(query.limit);

    Ok(NotebookRetrievalResponse {
        query: query.raw,
        strategy: "lexical".to_string(),
        results,
    })
}

pub fn retrieve_notes_with_query_vector(
    config: &AppConfig,
    query: &str,
    provider: &str,
    model_id: &str,
    query_vector: &[f32],
) -> Result<NotebookRetrievalResponse, String> {
    let query = query.trim();
    if query.is_empty() {
        return Ok(NotebookRetrievalResponse {
            query: String::new(),
            strategy: "none".to_string(),
            results: Vec::new(),
        });
    }

    let mut results = Vec::new();
    for note in list_notes(config)? {
        let relative = Path::new(&note.path);
        if !embedding_is_current(config, relative, provider, model_id) {
            continue;
        }
        let chunk_path = chunk_artifact_path(config, relative)
            .strip_prefix(&config.derived_dir)
            .map(display_path)
            .unwrap_or_else(|_| {
                chunk_artifact_path(config, relative)
                    .to_string_lossy()
                    .to_string()
            });
        let chunks = load_or_build_chunks(config, &note.path, note.format)?;
        let vectors = load_embedding_vectors(&embedding_artifact_path(config, relative))?;
        for (chunk_index, (chunk, vector)) in chunks.into_iter().zip(vectors).enumerate() {
            let similarity = cosine_similarity(query_vector, &vector);
            if similarity <= 0.0 {
                continue;
            }
            results.push(NotebookRetrievalResult {
                path: note.path.clone(),
                format: note_format_string(note.format),
                chunk_path: chunk_path.clone(),
                chunk_index,
                score: (similarity * 1000.0).round().clamp(0.0, 1000.0) as usize,
                snippet: chunk.chars().take(240).collect(),
                provenance: embedding_provenance(&note.path, note.format, provider, model_id),
            });
        }
    }

    results.sort_by(|left, right| {
        right
            .score
            .cmp(&left.score)
            .then_with(|| left.path.cmp(&right.path))
            .then_with(|| left.chunk_index.cmp(&right.chunk_index))
    });
    results.truncate(8);

    Ok(NotebookRetrievalResponse {
        query: query.to_string(),
        strategy: "semantic".to_string(),
        results,
    })
}

#[derive(Debug, Clone)]
pub struct NotebookEmbeddingInput {
    pub path: String,
    pub chunks: Vec<String>,
}

pub fn collect_embedding_inputs(config: &AppConfig) -> Result<Vec<NotebookEmbeddingInput>, String> {
    let mut inputs = Vec::new();
    for note in list_notes(config)? {
        let chunks = load_or_build_chunks(config, &note.path, note.format)?;
        if chunks.is_empty() {
            continue;
        }
        inputs.push(NotebookEmbeddingInput {
            path: note.path,
            chunks,
        });
    }
    Ok(inputs)
}

pub fn embedding_status(
    config: &AppConfig,
    provider: &str,
    model_id: &str,
) -> Result<NotebookEmbeddingResponse, String> {
    let mut items = Vec::new();
    for note in list_notes(config)? {
        let relative = Path::new(&note.path);
        let artifact = embedding_artifact_path(config, relative);
        if !embedding_is_current(config, relative, provider, model_id) {
            continue;
        }
        let payload = std::fs::read_to_string(&artifact)
            .map_err(|error| format!("failed to read notebook embeddings: {error}"))?;
        let parsed: serde_json::Value = serde_json::from_str(&payload)
            .map_err(|error| format!("failed to decode notebook embeddings: {error}"))?;
        let vectors = parsed
            .get("vectors")
            .and_then(|value| value.as_array())
            .cloned()
            .unwrap_or_default();
        let dimensions = vectors
            .first()
            .and_then(|value| value.as_array())
            .map(|vector| vector.len())
            .unwrap_or_default();
        items.push(NotebookEmbeddingItem {
            path: note.path.clone(),
            format: note_format_string(note.format),
            embedding_path: artifact
                .strip_prefix(&config.derived_dir)
                .map(display_path)
                .unwrap_or_else(|_| artifact.to_string_lossy().to_string()),
            provider: provider.to_string(),
            model_id: model_id.to_string(),
            chunks: vectors.len(),
            dimensions,
            provenance: embedding_provenance(&note.path, note.format, provider, model_id),
        });
    }

    Ok(NotebookEmbeddingResponse {
        embedded: items.len(),
        items,
    })
}

pub fn write_note_embeddings(
    config: &AppConfig,
    note_path: &str,
    provider: &str,
    model_id: &str,
    vectors: &[Vec<f32>],
) -> Result<NotebookEmbeddingItem, String> {
    std::fs::create_dir_all(&config.derived_dir)
        .map_err(|error| format!("failed to create derived workspace: {error}"))?;
    let relative = validate_note_path(note_path)?;
    let artifact = embedding_artifact_path(config, &relative);
    if let Some(parent) = artifact.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|error| format!("failed to create notebook embedding directory: {error}"))?;
        ensure_path_can_be_parent(parent, &config.derived_dir)?;
    }
    let payload = serde_json::json!({
        "provider": provider,
        "model_id": model_id,
        "vectors": vectors,
    });
    std::fs::write(
        &artifact,
        serde_json::to_vec_pretty(&payload)
            .map_err(|error| format!("failed to encode notebook embeddings: {error}"))?,
    )
    .map_err(|error| format!("failed to write notebook embeddings: {error}"))?;

    Ok(NotebookEmbeddingItem {
        path: display_path(&relative),
        format: note_format_string(format_from_path(&relative)?),
        embedding_path: artifact
            .strip_prefix(&config.derived_dir)
            .map(display_path)
            .unwrap_or_else(|_| artifact.to_string_lossy().to_string()),
        provider: provider.to_string(),
        model_id: model_id.to_string(),
        chunks: vectors.len(),
        dimensions: vectors
            .first()
            .map(|vector| vector.len())
            .unwrap_or_default(),
        provenance: embedding_provenance(
            &display_path(&relative),
            format_from_path(&relative)?,
            provider,
            model_id,
        ),
    })
}

pub fn index_status(config: &AppConfig) -> Result<NotebookIndexResponse, String> {
    let mut items = Vec::new();
    for note in list_notes(config)? {
        let artifact = index_artifact_path(config, Path::new(&note.path));
        if !index_is_current(config, Path::new(&note.path)) {
            continue;
        }
        let bytes = artifact
            .metadata()
            .map(|metadata| metadata.len() as usize)
            .unwrap_or_default();
        items.push(NotebookIndexItem {
            path: note.path.clone(),
            format: note.format,
            index_path: artifact
                .strip_prefix(&config.derived_dir)
                .map(display_path)
                .unwrap_or_else(|_| artifact.to_string_lossy().to_string()),
            provenance: index_provenance(&note.path, note.format),
            bytes,
        });
    }

    Ok(NotebookIndexResponse {
        indexed: items.len(),
        items,
    })
}

pub fn render_note(
    config: &AppConfig,
    input: NotebookRenderRequest,
) -> Result<NotebookRenderResponse, String> {
    if input.target != "pdf" {
        return Err("only pdf render target is currently supported".to_string());
    }

    let relative = validate_note_path(&input.path)?;
    let source = existing_note_path(config, &relative)?;
    let format = format_from_path(&relative)?;
    if format != NotebookNoteFormat::Typst {
        return Err("only Typst notes can be rendered by the Typst adapter".to_string());
    }

    let adapter = typst_adapter_status();
    if adapter.status != "available" {
        return Ok(render_response(
            config,
            relative,
            format,
            input.target,
            adapter.status,
            adapter.detail,
        ));
    }
    if typst_graph_uses_package_import(config, &relative)? {
        return Ok(render_response(
            config,
            relative,
            format,
            input.target,
            "blocked".to_string(),
            "Typst package imports are disabled until the package/cache policy is explicit"
                .to_string(),
        ));
    }

    let artifact = artifact_path(config, &relative, &input.target)?;
    if let Some(parent) = artifact.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|error| format!("failed to create notebook artifact directory: {error}"))?;
    }

    let output = run_typst_compile(&config.notebook_dir, &source, &artifact)?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
        let detail = if !stderr.is_empty() { stderr } else { stdout };
        return Ok(render_response(
            config,
            relative,
            format,
            input.target,
            "error".to_string(),
            format!("Typst render failed: {detail}"),
        ));
    }

    Ok(render_response(
        config,
        relative,
        format,
        input.target,
        "complete".to_string(),
        "Typst render complete".to_string(),
    ))
}

fn typst_adapter_status() -> NotebookAdapterStatus {
    match run_command_with_timeout(
        Command::new("typst").arg("--version"),
        TYPST_COMMAND_TIMEOUT,
    ) {
        Ok(output) if output.status.success() => {
            let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
            NotebookAdapterStatus {
                id: "typst".to_string(),
                label: "Typst".to_string(),
                status: "available".to_string(),
                version: (!version.is_empty()).then_some(version),
                detail: "typst executable was found; render runs only on explicit request"
                    .to_string(),
            }
        }
        Ok(output) => {
            let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
            NotebookAdapterStatus {
                id: "typst".to_string(),
                label: "Typst".to_string(),
                status: "error".to_string(),
                version: None,
                detail: if detail.is_empty() {
                    "typst --version failed".to_string()
                } else {
                    detail
                },
            }
        }
        Err(error) => {
            let missing = error.contains("failed to spawn command");
            NotebookAdapterStatus {
                id: "typst".to_string(),
                label: "Typst".to_string(),
                status: if missing { "missing" } else { "error" }.to_string(),
                version: None,
                detail: if missing {
                    format!("typst executable was not found: {error}")
                } else {
                    error
                },
            }
        }
    }
}

fn collect_notes(
    config: &AppConfig,
    root: &Path,
    current: &Path,
    notes: &mut Vec<NotebookNoteSummary>,
) -> Result<(), String> {
    for entry in
        std::fs::read_dir(current).map_err(|error| format!("failed to list notebook: {error}"))?
    {
        let entry = entry.map_err(|error| format!("failed to read notebook entry: {error}"))?;
        let file_type = entry
            .file_type()
            .map_err(|error| format!("failed to inspect notebook entry: {error}"))?;
        if file_type.is_symlink() {
            continue;
        }
        let path = entry.path();
        if file_type.is_dir() {
            collect_notes(config, root, &path, notes)?;
            continue;
        }
        if !file_type.is_file() {
            continue;
        }

        let Some(relative) = path.strip_prefix(root).ok().map(PathBuf::from) else {
            continue;
        };
        let Ok(format) = format_from_path(&relative) else {
            continue;
        };
        let content = std::fs::read_to_string(&path).unwrap_or_default();
        notes.push(summary_response(config, relative, format, &content));
    }
    Ok(())
}

fn validate_note_path(raw_path: &str) -> Result<PathBuf, String> {
    let trimmed = raw_path.trim().replace('\\', "/");
    if trimmed.is_empty() {
        return Err("note path is required".to_string());
    }

    let path = Path::new(&trimmed);
    if path.is_absolute() {
        return Err("note path must be relative to notebook root".to_string());
    }

    let mut relative = PathBuf::new();
    for component in path.components() {
        match component {
            Component::Normal(segment) => relative.push(segment),
            Component::CurDir => {}
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
                return Err("note path must stay inside notebook root".to_string());
            }
        }
    }

    format_from_path(&relative)?;
    Ok(relative)
}

fn validate_artifact_path(raw_path: &str) -> Result<PathBuf, String> {
    let relative = validate_relative_path(raw_path)?;
    match relative.extension().and_then(OsStr::to_str) {
        Some("pdf") => Ok(relative),
        _ => Err("supported notebook artifact extension is .pdf".to_string()),
    }
}

fn validate_relative_path(raw_path: &str) -> Result<PathBuf, String> {
    let trimmed = raw_path.trim().replace('\\', "/");
    if trimmed.is_empty() {
        return Err("path is required".to_string());
    }

    let path = Path::new(&trimmed);
    if path.is_absolute() {
        return Err("path must be relative".to_string());
    }

    let mut relative = PathBuf::new();
    for component in path.components() {
        match component {
            Component::Normal(segment) => relative.push(segment),
            Component::CurDir => {}
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
                return Err("path must stay inside its root".to_string());
            }
        }
    }

    Ok(relative)
}

fn existing_note_path(config: &AppConfig, relative: &Path) -> Result<PathBuf, String> {
    ensure_notebook_root(config)?;
    let target = config.notebook_dir.join(relative);
    let canonical_target = target
        .canonicalize()
        .map_err(|_| format!("note not found: {}", display_path(relative)))?;
    ensure_within_root(&canonical_target, &config.notebook_dir)?;
    Ok(canonical_target)
}

fn existing_artifact_path(config: &AppConfig, relative: &Path) -> Result<PathBuf, String> {
    let root = config.derived_dir.join("notebook-artifacts");
    let target = root.join(relative);
    let canonical_target = target
        .canonicalize()
        .map_err(|_| format!("notebook artifact not found: {}", display_path(relative)))?;
    ensure_within_root(&canonical_target, &root)?;
    Ok(canonical_target)
}

fn creatable_note_path(config: &AppConfig, relative: &Path) -> Result<PathBuf, String> {
    ensure_notebook_root(config)?;
    let target = config.notebook_dir.join(relative);
    if let Some(parent) = target.parent() {
        ensure_path_can_be_parent(parent, &config.notebook_dir)?;
    }
    Ok(target)
}

fn artifact_path(config: &AppConfig, relative: &Path, target: &str) -> Result<PathBuf, String> {
    std::fs::create_dir_all(&config.derived_dir)
        .map_err(|error| format!("failed to create derived workspace: {error}"))?;
    let mut artifact = config.derived_dir.join("notebook-artifacts").join(relative);
    artifact.set_extension(target);
    if let Some(parent) = artifact.parent() {
        ensure_path_can_be_parent(parent, &config.derived_dir)?;
    }
    Ok(artifact)
}

fn ensure_notebook_root(config: &AppConfig) -> Result<(), String> {
    std::fs::create_dir_all(&config.notebook_dir)
        .map_err(|error| format!("failed to create notebook root: {error}"))
}

fn ensure_path_can_be_parent(path: &Path, root: &Path) -> Result<(), String> {
    let canonical_root = root
        .canonicalize()
        .map_err(|error| format!("failed to resolve notebook root: {error}"))?;
    let mut current = path;
    while !current.exists() {
        current = current
            .parent()
            .ok_or_else(|| "note path must stay inside notebook root".to_string())?;
    }
    let canonical_parent = current
        .canonicalize()
        .map_err(|error| format!("failed to resolve notebook parent: {error}"))?;
    if !canonical_parent.starts_with(canonical_root) {
        return Err("note path must stay inside notebook root".to_string());
    }
    Ok(())
}

fn ensure_within_root(path: &Path, root: &Path) -> Result<(), String> {
    let canonical_root = root
        .canonicalize()
        .map_err(|error| format!("failed to resolve notebook root: {error}"))?;
    let canonical_path = path
        .canonicalize()
        .map_err(|error| format!("failed to resolve notebook path: {error}"))?;
    if !canonical_path.starts_with(canonical_root) {
        return Err("note path must stay inside notebook root".to_string());
    }
    Ok(())
}

fn format_from_path(path: &Path) -> Result<NotebookNoteFormat, String> {
    match path.extension().and_then(OsStr::to_str) {
        Some("md") => Ok(NotebookNoteFormat::Markdown),
        Some("typ") => Ok(NotebookNoteFormat::Typst),
        _ => Err("supported notebook note extensions are .md and .typ".to_string()),
    }
}

fn content_response(
    config: &AppConfig,
    relative: PathBuf,
    format: NotebookNoteFormat,
    content: String,
) -> NotebookNoteContent {
    let artifact_url = artifact_url(config, &relative, format);
    NotebookNoteContent {
        path: display_path(&relative),
        title: infer_title(&relative, format, &content),
        format,
        content,
        render_status: render_status(config, &relative, format).to_string(),
        artifact_url,
    }
}

fn summary_response(
    config: &AppConfig,
    relative: PathBuf,
    format: NotebookNoteFormat,
    content: &str,
) -> NotebookNoteSummary {
    let artifact_url = artifact_url(config, &relative, format);
    NotebookNoteSummary {
        path: display_path(&relative),
        title: infer_title(&relative, format, content),
        format,
        render_status: render_status(config, &relative, format).to_string(),
        artifact_url,
    }
}

fn render_response(
    config: &AppConfig,
    relative: PathBuf,
    format: NotebookNoteFormat,
    target: String,
    status: String,
    detail: String,
) -> NotebookRenderResponse {
    let artifact = artifact_file_path(config, &relative, &target);
    NotebookRenderResponse {
        path: display_path(&relative),
        format,
        target,
        artifact_path: artifact
            .strip_prefix(&config.derived_dir)
            .map(display_path)
            .unwrap_or_else(|_| artifact.to_string_lossy().to_string()),
        artifact_url: artifact_url(config, &relative, format),
        status,
        detail,
    }
}

fn starter_template(
    relative: &Path,
    format: NotebookNoteFormat,
    _template_id: Option<&str>,
) -> String {
    let title = title_from_path(relative);
    match format {
        NotebookNoteFormat::Markdown => format!("# {title}\n\n"),
        NotebookNoteFormat::Typst => {
            format!(
                "#set document(title: \"{}\")\n\n= {title}\n\n",
                escape_typst_string(&title)
            )
        }
    }
}

fn infer_title(relative: &Path, format: NotebookNoteFormat, content: &str) -> String {
    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        match format {
            NotebookNoteFormat::Markdown if trimmed.starts_with("# ") => {
                return trimmed.trim_start_matches("# ").trim().to_string();
            }
            NotebookNoteFormat::Typst if trimmed.starts_with('=') => {
                return trimmed.trim_start_matches('=').trim().to_string();
            }
            _ => {}
        }
    }
    title_from_path(relative)
}

fn title_from_path(relative: &Path) -> String {
    relative
        .file_stem()
        .and_then(OsStr::to_str)
        .unwrap_or("Untitled")
        .replace(['-', '_'], " ")
}

fn render_status(config: &AppConfig, relative: &Path, format: NotebookNoteFormat) -> &'static str {
    match format {
        NotebookNoteFormat::Markdown => "ready",
        NotebookNoteFormat::Typst if artifact_is_current(config, relative, "pdf") => "complete",
        NotebookNoteFormat::Typst => "not_rendered",
    }
}

fn artifact_url(config: &AppConfig, relative: &Path, format: NotebookNoteFormat) -> Option<String> {
    if format != NotebookNoteFormat::Typst {
        return None;
    }
    if !artifact_is_current(config, relative, "pdf") {
        return None;
    }
    let mut artifact_relative = relative.to_path_buf();
    artifact_relative.set_extension("pdf");
    Some(format!(
        "/api/notebook/artifact?path={}",
        encode_url_path(&display_path(&artifact_relative))
    ))
}

fn artifact_file_path(config: &AppConfig, relative: &Path, target: &str) -> PathBuf {
    let mut artifact = config.derived_dir.join("notebook-artifacts").join(relative);
    artifact.set_extension(target);
    artifact
}

fn artifact_is_current(config: &AppConfig, relative: &Path, target: &str) -> bool {
    let source = config.notebook_dir.join(relative);
    let artifact = artifact_file_path(config, relative, target);
    file_is_at_least_as_new(&artifact, &source)
}

fn index_artifact_path(config: &AppConfig, relative: &Path) -> PathBuf {
    let mut artifact = config.derived_dir.join("notebook-index").join(relative);
    artifact.set_extension("txt");
    artifact
}

fn chunk_artifact_path(config: &AppConfig, relative: &Path) -> PathBuf {
    let mut artifact = config.derived_dir.join("notebook-chunks").join(relative);
    artifact.set_extension("json");
    artifact
}

fn embedding_artifact_path(config: &AppConfig, relative: &Path) -> PathBuf {
    let mut artifact = config
        .derived_dir
        .join("notebook-embeddings")
        .join(relative);
    artifact.set_extension("json");
    artifact
}

fn write_indexable_text(
    config: &AppConfig,
    note_path: &str,
    indexable: &str,
) -> Result<String, String> {
    std::fs::create_dir_all(&config.derived_dir)
        .map_err(|error| format!("failed to create derived workspace: {error}"))?;
    let relative = validate_note_path(note_path)?;
    let artifact = index_artifact_path(config, &relative);
    if let Some(parent) = artifact.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|error| format!("failed to create notebook index directory: {error}"))?;
        ensure_path_can_be_parent(parent, &config.derived_dir)?;
    }
    std::fs::write(&artifact, indexable)
        .map_err(|error| format!("failed to write notebook index: {error}"))?;
    Ok(artifact
        .strip_prefix(&config.derived_dir)
        .map(display_path)
        .unwrap_or_else(|_| artifact.to_string_lossy().to_string()))
}

fn write_chunks(config: &AppConfig, note_path: &str, chunks: &[String]) -> Result<String, String> {
    std::fs::create_dir_all(&config.derived_dir)
        .map_err(|error| format!("failed to create derived workspace: {error}"))?;
    let relative = validate_note_path(note_path)?;
    let artifact = chunk_artifact_path(config, &relative);
    if let Some(parent) = artifact.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|error| format!("failed to create notebook chunk directory: {error}"))?;
        ensure_path_can_be_parent(parent, &config.derived_dir)?;
    }
    let payload = serde_json::to_string_pretty(
        &chunks
            .iter()
            .enumerate()
            .map(|(index, text)| serde_json::json!({ "index": index, "text": text }))
            .collect::<Vec<_>>(),
    )
    .map_err(|error| format!("failed to encode notebook chunks: {error}"))?;
    std::fs::write(&artifact, payload)
        .map_err(|error| format!("failed to write notebook chunks: {error}"))?;
    Ok(artifact
        .strip_prefix(&config.derived_dir)
        .map(display_path)
        .unwrap_or_else(|_| artifact.to_string_lossy().to_string()))
}

fn load_embedding_vectors(path: &Path) -> Result<Vec<Vec<f32>>, String> {
    let payload = std::fs::read_to_string(path)
        .map_err(|error| format!("failed to read notebook embeddings: {error}"))?;
    let parsed: serde_json::Value = serde_json::from_str(&payload)
        .map_err(|error| format!("failed to decode notebook embeddings: {error}"))?;
    Ok(parsed
        .get("vectors")
        .and_then(|value| value.as_array())
        .cloned()
        .unwrap_or_default()
        .into_iter()
        .map(|vector| {
            vector
                .as_array()
                .cloned()
                .unwrap_or_default()
                .into_iter()
                .filter_map(|value| value.as_f64().map(|value| value as f32))
                .collect::<Vec<_>>()
        })
        .collect())
}

fn load_or_normalize_indexable_text(
    config: &AppConfig,
    note_path: &str,
    format: NotebookNoteFormat,
    content: &str,
) -> Result<String, String> {
    let relative = validate_note_path(note_path)?;
    let artifact = index_artifact_path(config, &relative);
    if artifact.exists() {
        return std::fs::read_to_string(&artifact)
            .map_err(|error| format!("failed to read notebook index: {error}"))
            .map(|text| {
                if index_is_current(config, &relative) {
                    text
                } else {
                    normalize_indexable_text(format, content)
                }
            });
    }
    Ok(normalize_indexable_text(format, content))
}

fn load_or_build_chunks(
    config: &AppConfig,
    note_path: &str,
    format: NotebookNoteFormat,
) -> Result<Vec<String>, String> {
    let relative = validate_note_path(note_path)?;
    let artifact = chunk_artifact_path(config, &relative);
    if chunk_is_current(config, &relative) && artifact.exists() {
        return load_chunk_texts(&artifact);
    }

    let content = read_note(config, note_path)?.content;
    let indexable = load_or_normalize_indexable_text(config, note_path, format, &content)?;
    Ok(chunk_text(&indexable))
}

fn load_chunk_texts(path: &Path) -> Result<Vec<String>, String> {
    let content = std::fs::read_to_string(path)
        .map_err(|error| format!("failed to read notebook chunks: {error}"))?;
    let items = serde_json::from_str::<Vec<serde_json::Value>>(&content)
        .map_err(|error| format!("failed to decode notebook chunks: {error}"))?;
    Ok(items
        .into_iter()
        .filter_map(|item| {
            item.get("text")
                .and_then(|value| value.as_str())
                .map(str::to_string)
        })
        .collect())
}

fn index_is_current(config: &AppConfig, relative: &Path) -> bool {
    let source = config.notebook_dir.join(relative);
    let artifact = index_artifact_path(config, relative);
    file_is_at_least_as_new(&artifact, &source)
}

fn chunk_is_current(config: &AppConfig, relative: &Path) -> bool {
    let source = config.notebook_dir.join(relative);
    let index = index_artifact_path(config, relative);
    let baseline = if index_is_current(config, relative) {
        index
    } else {
        source
    };
    let chunks = chunk_artifact_path(config, relative);
    file_is_at_least_as_new(&chunks, &baseline)
}

fn embedding_is_current(
    config: &AppConfig,
    relative: &Path,
    provider: &str,
    model_id: &str,
) -> bool {
    let chunks = chunk_artifact_path(config, relative);
    let artifact = embedding_artifact_path(config, relative);
    if !file_is_at_least_as_new(&artifact, &chunks) {
        return false;
    }
    let Ok(payload) = std::fs::read_to_string(&artifact) else {
        return false;
    };
    let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&payload) else {
        return false;
    };
    parsed.get("provider").and_then(|value| value.as_str()) == Some(provider)
        && parsed.get("model_id").and_then(|value| value.as_str()) == Some(model_id)
}

fn file_is_at_least_as_new(artifact: &Path, source: &Path) -> bool {
    let Ok(artifact_modified) = artifact.metadata().and_then(|metadata| metadata.modified()) else {
        return false;
    };
    let Ok(source_modified) = source.metadata().and_then(|metadata| metadata.modified()) else {
        return false;
    };
    artifact_modified >= source_modified
}

fn remove_stale_index(config: &AppConfig, relative: &Path) -> Result<(), String> {
    let artifact = index_artifact_path(config, relative);
    if artifact.exists() {
        std::fs::remove_file(&artifact)
            .map_err(|error| format!("failed to remove stale notebook index: {error}"))?;
    }
    let chunks = chunk_artifact_path(config, relative);
    if chunks.exists() {
        std::fs::remove_file(&chunks)
            .map_err(|error| format!("failed to remove stale notebook chunks: {error}"))?;
    }
    let embeddings = embedding_artifact_path(config, relative);
    if embeddings.exists() {
        std::fs::remove_file(&embeddings)
            .map_err(|error| format!("failed to remove stale notebook embeddings: {error}"))?;
    }
    Ok(())
}

fn normalize_indexable_text(format: NotebookNoteFormat, content: &str) -> String {
    match format {
        NotebookNoteFormat::Markdown => normalize_markdown_text(content),
        NotebookNoteFormat::Typst => normalize_typst_text(content),
    }
}

fn normalize_markdown_text(content: &str) -> String {
    let mut in_fence = false;
    content
        .lines()
        .filter_map(|line| {
            let trimmed = line.trim();
            if trimmed.starts_with("```") {
                in_fence = !in_fence;
                return None;
            }
            if in_fence || trimmed.is_empty() {
                return None;
            }
            Some(
                trimmed
                    .trim_start_matches('#')
                    .trim_start_matches(['-', '*', '>'])
                    .trim()
                    .to_string(),
            )
        })
        .filter(|line| !line.is_empty())
        .collect::<Vec<_>>()
        .join("\n")
}

fn normalize_typst_text(content: &str) -> String {
    content
        .lines()
        .filter_map(|line| {
            let trimmed = line.trim();
            if trimmed.is_empty()
                || trimmed.starts_with("#set")
                || trimmed.starts_with("#import")
                || trimmed.starts_with("#include")
            {
                return None;
            }
            Some(
                trimmed
                    .trim_start_matches('=')
                    .replace(['#', '[', ']', '*', '_'], "")
                    .trim()
                    .to_string(),
            )
        })
        .filter(|line| !line.is_empty())
        .collect::<Vec<_>>()
        .join("\n")
}

fn note_format_string(format: NotebookNoteFormat) -> String {
    match format {
        NotebookNoteFormat::Markdown => "markdown".to_string(),
        NotebookNoteFormat::Typst => "typst".to_string(),
    }
}

pub(crate) fn cosine_similarity(left: &[f32], right: &[f32]) -> f64 {
    if left.is_empty() || right.is_empty() || left.len() != right.len() {
        return 0.0;
    }
    let mut dot = 0.0_f64;
    let mut left_norm = 0.0_f64;
    let mut right_norm = 0.0_f64;
    for (l, r) in left.iter().zip(right.iter()) {
        let l = *l as f64;
        let r = *r as f64;
        dot += l * r;
        left_norm += l * l;
        right_norm += r * r;
    }
    if left_norm == 0.0 || right_norm == 0.0 {
        return 0.0;
    }
    dot / (left_norm.sqrt() * right_norm.sqrt())
}

fn chunk_text(content: &str) -> Vec<String> {
    const TARGET_CHARS: usize = 800;
    let mut chunks = Vec::new();
    let mut current = String::new();
    for paragraph in content.split("\n\n") {
        let paragraph = paragraph.trim();
        if paragraph.is_empty() {
            continue;
        }
        let next_len = current.chars().count() + paragraph.chars().count() + 2;
        if !current.is_empty() && next_len > TARGET_CHARS {
            chunks.push(current.trim().to_string());
            current.clear();
        }
        if !current.is_empty() {
            current.push_str("\n\n");
        }
        current.push_str(paragraph);
    }
    if !current.trim().is_empty() {
        chunks.push(current.trim().to_string());
    }
    chunks
}

fn index_provenance(path: &str, format: NotebookNoteFormat) -> String {
    let format = match format {
        NotebookNoteFormat::Markdown => "markdown",
        NotebookNoteFormat::Typst => "typst",
    };
    format!("source=notebook/{path}; format={format}; extractor=source-normalizer-v1")
}

fn embedding_provenance(
    path: &str,
    format: NotebookNoteFormat,
    provider: &str,
    model_id: &str,
) -> String {
    let format = match format {
        NotebookNoteFormat::Markdown => "markdown",
        NotebookNoteFormat::Typst => "typst",
    };
    format!(
        "source=notebook/{path}; format={format}; embedder={provider}/{model_id}; extractor=chunk-embedding-v1"
    )
}

fn run_typst_compile(root: &Path, source: &Path, artifact: &Path) -> Result<Output, String> {
    let mut command = Command::new("typst");
    command
        .arg("compile")
        .arg("--root")
        .arg(root)
        .arg(source)
        .arg(artifact);
    run_command_with_timeout(&mut command, TYPST_COMMAND_TIMEOUT)
}

fn run_command_with_timeout(command: &mut Command, timeout: Duration) -> Result<Output, String> {
    let mut child = command
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|error| format!("failed to spawn command: {error}"))?;
    let deadline = Instant::now() + timeout;
    loop {
        match child.try_wait() {
            Ok(Some(_)) => {
                return child
                    .wait_with_output()
                    .map_err(|error| format!("failed to collect command output: {error}"));
            }
            Ok(None) if Instant::now() >= deadline => {
                let _ = child.kill();
                let output = child.wait_with_output().map_err(|error| {
                    format!("failed to collect timed out command output: {error}")
                })?;
                let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
                return Err(if stderr.is_empty() {
                    format!("command timed out after {} seconds", timeout.as_secs())
                } else {
                    format!(
                        "command timed out after {} seconds: {stderr}",
                        timeout.as_secs()
                    )
                });
            }
            Ok(None) => std::thread::sleep(Duration::from_millis(25)),
            Err(error) => return Err(format!("failed to poll command: {error}")),
        }
    }
}

fn uses_typst_package_import(content: &str) -> bool {
    content.lines().any(|line| {
        let trimmed = line.trim_start();
        (trimmed.starts_with("#import") || trimmed.starts_with("#include"))
            && (trimmed.contains("\"@") || trimmed.contains("'@"))
    })
}

fn typst_graph_uses_package_import(
    config: &AppConfig,
    source_relative: &Path,
) -> Result<bool, String> {
    ensure_notebook_root(config)?;
    let mut stack = vec![source_relative.to_path_buf()];
    let mut visited = HashSet::new();
    while let Some(relative) = stack.pop() {
        if !visited.insert(relative.clone()) {
            continue;
        }
        let source = existing_note_path(config, &relative)?;
        let content = std::fs::read_to_string(&source)
            .map_err(|error| format!("failed to read Typst source: {error}"))?;
        if uses_typst_package_import(&content) {
            return Ok(true);
        }
        for dependency in referenced_local_typst_files(&relative, &content)? {
            stack.push(dependency);
        }
    }
    Ok(false)
}

fn referenced_local_typst_files(relative: &Path, content: &str) -> Result<Vec<PathBuf>, String> {
    let mut results = Vec::new();
    for line in content.lines() {
        let trimmed = line.trim_start();
        if !(trimmed.starts_with("#include") || trimmed.starts_with("#import")) {
            continue;
        }
        let Some(path_literal) = first_quoted_path(trimmed) else {
            continue;
        };
        if path_literal.starts_with('@') {
            continue;
        }
        let parent = relative.parent().unwrap_or_else(|| Path::new(""));
        let candidate = normalize_relative_child(parent, Path::new(path_literal))?;
        if candidate.extension().and_then(OsStr::to_str) == Some("typ") {
            results.push(candidate);
        }
    }
    Ok(results)
}

fn first_quoted_path(value: &str) -> Option<&str> {
    let quote = if value.contains('"') { '"' } else { '\'' };
    let (_, tail) = value.split_once(quote)?;
    let (path, _) = tail.split_once(quote)?;
    Some(path)
}

fn normalize_relative_child(base: &Path, child: &Path) -> Result<PathBuf, String> {
    if child.is_absolute() {
        return Err("Typst imports must stay relative to notebook root".to_string());
    }
    let mut combined = PathBuf::from(base);
    for component in child.components() {
        match component {
            Component::Normal(segment) => combined.push(segment),
            Component::CurDir => {}
            Component::ParentDir => {
                if !combined.pop() {
                    return Err("Typst imports must stay inside notebook root".to_string());
                }
            }
            Component::RootDir | Component::Prefix(_) => {
                return Err("Typst imports must stay inside notebook root".to_string());
            }
        }
    }
    Ok(combined)
}

fn build_snippet(content: &str, query: &str) -> String {
    let query_lower = query.to_lowercase();
    for line in content.lines() {
        if line.to_lowercase().contains(&query_lower) {
            return line.trim().chars().take(160).collect();
        }
    }
    content
        .lines()
        .find(|line| !line.trim().is_empty())
        .unwrap_or("")
        .trim()
        .chars()
        .take(160)
        .collect()
}

fn escape_typst_string(value: &str) -> String {
    value.replace('\\', "\\\\").replace('"', "\\\"")
}

fn encode_url_path(value: &str) -> String {
    let mut encoded = String::new();
    for byte in value.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
                encoded.push(byte as char);
            }
            _ => encoded.push_str(&format!("%{byte:02X}")),
        }
    }
    encoded
}

fn display_path(path: &Path) -> String {
    path.components()
        .filter_map(|component| match component {
            Component::Normal(segment) => segment.to_str().map(str::to_string),
            _ => None,
        })
        .collect::<Vec<_>>()
        .join("/")
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use soma_studio_core::{
        AppConfig, NotebookNoteCreateRequest, NotebookNoteFormat, NotebookRenderRequest,
    };
    use uuid::Uuid;

    use super::{
        adapter_statuses, chunk_notes, chunk_status, create_note, index_notes, index_status,
        list_notes, read_note, render_note, retrieve_notes_with_query_vector, search_notes,
        write_note, write_note_embeddings,
    };

    #[test]
    fn note_roundtrip_supports_markdown_and_typst() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = test_config(&temp_dir);

        let markdown = create_note(
            &config,
            NotebookNoteCreateRequest {
                path: "notes/weekly.md".to_string(),
                format: NotebookNoteFormat::Markdown,
                template_id: None,
            },
        )
        .expect("markdown note");
        let typst = create_note(
            &config,
            NotebookNoteCreateRequest {
                path: "reports/weekly.typ".to_string(),
                format: NotebookNoteFormat::Typst,
                template_id: None,
            },
        )
        .expect("typst note");

        assert_eq!(markdown.format, NotebookNoteFormat::Markdown);
        assert_eq!(typst.format, NotebookNoteFormat::Typst);
        assert_eq!(typst.render_status, "not_rendered");

        let saved = write_note(
            &config,
            soma_studio_core::NotebookNoteWriteRequest {
                path: "reports/weekly.typ".to_string(),
                content: "= Weekly Report\n\nLocal Typst output".to_string(),
            },
        )
        .expect("write note");
        assert_eq!(saved.title, "Weekly Report");

        let listed = list_notes(&config).expect("list notes");
        assert_eq!(listed.len(), 2);
        let read = read_note(&config, "reports/weekly.typ").expect("read note");
        assert!(read.content.contains("Local Typst output"));

        let searched = search_notes(&config, "Typst").expect("search");
        assert_eq!(searched.len(), 1);
        assert_eq!(searched[0].path, "reports/weekly.typ");

        let indexed = index_notes(&config).expect("index notes");
        assert_eq!(indexed.indexed, 2);
        assert!(
            indexed
                .items
                .iter()
                .any(|item| item.index_path == "notebook-index/reports/weekly.txt")
        );
        let status = index_status(&config).expect("index status");
        assert_eq!(status.indexed, 2);

        let search_from_index =
            search_notes(&config, "Local Typst output").expect("indexed search");
        assert_eq!(search_from_index.len(), 1);
        let chunked = chunk_notes(&config).expect("chunk notes");
        assert_eq!(chunked.chunked, 2);
        assert!(
            chunked
                .items
                .iter()
                .any(|item| item.chunk_path == "notebook-chunks/reports/weekly.json")
        );
        let chunk_status = chunk_status(&config).expect("chunk status");
        assert_eq!(chunk_status.chunked, 2);

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

    #[test]
    fn note_paths_cannot_escape_notebook_root() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = test_config(&temp_dir);

        let result = create_note(
            &config,
            NotebookNoteCreateRequest {
                path: "../escape.md".to_string(),
                format: NotebookNoteFormat::Markdown,
                template_id: None,
            },
        );

        assert!(result.is_err());
        let _ = std::fs::remove_dir_all(temp_dir);
    }

    #[test]
    fn typst_render_reports_adapter_state() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = test_config(&temp_dir);
        create_note(
            &config,
            NotebookNoteCreateRequest {
                path: "reports/weekly.typ".to_string(),
                format: NotebookNoteFormat::Typst,
                template_id: None,
            },
        )
        .expect("typst note");

        let status = adapter_statuses()
            .into_iter()
            .find(|adapter| adapter.id == "typst")
            .expect("typst adapter");
        let result = render_note(
            &config,
            NotebookRenderRequest {
                path: "reports/weekly.typ".to_string(),
                target: "pdf".to_string(),
            },
        );

        if status.status == "available" {
            let response = result.expect("typst render");
            assert_eq!(response.status, "complete");
            assert_eq!(
                response.artifact_path,
                "notebook-artifacts/reports/weekly.pdf"
            );
        } else {
            let response = result.expect("typst render status");
            assert_eq!(response.status, status.status);
        }

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

    #[test]
    fn rendered_typst_artifact_updates_note_status() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = test_config(&temp_dir);
        create_note(
            &config,
            NotebookNoteCreateRequest {
                path: "reports/weekly.typ".to_string(),
                format: NotebookNoteFormat::Typst,
                template_id: None,
            },
        )
        .expect("typst note");
        let artifact = 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 note = read_note(&config, "reports/weekly.typ").expect("read note");
        assert_eq!(note.render_status, "complete");
        assert_eq!(
            note.artifact_url.as_deref(),
            Some("/api/notebook/artifact?path=reports/weekly.pdf")
        );
        let indexed = index_notes(&config).expect("index before rewrite");
        assert_eq!(indexed.indexed, 1);
        let index_artifact = config
            .derived_dir
            .join("notebook-index")
            .join("reports")
            .join("weekly.txt");
        assert!(index_artifact.exists());
        let searched = search_notes(&config, "weekly").expect("search");
        assert!(searched[0].artifact_available);

        let stale = write_note(
            &config,
            soma_studio_core::NotebookNoteWriteRequest {
                path: "reports/weekly.typ".to_string(),
                content: "= Weekly Report\n\nUpdated after render".to_string(),
            },
        )
        .expect("rewrite note");
        assert_eq!(stale.render_status, "not_rendered");
        assert!(stale.artifact_url.is_none());
        assert!(!artifact.exists());
        assert!(!index_artifact.exists());

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

    #[test]
    fn typst_package_imports_are_blocked_before_render() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = test_config(&temp_dir);
        create_note(
            &config,
            NotebookNoteCreateRequest {
                path: "reports/package.typ".to_string(),
                format: NotebookNoteFormat::Typst,
                template_id: None,
            },
        )
        .expect("typst note");
        write_note(
            &config,
            soma_studio_core::NotebookNoteWriteRequest {
                path: "reports/package.typ".to_string(),
                content: "#import \"@preview/example:0.1.0\": *".to_string(),
            },
        )
        .expect("write package note");

        let status = adapter_statuses()
            .into_iter()
            .find(|adapter| adapter.id == "typst")
            .expect("typst adapter");
        let response = render_note(
            &config,
            NotebookRenderRequest {
                path: "reports/package.typ".to_string(),
                target: "pdf".to_string(),
            },
        )
        .expect("render response");

        if status.status == "available" {
            assert_eq!(response.status, "blocked");
        }
        let _ = std::fs::remove_dir_all(temp_dir);
    }

    #[test]
    fn typst_package_imports_are_blocked_in_local_helpers() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = test_config(&temp_dir);
        create_note(
            &config,
            NotebookNoteCreateRequest {
                path: "reports/main.typ".to_string(),
                format: NotebookNoteFormat::Typst,
                template_id: None,
            },
        )
        .expect("typst note");
        create_note(
            &config,
            NotebookNoteCreateRequest {
                path: "reports/helper.typ".to_string(),
                format: NotebookNoteFormat::Typst,
                template_id: None,
            },
        )
        .expect("typst helper");
        write_note(
            &config,
            soma_studio_core::NotebookNoteWriteRequest {
                path: "reports/main.typ".to_string(),
                content: "#include \"helper.typ\"".to_string(),
            },
        )
        .expect("write main");
        write_note(
            &config,
            soma_studio_core::NotebookNoteWriteRequest {
                path: "reports/helper.typ".to_string(),
                content: "#import \"@preview/example:0.1.0\": *".to_string(),
            },
        )
        .expect("write helper");

        let status = adapter_statuses()
            .into_iter()
            .find(|adapter| adapter.id == "typst")
            .expect("typst adapter");
        let response = render_note(
            &config,
            NotebookRenderRequest {
                path: "reports/main.typ".to_string(),
                target: "pdf".to_string(),
            },
        )
        .expect("render response");

        if status.status == "available" {
            assert_eq!(response.status, "blocked");
        }
        let _ = std::fs::remove_dir_all(temp_dir);
    }

    #[test]
    fn unrelated_typst_package_imports_do_not_block_other_notes() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = test_config(&temp_dir);
        create_note(
            &config,
            NotebookNoteCreateRequest {
                path: "reports/main.typ".to_string(),
                format: NotebookNoteFormat::Typst,
                template_id: None,
            },
        )
        .expect("main typst");
        create_note(
            &config,
            NotebookNoteCreateRequest {
                path: "reports/other.typ".to_string(),
                format: NotebookNoteFormat::Typst,
                template_id: None,
            },
        )
        .expect("other typst");
        write_note(
            &config,
            soma_studio_core::NotebookNoteWriteRequest {
                path: "reports/main.typ".to_string(),
                content: "= Main\n\nno package import here".to_string(),
            },
        )
        .expect("write main");
        write_note(
            &config,
            soma_studio_core::NotebookNoteWriteRequest {
                path: "reports/other.typ".to_string(),
                content: "#import \"@preview/example:0.1.0\": *".to_string(),
            },
        )
        .expect("write other");

        let status = adapter_statuses()
            .into_iter()
            .find(|adapter| adapter.id == "typst")
            .expect("typst adapter");
        let response = render_note(
            &config,
            NotebookRenderRequest {
                path: "reports/main.typ".to_string(),
                target: "pdf".to_string(),
            },
        )
        .expect("render response");

        if status.status == "available" {
            assert_ne!(response.status, "blocked");
        }
        let _ = std::fs::remove_dir_all(temp_dir);
    }

    #[test]
    fn stale_external_index_is_not_used_for_search() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = test_config(&temp_dir);
        create_note(
            &config,
            NotebookNoteCreateRequest {
                path: "notes/topic.md".to_string(),
                format: NotebookNoteFormat::Markdown,
                template_id: None,
            },
        )
        .expect("markdown note");
        write_note(
            &config,
            soma_studio_core::NotebookNoteWriteRequest {
                path: "notes/topic.md".to_string(),
                content: "# Topic\n\nold indexed text".to_string(),
            },
        )
        .expect("write note");
        index_notes(&config).expect("index notes");
        chunk_notes(&config).expect("chunk notes");

        std::thread::sleep(std::time::Duration::from_millis(20));
        std::fs::write(
            config.notebook_dir.join("notes").join("topic.md"),
            "# Topic\n\nfresh source text",
        )
        .expect("external edit");

        let old_results = search_notes(&config, "old indexed").expect("old search");
        let fresh_results = search_notes(&config, "fresh source").expect("fresh search");
        let status = index_status(&config).expect("stale index status");
        let chunk_status = chunk_status(&config).expect("stale chunk status");

        assert!(old_results.is_empty());
        assert_eq!(fresh_results.len(), 1);
        assert_eq!(status.indexed, 0);
        assert_eq!(chunk_status.chunked, 0);

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

    #[test]
    fn semantic_retrieval_prefers_closest_embedding() {
        let temp_dir =
            std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&temp_dir).expect("temp dir");
        let config = test_config(&temp_dir);
        create_note(
            &config,
            NotebookNoteCreateRequest {
                path: "notes/a.md".to_string(),
                format: NotebookNoteFormat::Markdown,
                template_id: None,
            },
        )
        .expect("note a");
        create_note(
            &config,
            NotebookNoteCreateRequest {
                path: "notes/b.md".to_string(),
                format: NotebookNoteFormat::Markdown,
                template_id: None,
            },
        )
        .expect("note b");
        write_note(
            &config,
            soma_studio_core::NotebookNoteWriteRequest {
                path: "notes/a.md".to_string(),
                content: "# A\n\nAlpha context".to_string(),
            },
        )
        .expect("write a");
        write_note(
            &config,
            soma_studio_core::NotebookNoteWriteRequest {
                path: "notes/b.md".to_string(),
                content: "# B\n\nBeta context".to_string(),
            },
        )
        .expect("write b");
        index_notes(&config).expect("index");
        chunk_notes(&config).expect("chunk");
        write_note_embeddings(
            &config,
            "notes/a.md",
            "ollama",
            "embed-model",
            &[vec![1.0, 0.0]],
        )
        .expect("embed a");
        write_note_embeddings(
            &config,
            "notes/b.md",
            "ollama",
            "embed-model",
            &[vec![0.0, 1.0]],
        )
        .expect("embed b");

        let retrieval = retrieve_notes_with_query_vector(
            &config,
            "semantic query",
            "ollama",
            "embed-model",
            &[0.9, 0.1],
        )
        .expect("semantic retrieval");

        assert_eq!(retrieval.strategy, "semantic");
        assert_eq!(retrieval.results[0].path, "notes/a.md");

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

    fn test_config(temp_dir: &std::path::Path) -> AppConfig {
        AppConfig {
            app_name: "Soma Studio".to_string(),
            bind_addr: "127.0.0.1:0".to_string(),
            project_root: temp_dir.to_path_buf(),
            data_dir: temp_dir.to_path_buf(),
            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: PathBuf::from("unused"),
            web_shell_file: PathBuf::from("unused/spa.html"),
        }
    }
}
