use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceShellProjection {
    pub schema: String,
    pub workspace_id: String,
    pub workspace_name: String,
    pub active_page_id: Option<String>,
    pub active_page_title: Option<String>,
    pub degraded: bool,
    pub degraded_reason: Option<String>,
    pub dev_fixture: bool,
    pub starred_items: Vec<WorkspaceShellItem>,
    pub my_page_items: Vec<WorkspaceShellItem>,
    pub bottom_entries: Vec<WorkspaceShellEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceShellItem {
    pub id: String,
    pub title: String,
    pub icon: Option<String>,
    pub shortcut_id: Option<String>,
    pub workspace_id: Option<String>,
    pub source_kind: Option<String>,
    pub root_uri: Option<String>,
    pub kind: Option<String>,
    pub target_id: Option<String>,
    pub relative_path: Option<String>,
    pub document_id: Option<String>,
    pub parent_id: Option<String>,
    pub href: String,
    pub depth: u32,
    pub active: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceShellEntry {
    pub id: String,
    pub label: String,
    pub href: String,
    pub icon: String,
}

pub fn build_workspace_shell_projection(
    dataset: &Value,
    workspace_id: &str,
    active_page_id: Option<&str>,
    default_workspace_name: &str,
) -> WorkspaceShellProjection {
    let active_page_id = active_page_id
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let documents = dataset
        .get("documents")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let workspace_name = resolve_workspace_name(dataset, workspace_id)
        .unwrap_or_else(|| default_workspace_name.trim().to_string())
        .if_empty_else(|| "个人空间".to_string());

    let mut my_page_items = documents
        .iter()
        .filter_map(|document| document_to_item(document, workspace_id, None))
        .collect::<Vec<_>>();
    my_page_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));

    let active_page_id = active_page_id
        .or_else(|| {
            dataset
                .get("active_page_id")
                .or_else(|| dataset.get("activePageId"))
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(ToOwned::to_owned)
        })
        .or_else(|| my_page_items.first().map(|item| item.id.clone()));

    apply_active_page_to_items(&mut my_page_items, active_page_id.as_deref());

    let mut starred_items = documents
        .iter()
        .filter(|document| {
            document
                .get("is_starred")
                .or_else(|| document.get("isStarred"))
                .and_then(Value::as_bool)
                .unwrap_or(false)
        })
        .filter_map(|document| document_to_item(document, workspace_id, active_page_id.as_deref()))
        .collect::<Vec<_>>();
    let starred_page_ids = starred_items
        .iter()
        .map(|item| item.id.clone())
        .collect::<std::collections::BTreeSet<_>>();
    for shortcut in sidebar_shortcuts(dataset) {
        if let Some(item) = shortcut_to_item(
            shortcut,
            workspace_id,
            active_page_id.as_deref(),
            &documents,
            &starred_page_ids,
        ) {
            starred_items.push(item);
        }
    }
    starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));

    let active_page_title = active_title_from_items(&my_page_items, active_page_id.as_deref());
    let degraded = dataset
        .get("degraded")
        .or_else(|| dataset.get("is_degraded"))
        .and_then(Value::as_bool)
        .unwrap_or(false);
    let degraded_reason = dataset
        .get("degraded_reason")
        .or_else(|| dataset.get("degradedReason"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let dev_fixture = dataset
        .get("dev_fixture")
        .or_else(|| dataset.get("devFixture"))
        .and_then(Value::as_bool)
        .unwrap_or(false);

    WorkspaceShellProjection {
        schema: "mnote.workspace_shell.v1".into(),
        workspace_id: workspace_id.to_string(),
        workspace_name,
        active_page_id,
        active_page_title,
        degraded,
        degraded_reason,
        dev_fixture,
        starred_items,
        my_page_items,
        bottom_entries: vec![
            WorkspaceShellEntry {
                id: "trash".into(),
                label: "垃圾箱".into(),
                href: "/trash".into(),
                icon: "delete".into(),
            },
            WorkspaceShellEntry {
                id: "templates".into(),
                label: "模板中心".into(),
                href: "/templates".into(),
                icon: "inventory_2".into(),
            },
        ],
    }
}

fn resolve_workspace_name(dataset: &Value, workspace_id: &str) -> Option<String> {
    dataset
        .get("workspaces")
        .and_then(Value::as_array)
        .and_then(|workspaces| {
            workspaces.iter().find_map(|workspace| {
                let id = workspace
                    .get("id")
                    .or_else(|| workspace.get("workspace_id"))
                    .or_else(|| workspace.get("workspaceId"))
                    .and_then(Value::as_str)?;
                if id != workspace_id {
                    return None;
                }
                workspace
                    .get("name")
                    .or_else(|| workspace.get("title"))
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                    .map(ToOwned::to_owned)
            })
        })
}

fn document_to_item(
    document: &Value,
    workspace_id: &str,
    active_page_id: Option<&str>,
) -> Option<WorkspaceShellItem> {
    let document_workspace_id = document
        .get("workspace_id")
        .or_else(|| document.get("workspaceId"))
        .and_then(Value::as_str)
        .unwrap_or(workspace_id);
    if document_workspace_id != workspace_id {
        return None;
    }
    if document
        .get("deleted_at")
        .or_else(|| document.get("deletedAt"))
        .is_some_and(|value| !value.is_null())
    {
        return None;
    }
    let id = document
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())?;
    let title = document
        .get("title")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("无标题");
    let parent_id = document
        .get("parent_id")
        .or_else(|| document.get("parentId"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let depth = document
        .get("depth")
        .and_then(Value::as_u64)
        .or_else(|| parent_id.as_ref().map(|_| 1))
        .unwrap_or(0) as u32;
    Some(WorkspaceShellItem {
        id: id.to_string(),
        title: title.to_string(),
        icon: document
            .get("icon")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned),
        shortcut_id: None,
        workspace_id: Some(workspace_id.to_string()),
        source_kind: None,
        root_uri: None,
        kind: Some("page".to_string()),
        target_id: Some(id.to_string()),
        relative_path: None,
        document_id: Some(id.to_string()),
        parent_id,
        href: format!("/documents/{id}?workspaceId={workspace_id}"),
        depth,
        active: active_page_id.is_some_and(|active_id| active_id == id),
    })
}

fn sidebar_shortcuts(dataset: &Value) -> Vec<&Value> {
    dataset
        .get("sidebar_shortcuts")
        .or_else(|| dataset.get("sidebarShortcuts"))
        .and_then(Value::as_array)
        .map(|items| items.iter().collect())
        .unwrap_or_default()
}

fn shortcut_to_item(
    shortcut: &Value,
    workspace_id: &str,
    active_page_id: Option<&str>,
    documents: &[Value],
    starred_page_ids: &std::collections::BTreeSet<String>,
) -> Option<WorkspaceShellItem> {
    let shortcut_workspace_id = shortcut
        .get("workspace_id")
        .or_else(|| shortcut.get("workspaceId"))
        .and_then(Value::as_str)
        .unwrap_or(workspace_id);
    let source_kind = shortcut
        .get("source_kind")
        .or_else(|| shortcut.get("sourceKind"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("workspace");
    if shortcut_workspace_id != workspace_id && source_kind != "local_folder" {
        return None;
    }
    let kind = shortcut
        .get("kind")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())?;
    let target_id = shortcut
        .get("target_id")
        .or_else(|| shortcut.get("targetId"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())?;
    if kind == "page" {
        let document_id = shortcut
            .get("document_id")
            .or_else(|| shortcut.get("documentId"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or(target_id);
        if starred_page_ids.contains(document_id) {
            return None;
        }
        let root_uri = shortcut_root_uri(shortcut);
        if let Some(document) = documents.iter().find(|document| {
            document.get("id").and_then(Value::as_str).map(str::trim) == Some(document_id)
        }) {
            let mut item = document_to_item(document, workspace_id, active_page_id)?;
            let shortcut_id = shortcut
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .unwrap_or(document_id)
                .to_string();
            item.id = shortcut_id.clone();
            item.shortcut_id = Some(shortcut_id);
            item.kind = Some("page".to_string());
            item.workspace_id = Some(shortcut_workspace_id.to_string());
            item.source_kind = Some(source_kind.to_string());
            item.root_uri = root_uri.clone();
            item.href = shortcut_document_href(
                document_id,
                shortcut_workspace_id,
                source_kind,
                root_uri.as_deref(),
            );
            item.target_id = Some(target_id.to_string());
            item.document_id = Some(document_id.to_string());
            return Some(item);
        }
    }
    let title = shortcut
        .get("title")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| {
            if kind == "folder" {
                "文件夹"
            } else {
                "无标题"
            }
        });
    let relative_path = shortcut
        .get("relative_path")
        .or_else(|| shortcut.get("relativePath"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let document_id = shortcut
        .get("document_id")
        .or_else(|| shortcut.get("documentId"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let root_uri = shortcut_root_uri(shortcut);
    let shortcut_id = shortcut
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or(target_id)
        .to_string();
    Some(WorkspaceShellItem {
        id: shortcut_id.clone(),
        title: title.to_string(),
        icon: shortcut
            .get("icon")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned)
            .or_else(|| (kind == "folder").then(|| "folder_open".to_string())),
        shortcut_id: Some(shortcut_id),
        workspace_id: Some(shortcut_workspace_id.to_string()),
        source_kind: Some(source_kind.to_string()),
        root_uri: root_uri.clone(),
        kind: Some(kind.to_string()),
        target_id: Some(target_id.to_string()),
        relative_path,
        document_id: document_id.clone(),
        parent_id: None,
        href: document_id
            .as_ref()
            .map(|id| {
                shortcut_document_href(id, shortcut_workspace_id, source_kind, root_uri.as_deref())
            })
            .unwrap_or_default(),
        depth: 0,
        active: kind == "page"
            && active_page_id.is_some_and(|active_id| document_id.as_deref() == Some(active_id)),
    })
}

fn shortcut_root_uri(shortcut: &Value) -> Option<String> {
    shortcut
        .get("rootUri")
        .or_else(|| shortcut.get("root_uri"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .or_else(|| {
            shortcut
                .get("metadata")
                .and_then(|metadata| metadata.get("rootUri").or_else(|| metadata.get("root_uri")))
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(ToOwned::to_owned)
        })
}

fn shortcut_document_href(
    document_id: &str,
    workspace_id: &str,
    source_kind: &str,
    root_uri: Option<&str>,
) -> String {
    let mut href = format!(
        "/documents/{}?workspaceId={}",
        document_id,
        encode_query_component(workspace_id)
    );
    if !source_kind.trim().is_empty() && source_kind != "workspace" {
        href.push_str("&sourceKind=");
        href.push_str(&encode_query_component(source_kind));
    }
    if let Some(root_uri) = root_uri.map(str::trim).filter(|value| !value.is_empty()) {
        href.push_str("&rootUri=");
        href.push_str(&encode_query_component(root_uri));
    }
    href
}

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

fn apply_active_page_to_items(items: &mut [WorkspaceShellItem], active_page_id: Option<&str>) {
    for item in items {
        item.active = active_page_id.is_some_and(|active_id| active_id == item.id);
    }
}

fn active_title_from_items(
    items: &[WorkspaceShellItem],
    active_page_id: Option<&str>,
) -> Option<String> {
    active_page_id.and_then(|active_id| {
        items
            .iter()
            .find(|item| item.id == active_id)
            .map(|item| item.title.clone())
    })
}

pub fn apply_active_page(projection: &mut WorkspaceShellProjection, active_page_id: Option<&str>) {
    let normalized = active_page_id
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    projection.active_page_id = normalized;
    apply_active_page_to_items(
        &mut projection.my_page_items,
        projection.active_page_id.as_deref(),
    );
    apply_active_page_to_items(
        &mut projection.starred_items,
        projection.active_page_id.as_deref(),
    );
    projection.active_page_title = active_title_from_items(
        &projection.my_page_items,
        projection.active_page_id.as_deref(),
    );
}

trait StringEmptyExt {
    fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
}

impl StringEmptyExt for String {
    fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
        if self.trim().is_empty() {
            fallback()
        } else {
            self
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn workspace_shell_projection_contains_sidebar_sections_and_bottom_entries() {
        let dataset = json!({
            "active_workspace_id": "ws_demo",
            "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
            "documents": [
                { "id": "page_home", "workspace_id": "ws_demo", "title": "个人", "parent_id": null, "sort_order": 0, "is_starred": true },
                { "id": "page_child", "workspace_id": "ws_demo", "title": "软件开发", "parent_id": "page_home", "sort_order": 1, "is_starred": false }
            ]
        });

        let projection = build_workspace_shell_projection(
            &dataset,
            "ws_demo",
            Some("page_home"),
            "开发用户 的工作区",
        );

        assert_eq!(projection.schema, "mnote.workspace_shell.v1");
        assert_eq!(projection.workspace_id, "ws_demo");
        assert_eq!(projection.workspace_name, "开发用户 的工作区");
        assert_eq!(projection.active_page_id.as_deref(), Some("page_home"));
        assert_eq!(projection.active_page_title.as_deref(), Some("个人"));
        assert!(!projection.degraded);
        assert!(!projection.dev_fixture);
        assert_eq!(projection.starred_items.len(), 1);
        assert_eq!(projection.starred_items[0].title, "个人");
        assert_eq!(projection.my_page_items.len(), 2);
        assert!(projection.my_page_items[0].active);
        assert_eq!(
            projection.my_page_items[1].parent_id.as_deref(),
            Some("page_home")
        );
        assert!(projection
            .bottom_entries
            .iter()
            .any(|entry| entry.label == "垃圾箱"));
        assert!(projection
            .bottom_entries
            .iter()
            .any(|entry| entry.label == "模板中心"));
    }

    #[test]
    fn workspace_shell_sidebar_html_outputs_projection_rows_with_active_and_parent() {
        let dataset = json!({
            "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
            "documents": [
                { "id": "doc_root", "workspace_id": "ws_demo", "title": "Root", "parent_id": null, "sort_order": 0 },
                { "id": "doc_child", "workspace_id": "ws_demo", "title": "Child", "parent_id": "doc_root", "sort_order": 1 }
            ]
        });
        let projection = build_workspace_shell_projection(
            &dataset,
            "ws_demo",
            Some("doc_root"),
            "开发用户 的工作区",
        );
        let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);

        assert!(html.contains("data-testid=\"wolai-sidebar-row\""));
        assert!(html.contains("data-node-id=\"doc_root\""));
        assert!(html.contains("data-node-id=\"doc_child\""));
        assert!(html.contains("data-parent-id=\"doc_root\""));
        assert!(html.contains("data-active=\"true\""));
        assert!(html.contains("/documents/doc_root?workspaceId=ws_demo"));
        assert!(html.contains("data-testid=\"wolai-sidebar-create-page\""));
        assert!(html.contains("data-mnote-action=\"create-page\""));
        assert!(html.contains("data-testid=\"wolai-sidebar-create-folder\""));
        assert!(html.contains("data-mnote-action=\"create-folder\""));
        assert!(html.contains("wolai-section-add--folder"));
        assert!(html.contains("data-icon=\"folder_open\""));
        assert!(!html.contains(">create_new_folder</span>"));
        assert!(html.contains("class=\"material-symbols-outlined"));
        assert!(html.contains("data-icon=\"home\""));
        assert!(html.contains("data-icon=\"star\""));
        assert!(html.contains("data-icon=\"delete\""));
    }

    #[test]
    fn workspace_shell_sidebar_html_renders_sqlite_folder_shortcut_attrs() {
        let dataset = json!({
            "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
            "documents": [],
            "sidebarShortcuts": [{
                "id": "shortcut_design",
                "workspaceId": "local-ws:demo",
                "kind": "folder",
                "sourceKind": "local_folder",
                "targetId": "folder:design",
                "relativePath": "design",
                "title": "design",
                "icon": "folder_open",
                "metadata": {
                    "rootUri": "file:///tmp/mnote-demo"
                }
            }]
        });
        let projection =
            build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
        let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);

        assert_eq!(projection.starred_items.len(), 1);
        assert!(html.contains(r#"data-mnote-shortcut-kind="folder""#));
        assert!(html.contains(r#"data-mnote-shortcut-id="shortcut_design""#));
        assert!(html.contains(r#"data-workspace-id="local-ws:demo""#));
        assert!(html.contains(r#"data-mnote-shortcut-source-kind="local_folder""#));
        assert!(html.contains(r#"data-mnote-shortcut-target-id="folder:design""#));
        assert!(html.contains(r#"data-mnote-shortcut-relative-path="design""#));
        assert!(html.contains(r#"data-mnote-shortcut-root-uri="file:///tmp/mnote-demo""#));
        assert!(html.contains(r#"data-mnote-shortcut-action="menu""#));
        assert!(html.contains(r#"role="button""#));
    }

    #[test]
    fn workspace_shell_sidebar_html_uses_single_tabbed_tree_host() {
        let dataset = json!({
            "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
            "documents": [
                { "id": "doc_root", "workspace_id": "ws_demo", "title": "Root", "parent_id": null, "sort_order": 0 }
            ]
        });
        let projection = build_workspace_shell_projection(
            &dataset,
            "ws_demo",
            Some("doc_root"),
            "开发用户 的工作区",
        );
        let html = render_workspace_shell_sidebar_html(
            &projection,
            Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
            Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
            None,
            None,
        );

        assert!(html.contains(r#"data-mnote-sidebar-tree-tab="page""#));
        assert!(html.contains(r#"data-mnote-sidebar-tree-tab="filetree""#));
        assert!(html.contains(r#"data-mnote-sidebar-tree-panel="page""#));
        assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree" hidden"#));
        assert!(html.contains(r#"id="sidebar-tree-root""#));
        assert!(html.contains(r#"id="sidebar-file-tree-root""#));
        assert!(!html.contains("wolai-file-tree-section"));
    }

    #[test]
    fn workspace_shell_sidebar_html_can_render_filetree_tab_initially_active() {
        let dataset = json!({
            "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
            "documents": [
                { "id": "doc_root", "workspace_id": "ws_demo", "title": "Root", "parent_id": null, "sort_order": 0 }
            ]
        });
        let projection = build_workspace_shell_projection(
            &dataset,
            "ws_demo",
            Some("doc_root"),
            "开发用户 的工作区",
        );
        let html = render_workspace_shell_sidebar_html(
            &projection,
            Some(r#"<ul data-rust-page-renderer="initial_v1"></ul>"#),
            Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
            Some("filetree"),
            None,
        );

        assert!(html.contains(
            r#"class="wolai-sidebar-tab wolai-sidebar-tab-muted" data-mnote-sidebar-tree-tab="page" aria-selected="false""#
        ));
        assert!(html.contains(
            r#"class="wolai-sidebar-tab wolai-sidebar-tab-active" data-mnote-sidebar-tree-tab="filetree" aria-selected="true""#
        ));
        assert!(html.contains(r#"data-mnote-sidebar-tree-panel="page" hidden"#));
        assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree"><div"#));
    }

    #[test]
    fn workspace_shell_sidebar_html_marks_filetree_scope() {
        let dataset = json!({
            "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
            "documents": []
        });
        let projection =
            build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
        let html = render_workspace_shell_sidebar_html(
            &projection,
            None,
            Some(r#"<ul data-rust-filetree-renderer="initial_v1"></ul>"#),
            Some("filetree"),
            Some("design"),
        );

        assert!(html.contains(r#"id="sidebar-file-tree-root""#));
        assert!(html.contains(r#"data-mnote-filetree-scope="design""#));
    }

    #[test]
    fn workspace_shell_sidebar_html_outputs_empty_state() {
        let dataset = json!({
            "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
            "documents": []
        });
        let projection =
            build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
        let html = render_workspace_shell_sidebar_html(&projection, None, None, None, None);

        assert!(!html.contains("data-testid=\"wolai-sidebar-empty-state\""));
        assert!(!html.contains("暂无页面"));
    }

    #[test]
    fn workspace_shell_sidebar_html_marks_degraded_and_dev_fixture_states() {
        let degraded_dataset = json!({
            "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
            "documents": [],
            "degraded": true,
            "degraded_reason": "projection_unavailable"
        });
        let degraded_projection = build_workspace_shell_projection(
            &degraded_dataset,
            "ws_demo",
            None,
            "开发用户 的工作区",
        );
        let degraded_html =
            render_workspace_shell_sidebar_html(&degraded_projection, None, None, None, None);

        assert!(degraded_projection.degraded);
        assert!(degraded_html.contains("data-mnote-workspace-shell-degraded=\"true\""));
        assert!(degraded_html
            .contains("data-mnote-workspace-shell-degraded-reason=\"projection_unavailable\""));

        let dev_dataset = json!({
            "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
            "documents": [],
            "dev_fixture": true
        });
        let dev_projection =
            build_workspace_shell_projection(&dev_dataset, "ws_demo", None, "开发用户 的工作区");
        let dev_html = render_workspace_shell_sidebar_html(&dev_projection, None, None, None, None);

        assert!(dev_projection.dev_fixture);
        assert!(dev_html.contains("data-mnote-dev-fixture=\"true\""));
        assert!(dev_html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\""));
    }
}

pub fn render_workspace_shell_sidebar_html(
    projection: &WorkspaceShellProjection,
    sidebar_tree_html: Option<&str>,
    file_tree_html: Option<&str>,
    initial_tree_mode: Option<&str>,
    file_tree_scope: Option<&str>,
) -> String {
    let starred_rows = if projection.starred_items.is_empty() {
        String::new()
    } else {
        projection
            .starred_items
            .iter()
            .map(render_item_row)
            .collect::<Vec<_>>()
            .join("")
    };
    let projected_my_pages = projection
        .my_page_items
        .iter()
        .map(render_item_row)
        .collect::<Vec<_>>()
        .join("");
    let tree_html = sidebar_tree_html
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(|html| {
            format!(
                r#"<div class="sidebar-tree-section sidebar-page-tree-section" data-testid="wolai-sidebar-page-tree-section"><div class="sidebar-tree-divider"></div><div id="sidebar-tree-root" class="sidebar-tree" data-tree-shell-mode="page" data-workspace-id="{}">{html}</div></div>"#,
                escape_html(&projection.workspace_id),
            )
        })
        .unwrap_or_default();
    let file_tree_content = file_tree_html
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(|html| {
            let file_tree_scope_attr = file_tree_scope
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(|value| {
                    format!(
                        r#" data-mnote-filetree-scope="{}""#,
                        escape_html(value)
                    )
                })
                .unwrap_or_default();
            format!(
                r#"<div class="sidebar-tree-section sidebar-file-tree-section"><div id="sidebar-file-tree-root" class="sidebar-tree" data-tree-shell-mode="filetree" data-workspace-id="{}"{file_tree_scope_attr}>{html}</div></div>"#,
                escape_html(&projection.workspace_id),
            )
        })
        .unwrap_or_else(|| {
            String::new()
        });
    let my_pages = if !tree_html.is_empty() {
        tree_html
    } else if !projected_my_pages.is_empty() {
        projected_my_pages
    } else {
        String::new()
    };
    let filetree_initially_active = initial_tree_mode
        .map(str::trim)
        .is_some_and(|value| value == "filetree")
        && !file_tree_content.trim().is_empty();
    let page_tab_class = if filetree_initially_active {
        "wolai-sidebar-tab wolai-sidebar-tab-muted"
    } else {
        "wolai-sidebar-tab wolai-sidebar-tab-active"
    };
    let page_aria_selected = if filetree_initially_active {
        "false"
    } else {
        "true"
    };
    let filetree_tab_class = if filetree_initially_active {
        "wolai-sidebar-tab wolai-sidebar-tab-active"
    } else {
        "wolai-sidebar-tab wolai-sidebar-tab-muted"
    };
    let filetree_aria_selected = if filetree_initially_active {
        "true"
    } else {
        "false"
    };
    let page_panel_hidden = if filetree_initially_active {
        " hidden"
    } else {
        ""
    };
    let filetree_panel_hidden = if filetree_initially_active {
        ""
    } else {
        " hidden"
    };
    let bottom_entries = projection
        .bottom_entries
        .iter()
        .map(|entry| {
            let extra_attrs = if entry.id == "trash" {
                format!(
                    r#" data-testid="mnote-sidebar-trash-entry" data-mnote-action="open-trash-modal" data-workspace-id="{}""#,
                    escape_html(&projection.workspace_id),
                )
            } else {
                String::new()
            };
            format!(
                r#"<a href="{}" class="wolai-footer-entry"{}>{}{}</a>"#,
                escape_html(&entry.href),
                extra_attrs,
                render_symbol(&entry.icon, "wolai-footer-icon"),
                escape_html(&entry.label),
            )
        })
        .collect::<Vec<_>>()
        .join("");
    let status_markers = format!(
        r#"{degraded_marker}{dev_fixture_marker}"#,
        degraded_marker = if projection.degraded {
            format!(
                r#"<span hidden data-mnote-workspace-shell-degraded="true" data-mnote-workspace-shell-degraded-reason="{}"></span>"#,
                escape_html(
                    projection
                        .degraded_reason
                        .as_deref()
                        .unwrap_or("projection_unavailable")
                ),
            )
        } else {
            String::new()
        },
        dev_fixture_marker = if projection.dev_fixture {
            r#"<span hidden data-mnote-dev-fixture="true" data-mnote-dev-fixture-kind="workspace-shell"></span>"#.to_string()
        } else {
            String::new()
        },
    );

    format!(
        r#"{status_markers}<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title">{}星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="页面树" data-testid="wolai-sidebar-page-tree-shell"><div class="wolai-sidebar-tabs" data-testid="wolai-sidebar-tree-tabs" role="tablist" aria-label="页面树和文件树"><button type="button" class="{page_tab_class}" data-mnote-sidebar-tree-tab="page" aria-selected="{page_aria_selected}" aria-controls="wolai-sidebar-page-tree-panel">我的页面</button><button type="button" class="{filetree_tab_class}" data-mnote-sidebar-tree-tab="filetree" aria-selected="{filetree_aria_selected}" aria-controls="wolai-sidebar-file-tree-panel">{}Explorer</button><span class="wolai-section-caret">⌄</span><button type="button" class="wolai-section-add" data-testid="wolai-sidebar-create-page" data-mnote-action="create-page" data-workspace-id="{}" title="新建页面" aria-label="新建页面">+</button><button type="button" class="wolai-section-add wolai-section-add--folder" data-testid="wolai-sidebar-create-folder" data-mnote-action="create-folder" data-workspace-id="{}" title="新建文件夹" aria-label="新建文件夹"><span class="material-symbols-outlined" data-icon="folder_open" aria-hidden="true"></span></button></div><div class="wolai-sidebar-tab-panels"><div id="wolai-sidebar-page-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="page"{page_panel_hidden}>{my_pages}</div><div id="wolai-sidebar-file-tree-panel" class="wolai-sidebar-tab-panel" data-mnote-sidebar-tree-panel="filetree"{filetree_panel_hidden}>{file_tree_content}</div></div></section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#,
        render_symbol("star", "wolai-section-icon"),
        render_symbol("folder_open", "wolai-folder-icon"),
        escape_html(&projection.workspace_id),
        escape_html(&projection.workspace_id),
    )
}

fn render_item_row(item: &WorkspaceShellItem) -> String {
    let active_class = if item.active { " wolai-active-row" } else { "" };
    let depth_style = if item.depth == 0 {
        String::new()
    } else {
        format!(r#" style="padding-left:{}px""#, 12 + item.depth.min(6) * 12)
    };
    let parent_attr = item
        .parent_id
        .as_deref()
        .map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
        .unwrap_or_default();
    let workspace_attr = item
        .workspace_id
        .as_deref()
        .map(|workspace_id| format!(r#" data-workspace-id="{}""#, escape_html(workspace_id)))
        .unwrap_or_default();
    let shortcut_id_attr = item
        .shortcut_id
        .as_deref()
        .map(|shortcut_id| format!(r#" data-mnote-shortcut-id="{}""#, escape_html(shortcut_id)))
        .unwrap_or_default();
    let source_kind_attr = item
        .source_kind
        .as_deref()
        .map(|source_kind| {
            format!(
                r#" data-mnote-shortcut-source-kind="{}""#,
                escape_html(source_kind)
            )
        })
        .unwrap_or_default();
    let root_uri_attr = item
        .root_uri
        .as_deref()
        .map(|root_uri| {
            format!(
                r#" data-mnote-shortcut-root-uri="{}""#,
                escape_html(root_uri)
            )
        })
        .unwrap_or_default();
    let aria_current = if item.active {
        r#" aria-current="page""#
    } else {
        ""
    };
    let kind_attr = item
        .kind
        .as_deref()
        .map(|kind| format!(r#" data-mnote-shortcut-kind="{}""#, escape_html(kind)))
        .unwrap_or_default();
    let target_attr = item
        .target_id
        .as_deref()
        .map(|target_id| {
            format!(
                r#" data-mnote-shortcut-target-id="{}""#,
                escape_html(target_id)
            )
        })
        .unwrap_or_default();
    let relative_attr = item
        .relative_path
        .as_deref()
        .map(|relative_path| {
            format!(
                r#" data-mnote-shortcut-relative-path="{}""#,
                escape_html(relative_path)
            )
        })
        .unwrap_or_default();
    let document_attr = item
        .document_id
        .as_deref()
        .map(|document_id| {
            format!(
                r#" data-mnote-shortcut-document-id="{}""#,
                escape_html(document_id)
            )
        })
        .unwrap_or_default();
    let row_body = format!(
        r#"<span class="wolai-row-caret" aria-hidden="true">›</span><span class="wolai-row-icon">{}</span><span class="wolai-row-title">{}</span>"#,
        render_symbol(item_icon_name(item.icon.as_deref()), "wolai-row-symbol"),
        escape_html(&item.title),
    );
    let shortcut_action = item
        .shortcut_id
        .as_deref()
        .map(|_| {
            r#"<button type="button" class="wolai-row-more" data-mnote-shortcut-action="menu" aria-label="更多操作" title="更多操作"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>"#
                .to_string()
        })
        .unwrap_or_default();
    if item.href.trim().is_empty() {
        return format!(
            r#"<div class="wolai-page-row{active_class}" role="button" tabindex="0" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{workspace_attr}{parent_attr}{shortcut_id_attr}{kind_attr}{source_kind_attr}{target_attr}{relative_attr}{root_uri_attr}{document_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}>{row_body}{shortcut_action}</div>"#,
            escape_html(&item.id),
            escape_html(item.document_id.as_deref().unwrap_or(&item.id)),
            item.depth,
            item.active,
        );
    }
    format!(
        r#"<a class="wolai-page-row{active_class}" href="{}" data-testid="wolai-sidebar-row" data-node-id="{}" data-document-id="{}"{workspace_attr}{parent_attr}{shortcut_id_attr}{kind_attr}{source_kind_attr}{target_attr}{relative_attr}{root_uri_attr}{document_attr} data-depth="{}" data-active="{}"{aria_current}{depth_style}>{row_body}{shortcut_action}</a>"#,
        escape_html(&item.href),
        escape_html(&item.id),
        escape_html(item.document_id.as_deref().unwrap_or(&item.id)),
        item.depth,
        item.active,
    )
}

fn item_icon_name(icon: Option<&str>) -> &'static str {
    match icon.unwrap_or_default().trim() {
        "star" | "★" => "star",
        "folder" | "folder_open" | "▱" => "folder_open",
        "delete" | "trash" | "⌫" => "delete",
        "inventory_2" | "template" | "◇" => "inventory_2",
        "home" | "page" | "▣" | "⌂" | "" => "home",
        _ => "home",
    }
}

fn render_symbol(icon: &str, class_name: &str) -> String {
    let icon = item_icon_name(Some(icon));
    let filled_class = if icon == "star" || icon == "home" {
        " material-symbols-filled"
    } else {
        ""
    };
    format!(
        r#"<span class="material-symbols-outlined{} {}" data-icon="{}" aria-hidden="true"></span>"#,
        filled_class,
        escape_html(class_name),
        escape_html(icon),
    )
}

fn escape_html(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}
