/**
 * Folder tree component -- renders account folders with hierarchy,
 * expand/collapse, and optional unified inbox.
 */

import { getAccounts, getFolders, moveMessage, moveMessages, markFolderRead, createFolder, renameFolder, deleteFolder, moveFolderToTrash, emptyFolder, setupAccount, getDeviceAccounts, getVersion, syncAccount, popoutMainWindow } from "../lib/api-client.js";
import { showContextMenu, type MenuItem } from "./context-menu.js";
import { openTab } from "./tabs.js";

type FolderSelectHandler = (accountId: string, folderId: number, folderName: string, specialUse: string) => void;
// Unified inbox uses folderId = -1 as a sentinel
type UnifiedHandler = () => void;

let onFolderSelect: FolderSelectHandler;
let onUnifiedInbox: UnifiedHandler | null = null;
let selectedElement: HTMLElement;
let selectedAccountId: string | null = null;
let selectedFolderId: number | null = null;
let isFirstLoad = true; // only auto-select on first load
let hasAutoSelected = false; // track whether we've ever managed to auto-select

// Debounce timer for refreshFolderTree
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;

// Spring-loaded folder hover-expand delay (Q117). Matches Outlook/Finder
// feel — short enough to feel responsive, long enough that grazing past a
// collapsed folder doesn't expand it accidentally.
const DRAG_HOVER_EXPAND_MS = 600;

// Track folders that were auto-expanded during the current drag so they can
// be collapsed back when the drag finishes. Keyed by expandKey.
const dragAutoExpanded = new Set<string>();
let dragInFlight = false;

/** Restore every spring-loaded auto-expansion. Returns true if any state
 *  changed (caller decides whether to re-render). Called from BOTH the drop
 *  handler and dragend: a successful message drop removes the dragged row
 *  from the list (removeMessagesAndReconcile), and the browser does NOT fire
 *  dragend on a source element that left the DOM — so drop-time cleanup is
 *  the only reliable path there (Bob 2026-07-02: "drop it in the top dir, it
 *  autoexpands but does not autocollapse"). dragend still covers cancelled
 *  drags (Escape, drop outside any target). */
function collapseDragExpanded(): boolean {
    if (dragAutoExpanded.size === 0) return false;
    for (const key of dragAutoExpanded) delete expandState[key];
    dragAutoExpanded.clear();
    return true;
}

// Persist expand/collapse state in localStorage
const expandState: Record<string, boolean> = JSON.parse(localStorage.getItem("mailx-folders-expanded") || "{}");

function saveExpandState(): void {
    localStorage.setItem("mailx-folders-expanded", JSON.stringify(expandState));
}

// Last-sync tracking: populated by folderSynced events. Memory-only; fresh
// restarts start with an empty map and fill in as syncs happen.
const folderLastSync = new Map<string, number>(); // key = `${accountId}:${folderId}`

function syncKey(accountId: string, folderId: number): string {
    return `${accountId}:${folderId}`;
}

export function setFolderSynced(accountId: string, folderId: number, syncedAt: number): void {
    folderLastSync.set(syncKey(accountId, folderId), syncedAt);
    // Update the row in place if rendered — avoids a full re-render.
    const el = document.querySelector<HTMLElement>(`.ft-folder[data-account-id="${CSS.escape(accountId)}"][data-folder-id="${folderId}"]`);
    if (el) applyFreshness(el, syncedAt);
}

export function getFolderSynced(accountId: string, folderId: number): number | undefined {
    return folderLastSync.get(syncKey(accountId, folderId));
}

/** Programmatic selection highlight — for navigation that happens outside a
 *  tree click (e.g. closing search reveals the focused result's folder). Pass
 *  (null, -1) for All Inboxes. Returns the folder's display name, or null if
 *  the row isn't in the rendered tree (highlight left unchanged). */
export function highlightFolder(accountId: string | null, folderId: number): string | null {
    const el = accountId === null || folderId === -1
        ? document.querySelector<HTMLElement>(".ft-folder.ft-unified")
        : document.querySelector<HTMLElement>(`.ft-folder[data-account-id="${CSS.escape(accountId)}"][data-folder-id="${folderId}"]`);
    if (!el) return null;
    if (selectedElement) selectedElement.classList.remove("selected");
    el.classList.add("selected");
    selectedElement = el;
    selectedAccountId = accountId;
    selectedFolderId = folderId;
    return el.querySelector(".ft-folder-name")?.textContent || null;
}

function formatAge(ms: number): string {
    const secs = Math.round(ms / 1000);
    if (secs < 60) return `${secs}s ago`;
    const mins = Math.round(secs / 60);
    if (mins < 60) return `${mins}m ago`;
    const hours = Math.round(mins / 60);
    if (hours < 24) return `${hours}h ago`;
    return `${Math.round(hours / 24)}d ago`;
}

function freshnessClass(ageMs: number): string {
    if (ageMs < 5 * 60_000) return "fresh";        // green
    if (ageMs < 30 * 60_000) return "stale-soft";  // yellow
    return "stale";                                // red
}

function applyFreshness(el: HTMLElement, syncedAt: number): void {
    const age = Date.now() - syncedAt;
    el.classList.remove("fresh", "stale-soft", "stale");
    el.classList.add(freshnessClass(age));
    el.title = `Last synced: ${formatAge(age)} (${new Date(syncedAt).toLocaleTimeString()})`;
}

interface FolderNode {
    id: number;
    accountId: string;
    path: string;
    name: string;
    specialUse: string;
    delimiter: string;
    unreadCount: number;
    totalCount: number;
    children: FolderNode[];
}

/** Build a tree from flat folder list using delimiter */
function buildTree(folders: any[], delimiter: string, accountId: string): FolderNode[] {
    const root: FolderNode[] = [];
    const byPath: Record<string, FolderNode> = {};

    // Sort by path so parents come before children
    const sorted = [...folders].sort((a, b) => a.path.localeCompare(b.path));

    for (const f of sorted) {
        const node: FolderNode = {
            id: f.id,
            accountId: f.accountId,
            path: f.path,
            name: f.name,
            specialUse: f.specialUse,
            delimiter: f.delimiter || delimiter,
            unreadCount: f.unreadCount || 0,
            totalCount: f.totalCount || 0,
            children: [],
        };
        byPath[f.path] = node;

        // Find parent by stripping the last segment
        const lastDelim = f.path.lastIndexOf(delimiter);
        if (lastDelim > 0) {
            const parentPath = f.path.substring(0, lastDelim);
            let parent = byPath[parentPath];
            if (!parent) {
                // Create virtual parent for non-selectable folders (e.g., "Added2")
                const parentName = parentPath.split(delimiter).pop() || parentPath;
                parent = {
                    id: -1, // virtual, not selectable
                    accountId,
                    path: parentPath,
                    name: parentName,
                    specialUse: "",
                    delimiter,
                    unreadCount: 0,
                    totalCount: 0,
                    children: [],
                };
                byPath[parentPath] = parent;
                // Insert the virtual parent into the tree
                const grandParentDelim = parentPath.lastIndexOf(delimiter);
                if (grandParentDelim > 0) {
                    const grandParent = byPath[parentPath.substring(0, grandParentDelim)];
                    if (grandParent) {
                        grandParent.children.push(parent);
                    } else {
                        root.push(parent);
                    }
                } else {
                    root.push(parent);
                }
            }
            parent.children.push(node);
        } else {
            root.push(node);
        }
    }

    // Aggregate counts from children to parents (so collapsed parents show totals)
    function aggregateCounts(nodes: FolderNode[]): { unread: number; total: number } {
        let unread = 0, total = 0;
        for (const n of nodes) {
            const child = aggregateCounts(n.children);
            n.unreadCount += child.unread;
            n.totalCount += child.total;
            unread += n.unreadCount;
            total += n.totalCount;
        }
        return { unread, total };
    }
    aggregateCounts(root);

    return root;
}

