use crate::error::WebError;
use crate::page_aggregate::{
    PageAggregate, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, PageOptions,
    PagePermissions, PageStats, PageTree,
};
use crate::routes::local_markdown_parser::{
    file_stem_title, parse_markdown_page, split_frontmatter,
};
use crate::routes::snapshot_support::ProjectionSnapshot;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::cmp::Ordering;
use std::collections::hash_map::DefaultHasher;
use std::collections::BTreeMap;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Debug, Clone)]
struct LocalFolderEntry {
    path: PathBuf,
    relative_path: String,
    file_name: String,
    is_dir: bool,
    is_symlink: bool,
    is_readonly: bool,
}

#[derive(Debug, Clone, Default)]
struct LocalFolderMetadata {
    page_ids: BTreeMap<String, String>,
    page_options: BTreeMap<String, Value>,
    trash_entries: BTreeMap<String, LocalTrashEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalTrashEntry {
    document_id: String,
    original_relative_path: String,
    trash_relative_path: String,
    deleted_at_ms: u128,
}

#[derive(Debug, Clone)]
struct LocalFolderRow {
    node_id: String,
    row_id: String,
    parent_node_id: Option<String>,
    title: String,
    depth: u32,
    position: u32,
    row_kind: String,
    icon_hint: String,
    relative_path: String,
    source_uri: String,
    child_count: u32,
    expandable: bool,
    expanded_by_default: bool,
    document_id: Option<String>,
    capabilities: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalFolderWatchRevision {
    pub root_uri: String,
    pub revision: String,
    pub entry_count: usize,
    pub latest_modified_ms: u128,
}

pub fn load_local_folder_file_tree_snapshot(
    root_uri: &str,
) -> Result<ProjectionSnapshot, WebError> {
    let root_path = parse_file_root_uri(root_uri)?;
    let canonical_root = root_path.canonicalize().map_err(|error| {
        WebError::bad_request_code(
            "local_folder_unavailable",
            format!("无法访问本地文件夹: {error}"),
        )
    })?;
    if !canonical_root.is_dir() {
        return Err(WebError::bad_request_code(
            "local_folder_not_directory",
            "本地 rootUri 必须指向目录",
        ));
    }

    let root_source_uri = file_uri_for_path(&canonical_root);
    let metadata = load_local_folder_metadata(&canonical_root)?;
    let mut rows = Vec::new();
    scan_directory(
        &canonical_root,
        &canonical_root,
        None,
        0,
        &root_source_uri,
        &metadata,
        &mut rows,
    )?;

    let items = rows
        .iter()
        .map(local_folder_row_to_projection_item)
        .collect::<Vec<_>>();
    let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?;
    let dataset = json!({
        "workspace": {
            "id": local_workspace_id(&canonical_root),
            "sourceKind": "local_folder",
            "rootUri": root_source_uri,
        },
        "nodes": [],
        "edges": [],
        "documents": [],
        "media_assets": [],
        "mindmap_assets": [],
        "table_assets": [],
        "mindmap_asset_children": {},
    });
    let projection = json!({
        "projection": "file_tree",
        "sourceKind": "local_folder",
        "rootUri": root_source_uri,
        "watchRevision": watch_revision,
        "items": items,
    });

    Ok(ProjectionSnapshot {
        dataset,
        projection,
    })
}

pub fn load_local_folder_page_tree_snapshot(
    root_uri: &str,
) -> Result<ProjectionSnapshot, WebError> {
    let root_path = parse_file_root_uri(root_uri)?;
    let canonical_root = root_path.canonicalize().map_err(|error| {
        WebError::bad_request_code(
            "local_folder_unavailable",
            format!("无法访问本地文件夹: {error}"),
        )
    })?;
    if !canonical_root.is_dir() {
        return Err(WebError::bad_request_code(
            "local_folder_not_directory",
            "本地 rootUri 必须指向目录",
        ));
    }

    let root_source_uri = file_uri_for_path(&canonical_root);
    let metadata = load_local_folder_metadata(&canonical_root)?;
    let mut rows = Vec::new();
    scan_markdown_page_tree(
        &canonical_root,
        &canonical_root,
        None,
        0,
        &metadata,
        &mut rows,
    )?;
    let child_counts = rows
        .iter()
        .filter_map(|row| row.parent_node_id.as_ref())
        .fold(
            std::collections::BTreeMap::<String, u32>::new(),
            |mut acc, parent| {
                *acc.entry(parent.clone()).or_default() += 1;
                acc
            },
        );
    for row in &mut rows {
        row.child_count = child_counts.get(&row.node_id).copied().unwrap_or(0);
        row.expandable = row.child_count > 0;
        row.expanded_by_default = row.expandable && row.depth < 2;
    }

    let items = rows
        .iter()
        .map(local_folder_row_to_projection_item)
        .collect::<Vec<_>>();
    let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?;
    Ok(ProjectionSnapshot {
        dataset: json!({
            "workspace": {
                "id": local_workspace_id(&canonical_root),
                "sourceKind": "local_folder",
                "rootUri": root_source_uri,
            },
            "documents": rows
                .iter()
                .filter_map(|row| {
                    row.document_id.as_ref().map(|document_id| {
                        json!({
                            "id": document_id,
                            "workspace_id": local_workspace_id(&canonical_root),
                            "title": row.title,
                            "parent_id": row.parent_node_id,
                            "sort_order": row.position,
                        })
                    })
                })
                .collect::<Vec<_>>(),
        }),
        projection: json!({
            "projection": "page_tree",
            "sourceKind": "local_folder",
            "rootUri": root_source_uri,
            "watchRevision": watch_revision,
            "items": items,
        }),
    })
}

pub fn local_folder_watch_revision(root_uri: &str) -> Result<LocalFolderWatchRevision, WebError> {
    let root_path = parse_file_root_uri(root_uri)?;
    let canonical_root = root_path.canonicalize().map_err(|error| {
        WebError::bad_request_code(
            "local_folder_unavailable",
            format!("无法访问本地文件夹: {error}"),
        )
    })?;
    if !canonical_root.is_dir() {
        return Err(WebError::bad_request_code(
            "local_folder_not_directory",
            "本地 rootUri 必须指向目录",
        ));
    }
    let root_source_uri = file_uri_for_path(&canonical_root);
    local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)
}

pub fn resolve_local_markdown_page_aggregate(
    root_uri: &str,
    document_id: &str,
) -> Result<PageAggregate, WebError> {
    let root_path = parse_file_root_uri(root_uri)?;
    let canonical_root = root_path.canonicalize().map_err(|error| {
        WebError::bad_request_code(
            "local_folder_unavailable",
            format!("无法访问本地文件夹: {error}"),
        )
    })?;
    if !canonical_root.is_dir() {
        return Err(WebError::bad_request_code(
            "local_folder_not_directory",
            "本地 rootUri 必须指向目录",
        ));
    }
    let metadata = load_local_folder_metadata(&canonical_root)?;
    let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)?
        .ok_or_else(|| {
            WebError::bad_request_code(
                "local_markdown_not_found",
                "找不到本地 Markdown 页面对应的文件",
            )
        })?;
    let markdown = fs::read_to_string(&markdown_file.path).map_err(|error| {
        WebError::bad_request_code(
            "local_markdown_read_failed",
            format!(
                "无法读取本地 Markdown 文件 {}: {error}",
                markdown_file.path.display()
            ),
        )
    })?;
    let parsed = parse_markdown_page(&markdown, &markdown_file.file_name);
    let title = parsed.title;
    let content = crate::routes::local_markdown_parser::markdown_to_blocks(&parsed.body);
    let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64;
    let page_subtree = markdown_page_subtree(document_id, &title, &content);
    let workspace_id = local_workspace_id(&canonical_root);
    let page_options = metadata
        .page_options
        .get(document_id)
        .or_else(|| metadata.page_options.get(&markdown_file.relative_path))
        .map(page_options_from_metadata)
        .unwrap_or_default();
    let character_count = parsed.body.chars().count() as u64;
    let word_count = parsed
        .body
        .split_whitespace()
        .filter(|word| !word.trim().is_empty())
        .count() as u64;
    let read_only = markdown_file.is_readonly;

    Ok(PageAggregate {
        schema: PageAggregate::SCHEMA.into(),
        projection_version: PageAggregate::VERSION,
        source: PageAggregateSource::KernelProjection,
        page_id: document_id.to_string(),
        parent_id: None,
        title: title.clone(),
        path: vec![document_id.to_string()],
        sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()],
        body_ref: Some(format!("local-md:{document_id}")),
        layout_options: serde_json::to_value(&page_options).unwrap_or_else(|_| json!({})),
        updated_at: None,
        identity: PageIdentity {
            document_id: document_id.to_string(),
            workspace_id,
        },
        head: PageHead {
            title,
            updated_at: Value::Null,
            permissions: PagePermissions {
                read_only,
                disable_download: false,
                disable_copy: false,
            },
        },
        layout: PageLayout { page_options },
        body: PageBody {
            content,
            revision: Value::Number(0.into()),
            conflict_detection_key: Value::String(local_markdown_conflict_detection_key(
                document_id,
                &markdown_file.path,
            )?),
        },
        tree: PageTree { page_subtree },
        stats: PageStats {
            word_count,
            character_count,
            block_count,
            todo_total: 0,
            todo_done: 0,
        },
    })
}

pub fn save_local_markdown_page(
    root_uri: &str,
    document_id: &str,
    expected_conflict_detection_key: Option<&str>,
    content: &Value,
) -> Result<Value, WebError> {
    let root_path = parse_file_root_uri(root_uri)?;
    let canonical_root = root_path.canonicalize().map_err(|error| {
        WebError::bad_request_code(
            "local_folder_unavailable",
            format!("无法访问本地文件夹: {error}"),
        )
    })?;
    let metadata = load_local_folder_metadata(&canonical_root)?;
    let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)?
        .ok_or_else(|| {
            WebError::bad_request_code(
                "local_markdown_not_found",
                "找不到要保存的本地 Markdown 页面",
            )
        })?;
    let current = fs::read_to_string(&markdown_file.path).map_err(|error| {
        WebError::bad_request_code(
            "local_markdown_read_failed",
            format!(
                "无法读取本地 Markdown 文件 {}: {error}",
                markdown_file.path.display()
            ),
        )
    })?;
    let current_conflict_key =
        local_markdown_conflict_detection_key(document_id, &markdown_file.path)?;
    if let Some(expected_key) = expected_conflict_detection_key
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        if expected_key != current_conflict_key {
            return Err(WebError::new(
                StatusCode::CONFLICT,
                "local_markdown_external_change",
                "本地 Markdown 文件已被外部修改，请刷新后再保存",
            ));
        }
    }
    let (frontmatter, _) = split_frontmatter(&current);
    let body = editor_blocks_to_markdown(content);
    let next_markdown = if let Some(frontmatter) = frontmatter {
        format!("---\n{frontmatter}\n---\n{body}")
    } else {
        body
    };
    fs::write(&markdown_file.path, next_markdown).map_err(|error| {
        WebError::bad_request_code(
            "local_markdown_write_failed",
            format!(
                "无法保存本地 Markdown 文件 {}: {error}",
                markdown_file.path.display()
            ),
        )
    })?;
    let next_conflict_key =
        local_markdown_conflict_detection_key(document_id, &markdown_file.path)?;
    Ok(json!({
        "ok": true,
        "documentId": document_id,
        "revision": now_ms(),
        "conflict_detection_key": next_conflict_key,
        "executedCommand": "page.body.save",
        "canonicalCommand": "page.body.save",
        "sourceKind": "local_folder",
    }))
}

pub fn update_local_markdown_title(
    root_uri: &str,
    document_id: &str,
    title: &str,
) -> Result<Value, WebError> {
    let root_path = parse_file_root_uri(root_uri)?;
    let canonical_root = root_path.canonicalize().map_err(|error| {
        WebError::bad_request_code(
            "local_folder_unavailable",
            format!("无法访问本地文件夹: {error}"),
        )
    })?;
    let metadata = load_local_folder_metadata(&canonical_root)?;
    let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)?
        .ok_or_else(|| {
            WebError::bad_request_code(
                "local_markdown_not_found",
                "找不到要更新标题的本地 Markdown 页面",
            )
        })?;
    let current = fs::read_to_string(&markdown_file.path).map_err(|error| {
        WebError::bad_request_code(
            "local_markdown_read_failed",
            format!(
                "无法读取本地 Markdown 文件 {}: {error}",
                markdown_file.path.display()
            ),
        )
    })?;
    fs::write(
        &markdown_file.path,
        markdown_with_frontmatter_fields(&current, &[("title", title.trim())]),
    )
    .map_err(|error| {
        WebError::bad_request_code(
            "local_markdown_write_failed",
            format!(
                "无法写入本地 Markdown 标题 {}: {error}",
                markdown_file.path.display()
            ),
        )
    })?;
    Ok(json!({
        "ok": true,
        "documentId": document_id,
        "title": title.trim(),
        "updated_at": Value::Null,
        "sourceKind": "local_folder",
    }))
}

