// tree.js — in-memory tree model + DOM rendering.
//
// Nodes are stored flat in state.nodes (Map by id). The visible
// render is a depth-first walk starting from state.rootIds, skipping
// children of unexpanded folders. This decouples model from DOM and
// keeps re-renders linear in the visible-row count.
(function () {
    'use strict';

    var state = window.app.state;
    var loader = window.app.modules.loader;

    // ── Model helpers ────────────────────────────────────────────────────

    function newNode(raw, parentId, depth) {
        var id = state.nextId++;
        // A .zip file is treated as a folder for tree purposes — the
        // chevron expands it. On expand, server mode fetches the
        // server's "<…>.zip/" virtual-directory listing; offline mode
        // opens the zip with JSZip behind a ZipDirectoryHandle. Either
        // way the zip's members become ordinary directory/file nodes.
        var isZip = !raw.isDir && raw.ext === 'zip';
        var node = {
            id: id,
            name: raw.name,
            // displayName is the rendered label when set by the parent
            // .zddc display: map. Sort + lookup continues to use .name
            // (the on-disk basename) so URL composition stays canonical.
            displayName: raw.displayName || '',
            isDir: raw.isDir,
            size: raw.size,
            modTime: raw.modTime,
            ext: raw.ext,
            url: raw.url,
            handle: raw.handle,
            depth: depth,
            parentId: parentId,
            expanded: false,
            loaded: false,
            childIds: [],
            isZip: isZip,
            _zipDirHandle: null,     // cached ZipDirectoryHandle (offline / nested zips)
            // True when this entry was synthesized client-side (e.g.
            // canonical project folders that don't exist on disk yet).
            // Rendered with a muted style + an "(empty)" hint.
            virtual: !!raw.virtual,
            // Server-computed write authority. Editors (preview-yaml,
            // preview-markdown) consult this via canSave() to decide
            // whether to mount read-only. Dropping the field here
            // silently makes every node read-only — the actual root
            // cause behind "I'm admin but the editor says read-only".
            writable: !!raw.writable
        };
        state.nodes.set(id, node);
        return node;
    }

    function clearTree() {
        state.nodes.clear();
        state.rootIds = [];
        state.nextId = 1;
    }

    // Sort an array of nodes by current sort key. Folders always come
    // first within a level (mimics common file managers).
    function sortNodes(ids) {
        var key = state.sort.key;
        var dir = state.sort.dir;
        ids.sort(function (a, b) {
            var na = state.nodes.get(a);
            var nb = state.nodes.get(b);
            // Folders before files
            if (na.isDir !== nb.isDir) return na.isDir ? -1 : 1;
            var av, bv;
            switch (key) {
                case 'size':
                    av = na.size; bv = nb.size; break;
                case 'ext':
                    av = na.ext; bv = nb.ext; break;
                case 'date':
                    av = na.modTime ? na.modTime.getTime() : 0;
                    bv = nb.modTime ? nb.modTime.getTime() : 0;
                    break;
                default:
                    av = na.name.toLowerCase();
                    bv = nb.name.toLowerCase();
            }
            if (av < bv) return -1 * dir;
            if (av > bv) return  1 * dir;
            return na.name.toLowerCase().localeCompare(nb.name.toLowerCase());
        });
    }

    // Populate state with the root listing.
    function setRoot(rawEntries) {
        clearTree();
        rawEntries.forEach(function (raw) {
            var n = newNode(raw, null, 0);
            state.rootIds.push(n.id);
        });
        sortNodes(state.rootIds);
    }

    // Populate a folder's children. Caller passes raw entries in any order.
    function setChildren(parentId, rawEntries) {
        var parent = state.nodes.get(parentId);
        if (!parent) return;
        // Drop any existing children first (re-load case).
        parent.childIds.forEach(function (id) { state.nodes.delete(id); });
        parent.childIds = [];
        rawEntries.forEach(function (raw) {
            var n = newNode(raw, parentId, parent.depth + 1);
            parent.childIds.push(n.id);
        });
        sortNodes(parent.childIds);
        parent.loaded = true;
    }

    // Walk nodes in render order. Skips the children of a collapsed
    // expandable. When state.filterAST is set, also skips nodes that
    // don't match (files) or whose subtree has no matches (folders),
    // and force-walks into folders that have matching descendants so
    // those matches are visible even when the user hadn't expanded
    // the folder. The user's actual node.expanded flag stays untouched
    // so clearing the filter restores their original layout.
    function visibleIds() {
        var out = [];
        function walk(ids) {
            for (var i = 0; i < ids.length; i++) {
                var n = state.nodes.get(ids[i]);
                if (!n) continue;
                if (state.filterAST && !passesFilter(n)) continue;
                out.push(ids[i]);
                if (n.isDir || n.isZip) {
                    var forceWalk = !!state.filterAST;
                    if (forceWalk || n.expanded) walk(n.childIds);
                }
            }
        }
        // Re-sort everything at all levels so a sort change reorders
        // already-loaded children consistently.
        sortNodes(state.rootIds);
        state.nodes.forEach(function (n) {
            if ((n.isDir || n.isZip) && n.loaded) sortNodes(n.childIds);
        });
        walk(state.rootIds);
        return out;
    }

    // ── Filter ─────────────────────────────────────────────────────────────

    // Build the haystack string we run the filter AST against. We
    // concatenate every searchable field — name, displayName, plus any
    // ZDDC parts the basename parses to — so users can type a tracking
    // number, a status code, a date, or a piece of the title.
    function filterHaystack(node) {
        var parts = [node.name];
        if (node.displayName) parts.push(node.displayName);
        var z = window.zddc;
        if (z) {
            var parsed = node.isDir ? z.parseFolder(node.name)
                                    : z.parseFilename(node.name);
            if (parsed && parsed.valid) {
                if (parsed.trackingNumber) parts.push(parsed.trackingNumber);
                if (parsed.title)          parts.push(parsed.title);
                if (parsed.status)         parts.push(parsed.status);
                if (parsed.revision)       parts.push(parsed.revision);
                if (parsed.date)           parts.push(parsed.date);
            }
        }
        return parts.join(' ');
    }

    function nodeMatchesFilter(node) {
        if (!state.filterAST) return true;
        return window.zddc.filter.matches(filterHaystack(node), state.filterAST);
    }

    // True when this node should appear in the filtered view: either
    // the node itself matches, or it's an expandable with at least
    // one matching descendant (so we keep the path to a match visible).
    function passesFilter(node) {
        if (!state.filterAST) return true;
        if (nodeMatchesFilter(node)) return true;
        if (!(node.isDir || node.isZip)) return false;
        if (!node.loaded) return false; // unloaded subtrees aren't searched
        for (var i = 0; i < node.childIds.length; i++) {
            var child = state.nodes.get(node.childIds[i]);
            if (child && passesFilter(child)) return true;
        }
        return false;
    }

    // Is this folder being "forced open" by an active filter because
    // a descendant matches? Used by rowHtml to render the chevron as
    // expanded without mutating node.expanded.
    function filterForcesOpen(node) {
        if (!state.filterAST) return false;
        if (!(node.isDir || node.isZip)) return false;
        return passesFilter(node) && !nodeMatchesFilter(node);
    }

    // ── Rendering ────────────────────────────────────────────────────────

    function fmtSize(bytes) {
        if (bytes == null) return '';
        if (bytes < 1024) return bytes + ' B';
        if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
        if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
        return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
    }

    function fmtDate(d) {
        if (!d) return '';
        var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
        return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
            + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
    }

    function escapeHtml(s) {
        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
            .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    }

    // Per-extension icon map → Lucide outline-icon sprite ids. The
    // actual SVG markup is produced by window.zddc.icons.html(id),
    // which inlines `<svg><use href="#id"/></svg>` so the page CSS
    // can size and tint via currentColor.
    //
    //   book-marked    PDF              file-pen       markdown
    //   file-text      word / txt       file-spreadsheet  spreadsheet
    //   presentation   slides           file-image     image
    //   file-video     video            file-audio     audio
    //   ruler          CAD / drawing    globe          web
    //   file-cog       config / .zddc   file-code      source code
    //   file-archive   non-nav archive  folder-archive .zip (navigable)
    //   file           generic          folder         directory
    var ICON_BY_EXT = {
        pdf:  'icon-book-marked',
        md:   'icon-file-pen', markdown: 'icon-file-pen',
        doc:  'icon-file-text', docx: 'icon-file-text', rtf: 'icon-file-text', odt: 'icon-file-text',
        xls:  'icon-file-spreadsheet', xlsx: 'icon-file-spreadsheet',
        csv:  'icon-file-spreadsheet', ods:  'icon-file-spreadsheet', tsv: 'icon-file-spreadsheet',
        ppt:  'icon-presentation', pptx: 'icon-presentation', odp: 'icon-presentation',
        txt:  'icon-file-text', log: 'icon-file-text',
        jpg:  'icon-file-image', jpeg: 'icon-file-image', png: 'icon-file-image',
        gif:  'icon-file-image', webp: 'icon-file-image', svg: 'icon-file-image',
        bmp:  'icon-file-image', tif:  'icon-file-image', tiff: 'icon-file-image',
        ico:  'icon-file-image', heic: 'icon-file-image',
        mp4:  'icon-file-video', mov: 'icon-file-video', avi: 'icon-file-video',
        mkv:  'icon-file-video', webm: 'icon-file-video', m4v: 'icon-file-video',
        mp3:  'icon-file-audio', wav: 'icon-file-audio', flac: 'icon-file-audio',
        ogg:  'icon-file-audio', m4a: 'icon-file-audio', aac: 'icon-file-audio',
        dwg:  'icon-ruler', dxf: 'icon-ruler', step: 'icon-ruler',
        stp:  'icon-ruler', iges: 'icon-ruler', igs: 'icon-ruler',
        html: 'icon-globe', htm: 'icon-globe',
        yaml: 'icon-file-cog', yml: 'icon-file-cog', json: 'icon-file-cog',
        toml: 'icon-file-cog', ini: 'icon-file-cog', xml: 'icon-file-cog',
        conf: 'icon-file-cog', cfg: 'icon-file-cog',
        '7z': 'icon-file-archive', rar: 'icon-file-archive', tar: 'icon-file-archive',
        gz:   'icon-file-archive', tgz: 'icon-file-archive',
        bz2:  'icon-file-archive', xz:  'icon-file-archive',
        // Code — share one glyph across languages so users build the
        // "this is source" pattern. Distinguishing per language would
        // be visual noise without much added signal.
        js: 'icon-file-code', mjs: 'icon-file-code', cjs: 'icon-file-code',
        ts: 'icon-file-code', tsx: 'icon-file-code', jsx: 'icon-file-code',
        py: 'icon-file-code', go: 'icon-file-code', rs: 'icon-file-code',
        c:  'icon-file-code', cc: 'icon-file-code', cpp: 'icon-file-code',
        h:  'icon-file-code', hpp: 'icon-file-code', java: 'icon-file-code',
        rb: 'icon-file-code', php: 'icon-file-code', sh: 'icon-file-code',
        bash: 'icon-file-code', zsh: 'icon-file-code', lua: 'icon-file-code',
        swift: 'icon-file-code', kt: 'icon-file-code', kts: 'icon-file-code',
        css: 'icon-file-code', scss: 'icon-file-code', less: 'icon-file-code'
    };

    function symbolForNode(node) {
        if (node.isDir) return 'icon-folder';
        if (node.isZip) return 'icon-folder-archive';
        // `.zddc` (no extension) is the cascade config — same family
        // as yaml. Match the literal basename before falling through
        // to the extension table.
        if (node.name === '.zddc') return 'icon-file-cog';
        var ext = (node.ext || '').toLowerCase();
        return ICON_BY_EXT[ext] || 'icon-file';
    }

    function iconForNode(node) {
        return window.zddc.icons.html(symbolForNode(node));
    }

    // Render the label cell for a row. When the basename parses as a
    // ZDDC-conformant filename (files) or transmittal folder name
    // (directories), split into a two-line layout:
    //   top — trackingNumber · [revision · ]status     (small, muted)
    //   bot — title                                     (normal weight)
    // Otherwise fall back to a single line.
    //
    // .zddc `display:` overrides always render as a single line — the
    // operator chose that string for a reason; we don't try to second-
    // guess it by parsing for ZDDC structure.
    function labelHtml(node) {
        // No native title="…" — the rich hovercard (browse/js/hovercard.js)
        // replaces the browser tooltip with a metadata view that's
        // both more informative and styled to match the rest of the UI.
        if (node.displayName) {
            return '<span class="tree-name__label">'
                + escapeHtml(node.displayName)
                + '</span>';
        }
        var z = window.zddc;
        var parsed = null;
        if (z) {
            parsed = node.isDir
                ? z.parseFolder(node.name)
                : z.parseFilename(node.name);
        }
        if (parsed && parsed.valid) {
            // Folders carry a date (no revision); files carry a
            // revision (no date). Status is present on both.
            var parts;
            if (node.isDir) {
                parts = [parsed.date, parsed.trackingNumber, parsed.status];
            } else {
                parts = [parsed.trackingNumber, parsed.revision, parsed.status];
            }
            var metaText = parts.filter(Boolean).join(' · ');
            // Title-first: primary content on the top line so the row
            // reads like a normal file manager / mail list. Meta sits
            // below as the supporting "subtitle" — same hierarchy
            // pattern as Gmail, Linear, Notion file rows.
            return '<span class="tree-name__label tree-name__label--zddc">'
                + '<span class="tree-name__title">'
                + escapeHtml(parsed.title)
                + '</span>'
                + '<span class="tree-name__meta">'
                + escapeHtml(metaText)
                + '</span>'
                + '</span>';
        }
        return '<span class="tree-name__label">'
            + escapeHtml(node.name)
            + '</span>';
    }

    // Render a single tree row as a flat <div>. Indentation via
    // padding-left so the row's hover background spans the full
    // pane width. Files are rendered as plain rows (no anchor) —
    // the preview pane handles click navigation, and a Ctrl/Cmd-
    // click can fall back to opening the file's url in a new tab
    // via the events.js click handler (it sees the modifier key).
    function rowHtml(node) {
        var indent = 0.4 + node.depth * 1.0;
        var expandable = node.isDir || node.isZip;
        var iconChar = iconForNode(node);
        var chevronClass = 'tree-name__chevron'
            + (expandable ? '' : ' tree-name__chevron--leaf');
        // Outline Lucide chevron — single sprite glyph, rotated 90°
        // via CSS for the expanded state. Leaf rows ship an empty
        // chevron span so the icon column stays aligned.
        var chevronGlyph = expandable
            ? window.zddc.icons.html('icon-chevron-right')
            : '';
        // While a filter is active, folders that contain a matching
        // descendant are rendered as visually expanded so the user
        // can see the match — even if node.expanded is still false.
        // The actual flag stays untouched so clearing the filter
        // restores the user's original tree shape.
        var visuallyExpanded = node.expanded || filterForcesOpen(node);
        var selected = state.selectedId === node.id ? ' is-selected' : '';
        var virtualCls = node.virtual ? ' tree-row--virtual' : '';
        // No native title — the hovercard surfaces a dedicated
        // "Virtual: Not yet created on disk" row for these nodes.
        var virtualHint = node.virtual
            ? '<span class="tree-name__hint">(empty)</span>'
            : '';
        // Extension chip stacked under the file icon. Files with a
        // non-empty ext get a small uppercase label; folders / zips
        // skip it (the chevron + icon glyph carries enough info).
        var extChip = (!node.isDir && !node.isZip && node.ext)
            ? '<span class="tree-name__ext">' + escapeHtml(String(node.ext)) + '</span>'
            : '';
        return ''
            + '<div class="tree-row ' + (visuallyExpanded ? 'expanded' : '') + selected + virtualCls
                + '" data-id="' + node.id
                + '" data-isdir="' + node.isDir
                + '" data-iszip="' + node.isZip + '"'
                + (node.virtual ? ' data-virtual="true"' : '')
                + ' style="padding-left:' + indent + 'rem"'
                + ' role="treeitem" tabindex="-1">'
            + '<span class="' + chevronClass + '">' + chevronGlyph + '</span>'
            + '<span class="tree-name__icon">' + iconChar + extChip + '</span>'
            + labelHtml(node)
            + virtualHint
            + '</div>';
    }

    function render() {
        var body = document.getElementById('treeBody');
        if (!body) return;
        var ids = visibleIds();
        var html = '';
        for (var i = 0; i < ids.length; i++) {
            html += rowHtml(state.nodes.get(ids[i]));
        }
        body.innerHTML = html;
        renderBreadcrumbs();
    }

    // ── Breadcrumbs ──────────────────────────────────────────────────────

    // Inline outline home icon. Stroke-based so it tints with the
    // current text color rather than depending on emoji rendering.
    var HOME_SVG = '<svg class="bc-home-icon" xmlns="http://www.w3.org/2000/svg" '
        + 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
        + 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
        + '<path d="M3 12l9-9 9 9"/>'
        + '<path d="M5 10v10h14V10"/>'
        + '<path d="M10 20v-6h4v6"/></svg>';

    function renderBreadcrumbs() {
        var el = document.getElementById('breadcrumbs');
        if (!el) return;
        var html = '';
        if (state.source === 'server') {
            // Server mode: every segment links to its directory URL.
            // The browser navigates → server returns embedded browse →
            // the new instance auto-loads that directory's listing.
            var path = state.currentPath || '/';
            var parts = path.split('/').filter(Boolean);
            html += '<a class="bc-link bc-root" href="/" title="Site root">'
                + HOME_SVG + '</a>';
            var sofar = '';
            for (var i = 0; i < parts.length; i++) {
                sofar += '/' + parts[i];
                var isLast = i === parts.length - 1;
                html += '<span class="bc-sep">/</span>';
                if (isLast) {
                    html += '<span class="bc-link bc-link--current">'
                        + escapeHtml(parts[i]) + '</span>';
                } else {
                    html += '<a class="bc-link" href="' + escapeHtml(sofar + '/') + '">'
                        + escapeHtml(parts[i]) + '</a>';
                }
            }
            html += '<span class="bc-sep">/</span>';
        } else if (state.source === 'fs') {
            // FS-API mode: ancestor handles weren't retained when the
            // user picked the root, so we can't navigate up. Show the
            // root icon + handle name without links.
            var name = state.rootHandle ? state.rootHandle.name : '';
            html += '<span class="bc-link bc-root" title="Local directory">'
                + HOME_SVG + '</span>';
            if (name) {
                html += '<span class="bc-sep">/</span>';
                html += '<span class="bc-link bc-link--current">' + escapeHtml(name) + '</span>';
            }
            html += '<span class="bc-sep">/</span>';
        }
        el.innerHTML = html;
    }

    // Sort headers no longer exist in the DOM (the tree replaced the
    // table); the tree.setSort() method still works but only via
    // programmatic callers — there's no UI for changing sort yet.

    // True when this .zip node lives inside another zip, so its bytes
    // can't be fetched as a standalone server resource: we read them
    // through the containing handle (offline / nested) or by fetching
    // the inner-zip member URL. In server mode a zip-inside-a-zip's URL
    // contains ".zip/"; offline it has a handle that is itself a zip
    // entry.
    function zipNestedInsideZip(node) {
        if (state.source === 'server') {
            return pathFor(node).toLowerCase().indexOf('.zip/') !== -1;
        }
        return !!(node.handle && node.handle.isZipEntry);
    }

    // Open a .zip node as a directory handle (a ZipDirectoryHandle over
    // a JSZip instance), cached on the node. Bytes come from a real
    // FileSystemFileHandle / ZipFileHandle when present (offline, or a
    // zip nested in a zip), else from a server URL — zddc-server returns
    // the raw .zip for "<…>.zip" and the inner-zip bytes for
    // "<outer>.zip/inner.zip".
    async function zipDirHandle(node) {
        if (node._zipDirHandle) return node._zipDirHandle;
        await loader.ensureJSZip();
        var zh;
        if (node.handle) {
            zh = await window.zddc.zip.fromFileHandle(node.handle);
        } else if (node.url) {
            var resp = await fetch(node.url, { credentials: 'same-origin' });
            if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + node.url);
            zh = await window.zddc.zip.fromBlob(await resp.arrayBuffer(), node.name);
        } else {
            throw new Error('cannot open zip ' + node.name + ' (no handle or URL)');
        }
        node._zipDirHandle = zh;
        return zh;
    }

    // Load a folder's children (lazy; idempotent re-loads). Dispatches
    // by node kind:
    //   - regular folder              → server JSON listing OR FS-API entries
    //   - top-level .zip, server mode → the server's "<…>.zip/" virtual-
    //                                   directory listing (no whole-zip
    //                                   download — zddc-server extracts a
    //                                   member only when one is requested)
    //   - .zip otherwise (offline, or a zip nested in a zip)
    //                                 → open it with JSZip and enumerate
    //                                   it as a directory handle; members
    //                                   become ordinary dir/file nodes
    async function loadChildren(node) {
        if (node.loaded) return;
        try {
            if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) {
                setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/'));
            } else if (node.isZip) {
                setChildren(node.id, await loader.fetchFsChildren(await zipDirHandle(node)));
            } else if (node.isDir) {
                var raw;
                if (state.source === 'server') {
                    raw = await loader.fetchServerChildren(pathFor(node) + '/');
                } else if (state.source === 'fs') {
                    raw = await loader.fetchFsChildren(node.handle);
                } else {
                    return;
                }
                setChildren(node.id, raw);
            }
        } catch (e) {
            window.app.modules.events.statusError(
                'Failed to load ' + node.name + ': ' + e.message);
        }
    }

    // Toggle a folder's expanded state. Loads children on first expand.
    // Treats "expandable" as either a real directory OR a zip file
    // (zip files act like folders for tree purposes — the chevron
    // expands them and the contents come from JSZip).
    async function toggleFolder(nodeId) {
        var n = state.nodes.get(nodeId);
        if (!n || !(n.isDir || n.isZip)) return;
        if (!n.expanded && !n.loaded) {
            await loadChildren(n);
            if (!n.loaded) return; // load failed (statusError already set)
        }
        n.expanded = !n.expanded;
        render();
    }

    // Recursive expand: load + expand all descendants of nodeId. Used
    // for Shift-click on a folder. Walks breadth-first, fanning out
    // through children, grand-children, etc. until every reachable
    // expandable node (folder OR zip) is loaded and marked expanded.
    // Skips zip-EXPANSION recursion to avoid auto-loading every
    // archive in the tree (those can be huge); plain folders only.
    async function expandSubtree(nodeId) {
        var root = state.nodes.get(nodeId);
        if (!root || !(root.isDir || root.isZip)) return;
        var status = window.app.modules.events.statusInfo;
        status('Expanding subtree…');
        var processed = 0;
        var queue = [root];
        while (queue.length) {
            var batch = queue;
            queue = [];
            await Promise.all(batch.map(function (n) { return loadChildren(n); }));
            for (var i = 0; i < batch.length; i++) {
                var n = batch[i];
                n.expanded = true;
                processed++;
                for (var j = 0; j < n.childIds.length; j++) {
                    var c = state.nodes.get(n.childIds[j]);
                    // Recurse into plain folders only — don't auto-
                    // expand zip archives during a subtree expand
                    // (they can be very large).
                    if (c && c.isDir && !c.isZip) queue.push(c);
                }
            }
            render();
            status('Expanding subtree… (' + processed + ' folders loaded)');
        }
        status('Expanded ' + processed + ' folder' + (processed === 1 ? '' : 's'));
    }

    function collapseSubtree(nodeId) {
        var root = state.nodes.get(nodeId);
        if (!root || !(root.isDir || root.isZip)) return;
        function walk(n) {
            n.expanded = false;
            for (var i = 0; i < n.childIds.length; i++) {
                var c = state.nodes.get(n.childIds[i]);
                if (c && (c.isDir || c.isZip)) walk(c);
            }
        }
        walk(root);
        render();
    }

    // Compute the URL/path for a node by walking parents.
    function pathFor(node) {
        var parts = [];
        var cur = node;
        while (cur) {
            parts.unshift(cur.name);
            cur = cur.parentId == null ? null : state.nodes.get(cur.parentId);
        }
        if (state.source === 'server') {
            // currentPath is the dir containing rootIds — root nodes
            // sit DIRECTLY under it.
            return state.currentPath.replace(/\/$/, '') + '/' + parts.join('/');
        }
        return parts.join('/');
    }

    // ── State snapshot / restore ───────────────────────────────────────────
    //
    // Used by refresh + show-hidden so the user doesn't lose their
    // tree layout when the listing reloads. The key is the absolute
    // path of each node, computed by pathFor; on restore we walk the
    // new tree and re-apply expansion + selection to nodes whose
    // paths match.

    function snapshotState() {
        var expanded = {};
        var selectedPath = null;
        var previewPath = null;
        state.nodes.forEach(function (n) {
            if ((n.isDir || n.isZip) && n.expanded) {
                expanded[pathFor(n)] = true;
            }
            if (n.id === state.selectedId) selectedPath = pathFor(n);
            if (n.id === state.lastPreviewedNodeId) previewPath = pathFor(n);
        });
        return {
            expanded: expanded,
            selectedPath: selectedPath,
            previewPath: previewPath
        };
    }

    // Walk the current tree (already populated by setRoot) and re-
    // load + expand every folder whose path appears in snapshot.expanded.
    // Sets selectedId and lastPreviewedNodeId by matching the snapshot
    // paths to the freshly-issued node IDs.
    async function restoreState(snap) {
        if (!snap) return;
        async function walk(ids) {
            for (var i = 0; i < ids.length; i++) {
                var n = state.nodes.get(ids[i]);
                if (!n) continue;
                var p = pathFor(n);
                if (snap.selectedPath && p === snap.selectedPath) {
                    state.selectedId = n.id;
                }
                if (snap.previewPath && p === snap.previewPath) {
                    state.lastPreviewedNodeId = n.id;
                }
                if ((n.isDir || n.isZip) && snap.expanded[p]) {
                    await loadChildren(n);
                    if (n.loaded) {
                        n.expanded = true;
                        await walk(n.childIds);
                    }
                }
            }
        }
        await walk(state.rootIds);
    }

    // Public API
    window.app.modules.tree = {
        setRoot: setRoot,
        setChildren: setChildren,
        render: render,
        toggleFolder: toggleFolder,
        expandSubtree: expandSubtree,
        collapseSubtree: collapseSubtree,
        loadChildren: loadChildren,
        snapshotState: snapshotState,
        restoreState: restoreState,
        setSort: function (key) {
            if (state.sort.key === key) {
                state.sort.dir = -state.sort.dir;
            } else {
                state.sort.key = key;
                state.sort.dir = 1;
            }
            render();
        },
        // Set both key and direction explicitly. dir: 1 (asc) or -1 (desc).
        // Used by the toolbar's sort dropdown.
        setSortExplicit: function (key, dir) {
            state.sort.key = key;
            state.sort.dir = (dir === -1 ? -1 : 1);
            render();
        },
        pathFor: pathFor,
        visibleIds: visibleIds
    };
})();