/** Sort: INBOX first, then special folders, then alphabetical */
function sortFolders(nodes: FolderNode[]): void {
    const specialOrder: Record<string, number> = { inbox: 0, sent: 1, outbox: 2, drafts: 3, trash: 4, junk: 5, archive: 6 };
    const nameOrder: Record<string, number> = { inbox: 0, sent: 1, "sent items": 1, outbox: 2, drafts: 3, trash: 4, "deleted items": 4, junk: 5, "junk email": 5, "junk e-mail": 5, spam: 5, archive: 6 };
    nodes.sort((a, b) => {
        const aOrder = specialOrder[a.specialUse] ?? nameOrder[a.name.toLowerCase()] ?? 99;
        const bOrder = specialOrder[b.specialUse] ?? nameOrder[b.name.toLowerCase()] ?? 99;
        if (aOrder !== bOrder) return aOrder - bOrder;
        return a.name.localeCompare(b.name);
    });
    for (const n of nodes) {
        if (n.children.length > 0) sortFolders(n.children);
    }
}

/** Render a folder node and its children recursively */
function renderNode(node: FolderNode, container: HTMLElement, depth: number): void {
    const hasChildren = node.children.length > 0;
    const expandKey = `${node.accountId}:${node.path}`;
    const isExpanded = expandState[expandKey] === true; // default collapsed

    const folderEl = document.createElement("div");
    folderEl.className = "ft-folder";
    folderEl.dataset.accountId = node.accountId;
    folderEl.dataset.folderId = String(node.id);
    folderEl.dataset.folderPath = node.path;
    folderEl.dataset.specialUse = node.specialUse || "";
    folderEl.style.paddingLeft = `${depth * 16 + 8}px`;

    // ── Folder drag source (reparent) ──
    // Real folders are draggable so the user can drop one onto another to
    // reparent it (a server-side mailbox rename). Virtual parents (id -1) and
    // special-use folders aren't draggable — reparenting INBOX/Sent/etc. would
    // break the special-folder mapping and most servers reject it.
    if (node.id !== -1 && !node.specialUse) {
        folderEl.draggable = true;
        folderEl.addEventListener("dragstart", (e) => {
            // Propagation is intentionally NOT stopped: the document-level
            // dragstart tracker matches any `application/x-mailx-*` payload and
            // drives spring-loaded auto-expand, which we want for folder drags
            // too (so a folder can be dropped into a collapsed parent).
            e.dataTransfer!.effectAllowed = "move";
            e.dataTransfer!.setData("application/x-mailx-folder", JSON.stringify({
                accountId: node.accountId,
                folderId: node.id,
                name: node.name,
                path: node.path,
                // Carry the real hierarchy delimiter so the descendant-drop
                // guard doesn't have to guess it from the path (a top-level
                // folder like "Work" has neither "/" nor "." to infer from).
                delimiter: (node as any).delimiter,
            }));
        });
    }

    // Expand/collapse toggle
    const toggle = document.createElement("span");
    toggle.className = "ft-toggle";
    if (hasChildren) {
        toggle.textContent = isExpanded ? "▾" : "▸";
        toggle.addEventListener("click", (e) => {
            e.stopPropagation();
            expandState[expandKey] = !isExpanded;
            saveExpandState();
            // Re-render the tree
            const treeContainer = document.getElementById("folder-tree");
            if (treeContainer) loadFolderTree(treeContainer);
        });
    } else {
        toggle.textContent = " ";
    }
    folderEl.appendChild(toggle);

    const freshnessDot = document.createElement("span");
    freshnessDot.className = "ft-freshness";
    freshnessDot.setAttribute("aria-hidden", "true");
    folderEl.appendChild(freshnessDot);

    const nameSpan = document.createElement("span");
    nameSpan.className = "ft-folder-name";
    nameSpan.textContent = node.name;
    folderEl.appendChild(nameSpan);

    const syncedAt = getFolderSynced(node.accountId, node.id);
    if (syncedAt) applyFreshness(folderEl, syncedAt);

    const isOutbox = node.specialUse === "outbox" || node.path.toLowerCase() === "outbox";
    if (isOutbox && node.totalCount > 0) {
        // Outbox: show total (pending) count with warning style
        const badge = document.createElement("span");
        badge.className = "ft-badge ft-badge-outbox";
        badge.textContent = String(node.totalCount);
        badge.title = `${node.totalCount} pending`;
        folderEl.appendChild(badge);
    } else if (node.unreadCount > 0) {
        const badge = document.createElement("span");
        badge.className = "ft-badge";
        badge.textContent = String(node.unreadCount);
        badge.title = `${node.unreadCount} unread`;
        folderEl.appendChild(badge);
    }
    // Total count (shown when View > Folder counts is checked)
    if (node.totalCount > 0) {
        const total = document.createElement("span");
        total.className = "ft-total-count";
        total.textContent = String(node.totalCount);
        total.title = `${node.totalCount} total messages`;
        folderEl.appendChild(total);
    }

    folderEl.addEventListener("click", () => {
        if (node.id === -1) {
            // Virtual parent — toggle expand instead of selecting
            expandState[expandKey] = !isExpanded;
            saveExpandState();
            const treeContainer = document.getElementById("folder-tree");
            if (treeContainer) loadFolderTree(treeContainer);
            return;
        }
        if (selectedElement) selectedElement.classList.remove("selected");
        folderEl.classList.add("selected");
        selectedElement = folderEl;
        selectedAccountId = node.accountId;
        selectedFolderId = node.id;
        onFolderSelect(node.accountId, node.id, node.name, node.specialUse || node.path.toLowerCase());
    });

    // ── Right-click context menu ──
    folderEl.addEventListener("contextmenu", (e) => {
        e.preventDefault();
        e.stopPropagation();
        const isTrash = node.specialUse === "trash" || node.path.toLowerCase().includes("trash");
        const isJunk = node.specialUse === "junk" || node.path.toLowerCase().includes("spam") || node.path.toLowerCase().includes("junk");

        const items: MenuItem[] = [
            { label: "Open in new tab", action: () => {
                openTab(
                    { kind: "folder", accountId: node.accountId, folderId: node.id, specialUse: node.specialUse || "" },
                    node.name,
                    true,
                );
            }},
            { label: "Open in new window", action: async () => {
                // Separate OS window pre-navigated to this folder — the URL
                // carries goAccount/goFolder; first tree load selects it.
                try {
                    const r = await popoutMainWindow(node.accountId, node.id, node.name);
                    if (!r?.ok) alert(`New window failed: ${r?.reason || "unknown"}`);
                } catch (err: any) { alert(`New window failed: ${err.message}`); }
            }},
            { label: "", action: () => {}, separator: true },
            { label: "Mark all read", action: async () => {
                try {
                    await markFolderRead(node.accountId, node.id);
                    const treeContainer = document.getElementById("folder-tree");
                    if (treeContainer) loadFolderTree(treeContainer);
                } catch { /* ignore */ }
            }},
            { label: "", action: () => {}, separator: true },
            { label: "New subfolder...", action: async () => {
                const name = prompt("New folder name:");
                if (!name) return;
                try {
                    await createFolder(node.accountId, node.path, name);
                    // Auto-expand the parent so the just-created child is
                    // visible immediately. Without this, a collapsed parent
                    // hides the new folder until the user clicks the
                    // disclosure triangle — looked like CREATE silently
                    // failed, which it hadn't.
                    expandState[`${node.accountId}:${node.path}`] = true;
                    saveExpandState();
                    const treeContainer = document.getElementById("folder-tree");
                    if (treeContainer) loadFolderTree(treeContainer);
                } catch (err: any) { alert(`Failed: ${err.message}`); }
            }},
            { label: "Rename...", action: async () => {
                const newName = prompt("Rename folder:", node.name);
                if (!newName || newName === node.name) return;
                try {
                    await renameFolder(node.accountId, node.id, newName);
                    const treeContainer = document.getElementById("folder-tree");
                    if (treeContainer) loadFolderTree(treeContainer);
                } catch (err: any) { alert(`Failed: ${err.message}`); }
            }, disabled: !!node.specialUse },
            // Two delete entries. Move-to-Trash is the default; permanent
            // delete is a second, separated item with a tooltip so it's
            // visible from the menu rather than requiring a discovery
            // shortcut. The IMAP RENAME under Trash brings messages +
            // subfolders along; the server-side fallback (when Trash is
            // \Noinferiors) moves messages to Trash root and then deletes
            // the empty folder. Permanent skips Trash entirely.
            { label: "Move folder to Trash", action: async () => {
                if (!confirm(`Move folder "${node.name}" to Trash? It can be restored by dragging back out of Trash.`)) return;
                try {
                    await moveFolderToTrash(node.accountId, node.id);
                    const treeContainer = document.getElementById("folder-tree");
                    if (treeContainer) loadFolderTree(treeContainer);
                } catch (err: any) { alert(`Failed: ${err.message}`); }
            }, disabled: !!node.specialUse,
              tooltip: "Renames the folder into Trash (date-suffixed if needed); use Delete permanently below to skip Trash." },
            { label: "Delete folder permanently", action: async () => {
                if (!confirm(`Permanently delete folder "${node.name}" and ALL its messages? This cannot be undone.`)) return;
                try {
                    await deleteFolder(node.accountId, node.id);
                    const treeContainer = document.getElementById("folder-tree");
                    if (treeContainer) loadFolderTree(treeContainer);
                } catch (err: any) { alert(`Failed: ${err.message}`); }
            }, disabled: !!node.specialUse,
              tooltip: "Skips Trash. Same as Shift+Delete on a regular file. No undo." },
            { label: "", action: () => {}, separator: true },
            // Q57: copy IMAP path so user can paste into accounts.jsonc as
            // a spam/sent/drafts/trash hint without retyping case-sensitively.
            { label: "Copy folder path", action: async () => {
                try {
                    await navigator.clipboard.writeText(node.path);
                    const status = document.getElementById("status-sync");
                    if (status) status.textContent = `Copied: ${node.path}`;
                } catch {
                    prompt("Folder path:", node.path);
                }
            }},
        ];

        if (isTrash || isJunk) {
            items.push({ label: "", action: () => {}, separator: true });
            items.push({ label: `Empty ${node.name}`, action: async () => {
                if (!confirm(`Permanently delete all messages in "${node.name}"?`)) return;
                try {
                    await emptyFolder(node.accountId, node.id);
                    const { setMessages } = await import("../lib/message-state.js");
                    setMessages([]); // Folder emptied — clear list and viewer
                    const treeContainer = document.getElementById("folder-tree");
                    if (treeContainer) loadFolderTree(treeContainer);
                } catch (err: any) { alert(`Failed: ${err.message}`); }
            }});
        }

        showContextMenu(e.clientX, e.clientY, items, { editTarget: e.target });
    });

    // ── Drop target for message drag-and-drop ──
    if (node.id !== -1) {
        let dragExpandTimer: ReturnType<typeof setTimeout> | null = null;
        folderEl.addEventListener("dragover", (e) => {
            e.preventDefault();
            e.dataTransfer!.dropEffect = e.ctrlKey ? "copy" : "move";
            folderEl.classList.add("drop-target");
        });
        folderEl.addEventListener("dragenter", () => {
            if (hasChildren && !isExpanded && !dragExpandTimer) {
                dragExpandTimer = setTimeout(() => {
                    dragExpandTimer = null;
                    expandState[expandKey] = true;
                    // In-drag expand is transient: don't persist it to
                    // localStorage so closing the tree returns to user's
                    // explicit collapsed state. saveExpandState() only runs
                    // for explicit toggles below.
                    dragAutoExpanded.add(expandKey);
                    const treeContainer = document.getElementById("folder-tree");
                    if (treeContainer) loadFolderTree(treeContainer);
                }, DRAG_HOVER_EXPAND_MS);
            }
        });
        folderEl.addEventListener("dragleave", (e) => {
            // A folder row contains child nodes (the ▸ toggle, the label, the
            // count badge). The browser fires `dragleave` on the row every time
            // the cursor crosses from one of those children to another — even
            // though it never left the row. The old handler cleared the
            // spring-expand timer on every such false leave, so the 600ms
            // delay was almost never reached and the folder never expanded
            // (Bob 2026-06-14: "hovering over a folder when dragging should
            // expand"). Only treat it as a real leave when the cursor has
            // actually moved OUTSIDE this row.
            const to = (e as DragEvent).relatedTarget as Node | null;
            if (to && folderEl.contains(to)) return;
            folderEl.classList.remove("drop-target");
            if (dragExpandTimer) { clearTimeout(dragExpandTimer); dragExpandTimer = null; }
        });
        folderEl.addEventListener("drop", async (e) => {
            e.preventDefault();
            folderEl.classList.remove("drop-target");
            if (dragExpandTimer) { clearTimeout(dragExpandTimer); dragExpandTimer = null; }

            // Collapse spring-loaded expansions NOW. dragend can't be relied
            // on for this: a message drop removes the dragged row from the
            // list, and no dragend fires for a removed source element. The
            // message path re-renders the tree below anyway; re-render here
            // only for paths that would otherwise return early.
            const springCollapsed = collapseDragExpanded();
            const rerenderTree = (): void => {
                const tc = document.getElementById("folder-tree");
                if (tc) loadFolderTree(tc);
            };
            if (springCollapsed) rerenderTree();

            // ── Folder-onto-folder drop = reparent ──
            // Checked before the message payload so dragging a folder row never
            // gets mistaken for a message move.
            const folderData = e.dataTransfer!.getData("application/x-mailx-folder");
            if (folderData) {
                let src: { accountId: string; folderId: number; name: string; path: string; delimiter?: string };
                try { src = JSON.parse(folderData); } catch { return; }
                // No-op / illegal drops: onto itself, across accounts, onto its
                // current parent, or onto its own descendant (would orphan the
                // subtree). The descendant check uses the dropped folder's real
                // delimiter (carried in the payload), falling back to a guess
                // only if absent; the service re-validates server-side anyway.
                const delim = src.delimiter || (node.path.includes("/") ? "/" : (node.path.includes(".") ? "." : "/"));
                if (src.accountId !== node.accountId) {
                    const statusEl = document.getElementById("status-sync");
                    if (statusEl) statusEl.textContent = "Folders can only be reparented within the same account.";
                    return;
                }
                if (src.folderId === node.id) return;
                if (node.path === src.path || node.path.startsWith(src.path + delim)) {
                    const statusEl = document.getElementById("status-sync");
                    if (statusEl) statusEl.textContent = "Can't move a folder into one of its own subfolders.";
                    return;
                }
                if (!confirm(`Move folder "${src.name}" into "${node.name}"?`)) return;
                const statusEl = document.getElementById("status-sync");
                try {
                    // Reparent keeps the leaf name and supplies the drop target
                    // as the new parent. Local-first: the service rewrites the
                    // local folder rows immediately and the tree refreshes on
                    // folderCountsChanged, so refresh now for instant feedback.
                    await renameFolder(src.accountId, src.folderId, src.name, node.id);
                    if (statusEl) statusEl.textContent = `Moved "${src.name}" into "${node.name}"`;
                    expandState[`${node.accountId}:${node.path}`] = true;
                    saveExpandState();
                    const treeContainer = document.getElementById("folder-tree");
                    if (treeContainer) loadFolderTree(treeContainer);
                } catch (err: any) {
                    if (statusEl) statusEl.textContent = `Move failed: ${err?.message || err}`;
                    alert(`Failed: ${err?.message || err}`);
                }
                return;
            }

            // Multi-message or single-message drop
            const multiData = e.dataTransfer!.getData("application/x-mailx-messages");
            const singleData = e.dataTransfer!.getData("application/x-mailx-message");
            const messages: { accountId: string; uid: number; folderId: number }[] =
                multiData ? JSON.parse(multiData) : singleData ? [JSON.parse(singleData)] : [];

            // Filter: not already in target folder
            const toMove = messages.filter(m => m.folderId !== node.id || m.accountId !== node.accountId);
            if (toMove.length === 0) return;

            const statusEl = document.getElementById("status-sync");
            const crossAccount = toMove.some(m => m.accountId !== node.accountId);
            // Local-first: optimistic remove + Ctrl+Z entry FIRST, then
            // fire IPC without awaiting. Old order awaited the daemon
            // round-trip before removing rows; if simpleParser had the
            // event loop saturated the move "stuck" on the drop target
            // for the full mailxapi 120s timeout before the rows finally
            // disappeared. Service-side moveMessages is itself local-first
            // (sync DB commit + queued IMAP), so the IPC ack adds nothing.
            const moved = toMove.length;
            if (statusEl) statusEl.textContent = `Moved ${moved} message${moved > 1 ? "s" : ""} to ${node.name} — Ctrl+Z to undo`;
            const { removeMessagesAndReconcile } = await import("./message-list.js");
            removeMessagesAndReconcile(toMove);
            const treeContainer = document.getElementById("folder-tree");
            if (treeContainer) loadFolderTree(treeContainer);
            document.dispatchEvent(new CustomEvent("mailx-moved", {
                detail: {
                    messages: toMove.map(m => ({ accountId: m.accountId, uid: m.uid, sourceFolderId: m.folderId })),
                    targetAccountId: node.accountId,
                    targetFolderId: node.id,
                },
            }));
            // Fire the IPC(s); surface only real service-side failures.
            const onErr = (err: any) => {
                console.error(`Move failed: ${err?.message || err}`);
                if (statusEl) statusEl.textContent = `Move sync issue: ${err?.message || err}`;
            };
            if (crossAccount) {
                for (const msg of toMove) {
                    const targetAccountId = msg.accountId !== node.accountId ? node.accountId : undefined;
                    moveMessage(msg.accountId, msg.uid, node.id, targetAccountId).catch(onErr);
                }
            } else {
                const accountId = toMove[0].accountId;
                const uids = toMove.map(m => m.uid);
                const folderIds = toMove.map(m => m.folderId);
                moveMessages(accountId, uids, node.id, folderIds).catch(onErr);
            }
        });
    }

    container.appendChild(folderEl);

    // Render children if expanded
    if (hasChildren && isExpanded) {
        for (const child of node.children) {
            renderNode(child, container, depth + 1);
        }
    }
}