pub fn update_local_page_options(
    root_uri: &str,
    document_id: &str,
    options: &Value,
) -> Result<Value, WebError> {
    let root_path = parse_file_root_uri(root_uri)?;
    let canonical_root = root_path.canonicalize().map_err(|error| {
        WebError::bad_request_code(
            "local_folder_unavailable",
            format!("无法访问本地文件夹: {error}"),
        )
    })?;
    let mut metadata = load_local_folder_metadata(&canonical_root)?;
    metadata
        .page_options
        .insert(document_id.to_string(), options.clone());
    write_page_options_metadata(&canonical_root, &metadata.page_options)?;
    Ok(json!({
        "ok": true,
        "documentId": document_id,
        "options": options,
        "updated_at": Value::Null,
        "sourceKind": "local_folder",
    }))
}

pub fn execute_local_tree_command(
    root_uri: &str,
    action: &str,
    document_id: &str,
    parent_id: Option<&str>,
    title: Option<&str>,
) -> Result<Value, WebError> {
    let root_path = parse_file_root_uri(root_uri)?;
    let canonical_root = root_path.canonicalize().map_err(|error| {
        WebError::bad_request_code(
            "local_folder_unavailable",
            format!("无法访问本地文件夹: {error}"),
        )
    })?;
    match action {
        "create" => {
            create_local_markdown_page(&canonical_root, parent_id, title.unwrap_or("新页面"))
        }
        "createFolder" | "create_folder" | "folder.create" => {
            create_local_folder(&canonical_root, parent_id, title.unwrap_or("新建文件夹"))
        }
        "rename" => rename_local_entry(&canonical_root, document_id, title.unwrap_or("无标题")),
        "copy" => {
            copy_local_markdown_page(&canonical_root, document_id, parent_id, title.unwrap_or(""))
        }
        "dropFiles" | "drop_files" => drop_external_files_into_local_folder(
            &canonical_root,
            parent_id,
            title.unwrap_or("外部文件"),
        ),
        "move" => move_local_entry(&canonical_root, document_id, parent_id),
        "delete" | "trash" => trash_local_markdown_page(&canonical_root, document_id),
        "restore" => restore_local_markdown_page(&canonical_root, document_id),
        "purge" => purge_local_markdown_page(&canonical_root, document_id),
        other => Err(WebError::bad_request_code(
            "local_tree_command_unsupported",
            format!("local_folder 暂不支持 tree action: {other}"),
        )),
    }
}

fn create_local_markdown_page(
    root: &Path,
    parent_id: Option<&str>,
    title: &str,
) -> Result<Value, WebError> {
    let metadata = load_local_folder_metadata(root)?;
    let parent_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
    let safe_title = sanitize_file_stem(title, "新页面");
    let target = next_available_path(&parent_directory, &safe_title, "md");
    let relative_path = normalize_relative_path(root, &target)?;
    let page_id = normalize_metadata_page_id(&format!(
        "local-mdid:{}",
        encode_local_id_segment(&format!("{}:{relative_path}", local_workspace_id(root)))
    ));
    let markdown = markdown_with_frontmatter_fields(
        &format!("# {}\n", title.trim()),
        &[("mnote_id", page_id.as_str()), ("title", title.trim())],
    );
    fs::write(&target, markdown).map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!("无法创建本地 Markdown 文件 {}: {error}", target.display()),
        )
    })?;
    let mut page_ids = metadata.page_ids;
    page_ids.insert(relative_path.clone(), page_id.clone());
    write_page_ids_metadata(root, &page_ids)?;
    Ok(json!({
        "ok": true,
        "id": page_id,
        "documentId": page_id,
        "relativePath": relative_path,
        "action": "create",
        "sourceKind": "local_folder",
    }))
}

fn create_local_folder(
    root: &Path,
    parent_id: Option<&str>,
    title: &str,
) -> Result<Value, WebError> {
    let metadata = load_local_folder_metadata(root)?;
    let parent_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
    let target =
        next_available_directory_path(&parent_directory, &sanitize_file_stem(title, "新建文件夹"));
    fs::create_dir_all(&target).map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!("无法创建本地文件夹 {}: {error}", target.display()),
        )
    })?;
    Ok(json!({
        "ok": true,
        "id": local_directory_group_id(&normalize_relative_path(root, &target)?),
        "relativePath": normalize_relative_path(root, &target)?,
        "action": "createFolder",
        "sourceKind": "local_folder",
    }))
}

fn rename_local_entry(root: &Path, document_id: &str, title: &str) -> Result<Value, WebError> {
    if let Some(directory) = resolve_local_directory_id(root, document_id)? {
        return rename_local_directory(root, &directory, title);
    }
    if let Some(file) = resolve_local_raw_file_id(root, document_id)? {
        return rename_local_raw_file(root, &file, title);
    }
    rename_local_markdown_page(root, document_id, title)
}

fn rename_local_raw_file(root: &Path, file: &Path, title: &str) -> Result<Value, WebError> {
    let parent = file.parent().ok_or_else(|| {
        WebError::bad_request_code("local_tree_command_failed", "无法解析文件父目录")
    })?;
    let original_extension = file
        .extension()
        .and_then(|value| value.to_str())
        .unwrap_or("");
    let requested_name = sanitize_file_name(title, "文件");
    let requested_has_extension = Path::new(&requested_name).extension().is_some();
    let target_name = if requested_has_extension || original_extension.is_empty() {
        requested_name
    } else {
        format!(
            "{}.{}",
            sanitize_file_stem(&requested_name, "文件"),
            original_extension
        )
    };
    let target = next_available_raw_path(parent, &target_name);
    fs::rename(file, &target).map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!("无法重命名本地文件 {}: {error}", file.display()),
        )
    })?;
    Ok(json!({
        "ok": true,
        "id": local_node_id(&normalize_relative_path(root, &target)?),
        "relativePath": normalize_relative_path(root, &target)?,
        "action": "rename",
        "sourceKind": "local_folder",
    }))
}

fn rename_local_markdown_page(
    root: &Path,
    document_id: &str,
    title: &str,
) -> Result<Value, WebError> {
    let mut metadata = load_local_folder_metadata(root)?;
    let markdown_file =
        find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| {
            WebError::bad_request_code(
                "local_markdown_not_found",
                "找不到要重命名的本地 Markdown 页面",
            )
        })?;
    let parent = markdown_file.path.parent().ok_or_else(|| {
        WebError::bad_request_code("local_tree_command_failed", "无法解析 Markdown 父目录")
    })?;
    let target = next_available_path(parent, &sanitize_file_stem(title, "无标题"), "md");
    let old_relative_path = markdown_file.relative_path;
    fs::rename(&markdown_file.path, &target).map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!(
                "无法重命名本地 Markdown 文件 {}: {error}",
                markdown_file.path.display()
            ),
        )
    })?;
    let new_relative_path = normalize_relative_path(root, &target)?;
    metadata.page_ids.remove(&old_relative_path);
    metadata
        .page_ids
        .insert(new_relative_path.clone(), document_id.to_string());
    write_page_ids_metadata(root, &metadata.page_ids)?;
    Ok(json!({
        "ok": true,
        "id": document_id,
        "documentId": document_id,
        "relativePath": new_relative_path,
        "action": "rename",
        "sourceKind": "local_folder",
    }))
}

fn rename_local_directory(root: &Path, directory: &Path, title: &str) -> Result<Value, WebError> {
    if directory == root {
        return Err(WebError::bad_request_code(
            "local_tree_command_failed",
            "不能重命名本地 workspace root",
        ));
    }
    let mut metadata = load_local_folder_metadata(root)?;
    let parent = directory.parent().ok_or_else(|| {
        WebError::bad_request_code("local_tree_command_failed", "无法解析文件夹父目录")
    })?;
    let target = next_available_directory_path(parent, &sanitize_file_stem(title, "文件夹"));
    let old_relative_path = normalize_relative_path(root, directory)?;
    fs::rename(directory, &target).map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!("无法重命名本地文件夹 {}: {error}", directory.display()),
        )
    })?;
    let new_relative_path = normalize_relative_path(root, &target)?;
    rewrite_page_ids_prefix(
        &mut metadata.page_ids,
        &old_relative_path,
        &new_relative_path,
    );
    write_page_ids_metadata(root, &metadata.page_ids)?;
    Ok(json!({
        "ok": true,
        "id": local_directory_group_id(&new_relative_path),
        "relativePath": new_relative_path,
        "action": "rename",
        "sourceKind": "local_folder",
    }))
}

fn copy_local_markdown_page(
    root: &Path,
    document_id: &str,
    parent_id: Option<&str>,
    title: &str,
) -> Result<Value, WebError> {
    let mut metadata = load_local_folder_metadata(root)?;
    let markdown_file =
        find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| {
            WebError::bad_request_code(
                "local_markdown_not_found",
                "找不到要复制的本地 Markdown 页面",
            )
        })?;
    let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
    let source_stem = markdown_file
        .path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or("页面");
    let copy_stem = if title.trim().is_empty() {
        source_stem
    } else {
        title.trim()
    };
    let target = next_available_path(
        &target_directory,
        &sanitize_file_stem(copy_stem, "页面"),
        "md",
    );
    let new_relative_path = normalize_relative_path(root, &target)?;
    let new_page_id = normalize_metadata_page_id(&format!(
        "local-mdid:{}",
        encode_local_id_segment(&format!("{}:{new_relative_path}", local_workspace_id(root)))
    ));
    let source_markdown = fs::read_to_string(&markdown_file.path).map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!(
                "无法读取待复制 Markdown 文件 {}: {error}",
                markdown_file.path.display()
            ),
        )
    })?;
    fs::write(
        &target,
        markdown_with_frontmatter_fields(&source_markdown, &[("mnote_id", new_page_id.as_str())]),
    )
    .map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!("无法复制本地 Markdown 文件到 {}: {error}", target.display()),
        )
    })?;
    metadata
        .page_ids
        .insert(new_relative_path.clone(), new_page_id.clone());
    write_page_ids_metadata(root, &metadata.page_ids)?;
    Ok(json!({
        "ok": true,
        "id": new_page_id,
        "documentId": new_page_id,
        "relativePath": new_relative_path,
        "action": "copy",
        "sourceKind": "local_folder",
    }))
}

fn move_local_entry(
    root: &Path,
    document_id: &str,
    parent_id: Option<&str>,
) -> Result<Value, WebError> {
    if let Some(directory) = resolve_local_directory_id(root, document_id)? {
        return move_local_directory(root, &directory, parent_id);
    }
    move_local_markdown_page(root, document_id, parent_id)
}

fn move_local_markdown_page(
    root: &Path,
    document_id: &str,
    parent_id: Option<&str>,
) -> Result<Value, WebError> {
    let mut metadata = load_local_folder_metadata(root)?;
    let markdown_file =
        find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| {
            WebError::bad_request_code(
                "local_markdown_not_found",
                "找不到要移动的本地 Markdown 页面",
            )
        })?;
    let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
    if markdown_file.path.parent() == Some(target_directory.as_path()) {
        return Ok(json!({
            "ok": true,
            "id": document_id,
            "documentId": document_id,
            "relativePath": markdown_file.relative_path,
            "action": "move",
            "sourceKind": "local_folder",
        }));
    }
    let file_name = markdown_file
        .path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .map(|stem| sanitize_file_stem(stem, "页面"))
        .unwrap_or_else(|| "页面".to_string());
    let target = next_available_path(&target_directory, &file_name, "md");
    if target == markdown_file.path {
        return Ok(json!({
            "ok": true,
            "id": document_id,
            "documentId": document_id,
            "relativePath": markdown_file.relative_path,
            "action": "move",
            "sourceKind": "local_folder",
        }));
    }
    if target.starts_with(&markdown_file.path) {
        return Err(WebError::bad_request_code(
            "local_tree_command_failed",
            "禁止把本地页面移动到自身或后代",
        ));
    }
    let old_relative_path = markdown_file.relative_path;
    fs::rename(&markdown_file.path, &target).map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!(
                "无法移动本地 Markdown 文件 {}: {error}",
                markdown_file.path.display()
            ),
        )
    })?;
    let new_relative_path = normalize_relative_path(root, &target)?;
    metadata.page_ids.remove(&old_relative_path);
    metadata
        .page_ids
        .insert(new_relative_path.clone(), document_id.to_string());
    write_page_ids_metadata(root, &metadata.page_ids)?;
    Ok(json!({
        "ok": true,
        "id": document_id,
        "documentId": document_id,
        "relativePath": new_relative_path,
        "action": "move",
        "sourceKind": "local_folder",
    }))
}

