use crate::error::WebError;
use crate::routes::local_folder_source::encode_local_id_segment;
use crate::routes::local_markdown_parser::{
    parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

const LOCAL_SEARCH_INDEX_VERSION: u32 = 1;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalSearchIndex {
    version: u32,
    built_at: u128,
    root_uri: String,
    workspace_id: String,
    documents: Vec<LocalSearchDocument>,
    #[serde(default)]
    resources: Vec<LocalSearchResource>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalSearchDocument {
    document_id: String,
    title: String,
    path: String,
    raw_text: String,
    tags: Vec<String>,
    backlinks: Vec<String>,
    resource_refs: Vec<String>,
    updated_at: u128,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalSearchResource {
    resource_id: String,
    resource_type: String,
    title: String,
    path: String,
    updated_at: u128,
}

pub(crate) fn query_local_search_index(
    root_path: &Path,
    root_uri: &str,
    workspace_id: &str,
    query: &str,
    page_id: Option<&str>,
    limit: u32,
    title_only: bool,
    exact: bool,
) -> Result<Value, WebError> {
    let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
    let normalized_query = normalize_search_text(query);
    let page_id = page_id.map(str::trim).filter(|value| !value.is_empty());
    let recent_changes = local_recent_changes_projection(&index.documents, root_uri);
    let mut results = Vec::new();
    for document in index.documents.iter() {
        if let Some(page_id) = page_id {
            if document.document_id != page_id {
                continue;
            }
        }
        if !local_search_document_matches(document, &normalized_query, title_only, exact) {
            continue;
        }
        results.push(local_search_document_projection(
            document,
            root_uri,
            &normalized_query,
        ));
        if results.len() >= limit.max(1) as usize {
            break;
        }
    }
    if page_id.is_none() && results.len() < limit.max(1) as usize {
        for resource in index.resources.iter() {
            if !local_search_resource_matches(resource, &normalized_query, title_only, exact) {
                continue;
            }
            results.push(local_search_resource_projection(resource, root_uri));
            if results.len() >= limit.max(1) as usize {
                break;
            }
        }
    }
    Ok(json!({
        "enqueueAssetIds": [],
        "projectionOwner": "rust-kernel",
        "sourceKind": "local_folder",
        "index": {
            "version": index.version,
            "rootUri": index.root_uri,
            "workspaceId": index.workspace_id,
            "builtAt": index.built_at,
            "documentCount": index.documents.len(),
            "resourceCount": index.resources.len()
        },
        "recentChanges": recent_changes,
        "results": results
    }))
}

pub(crate) fn refresh_local_search_index(
    root_path: &Path,
    root_uri: &str,
    workspace_id: &str,
) -> Result<Value, WebError> {
    let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
    Ok(json!({
        "version": index.version,
        "rootUri": index.root_uri,
        "workspaceId": index.workspace_id,
        "builtAt": index.built_at,
        "documentCount": index.documents.len(),
        "resourceCount": index.resources.len()
    }))
}

pub(crate) fn refresh_local_search_index_for_path(
    root_path: &Path,
    root_uri: &str,
    workspace_id: &str,
    relative_path: &str,
) -> Result<Value, WebError> {
    let relative_path = normalize_index_relative_path(relative_path)?;
    let mut index = match read_local_search_index(root_path) {
        Ok(Some(index))
            if index.version == LOCAL_SEARCH_INDEX_VERSION
                && index.root_uri == root_uri
                && index.workspace_id == workspace_id =>
        {
            index
        }
        Ok(_) | Err(_) => {
            return refresh_local_search_index(root_path, root_uri, workspace_id);
        }
    };

    index
        .documents
        .retain(|document| document.path != relative_path);
    index
        .resources
        .retain(|resource| resource.path != relative_path);
    let absolute_path = root_path.join(&relative_path);
    if absolute_path.exists() && absolute_path.is_file() && is_markdown_path(&absolute_path) {
        index
            .documents
            .push(index_markdown_file(root_path, &absolute_path)?);
    } else if absolute_path.exists()
        && absolute_path.is_file()
        && resource_type_from_path(&absolute_path).is_some()
    {
        index
            .resources
            .push(index_resource_file(root_path, &absolute_path)?);
    }
    index
        .documents
        .sort_by(|left, right| left.path.cmp(&right.path));
    index
        .resources
        .sort_by(|left, right| left.path.cmp(&right.path));
    index.built_at = now_ms();
    write_local_search_index(root_path, &index)?;
    Ok(json!({
        "version": index.version,
        "rootUri": index.root_uri,
        "workspaceId": index.workspace_id,
        "builtAt": index.built_at,
        "documentCount": index.documents.len(),
        "resourceCount": index.resources.len()
    }))
}

pub(crate) fn query_local_backlinks(
    root_path: &Path,
    root_uri: &str,
    workspace_id: &str,
    document_id: &str,
) -> Result<Value, WebError> {
    let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
    let Some(target) = index
        .documents
        .iter()
        .find(|document| document.document_id == document_id)
    else {
        return Ok(json!({
            "documentId": document_id,
            "rootUri": root_uri,
            "workspaceId": workspace_id,
            "backlinks": []
        }));
    };
    let target_keys = normalized_document_targets(target);
    let backlinks = index
        .documents
        .iter()
        .filter(|document| document.document_id != target.document_id)
        .filter_map(|document| {
            let matched_targets = document
                .backlinks
                .iter()
                .filter(|link| target_keys.contains(&normalize_search_text(link)))
                .cloned()
                .collect::<Vec<_>>();
            if matched_targets.is_empty() {
                return None;
            }
            Some(json!({
                "documentId": document.document_id,
                "title": document.title,
                "path": document.path,
                "resourceType": "markdown",
                "matchedTargets": matched_targets,
                "snippet": search_snippet(document, &normalize_search_text(&target.title))
            }))
        })
        .collect::<Vec<_>>();
    Ok(json!({
        "documentId": target.document_id,
        "title": target.title,
        "path": target.path,
        "rootUri": root_uri,
        "workspaceId": workspace_id,
        "backlinks": backlinks
    }))
}

pub(crate) fn query_local_tags(
    root_path: &Path,
    root_uri: &str,
    workspace_id: &str,
) -> Result<Value, WebError> {
    let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
    let mut tags = std::collections::BTreeMap::<String, Vec<&LocalSearchDocument>>::new();
    for document in &index.documents {
        for tag in &document.tags {
            tags.entry(tag.clone()).or_default().push(document);
        }
    }
    Ok(json!({
        "rootUri": root_uri,
        "workspaceId": workspace_id,
        "tags": tags
            .into_iter()
            .map(|(tag, documents)| {
                json!({
                    "tag": tag,
                    "count": documents.len(),
                    "documents": documents
                        .into_iter()
                        .map(|document| {
                            json!({
                                "documentId": document.document_id,
                                "title": document.title,
                                "path": document.path,
                                "resourceType": "markdown"
                            })
                        })
                        .collect::<Vec<_>>()
                })
            })
            .collect::<Vec<_>>()
    }))
}

fn rebuild_local_search_index(
    root_path: &Path,
    root_uri: &str,
    workspace_id: &str,
) -> Result<LocalSearchIndex, WebError> {
    let mut documents = Vec::new();
    let mut resources = Vec::new();
    collect_markdown_documents(root_path, root_path, &mut documents, &mut resources)?;
    documents.sort_by(|left, right| left.path.cmp(&right.path));
    resources.sort_by(|left, right| left.path.cmp(&right.path));
    let index = LocalSearchIndex {
        version: LOCAL_SEARCH_INDEX_VERSION,
        built_at: now_ms(),
        root_uri: root_uri.to_string(),
        workspace_id: workspace_id.to_string(),
        documents,
        resources,
    };
    write_local_search_index(root_path, &index)?;
    Ok(index)
}

fn read_local_search_index(root_path: &Path) -> Result<Option<LocalSearchIndex>, WebError> {
    let index_path = root_path
        .join(".mnote")
        .join("index")
        .join("search-index.json");
    if !index_path.exists() {
        return Ok(None);
    }
    let content = fs::read_to_string(&index_path).map_err(|error| {
        WebError::bad_request_code(
            "local_search_index_read_failed",
            format!("无法读取本地搜索索引 {}: {error}", index_path.display()),
        )
    })?;
    serde_json::from_str::<LocalSearchIndex>(&content)
        .map(Some)
        .map_err(|error| {
            WebError::bad_request_code(
                "local_search_index_invalid",
                format!("本地搜索索引格式非法 {}: {error}", index_path.display()),
            )
        })
}

fn normalize_index_relative_path(relative_path: &str) -> Result<String, WebError> {
    let normalized = relative_path.trim().replace('\\', "/");
    if normalized.is_empty() {
        return Err(WebError::bad_request_code(
            "local_search_index_path_required",
            "本地搜索索引更新缺少相对路径",
        ));
    }
    let path = PathBuf::from(&normalized);
    if path.is_absolute()
        || path.components().any(|component| {
            matches!(
                component,
                Component::ParentDir | Component::RootDir | Component::Prefix(_)
            )
        })
    {
        return Err(WebError::bad_request_code(
            "local_search_index_path_escape",
            "本地搜索索引相对路径不能越过 root",
        ));
    }
    Ok(normalized)
}

fn collect_markdown_documents(
    root_path: &Path,
    current: &Path,
    documents: &mut Vec<LocalSearchDocument>,
    resources: &mut Vec<LocalSearchResource>,
) -> Result<(), WebError> {
    let entries = match fs::read_dir(current) {
        Ok(entries) => entries,
        Err(error) => {
            return Err(WebError::bad_request_code(
                "local_search_index_read_dir_failed",
                format!("无法读取本地搜索索引目录 {}: {error}", current.display()),
            ));
        }
    };
    for entry in entries {
        let entry = entry.map_err(|error| {
            WebError::bad_request_code(
                "local_search_index_read_entry_failed",
                format!("无法读取本地搜索索引条目: {error}"),
            )
        })?;
        let path = entry.path();
        let file_name = path
            .file_name()
            .and_then(|value| value.to_str())
            .unwrap_or_default();
        if file_name == ".mnote" {
            continue;
        }
        let file_type = entry.file_type().map_err(|error| {
            WebError::bad_request_code(
                "local_search_index_stat_failed",
                format!("无法读取本地搜索索引文件状态 {}: {error}", path.display()),
            )
        })?;
        if file_type.is_dir() {
            collect_markdown_documents(root_path, &path, documents, resources)?;
            continue;
        }
        if !file_type.is_file() {
            continue;
        }
        if is_markdown_path(&path) {
            documents.push(index_markdown_file(root_path, &path)?);
        } else if resource_type_from_path(&path).is_some() {
            resources.push(index_resource_file(root_path, &path)?);
        }
    }
    Ok(())
}

fn index_markdown_file(root_path: &Path, path: &Path) -> Result<LocalSearchDocument, WebError> {
    let markdown = fs::read_to_string(path).map_err(|error| {
        WebError::bad_request_code(
            "local_search_index_read_markdown_failed",
            format!("无法读取本地 Markdown 索引文件 {}: {error}", path.display()),
        )
    })?;
    let file_name = path
        .file_name()
        .and_then(|value| value.to_str())
        .unwrap_or("");
    let parsed = parse_markdown_page(&markdown, file_name);
    let relative_path = path
        .strip_prefix(root_path)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/");
    let document_id = format!("local-md:{}", encode_local_id_segment(&relative_path));
    let metadata = fs::metadata(path).map_err(|error| {
        WebError::bad_request_code(
            "local_search_index_stat_failed",
            format!(
                "无法读取本地 Markdown 索引文件状态 {}: {error}",
                path.display()
            ),
        )
    })?;
    Ok(LocalSearchDocument {
        document_id,
        title: parsed.title,
        path: relative_path,
        raw_text: parsed.body.clone(),
        tags: extract_tags(&markdown),
        backlinks: extract_backlinks(&parsed.body),
        resource_refs: extract_resource_refs(&parsed.body),
        updated_at: metadata
            .modified()
            .ok()
            .and_then(|value| value.duration_since(UNIX_EPOCH).ok())
            .map(|value| value.as_millis())
            .unwrap_or_default(),
    })
}

fn index_resource_file(root_path: &Path, path: &Path) -> Result<LocalSearchResource, WebError> {
    let resource_type = resource_type_from_path(path).ok_or_else(|| {
        WebError::bad_request_code(
            "local_search_index_resource_type_unsupported",
            format!("不支持索引该资源文件 {}", path.display()),
        )
    })?;
    let relative_path = path
        .strip_prefix(root_path)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/");
    let title = path
        .file_stem()
        .and_then(|value| value.to_str())
        .unwrap_or("resource")
        .trim()
        .trim_end_matches(".mindmap")
        .to_string();
    let metadata = fs::metadata(path).map_err(|error| {
        WebError::bad_request_code(
            "local_search_index_stat_failed",
            format!("无法读取本地资源索引文件状态 {}: {error}", path.display()),
        )
    })?;
    Ok(LocalSearchResource {
        resource_id: format!("local-resource:{}", relative_path.replace('/', "~2F")),
        resource_type: resource_type.to_string(),
        title,
        path: relative_path,
        updated_at: metadata
            .modified()
            .ok()
            .and_then(|value| value.duration_since(UNIX_EPOCH).ok())
            .map(|value| value.as_millis())
            .unwrap_or_default(),
    })
}

fn write_local_search_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> {
    let index_dir = root_path.join(".mnote").join("index");
    fs::create_dir_all(&index_dir).map_err(|error| {
        WebError::bad_request_code(
            "local_search_index_create_failed",
            format!("无法创建本地搜索索引目录 {}: {error}", index_dir.display()),
        )
    })?;
    let index_path = index_dir.join("search-index.json");
    let payload = serde_json::to_string_pretty(index)
        .map_err(|error| WebError::internal(format!("本地搜索索引序列化失败: {error}")))?;
    fs::write(&index_path, format!("{payload}\n")).map_err(|error| {
        WebError::bad_request_code(
            "local_search_index_write_failed",
            format!("无法写入本地搜索索引 {}: {error}", index_path.display()),
        )
    })
}

fn local_search_document_matches(
    document: &LocalSearchDocument,
    query: &str,
    title_only: bool,
    exact: bool,
) -> bool {
    if query.is_empty() {
        return false;
    }
    let haystack = if title_only {
        normalize_search_text(&document.title)
    } else {
        normalize_search_text(&format!(
            "{}\n{}\n{}\n{}",
            document.title,
            document.raw_text,
            document.tags.join(" "),
            document.resource_refs.join(" ")
        ))
    };
    if exact {
        haystack == query
    } else {
        haystack.contains(query)
    }
}

fn local_search_resource_matches(
    resource: &LocalSearchResource,
    query: &str,
    title_only: bool,
    exact: bool,
) -> bool {
    if query.is_empty() {
        return false;
    }
    let haystack = if title_only {
        normalize_search_text(&resource.title)
    } else {
        normalize_search_text(&format!(
            "{}\n{}\n{}",
            resource.title, resource.path, resource.resource_type
        ))
    };
    if exact {
        haystack == query
    } else {
        haystack.contains(query)
    }
}

fn local_search_document_projection(
    document: &LocalSearchDocument,
    root_uri: &str,
    query: &str,
) -> Value {
    json!({
        "id": document.document_id,
        "documentId": document.document_id,
        "title": document.title,
        "path": document.path,
        "resourceType": "markdown",
        "sourceKind": "local_folder",
        "rootUri": root_uri,
        "snippet": search_snippet(document, query),
        "tags": document.tags,
        "backlinks": document.backlinks,
        "resourceRefs": document.resource_refs,
        "updatedAt": document.updated_at,
        "publicPath": format!(
            "/documents/{}?sourceKind=local_folder&rootUri={}",
            document.document_id,
            encode_query_component(root_uri),
        )
    })
}

fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &str) -> Value {
    json!({
        "id": resource.resource_id,
        "documentId": resource.resource_id,
        "title": resource.title,
        "path": resource.path,
        "resourceType": resource.resource_type,
        "sourceKind": "local_folder",
        "rootUri": root_uri,
        "updatedAt": resource.updated_at,
        "publicPath": format!(
            "/?treeView=filetree&sourceKind=local_folder&rootUri={}",
            encode_query_component(root_uri),
        )
    })
}

fn encode_query_component(value: &str) -> String {
    let mut encoded = String::with_capacity(value.len());
    for byte in value.as_bytes() {
        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
            encoded.push(*byte as char);
        } else {
            encoded.push_str(&format!("%{byte:02X}"));
        }
    }
    encoded
}

fn local_recent_changes_projection(documents: &[LocalSearchDocument], root_uri: &str) -> Value {
    let mut sorted = documents.iter().collect::<Vec<_>>();
    sorted.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
    Value::Array(
        sorted
            .into_iter()
            .take(20)
            .map(|document| {
                json!({
                    "id": document.document_id,
                    "documentId": document.document_id,
                    "title": document.title,
                    "path": document.path,
                    "resourceType": "markdown",
                    "sourceKind": "local_folder",
                    "rootUri": root_uri,
                    "updatedAt": document.updated_at
                })
            })
            .collect(),
    )
}

fn normalized_document_targets(
    document: &LocalSearchDocument,
) -> std::collections::BTreeSet<String> {
    let mut values = std::collections::BTreeSet::new();
    values.insert(normalize_search_text(&document.document_id));
    values.insert(normalize_search_text(&document.path));
    values.insert(normalize_search_text(&document.title));
    if let Some(encoded) = document.document_id.strip_prefix("local-md:") {
        values.insert(normalize_search_text(&encoded.replace("~2F", "/")));
    }
    values
}

fn extract_tags(markdown: &str) -> Vec<String> {
    let Some(frontmatter) = split_frontmatter(markdown).0 else {
        return Vec::new();
    };
    for line in frontmatter.lines() {
        let trimmed = line.trim();
        let Some(value) = trimmed
            .strip_prefix("tags:")
            .or_else(|| trimmed.strip_prefix("tag:"))
        else {
            continue;
        };
        return value
            .trim()
            .trim_matches(['[', ']'])
            .split([',', ' '])
            .map(|item| item.trim().trim_matches(['"', '\'']))
            .filter(|item| !item.is_empty())
            .map(ToOwned::to_owned)
            .collect();
    }
    Vec::new()
}

fn extract_backlinks(markdown: &str) -> Vec<String> {
    let mut values = extract_markdown_links(markdown)
        .into_iter()
        .filter(|target| is_markdown_reference(target))
        .collect::<Vec<_>>();
    let mut rest = markdown;
    while let Some(start) = rest.find("[[") {
        let after_start = &rest[start + 2..];
        let Some(end) = after_start.find("]]") else {
            break;
        };
        let link = after_start[..end].trim();
        if !link.is_empty() {
            values.push(link.to_string());
        }
        rest = &after_start[end + 2..];
    }
    values.sort();
    values.dedup();
    values
}

fn extract_resource_refs(markdown: &str) -> Vec<String> {
    let mut values = markdown
        .lines()
        .filter_map(|line| parse_markdown_attachment_link(line.trim()))
        .map(|(_, target)| target)
        .chain(
            extract_markdown_links(markdown)
                .into_iter()
                .filter(|target| {
                    !is_markdown_reference(target) && is_local_resource_reference(target)
                }),
        )
        .collect::<Vec<_>>();
    values.sort();
    values.dedup();
    values
}

fn extract_markdown_links(markdown: &str) -> Vec<String> {
    let mut links = Vec::new();
    let mut rest = markdown;
    while let Some(label_end) = rest.find("](") {
        let after = &rest[label_end + 2..];
        let Some(target_end) = after.find(')') else {
            break;
        };
        let target = after[..target_end].trim();
        if !target.is_empty() {
            links.push(target.to_string());
        }
        rest = &after[target_end + 1..];
    }
    links
}

fn is_markdown_reference(target: &str) -> bool {
    let lower = target.to_lowercase();
    lower.ends_with(".md") || lower.ends_with(".markdown") || lower.starts_with("local-md:")
}

fn is_local_resource_reference(target: &str) -> bool {
    let lower = target.to_lowercase();
    !lower.starts_with("http://")
        && !lower.starts_with("https://")
        && !lower.starts_with("mailto:")
        && !lower.starts_with('#')
        && Path::new(target).extension().is_some()
}

fn search_snippet(document: &LocalSearchDocument, query: &str) -> String {
    for line in document.raw_text.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        if normalize_search_text(trimmed).contains(query) {
            return trimmed.chars().take(180).collect();
        }
    }
    document.raw_text.chars().take(180).collect()
}