export function initFolderTree(container: HTMLElement, handler: FolderSelectHandler, unifiedHandler?: UnifiedHandler): void {
    onFolderSelect = handler;
    onUnifiedInbox = unifiedHandler || null;
    loadFolderTree(container);
}

// Spring-loaded folders (Q117): collapse any auto-expanded folders when the
// drag ends, regardless of where it dropped. The user got the visual feedback
// they needed; if the drop landed in a child folder the move already happened.
// Restoring the prior state matches Outlook/Finder behavior.
document.addEventListener("dragstart", (e) => {
    const dt = e.dataTransfer;
    if (!dt) return;
    // Only track drags that carry a mailx message payload — ignore unrelated
    // drag interactions (image drag, text selection, etc.).
    const types = dt.types || [];
    if (!Array.from(types).some(t => t.startsWith("application/x-mailx-"))) return;
    dragInFlight = true;
});
document.addEventListener("dragend", () => {
    if (!dragInFlight) return;
    dragInFlight = false;
    if (!collapseDragExpanded()) return;
    const treeContainer = document.getElementById("folder-tree");
    if (treeContainer) loadFolderTree(treeContainer);
});

// Item 12: outbox total drives a synthesized "Send-pending" row at the top
// of the folder tree. The server pushes outboxStatus on every mutation; when
// the total flips between zero and non-zero we re-render so the row appears
// / disappears without waiting for the next full refresh.
let lastOutboxTotal = 0;
export function setOutboxTotal(total: number): void {
    const prev = lastOutboxTotal;
    lastOutboxTotal = total | 0;
    // Zero → zero: nothing to render, nothing to clear.
    if (prev === 0 && lastOutboxTotal === 0) return;
    const existing = document.getElementById("ft-send-pending") as HTMLElement;
    // Non-zero in both → just update the badge text; avoid a full re-render.
    if (prev > 0 && lastOutboxTotal > 0 && existing) {
        const badge = existing.querySelector<HTMLElement>(".ft-badge");
        if (badge) badge.textContent = String(lastOutboxTotal);
        existing.title = `${lastOutboxTotal} message${lastOutboxTotal === 1 ? "" : "s"} queued for send`;
        return;
    }
    // Presence flipped (0→N or N→0) — re-render to insert / remove the row.
    const container = document.getElementById("folder-tree");
    if (container) loadFolderTree(container);
}