fn move_local_directory(
    root: &Path,
    directory: &Path,
    parent_id: Option<&str>,
) -> Result<Value, WebError> {
    if directory == root {
        return Err(WebError::bad_request_code(
            "local_tree_command_failed",
            "不能移动本地 workspace root",
        ));
    }
    let mut metadata = load_local_folder_metadata(root)?;
    let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
    if target_directory == directory || target_directory.starts_with(directory) {
        return Err(WebError::bad_request_code(
            "local_tree_command_failed",
            "禁止把本地文件夹移动到自身或后代",
        ));
    }
    if directory.parent() == Some(target_directory.as_path()) {
        return Ok(json!({
            "ok": true,
            "id": local_directory_group_id(&normalize_relative_path(root, directory)?),
            "relativePath": normalize_relative_path(root, directory)?,
            "action": "move",
            "sourceKind": "local_folder",
        }));
    }
    let stem = directory
        .file_name()
        .and_then(|name| name.to_str())
        .map(|name| sanitize_file_stem(name, "文件夹"))
        .unwrap_or_else(|| "文件夹".to_string());
    let target = next_available_directory_path(&target_directory, &stem);
    let old_relative_path = normalize_relative_path(root, directory)?;
    fs::rename(directory, &target).map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!("无法移动本地文件夹 {}: {error}", directory.display()),
        )
    })?;
    let new_relative_path = normalize_relative_path(root, &target)?;
    rewrite_page_ids_prefix(
        &mut metadata.page_ids,
        &old_relative_path,
        &new_relative_path,
    );
    write_page_ids_metadata(root, &metadata.page_ids)?;
    Ok(json!({
        "ok": true,
        "id": local_directory_group_id(&new_relative_path),
        "relativePath": new_relative_path,
        "action": "move",
        "sourceKind": "local_folder",
    }))
}

fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
    let mut metadata = load_local_folder_metadata(root)?;
    let markdown_file =
        find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| {
            WebError::bad_request_code(
                "local_markdown_not_found",
                "找不到要删除的本地 Markdown 页面",
            )
        })?;
    let trash_dir = root.join(".mnote").join("trash");
    fs::create_dir_all(&trash_dir).map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!("无法创建本地回收站 {}: {error}", trash_dir.display()),
        )
    })?;
    let file_name = markdown_file
        .path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("page.md");
    let target = next_available_path(&trash_dir, &file_stem_title(file_name), "md");
    fs::rename(&markdown_file.path, &target).map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!(
                "无法移动到本地回收站 {}: {error}",
                markdown_file.path.display()
            ),
        )
    })?;
    let trash_relative_path = normalize_relative_path(root, &target)?;
    metadata.page_ids.remove(&markdown_file.relative_path);
    metadata.trash_entries.insert(
        document_id.to_string(),
        LocalTrashEntry {
            document_id: document_id.to_string(),
            original_relative_path: markdown_file.relative_path,
            trash_relative_path: trash_relative_path.clone(),
            deleted_at_ms: now_ms(),
        },
    );
    write_page_ids_metadata(root, &metadata.page_ids)?;
    write_trash_index_metadata(root, &metadata.trash_entries)?;
    Ok(json!({
        "ok": true,
        "id": document_id,
        "documentId": document_id,
        "trashPath": trash_relative_path,
        "action": "delete",
        "sourceKind": "local_folder",
    }))
}

fn restore_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
    let mut metadata = load_local_folder_metadata(root)?;
    let trash_entry = metadata
        .trash_entries
        .get(document_id)
        .cloned()
        .ok_or_else(|| {
            WebError::bad_request_code(
                "local_trash_entry_not_found",
                "找不到要恢复的本地回收站记录",
            )
        })?;
    let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?;
    if !trash_path.is_file() {
        return Err(WebError::bad_request_code(
            "local_trash_entry_not_found",
            "本地回收站文件不存在，无法恢复",
        ));
    }
    let original_path = resolve_metadata_relative_path(root, &trash_entry.original_relative_path)?;
    let parent = original_path.parent().ok_or_else(|| {
        WebError::bad_request_code("local_tree_command_failed", "无法解析恢复目标目录")
    })?;
    fs::create_dir_all(parent).map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!("无法创建恢复目标目录 {}: {error}", parent.display()),
        )
    })?;
    let stem = original_path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .map(|stem| sanitize_file_stem(stem, "页面"))
        .unwrap_or_else(|| "页面".to_string());
    let target = if original_path.exists() {
        next_available_path(parent, &stem, "md")
    } else {
        original_path
    };
    fs::rename(&trash_path, &target).map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!("无法从本地回收站恢复 {}: {error}", trash_path.display()),
        )
    })?;
    let restored_relative_path = normalize_relative_path(root, &target)?;
    metadata
        .page_ids
        .insert(restored_relative_path.clone(), document_id.to_string());
    metadata.trash_entries.remove(document_id);
    write_page_ids_metadata(root, &metadata.page_ids)?;
    write_trash_index_metadata(root, &metadata.trash_entries)?;
    Ok(json!({
        "ok": true,
        "id": document_id,
        "documentId": document_id,
        "relativePath": restored_relative_path,
        "action": "restore",
        "sourceKind": "local_folder",
    }))
}

fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
    let mut metadata = load_local_folder_metadata(root)?;
    if let Some(markdown_file) = find_markdown_by_page_id(root, &metadata, document_id)? {
        fs::remove_file(&markdown_file.path).map_err(|error| {
            WebError::bad_request_code(
                "local_tree_command_failed",
                format!(
                    "无法永久删除本地 Markdown 文件 {}: {error}",
                    markdown_file.path.display()
                ),
            )
        })?;
        metadata.page_ids.remove(&markdown_file.relative_path);
        write_page_ids_metadata(root, &metadata.page_ids)?;
        return Ok(json!({
            "ok": true,
            "id": document_id,
            "documentId": document_id,
            "action": "purge",
            "sourceKind": "local_folder",
        }));
    }
    let trash_entry = metadata.trash_entries.remove(document_id).ok_or_else(|| {
        WebError::bad_request_code(
            "local_markdown_not_found",
            "找不到要永久删除的本地 Markdown 页面",
        )
    })?;
    let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?;
    if trash_path.exists() {
        fs::remove_file(&trash_path).map_err(|error| {
            WebError::bad_request_code(
                "local_tree_command_failed",
                format!(
                    "无法永久删除本地回收站文件 {}: {error}",
                    trash_path.display()
                ),
            )
        })?;
    }
    write_trash_index_metadata(root, &metadata.trash_entries)?;
    Ok(json!({
        "ok": true,
        "id": document_id,
        "documentId": document_id,
        "action": "purge",
        "sourceKind": "local_folder",
    }))
}

fn resolve_local_parent_directory(
    root: &Path,
    metadata: &LocalFolderMetadata,
    parent_id: Option<&str>,
) -> Result<PathBuf, WebError> {
    let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) else {
        return Ok(root.to_path_buf());
    };
    if let Some(encoded) = parent_id.strip_prefix("local-dir:") {
        let relative_path = decode_local_id_segment(encoded)?;
        let directory = root.join(relative_path);
        if directory.starts_with(root) && directory.is_dir() {
            return Ok(directory);
        }
    }
    if let Some(relative_path) = parent_id.strip_prefix("local:node:") {
        let directory = resolve_metadata_relative_path(root, relative_path)?;
        if directory.is_dir() {
            return Ok(directory);
        }
    }
    if let Some(relative_path) = parent_id.strip_prefix("local:folder:") {
        let directory = resolve_metadata_relative_path(root, relative_path)?;
        if directory.is_dir() {
            return Ok(directory);
        }
    }
    if let Some(markdown_file) = find_markdown_by_page_id(root, metadata, parent_id)? {
        if let Some(parent) = markdown_file.path.parent() {
            return Ok(parent.to_path_buf());
        }
    }
    Err(WebError::bad_request_code(
        "local_tree_command_failed",
        "无法解析本地新建目标目录",
    ))
}

fn drop_external_files_into_local_folder(
    root: &Path,
    parent_id: Option<&str>,
    files_json: &str,
) -> Result<Value, WebError> {
    let metadata = load_local_folder_metadata(root)?;
    let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?;
    let files = serde_json::from_str::<Vec<LocalDropFilePayload>>(files_json).map_err(|error| {
        WebError::bad_request_code(
            "local_tree_command_failed",
            format!("外部拖入文件 payload 非法: {error}"),
        )
    })?;
    if files.is_empty() {
        return Err(WebError::bad_request_code(
            "local_tree_command_failed",
            "外部拖入文件不能为空",
        ));
    }
    let mut written = Vec::new();
    for file in files {
        let name = sanitize_file_name(&file.name, "dropped-file");
        let path = next_available_raw_path(&target_directory, &name);
        fs::write(&path, file.text.unwrap_or_default()).map_err(|error| {
            WebError::bad_request_code(
                "local_tree_command_failed",
                format!("无法写入外部拖入文件 {}: {error}", path.display()),
            )
        })?;
        written.push(json!({
            "name": name,
            "relativePath": normalize_relative_path(root, &path)?,
        }));
    }
    Ok(json!({
        "ok": true,
        "action": "dropFiles",
        "files": written,
        "sourceKind": "local_folder",
    }))
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalDropFilePayload {
    name: String,
    text: Option<String>,
}

fn resolve_local_directory_id(root: &Path, entry_id: &str) -> Result<Option<PathBuf>, WebError> {
    let trimmed = entry_id.trim();
    if let Some(relative_path) = trimmed
        .strip_prefix("local:node:")
        .or_else(|| trimmed.strip_prefix("local:folder:"))
    {
        let directory = resolve_metadata_relative_path(root, relative_path)?;
        if directory.is_dir() {
            return Ok(Some(directory));
        }
        if directory.is_file() {
            return Ok(None);
        }
        return Err(WebError::bad_request_code(
            "local_tree_command_failed",
            "本地文件夹不存在",
        ));
    }
    let Some(encoded) = trimmed.strip_prefix("local-dir:") else {
        return Ok(None);
    };
    let relative_path = decode_local_id_segment(encoded)?;
    let directory = resolve_metadata_relative_path(root, &relative_path)?;
    if directory.is_dir() {
        Ok(Some(directory))
    } else {
        Err(WebError::bad_request_code(
            "local_tree_command_failed",
            "本地文件夹不存在",
        ))
    }
}

fn resolve_local_raw_file_id(root: &Path, entry_id: &str) -> Result<Option<PathBuf>, WebError> {
    let trimmed = entry_id.trim();
    let Some(relative_path) = trimmed
        .strip_prefix("local:node:")
        .or_else(|| trimmed.strip_prefix("local:asset:"))
    else {
        return Ok(None);
    };
    let file = resolve_metadata_relative_path(root, relative_path)?;
    if file.is_file() {
        Ok(Some(file))
    } else {
        Err(WebError::bad_request_code(
            "local_tree_command_failed",
            "本地文件不存在",
        ))
    }
}

pub fn local_workspace_id_from_root_uri(root_uri: &str) -> Result<String, WebError> {
    let root_path = parse_file_root_uri(root_uri)?;
    let canonical_root = root_path.canonicalize().map_err(|error| {
        WebError::bad_request_code(
            "local_folder_unavailable",
            format!("无法访问本地文件夹: {error}"),
        )
    })?;
    Ok(local_workspace_id(&canonical_root))
}

fn parse_file_root_uri(root_uri: &str) -> Result<PathBuf, WebError> {
    let trimmed = root_uri.trim();
    if trimmed.is_empty() {
        return Err(WebError::bad_request_code(
            "local_folder_root_required",
            "缺少本地文件夹 rootUri",
        ));
    }
    let Some(path) = trimmed.strip_prefix("file://") else {
        return Err(WebError::bad_request_code(
            "local_folder_root_invalid",
            "本地文件夹 rootUri 必须使用 file://",
        ));
    };
    if path.trim().is_empty() {
        return Err(WebError::bad_request_code(
            "local_folder_root_invalid",
            "本地文件夹 rootUri 不能为空",
        ));
    }
    Ok(PathBuf::from(percent_decode_file_uri_path(path)?))
}

fn percent_decode_file_uri_path(value: &str) -> Result<String, WebError> {
    let bytes = value.as_bytes();
    let mut decoded = Vec::with_capacity(bytes.len());
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] == b'%' {
            if index + 2 >= bytes.len() {
                return Err(WebError::bad_request_code(
                    "local_folder_root_invalid",
                    "file:// rootUri 包含不完整的百分号编码",
                ));
            }
            let hex = &value[index + 1..index + 3];
            let byte = u8::from_str_radix(hex, 16).map_err(|_| {
                WebError::bad_request_code(
                    "local_folder_root_invalid",
                    "file:// rootUri 包含无效的百分号编码",
                )
            })?;
            decoded.push(byte);
            index += 3;
        } else {
            decoded.push(bytes[index]);
            index += 1;
        }
    }
    String::from_utf8(decoded).map_err(|_| {
        WebError::bad_request_code(
            "local_folder_root_invalid",
            "file:// rootUri 不是 UTF-8 路径",
        )
    })
}