fn is_markdown_path(path: &Path) -> bool {
    path.extension()
        .and_then(|value| value.to_str())
        .map(|value| value.eq_ignore_ascii_case("md") || value.eq_ignore_ascii_case("markdown"))
        .unwrap_or(false)
}

fn resource_type_from_path(path: &Path) -> Option<&'static str> {
    let file_name = path
        .file_name()
        .and_then(|value| value.to_str())
        .unwrap_or_default()
        .to_lowercase();
    if file_name.ends_with(".mindmap.json") {
        return Some("mindmap");
    }
    let extension = path
        .extension()
        .and_then(|value| value.to_str())
        .unwrap_or_default()
        .to_lowercase();
    match extension.as_str() {
        "doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods" => Some("office"),
        _ => None,
    }
}

fn normalize_search_text(value: &str) -> String {
    value.trim().to_lowercase()
}

fn now_ms() -> u128 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|value| value.as_millis())
        .unwrap_or_default()
}

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

    fn temp_root(name: &str) -> PathBuf {
        let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(root.join("docs")).expect("create root");
        root
    }

    #[test]
    fn local_search_index_extracts_backlinks_resource_refs_and_tags() {
        let root = temp_root("mnote-local-search-index");
        fs::write(
            root.join("README.md"),
            "---\ntitle: Home\ntags: [alpha, beta]\n---\n# Home\nSee [[Daily]] and [Child](docs/child.md).\n[Spec](assets/spec.pdf)\n[Map](maps/idea.mindmap.json)\n[Sheet](office/report.xlsx)\n![Diagram](assets/diagram.png)\n",
        )
        .expect("write readme");
        fs::write(
            root.join("docs").join("child.md"),
            "---\nmnote_id: child-page\ntitle: Child\n---\nChild body with alpha and office.xlsx\n",
        )
        .expect("write child");
        fs::create_dir_all(root.join("maps")).expect("create maps");
        fs::create_dir_all(root.join("office")).expect("create office");
        fs::write(
            root.join("maps").join("idea.mindmap.json"),
            r#"{"title":"Idea Map","nodes":[]}"#,
        )
        .expect("write mindmap");
        fs::write(root.join("office").join("report.xlsx"), b"office bytes").expect("write office");

        let projection = query_local_search_index(
            &root,
            &format!("file://{}", root.display()),
            "local-ws-test",
            "alpha",
            None,
            10,
            false,
            false,
        )
        .expect("projection");
        let results = projection["results"].as_array().expect("results");
        let home = results
            .iter()
            .find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
            .expect("home result");
        assert!(home["tags"]
            .as_array()
            .unwrap()
            .iter()
            .any(|tag| tag.as_str() == Some("alpha")));
        assert!(home["backlinks"]
            .as_array()
            .unwrap()
            .iter()
            .any(|link| link.as_str() == Some("Daily")));
        assert!(home["backlinks"]
            .as_array()
            .unwrap()
            .iter()
            .any(|link| link.as_str() == Some("docs/child.md")));
        assert!(home["resourceRefs"]
            .as_array()
            .unwrap()
            .iter()
            .any(|reference| reference.as_str() == Some("assets/spec.pdf")));
        assert!(home["resourceRefs"]
            .as_array()
            .unwrap()
            .iter()
            .any(|reference| reference.as_str() == Some("maps/idea.mindmap.json")));
        assert!(home["resourceRefs"]
            .as_array()
            .unwrap()
            .iter()
            .any(|reference| reference.as_str() == Some("office/report.xlsx")));
        assert!(home["publicPath"]
            .as_str()
            .is_some_and(|path| path.starts_with(
                "/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
            )));

        // 含 mnote_id 的 Markdown 仍返回路径型 documentId，不应出现 local-mdid:
        let child_search = query_local_search_index(
            &root,
            &format!("file://{}", root.display()),
            "local-ws-test",
            "office.xlsx",
            None,
            10,
            false,
            false,
        )
        .expect("child search");
        let child_result = child_search["results"]
            .as_array()
            .unwrap()
            .iter()
            .find(|item| item["path"].as_str() == Some("docs/child.md"))
            .expect("child in search results");
        assert_eq!(
            child_result["documentId"].as_str(),
            Some("local-md:docs~2Fchild.md")
        );
        assert_ne!(
            child_result["documentId"].as_str(),
            Some("local-mdid:child-page")
        );

        assert!(root
            .join(".mnote")
            .join("index")
            .join("search-index.json")
            .exists());
        let mindmap_projection = query_local_search_index(
            &root,
            &format!("file://{}", root.display()),
            "local-ws-test",
            "idea.mindmap",
            None,
            10,
            false,
            false,
        )
        .expect("mindmap projection");
        assert!(mindmap_projection["results"]
            .as_array()
            .unwrap()
            .iter()
            .any(|item| item["resourceType"].as_str() == Some("mindmap")
                && item["path"].as_str() == Some("maps/idea.mindmap.json")
                && item["publicPath"]
                    .as_str()
                    .is_some_and(|path| path
                        .starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri="))
                && item["publicPath"]
                    .as_str()
                    .is_some_and(|path| !path.starts_with("/tree?"))));
        let office_projection = query_local_search_index(
            &root,
            &format!("file://{}", root.display()),
            "local-ws-test",
            "report.xlsx",
            None,
            10,
            false,
            false,
        )
        .expect("office projection");
        assert!(office_projection["results"]
            .as_array()
            .unwrap()
            .iter()
            .any(|item| item["resourceType"].as_str() == Some("office")
                && item["path"].as_str() == Some("office/report.xlsx")));
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn local_search_index_incrementally_updates_single_markdown_path() {
        let root = temp_root("mnote-local-search-index-incremental");
        let root_uri = format!("file://{}", root.display());
        let workspace_id = "local-ws-incremental";
        fs::write(root.join("README.md"), "# Home\nRoot body.\n").expect("write home");
        fs::write(
            root.join("docs").join("child.md"),
            "---\ntitle: Child\n---\n# Child\nOriginal body.\n",
        )
        .expect("write child");

        refresh_local_search_index(&root, &root_uri, workspace_id).expect("initial refresh");
        fs::write(
            root.join("docs").join("child.md"),
            "---\ntitle: Child Updated\n---\n# Child Updated\nChangedToken body.\n",
        )
        .expect("update child");
        refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md")
            .expect("incremental update");
        let index = read_local_search_index(&root)
            .expect("read index")
            .expect("index exists");
        assert!(index
            .documents
            .iter()
            .any(|document| document.path == "README.md"));
        let child = index
            .documents
            .iter()
            .find(|document| document.path == "docs/child.md")
            .expect("child document");
        // frontmatter title "Child Updated" 优先于文件名 "child"。
        assert_eq!(child.title, "Child Updated");
        assert!(child.raw_text.contains("ChangedToken"));

        fs::remove_file(root.join("docs").join("child.md")).expect("remove child");
        refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md")
            .expect("incremental remove");
        let index = read_local_search_index(&root)
            .expect("read index")
            .expect("index exists");
        assert!(!index
            .documents
            .iter()
            .any(|document| document.path == "docs/child.md"));
        assert!(index
            .documents
            .iter()
            .any(|document| document.path == "README.md"));
        let _ = fs::remove_dir_all(&root);
    }
}