async function loadFolderTree(container: HTMLElement): Promise<void> {
    // Show loading state while preserving existing tree (if any) on refresh
    const hadContent = container.children.length > 0 && !container.querySelector(".folder-loading");
    if (!hadContent) {
        container.innerHTML = `<div class="folder-loading">Loading accounts...</div>`;
    }

    try {
        const accounts = await getAccounts();
        // The instant getAccounts answers, the IPC bridge is alive. Dismiss
        // the startup overlay NOW — don't make the user
        // stare at a spinner while we go on to fetch every account's folder
        // list (which is the actual paint of the tree, but it doesn't
        // belong on top of a global modal). The tree's own
        // ".folder-loading" inline state covers the residual fetch.
        const earlyOverlay = document.getElementById("startup-overlay");
        if (earlyOverlay) { earlyOverlay.classList.add("hidden"); setTimeout(() => earlyOverlay.remove(), 400); }
        // No polling. The daemon now serves IPC immediately with the local
        // accounts.jsonc cache (cloud refresh is fire-and-forget post-launch),
        // so the first getAccounts call returns the real list. If it returns
        // empty, that means truly no accounts are configured — show the
        // setup form (handled below). When the user adds an account or
        // accounts.jsonc changes from another device, the daemon emits
        // `configChanged` and the UI picks it up via refreshFolderTree.

        if (accounts.length === 0) {
            // On Android a zero-account answer usually does NOT mean "no
            // accounts" — a fresh install starts with an empty local store
            // and pulls accounts.jsonc from Drive in the background, so this
            // is the *bootstrap* state, not the *unconfigured* state. Say so.
            const onAndroid = (window as any).mailxapi?.platform === "android";
            container.innerHTML = onAndroid
                ? `<div class="folder-loading">Initializing…</div>`
                : `<div class="folder-loading">No accounts</div>`;
            // Hide the message list and show setup in the viewer pane (full width)
            const mlSection = document.querySelector(".message-list") as HTMLElement;
            if (mlSection) mlSection.style.display = "none";
            const splitter = document.getElementById("splitter-h");
            if (splitter) splitter.style.display = "none";
            const mvHeader = document.getElementById("mv-header");
            if (mvHeader) mvHeader.style.display = "none";
            // On a narrow layout the viewer is `display:none` unless it's
            // marked narrow-active — and we just hid the list, so a phone was
            // left with BOTH panes gone: a blank screen with "Syncing..." in
            // the status bar (Bob 2026-08-01). This pane holds the only
            // startup feedback (and the device-account picker), so it has to
            // be the visible one while there are no accounts.
            const mvSection = document.querySelector(".message-viewer") as HTMLElement;
            if (mvSection && onAndroid) mvSection.classList.add("narrow-active");
            const mainBody = document.getElementById("mv-body");
            if (mainBody) {
                const isAndroid = onAndroid;
                const formDisplay = isAndroid ? "display:none;" : "";
                const introText = isAndroid ? "" : "Add your email account to get started.";
                // Live bootstrap narration, not a frozen "Checking…" string:
                // app.ts pipes every bootstrapStatus stage into this slot, so
                // the user sees "Initializing…" → "Found 4 account(s): …" →
                // "Setting up bob@bob.ma… (1/4)" instead of a still screen.
                const checkingHtml = isAndroid ? '<div id="bootstrap-narration" style="padding:0.5rem;color:var(--color-text-muted)">Initializing…</div>' : "";
                const gmailNote = isAndroid ? "" : `<p style="margin-top:0.5rem;padding:0.5rem 0.75rem;background:color-mix(in oklch, var(--color-accent) 12%, var(--color-bg-surface));border:1px solid var(--color-border);border-radius:4px;font-size:0.9rem">For now, a <strong>Gmail</strong> or Google Workspace account is required. Support for other providers is coming.</p>`;
                mainBody.innerHTML = `<div style="padding:2rem;line-height:1.8;max-width:500px">
                    <h2 style="margin-bottom:1rem">Welcome to mailx</h2>
                    <div id="setup-device-accounts">${checkingHtml}</div>
                    <div id="setup-cloud-status"></div>
                    <p id="setup-form-intro">${introText}</p>
                    ${gmailNote}
                    <form id="setup-form" style="margin-top:1rem;${formDisplay}">
                        <label style="display:block;margin-bottom:0.5rem">
                            Email address
                            <input id="setup-email" type="email" placeholder="you@gmail.com" required style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
                        </label>
                        <div id="setup-provider-preview" style="display:none;margin-bottom:0.5rem;padding:0.4rem 0.6rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;font-size:0.9rem">
                            <span id="setup-provider-icon" style="display:inline-block;width:1.2em;text-align:center;margin-right:0.4em"></span><span id="setup-provider-label"></span>
                        </div>
                        <label id="setup-name-row" style="display:none;margin-bottom:0.5rem">
                            Your name <span style="color:var(--color-text-muted);font-size:0.85rem">(optional — auto-detected from Google)</span>
                            <input id="setup-name" type="text" placeholder="Your Name (leave blank to use Google profile)" style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
                        </label>
                        <label id="setup-password-row" style="display:none;margin-bottom:0.5rem">
                            Password
                            <input id="setup-password" type="password" placeholder="password" style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
                            <div id="setup-app-password-help" style="display:none;margin-top:0.25rem;font-size:0.8rem;color:var(--color-text-muted)"></div>
                        </label>
                        <button id="setup-submit" type="submit" style="display:none;margin-top:1rem;padding:0.5rem 2rem;background:var(--color-accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:1rem">Add Account</button>
                        <div id="setup-status" style="margin-top:1rem;color:var(--color-text-muted)"></div>
                    </form>
                    <details style="margin-top:2rem;color:var(--color-text-muted)">
                        <summary>Manual setup (advanced)</summary>
                        <p style="margin-top:0.5rem">Create <code>~/.rmfmail/config.jsonc</code> with a cloud provider:</p>
                        <code style="display:block;padding:0.75rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;margin:0.5rem 0;white-space:pre;font-size:0.85rem">{ "sharedDir": { "provider": "gdrive", "path": "rmfmail" } }</code>
                        <p style="margin-top:0.5rem;font-size:0.85rem">Settings sync via Google Drive API (auto-configured for Gmail accounts).</p>
                    </details>
                </div>`;
                // Wire up the setup form
                const form = document.getElementById("setup-form") as HTMLFormElement;
                const emailInput = document.getElementById("setup-email") as HTMLInputElement;
                const statusEl = document.getElementById("setup-status")!;
                // Hide password for OAuth providers, show app-password help for others
                const APP_PASSWORD_HELP: Record<string, string> = {
                    "yahoo.com": "Use an app password: Yahoo Settings → Account Security → Generate app password",
                    "aol.com": "Use an app password: AOL Settings → Account Security → Generate app password",
                    "icloud.com": "Use an app-specific password: appleid.apple.com → Sign-In and Security → App-Specific Passwords",
                };
                // Q67: describe the detected provider so the user knows which
                // auto-config path we're about to take BEFORE they hit Next.
                // Gmail / Google Workspace domains auto-detect via MX in the
                // service; here we can only name the known ones up front and
                // say "will auto-detect" for everything else.
                const PROVIDER_PREVIEW: Record<string, { icon: string; label: string }> = {
                    "gmail.com":       { icon: "✉", label: "Gmail — OAuth (no password needed)" },
                    "googlemail.com":  { icon: "✉", label: "Gmail — OAuth (no password needed)" },
                    "outlook.com":     { icon: "✉", label: "Outlook.com — OAuth (no password needed)" },
                    "hotmail.com":     { icon: "✉", label: "Outlook.com — OAuth (no password needed)" },
                    "live.com":        { icon: "✉", label: "Outlook.com — OAuth (no password needed)" },
                    "yahoo.com":       { icon: "✉", label: "Yahoo Mail — IMAP (needs app password)" },
                    "aol.com":         { icon: "✉", label: "AOL Mail — IMAP (needs app password)" },
                    "icloud.com":      { icon: "✉", label: "iCloud Mail — IMAP (needs app-specific password)" },
                    "me.com":          { icon: "✉", label: "iCloud Mail — IMAP (needs app-specific password)" },
                    "mac.com":         { icon: "✉", label: "iCloud Mail — IMAP (needs app-specific password)" },
                };
                let oauthAutoFired = false;
                emailInput?.addEventListener("input", () => {
                    const email = emailInput.value.trim();
                    const domain = email.split("@")[1]?.toLowerCase() || "";
                    const hasAt = email.includes("@") && domain.length > 0;
                    const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
                    const isGmailLike = ["gmail.com", "googlemail.com"].includes(domain);
                    // Provider preview row
                    const preview = document.getElementById("setup-provider-preview");
                    const icon = document.getElementById("setup-provider-icon");
                    const label = document.getElementById("setup-provider-label");
                    if (preview && icon && label) {
                        if (hasAt) {
                            const hit = PROVIDER_PREVIEW[domain];
                            icon.textContent = hit ? hit.icon : "❓";
                            label.textContent = hit ? hit.label : `${domain} — will auto-detect via MX records`;
                            preview.style.display = "block";
                        } else {
                            preview.style.display = "none";
                        }
                    }
                    // OAuth providers: auto-fire setup immediately once domain
                    // is recognized — don't show name/password (name is auto-
                    // detected from Google profile, no password needed). This
                    // eliminates the "form flash" where fields briefly appear
                    // before the page reloads.
                    if (hasAt && isOAuth && !oauthAutoFired && !setupTriggered) {
                        oauthAutoFired = true;
                        statusEl.textContent = `Connecting to ${isGmailLike ? "Gmail" : "Outlook"}...`;
                        trySetup();
                        return;
                    }
                    // Non-OAuth: progressive reveal of name + password + submit.
                    const nameRow = document.getElementById("setup-name-row");
                    const pwRow = document.getElementById("setup-password-row");
                    const submitBtn = document.getElementById("setup-submit");
                    if (nameRow) nameRow.style.display = hasAt && !isOAuth ? "block" : "none";
                    if (pwRow) pwRow.style.display = hasAt && !isOAuth ? "block" : "none";
                    if (submitBtn) submitBtn.style.display = hasAt && !isOAuth ? "block" : "none";
                    const helpEl = document.getElementById("setup-app-password-help");
                    if (helpEl) {
                        const help = APP_PASSWORD_HELP[domain];
                        helpEl.style.display = help ? "block" : "none";
                        helpEl.textContent = help || "";
                    }
                });
                // When a valid email is entered, try setup immediately — if cloud has
                // existing accounts, loads them without needing name/password
                let setupTriggered = false;
                async function trySetup(): Promise<void> {
                    const email = emailInput.value.trim();
                    if (!email || !email.includes("@") || setupTriggered) return;
                    const name = (document.getElementById("setup-name") as HTMLInputElement).value.trim();
                    const password = (document.getElementById("setup-password") as HTMLInputElement).value;
                    setupTriggered = true;
                    statusEl.textContent = "Checking for existing accounts...";
                    try {
                        const data = await setupAccount(name, email, password);
                        if (data.ok) {
                            // Reload immediately. The status text was a
                            // dwell-time band-aid around the user's
                            // perception that setup "didn't complete";
                            // the real fix is for setup itself to be
                            // fast enough that the result is visible
                            // through the new render. setupAccount's own
                            // 20 s+ of OAuth/GDrive work has already
                            // happened by the time we get here — adding
                            // 7 s of "look at this status" on top is
                            // pure latency.
                            location.reload();
                        } else {
                            setupTriggered = false;
                            statusEl.style.color = "#f55";
                            statusEl.textContent = `Error: ${data.error || "Setup failed"}`;
                        }
                    } catch (err: any) {
                        setupTriggered = false;
                        statusEl.style.color = "#f55";
                        statusEl.textContent = `Error: ${err.message}`;
                    }
                }
                form?.addEventListener("submit", async (e) => {
                    e.preventDefault();
                    await trySetup();
                });
                // Auto-trigger for OAuth providers when email looks complete
                emailInput?.addEventListener("change", async () => {
                    const domain = emailInput.value.split("@")[1]?.toLowerCase() || "";
                    const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
                    if (isOAuth) await trySetup();
                });
                // Show cloud storage status in setup form
                getVersion().then((d: any) => {
                    const cloudEl = document.getElementById("setup-cloud-status");
                    if (!cloudEl) return;
                    const s = d.storage || {};
                    if (s.cloudError) {
                        cloudEl.innerHTML = `<div style="padding:0.75rem;margin-bottom:1rem;background:#5c1a1a;color:#fca;border:1px solid #a33;border-radius:4px">
                            <strong>Cloud storage unavailable:</strong> ${s.cloudError}<br>
                            <span style="font-size:0.85rem">Settings on ${s.provider || "cloud"} cannot be read. Add an account below to initialize cloud storage.</span>
                        </div>`;
                    } else if (s.mode === "api") {
                        cloudEl.innerHTML = `<div style="padding:0.5rem;margin-bottom:1rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;color:var(--color-text-muted)">
                            Using ${s.provider} API (no local mount)
                        </div>`;
                    }
                }).catch(() => {});
                // On Android, check for device Google accounts.
                // RACE a timeout against the bridge call: on a cold first
                // boot the native side answered late (or never — new-device
                // first boot 2026-07-22 sat on "Checking for accounts..."
                // with the manual form hidden and NO way to sign in until a
                // fold-triggered reload). If the bridge is slow, reveal the
                // manual form; a late bridge answer still upgrades the UI to
                // the account picker when it arrives.
                const DEVICE_ACCTS_TIMEOUT_MS = 5000;
                let deviceAcctsResolved = false;
                setTimeout(() => {
                    if (deviceAcctsResolved) return;
                    const p = document.getElementById("setup-device-accounts");
                    if (p) p.innerHTML = "";
                    const f = document.getElementById("setup-form") as HTMLElement;
                    const i = document.getElementById("setup-form-intro") as HTMLElement;
                    if (f) f.style.display = "block";
                    if (i) i.textContent = "Add your email account to get started.";
                }, DEVICE_ACCTS_TIMEOUT_MS);
                getDeviceAccounts().then(async (deviceAccounts) => {
                    deviceAcctsResolved = true;
                    const pickerEl = document.getElementById("setup-device-accounts");
                    if (!pickerEl) return;
                    if (deviceAccounts.length === 0) {
                        // No device accounts — show the form
                        pickerEl.innerHTML = "";
                        const f = document.getElementById("setup-form") as HTMLElement;
                        const i = document.getElementById("setup-form-intro") as HTMLElement;
                        if (f) f.style.display = "block";
                        if (i) i.textContent = "Add your email account to get started.";
                        return;
                    }
                    const formEl = document.getElementById("setup-form") as HTMLElement;
                    const introEl = document.getElementById("setup-form-intro") as HTMLElement;

                    // Auto-setup helper
                    async function autoSetup(email: string, name: string): Promise<void> {
                        if (introEl) introEl.textContent = "";
                        if (formEl) formEl.style.display = "none";
                        pickerEl.innerHTML = `<div style="padding:0.5rem;color:var(--color-text-muted)">Setting up ${email}...</div>`;
                        const result = await setupAccount(name, email, "");
                        if (result?.ok) {
                            // Reload immediately — the dwell-time was a
                            // perception band-aid, same removal as the
                            // explicit setup form's reload above.
                            location.reload();
                        } else {
                            pickerEl.innerHTML = `<div style="padding:0.5rem;color:#f55">${result?.error || "Setup failed"}</div>`;
                            if (formEl) formEl.style.display = "block";
                        }
                    }

                    // One account — auto-select it
                    if (deviceAccounts.length === 1) {
                        await autoSetup(deviceAccounts[0].email, deviceAccounts[0].name);
                        return;
                    }

                    // Multiple accounts — show picker
                    if (introEl) introEl.textContent = "Select an account:";
                    if (formEl) formEl.style.display = "none";
                    pickerEl.innerHTML = deviceAccounts.map((a: { email: string; name: string }) =>
                        `<button class="device-account-btn" data-email="${a.email}" data-name="${a.name}" style="display:block;width:100%;padding:0.75rem 1rem;margin-bottom:0.5rem;background:var(--color-accent);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:1rem;text-align:left">Use ${a.email}</button>`
                    ).join("") + `<button id="setup-show-form" style="margin-top:0.5rem;padding:0.5rem 1rem;background:none;color:var(--color-text-muted);border:none;cursor:pointer;font-size:0.9rem">Use a different account...</button>`;
                    pickerEl.querySelectorAll(".device-account-btn").forEach((btn: Element) => {
                        btn.addEventListener("click", async () => {
                            await autoSetup((btn as HTMLElement).dataset.email || "", (btn as HTMLElement).dataset.name || "");
                        });
                    });
                    document.getElementById("setup-show-form")?.addEventListener("click", () => {
                        pickerEl.style.display = "none";
                        if (formEl) formEl.style.display = "block";
                        if (introEl) introEl.textContent = "Add your email account to get started.";
                    });
                }).catch(() => {
                    // Bridge failed — show the form
                    deviceAcctsResolved = true;
                    const p = document.getElementById("setup-device-accounts");
                    if (p) p.innerHTML = "";
                    const f = document.getElementById("setup-form") as HTMLElement;
                    const i = document.getElementById("setup-form-intro") as HTMLElement;
                    if (f) f.style.display = "block";
                    if (i) i.textContent = "Add your email account to get started.";
                });
            }
            // Dismiss startup overlay
            const overlay = document.getElementById("startup-overlay");
            if (overlay) { overlay.classList.add("hidden"); setTimeout(() => overlay.remove(), 400); }
            return;
        }

        // Coming OUT of the zero-account state. On Android the accounts
        // arrive from Drive after the first render and this function re-runs
        // with no page reload, so whatever the branch above hid stays hidden
        // unless we put it back — that left the phone on a blank list pane
        // even after the mailboxes had loaded (Bob 2026-08-01).
        const mlPane = document.querySelector(".message-list") as HTMLElement | null;
        if (mlPane?.style.display === "none") mlPane.style.display = "";
        const splitterPane = document.getElementById("splitter-h");
        if (splitterPane?.style.display === "none") splitterPane.style.display = "";
        const mvHead = document.getElementById("mv-header");
        if (mvHead?.style.display === "none") mvHead.style.display = "";
        // The setup/narration pane was force-shown on narrow layouts; drop
        // that only if it's still showing bootstrap copy (never yank a
        // message the user has opened).
        const mvPane = document.querySelector(".message-viewer") as HTMLElement | null;
        if (mvPane?.classList.contains("narrow-active") &&
            document.getElementById("bootstrap-narration")) {
            mvPane.classList.remove("narrow-active");
        }

        // Fetch ALL account folder data in parallel BEFORE touching the DOM
        const accountFolderData: { account: any; folders: any[] }[] = await Promise.all(
            accounts.map(async (account: any) => {
                const accountKey = `account:${account.id}`;
                const accountExpanded = expandState[accountKey] !== false;
                const folders = accountExpanded ? await getFolders(account.id) : [];
                return { account, folders };
            })
        );

        // Save scroll position before rebuild
        const savedScroll = container.scrollTop;

        // Build entire new tree into a DocumentFragment (off-screen, no reflows)
        const fragment = document.createDocumentFragment();

        // Unified Inbox — always shown so startup auto-selects it consistently
        // (with one account it's effectively that account's INBOX, but the UI
        // stays uniform so the auto-select path doesn't fork on account count)
        if (accounts.length >= 1) {
            const unifiedEl = document.createElement("div");
            unifiedEl.className = "ft-folder ft-unified";
            unifiedEl.title = accounts.length > 1
                ? "Merged inbox view of all accounts — click to see messages from every account's INBOX sorted by date"
                : "Inbox view across all your accounts";
            unifiedEl.innerHTML = `<span class="ft-toggle"> </span><span class="ft-folder-name">All Inboxes</span>`;
            unifiedEl.addEventListener("click", () => {
                if (selectedElement) selectedElement.classList.remove("selected");
                unifiedEl.classList.add("selected");
                selectedElement = unifiedEl;
                selectedAccountId = null;
                selectedFolderId = -1;
                if (onUnifiedInbox) onUnifiedInbox();
            });
            fragment.appendChild(unifiedEl);
        }

        // Item 12: Send-pending virtual row — synthesized from the outbox
        // queue, only shown when something is actually queued. Clicking
        // opens the outbox-view modal (pink rows, cancellable). Lives at
        // the top of the tree so a stuck send is impossible to miss.
        if (lastOutboxTotal > 0) {
            const pendingEl = document.createElement("div");
            pendingEl.className = "ft-folder ft-unified ft-send-pending";
            pendingEl.id = "ft-send-pending";
            pendingEl.title = `${lastOutboxTotal} message${lastOutboxTotal === 1 ? "" : "s"} queued for send`;
            pendingEl.innerHTML = `<span class="ft-toggle"> </span><span class="ft-folder-name">Send-pending</span><span class="ft-badge ft-badge-outbox">${lastOutboxTotal}</span>`;
            pendingEl.addEventListener("click", async () => {
                try {
                    const { openOutboxView } = await import("./outbox-view.js");
                    openOutboxView();
                } catch { /* outbox-view load failed — silent is OK, status pill still works */ }
            });
            fragment.appendChild(pendingEl);
        }

        // When two accounts share the same display name (e.g. both `bobma`
        // and `gmail` set name="Bob Frankston"), the folder tree previously
        // showed two identical "Bob Frankston" headers and the user had no
        // way to tell which was which. Detect collisions on the displayed
        // label and append the email (or account id) to the duplicates so
        // each header is unique.
        const labelOf = (a: any): string => a.label || a.name || a.id;
        const labelCounts = new Map<string, number>();
        for (const { account } of accountFolderData) {
            const l = labelOf(account);
            labelCounts.set(l, (labelCounts.get(l) || 0) + 1);
        }
        for (const { account, folders } of accountFolderData) {
            const accountEl = document.createElement("div");
            accountEl.className = "ft-account";

            const accountKey = `account:${account.id}`;
            const accountExpanded = expandState[accountKey] !== false; // accounts default expanded

            const header = document.createElement("div");
            header.className = "ft-account-header";
            const baseLabel = labelOf(account);
            const isDup = (labelCounts.get(baseLabel) || 0) > 1;
            const disambiguator = isDup ? ` (${(account as any).email || account.id})` : "";
            header.textContent = `${accountExpanded ? "▾" : "▸"} ${baseLabel}${disambiguator}`;
            header.addEventListener("click", () => {
                expandState[accountKey] = !accountExpanded;
                saveExpandState();
                const treeContainer = document.getElementById("folder-tree");
                if (treeContainer) loadFolderTree(treeContainer);
            });

            // Right-click: full menu instead of plain toggle (Q54).
            header.addEventListener("contextmenu", (e) => {
                e.preventDefault();
                e.stopPropagation();
                const items: MenuItem[] = [
                    { label: "Mark all read (account)", action: async () => {
                        const folderRows = folders.slice();
                        for (const f of folderRows) {
                            try { await markFolderRead(account.id, f.id); } catch { /* keep going */ }
                        }
                        const tc = document.getElementById("folder-tree");
                        if (tc) loadFolderTree(tc);
                    }},
                    { label: "", action: () => {}, separator: true },
                    { label: "Expand all folders", action: () => {
                        const keys = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));
                        for (const k of keys) expandState[k] = true;
                        expandState[accountKey] = true;
                        saveExpandState();
                        const tc = document.getElementById("folder-tree");
                        if (tc) loadFolderTree(tc);
                    }},
                    { label: "Collapse all folders", action: () => {
                        const keys = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));
                        for (const k of keys) expandState[k] = false;
                        expandState[accountKey] = false;
                        saveExpandState();
                        const tc = document.getElementById("folder-tree");
                        if (tc) loadFolderTree(tc);
                    }},
                    { label: "", action: () => {}, separator: true },
                    { label: "Sync this account now", action: async () => {
                        try { await syncAccount(account.id); } catch (err: any) { alert(`Sync failed: ${err?.message || err}`); }
                    }},
                ];
                showContextMenu(e.clientX, e.clientY, items, { editTarget: e.target });
            });

            accountEl.appendChild(header);

            if (accountExpanded && folders.length > 0) {
                const delimiter = folders[0]?.delimiter || ".";
                const tree = buildTree(folders, delimiter, account.id);
                sortFolders(tree);

                // Case-duplicate detection: fold folder paths to lowercase and
                // flag any whose form matches another. Common with servers that
                // let users create `Archive` and `archive` as distinct folders,
                // or `Sent Items` alongside a `Sent items` rename gone sideways.
                // A ⚠ glyph on the affected rows lets the user notice before
                // losing mail to the wrong one.
                const lowerCounts = new Map<string, number>();
                for (const f of folders) {
                    const key = (f.path || "").toLowerCase();
                    lowerCounts.set(key, (lowerCounts.get(key) || 0) + 1);
                }
                const duplicatePaths = new Set<string>();
                for (const [k, c] of lowerCounts) if (c > 1) duplicatePaths.add(k);

                for (const node of tree) {
                    renderNode(node, accountEl, 1);
                }

                if (duplicatePaths.size > 0) {
                    accountEl.querySelectorAll<HTMLElement>(".ft-folder").forEach(el => {
                        const p = (el.dataset.folderPath || "").toLowerCase();
                        if (duplicatePaths.has(p)) {
                            el.classList.add("ft-folder-duplicate");
                            el.title = (el.title ? el.title + " — " : "") +
                                "Case-duplicate folder name on the server (another folder with the same name in different case exists)";
                        }
                    });
                }
            }

            fragment.appendChild(accountEl);
        }

        // Atomic swap — single reflow, no intermediate empty state
        container.replaceChildren(fragment);

        // Restore scroll position
        container.scrollTop = savedScroll;

        // Re-select previous folder, or auto-select on first load
        const allFolderEls = container.querySelectorAll('.ft-folder');
        let target: HTMLElement | null = null;

        if (selectedFolderId === -1) {
            // Unified inbox was selected — just re-highlight it, don't click
            const unified = container.querySelector('.ft-unified') as HTMLElement;
            if (unified) {
                unified.classList.add("selected");
                selectedElement = unified;
                target = unified;
            }
        } else if (selectedAccountId && selectedFolderId !== null && selectedFolderId >= 0) {
            for (const f of allFolderEls) {
                const el = f as HTMLElement;
                if (el.dataset.accountId === selectedAccountId && el.dataset.folderId === String(selectedFolderId)) {
                    el.classList.add("selected");
                    selectedElement = el;
                    target = el;
                    break;
                }
            }
        }

        // Auto-select on first load OR until we successfully auto-selected at least once
        // (handles Android where folders don't exist on first load — they arrive after sync)
        if (!target && (isFirstLoad || !hasAutoSelected)) {
            // Boot navigation: a secondary window opened via the folder tree's
            // "Open in new window" carries its target in the URL
            // (bin/mailx.ts mainPopoutFn → ?goAccount=…&goFolder=…).
            const bootGo = new URLSearchParams(location.search);
            const goAcct = bootGo.get("goAccount");
            const goFolder = bootGo.get("goFolder");
            if (goAcct && goFolder) {
                for (const f of allFolderEls) {
                    const el = f as HTMLElement;
                    if (el.dataset.accountId === goAcct && el.dataset.folderId === goFolder) { target = el; break; }
                }
            }
            // Auto-select only on first load — not on refresh (prevents jumping)
            // (skipped when boot navigation above already picked the target)
            const unified = container.querySelector('.ft-unified') as HTMLElement;
            if (!target && unified) {
                target = unified;
            } else if (!target) {
                let bestInbox: HTMLElement | null = null;
                let bestCount = -1;
                for (const f of allFolderEls) {
                    const name = f.querySelector('.ft-folder-name')?.textContent ?? "";
                    if (name.toLowerCase() === "inbox") {
                        const badge = f.querySelector('.ft-badge');
                        const count = badge ? parseInt(badge.textContent || "0") : 0;
                        if (count > bestCount) {
                            bestCount = count;
                            bestInbox = f as HTMLElement;
                        }
                    }
                }
                target = bestInbox;
            }
            if (!target && allFolderEls.length > 0) target = allFolderEls[0] as HTMLElement;
            if (target) {
                target.click();
                hasAutoSelected = true;
            }
        }
        isFirstLoad = false;
        // Dismiss startup overlay once tree is loaded
        const overlay = document.getElementById("startup-overlay");
        if (overlay) overlay.classList.add("hidden");
        // Remove from DOM after transition
        setTimeout(() => overlay?.remove(), 400);

    } catch (e: any) {
        // Don't destroy existing folder tree on error — just log it
        console.error(`Folder tree error: ${e.message}`);
        // Only show error if tree is completely empty (first load failure)
        if (container.children.length === 0 || container.querySelector(".folder-loading")) {
            const errEl = document.createElement("div");
            errEl.className = "folder-loading";
            errEl.textContent = `Error loading folders: ${e.message}`;
            container.replaceChildren(errEl);
        }
        // Dismiss overlay on error too
        const overlay = document.getElementById("startup-overlay");
        if (overlay) {
            const status = document.getElementById("startup-status");
            if (status) status.textContent = `Error: ${e.message}`;
            setTimeout(() => { overlay.classList.add("hidden"); setTimeout(() => overlay.remove(), 400); }, 2000);
        }
    }
}