fn load_local_folder_metadata(root: &Path) -> Result<LocalFolderMetadata, WebError> {
    Ok(LocalFolderMetadata {
        page_ids: load_metadata_string_map(&root.join(".mnote").join("page-ids.json"))?,
        page_options: load_metadata_value_map(&root.join(".mnote").join("page-options.json"))?,
        trash_entries: load_trash_index_map(&root.join(".mnote").join("trash-index.json"))?,
    })
}

fn load_metadata_string_map(path: &Path) -> Result<BTreeMap<String, String>, WebError> {
    if !path.exists() {
        return Ok(BTreeMap::new());
    }
    let value = read_metadata_json(path)?;
    let source = value
        .get("pages")
        .or_else(|| value.get("pageIds"))
        .unwrap_or(&value);
    let Some(map) = source.as_object() else {
        return Err(metadata_invalid(path, "元数据必须是对象"));
    };
    let mut result = BTreeMap::new();
    for (key, value) in map {
        let Some(page_id) = value
            .as_str()
            .map(str::trim)
            .filter(|item| !item.is_empty())
        else {
            return Err(metadata_invalid(path, "page id 必须是非空字符串"));
        };
        result.insert(key.clone(), page_id.to_string());
    }
    Ok(result)
}

fn load_metadata_value_map(path: &Path) -> Result<BTreeMap<String, Value>, WebError> {
    if !path.exists() {
        return Ok(BTreeMap::new());
    }
    let value = read_metadata_json(path)?;
    let source = value
        .get("pages")
        .or_else(|| value.get("options"))
        .unwrap_or(&value);
    let Some(map) = source.as_object() else {
        return Err(metadata_invalid(path, "页面选项元数据必须是对象"));
    };
    Ok(map
        .iter()
        .map(|(key, value)| (key.clone(), value.clone()))
        .collect())
}

fn read_metadata_json(path: &Path) -> Result<Value, WebError> {
    let raw = fs::read_to_string(path).map_err(|error| {
        WebError::bad_request_code(
            "local_metadata_read_failed",
            format!("无法读取本地元数据 {}: {error}", path.display()),
        )
    })?;
    serde_json::from_str(&raw)
        .map_err(|error| metadata_invalid(path, format!("元数据 JSON 损坏: {error}")))
}

fn load_trash_index_map(path: &Path) -> Result<BTreeMap<String, LocalTrashEntry>, WebError> {
    if !path.exists() {
        return Ok(BTreeMap::new());
    }
    let value = read_metadata_json(path)?;
    let source = value.get("entries").unwrap_or(&value);
    let entries: BTreeMap<String, LocalTrashEntry> = serde_json::from_value(source.clone())
        .map_err(|error| metadata_invalid(path, format!("回收站索引损坏: {error}")))?;
    Ok(entries)
}

fn metadata_invalid(path: &Path, message: impl Into<String>) -> WebError {
    WebError::bad_request_code(
        "local_metadata_invalid",
        format!("{}；请修复或移走 {} 后重试", message.into(), path.display()),
    )
}

#[allow(dead_code)]
pub fn initialize_local_page_id(root_uri: &str, relative_path: &str) -> Result<String, WebError> {
    let root_path = parse_file_root_uri(root_uri)?;
    let canonical_root = root_path.canonicalize().map_err(|error| {
        WebError::bad_request_code(
            "local_folder_unavailable",
            format!("无法访问本地文件夹: {error}"),
        )
    })?;
    let target = canonical_root
        .join(relative_path)
        .canonicalize()
        .map_err(|error| {
            WebError::bad_request_code(
                "local_page_id_target_invalid",
                format!("无法解析本地 Markdown 文件: {error}"),
            )
        })?;
    if !target.starts_with(&canonical_root) || !target.is_file() || !is_markdown_file(relative_path)
    {
        return Err(WebError::bad_request_code(
            "local_page_id_target_invalid",
            "只能为 root 内的 Markdown 文件初始化 page id",
        ));
    }
    let mut metadata = load_local_folder_metadata(&canonical_root)?;
    if let Some(existing) = metadata.page_ids.get(relative_path) {
        return Ok(normalize_metadata_page_id(existing));
    }
    let page_id = format!(
        "local-mdid:{}",
        encode_local_id_segment(&format!(
            "{}:{relative_path}",
            local_workspace_id(&canonical_root)
        ))
    );
    metadata
        .page_ids
        .insert(relative_path.to_string(), page_id.clone());
    write_page_ids_metadata(&canonical_root, &metadata.page_ids)?;
    Ok(page_id)
}

#[allow(dead_code)]
fn write_page_ids_metadata(
    root: &Path,
    page_ids: &BTreeMap<String, String>,
) -> Result<(), WebError> {
    let mnote_dir = root.join(".mnote");
    fs::create_dir_all(&mnote_dir).map_err(|error| {
        WebError::bad_request_code(
            "local_metadata_write_failed",
            format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()),
        )
    })?;
    let path = mnote_dir.join("page-ids.json");
    let value = json!({
        "version": 1,
        "pages": page_ids,
    });
    write_json_atomic(&path, &value)
}

fn write_trash_index_metadata(
    root: &Path,
    entries: &BTreeMap<String, LocalTrashEntry>,
) -> Result<(), WebError> {
    let mnote_dir = root.join(".mnote");
    fs::create_dir_all(&mnote_dir).map_err(|error| {
        WebError::bad_request_code(
            "local_metadata_write_failed",
            format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()),
        )
    })?;
    let path = mnote_dir.join("trash-index.json");
    let value = json!({
        "version": 1,
        "entries": entries,
    });
    write_json_atomic(&path, &value)
}

fn write_page_options_metadata(
    root: &Path,
    page_options: &BTreeMap<String, Value>,
) -> Result<(), WebError> {
    let mnote_dir = root.join(".mnote");
    fs::create_dir_all(&mnote_dir).map_err(|error| {
        WebError::bad_request_code(
            "local_metadata_write_failed",
            format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()),
        )
    })?;
    let path = mnote_dir.join("page-options.json");
    let value = json!({
        "version": 1,
        "pages": page_options,
    });
    write_json_atomic(&path, &value)
}

#[allow(dead_code)]
fn write_json_atomic(path: &Path, value: &Value) -> Result<(), WebError> {
    let tmp_path = path.with_extension("json.tmp");
    let bytes = serde_json::to_vec_pretty(value)
        .map_err(|error| WebError::internal(format!("本地元数据序列化失败: {error}")))?;
    fs::write(&tmp_path, bytes).map_err(|error| {
        WebError::bad_request_code(
            "local_metadata_write_failed",
            format!("无法写入临时元数据 {}: {error}", tmp_path.display()),
        )
    })?;
    fs::rename(&tmp_path, path).map_err(|error| {
        let _ = fs::remove_file(&tmp_path);
        WebError::bad_request_code(
            "local_metadata_write_failed",
            format!("无法替换本地元数据 {}: {error}", path.display()),
        )
    })
}

fn scan_directory(
    root: &Path,
    directory: &Path,
    parent_node_id: Option<String>,
    depth: u32,
    root_source_uri: &str,
    metadata: &LocalFolderMetadata,
    rows: &mut Vec<LocalFolderRow>,
) -> Result<(), WebError> {
    let entries = read_sorted_entries(directory, root)?;
    let entry_count = entries.len();
    for (position, entry) in entries.into_iter().enumerate() {
        let node_id = local_node_id(if entry.relative_path.is_empty() {
            "."
        } else {
            &entry.relative_path
        });
        let child_count = if entry.is_dir && !entry.is_symlink {
            count_visible_children(&entry.path, root)?
        } else {
            0
        };
        let row_kind = if entry.is_dir {
            "folder".to_string()
        } else if is_markdown_file(&entry.file_name) {
            "markdown".to_string()
        } else {
            "asset".to_string()
        };
        let icon_hint = icon_hint_for_entry(&entry);
        rows.push(LocalFolderRow {
            node_id: node_id.clone(),
            row_id: format!("local:{row_kind}:{}", entry.relative_path),
            parent_node_id: parent_node_id.clone(),
            title: entry.file_name.clone(),
            depth,
            position: position as u32,
            row_kind,
            icon_hint,
            relative_path: entry.relative_path.clone(),
            source_uri: file_uri_for_path(&entry.path),
            child_count,
            expandable: entry.is_dir && child_count > 0,
            expanded_by_default: depth < 1 && entry.is_dir && entry_count <= 80,
            document_id: if is_markdown_file(&entry.file_name) {
                Some(local_markdown_page_id(&entry.relative_path, None, metadata))
            } else {
                None
            },
            capabilities: local_entry_capabilities(&entry),
        });
        if entry.is_dir && !entry.is_symlink {
            scan_directory(
                root,
                &entry.path,
                Some(node_id),
                depth + 1,
                root_source_uri,
                metadata,
                rows,
            )?;
        }
    }

    if rows.is_empty() && directory == root {
        rows.push(LocalFolderRow {
            node_id: local_node_id("."),
            row_id: "local:folder:.".to_string(),
            parent_node_id: None,
            title: root
                .file_name()
                .and_then(|name| name.to_str())
                .unwrap_or("local folder")
                .to_string(),
            depth: 0,
            position: 0,
            row_kind: "folder".to_string(),
            icon_hint: "folder".to_string(),
            relative_path: ".".to_string(),
            source_uri: root_source_uri.to_string(),
            child_count: 0,
            expandable: false,
            expanded_by_default: false,
            document_id: None,
            capabilities: local_path_capabilities(root),
        });
    }

    Ok(())
}

fn read_sorted_entries(directory: &Path, root: &Path) -> Result<Vec<LocalFolderEntry>, WebError> {
    let read_dir = fs::read_dir(directory).map_err(|error| {
        WebError::bad_request_code(
            "local_folder_scan_failed",
            format!("无法读取本地目录 {}: {error}", directory.display()),
        )
    })?;
    let mut entries = Vec::new();
    for entry in read_dir {
        let entry = entry.map_err(|error| {
            WebError::bad_request_code(
                "local_folder_scan_failed",
                format!("读取目录项失败: {error}"),
            )
        })?;
        let path = entry.path();
        let metadata = fs::symlink_metadata(&path).map_err(|error| {
            WebError::bad_request_code(
                "local_folder_scan_failed",
                format!("无法读取目录项元数据 {}: {error}", path.display()),
            )
        })?;
        let file_name = entry.file_name().to_string_lossy().to_string();
        let relative_path = normalize_relative_path(root, &path)?;
        if should_ignore_entry(&relative_path, &file_name) {
            continue;
        }
        let is_symlink = metadata.file_type().is_symlink();
        let is_dir = if is_symlink { false } else { metadata.is_dir() };
        let is_readonly = metadata.permissions().readonly();
        entries.push(LocalFolderEntry {
            path,
            relative_path,
            file_name,
            is_dir,
            is_symlink,
            is_readonly,
        });
    }
    entries.sort_by(compare_local_entries);
    Ok(entries)
}

