"use client";

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { useAuthActions } from "@convex-dev/auth/react";
import { useConvex } from "convex/react";
import { useConvexAuth } from "convex/react";
import {
  ArrowRightLeft,
  ArrowUpRight,
  ChevronRight,
  Copy,
  Edit3,
  GitMerge,
  Globe,
  Hash,
  LayoutGrid,
  Library,
  Link as LinkIcon,
  LogOut,
  MoreHorizontal,
  PanelRightOpen,
  Plus,
  Search as SearchIcon,
  Share2,
  Shield,
  Star,
  Trash2,
  Upload,
  Users,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
import { useSidebarData, type SidebarDataResult } from "@/hooks/use-sidebar-data";
import {
  buildMediaAssetListSyncKey,
  buildSidebarTreeSyncKey,
} from "@/components/sidebar/sidebar-sync";
import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot";
import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events";
import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
import {
  buildPageTreeProjectionItems,
  filterVisiblePageTreeProjectionItems,
} from "@/lib/tree-projection";
import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { useCurrentDocumentStore } from "@/store/current-document";
import { buildVisibleRows } from "@/lib/file-tree/rows";
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
import { isRealFileAsset } from "@/lib/file-tree/asset";
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
import {
  computeTreePaneDeleteTargets,
  inferPasteTargetDocId,
  isTextInputTarget,
  normalizeTreePaneSelectionForVisibleRows,
  readTreePaneClipboardPayload,
  reduceTreePaneSelection,
  type TreePaneRow,
  type TreePaneSelectionState,
  writeTreePaneClipboardPayload,
} from "@/components/sidebar/tree-pane-bindings";
import type { MediaAsset } from "@/types/media";
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
import { useSidebarTreeStream } from "@/lib/tree-stream/use-sidebar-tree-stream";
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
import { api } from "@/lib/convex/api";
import { GroupManagerDialog } from "@/components/groups/group-manager-dialog";
import {
  copyTreeCommand,
  createDocumentCommand,
  deleteDocumentCommand,
  embedDocumentCommand,
  moveDocumentCommand,
  renameDocumentCommand,
  restoreDocumentCommand,
  purgeDocumentCommand,
} from "@/lib/documents/tree-command-client";

const TOP_BUTTONS = [
  { id: "search", icon: SearchIcon, label: "搜索" },
  { id: "graph", icon: Share2, label: "关系图" },
  { id: "import", icon: Upload, label: "导入" },
  { id: "members", icon: Users, label: "成员" },
  { id: "starred", icon: Star, label: "星标置顶" },
  { id: "public", icon: Globe, label: "公共页面" },
  { id: "shared", icon: Shield, label: "共享页面" },
  { id: "templates", icon: LayoutGrid, label: "模板中心" },
] as const;

const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNode> = {
  starred: <Star className="h-4 w-4 text-[#f5a623]" />,
  public: <Globe className="h-4 w-4 text-[#60a5fa]" />,
  shared: <Shield className="h-4 w-4 text-[#9b87f5]" />,
  templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />,
};

const normalizeStoragePath = (storagePath: string): string => storagePath.replaceAll("\\", "/");

const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | null) => {
  const name = (fileName ?? "").trim().toLowerCase();
  const mt = (mimeType ?? "").trim().toLowerCase();
  const ext = name.includes(".") ? name.split(".").pop() : null;
  if (ext && ["doc", "docx", "odt", "rtf"].includes(ext)) return ext;
  if (ext && ["ppt", "pptx", "odp"].includes(ext)) return ext;
  if (ext && ["xls", "xlsx", "ods", "csv"].includes(ext)) return ext;
  if (ext && ["pdf"].includes(ext)) return ext;
  if (mt.includes("wordprocessingml")) return "docx";
  if (mt.includes("presentationml")) return "pptx";
  if (mt.includes("spreadsheetml")) return "xlsx";
  if (mt.includes("pdf")) return "pdf";
  return null;
};

const extractMindmapIdFromStoragePath = (
  storagePath: string | null | undefined,
): string | null => {
  if (!storagePath) return null;
  const normalized = normalizeStoragePath(storagePath);

  const prefix = "mindmaps/";
  if (normalized.startsWith(prefix)) {
    const rest = normalized.slice(prefix.length);
    const id = rest.split("/")[0];
    return id ? id : null;
  }

  const marker = "/mindmaps/";
  const idx = normalized.indexOf(marker);
  if (idx === -1) return null;
  const rest = normalized.slice(idx + marker.length);
  const id = rest.split("/")[0];
  return id ? id : null;
};

interface SidebarProps {
  initialData: SidebarInitialData;
  sidebarData?: SidebarInitialData;
  sidebarQuery?: SidebarDataResult;
  treeStream?: ReturnType<typeof useSidebarTreeStream>;
}

interface ContextMenuState {
  node: SidebarTreeNode;
  x: number;
  y: number;
}

export function Sidebar({ initialData, sidebarData, sidebarQuery, treeStream }: SidebarProps) {
  if (sidebarQuery && treeStream) {
    return (
      <SidebarContent
        initialData={initialData}
        sidebarData={sidebarData}
        sidebarQuery={sidebarQuery}
        treeStream={treeStream}
      />
    );
  }
  return <SidebarConvex initialData={initialData} />;
}

// Convex 模式专用组件 - 只调用 Convex hooks
function SidebarConvex({ initialData }: SidebarProps) {
  const sidebarData = useSidebarData(initialData);
  const treeStream = useSidebarTreeStream(initialData);
  return <SidebarContent initialData={initialData} sidebarQuery={sidebarData} treeStream={treeStream} />;
}

// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
interface SidebarContentProps {
  initialData: SidebarInitialData;
  sidebarData?: SidebarInitialData;
  sidebarQuery: SidebarDataResult;
  treeStream: ReturnType<typeof useSidebarTreeStream>;
}