/** Refresh folder tree (e.g., after sync) — debounced to prevent rapid rebuilds */
export function refreshFolderTree(): void {
    if (refreshDebounceTimer) clearTimeout(refreshDebounceTimer);
    refreshDebounceTimer = setTimeout(() => {
        refreshDebounceTimer = null;
        const container = document.getElementById("folder-tree");
        if (container) loadFolderTree(container);
    }, 300);
}

/**
 * Incremental count update — patches badge counts in-place without rebuilding the DOM.
 * Used for folderCountsChanged events to avoid jitter. Falls back to full rebuild
 * if the folder structure has changed.
 */
export async function updateFolderCounts(): Promise<void> {
    const container = document.getElementById("folder-tree");
    if (!container) return;

    // If tree hasn't loaded yet, do a full load
    if (container.children.length === 0 || container.querySelector(".folder-loading")) {
        refreshFolderTree();
        return;
    }

    try {
        const accounts = await getAccounts();

        // Fetch all folder data in parallel
        const allFolderData = await Promise.all(
            accounts.map(async (account: any) => {
                const folders = await getFolders(account.id);
                return { accountId: account.id, folders };
            })
        );

        // Build a lookup: accountId+folderId → { unreadCount, totalCount }
        // Also rebuild trees to get aggregated counts
        const countMap = new Map<string, { unread: number; total: number }>();
        for (const { accountId, folders } of allFolderData) {
            const delimiter = folders[0]?.delimiter || ".";
            const tree = buildTree(folders, delimiter, accountId);
            // Walk the tree and collect counts (buildTree already aggregates)
            function collectCounts(nodes: FolderNode[]): void {
                for (const n of nodes) {
                    countMap.set(`${n.accountId}:${n.id}`, { unread: n.unreadCount, total: n.totalCount });
                    collectCounts(n.children);
                }
            }
            collectCounts(tree);
        }

        // Patch existing DOM elements in-place
        const folderEls = container.querySelectorAll(".ft-folder[data-account-id][data-folder-id]");
        let structureChanged = false;

        for (const el of folderEls) {
            const htmlEl = el as HTMLElement;
            const key = `${htmlEl.dataset.accountId}:${htmlEl.dataset.folderId}`;
            const counts = countMap.get(key);
            if (!counts) continue; // folder not found — structure may have changed

            // Update unread badge
            const isOutbox = htmlEl.dataset.specialUse === "outbox" || htmlEl.dataset.folderPath?.toLowerCase() === "outbox";
            let badge = htmlEl.querySelector(".ft-badge") as HTMLElement;
            const outboxBadge = htmlEl.querySelector(".ft-badge-outbox") as HTMLElement;

            if (isOutbox) {
                if (counts.total > 0) {
                    if (outboxBadge) {
                        outboxBadge.textContent = String(counts.total);
                    } else {
                        const b = document.createElement("span");
                        b.className = "ft-badge ft-badge-outbox";
                        b.textContent = String(counts.total);
                        htmlEl.querySelector(".ft-folder-name")?.after(b);
                    }
                } else if (outboxBadge) {
                    outboxBadge.remove();
                }
            } else {
                if (counts.unread > 0) {
                    if (badge) {
                        badge.textContent = String(counts.unread);
                    } else {
                        const b = document.createElement("span");
                        b.className = "ft-badge";
                        b.textContent = String(counts.unread);
                        htmlEl.querySelector(".ft-folder-name")?.after(b);
                    }
                } else if (badge && !badge.classList.contains("ft-badge-outbox")) {
                    badge.remove();
                }
            }

            // Update total count
            let totalEl = htmlEl.querySelector(".ft-total-count") as HTMLElement;
            if (counts.total > 0) {
                if (totalEl) {
                    totalEl.textContent = String(counts.total);
                } else {
                    const t = document.createElement("span");
                    t.className = "ft-total-count";
                    t.textContent = String(counts.total);
                    htmlEl.appendChild(t);
                }
            } else if (totalEl) {
                totalEl.remove();
            }
        }

        // Check if folder count changed (new folders added or removed)
        const existingCount = folderEls.length;
        let serverCount = 0;
        for (const { folders } of allFolderData) serverCount += folders.length;
        if (Math.abs(existingCount - serverCount) > 2) {
            // Structure changed significantly — do a full rebuild
            refreshFolderTree();
        }
    } catch {
        // If count update fails, fall back to full rebuild
        refreshFolderTree();
    }
}