fn count_visible_children(directory: &Path, root: &Path) -> Result<u32, WebError> {
    Ok(read_sorted_entries(directory, root)?.len() as u32)
}

fn local_entry_capabilities(entry: &LocalFolderEntry) -> Vec<String> {
    let mut capabilities = Vec::new();
    if entry.is_readonly {
        capabilities.push("readonly".to_string());
    }
    if entry.is_symlink {
        capabilities.push("symlink".to_string());
    }
    capabilities
}

fn local_path_capabilities(path: &Path) -> Vec<String> {
    match fs::symlink_metadata(path) {
        Ok(metadata) if metadata.permissions().readonly() => vec!["readonly".to_string()],
        _ => Vec::new(),
    }
}

fn normalize_relative_path(root: &Path, path: &Path) -> Result<String, WebError> {
    if !path.starts_with(root) {
        return Err(WebError::bad_request_code(
            "local_folder_root_escape",
            "本地文件夹扫描不能越过 root",
        ));
    }
    let relative = path.strip_prefix(root).map_err(|_| {
        WebError::bad_request_code("local_folder_root_escape", "本地路径不在 root 内")
    })?;
    Ok(relative
        .components()
        .map(|component| component.as_os_str().to_string_lossy().to_string())
        .collect::<Vec<_>>()
        .join("/"))
}

fn resolve_metadata_relative_path(root: &Path, relative_path: &str) -> Result<PathBuf, WebError> {
    let relative = Path::new(relative_path);
    if relative.is_absolute()
        || relative
            .components()
            .any(|component| matches!(component, std::path::Component::ParentDir))
    {
        return Err(WebError::bad_request_code(
            "local_folder_root_escape",
            "本地元数据路径不能越过 root",
        ));
    }
    let target = root.join(relative);
    if !target.starts_with(root) {
        return Err(WebError::bad_request_code(
            "local_folder_root_escape",
            "本地元数据路径不在 root 内",
        ));
    }
    Ok(target)
}

fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool {
    if matches!(file_name, ".git" | "node_modules" | ".mnote") {
        return true;
    }
    relative_path == ".mnote/trash" || relative_path.starts_with(".mnote/trash/")
}

fn compare_local_entries(a: &LocalFolderEntry, b: &LocalFolderEntry) -> Ordering {
    match (a.is_dir, b.is_dir) {
        (true, false) => Ordering::Less,
        (false, true) => Ordering::Greater,
        _ => a
            .file_name
            .to_ascii_lowercase()
            .cmp(&b.file_name.to_ascii_lowercase())
            .then_with(|| a.file_name.cmp(&b.file_name)),
    }
}

fn scan_markdown_page_tree(
    root: &Path,
    directory: &Path,
    parent_node_id: Option<String>,
    depth: u32,
    metadata: &LocalFolderMetadata,
    rows: &mut Vec<LocalFolderRow>,
) -> Result<bool, WebError> {
    let entries = read_sorted_entries(directory, root)?;
    let mut directory_rows = Vec::<LocalFolderRow>::new();
    let mut contains_markdown = false;
    for (position, entry) in entries.into_iter().enumerate() {
        if entry.is_dir && !entry.is_symlink {
            let node_id = local_directory_group_id(&entry.relative_path);
            let mut child_rows = Vec::new();
            let child_contains_markdown = scan_markdown_page_tree(
                root,
                &entry.path,
                Some(node_id.clone()),
                depth + 1,
                metadata,
                &mut child_rows,
            )?;
            if child_contains_markdown {
                directory_rows.push(LocalFolderRow {
                    node_id,
                    row_id: format!("local:page-group:{}", entry.relative_path),
                    parent_node_id: parent_node_id.clone(),
                    title: entry.file_name.clone(),
                    depth,
                    position: position as u32,
                    row_kind: "document".to_string(),
                    icon_hint: "folder".to_string(),
                    relative_path: entry.relative_path.clone(),
                    source_uri: file_uri_for_path(&entry.path),
                    child_count: 0,
                    expandable: true,
                    expanded_by_default: depth < 2,
                    document_id: None,
                    capabilities: local_entry_capabilities(&entry),
                });
                directory_rows.extend(child_rows);
                contains_markdown = true;
            }
            continue;
        }
        if is_markdown_file(&entry.file_name) {
            let markdown = fs::read_to_string(&entry.path).unwrap_or_default();
            let parsed = parse_markdown_page(&markdown, &entry.file_name);
            let page_id =
                local_markdown_page_id(&entry.relative_path, parsed.mnote_id.as_deref(), metadata);
            directory_rows.push(LocalFolderRow {
                node_id: page_id.clone(),
                row_id: format!("local:page:{}", entry.relative_path),
                parent_node_id: parent_node_id.clone(),
                title: parsed.title,
                depth,
                position: position as u32,
                row_kind: "document".to_string(),
                icon_hint: "markdown".to_string(),
                relative_path: entry.relative_path.clone(),
                source_uri: file_uri_for_path(&entry.path),
                child_count: 0,
                expandable: false,
                expanded_by_default: false,
                document_id: Some(page_id),
                capabilities: local_entry_capabilities(&entry),
            });
            contains_markdown = true;
        }
    }
    rows.extend(directory_rows);
    Ok(contains_markdown)
}

fn find_markdown_by_page_id(
    root: &Path,
    metadata: &LocalFolderMetadata,
    document_id: &str,
) -> Result<Option<LocalFolderEntry>, WebError> {
    fn walk(
        root: &Path,
        directory: &Path,
        metadata: &LocalFolderMetadata,
        document_id: &str,
    ) -> Result<Option<LocalFolderEntry>, WebError> {
        for entry in read_sorted_entries(directory, root)? {
            if entry.is_dir && !entry.is_symlink {
                if let Some(found) = walk(root, &entry.path, metadata, document_id)? {
                    return Ok(Some(found));
                }
                continue;
            }
            if !is_markdown_file(&entry.file_name) {
                continue;
            }
            let markdown = fs::read_to_string(&entry.path).unwrap_or_default();
            let parsed = parse_markdown_page(&markdown, &entry.file_name);
            let page_id =
                local_markdown_page_id(&entry.relative_path, parsed.mnote_id.as_deref(), metadata);
            if page_id == document_id {
                return Ok(Some(entry));
            }
        }
        Ok(None)
    }

    walk(root, root, metadata, document_id)
}

fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value {
    let mut item = json!({
        "rowId": row.row_id,
        "rowKind": row.row_kind,
        "nodeId": row.node_id,
        "parentNodeId": row.parent_node_id,
        "title": row.title,
        "depth": row.depth,
        "position": row.position,
        "childCount": row.child_count,
        "expandable": row.expandable,
        "expandedByDefault": row.expanded_by_default,
        "capabilities": row.capabilities.clone(),
        "iconHint": row.icon_hint,
        "resourceMeta": {
            "resourceKind": row.row_kind,
            "iconHint": row.icon_hint,
            "extra": {
                "source": {
                    "sourceKind": "local_folder",
                    "sourceUri": row.source_uri,
                    "relativePath": row.relative_path,
                    "storageIdentity": {
                        "kind": "file_path",
                        "path": row.relative_path,
                    },
                    "operationProfile": "local_readonly",
                }
            }
        }
    });
    if let Some(document_id) = row.document_id.as_ref() {
        item["resourceMeta"]["documentId"] = Value::String(document_id.clone());
    }
    item
}

fn local_node_id(relative_path: &str) -> String {
    format!("local:node:{relative_path}")
}

fn local_directory_group_id(relative_path: &str) -> String {
    format!("local-dir:{}", encode_local_id_segment(relative_path))
}

fn local_markdown_page_id(
    relative_path: &str,
    mnote_id: Option<&str>,
    metadata: &LocalFolderMetadata,
) -> String {
    if let Some(mnote_id) = mnote_id.map(str::trim).filter(|value| !value.is_empty()) {
        if mnote_id.starts_with("local-mdid:") || mnote_id.starts_with("local-md:") {
            return mnote_id.to_string();
        }
        return format!("local-mdid:{}", encode_local_id_segment(mnote_id));
    }
    if let Some(page_id) = metadata
        .page_ids
        .get(relative_path)
        .map(String::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        return normalize_metadata_page_id(page_id);
    }
    format!("local-md:{}", encode_local_id_segment(relative_path))
}

fn normalize_metadata_page_id(page_id: &str) -> String {
    let trimmed = page_id.trim();
    if trimmed.starts_with("local-md:") || trimmed.starts_with("local-mdid:") {
        trimmed.to_string()
    } else {
        format!("local-mdid:{}", encode_local_id_segment(trimmed))
    }
}

fn page_options_from_metadata(value: &Value) -> PageOptions {
    let mut options = PageOptions::default();
    if let Some(wide_layout) = value
        .get("wideLayout")
        .or_else(|| value.get("wide_layout"))
        .and_then(Value::as_bool)
    {
        options.wide_layout = wide_layout;
    }
    if let Some(small_text) = value
        .get("smallText")
        .or_else(|| value.get("small_text"))
        .and_then(Value::as_bool)
    {
        options.small_text = small_text;
    }
    if let Some(layout_density) = value
        .get("layoutDensity")
        .or_else(|| value.get("layout_density"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|item| !item.is_empty())
    {
        options.layout_density = layout_density.to_string();
    }
    if let Some(show_toc) = value
        .get("showToc")
        .or_else(|| value.get("show_toc"))
        .and_then(Value::as_bool)
    {
        options.show_toc = show_toc;
    }
    if let Some(show_structure) = value
        .get("showStructure")
        .or_else(|| value.get("show_structure"))
        .and_then(Value::as_bool)
    {
        options.show_structure = show_structure;
    }
    if let Some(show_heading_numbers) = value
        .get("showHeadingNumbers")
        .or_else(|| value.get("show_heading_numbers"))
        .and_then(Value::as_bool)
    {
        options.show_heading_numbers = show_heading_numbers;
    }
    if let Some(protect_editing) = value
        .get("protectEditing")
        .or_else(|| value.get("protect_editing"))
        .and_then(Value::as_bool)
    {
        options.protect_editing = protect_editing;
    }
    options
}

fn local_workspace_id(root: &Path) -> String {
    format!(
        "local:{}",
        root.to_string_lossy()
            .chars()
            .map(|character| match character {
                '/' | '\\' | ':' | ' ' => '_',
                value if value.is_ascii_alphanumeric() || matches!(value, '_' | '-' | '.') => value,
                _ => '_',
            })
            .collect::<String>()
    )
}

fn local_folder_watch_revision_for_root(
    root: &Path,
    root_source_uri: &str,
) -> Result<LocalFolderWatchRevision, WebError> {
    let mut hasher = DefaultHasher::new();
    let mut entry_count = 0usize;
    let mut latest_modified_ms = 0u128;

    fn visit(
        root: &Path,
        directory: &Path,
        hasher: &mut DefaultHasher,
        entry_count: &mut usize,
        latest_modified_ms: &mut u128,
    ) -> Result<(), WebError> {
        for entry in read_sorted_entries(directory, root)? {
            entry.relative_path.hash(hasher);
            entry.file_name.hash(hasher);
            entry.is_dir.hash(hasher);
            entry.is_symlink.hash(hasher);
            entry.is_readonly.hash(hasher);
            if let Ok(meta) = fs::symlink_metadata(&entry.path) {
                meta.len().hash(hasher);
                if let Ok(modified) = meta.modified() {
                    if let Ok(delta) = modified.duration_since(UNIX_EPOCH) {
                        let modified_ms = delta.as_millis();
                        modified_ms.hash(hasher);
                        if modified_ms > *latest_modified_ms {
                            *latest_modified_ms = modified_ms;
                        }
                    }
                }
            }
            *entry_count += 1;
            if entry.is_dir && !entry.is_symlink {
                visit(root, &entry.path, hasher, entry_count, latest_modified_ms)?;
            }
        }
        Ok(())
    }

    visit(
        root,
        root,
        &mut hasher,
        &mut entry_count,
        &mut latest_modified_ms,
    )?;
    Ok(LocalFolderWatchRevision {
        root_uri: root_source_uri.to_string(),
        revision: format!("{:016x}", hasher.finish()),
        entry_count,
        latest_modified_ms,
    })
}

fn file_uri_for_path(path: &Path) -> String {
    format!("file://{}", path.display())
}

fn is_markdown_file(file_name: &str) -> bool {
    file_name.to_ascii_lowercase().ends_with(".md")
}

fn icon_hint_for_entry(entry: &LocalFolderEntry) -> String {
    if entry.is_dir {
        return "folder".to_string();
    }
    if entry.is_symlink {
        return "symlink".to_string();
    }
    let lower = entry.file_name.to_ascii_lowercase();
    if lower.ends_with(".md") || lower.ends_with(".markdown") {
        "markdown"
    } else if matches!(
        extension(&lower).as_deref(),
        Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp")
    ) {
        "image"
    } else if matches!(
        extension(&lower).as_deref(),
        Some("mp4" | "webm" | "mov" | "mkv")
    ) {
        "video"
    } else if matches!(
        extension(&lower).as_deref(),
        Some("mp3" | "wav" | "flac" | "ogg")
    ) {
        "audio"
    } else if lower.ends_with(".pdf") {
        "pdf"
    } else if matches!(extension(&lower).as_deref(), Some("epub" | "mobi")) {
        "book"
    } else if matches!(
        extension(&lower).as_deref(),
        Some("csv" | "tsv" | "xlsx" | "xls")
    ) {
        "table"
    } else {
        "unknown"
    }
    .to_string()
}

fn extension(file_name: &str) -> Option<String> {
    Path::new(file_name)
        .extension()
        .and_then(|extension| extension.to_str())
        .map(ToOwned::to_owned)
}

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

pub(crate) fn decode_local_id_segment(value: &str) -> Result<String, WebError> {
    let bytes = value.as_bytes();
    let mut decoded = Vec::with_capacity(bytes.len());
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] == b'~' {
            if index + 2 >= bytes.len() {
                return Err(WebError::bad_request_code(
                    "local_id_invalid",
                    "本地 page id 编码不完整",
                ));
            }
            let hex = &value[index + 1..index + 3];
            let byte = u8::from_str_radix(hex, 16).map_err(|_| {
                WebError::bad_request_code("local_id_invalid", "本地 page id 编码非法")
            })?;
            decoded.push(byte);
            index += 3;
        } else {
            decoded.push(bytes[index]);
            index += 1;
        }
    }
    String::from_utf8(decoded)
        .map_err(|_| WebError::bad_request_code("local_id_invalid", "本地 page id 不是 UTF-8"))
}