function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebarQuery, treeStream }: SidebarContentProps) {
  const convex = useConvex();
  useConvexAuth();
  const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
    useSidebarStore();
  const viewMode = useSidebarStore((state) => state.viewMode);
  const setViewMode = useSidebarStore((state) => state.setViewMode);
  const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);

  // 处理数据
  const preferredSidebarSnapshot = usePreferredSidebarSnapshot({
    initialData,
    sidebarQueryData: sidebarQuery.data,
    treeStreamData: treeStream.data,
    treeStreamStatus: treeStream.status,
  });
  const sidebarData = externalSidebarData ?? preferredSidebarSnapshot.data;

  const sidebarHydrate = useSidebarStore.persist?.rehydrate;
  const sidebarRefetch = sidebarQuery.refetch;
  // isLoading 判断
  const isLoading = sidebarQuery.isLoading;
  const segments = useSelectedLayoutSegments();
  const router = useRouter();
  const { signOut } = useAuthActions();
  const activeId = segments?.[1] ?? "";
  const editorBridge = useEditorBridgeStore((state) => state.bridge);
  const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "react";

  const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree);
  const [filter, setFilter] = useState("");
  const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
  const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
  const [topPanel, setTopPanel] = useState<"starred" | "public" | "shared" | "templates" | null>(null);
  const [shareSummary, setShareSummary] = useState<{
    incoming: Array<{
      workspaceId: string;
      workspaceName: string | null;
      documentId: string;
      documentTitle: string | null;
      permission: "read" | "edit";
      includeDescendants: boolean;
      createdBy: string;
      updatedAt: string;
    }>;
    outgoing: Array<{
      workspaceId: string;
      workspaceName: string | null;
      documentId: string;
      documentTitle: string | null;
      includeDescendants: boolean;
      sharedWithCount: number;
      updatedAt: string;
    }>;
  } | null>(null);
  const [shareSummaryError, setShareSummaryError] = useState<string | null>(null);
  const [groupPublicSummary, setGroupPublicSummary] = useState<
    Array<{
      groupId: string;
      groupName: string | null;
      documents: Array<{ documentId: string; includeDescendants: boolean }>;
    }>
  >([]);
  const [groupPublicError, setGroupPublicError] = useState<string | null>(null);
  const [openPublicGroups, setOpenPublicGroups] = useState<Set<string>>(() => new Set());
  const [shareDialogOpen, setShareDialogOpen] = useState(false);
  const [shareTarget, setShareTarget] = useState<{
    id: string;
    title: string | null;
    workspaceId: string;
    allowIncludeDescendants: boolean;
  } | null>(null);
  const [groupManagerOpen, setGroupManagerOpen] = useState(false);
  const [signingOut, setSigningOut] = useState(false);
  const [trashOpen, setTrashOpen] = useState(false);
  const [trashSearch, setTrashSearch] = useState("");
  const [trashTab, setTrashTab] = useState<"documents" | "assets">("documents");
  const [emptyingTrash, setEmptyingTrash] = useState(false);
  const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
  const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
  const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
  const [moveEmbedOpen, setMoveEmbedOpen] = useState(false);
  const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move");
  const [moveEmbedSource, setMoveEmbedSource] = useState<SidebarTreeNode | null>(null);
  const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
    null,
  );
  const [resourceSelection, setResourceSelection] = useState<TreePaneSelectionState>(() => ({
    selectedRowIds: new Set(),
    anchorRowId: null,
    focusedRowId: null,
  }));
  const [sidebarHydrated, setSidebarHydrated] = useState(false);
  const treeSyncKeyRef = useRef<string>(buildSidebarTreeSyncKey(sidebarData.kernelSidebarTree));
  const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
  const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
  const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));

  const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
  const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
  // 删除在线表格后，Convex 订阅刷新存在极短延迟；这里做短暂“乐观隐藏”，避免文件树闪回。
  const hiddenTableIdsRef = useRef<Map<string, number>>(new Map());

  useEffect(() => {
    const nextTree = sidebarData.kernelSidebarTree;
    const nextSyncKey = buildSidebarTreeSyncKey(nextTree);
    if (treeSyncKeyRef.current === nextSyncKey) {
      return;
    }
    treeSyncKeyRef.current = nextSyncKey;
    setTree(() => {
      setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
      return nextTree;
    });
  }, [sidebarData.kernelSidebarTree]);

  useEffect(() => {
    const nextAssets = sidebarData.mediaAssets ?? [];
    const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
    if (mediaAssetsSyncKeyRef.current === nextSyncKey) {
      return;
    }
    mediaAssetsSyncKeyRef.current = nextSyncKey;
    setMediaAssets(nextAssets);
  }, [sidebarData.mediaAssets]);

  useEffect(() => {
    const nextAssets = sidebarData.mindmapAssets ?? [];
    const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
    if (mindmapAssetsSyncKeyRef.current === nextSyncKey) {
      return;
    }
    mindmapAssetsSyncKeyRef.current = nextSyncKey;
    setMindmapAssets(nextAssets);
  }, [sidebarData.mindmapAssets]);

  useEffect(() => {
    const hidden = hiddenTableIdsRef.current;
    const now = Date.now();
    hidden.forEach((ts, id) => {
      if (now - ts > 15000) {
        hidden.delete(id);
      }
    });
    const nextAssets = (sidebarData.tableAssets ?? []).filter((item) => !hidden.has(item.id));
    const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
    if (tableAssetsSyncKeyRef.current === nextSyncKey) {
      return;
    }
    tableAssetsSyncKeyRef.current = nextSyncKey;
    setTableAssets(nextAssets);
  }, [sidebarData.tableAssets]);

  useEffect(() => {
    setOpen(false);
  }, [activeId, setOpen]);

  useEffect(() => {
    if (sidebarHydrated) {
      return;
    }
    let cancelled = false;
    void (async () => {
      await sidebarHydrate?.();
      if (!cancelled) {
        setSidebarHydrated(true);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [sidebarHydrate, sidebarHydrated]);

  useEffect(() => {
    const onSaved = (event: Event) => {
      const detail = (event as CustomEvent<{ tableId?: string }>).detail;
      const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
      if (tableId) {
        hiddenTableIdsRef.current.delete(tableId);
      }
      void sidebarRefetch();
    };
    const onDeleted = (event: Event) => {
      const detail = (event as CustomEvent<{ tableId?: string }>).detail;
      const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
      if (tableId) {
        hiddenTableIdsRef.current.set(tableId, Date.now());
        setTableAssets((prev) => prev.filter((item) => item.id !== tableId));
      }
      void sidebarRefetch();
    };
    window.addEventListener("online-table-saved", onSaved as EventListener);
    window.addEventListener("online-table-deleted", onDeleted as EventListener);
    return () => {
      window.removeEventListener("online-table-saved", onSaved as EventListener);
      window.removeEventListener("online-table-deleted", onDeleted as EventListener);
    };
  }, [sidebarRefetch]);

  const refreshTree = useCallback(async () => {
    await sidebarRefetch();
  }, [sidebarRefetch]);

  const refreshShareSummary = useCallback(async () => {
    try {
      const resp = await convex.query(api.documentShares.listMyShareRoots, {});
      setShareSummary(resp as any);
      setShareSummaryError(null);
    } catch (e: any) {
      const msg = e?.message ?? "加载共享摘要失败";
      if (String(msg).includes("Could not find public function for 'documentShares:listMyShareRoots'")) {
        setShareSummaryError(
          "共享功能后端未部署/未更新：请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all`。",
        );
      } else {
        setShareSummaryError(msg);
      }
      setShareSummary(null);
    }
  }, [convex]);

  useEffect(() => {
    void refreshShareSummary();
  }, [refreshShareSummary]);

  const refreshGroupPublicSummary = useCallback(async () => {
    const workspaceId = sidebarData.activeWorkspaceId;
    if (!workspaceId) {
      setGroupPublicSummary([]);
      setGroupPublicError(null);
      return;
    }

    try {
      const resp = await convex.query(api.documentGroupShares.listPublicByWorkspace, { workspaceId });
      const rows = Array.isArray(resp) ? (resp as any[]) : [];
      setGroupPublicSummary(
        rows.map((r) => ({
          groupId: String(r.groupId),
          groupName: r.groupName ? String(r.groupName) : null,
          documents: Array.isArray(r.documents)
            ? r.documents.map((d: any) => ({
                documentId: String(d.documentId),
                includeDescendants: Boolean(d.includeDescendants),
              }))
            : [],
        })),
      );
      setGroupPublicError(null);
    } catch (e: any) {
      const msg = e?.message ?? "加载群组公开摘要失败";
      if (String(msg).includes("Could not find public function for 'documentGroupShares:listPublicByWorkspace'")) {
        setGroupPublicError("群组公开功能后端未部署/未更新：请执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all`。");
      } else {
        setGroupPublicError(msg);
      }
      setGroupPublicSummary([]);
    }
  }, [convex, sidebarData.activeWorkspaceId]);

  useEffect(() => {
    void refreshGroupPublicSummary();
  }, [refreshGroupPublicSummary]);

  useEffect(() => {
    const dispose = bindSidebarRefreshEvents({
      onAsset: (asset) => {
      if (asset?.id) {
        if (asset.asset_type === "mindmap") {
          setMindmapAssets((prev) => {
            if (prev.find((item) => item.id === asset.id)) return prev;
            return [asset, ...prev];
          });
        } else if (asset.asset_type === "luckysheet") {
          setTableAssets((prev) => {
            if (prev.find((item) => item.id === asset.id)) return prev;
            return [asset, ...prev];
          });
        } else {
          setMediaAssets((prev) => {
            if (prev.find((item) => item.id === asset.id)) return prev;
            return [asset, ...prev];
          });
        }
      }
      },
      sidebarRefetch,
      // 同步刷新共享/公共摘要，避免跨页面操作后出现“幽灵共享条目”（点开 404 / 无标题）。
      refreshShareSummary,
      refreshGroupPublicSummary,
    });
    return dispose;
  }, [sidebarRefetch, refreshShareSummary, refreshGroupPublicSummary]);

  useEffect(() => {
    // 说明：shareSummary/groupPublicSummary 目前走的是一次性 query + 本地 state，
    // 为了让 A 侧删除/清空回收站后，B 侧能自动消失（而不是保留 404 幽灵项），这里做轻量轮询刷新。
    if (topPanel !== "shared" && topPanel !== "public") {
      return;
    }

    const refresh = () => {
      if (topPanel === "shared") void refreshShareSummary();
      if (topPanel === "public") void refreshGroupPublicSummary();
    };

    refresh();
    const intervalId = window.setInterval(refresh, 2500);

    const onFocus = () => {
      if (document.visibilityState && document.visibilityState !== "visible") return;
      refresh();
    };

    window.addEventListener("focus", onFocus);
    document.addEventListener("visibilitychange", onFocus);
    return () => {
      window.clearInterval(intervalId);
      window.removeEventListener("focus", onFocus);
      document.removeEventListener("visibilitychange", onFocus);
    };
  }, [topPanel, refreshShareSummary, refreshGroupPublicSummary]);

  const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
  const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
  const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.nodes ?? [], [sections]);
  const sharedNodes = useMemo(() => sections.find((section) => section.id === "shared")?.nodes ?? [], [sections]);
  const templateNodes = useMemo(() => sections.find((section) => section.id === "templates")?.nodes ?? [], [sections]);
  const privateTree = useMemo(() => sections.find((section) => section.id === "private")?.nodes ?? [], [sections]);

  const nodeById = useMemo(() => {
    const map = new Map<string, SidebarTreeNode>();
    const walk = (nodes: SidebarTreeNode[]) => {
      nodes.forEach((node) => {
        map.set(node.id, node);
        if (node.children.length > 0) {
          walk(node.children);
        }
      });
    };
    walk(tree);
    return map;
  }, [tree]);

  const publicGroupNodesByGroupId = useMemo(() => {
    const map = new Map<string, SidebarTreeNode[]>();
    for (const g of groupPublicSummary) {
      const nodes: SidebarTreeNode[] = [];
      const uniq = new Map<string, SidebarTreeNode>();
      for (const d of g.documents ?? []) {
        const node = nodeById.get(d.documentId);
        if (node) {
          uniq.set(node.id, node);
        }
      }
      uniq.forEach((v) => nodes.push(v));
      map.set(g.groupId, nodes);
    }
    return map;
  }, [groupPublicSummary, nodeById]);
  const filteredPageNodes = useMemo(
    () => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree),
    [filter, privateTree],
  );
  const privatePageRows = useMemo(
    () => buildPageTreeProjectionItems(privateTree),
    [privateTree],
  );
  const filteredPrivatePageRows = useMemo(
    () => buildPageTreeProjectionItems(filteredPageNodes),
    [filteredPageNodes],
  );
  const flattenedPrivate = useMemo(
    () => filterVisiblePageTreeProjectionItems(privatePageRows, expanded),
    [expanded, privatePageRows],
  );
  const visibleFilteredPrivatePageRows = useMemo(
    () => filterVisiblePageTreeProjectionItems(filteredPrivatePageRows, expanded),
    [expanded, filteredPrivatePageRows],
  );
  const filteredTrash = useMemo(() => {
    const keyword = trashSearch.trim().toLowerCase();
    if (!keyword) {
      return sidebarData.trashedDocuments;
    }
    return sidebarData.trashedDocuments.filter((item) =>
      (item.title ?? "无标题").toLowerCase().includes(keyword),
    );
  }, [sidebarData.trashedDocuments, trashSearch]);

  const filteredTrashedMediaAssets = useMemo(() => {
    const assets = [
      ...(sidebarData.trashedMediaAssets ?? []),
      ...(sidebarData.trashedMindmapAssets ?? []),
      ...(sidebarData.trashedTableAssets ?? []),
    ];
    const keyword = trashSearch.trim().toLowerCase();
    if (!keyword) {
      return assets;
    }
    return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
  }, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, sidebarData.trashedTableAssets, trashSearch]);

  const mindmapChildrenSnapshot = useMemo(() => {
    const mapping = sidebarData.mindmapAssetChildren ?? {};
    const mindmapDocById = new Map<string, string>(
      (mindmapAssets ?? [])
        .filter((asset) => asset.asset_type === "mindmap")
        .map((asset) => [asset.id, asset.document_id]),
    );
    const mindmapIds = new Set(mindmapDocById.keys());

    const mediaById = new Map<string, MediaAsset>(
      (mediaAssets ?? []).map((asset) => [asset.id, asset]),
    );

    const childAssetsByMindmapId: Record<string, MediaAsset[]> = {};
    const childIds = new Set<string>();
    const assigned = new Set<string>();

    // 物理目录：storage_path 归属到 mindmaps/<mindmapId>/ 的附件，作为导图文件夹内容
    (mediaAssets ?? []).forEach((asset) => {
      const sp = asset.storage_path;
      if (!sp || typeof sp !== "string") return;
      const mindmapId = extractMindmapIdFromStoragePath(sp);
      if (!mindmapId) return;
      if (!mindmapIds.has(mindmapId)) return;
      const docId = mindmapDocById.get(mindmapId);
      if (docId && asset.document_id !== docId) return;
      if (assigned.has(asset.id)) return;
      assigned.add(asset.id);
      childIds.add(asset.id);
      if (!childAssetsByMindmapId[mindmapId]) childAssetsByMindmapId[mindmapId] = [];
      childAssetsByMindmapId[mindmapId].push(asset);
    });

    // 引用图片：从 mindmap JSON 解析出的 assetIds，也放到导图文件夹下（去重）
    (mindmapAssets ?? []).forEach((mindmapAsset) => {
      const ids = mapping[mindmapAsset.id] ?? [];
      if (!Array.isArray(ids) || ids.length === 0) return;
      ids.forEach((id) => {
        const asset = mediaById.get(id);
        if (!asset) return;
        if (asset.document_id !== mindmapAsset.document_id) return;
        if (assigned.has(asset.id)) return;
        assigned.add(asset.id);
        childIds.add(asset.id);
        if (!childAssetsByMindmapId[mindmapAsset.id]) childAssetsByMindmapId[mindmapAsset.id] = [];
        childAssetsByMindmapId[mindmapAsset.id].push(asset);
      });
    });

    return { childAssetsByMindmapId, childIds };
  }, [mediaAssets, mindmapAssets, sidebarData.mindmapAssetChildren]);

  const assetsByDoc = useMemo(() => {
    const map: Record<string, MediaAsset[]> = {};
    const assets = [
      ...((mediaAssets ?? []).filter(
        (asset) => !mindmapChildrenSnapshot.childIds.has(asset.id),
      )),
      ...(mindmapAssets ?? []),
      ...(tableAssets ?? []),
    ];

    assets.forEach((asset) => {
      if (!map[asset.document_id]) {
        map[asset.document_id] = [];
      }
      const exists = map[asset.document_id].some((a) => a.id === asset.id && a.asset_type === asset.asset_type);
      if (!exists) {
        map[asset.document_id].push(asset);
      }
    });
    return map;
  }, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]);

  const assetById = useMemo(() => {
    const map = new Map<string, MediaAsset>();
    [...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
      map.set(asset.id, asset);
    });
    return map;
  }, [mediaAssets, mindmapAssets, tableAssets]);

  const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());

  const resourceRows = useMemo(
    () =>
      buildVisibleRows({
        fileTreeItems:
          filter.trim().length === 0
            ? sidebarData.kernelFileTreeProjection.items
            : undefined,
        pageRows: visibleFilteredPrivatePageRows,
        expanded,
        assetsByDoc,
        assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
        expandedAssetFolderIds: expandedAssetFolders,
        nodeById,
        assetById,
      }),
    [
      assetById,
      assetsByDoc,
      expanded,
      expandedAssetFolders,
      mindmapChildrenSnapshot.childAssetsByMindmapId,
      nodeById,
      sidebarData.kernelFileTreeProjection.items,
      visibleFilteredPrivatePageRows,
      filter,
    ],
  );

  const resourceVisibleRowIds = useMemo(() => resourceRows.map((row) => row.rowId), [resourceRows]);
  const resourceRowById = useMemo(() => new Map(resourceRows.map((row) => [row.rowId, row])), [resourceRows]);

  useEffect(() => {
    setResourceSelection((prev) => normalizeTreePaneSelectionForVisibleRows(prev, resourceVisibleRowIds));
  }, [resourceVisibleRowIds]);

  const docParentById = useMemo(
    () =>
      buildParentById(
        (sidebarData.documents ?? []).map((doc) => ({
          id: doc.id,
          parentId: doc.parent_id ?? null,
        })),
      ),
    [sidebarData.documents],
  );

  const childrenCountByParentId = useMemo(() => {
    const map = new Map<string | null, number>();
    (sidebarData.documents ?? []).forEach((doc) => {
      const parentId = doc.parent_id ?? null;
      map.set(parentId, (map.get(parentId) ?? 0) + 1);
    });
    return map;
  }, [sidebarData.documents]);

  const activeWorkspace =
    sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
    sidebarData.workspaces[0];

  const handleOpenDocument = useCallback(
    (documentId: string, mode: "main" | "sidebar") => {
      const targetPath = `/documents/${documentId}`;
      if (mode === "main") {
        router.push(targetPath);
        setOpen(false);
        return;
      }
      if (typeof window !== "undefined") {
        const sidebarUrl = `${buildDocumentUrl(documentId)}?preview=sidebar`;
        window.open(sidebarUrl, "_blank", "noopener,noreferrer");
      }
    },
    [router, setOpen],
  );

  const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => {
    const url = buildDocumentUrl(node.id);
    const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
    await copyText(payload, includeTitle ? "标题 + 链接已复制" : "页面链接已复制");
  }, []);

  const handleCopyReference = useCallback(async (node: SidebarTreeNode, mode: "inline" | "embed") => {
    const template = mode === "inline" ? `((${node.id}))` : `{{${node.id}}}`;
    await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制");
  }, []);

  const handleCopyId = useCallback(async (node: SidebarTreeNode) => {
    await copyText(node.id, "页面 ID 已复制");
  }, []);

  const handleDuplicateDocument = useCallback(
    async (node: SidebarTreeNode) => {
      const response = await fetch("/api/documents/duplicate", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ documentId: node.id }),
      });
      if (!response.ok) {
        window.alert("复制失败，请稍后再试");
        return;
      }
      await refreshTree();
    },
    [refreshTree],
  );

  const openMoveEmbedPicker = useCallback((node: SidebarTreeNode, nextMode: MoveEmbedMode) => {
    setMoveEmbedSource(node);
    setMoveEmbedMode(nextMode);
    setMoveEmbedOpen(true);
  }, []);

  const handleEmbedPrompt = useCallback(async (node: SidebarTreeNode) => {
    openMoveEmbedPicker(node, "embed");
  }, [openMoveEmbedPicker]);

  const toggleExpand = useCallback((id: string) => {
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  }, []);

  const knownMindmapFolderIdsRef = useRef<Set<string>>(new Set());
  useEffect(() => {
    setExpandedAssetFolders((prev) => {
      const next = new Set(prev);
      (mindmapAssets ?? []).forEach((asset) => {
        if (asset.asset_type !== "mindmap") return;
        if (knownMindmapFolderIdsRef.current.has(asset.id)) return;
        knownMindmapFolderIdsRef.current.add(asset.id);
        next.add(asset.id);
      });
      return next;
    });
  }, [mindmapAssets]);

  const toggleAssetFolderExpand = useCallback((assetId: string) => {
    setExpandedAssetFolders((prev) => {
      const next = new Set(prev);
      if (next.has(assetId)) next.delete(assetId);
      else next.add(assetId);
      return next;
    });
  }, []);

  const handleOpenAsset = useCallback((asset: MediaAsset) => {
    if (asset.asset_type === "mindmap") {
      router.push(`/mindmap/${asset.document_id}/${asset.id}`);
      setOpen(false);
      return;
    }
    if (asset.asset_type === "luckysheet") {
      if (activeId && activeId === asset.document_id && editorBridge?.openTableFullScreen) {
        editorBridge.openTableFullScreen(asset.id);
        setOpen(false);
        return;
      }
      router.push(`/documents/${asset.document_id}?openTableId=${encodeURIComponent(asset.id)}`);
      setOpen(false);
      return;
    }
 
    const officeFileType = officeFileTypeFromAsset(asset.file_name ?? null, asset.mime_type ?? null);
    if (officeFileType) {
      const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
      if (!officeBase) {
        window.alert("缺少 ONLYOFFICE 地址，请在 .env.all 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
        return;
      }
      void (async () => {
        try {
          const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`);
          if (!res.ok) {
            const payload = await res.json().catch(() => null);
            throw new Error(payload?.error ?? "生成签名链接失败");
          }
          const { signedUrl } = (await res.json()) as { signedUrl: string };
          const target = new URL("/onlyoffice", window.location.origin);
          target.searchParams.set("fileUrl", signedUrl);
          target.searchParams.set("fileName", asset.file_name ?? `未命名.${officeFileType}`);
          target.searchParams.set("fileType", officeFileType);
          target.searchParams.set("assetId", asset.id);
          target.searchParams.set("documentId", asset.document_id);
          target.searchParams.set("mode", "edit");
          window.open(target.toString(), "_blank", "noopener,noreferrer");
          setOpen(false);
        } catch (error) {
          window.alert((error as Error).message);
        }
      })();
      return;
    }
 
    void (async () => {
      try {
        const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`);
        if (!res.ok) {
          const payload = await res.json().catch(() => null);
          throw new Error(payload?.error ?? "生成签名链接失败");
        }
        const { signedUrl } = (await res.json()) as { signedUrl: string };
        if (!signedUrl) {
          throw new Error("暂无可用的文件链接");
        }
        if (typeof window !== "undefined") {
          window.open(signedUrl, "_blank", "noopener,noreferrer");
        }
        setOpen(false);
      } catch {
        const fallback = asset.signed_url ?? asset.file_url;
        if (!fallback) {
          window.alert("暂无可用的文件链接");
          return;
        }
        if (typeof window !== "undefined") {
          window.open(fallback, "_blank", "noopener,noreferrer");
        }
        setOpen(false);
      }
    })();
  }, [activeId, editorBridge, router, setOpen]);

  const handleResourcePaneBlankMouseDown = useCallback(() => {
    setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
  }, []);

  const handleResourceRowClick = useCallback(
    (row: TreePaneRow, event: React.MouseEvent) => {
      setResourceSelection((prev) =>
        reduceTreePaneSelection(prev, {
          type: "click",
          rowId: row.rowId,
          visibleRowIds: resourceVisibleRowIds,
          modifiers: {
            shiftKey: event.shiftKey,
            ctrlKey: event.ctrlKey,
            metaKey: event.metaKey,
          },
        }),
      );

      // 与 VS Code 的单击打开不同：为了避免误触导致重资源文件（思维导图/表格等）被
      // 直接打开，我们在“无修饰键”的单击时只跳转到对应页面的 index.md（即文档本身）。
      if (event.button !== 0) return;
      if (event.shiftKey || event.ctrlKey || event.metaKey) return;

      const targetDocId = row.docId;
      if (!targetDocId) return;
      if (activeId && activeId === targetDocId) return;

      // doc/index/asset 都统一跳转到所属页面（index.md）
      handleOpenDocument(targetDocId, "main");
    },
    [activeId, resourceVisibleRowIds, handleOpenDocument],
  );

  const handleResourceRowDragStart = useCallback((row: TreePaneRow) => {
    setResourceSelection((prev) => {
      if (prev.selectedRowIds.has(row.rowId)) return prev;
      return reduceTreePaneSelection(prev, {
        type: "click",
        rowId: row.rowId,
        visibleRowIds: resourceVisibleRowIds,
        modifiers: { shiftKey: false, ctrlKey: false, metaKey: false },
      });
    });
  }, [resourceVisibleRowIds]);

  const handleResourceRowDoubleClick = useCallback(
    (row: TreePaneRow, _event?: React.MouseEvent) => {
      if (row.kind === "asset" || row.kind === "asset-folder") {
        handleOpenAsset(row.asset);
        return;
      }
      handleOpenDocument(row.docId, "main");
    },
    [handleOpenAsset, handleOpenDocument],
  );

  const handleResourceRowContextMenu = useCallback(
    (row: TreePaneRow, event: React.MouseEvent) => {
      event.preventDefault();
      event.stopPropagation();
      setResourceSelection((prev) =>
        reduceTreePaneSelection(prev, { type: "contextmenu", rowId: row.rowId }),
      );

      if (row.kind === "asset" || row.kind === "asset-folder") {
        setAssetMenu({ asset: row.asset, x: event.clientX, y: event.clientY }); 
        return;
      }

      setContextMenu({
        node: row.node,
        x: event.clientX,
        y: event.clientY,
      });
    },
    [],
  );

  const handlePageTreeShellNavigate = useCallback(
    (documentId: string) => {
      handleOpenDocument(documentId, "sidebar");
    },
    [handleOpenDocument],
  );

  const handleFileTreeShellNavigate = useCallback(
    (documentId: string) => {
      handleOpenDocument(documentId, "main");
    },
    [handleOpenDocument],
  );

  const handlePageTreeShellContextMenu = useCallback(
    (payload: { documentId: string; x: number; y: number }) => {
      const node = nodeById.get(payload.documentId);
      if (!node) {
        return;
      }
      setContextMenu({
        node,
        x: payload.x,
        y: payload.y,
      });
    },
    [nodeById],
  );

  const handleFileTreeShellContextMenu = useCallback(
    (payload: {
      documentId: string | null;
      assetId: string | null;
      rowId: string | null;
      rowKind: string | null;
      x: number;
      y: number;
    }) => {
      if (payload.assetId) {
        const asset = assetById.get(payload.assetId);
        if (asset) {
          setAssetMenu({
            asset,
            x: payload.x,
            y: payload.y,
          });
          return;
        }
      }

      const row = payload.rowId ? resourceRowById.get(payload.rowId) : null;
      const node =
        row && (row.kind === "doc" || row.kind === "index")
          ? row.node
          : payload.documentId
            ? nodeById.get(payload.documentId) ?? null
            : null;
      if (!node) {
        return;
      }
      setContextMenu({
        node,
        x: payload.x,
        y: payload.y,
      });
    },
    [assetById, nodeById, resourceRowById],
  );

  const handleFileTreeShellSelectionChange = useCallback(
    (payload: {
      selectedRowIds: string[];
      anchorRowId: string | null;
      focusedRowId: string | null;
    }) => {
      const visibleRowIds = resourceRows.map((row) => row.rowId);
      const normalized = normalizeTreePaneSelectionForVisibleRows(
        {
          selectedRowIds: new Set(
            payload.selectedRowIds.filter((rowId) => resourceRowById.has(rowId)),
          ),
          anchorRowId:
            payload.anchorRowId && resourceRowById.has(payload.anchorRowId)
              ? payload.anchorRowId
              : null,
          focusedRowId:
            payload.focusedRowId && resourceRowById.has(payload.focusedRowId)
              ? payload.focusedRowId
              : null,
        },
        visibleRowIds,
      );
      setResourceSelection(normalized);
    },
    [resourceRowById, resourceRows],
  );

  const handleFileTreeShellAssetOpen = useCallback(
    (payload: { assetId: string; documentId: string | null }) => {
      const asset = assetById.get(payload.assetId);
      if (!asset) {
        return;
      }
      handleOpenAsset(asset);
    },
    [assetById, handleOpenAsset],
  );

  const handleTreeShellMutation = useCallback(() => {
    void refreshTree();
  }, [refreshTree]);

  useEffect(() => {
    const handler = async (event: KeyboardEvent) => {
      const isCopy =
        (event.ctrlKey || event.metaKey) &&
        !event.altKey &&
        (event.key === "c" || event.key === "C");
      const isPaste =
        (event.ctrlKey || event.metaKey) &&
        !event.altKey &&
        (event.key === "v" || event.key === "V");

      if (!isCopy && !isPaste) {
        return;
      }

      if (isTextInputTarget(event.target)) {
        return;
      }

      const container = resourcePaneContainerRef.current;
      const activeElement = document.activeElement;
      if (!container || !activeElement || !container.contains(activeElement)) {
        return;
      }

      if (isCopy) {
        if (resourceSelection.selectedRowIds.size === 0) {
          return;
        }
        event.preventDefault();
        const orderedRowIds = resourceRows
          .filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
          .map((row) => row.rowId);
        await writeTreePaneClipboardPayload({
          type: "mnote-file-tree",
          version: 1,
          action: "copy",
          rowIds: orderedRowIds,
        });
        return;
      }

      if (isPaste) {
        event.preventDefault();
        const payload = await readTreePaneClipboardPayload();
        if (!payload || payload.rowIds.length === 0) {
          return;
        }

        const targetDocId = inferPasteTargetDocId({
          focusedRowId: resourceSelection.focusedRowId,
          rowById: resourceRowById,
          activeDocId: activeId || null,
        });
        if (!targetDocId) {
          setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0);
          return;
        }

        const rows = payload.rowIds
          .map((rowId) => resourceRowById.get(rowId as any))
          .filter(Boolean) as TreePaneRow[];

        const docItemsMap = new Map<string, boolean>();
        rows.forEach((row) => {
          if (row.kind === "doc") {
            docItemsMap.set(row.docId, true);
          } else if (row.kind === "index") {
            if (!docItemsMap.has(row.docId)) {
              docItemsMap.set(row.docId, false);
            }
          }
        });

        if (docItemsMap.size > 0) {
          try {
            await copyTreeCommand({
              items: Array.from(docItemsMap.entries()).map(([documentId, recursive]) => ({
                documentId,
                recursive,
              })),
              targetParentId: targetDocId,
            });
          } catch (error) {
            const message = error instanceof Error ? error.message : "粘贴页面失败";
            setTimeout(() => window.alert(message), 0);
            return;
          }
          await sidebarQuery.refetch();
          emitDocumentsChanged(targetDocId);
        }

        const assetRows = rows.filter((row) => row.kind === "asset") as Extract<TreePaneRow, { kind: "asset" }>[];
        const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);

        if (copyableAssetIds.length > 0) {
          const resp = await fetch("/api/media/batch", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
              action: "copy",
              assetIds: copyableAssetIds,
              targetDocumentId: targetDocId,
            }),
          });
          if (!resp.ok) {
            const data = await resp.json().catch(() => ({}));
            setTimeout(() => window.alert(data?.error ?? "粘贴附件失败"), 0);
            return;
          }
          await sidebarQuery.refetch();
          emitAssetsChanged(targetDocId);
        } else if (docItemsMap.size === 0) {
          setTimeout(() => window.alert("没有可粘贴的真实文件（mindmap.json 等虚拟附件暂不支持）"), 0);
        }
      }
    };

    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, [
    activeId,
    resourceRowById,
    resourceRows,
    resourceSelection.focusedRowId,
    resourceSelection.selectedRowIds,
    sidebarQuery,
  ]);

  const handleCopyAssetLink = useCallback(
    async (asset: MediaAsset) => {
      if (asset.asset_type === "mindmap") {
        await copyText(buildMindmapUrl(asset.document_id, asset.id), "思维导图链接已复制");
        return;
      }
      if (asset.asset_type === "luckysheet") {
        await copyText(buildTableUrl(asset.id), "表格链接已复制");
        return;
      }
      const url = asset.signed_url ?? asset.file_url ?? "";
      if (!url) {
        window.alert("暂无可用的文件链接");
        return;
      }
      await copyText(url, "附件链接已复制");
    },
    [],
  );

  const handleCopyAssetPath = useCallback(async (asset: MediaAsset) => {        
    const path =
      asset.asset_type === "mindmap"
        ? ((asset.file_url ? asset.file_url.replace(/^\//, "") : "") ||
          `documents/${asset.document_id}/${asset.file_name ?? "mindmap.json"}`)
        : asset.asset_type === "luckysheet"
          ? (`tables/${asset.id}`)
        : asset.storage_path || asset.file_url || asset.file_name || "附件";    
    await copyText(path, "存储路径已复制");
  }, []);

  const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
    const current = useCurrentDocumentStore.getState();
    if (
      current.disableDownload &&
      current.documentId &&
      asset.document_id &&
      String(asset.document_id) === String(current.documentId)
    ) {
      window.alert("该页面已禁止下载");
      return;
    }
    if (asset.asset_type === "mindmap") {
      const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`);
      if (!resp.ok) {
        window.alert("下载失败");
        return;
      }
      const payload = await resp.json().catch(() => null);
      const data = payload?.data ?? {};
      const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = asset.file_name ?? "mindmap.json";
      a.click();
      URL.revokeObjectURL(url);
      return;
    }
    if (asset.asset_type === "luckysheet") {
      window.alert("在线表格暂不支持下载（后续可做导出 JSON/Excel）");
      return;
    }
    try {
      const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`);
      if (!res.ok) {
        const payload = await res.json().catch(() => null);
        throw new Error(payload?.error ?? "生成签名链接失败");
      }
      const { signedUrl } = (await res.json()) as { signedUrl: string };
      if (!signedUrl) {
        throw new Error("暂无可用的下载链接");
      }
      if (typeof window !== "undefined") {
        window.open(signedUrl, "_blank", "noopener,noreferrer");
      }
    } catch {
      const fallback = asset.signed_url ?? asset.file_url;
      if (!fallback) {
        window.alert("暂无可用的下载链接");
        return;
      }
      if (typeof window !== "undefined") {
        window.open(fallback, "_blank", "noopener,noreferrer");
      }
    }
  }, []);

  const handleRenameAsset = useCallback(
    async (asset: MediaAsset) => {
      if (asset.asset_type === "mindmap") {
        window.alert("思维导图暂不支持重命名");
        return;
      }
      if (asset.asset_type === "luckysheet") {
        const currentTitle =
          (asset.file_name ?? "").toLowerCase().endsWith(".luckysheet")
            ? (asset.file_name ?? "").slice(0, -".luckysheet".length)
            : (asset.file_name ?? "");
        const input = window.prompt("输入新表格名", currentTitle);
        if (!input || !input.trim()) return;
        const newTitle = input.trim();
        const resp = await fetch(`/api/tables/${asset.id}`, {
          method: "PATCH",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ title: newTitle }),
        });
        if (!resp.ok) {
          const payload = await resp.json().catch(() => ({}));
          window.alert(payload?.error ?? "重命名失败");
          return;
        }
        window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId: asset.id } }));
        await sidebarQuery.refetch();
        setAssetMenu(null);
        return;
      }
      const input = window.prompt("输入新文件名", asset.file_name ?? "");       
      if (!input || !input.trim()) return;
      const newName = input.trim();
      const resp = await fetch("/api/media/batch", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ action: "rename", assetIds: [asset.id], newName }),
      });
      if (!resp.ok) {
        const payload = await resp.json().catch(() => ({}));
        window.alert(payload?.error ?? "重命名失败");
        return;
      }
      await sidebarQuery.refetch();
      setAssetMenu(null);
      emitAssetsChanged(asset.document_id);
    },
    [sidebarQuery],
  );

  const handleMoveAsset = useCallback(
    async (asset: MediaAsset) => {
      if (asset.asset_type === "mindmap") {
        window.alert("思维导图文件无需移动，请在页面中直接编辑");
        return;
      }
      if (asset.asset_type === "luckysheet") {
        window.alert("在线表格暂不支持移动（后续可实现跨页面迁移）");
        return;
      }
      const target = window.prompt("输入目标页面 ID", asset.document_id);       
      if (!target || !target.trim()) return;
      const resp = await fetch("/api/media/batch", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          action: "move",
          assetIds: [asset.id],
          targetDocumentId: target.trim(),
        }),
      });
      if (!resp.ok) {
        const payload = await resp.json().catch(() => ({}));
        window.alert(payload?.error ?? "移动失败");
        return;
      }
      await sidebarQuery.refetch();
      setAssetMenu(null);
      emitAssetsChanged(asset.document_id);
      emitAssetsChanged(target.trim());
    },
    [sidebarQuery],
  );

  const handleDeleteAssets = useCallback(
    async (assetIds: string[], assetHint?: MediaAsset | MediaAsset[]) => {
      const uniqueAssetIds = Array.from(new Set(assetIds));
      const assets = uniqueAssetIds
        .map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
        .filter(Boolean) as MediaAsset[];

      const hints = Array.isArray(assetHint) ? assetHint : assetHint ? [assetHint] : [];
      hints.forEach((hint) => {
        if (!hint) return;
        if (!assets.find((item) => item.id === hint.id)) {
          assets.unshift(hint);
        }
      });

      const mindmapAssetsToDelete = assets.filter((item) => item.asset_type === "mindmap");
      const tableAssetsToDelete = assets.filter((item) => item.asset_type === "luckysheet");
      const fileAssetsToDelete = assets.filter(
        (item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet",
      );
      const mindmapIdsByDocId = new Map<string, string[]>();
      mindmapAssetsToDelete.forEach((item) => {
        const prev = mindmapIdsByDocId.get(item.document_id) ?? [];
        prev.push(item.id);
        mindmapIdsByDocId.set(item.document_id, prev);
      });
      const fileAssetIdsByDocId = new Map<string, string[]>();
      fileAssetsToDelete.forEach((item) => {
        const prev = fileAssetIdsByDocId.get(item.document_id) ?? [];
        prev.push(item.id);
        fileAssetIdsByDocId.set(item.document_id, prev);
      });
      const fileAssetIds = Array.from(fileAssetIdsByDocId.values()).flat();

      for (const asset of mindmapAssetsToDelete) {
        const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`, { method: "DELETE" });
        if (!resp.ok) {
          const payload = await resp.json().catch(() => ({}));
          window.alert(payload?.error ?? "删除思维导图失败");
          return;
        }
      }

      for (const asset of tableAssetsToDelete) {
        const resp = await fetch(`/api/tables/${asset.id}`, { method: "DELETE" });
        if (!resp.ok) {
          const payload = await resp.json().catch(() => ({}));
          window.alert(payload?.error ?? "删除在线表格失败");
          return;
        }
        window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId: asset.id } }));
      }

      if (fileAssetIds.length > 0) {
        const resp = await fetch("/api/media/batch", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ action: "delete", assetIds: fileAssetIds }),
        });
        if (!resp.ok) {
          const payload = await resp.json().catch(() => ({}));
          window.alert(payload?.error ?? "删除失败");
          return;
        }
      }

      if (uniqueAssetIds.length > 0) {
        const mindmapSet = new Set(mindmapAssetsToDelete.map((item) => item.id));
        const tableSet = new Set(tableAssetsToDelete.map((item) => item.id));
        const fileSet = new Set(fileAssetsToDelete.map((item) => item.id));
        setMediaAssets((prev) => prev.filter((item) => !fileSet.has(item.id)));
        setMindmapAssets((prev) => prev.filter((item) => !mindmapSet.has(item.id)));
        setTableAssets((prev) => prev.filter((item) => !tableSet.has(item.id)));
      }
      setAssetMenu(null);
      mindmapIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, undefined, false, ids));
      fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids));
      await refreshTree();
    },
    [mediaAssets, mindmapAssets, refreshTree, tableAssets],
  );

  const handleDeleteResourceSelection = useCallback(async () => {
    const { docIds, assetIds } = computeTreePaneDeleteTargets({
      visibleRows: resourceRows,
      selectedRowIds: resourceSelection.selectedRowIds,
      parentById: docParentById,
    });

    if (docIds.length === 0 && assetIds.length === 0) {
      return;
    }

    const docText = docIds.length > 0 ? `${docIds.length} 个页面（删除到垃圾桶）` : "";

    // 说明：文件树里可能展示“思维导图子文件”等动态资源（不一定在 mindmapAssets 列表里）。
    // 为避免出现“看起来选中了，但删除不生效”，这里优先从可见行里拿到选中资源的完整元数据。
    const isAssetRow = (
      row: TreePaneRow,
    ): row is Extract<TreePaneRow, { kind: "asset" | "asset-folder" }> =>
      row.kind === "asset" || row.kind === "asset-folder";

    const selectedAssetHints = Array.from(
      new Map(
        resourceRows
          .filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
          .filter(isAssetRow)
          .map((row) => [row.asset.id, row.asset] as const),
      ).values(),
    );

    const mindmapCount = selectedAssetHints.filter((item) => item.asset_type === "mindmap").length;
    const tableCount = selectedAssetHints.filter((item) => item.asset_type === "luckysheet").length;
    const fileCount = selectedAssetHints.filter(
      (item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet",
    ).length;
    const unknownCount = Math.max(0, assetIds.length - mindmapCount - tableCount - fileCount);
    const assetTextParts: string[] = [];
    if (fileCount > 0) assetTextParts.push(`${fileCount} 个附件（删除，10 分钟内可撤销）`);
    if (mindmapCount > 0) assetTextParts.push(`${mindmapCount} 个思维导图（移入垃圾桶，10 分钟内可恢复）`);
    if (tableCount > 0) assetTextParts.push(`${tableCount} 个在线表格（删除）`);
    if (unknownCount > 0) assetTextParts.push(`${unknownCount} 个对象（删除）`);
    const assetText = assetTextParts.length > 0 ? assetTextParts.join(" + ") : "";
    const joinText = docText && assetText ? " + " : "";
    const ok = window.confirm(`确认删除选中的 ${docText}${joinText}${assetText} 吗？`);
    if (!ok) return;

    try {
      if (docIds.length > 0) {
        const results = await Promise.all(
          docIds.map(async (documentId) => {
            try {
              await deleteDocumentCommand({
                documentId,
                workspaceId: sidebarData.activeWorkspaceId ?? null,
              });
              return { documentId, ok: true };
            } catch {
              return { documentId, ok: false };
            }
          }),
        );
        const failed = results.filter((item) => !item.ok).map((item) => item.documentId);
        if (failed.length > 0) {
          window.alert(`部分页面删除失败：${failed.slice(0, 5).join(", ")}${failed.length > 5 ? "…" : ""}`);
          return;
        }

        docIds.forEach((documentId) => emitDocumentsChanged(documentId));
        if (activeId && docIds.includes(activeId)) {
          router.push("/");
        }
      }

      if (assetIds.length > 0) {
        await handleDeleteAssets(assetIds, selectedAssetHints);
      }

      await refreshTree();
      setContextMenu(null);
      setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
    } catch (error) {
      window.alert(error instanceof Error ? error.message : "删除失败");
    }
  }, [
    activeId,
    docParentById,
    resourceRows,
    resourceSelection.selectedRowIds,
    handleDeleteAssets,
    mediaAssets,
    mindmapAssets,
    tableAssets,
    refreshTree,
    router,
  ]);

  const handleResizeStart = useCallback(
    (event: React.MouseEvent) => {
      event.preventDefault();
      const startX = event.clientX;
      const startWidth = width;
      const onMove = (moveEvent: MouseEvent) => {
        const delta = moveEvent.clientX - startX;
        const targetWidth = Math.min(420, Math.max(240, startWidth + delta));
        setWidth(targetWidth);
      };
      const onUp = () => {
        document.removeEventListener("mousemove", onMove);
        document.removeEventListener("mouseup", onUp);
      };
      document.addEventListener("mousemove", onMove);
      document.addEventListener("mouseup", onUp);
    },
    [setWidth, width],
  );

  const handleCreate = useCallback(
    async (parentId: string | null) => {
      const creatingKey = parentId ?? "__root__";
      if (creatingDocumentUnderParentRef.current.has(creatingKey)) {
        return;
      }
      creatingDocumentUnderParentRef.current.add(creatingKey);
      try {
        const payload = (await createDocumentCommand(parentId)) as SidebarTreeNode;
        const nextNode: SidebarTreeNode = {
          ...payload,
          access_scope: payload.access_scope ?? "private",
          is_template: payload.is_template ?? false,
          updated_at: payload.updated_at ?? payload.created_at,
          title: payload.title ?? "无标题",
          children: [],
          kernel: {
            nodeType: "page",
            depth: 0,
            position: payload.sort_order ?? null,
            childCount: 0,
            expandedByDefault: true,
          },
        };

        setTree((prev) => {
          // 防止同一节点被插入多次（会导致重复 key / 列表出现“同一个页面两条记录”）
          const exists = (nodes: SidebarTreeNode[]): boolean => {
            for (const node of nodes) {
              if (node.id === nextNode.id) return true;
              if (node.children.length > 0 && exists(node.children)) return true;
            }
            return false;
          };
          if (exists(prev)) return prev;
          return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
        });
        setExpanded((prev) => {
          const next = new Set(prev);
          if (parentId) {
            next.add(parentId);
          }
          if (!parentId) {
            next.add(nextNode.id);
          }
          return next;
        });

        await refreshTree();
        const query = new URLSearchParams();
        if (nextNode.workspace_id) {
          query.set("workspaceId", nextNode.workspace_id);
        }
        const nextQuery = query.toString();
        router.push(
          nextQuery
            ? `/documents/${nextNode.id}?${nextQuery}`
            : `/documents/${nextNode.id}`,
        );
      } catch (error) {
        window.alert(error instanceof Error ? error.message : "新建页面失败，请稍后再试");
      } finally {
        creatingDocumentUnderParentRef.current.delete(creatingKey);
      }
    },
    [refreshTree, router],
  );

  const handleRename = useCallback(
    async (documentId: string, currentTitle: string | null) => {
      const title = window.prompt("输入新的标题", currentTitle ?? "无标题") ?? "";
      if (!title.trim()) {
        return;
      }
      try {
        await renameDocumentCommand({
          documentId,
          workspaceId: sidebarData.activeWorkspaceId ?? null,
          title: title.trim(),
        });
      } catch (error) {
        window.alert(error instanceof Error ? error.message : "重命名失败，请稍后再试");
        return;
      }
      await refreshTree();
    },
    [refreshTree, sidebarData.activeWorkspaceId],
  );

  const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
    const cloned = cloneNodes(currentTree);
    const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
    if (!removed) {
      return currentTree;
    }
    const next = insertNode(withoutTarget, parentId, index, removed);
    return next;
  }, []);

  const handleMove = useCallback(
    async (documentId: string, parentId: string | null, index: number) => {
      setTree((prev) => moveLocalNode(prev, documentId, parentId, index));
      if (parentId) {
        setExpanded((prev) => new Set(prev).add(parentId));
      }
      try {
        await moveDocumentCommand({
          documentId,
          parentId,
          position: index,
        });
      } catch (error) {
        window.alert(error instanceof Error ? error.message : "移动失败，请稍后再试");
        await refreshTree();
        return;
      }
      await refreshTree();
    },
    [moveLocalNode, refreshTree, setExpanded],
  );

  const handleResourcePaneDropFiles = useCallback(
    (docId: string, files: FileList, targetRow?: TreePaneRow) => {
      void (async () => {
        const droppedFiles = Array.from(files ?? []);
        if (droppedFiles.length === 0) return;

        const targetMindmapId = (() => {
          if (!targetRow) return null;
          if (targetRow.kind === "asset-folder" && targetRow.asset.asset_type === "mindmap") {
            return targetRow.asset.id;
          }
          if (targetRow.kind === "asset") {
            return extractMindmapIdFromStoragePath(targetRow.asset.storage_path);
          }
          return null;
        })();

        if (targetMindmapId) {
          setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
        }

        const inferredTargetDocId =
          docId ||
          inferPasteTargetDocId({
            focusedRowId: resourceSelection.focusedRowId,
            rowById: resourceRowById,
            activeDocId: activeId || null,
          }) ||
          "";

        if (!inferredTargetDocId) {
          setTimeout(() => window.alert("请选择一个目标页面后再拖入文件"), 0);
          return;
        }

        const targetDoc = sidebarData.documents.find((doc) => doc.id === inferredTargetDocId) ?? null;
        const workspaceId = targetDoc?.workspace_id ?? sidebarData.activeWorkspaceId ?? "";
        if (!workspaceId) {
          setTimeout(() => window.alert("无法识别当前工作区，上传失败"), 0);
          return;
        }

        const errors: string[] = [];
        for (const file of droppedFiles) {
          try {
            const form = new FormData();
            form.append("file", file);
            form.append("workspaceId", workspaceId);
            form.append("documentId", inferredTargetDocId);
            if (targetMindmapId) {
              form.append("mindmapId", targetMindmapId);
            }
            const resp = await fetch("/api/media/upload", { method: "POST", body: form });
            if (!resp.ok) {
              const payload = await resp.json().catch(() => ({}));
              errors.push(`${file.name}: ${payload?.error ?? "上传失败"}`);
              continue;
            }
            const payload = (await resp.json()) as { asset?: MediaAsset };
            if (payload.asset?.id) {
              emitAssetsChanged(inferredTargetDocId, payload.asset);
              // 拖拽文件进文件树：如果落点就是当前打开的页面，则把文件也插入到主编辑区
              if (inferredTargetDocId === activeId && !targetMindmapId) {
                editorBridge?.insertMediaAsset?.(payload.asset);
              }
            } else {
              errors.push(`${file.name}: 返回数据缺少 asset`);
            }
          } catch (err) {
            errors.push(`${file.name}: ${(err as Error).message}`);
          }
        }

        await sidebarQuery.refetch();

        if (errors.length > 0) {
          setTimeout(() => {
            const shown = errors.slice(0, 6).join("\n");
            window.alert(
              errors.length === 1
                ? `部分文件上传失败：\n${shown}`
                : `部分文件上传失败（${errors.length}个）：\n${shown}${errors.length > 6 ? "\n..." : ""}`,
            );
          }, 0);
        }
      })();
    },
    [
      activeId,
      editorBridge,
      resourceRowById,
      resourceSelection.focusedRowId,
      sidebarData.activeWorkspaceId,
      sidebarData.documents,
      sidebarQuery,
    ],
  );

  const handleResourcePaneInternalDrop = useCallback(
    (args: { targetRow: TreePaneRow; rowIds: string[]; copy: boolean }) => {    
      void (async () => {
        const targetDocId = inferDropTargetDocId(args.targetRow);
        if (!targetDocId) {
          setTimeout(() => window.alert("无法识别拖拽目标页面"), 0);
          return;
        }

        const targetMindmapId = (() => {
          if (args.targetRow.kind === "asset-folder" && args.targetRow.asset.asset_type === "mindmap") {
            return args.targetRow.asset.id;
          }
          if (args.targetRow.kind === "asset") {
            return extractMindmapIdFromStoragePath(args.targetRow.asset.storage_path);
          }
          return null;
        })();

        const targetSubPath = targetMindmapId ? `mindmaps/${targetMindmapId}` : undefined;

        if (targetMindmapId) {
          setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
        }

        const uniqueRowIds: string[] = [];
        const seen = new Set<string>();
        args.rowIds.forEach((id) => {
          if (!id || seen.has(id)) return;
          seen.add(id);
          uniqueRowIds.push(id);
        });

        const rows = uniqueRowIds
          .map((rowId) => resourceRowById.get(rowId as any))
          .filter(Boolean) as TreePaneRow[];

        const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId);
        const assetRows = rows.filter((row) => row.kind === "asset") as Extract<TreePaneRow, { kind: "asset" }>[];
        const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);

        if (docIds.length === 0 && copyableAssetIds.length === 0) {
          setTimeout(() => window.alert("没有可拖拽的对象（虚拟附件暂不支持）"), 0);
          return;
        }

        if (args.copy) {
          if (docIds.length > 0) {
            try {
              await copyTreeCommand({
                items: docIds.map((documentId) => ({ documentId, recursive: true })),
                targetParentId: targetDocId,
              });
            } catch (error) {
              const message = error instanceof Error ? error.message : "复制页面失败";
              setTimeout(() => window.alert(message), 0);
              return;
            }
            await sidebarQuery.refetch();
            emitDocumentsChanged(targetDocId);
          }

          if (copyableAssetIds.length > 0) {
            const resp = await fetch("/api/media/batch", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({
                action: "copy",
                assetIds: copyableAssetIds,
                targetDocumentId: targetDocId,
                targetSubPath,
              }),
            });
            if (!resp.ok) {
              const payload = await resp.json().catch(() => ({}));
              setTimeout(() => window.alert(payload?.error ?? "复制附件失败"), 0);
              return;
            }
            await sidebarQuery.refetch();
            emitAssetsChanged(targetDocId);
          }

          return;
        }

        const topLevelDocIds = filterTopLevelDocIds(docIds, docParentById);
        if (topLevelDocIds.length > 0) {
          if (
            isInvalidDocDrop({
              sourceDocIds: topLevelDocIds,
              targetParentId: targetDocId,
              parentById: docParentById,
            })
          ) {
            setTimeout(() => window.alert("不能把页面移动到自身或其子页面中"), 0);
            return;
          }

          const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0;
          setTree((prev) => {
            let next = prev;
            topLevelDocIds.forEach((id, offset) => {
              next = moveLocalNode(next, id, targetDocId, baseIndex + offset);
            });
            return next;
          });
          setExpanded((prev) => new Set(prev).add(targetDocId));

          for (let i = 0; i < topLevelDocIds.length; i += 1) {
            await moveDocumentCommand({
              documentId: topLevelDocIds[i],
              parentId: targetDocId,
              position: baseIndex + i,
            });
          }
          await refreshTree();
          emitDocumentsChanged(targetDocId);
        }

        if (copyableAssetIds.length > 0) {
          const resp = await fetch("/api/media/batch", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
              action: "move",
              assetIds: copyableAssetIds,
              targetDocumentId: targetDocId,
              targetSubPath,
            }),
          });
          if (!resp.ok) {
            const payload = await resp.json().catch(() => ({}));
            setTimeout(() => window.alert(payload?.error ?? "移动附件失败"), 0);
            return;
          }
          await sidebarQuery.refetch();
          const sourceDocIds = new Set(assetRows.map((row) => row.asset.document_id));
          sourceDocIds.forEach((id) => emitAssetsChanged(id));
          emitAssetsChanged(targetDocId);
        }
      })();
    },
    [
      childrenCountByParentId,
      docParentById,
      resourceRowById,
      moveLocalNode,
      refreshTree,
      sidebarQuery,
    ],
  );

  const handleMovePrompt = useCallback(
    async (node: SidebarTreeNode) => {
      openMoveEmbedPicker(node, "move");
    },
    [openMoveEmbedPicker],
  );

  const handleDelete = useCallback(
    async (documentId: string) => {
      await deleteDocumentCommand({
        documentId,
        workspaceId: sidebarData.activeWorkspaceId ?? null,
      });
      await refreshTree();
      emitDocumentsChanged(documentId);
      if (activeId === documentId) {
        router.push("/");
      }
    },
    [activeId, refreshTree, router, sidebarData.activeWorkspaceId],
  );

  const handleDeleteFromContextMenuNode = useCallback(
    async (node: SidebarTreeNode) => {
      if (viewMode === "filesystem") {
        await handleDeleteResourceSelection();
        return;
      }

      const ok = window.confirm("确认删除该页面到垃圾桶吗？");
      if (!ok) return;

      try {
        await handleDelete(node.id);
      } catch (error) {
        window.alert(error instanceof Error ? error.message : "删除失败");
      }
    },
    [handleDelete, handleDeleteResourceSelection, viewMode],
  );

  const handleDeleteFromAssetContextMenu = useCallback(
    async (assetIds: string[], assetHint?: MediaAsset) => {
      const uniqueAssetIds = Array.from(new Set(assetIds));
      if (uniqueAssetIds.length === 0) return;

      const assets = uniqueAssetIds
        .map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
        .filter(Boolean) as MediaAsset[];
      if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
        assets.unshift(assetHint);
      }

      const mindmapCount = assets.filter((item) => item.asset_type === "mindmap").length;
      const tableCount = assets.filter((item) => item.asset_type === "luckysheet").length;
      const fileCount = assets.filter((item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet").length;
      const unknownCount = Math.max(0, uniqueAssetIds.length - mindmapCount - tableCount - fileCount);

      const parts: string[] = [];
      if (fileCount > 0) parts.push(`${fileCount} 个附件（删除，10 分钟内可撤销）`);
      if (mindmapCount > 0) parts.push(`${mindmapCount} 个思维导图（删除）`);
      if (tableCount > 0) parts.push(`${tableCount} 个在线表格（删除）`);
      if (unknownCount > 0) parts.push(`${unknownCount} 个对象（删除）`);

      const ok = window.confirm(`确认删除选中的 ${parts.join(" + ")} 吗？`);
      if (!ok) return;

      try {
        await handleDeleteAssets(uniqueAssetIds, assetHint);
        setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
      } catch (error) {
        window.alert(error instanceof Error ? error.message : "删除失败");
      }
    },
    [handleDeleteAssets, mediaAssets, mindmapAssets, tableAssets],
  );

  const handleConvertToChild = useCallback(
    async (documentId: string) => {
      const flat = flattenedPrivate;
      const currentIndex = flat.findIndex((item) => item.nodeId === documentId);
      if (currentIndex <= 0) {
        return;
      }
      const targetParentId = flat[currentIndex - 1].nodeId;
      await handleMove(documentId, targetParentId, flat[currentIndex - 1].childCount);
    },
    [flattenedPrivate, handleMove],
  );

  const confirmTrashAction = useCallback(
    (message: string) => {
      if (!trashConfirm) {
        return true;
      }
      return window.confirm(message);
    },
    [trashConfirm],
  );

  const handleRestoreFromTrash = useCallback(
    async (documentId: string) => {
      if (!confirmTrashAction("确认恢复该页面吗？")) {
        return;
      }
      await restoreDocumentCommand({
        documentId,
        workspaceId: sidebarData.activeWorkspaceId ?? null,
      });
      await Promise.all([sidebarQuery.refetch(), refreshTree()]);
    },
    [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery],
  );

  const handlePurgeFromTrash = useCallback(
    async (documentId: string) => {
      if (!confirmTrashAction("彻底删除后将无法找回，是否继续？")) {
        return;
      }
      await purgeDocumentCommand({ documentId });
      await Promise.all([sidebarQuery.refetch(), refreshTree()]);
    },
    [confirmTrashAction, refreshTree, sidebarQuery],
  );

  const handleEmptyTrash = useCallback(async () => {
    if (!sidebarData.activeWorkspaceId) {
      window.alert("暂无可清空的工作空间");
      return;
    }
    if (!confirmTrashAction("清空垃圾桶后将无法恢复，是否继续？")) {
      return;
    }
    setEmptyingTrash(true);
    try {
      const response = await fetch("/api/documents/empty-trash", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
      });
      if (!response.ok) {
        const payload = await response.json().catch(() => ({}));
        window.alert(payload?.error ?? "清空垃圾桶失败，请稍后再试");
        return;
      }
      await Promise.all([sidebarQuery.refetch(), refreshTree()]);
    } finally {
      setEmptyingTrash(false);
    }
  }, [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery]);

  const handleRestoreMediaAssetFromTrash = useCallback(
    async (assetId: string) => {
      if (!confirmTrashAction("确认恢复该附件吗？")) {
        return;
      }
      const assetHint =
        (filteredTrashedMediaAssets ?? []).find((a) => a.id === assetId) ??
        (sidebarData.trashedMediaAssets ?? []).find((a) => a.id === assetId) ??
        null;
      const response = await fetch("/api/media/batch", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ action: "restore", assetIds: [assetId] }),
      });
      if (!response.ok) {
        const payload = await response.json().catch(() => ({}));
        window.alert(payload?.error ?? "恢复附件失败，请稍后再试");
        return;
      }
      await Promise.all([sidebarQuery.refetch(), refreshTree()]);
      // 重要：删除附件时会同步移除主编辑区块；恢复后向编辑器广播“恢复事件”，由编辑器决定是否插入。
      let assetForInsert = assetHint as any;
      if (assetHint?.document_id && String(assetHint.document_id) === String(activeId)) {
        const fallback = (assetHint.signed_url ?? assetHint.file_url ?? "").trim();
        let fileUrl = fallback;
        if (!fileUrl) {
          try {
            const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`);
            if (res.ok) {
              const payload = (await res.json().catch(() => null)) as any;
              fileUrl = String(payload?.signedUrl ?? "").trim();
            }
          } catch {
            // ignore
          }
        }
        if (fileUrl) {
          assetForInsert = {
            ...(assetHint as any),
            id: assetId,
            file_url: fileUrl,
            thumbnail_url: (assetHint.thumbnail_url ?? fileUrl) as any,
          } as any;
          editorBridge?.insertMediaAsset?.(assetForInsert);
        }
      }
      if (assetHint?.document_id) {
        emitAssetsRestored({ docId: String(assetHint.document_id), kind: "media", assetId, asset: assetForInsert });
      }
    },
    [
      activeId,
      confirmTrashAction,
      editorBridge,
      filteredTrashedMediaAssets,
      refreshTree,
      sidebarData.trashedMediaAssets,
      sidebarQuery,
    ],
  );

  const handleRestoreTableFromTrash = useCallback(
    async (tableId: string) => {
      if (!confirmTrashAction("确认恢复该在线表格吗？")) {
        return;
      }
      const tableHint =
        (filteredTrashedMediaAssets ?? []).find((a) => a.id === tableId && a.asset_type === "luckysheet") ??
        (sidebarData.trashedTableAssets ?? []).find((a) => a.id === tableId) ??
        null;
      const response = await fetch("/api/tables/restore", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ tableId }),
      });
      if (!response.ok) {
        const payload = await response.json().catch(() => ({}));
        window.alert(payload?.error ?? "恢复在线表格失败，请稍后再试");
        return;
      }
      await Promise.all([sidebarQuery.refetch(), refreshTree()]);
      // 重要：删除在线表格会同步移除主编辑区块；恢复后若目标页面正打开，则把表格重新插入主编辑区。
      if (tableHint?.document_id && String(tableHint.document_id) === String(activeId)) {
        editorBridge?.insertOnlineTableAsset?.({ documentId: String(tableHint.document_id), tableId });
      }
      if (tableHint?.document_id) {
        emitAssetsRestored({ docId: String(tableHint.document_id), kind: "table", tableId });
      }
    },
    [
      activeId,
      confirmTrashAction,
      editorBridge,
      filteredTrashedMediaAssets,
      refreshTree,
      sidebarData.trashedTableAssets,
      sidebarQuery,
    ],
  );

  const handlePurgeMediaAssetFromTrash = useCallback(
    async (assetId: string) => {
      if (!confirmTrashAction("彻底删除后将无法恢复，是否继续？")) {
        return;
      }
      const response = await fetch("/api/media/purge", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ assetId }),
      });
      if (!response.ok) {
        const payload = await response.json().catch(() => ({}));
        window.alert(payload?.error ?? "彻底删除附件失败，请稍后再试");
        return;
      }
      await Promise.all([sidebarQuery.refetch(), refreshTree()]);
    },
    [confirmTrashAction, refreshTree, sidebarQuery],
  );

  const handlePurgeTableFromTrash = useCallback(
    async (tableId: string) => {
      if (!confirmTrashAction("彻底删除后将无法恢复，是否继续？")) {
        return;
      }
      const response = await fetch("/api/tables/purge", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ tableId }),
      });
      if (!response.ok) {
        const payload = await response.json().catch(() => ({}));
        window.alert(payload?.error ?? "彻底删除在线表格失败，请稍后再试");
        return;
      }
      await Promise.all([sidebarQuery.refetch(), refreshTree()]);
    },
    [confirmTrashAction, refreshTree, sidebarQuery],
  );

  const handleEmptyMediaTrash = useCallback(async () => {
    if (!sidebarData.activeWorkspaceId) {
      window.alert("暂无可清空的工作空间");
      return;
    }
    if (!confirmTrashAction("清空附件垃圾桶后将无法恢复，是否继续？")) {
      return;
    }
    setEmptyingTrash(true);
    try {
      const [mediaResp, mindmapResp, tableResp] = await Promise.all([
        fetch("/api/media/empty-trash", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
        }),
        fetch("/api/mindmap-trash/empty", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
        }),
        fetch("/api/tables/empty-trash", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
        }),
      ]);
      if (!mediaResp.ok) {
        const payload = await mediaResp.json().catch(() => ({}));
        window.alert(payload?.error ?? "清空附件垃圾桶失败，请稍后再试");
        return;
      }
      if (!mindmapResp.ok) {
        const payload = await mindmapResp.json().catch(() => ({}));
        window.alert(payload?.error ?? "清空思维导图垃圾桶失败，请稍后再试");
        return;
      }
      if (!tableResp.ok) {
        const payload = await tableResp.json().catch(() => ({}));
        window.alert(payload?.error ?? "清空在线表格垃圾桶失败，请稍后再试");
        return;
      }
      await Promise.all([sidebarQuery.refetch(), refreshTree()]);
    } finally {
      setEmptyingTrash(false);
    }
  }, [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery]);

  const handleRestoreMindmapFromTrash = useCallback(
    async (documentId: string, mindmapId: string) => {
      if (!confirmTrashAction("确认恢复该思维导图吗？")) {
        return;
      }
      const response = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ action: "restore" }),
      });
      if (!response.ok) {
        const payload = await response.json().catch(() => ({}));
        window.alert(payload?.error ?? "恢复思维导图失败，请稍后再试");
        return;
      }
      await Promise.all([sidebarQuery.refetch(), refreshTree()]);
      // 重要：删除思维导图会同步移除主编辑区块；恢复后若目标页面正打开，则把导图重新插入主编辑区。
      if (documentId && String(documentId) === String(activeId)) {
        editorBridge?.insertMindmapAsset?.({ documentId, mindmapId });
      }
      if (documentId) {
        emitAssetsRestored({ docId: String(documentId), kind: "mindmap", mindmapId });
      }
    },
    [activeId, confirmTrashAction, editorBridge, refreshTree, sidebarQuery],
  );

  const handlePurgeMindmapFromTrash = useCallback(
    async (documentId: string, mindmapId: string) => {
      if (!confirmTrashAction("彻底删除后将无法恢复，是否继续？")) {
        return;
      }
      const response = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ action: "purge" }),
      });
      if (!response.ok) {
        const payload = await response.json().catch(() => ({}));
        window.alert(payload?.error ?? "彻底删除思维导图失败，请稍后再试");
        return;
      }
      await Promise.all([sidebarQuery.refetch(), refreshTree()]);
    },
    [confirmTrashAction, refreshTree, sidebarQuery],
  );

  const handleSignOut = useCallback(async () => {
    if (signingOut) {
      return;
    }
    if (!window.confirm("确认退出登录吗？")) {
      return;
    }
    setSigningOut(true);
    try {
      await signOut();
      router.replace("/auth");
      router.refresh();
    } catch (error: any) {
      window.alert(`退出登录失败：${error?.message ?? "请稍后再试"}`);
    } finally {
      setSigningOut(false);
    }
  }, [router, signOut, signingOut]);

  const openContextMenu = useCallback((event: React.MouseEvent, node: SidebarTreeNode) => {
    event.preventDefault();
    event.stopPropagation();
    setContextMenu({
      node,
      x: event.clientX,
      y: event.clientY,
    });
  }, []);

  const openShareDialog = useCallback((node: SidebarTreeNode) => {
    setShareTarget({
      id: node.id,
      title: node.title ?? null,
      workspaceId: node.workspace_id,
      allowIncludeDescendants: (node.children?.length ?? 0) > 0,
    });
    setShareDialogOpen(true);
  }, []);

  const handleTopButtonClick = useCallback(
    (buttonId: (typeof TOP_BUTTONS)[number]["id"]) => {
      if (buttonId === "search") {
        openSearchPalette();
        return;
      }
      if (
        buttonId === "starred" ||
        buttonId === "public" ||
        buttonId === "shared" ||
        buttonId === "templates"
      ) {
        setViewMode("section");
        setTopPanel((prev) => (prev === buttonId ? null : buttonId));
        return;
      }
      if (buttonId === "members") {
        setGroupManagerOpen(true);
        return;
      }
      window.alert("该功能即将上线，敬请期待");
    },
    [
      openSearchPalette,
      setViewMode,
    ],
  );

  useEffect(() => {
    if (!contextMenu) {
      return;
    }
    const closeMenu = () => setContextMenu(null);
    window.addEventListener("click", closeMenu);
    return () => window.removeEventListener("click", closeMenu);
  }, [contextMenu]);

  const sidebarBody = (
    <div className="flex h-full min-w-0 flex-col">
      <div className="border-b border-[#f1f1f1] p-3">
        <div className="text-xs text-gray-400">当前工作空间</div>
        <div className="mt-1 flex items-center justify-between">
          <div>
            <div className="text-base font-semibold text-gray-900">{activeWorkspace?.name ?? "我的空间"}</div>
            <div className="text-xs text-gray-500">{activeWorkspace?.type === "team" ? "团队空间" : "个人空间"}</div>
          </div>
          <button
            type="button"
            className="rounded-md border border-[#e1e1e1] px-2 py-1 text-xs text-gray-400"
            onClick={(event) => {
              event.preventDefault();
              window.alert("工作空间切换功能暂时停用（正在修复中）。");
            }}
          >
            切换（暂停）
          </button>
        </div>
        <div className="mt-2 text-xs text-gray-400">
          提示：当前工作空间切换暂时停用；共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。
        </div>
        <div className="mt-2">
          <button
            type="button"
            className="flex items-center gap-2 rounded-md px-2 py-1 text-xs text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
            onClick={() => void handleSignOut()}
            disabled={signingOut}
          >
            <LogOut className="h-4 w-4" />
            <span>{signingOut ? "正在退出..." : "退出登录"}</span>
          </button>
        </div>
      </div>

      <div className="grid grid-cols-4 gap-2 border-b border-[#f1f1f1] p-3">
        {TOP_BUTTONS.map((button) => (
          <button
            key={button.id}
            type="button"
            title={button.label}
            aria-label={button.label}
            className="flex h-12 items-center justify-center rounded-md border border-transparent text-gray-500 hover:border-[#d6e3ff] hover:bg-[#f5f7fb] hover:text-[#2563eb]"
            onClick={() => handleTopButtonClick(button.id)}
          >
            <button.icon className="h-5 w-5" />
            <span className="sr-only">{button.label}</span>
          </button>
        ))}
      </div>

      {topPanel ? (
        <div className="border-b border-[#f1f1f1] bg-white px-3 pb-3">
          <div className="flex items-center gap-2 px-1 py-2 text-sm font-medium text-gray-600">
            {SECTION_ICONS[topPanel]}
            {topPanel === "starred"
              ? "星标置顶"
              : topPanel === "public"
                ? "公共页面"
                : topPanel === "shared"
                  ? "共享页面"
                  : "模板中心"}
            <span className="ml-auto text-xs text-gray-400">再次点击图标可收起</span>
          </div>
          <div className="max-h-52 overflow-auto rounded-md border border-[#eff2f6] bg-white px-2 py-2">
            {(() => {
              const renderList = (nodes: SidebarTreeNode[]) => {
                const flat = buildPageTreeProjectionItems(nodes);
                if (flat.length === 0) {
                  return <div className="px-2 py-2 text-xs text-gray-400">暂无内容</div>;
                }
                return (
                  <div className="space-y-1">
                    {flat.map((item) => (
                      <Link
                        key={item.nodeId}
                        href={`/documents/${item.nodeId}`}
                        className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
                        style={{ paddingLeft: 8 + item.depth * 12 }}
                      >
                        {item.title}
                      </Link>
                    ))}
                  </div>
                );
              };

              if (topPanel === "shared") {
                const groupByWorkspace = (
                  rows: Array<{
                    workspaceId: string;
                    workspaceName: string | null;
                    documentId: string;
                    documentTitle: string | null;
                    includeDescendants: boolean;
                    permission?: "read" | "edit";
                    sharedWithCount?: number;
                  }>,
                ) => {
                  const map = new Map<string, { workspaceName: string | null; rows: typeof rows }>();
                  for (const r of rows) {
                    const existing = map.get(r.workspaceId);
                    if (!existing) {
                      map.set(r.workspaceId, { workspaceName: r.workspaceName ?? null, rows: [r] });
                    } else {
                      existing.rows.push(r);
                    }
                  }
                  return Array.from(map.entries()).map(([workspaceId, v]) => ({
                    workspaceId,
                    workspaceName: v.workspaceName,
                    rows: v.rows,
                  }));
                };

                const renderShareRows = (rows: Array<any>) => {
                  if (!rows || rows.length === 0) {
                    return <div className="px-2 py-2 text-xs text-gray-400">暂无内容</div>;
                  }
                  const groups = groupByWorkspace(rows);
                  return (
                    <div className="space-y-3">
                      {groups.map((g) => (
                        <div key={g.workspaceId}>
                          <div className="px-2 pb-1 text-xs font-medium text-gray-500">
                            {g.workspaceName ?? g.workspaceId}
                          </div>
                          <div className="space-y-1">
                            {g.rows.map((r) => (
                              <Link
                                key={`${r.workspaceId}:${r.documentId}`}
                                href={`/documents/${r.documentId}`}
                                className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
                              >
                                {r.documentTitle || "无标题"}
                                {typeof r.permission === "string" ? (
                                  <span className="ml-2 text-xs text-gray-400">
                                    {r.permission === "edit" ? "可编辑" : "只读"}
                                  </span>
                                ) : null}
                                {typeof r.sharedWithCount === "number" ? (
                                  <span className="ml-2 text-xs text-gray-400">{r.sharedWithCount} 人</span>
                                ) : null}
                              </Link>
                            ))}
                          </div>
                        </div>
                      ))}
                    </div>
                  );
                };

                return (
                  <div className="space-y-3">
                    {shareSummaryError ? (
                      <div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">{shareSummaryError}</div>
                    ) : null}

                    <div>
                      <div className="px-2 pb-1 text-xs font-medium text-gray-500">
                        共享给我的（{shareSummary?.incoming?.length ?? 0}）
                      </div>
                      {renderShareRows(shareSummary?.incoming ?? [])}
                    </div>

                    <div className="border-t border-[#f1f1f1] pt-2">
                      <div className="px-2 pb-1 text-xs font-medium text-gray-500">
                        我共享出去的（{shareSummary?.outgoing?.length ?? 0}）
                      </div>
                      {renderShareRows(shareSummary?.outgoing ?? [])}
                    </div>
                  </div>
                );
              }

              if (topPanel === "public") {
                const toggleGroup = (groupId: string) => {
                  setOpenPublicGroups((prev) => {
                    const next = new Set(prev);
                    if (next.has(groupId)) next.delete(groupId);
                    else next.add(groupId);
                    return next;
                  });
                };

                return (
                  <div className="space-y-3">
                    {groupPublicError ? (
                      <div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">{groupPublicError}</div>
                    ) : null}

                    <div>
                      <div className="px-2 pb-1 text-xs font-medium text-gray-500">全员公开</div>
                      {renderList(publicNodes)}
                    </div>

                    <div className="border-t border-[#f1f1f1] pt-2">
                      <div className="px-2 pb-1 text-xs font-medium text-gray-500">群组公开</div>
                      {groupPublicSummary.length === 0 ? (
                        <div className="px-2 py-2 text-xs text-gray-400">暂无群组公开页面</div>
                      ) : (
                        <div className="space-y-1">
                          {groupPublicSummary.map((g) => {
                            const isOpen = openPublicGroups.has(g.groupId);
                            const nodes = publicGroupNodesByGroupId.get(g.groupId) ?? [];
                            return (
                              <div key={g.groupId} className="rounded-md border border-[#eff2f6]">
                                <button
                                  type="button"
                                  className="flex w-full items-center justify-between px-2 py-2 text-sm text-gray-600 hover:bg-gray-50"
                                  onClick={() => toggleGroup(g.groupId)}
                                >
                                  <span className="flex min-w-0 items-center gap-2">
                                    <ChevronRight
                                      className={cn(
                                        "h-4 w-4 text-gray-400 transition-transform",
                                        isOpen && "rotate-90",
                                      )}
                                    />
                                    <span className="truncate">{g.groupName ?? "未命名群组"}</span>
                                  </span>
                                  <span className="text-xs text-gray-400">{g.documents.length}</span>
                                </button>
                                {isOpen ? <div className="px-1 pb-2">{renderList(nodes)}</div> : null}
                              </div>
                            );
                          })}
                        </div>
                      )}
                    </div>
                  </div>
                );
              }

              const nodes = topPanel === "starred" ? starredNodes : templateNodes;
              return renderList(nodes);
            })()}
          </div>
        </div>
      ) : null}

      <div className="flex-1 min-w-0 overflow-hidden">
        <div className="flex h-full min-w-0 flex-col overflow-y-auto overflow-x-hidden">
          <div className="border-b border-[#f1f1f1] p-3">
            <div className="flex items-center gap-2 text-sm text-gray-500">
              <Library className="h-4 w-4" />
              页面树
            </div>
            <div className="mt-2 flex items-center gap-2">
              <Input
                className="h-8 flex-1 rounded-md border-[#eeeeee]"
                placeholder="搜索页面"
                value={filter}
                onChange={(event) => setFilter(event.target.value)}
              />
              <div className="flex rounded-md border border-[#e5e7eb] bg-white">
                <button
                  type="button"
                  className={cn(
                    "px-3 py-1 text-xs",
                    viewMode === "section" ? "bg-[#2563eb] text-white" : "text-gray-600",
                  )}
                  onClick={() => setViewMode("section")}
                >
                  分组
                </button>
                <button
                  type="button"
                  className={cn(
                    "px-3 py-1 text-xs",
                    viewMode === "filesystem" ? "bg-[#2563eb] text-white" : "text-gray-600",
                  )}
                  onClick={() => setViewMode("filesystem")}
                >
                  文件
                </button>
              </div>
            </div>
          </div>

          {viewMode === "section" ? (
            <>
              <div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
                <button
                  type="button"
                  className="flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-600"
                  onClick={() => toggleSection("private")}
                >
                  <span>私有 / 我的页面</span>
                  <MoreHorizontal className="h-4 w-4 text-gray-400" />
                </button>
                {!collapsedSections.private ? (
                  <div className="flex-1 px-1 pb-2">
                    <SidebarTreeSurface
                      mode="page"
                      rendererFamily={treeRendererFamily}
                      workspaceId={sidebarData.activeWorkspaceId ?? null}
                      treeShellEnabled={filter.trim().length === 0}
                      className="h-full"
                      rows={visibleFilteredPrivatePageRows}
                      expanded={expanded}
                      activeId={activeId}
                      onToggleExpand={toggleExpand}
                      onMove={handleMove}
                      onCreateChild={handleCreate}
                      onContextMenu={openContextMenu}
                      onNavigate={handlePageTreeShellNavigate}
                      onPageContextMenu={handlePageTreeShellContextMenu}
                      onTreeMutation={handleTreeShellMutation}
                    />
                  </div>
                ) : (
                  <div className="px-4 pb-2 text-xs text-gray-400">已折叠</div>
                )}
              </div>
            </>
          ) : (
            <div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
              <div className="flex-1 px-1 pb-2">
                <div
                  ref={resourcePaneContainerRef}
                  data-testid="file-tree-container"
                  className="h-full w-full min-w-0 overflow-x-hidden"
                >
                  <SidebarTreeSurface
                    mode="filetree"
                    rendererFamily={treeRendererFamily}
                    workspaceId={sidebarData.activeWorkspaceId ?? null}
                    treeShellEnabled={filter.trim().length === 0}
                    className="h-full"
                    rows={resourceRows}
                    activeId={activeId}
                    selectedRowIds={resourceSelection.selectedRowIds}
                    onRowClick={handleResourceRowClick}
                    onRowDoubleClick={(row, event) => handleResourceRowDoubleClick(row, event)}
                    onRowContextMenu={handleResourceRowContextMenu}
                    onRowDragStart={(row, event) => {
                      void event;
                      handleResourceRowDragStart(row);
                    }}
                    onToggleExpand={toggleExpand}
                    onToggleAssetFolderExpand={toggleAssetFolderExpand}
                    onCreateChild={handleCreate}
                    onBlankMouseDown={handleResourcePaneBlankMouseDown}
                    onDropFiles={handleResourcePaneDropFiles}
                    onInternalDrop={handleResourcePaneInternalDrop}
                    onNavigate={handleFileTreeShellNavigate}
                    onFileTreeContextMenu={handleFileTreeShellContextMenu}
                    onFileTreeSelectionChange={handleFileTreeShellSelectionChange}
                    onAssetOpen={handleFileTreeShellAssetOpen}
                    onTreeMutation={handleTreeShellMutation}
                  />
                </div>
              </div>
            </div>
          )}

          <div className="border-t border-[#f1f1f1] p-3">
            <Button className="w-full justify-center gap-2" variant="outline" onClick={() => handleCreate(null)}>
              <Plus className="h-4 w-4" />
              新建页面
            </Button>
            <button
              type="button"
              className="mt-3 flex w-full items-center justify-between rounded-md border border-[#e8e8e8] px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"
              onClick={() => setTrashOpen(true)}
            >
              <span className="flex items-center gap-2">
                <Trash2 className="h-4 w-4 text-gray-500" />
                垃圾桶
              </span>
              <span className="text-xs text-gray-400">
                {sidebarData.trashedDocuments.length +
                  (sidebarData.trashedMediaAssets?.length ?? 0) +
                  (sidebarData.trashedMindmapAssets?.length ?? 0) +
                  (sidebarData.trashedTableAssets?.length ?? 0)}{" "}
                条
              </span>
            </button>
          </div>
        </div>
      </div>
    </div>
  );

  return (
    <>
      {!sidebarHydrated ? null : null}
      <aside className="hidden h-full min-w-0 overflow-hidden md:flex shrink-0" style={{ width }}>
        <div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-r border-wolai-border bg-wolai-bg-sidebar">
          {sidebarBody}
        </div>
        <div className="w-1 cursor-col-resize bg-transparent" onMouseDown={handleResizeStart} />
      </aside>
      <Drawer open={open} onOpenChange={setOpen}>
        <DrawerContent className="max-h-[92vh]">
          <DrawerHeader className="text-left">
            <DrawerTitle>页面目录</DrawerTitle>
          </DrawerHeader>
          <div className="px-4 pb-6">{sidebarBody}</div>
        </DrawerContent>
      </Drawer>
      {contextMenu && (
        <ContextMenu
          contextMenu={contextMenu}
          onClose={() => setContextMenu(null)}
          onOpenRight={(node) => handleOpenDocument(node.id, "sidebar")}
          onShare={openShareDialog}
          onMove={handleMovePrompt}
          onEmbed={handleEmbedPrompt}
          onCopyLink={handleCopyLink}
          onCopyReference={handleCopyReference}
          onCopyId={handleCopyId}
          onDuplicate={handleDuplicateDocument}
          onRename={() => void handleRename(contextMenu.node.id, contextMenu.node.title)}
          onCreateChild={() => void handleCreate(contextMenu.node.id)}
          onConvertChild={() => void handleConvertToChild(contextMenu.node.id)}
          onDelete={() => void handleDeleteFromContextMenuNode(contextMenu.node)}
        />
      )}
      {shareTarget && (
        <DocumentShareDialog
          open={shareDialogOpen}
          onOpenChange={(nextOpen) => {
            setShareDialogOpen(nextOpen);
            if (!nextOpen) {
              setShareTarget(null);
            }
          }}
          documentId={shareTarget.id}
          documentTitle={shareTarget.title}
          workspaceId={shareTarget.workspaceId}
          allowIncludeDescendants={shareTarget.allowIncludeDescendants}
          onChanged={async () => {
            await refreshTree();
            await refreshShareSummary();
            await refreshGroupPublicSummary();
          }}
        />
      )}
      <GroupManagerDialog
        open={groupManagerOpen}
        onOpenChange={setGroupManagerOpen}
        workspaceId={sidebarData.activeWorkspaceId || ""}
      />
      <MoveEmbedPickerDialog
        open={moveEmbedOpen}
        onOpenChange={setMoveEmbedOpen}
        workspaceId={sidebarData.activeWorkspaceId ?? null}
        defaultMode={moveEmbedMode}
        excludeIds={moveEmbedSource?.id ? [moveEmbedSource.id] : []}
        onPick={async (pickedMode, targetId) => {
          const source = moveEmbedSource;
          if (!source) {
            return;
          }
          if (pickedMode === "move") {
            await handleMove(source.id, targetId, 0);
            return;
          }
          if (!targetId) {
            return;
          }
          if (typeof window !== "undefined" && source.id === targetId) {
            window.alert("不能嵌入到自身页面");
            return;
          }
          try {
            await embedDocumentCommand({
              sourceId: source.id,
              targetId,
            });
          } catch (error) {
            const message = error instanceof Error ? error.message : "嵌入失败，请检查目标页面";
            if (typeof window !== "undefined") {
              window.alert(message);
            }
            return;
          }
          if (typeof window !== "undefined") {
            window.alert("已在目标页面末尾插入引用块");
          }
        }}
      />
      {assetMenu && (
        <AssetContextMenu
          asset={assetMenu.asset}
          position={{ x: assetMenu.x, y: assetMenu.y }}
          onClose={() => setAssetMenu(null)}
          onOpen={handleOpenAsset}
          onCopyLink={handleCopyAssetLink}
          onCopyPath={handleCopyAssetPath}
          onRename={handleRenameAsset}
          onMove={handleMoveAsset}
          onDelete={(assetIds) => void handleDeleteFromAssetContextMenu(assetIds, assetMenu.asset)}
          onDownload={handleDownloadAsset}
        />
      )}
      <Drawer open={trashOpen} onOpenChange={setTrashOpen}>
        <DrawerContent className="max-h-[90vh] overflow-hidden">
          <div className="flex max-h-[90vh] flex-col">
          <DrawerHeader className="text-left">
            <DrawerTitle>垃圾桶</DrawerTitle>
            {trashTab === "documents" ? (
              <p className="mt-1 text-xs text-gray-500">180 天内的记录都可以在这里恢复。</p>
            ) : (
              <p className="mt-1 text-xs text-gray-500">
                附件删除后 10 分钟内可撤销；也可在此手动清空（不可撤销）。
              </p>
            )}
          </DrawerHeader>
          <div className="flex-1 overflow-y-auto">
          <div className="space-y-4 px-4 pb-6">
            <div className="flex gap-2">
              <button
                type="button"
                className={`rounded-md border px-3 py-1 text-sm ${
                  trashTab === "documents"
                    ? "border-[#d7e5ff] bg-[#eff5ff] text-[#2563eb]"
                    : "border-[#e8e8e8] text-gray-600 hover:bg-gray-50"
                }`}
                onClick={() => setTrashTab("documents")}
              >
                页面 ({sidebarData.trashedDocuments.length})
              </button>
              <button
                type="button"
                className={`rounded-md border px-3 py-1 text-sm ${
                  trashTab === "assets"
                    ? "border-[#d7e5ff] bg-[#eff5ff] text-[#2563eb]"
                    : "border-[#e8e8e8] text-gray-600 hover:bg-gray-50"
                }`}
                onClick={() => setTrashTab("assets")}
              >
                附件 (
                {(sidebarData.trashedMediaAssets?.length ?? 0) +
                  (sidebarData.trashedMindmapAssets?.length ?? 0) +
                  (sidebarData.trashedTableAssets?.length ?? 0)}
                )
              </button>
            </div>
            <Input
              placeholder={trashTab === "documents" ? "搜索删除的页面..." : "搜索删除的附件..."}
              value={trashSearch}
              onChange={(event) => setTrashSearch(event.target.value)}
            />
            <div className="flex flex-wrap items-center justify-between gap-2 text-sm text-gray-600">
              <label className="flex items-center gap-2">
                <input
                  type="checkbox"
                  checked={trashConfirm}
                  onChange={(event) => setTrashConfirm(event.target.checked)}
                />
                操作确认
              </label>
              <Button
                type="button"
                size="sm"
                variant="destructive"
                onClick={() => void (trashTab === "documents" ? handleEmptyTrash() : handleEmptyMediaTrash())}
                disabled={emptyingTrash}
              >
                {emptyingTrash
                  ? "清空中..."
                  : trashTab === "documents"
                    ? "清空垃圾桶"
                    : "清空附件垃圾桶"}
              </Button>
            </div>
            <div className="rounded-lg border border-[#eaeaea]">
              {trashTab === "documents" ? (
                filteredTrash.length === 0 ? (
                  <div className="p-4 text-sm text-gray-400">暂无已删除页面</div>
                ) : (
                  filteredTrash.map((item) => (
                    <div
                      key={item.id}
                      className="flex items-center justify-between border-b border-[#f3f3f3] px-3 py-2 text-sm last:border-b-0"
                    >
                      <div>
                        <div className="font-medium text-gray-800">{item.title || "无标题"}</div>
                        <div className="text-xs text-gray-400">
                          删除时间：{new Date(item.deleted_at).toLocaleString()}
                        </div>
                      </div>
                      <div className="flex gap-2">
                        <button
                          type="button"
                          className="rounded-md border border-[#d7e5ff] px-2 py-1 text-xs text-[#2563eb] hover:bg-[#eff5ff]"
                          onClick={() => void handleRestoreFromTrash(item.id)}
                        >
                          恢复
                        </button>
                        <button
                          type="button"
                          className="rounded-md border border-red-100 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
                          onClick={() => void handlePurgeFromTrash(item.id)}
                        >
                          彻底删除
                        </button>
                      </div>
                    </div>
                  ))
                )
              ) : filteredTrashedMediaAssets.length === 0 ? (
                <div className="p-4 text-sm text-gray-400">暂无已删除附件</div>
              ) : (
                filteredTrashedMediaAssets.map((item) => (
                  <div
                    key={item.id}
                    className="flex items-center justify-between border-b border-[#f3f3f3] px-3 py-2 text-sm last:border-b-0"
                  >
                    <div className="min-w-0 pr-2">
                      <div className="truncate font-medium text-gray-800">{item.file_name || "未命名附件"}</div>
                      <div className="text-xs text-gray-400">
                        删除时间：{item.deleted_at ? new Date(item.deleted_at).toLocaleString() : "-"}
                      </div>
                      <div className="text-xs text-gray-400">
                        类型：{item.asset_type === "mindmap" ? "思维导图" : (item.mime_type ?? item.asset_type ?? "unknown")}
                      </div>
                    </div>
                    <div className="flex gap-2">
                      <button
                        type="button"
                        className="rounded-md border border-[#d7e5ff] px-2 py-1 text-xs text-[#2563eb] hover:bg-[#eff5ff]"
                        onClick={() =>
                          void (item.asset_type === "mindmap"
                            ? handleRestoreMindmapFromTrash(item.document_id, item.id)
                            : item.asset_type === "luckysheet"
                              ? handleRestoreTableFromTrash(item.id)
                              : handleRestoreMediaAssetFromTrash(item.id))
                        }
                      >
                        恢复
                      </button>
                      <button
                        type="button"
                        className="rounded-md border border-red-100 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
                        onClick={() =>
                          void (item.asset_type === "mindmap"
                            ? handlePurgeMindmapFromTrash(item.document_id, item.id)
                            : item.asset_type === "luckysheet"
                              ? handlePurgeTableFromTrash(item.id)
                              : handlePurgeMediaAssetFromTrash(item.id))
                        }
                      >
                        彻底删除
                      </button>
                    </div>
                  </div>
                ))
              )}
            </div>
          </div>
          </div>
          </div>
        </DrawerContent>
      </Drawer>
    </>
  );
}