fn rewrite_page_ids_prefix(
    page_ids: &mut BTreeMap<String, String>,
    old_relative_path: &str,
    new_relative_path: &str,
) {
    let old_prefix = format!("{old_relative_path}/");
    let replacements = page_ids
        .iter()
        .filter_map(|(relative_path, page_id)| {
            if relative_path == old_relative_path {
                Some((
                    relative_path.clone(),
                    new_relative_path.to_string(),
                    page_id.clone(),
                ))
            } else if let Some(rest) = relative_path.strip_prefix(&old_prefix) {
                Some((
                    relative_path.clone(),
                    format!("{new_relative_path}/{rest}"),
                    page_id.clone(),
                ))
            } else {
                None
            }
        })
        .collect::<Vec<_>>();
    for (old_key, new_key, page_id) in replacements {
        page_ids.remove(&old_key);
        page_ids.insert(new_key, page_id);
    }
}

fn sanitize_file_stem(value: &str, fallback: &str) -> String {
    let sanitized = value
        .trim()
        .chars()
        .map(|character| {
            if matches!(
                character,
                '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'
            ) {
                '_'
            } else {
                character
            }
        })
        .collect::<String>();
    let trimmed = sanitized.trim().trim_matches('.').trim();
    if trimmed.is_empty() {
        fallback.to_string()
    } else {
        trimmed.to_string()
    }
}

fn sanitize_file_name(value: &str, fallback: &str) -> String {
    let sanitized = value
        .trim()
        .chars()
        .map(|character| {
            if matches!(
                character,
                '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'
            ) {
                '_'
            } else {
                character
            }
        })
        .collect::<String>();
    let trimmed = sanitized.trim().trim_matches('.').trim();
    if trimmed.is_empty() {
        fallback.to_string()
    } else {
        trimmed.to_string()
    }
}

fn next_available_path(directory: &Path, stem: &str, extension: &str) -> PathBuf {
    let first = directory.join(format!("{stem}.{extension}"));
    if !first.exists() {
        return first;
    }
    for index in 2..10_000 {
        let candidate = directory.join(format!("{stem} {index}.{extension}"));
        if !candidate.exists() {
            return candidate;
        }
    }
    directory.join(format!("{stem}-{}.{}", std::process::id(), extension))
}

fn next_available_raw_path(directory: &Path, file_name: &str) -> PathBuf {
    let first = directory.join(file_name);
    if !first.exists() {
        return first;
    }
    let path = Path::new(file_name);
    let stem = path
        .file_stem()
        .and_then(|value| value.to_str())
        .unwrap_or(file_name);
    let extension = path.extension().and_then(|value| value.to_str());
    for index in 2..10_000 {
        let candidate_name = if let Some(extension) = extension {
            format!("{stem} {index}.{extension}")
        } else {
            format!("{stem} {index}")
        };
        let candidate = directory.join(candidate_name);
        if !candidate.exists() {
            return candidate;
        }
    }
    directory.join(format!("{stem}-{}", std::process::id()))
}

fn next_available_directory_path(directory: &Path, stem: &str) -> PathBuf {
    let first = directory.join(stem);
    if !first.exists() {
        return first;
    }
    for index in 2..10_000 {
        let candidate = directory.join(format!("{stem} {index}"));
        if !candidate.exists() {
            return candidate;
        }
    }
    directory.join(format!("{stem}-{}", std::process::id()))
}

fn markdown_with_frontmatter_fields(markdown: &str, fields: &[(&str, &str)]) -> String {
    let (frontmatter, body) = split_frontmatter(markdown);
    let mut lines = frontmatter
        .unwrap_or_default()
        .lines()
        .map(ToOwned::to_owned)
        .collect::<Vec<_>>();
    for (key, value) in fields {
        let replacement = format!("{key}: {}", value.trim());
        if let Some(existing) = lines.iter_mut().find(|line| {
            line.split_once(':')
                .map(|(candidate, _)| candidate.trim() == *key)
                .unwrap_or(false)
        }) {
            *existing = replacement;
        } else {
            lines.push(replacement);
        }
    }
    format!("---\n{}\n---\n{}", lines.join("\n"), body)
}

fn editor_blocks_to_markdown(content: &Value) -> String {
    let blocks = if let Some(array) = content.as_array() {
        array.clone()
    } else {
        content
            .get("blocks")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default()
    };
    let mut lines = Vec::new();
    for block in blocks {
        let block_type = block
            .get("type")
            .or_else(|| block.get("blockType"))
            .and_then(Value::as_str)
            .unwrap_or("paragraph");
        let inline_markdown = block_content_value(&block)
            .map(inline_nodes_to_markdown)
            .unwrap_or_default()
            .trim()
            .to_string();
        let text = if inline_markdown.is_empty() {
            extract_block_text(&block).trim().to_string()
        } else {
            inline_markdown
        };
        if text.is_empty() && !matches!(block_type, "divider" | "media" | "table") {
            lines.push(String::new());
            continue;
        }
        match block_type {
            "media" => {
                let props = block.get("props").and_then(Value::as_object);
                let url = props
                    .and_then(|props| {
                        props
                            .get("sourcePath")
                            .or_else(|| props.get("url"))
                            .or_else(|| props.get("src"))
                    })
                    .and_then(Value::as_str)
                    .unwrap_or("")
                    .trim();
                let name = props
                    .and_then(|props| props.get("name").or_else(|| props.get("fileName")))
                    .and_then(Value::as_str)
                    .unwrap_or(text.as_str())
                    .trim();
                if url.is_empty() {
                    lines.push(text);
                } else {
                    lines.push(format!(
                        "[{}]({url})",
                        if name.is_empty() { url } else { name }
                    ));
                }
            }
            "heading" => {
                let level = block
                    .get("props")
                    .and_then(|props| props.get("level"))
                    .and_then(Value::as_u64)
                    .unwrap_or(1)
                    .clamp(1, 6);
                lines.push(format!("{} {text}", "#".repeat(level as usize)));
            }
            "bulletListItem" | "bullet_list_item" | "list_item" => {
                lines.push(format!("- {text}"));
            }
            "numberedListItem" | "numbered_list_item" => {
                lines.push(format!("1. {text}"));
            }
            "todo" | "checkListItem" | "advancedTodo" => {
                let checked = block
                    .get("props")
                    .and_then(|props| props.get("checked"))
                    .and_then(Value::as_bool)
                    .unwrap_or(false);
                let checkbox = if checked { "[x]" } else { "[ ]" };
                lines.push(format!("- {checkbox} {text}"));
            }
            "quote" | "blockquote" => {
                lines.push(format!("> {text}"));
            }
            "codeBlock" | "code_block" | "code" => {
                lines.push(format!("```\n{text}\n```"));
            }
            "divider" => {
                lines.push("---".to_string());
            }
            "table" => {
                let table_markdown = editor_block_table_to_markdown(&block);
                if table_markdown.is_empty() {
                    lines.push(text);
                } else {
                    lines.extend(table_markdown.lines().map(ToOwned::to_owned));
                }
            }
            _ => lines.push(text),
        }
        lines.push(String::new());
    }
    let markdown = lines.join("\n").trim_end().to_string();
    if markdown.is_empty() {
        "\n".to_string()
    } else {
        format!("{markdown}\n")
    }
}

fn editor_block_table_to_markdown(block: &Value) -> String {
    let table = block
        .get("props")
        .and_then(|props| props.get("tiptapTable"))
        .or_else(|| block.get("tiptapTable"));
    let rows = table
        .and_then(|value| value.get("content"))
        .and_then(Value::as_array);
    let Some(rows) = rows else {
        return String::new();
    };
    let mut parsed_rows = Vec::<Vec<(String, bool)>>::new();
    let mut max_columns = 0usize;
    for row in rows {
        let cells = row
            .get("content")
            .and_then(Value::as_array)
            .map(|cells| {
                cells
                    .iter()
                    .map(|cell| {
                        let cell_text = cell
                            .get("content")
                            .map(inline_nodes_to_markdown)
                            .unwrap_or_default()
                            .replace('\n', " ")
                            .replace('|', r"\|")
                            .trim()
                            .to_string();
                        let is_header =
                            cell.get("type").and_then(Value::as_str) == Some("tableHeader");
                        (cell_text, is_header)
                    })
                    .collect::<Vec<_>>()
            })
            .unwrap_or_default();
        max_columns = max_columns.max(cells.len());
        parsed_rows.push(cells);
    }
    if parsed_rows.is_empty() || max_columns == 0 {
        return String::new();
    }
    let alignments = alignments_from_table(block);
    let mut lines = Vec::<String>::new();
    for (index, row) in parsed_rows.iter().enumerate() {
        let mut cells = row.iter().map(|(text, _)| text.clone()).collect::<Vec<_>>();
        while cells.len() < max_columns {
            cells.push(String::new());
        }
        lines.push(format!("| {} |", cells.join(" | ")));
        if index == 0 {
            let align_row = (0..max_columns)
                .map(
                    |column_index| match table_cell_alignment(&alignments, column_index) {
                        "left" => ":---",
                        "center" => ":---:",
                        "right" => "---:",
                        _ => "---",
                    },
                )
                .collect::<Vec<_>>()
                .join(" | ");
            lines.push(format!("| {} |", align_row));
        }
    }
    if lines.len() < 2 {
        String::new()
    } else {
        lines.join("\n")
    }
}

fn alignments_from_table(block: &Value) -> Vec<String> {
    block
        .get("props")
        .and_then(|props| props.get("tiptapTable"))
        .or_else(|| block.get("tiptapTable"))
        .and_then(|value| value.get("content"))
        .and_then(Value::as_array)
        .and_then(|rows| rows.get(0))
        .and_then(|row| row.get("content"))
        .and_then(Value::as_array)
        .map(|cells| {
            cells
                .iter()
                .map(|cell| {
                    cell.get("attrs")
                        .and_then(|attrs| attrs.get("textAlign"))
                        .and_then(Value::as_str)
                        .unwrap_or("")
                        .to_string()
                })
                .collect::<Vec<_>>()
        })
        .unwrap_or_default()
}

fn table_cell_alignment(alignments: &[String], index: usize) -> &str {
    alignments.get(index).map(String::as_str).unwrap_or("")
}