interface SectionListProps {
  label: string;
  icon: React.ReactNode;
  nodes: SidebarTreeNode[];
  collapsed: boolean;
  onToggle: () => void;
}

function SectionList({ label, icon, nodes, collapsed, onToggle }: SectionListProps) {
  return (
    <div className="border-b border-[#f5f5f5]">
      <button
        type="button"
        className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-gray-600"
        onClick={onToggle}
      >
        <span className="flex items-center gap-2">
          {icon}
          {label}
        </span>
        <span className="text-xs text-gray-400">{nodes.length}</span>
      </button>
      {!collapsed && (
        <div className="space-y-1 px-4 pb-3">
          {nodes.length === 0 ? (
            <div className="text-xs text-gray-400">暂无内容</div>
          ) : (
            nodes.map((node) => (
              <Link
                key={node.id}
                href={`/documents/${node.id}`}
                className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
              >
                {node.title || "无标题"}
              </Link>
            ))
          )}
        </div>
      )}
    </div>
  );
}

interface ContextMenuProps {
  contextMenu: ContextMenuState;
  onClose: () => void;
  onOpenRight: (node: SidebarTreeNode) => void;
  onShare: (node: SidebarTreeNode) => void;
  onMove: (node: SidebarTreeNode) => void;
  onEmbed: (node: SidebarTreeNode) => void;
  onCopyLink: (node: SidebarTreeNode, withTitle?: boolean) => void;
  onCopyReference: (node: SidebarTreeNode, mode: "inline" | "embed") => void;
  onCopyId: (node: SidebarTreeNode) => void;
  onDuplicate: (node: SidebarTreeNode) => void;
  onRename: () => void;
  onCreateChild: () => void;
  onConvertChild: () => void;
  onDelete: () => void;
}