fn extract_block_text(value: &Value) -> String {
    if let Some(text) = value.as_str() {
        return text.to_string();
    }
    if let Some(array) = value.as_array() {
        return array
            .iter()
            .map(extract_block_text)
            .collect::<Vec<_>>()
            .join("");
    }
    if let Some(object) = value.as_object() {
        if let Some(text) = object.get("text").and_then(Value::as_str) {
            return text.to_string();
        }
        if let Some(content) = object.get("content") {
            return extract_block_text(content);
        }
        if let Some(children) = object.get("children") {
            return extract_block_text(children);
        }
    }
    String::new()
}

fn block_content_value(block: &Value) -> Option<&Value> {
    block.get("content").or_else(|| block.get("contentNodes"))
}

fn inline_nodes_to_markdown(value: &Value) -> String {
    if let Some(text) = value.as_str() {
        return escape_markdown_inline_text(text);
    }
    if let Some(array) = value.as_array() {
        return array
            .iter()
            .map(inline_node_to_markdown)
            .collect::<Vec<_>>()
            .join("");
    }
    if let Some(object) = value.as_object() {
        if let Some(text) = object.get("text").and_then(Value::as_str) {
            return markdown_text_with_styles(text, &inline_styles_from_object(object));
        }
        if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) {
            return inline_nodes_to_markdown(content);
        }
    }
    String::new()
}

fn inline_node_to_markdown(node: &Value) -> String {
    if let Some(text) = node.as_str() {
        return escape_markdown_inline_text(text);
    }
    if let Some(object) = node.as_object() {
        let text = object
            .get("text")
            .and_then(Value::as_str)
            .unwrap_or_default();
        if !text.is_empty() {
            return markdown_text_with_styles(text, &inline_styles_from_object(object));
        }
        if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) {
            return inline_nodes_to_markdown(content);
        }
    }
    String::new()
}

fn inline_styles_from_object(object: &Map<String, Value>) -> Value {
    let mut styles = object
        .get("styles")
        .and_then(Value::as_object)
        .cloned()
        .unwrap_or_default();
    for mark in object
        .get("marks")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
    {
        let mark_type = mark.get("type").and_then(Value::as_str).unwrap_or_default();
        match mark_type {
            "bold" | "strong" => {
                styles.insert("bold".to_string(), Value::Bool(true));
            }
            "italic" | "em" => {
                styles.insert("italic".to_string(), Value::Bool(true));
            }
            "underline" => {
                styles.insert("underline".to_string(), Value::Bool(true));
            }
            "strike" | "strikethrough" => {
                styles.insert("strike".to_string(), Value::Bool(true));
            }
            "code" => {
                styles.insert("code".to_string(), Value::Bool(true));
            }
            "link" => {
                if let Some(href) = mark
                    .get("attrs")
                    .and_then(|attrs| attrs.get("href"))
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|href| !href.is_empty())
                {
                    styles.insert("link".to_string(), Value::String(href.to_string()));
                }
            }
            _ => {}
        }
    }
    Value::Object(styles)
}

fn markdown_text_with_styles(text: &str, styles: &Value) -> String {
    let mut value = escape_markdown_inline_text(text);
    let link = styles
        .get("link")
        .or_else(|| styles.get("href"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|href| !href.is_empty())
        .map(ToOwned::to_owned);
    if styles
        .get("code")
        .or_else(|| styles.get("inlineCode"))
        .and_then(Value::as_bool)
        .unwrap_or(false)
    {
        value = format!("`{}`", text.replace('`', r"\`"));
    }
    if styles.get("bold").and_then(Value::as_bool).unwrap_or(false) {
        value = format!("**{value}**");
    }
    if styles
        .get("italic")
        .and_then(Value::as_bool)
        .unwrap_or(false)
    {
        value = format!("*{value}*");
    }
    if styles
        .get("strike")
        .or_else(|| styles.get("strikethrough"))
        .and_then(Value::as_bool)
        .unwrap_or(false)
    {
        value = format!("~~{value}~~");
    }
    if let Some(href) = link {
        value = format!("[{}]({href})", value.replace(']', r"\]"));
    }
    value
}

fn escape_markdown_inline_text(text: &str) -> String {
    text.replace('\\', r"\\")
}

fn now_ms() -> u128 {
    system_time_ms(SystemTime::now())
}

fn system_time_ms(time: SystemTime) -> u128 {
    time.duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis())
        .unwrap_or(0)
}

fn local_markdown_conflict_detection_key(
    document_id: &str,
    markdown_path: &Path,
) -> Result<String, WebError> {
    let content = fs::read(markdown_path).map_err(|error| {
        WebError::bad_request_code(
            "local_markdown_read_failed",
            format!(
                "无法读取本地 Markdown 文件 {}: {error}",
                markdown_path.display()
            ),
        )
    })?;
    let meta = fs::metadata(markdown_path).map_err(|error| {
        WebError::bad_request_code(
            "local_markdown_stat_failed",
            format!(
                "无法读取本地 Markdown 文件状态 {}: {error}",
                markdown_path.display()
            ),
        )
    })?;
    let modified_ms = system_time_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
    let mut hasher = DefaultHasher::new();
    content.hash(&mut hasher);
    let content_hash = hasher.finish();
    Ok(format!(
        "local-md:{document_id}:{modified_ms}:{}:{content_hash:016x}",
        meta.len()
    ))
}

fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Value {
    let outline = content
        .as_array()
        .unwrap_or(&Vec::new())
        .iter()
        .filter(|block| block.get("type").and_then(Value::as_str) == Some("heading"))
        .enumerate()
        .map(|(index, block)| {
            json!({
                "id": format!("outline-{index}"),
                "nodeId": document_id,
                "blockId": block.get("id").and_then(Value::as_str).unwrap_or_default(),
                "title": block
                    .get("content")
                    .and_then(Value::as_array)
                    .and_then(|items| items.first())
                    .and_then(|item| item.get("text"))
                    .and_then(Value::as_str)
                    .unwrap_or(title),
                "depth": block
                    .get("props")
                    .and_then(|props| props.get("level"))
                    .and_then(Value::as_u64)
                    .unwrap_or(1),
            })
        })
        .collect::<Vec<_>>();
    json!({
        "projectionId": format!("page-subtree:{document_id}"),
        "projection": "page_tree",
        "rootNodeId": document_id,
        "rootNode": {
            "id": document_id,
            "title": title,
            "nodeType": "page",
            "depth": 0,
        },
        "subtree": {
            "rootNodeId": document_id,
            "nodes": [{
                "id": document_id,
                "title": title,
                "nodeType": "page",
                "depth": 0,
            }],
        },
        "outline": outline,
        "evidence": [{
            "id": format!("evidence:{document_id}"),
            "nodeId": document_id,
            "kind": "page",
            "snippet": title,
        }],
        "stats": {
            "blockCount": content.as_array().map(|blocks| blocks.len()).unwrap_or(0),
            "headingCount": outline.len(),
            "evidenceCount": 1,
            "maxDepth": 1,
        },
    })
}

#[cfg(test)]
mod tests {
    use super::{
        initialize_local_page_id, load_local_folder_page_tree_snapshot,
        local_folder_watch_revision, resolve_local_markdown_page_aggregate,
        save_local_markdown_page,
    };
    use serde_json::Value;

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

    #[test]
    fn local_frontmatter_mnote_id_survives_rename_and_move() {
        let root = temp_root("mnote-local-frontmatter-id");
        std::fs::write(
            root.join("page.md"),
            "---\nmnote_id: stable-frontmatter-id\ntitle: Stable\n---\n正文\n",
        )
        .expect("write md");
        let root_uri = format!("file://{}", root.display());

        let first = load_local_folder_page_tree_snapshot(&root_uri).expect("first snapshot");
        assert!(first
            .projection
            .to_string()
            .contains("local-mdid:stable-frontmatter-id"));

        std::fs::create_dir_all(root.join("docs")).expect("create docs");
        std::fs::rename(root.join("page.md"), root.join("docs").join("renamed.md"))
            .expect("move md");
        let second = load_local_folder_page_tree_snapshot(&root_uri).expect("second snapshot");
        let second_json = second.projection.to_string();
        assert!(second_json.contains("local-mdid:stable-frontmatter-id"));
        assert!(second_json.contains("renamed.md"));

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_page_ids_metadata_overrides_path_derived_id_and_hides_mnote() {
        let root = temp_root("mnote-local-page-ids");
        std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir");
        std::fs::create_dir_all(root.join("docs")).expect("create docs");
        std::fs::write(root.join("docs").join("page.md"), "# Metadata Page\n").expect("write md");
        std::fs::write(
            root.join(".mnote").join("page-ids.json"),
            r#"{"version":1,"pages":{"docs/page.md":"stable-from-page-ids"}}"#,
        )
        .expect("write page ids");
        let root_uri = format!("file://{}", root.display());

        let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("snapshot");
        let html_json = snapshot.projection.to_string();
        assert!(html_json.contains("local-mdid:stable-from-page-ids"));
        assert!(!html_json.contains("page-ids.json"));

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_page_id_initialization_writes_page_ids_atomically() {
        let root = temp_root("mnote-local-page-id-init");
        std::fs::write(root.join("draft.md"), "# Draft\n").expect("write md");
        let root_uri = format!("file://{}", root.display());

        let page_id = initialize_local_page_id(&root_uri, "draft.md").expect("init page id");
        assert!(page_id.starts_with("local-mdid:"));
        let metadata = std::fs::read_to_string(root.join(".mnote").join("page-ids.json"))
            .expect("read page ids");
        let parsed: Value = serde_json::from_str(&metadata).expect("json");
        assert_eq!(parsed["pages"]["draft.md"], page_id);
        assert!(!root.join(".mnote").join("page-ids.json.tmp").exists());

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_metadata_damage_returns_explainable_error() {
        let root = temp_root("mnote-local-bad-metadata");
        std::fs::create_dir_all(root.join(".mnote")).expect("create metadata");
        std::fs::write(root.join("README.md"), "# Broken Metadata\n").expect("write md");
        std::fs::write(root.join(".mnote").join("page-ids.json"), "{not-json")
            .expect("write bad metadata");
        let root_uri = format!("file://{}", root.display());

        let error = load_local_folder_page_tree_snapshot(&root_uri).expect_err("metadata error");
        assert!(error.message().contains("元数据 JSON 损坏"));
        assert!(error.message().contains("请修复或移走"));

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_page_options_metadata_flows_into_page_aggregate() {
        let root = temp_root("mnote-local-page-options");
        std::fs::create_dir_all(root.join(".mnote")).expect("create metadata");
        std::fs::write(root.join("README.md"), "# Local Options\n").expect("write md");
        std::fs::write(
            root.join(".mnote").join("page-options.json"),
            r#"{"version":1,"pages":{"local-md:README.md":{"wideLayout":true,"showToc":true,"showHeadingNumbers":true}}}"#,
        )
        .expect("write options");
        let root_uri = format!("file://{}", root.display());

        let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md")
            .expect("aggregate");
        assert!(aggregate.layout.page_options.wide_layout);
        assert!(aggregate.layout.page_options.show_toc);
        assert!(aggregate.layout.page_options.show_heading_numbers);

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_markdown_parser_preserves_pipe_table_as_table_block() {
        let blocks = crate::routes::local_markdown_parser::markdown_to_blocks(
            "| 左 | 右 |\n| --- | --- |\n| `A` | **B** |\n",
        );
        let array = blocks.as_array().expect("blocks");
        let table = array
            .iter()
            .find(|block| block["type"].as_str() == Some("table"))
            .expect("pipe table 应保留为 table block");
        assert_eq!(table["props"]["tiptapTable"]["type"], "table");
        assert_eq!(
            table["props"]["tiptapTable"]["content"][0]["content"][0]["type"],
            "tableHeader"
        );
        assert_eq!(
            table["props"]["tiptapTable"]["content"][1]["content"][0]["type"],
            "tableCell"
        );
        assert_eq!(
            table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0]
                ["text"],
            "左"
        );
        assert_eq!(
            table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]
                ["text"],
            "A"
        );
        assert_eq!(
            table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]
                ["marks"][0]["type"],
            "code"
        );
        assert_eq!(
            table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]
                ["text"],
            "B"
        );
        assert_eq!(
            table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]
                ["marks"][0]["type"],
            "bold"
        );
    }

    #[test]
    fn local_markdown_parser_preserves_task_list_and_inline_marks() {
        let blocks = crate::routes::local_markdown_parser::markdown_to_blocks(
            "- [x] `3000` 默认由 **mnote-web** 监听。\n- [ ] *待办* ~~删除~~ [链接](https://example.com)\n",
        );
        let array = blocks.as_array().expect("blocks");
        assert_eq!(array[0]["type"], "todo");
        assert_eq!(array[0]["props"]["checked"], true);
        assert_eq!(array[1]["type"], "todo");
        assert_eq!(array[1]["props"]["checked"], false);

        let first_content = array[0]["content"].as_array().expect("first content");
        assert!(first_content.iter().any(|node| {
            node["text"].as_str() == Some("3000") && node["styles"]["code"].as_bool() == Some(true)
        }));
        assert!(first_content.iter().any(|node| {
            node["text"].as_str() == Some("mnote-web")
                && node["styles"]["bold"].as_bool() == Some(true)
        }));

        let second_content = array[1]["content"].as_array().expect("second content");
        assert!(second_content.iter().any(|node| {
            node["text"].as_str() == Some("待办")
                && node["styles"]["italic"].as_bool() == Some(true)
        }));
        assert!(second_content.iter().any(|node| {
            node["text"].as_str() == Some("删除")
                && node["styles"]["strike"].as_bool() == Some(true)
        }));
        assert!(second_content.iter().any(|node| {
            node["text"].as_str() == Some("链接")
                && node["styles"]["link"].as_str() == Some("https://example.com")
        }));
    }

    #[test]
    fn local_markdown_aggregate_follows_file_permissions_for_editability() {
        let root = temp_root("mnote-local-readonly-permission");
        std::fs::write(root.join("README.md"), "# Editable?\n").expect("write md");
        let root_uri = format!("file://{}", root.display());

        let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md")
            .expect("aggregate");
        assert!(!aggregate.head.permissions.read_only);

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_folder_watch_revision_changes_for_visible_files_only() {
        let root = temp_root("mnote-local-watch-revision");
        std::fs::create_dir_all(root.join(".mnote")).expect("create metadata");
        std::fs::create_dir_all(root.join("docs")).expect("create docs");
        std::fs::write(root.join("docs").join("page.md"), "# Watch\n").expect("write md");
        let root_uri = format!("file://{}", root.display());

        let initial = local_folder_watch_revision(&root_uri).expect("initial revision");
        std::fs::write(root.join(".mnote").join("ignored.txt"), "ignored").expect("write ignored");
        let ignored = local_folder_watch_revision(&root_uri).expect("ignored revision");
        assert_eq!(initial.revision, ignored.revision);
        std::fs::write(root.join("docs").join("page.md"), "# Watch Again\n").expect("update md");
        let updated = local_folder_watch_revision(&root_uri).expect("updated revision");
        assert_ne!(initial.revision, updated.revision);
        assert!(updated.entry_count >= 1);

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_markdown_conflict_detection_key_changes_when_content_changes_with_same_size() {
        let root = temp_root("mnote-local-conflict-key-content");
        let file = root.join("README.md");
        std::fs::write(&file, "aaaa\n").expect("write first content");

        let first = super::local_markdown_conflict_detection_key("local-md:README.md", &file)
            .expect("first conflict key");

        std::fs::write(&file, "bbbb\n").expect("write second content");

        let second = super::local_markdown_conflict_detection_key("local-md:README.md", &file)
            .expect("second conflict key");

        assert_ne!(first, second);

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_markdown_parser_covers_basic_blocks_and_attachment_refs() {
        let blocks = crate::routes::local_markdown_parser::markdown_to_blocks(
            r#"# Title

paragraph

## Section
- bullet
1. numbered
> quote
```rust
fn main() {}
```
---
[Spec](attachments/spec.pdf)
| unsupported | table |
"#,
        );
        let block_types = blocks
            .as_array()
            .expect("blocks")
            .iter()
            .map(|block| block["type"].as_str().unwrap_or_default().to_string())
            .collect::<Vec<_>>();
        assert!(block_types.contains(&"heading".to_string()));
        assert!(block_types.contains(&"paragraph".to_string()));
        assert!(block_types.contains(&"bulletListItem".to_string()));
        assert!(block_types.contains(&"numberedListItem".to_string()));
        assert!(block_types.contains(&"quote".to_string()));
        assert!(block_types.contains(&"codeBlock".to_string()));
        assert!(block_types.contains(&"divider".to_string()));
        assert!(block_types.contains(&"media".to_string()));
        assert!(blocks.to_string().contains("attachments/spec.pdf"));
        assert!(blocks.to_string().contains("unsupported"));
    }

    #[test]
    fn local_markdown_parser_preserves_attachment_media_block() {
        let blocks = crate::routes::local_markdown_parser::markdown_to_blocks(
            "[Spec](attachments/spec.pdf)\n",
        );
        let array = blocks.as_array().expect("blocks");
        let media = array
            .iter()
            .find(|block| block["type"].as_str() == Some("media"))
            .expect("media block");
        assert_eq!(media["props"]["name"], "Spec");
        assert_eq!(media["props"]["sourcePath"], "attachments/spec.pdf");
    }

    #[test]
    fn local_markdown_save_preserves_frontmatter_and_writes_basic_blocks() {
        let root = temp_root("mnote-local-markdown-save-basic-blocks");
        std::fs::write(
            root.join("README.md"),
            "---\ntitle: Preserved\nmnote_id: stable\n---\n# Old\n",
        )
        .expect("write md");
        let root_uri = format!("file://{}", root.display());
        save_local_markdown_page(
            &root_uri,
            "local-mdid:stable",
            None,
            &serde_json::json!([
                {"type":"heading","props":{"level":2},"content":[{"type":"text","text":"Heading"}]},
                {"type":"bulletListItem","content":[{"type":"text","text":"Bullet"}]},
                {"type":"numberedListItem","content":[{"type":"text","text":"Numbered"}]},
                {"type":"quote","content":[{"type":"text","text":"Quoted"}]},
                {"type":"codeBlock","content":[{"type":"text","text":"let x = 1;"}]},
                {"type":"divider"},
                {"type":"media","props":{"name":"Spec","sourcePath":"attachments/spec.pdf"}},
                {"type":"table","props":{"tiptapTable":{"type":"table","content":[
                    {"type":"tableRow","content":[
                        {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"左","styles":{}}]}]},
                        {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"右","styles":{}}]}]}
                    ]},
                    {"type":"tableRow","content":[
                        {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{}}]}]},
                        {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{}}]}]}
                    ]}
                ]}}}
            ]),
        )
        .expect("save");

        let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
        assert!(saved.starts_with("---\ntitle: Preserved\nmnote_id: stable\n---\n"));
        assert!(saved.contains("## Heading"));
        assert!(saved.contains("- Bullet"));
        assert!(saved.contains("1. Numbered"));
        assert!(saved.contains("> Quoted"));
        assert!(saved.contains("```\nlet x = 1;\n```"));
        assert!(saved.contains("[Spec](attachments/spec.pdf)"));
        assert!(saved.contains("| 左 | 右 |"));
        assert!(saved.contains("| --- | --- |"));
        assert!(saved.contains("| A | B |"));

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_markdown_save_writes_task_list_and_inline_marks() {
        let root = temp_root("mnote-local-markdown-save-inline-marks");
        std::fs::write(root.join("README.md"), "---\ntitle: Inline\n---\n# Old\n")
            .expect("write md");
        let root_uri = format!("file://{}", root.display());
        save_local_markdown_page(
            &root_uri,
            "local-md:README.md",
            None,
            &serde_json::json!([
                {
                    "type":"todo",
                    "props":{"checked":true},
                    "content":[
                        {"type":"text","text":"3000","styles":{"code":true}},
                        {"type":"text","text":" 默认由 "},
                        {"type":"text","text":"mnote-web","styles":{"bold":true}},
                        {"type":"text","text":" 监听"}
                    ]
                },
                {
                    "type":"todo",
                    "props":{"checked":false},
                    "content":[
                        {"type":"text","text":"待办","styles":{"italic":true}},
                        {"type":"text","text":" "},
                        {"type":"text","text":"删除","styles":{"strike":true}},
                        {"type":"text","text":" "},
                        {"type":"text","text":"链接","styles":{"link":"https://example.com"}}
                    ]
                }
            ]),
        )
        .expect("save");

        let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
        assert!(saved.contains("- [x] `3000` 默认由 **mnote-web** 监听"));
        assert!(saved.contains("- [ ] *待办* ~~删除~~ [链接](https://example.com)"));

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_markdown_save_rejects_stale_external_file_change() {
        let root = temp_root("mnote-local-markdown-save-stale-conflict");
        std::fs::write(root.join("README.md"), "---\ntitle: Stale\n---\n# Old\n")
            .expect("write md");
        let root_uri = format!("file://{}", root.display());
        let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md")
            .expect("aggregate");
        let stale_key = aggregate
            .body
            .conflict_detection_key
            .as_str()
            .expect("conflict key")
            .to_string();

        std::thread::sleep(std::time::Duration::from_millis(5));
        std::fs::write(
            root.join("README.md"),
            "---\ntitle: Stale\n---\n# External\n",
        )
        .expect("external write");

        let error = save_local_markdown_page(
            &root_uri,
            "local-md:README.md",
            Some(&stale_key),
            &serde_json::json!([
                {"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Editor"}]}
            ]),
        )
        .expect_err("stale save should fail");
        assert!(error.message().contains("本地 Markdown 文件已被外部修改"));

        let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
        assert!(saved.contains("# External"));
        assert!(!saved.contains("# Editor"));

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_markdown_save_writes_table_inline_marks() {
        let root = temp_root("mnote-local-markdown-save-table-inline-marks");
        std::fs::write(root.join("README.md"), "---\ntitle: Table\n---\n# Old\n")
            .expect("write md");
        let root_uri = format!("file://{}", root.display());
        save_local_markdown_page(
            &root_uri,
            "local-md:README.md",
            None,
            &serde_json::json!([
                {"type":"table","props":{"tiptapTable":{"type":"table","content":[
                    {"type":"tableRow","content":[
                        {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"左","styles":{}}]}]},
                        {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"右","styles":{}}]}]}
                    ]},
                    {"type":"tableRow","content":[
                        {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{"code":true}}]}]},
                        {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{"bold":true}}]}]}
                    ]}
                ]}}}
            ]),
        )
        .expect("save");

        let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
        assert!(saved.contains("| `A` | **B** |"));

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_markdown_save_round_trips_tiptap_table_marks() {
        let root = temp_root("mnote-local-markdown-save-table-marks-roundtrip");
        std::fs::write(
            root.join("README.md"),
            "---\ntitle: Table Marks\n---\n# Old\n",
        )
        .expect("write md");
        let root_uri = format!("file://{}", root.display());
        let parsed = crate::routes::local_markdown_parser::markdown_to_blocks(
            "| 左 | 右 |\n| --- | --- |\n| `A` | **B** |\n",
        );
        save_local_markdown_page(&root_uri, "local-md:README.md", None, &parsed).expect("save");

        let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
        assert!(saved.contains("| `A` | **B** |"));

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn local_markdown_save_writes_table_alignment_markers() {
        let root = temp_root("mnote-local-markdown-save-table-alignments");
        std::fs::write(
            root.join("README.md"),
            "---\ntitle: Table Align\n---\n# Old\n",
        )
        .expect("write md");
        let root_uri = format!("file://{}", root.display());
        save_local_markdown_page(
            &root_uri,
            "local-md:README.md",
            None,
            &serde_json::json!([
                {"type":"table","props":{"tiptapTable":{"type":"table","content":[
                    {"type":"tableRow","content":[
                        {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"left"},"content":[{"type":"paragraph","content":[{"type":"text","text":"左","styles":{}}]}]},
                        {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"center"},"content":[{"type":"paragraph","content":[{"type":"text","text":"中","styles":{}}]}]},
                        {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"right"},"content":[{"type":"paragraph","content":[{"type":"text","text":"右","styles":{}}]}]}
                    ]},
                    {"type":"tableRow","content":[
                        {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"left"},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{}}]}]},
                        {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"center"},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{}}]}]},
                        {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"right"},"content":[{"type":"paragraph","content":[{"type":"text","text":"C","styles":{}}]}]}
                    ]}
                ]}}}
            ]),
        )
        .expect("save");

        let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
        assert!(saved.contains("| :--- | :---: | ---: |"));

        let _ = std::fs::remove_dir_all(&root);
    }
}