function ContextMenu({
  contextMenu,
  onClose,
  onOpenRight,
  onShare,
  onMove,
  onEmbed,
  onCopyLink,
  onCopyReference,
  onCopyId,
  onDuplicate,
  onRename,
  onCreateChild,
  onConvertChild,
  onDelete,
}: ContextMenuProps) {
  const { node } = contextMenu;
  const menuRef = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState({ top: contextMenu.y, left: contextMenu.x });

  useLayoutEffect(() => {
    const clampPosition = () => {
      const element = menuRef.current;
      if (!element) {
        setPosition({ top: contextMenu.y, left: contextMenu.x });
        return;
      }
      const rect = element.getBoundingClientRect();
      const padding = 12;
      const maxLeft = Math.max(padding, window.innerWidth - rect.width - padding);
      const maxTop = Math.max(padding, window.innerHeight - rect.height - padding);
      const left = Math.min(Math.max(padding, contextMenu.x), maxLeft);
      const top = Math.min(Math.max(padding, contextMenu.y), maxTop);
      setPosition({ top, left });
    };
    clampPosition();
    window.addEventListener("resize", clampPosition);
    return () => window.removeEventListener("resize", clampPosition);
  }, [contextMenu]);

  const handleAction = (action: () => void) => {
    action();
    onClose();
  };

  const buttonClass =
    "flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-gray-700 hover:bg-[#f5f7fb]";

  return (
    <div
      ref={menuRef}
      className="fixed z-50 rounded-xl border border-[#ececec] bg-white p-1 text-sm text-gray-700 shadow-2xl"
      style={{ top: position.top, left: position.left, minWidth: 220 }}
    >
      <button
        type="button"
        className={buttonClass}
        onClick={() => handleAction(() => onOpenRight(node))}
      >
        <PanelRightOpen className="h-4 w-4 text-[#2563eb]" />
        <span>在右侧边栏打开</span>
        <span className="ml-auto text-[11px] text-gray-400">Alt + O</span>
      </button>
      <button
        type="button"
        className={buttonClass}
        onClick={() => handleAction(() => onShare(node))}
      >
        <Share2 className="h-4 w-4 text-gray-500" />
        <span>共享...</span>
      </button>
      <button
        type="button"
        className={buttonClass}
        onClick={() => handleAction(() => onMove(node))}
      >
        <ArrowRightLeft className="h-4 w-4 text-gray-500" />
        <span>移动到...</span>
      </button>
      <button
        type="button"
        className={buttonClass}
        onClick={() => handleAction(() => onEmbed(node))}
      >
        <GitMerge className="h-4 w-4 text-gray-500" />
        <span>嵌入到...</span>
      </button>
      <div className="relative">
        <button type="button" className={`${buttonClass} pr-6`} onClick={() => handleAction(() => onCopyLink(node))}>
          <LinkIcon className="h-4 w-4 text-gray-500" />
          <span>复制访问链接</span>
        </button>
        <button
          type="button"
          className={`${buttonClass} pr-6`}
          onClick={() => handleAction(() => onCopyLink(node, true))}
        >
          <LinkIcon className="h-4 w-4 text-gray-500" />
          <span>复制访问链接（带标题）</span>
        </button>
      </div>
      <div className="relative group">
        <button type="button" className={`${buttonClass} pr-6`}>
          <Copy className="h-4 w-4 text-gray-500" />
          <span>复制页面引用链接</span>
          <ChevronRight className="ml-auto h-3.5 w-3.5 text-gray-400" />
        </button>
        <div className="invisible absolute left-full top-0 z-50 ml-1 min-w-[180px] rounded-md border border-[#e5e5e5] bg-white py-1 shadow-xl group-hover:visible">
          <button
            type="button"
            className={buttonClass}
            onClick={() => handleAction(() => onCopyReference(node, "inline"))}
          >
            (( 行内页面引用
          </button>
          <button
            type="button"
            className={buttonClass}
            onClick={() => handleAction(() => onCopyReference(node, "embed"))}
          >
            {"{{ 嵌入页面引用"}
          </button>
        </div>
      </div>
      <button
        type="button"
        className={buttonClass}
        onClick={() => handleAction(() => onCopyId(node))}
      >
        <Hash className="h-4 w-4 text-gray-500" />
        <span>复制页面 ID</span>
      </button>
      <button
        type="button"
        className={buttonClass}
        onClick={() => handleAction(() => onDuplicate(node))}
      >
        <Copy className="h-4 w-4 text-gray-500" />
        <span>拷贝副本</span>
      </button>
      <div className="my-1 border-t border-[#f2f2f2]" />
      <button type="button" className={buttonClass} onClick={() => handleAction(onRename)}>
        <Edit3 className="h-4 w-4 text-gray-500" />
        <span>重命名</span>
      </button>
      <button type="button" className={buttonClass} onClick={() => handleAction(onCreateChild)}>
        <Plus className="h-4 w-4 text-gray-500" />
        <span>新建子页面</span>
      </button>
      <button type="button" className={buttonClass} onClick={() => handleAction(onConvertChild)}>
        <ArrowUpRight className="h-4 w-4 text-gray-500" />
        <span>转为上一个子页面</span>
      </button>
      <button
        type="button"
        className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
        onClick={() => handleAction(onDelete)}
      >
        <Trash2 className="h-4 w-4" />
        <span>删除到垃圾桶</span>
      </button>
    </div>
  );
}

const cloneNodes = (nodes: SidebarTreeNode[]): SidebarTreeNode[] =>
  nodes.map((node) => ({
    ...node,
    children: cloneNodes(node.children),
  }));

const removeNode = (
  nodes: SidebarTreeNode[],
  targetId: string,
): { removed: SidebarTreeNode | null; tree: SidebarTreeNode[] } => {
  let removed: SidebarTreeNode | null = null;
  const nextTree = nodes
    .map((node) => {
      if (removed) return node;
      if (node.id === targetId) {
        removed = node;
        return null;
      }
      const { removed: childRemoved, tree: childTree } = removeNode(node.children, targetId);
      if (childRemoved) {
        removed = childRemoved;
        return { ...node, children: childTree };
      }
      return node;
    })
    .filter(Boolean) as SidebarTreeNode[];
  return { removed, tree: nextTree };
};

const insertNode = (nodes: SidebarTreeNode[], parentId: string | null, index: number, newNode: SidebarTreeNode): SidebarTreeNode[] => {
  if (!parentId) {
    const next = [...nodes];
    next.splice(Math.min(index, next.length), 0, newNode);
    return next;
  }
  return nodes.map((node) => {
    if (node.id === parentId) {
      const children = [...node.children];
      const targetIndex = Math.min(index, children.length);
      children.splice(targetIndex, 0, newNode);
      return { ...node, children };
    }
    return { ...node, children: insertNode(node.children, parentId, index, newNode) };
  });
};

const filterTree = (nodes: SidebarTreeNode[], keyword: string): SidebarTreeNode[] => {
  if (!keyword) {
    return nodes;
  }
  const filtered: SidebarTreeNode[] = [];
  nodes.forEach((node) => {
    const childMatches = filterTree(node.children, keyword);
    const title = (node.title ?? "").toLowerCase();
    if (title.includes(keyword) || childMatches.length > 0) {
      filtered.push({ ...node, children: childMatches });
    }
  });
  return filtered;
};

function collectNodeIds(nodes: SidebarTreeNode[], bag: Set<string> = new Set()): Set<string> {
  nodes.forEach((node) => {
    bag.add(node.id);
    collectNodeIds(node.children, bag);
  });
  return bag;
}

const buildDocumentUrl = (documentId: string): string => {
  if (typeof window === "undefined" || !window.location) {
    return `/documents/${documentId}`;
  }
  return `${window.location.origin}/documents/${documentId}`;
};

const buildMindmapUrl = (documentId: string, mindmapId: string): string => {
  if (typeof window === "undefined" || !window.location) {
    return `/mindmap/${documentId}/${mindmapId}`;
  }
  return `${window.location.origin}/mindmap/${documentId}/${mindmapId}`;
};

const buildTableUrl = (tableId: string): string => {
  if (typeof window === "undefined" || !window.location) {
    return `/tables/${tableId}/view`;
  }
  return `${window.location.origin}/tables/${tableId}/view`;
};

const copyText = async (text: string, successMessage: string) => {
  if (typeof navigator !== "undefined" && navigator.clipboard) {
    try {
      await navigator.clipboard.writeText(text);
      window.alert(successMessage);
      return;
    } catch {
      // ignore and fallback
    }
  }
  window.prompt("复制失败，请手动复制内容", text);
};
