// events.js — wires up DOM listeners. Idempotent so app.js can call
// init() once on load.
(function () {
    'use strict';

    var state = window.app.state;
    var tree = window.app.modules.tree;
    var loader = window.app.modules.loader;
    // preview module is loaded later (concat order); look it up at
    // call time, not at IIFE-eval time.
    function previewMod() { return window.app.modules.preview; }

    function status(msg, kind) {
        var el = document.getElementById('statusBar');
        if (!el) return;
        el.textContent = msg || '';
        el.classList.remove('status-bar--error', 'status-bar--info');
        if (kind === 'error') el.classList.add('status-bar--error');
        if (kind === 'info')  el.classList.add('status-bar--info');
    }

    function statusError(msg) { status(msg, 'error'); }
    function statusInfo(msg)  { status(msg, 'info'); }
    function statusClear()    { status('', null); }

    async function pickLocalDir() {
        if (typeof window.showDirectoryPicker !== 'function') {
            statusError('Your browser does not support local folder selection. Use a recent Chromium-based browser, or open this page via zddc-server.');
            return;
        }
        var handle;
        try {
            handle = await window.showDirectoryPicker({ mode: 'read' });
        } catch (e) {
            // User cancelled — silent
            return;
        }
        state.source = 'fs';
        state.rootHandle = handle;
        state.currentPath = handle.name + '/';
        var raw;
        try {
            raw = await loader.fetchFsChildren(handle);
        } catch (e) {
            statusError('Failed to read directory: ' + e.message);
            return;
        }
        tree.setRoot(raw);
        showBrowseRoot();
        tree.render();
        statusInfo('Loaded ' + raw.length + ' item' + (raw.length === 1 ? '' : 's'));
    }

    function showBrowseRoot() {
        var empty = document.getElementById('emptyState');
        var root = document.getElementById('browseRoot');
        if (empty) empty.classList.add('hidden');
        if (root) root.classList.remove('hidden');
        applySourceUI();
    }

    // Visual state of the "Select Directory" button + the refresh
    // button depends on the source. In server mode the user is
    // already viewing a server-backed listing — Select Directory
    // becomes a quiet "switch to local" affordance (subtle styling),
    // and the refresh button is shown. In FS mode the button is
    // primary (it's how you got here) and refresh is hidden (the
    // listing was already a fresh enumeration).
    function applySourceUI() {
        var add = document.getElementById('addDirectoryBtn');
        var refresh = document.getElementById('refreshHeaderBtn');
        if (add) {
            if (state.source === 'server') {
                add.classList.remove('btn-primary');
                add.classList.add('btn--subtle');
            } else {
                add.classList.add('btn-primary');
                add.classList.remove('btn--subtle');
            }
        }
        if (refresh) {
            if (state.source) {
                refresh.classList.remove('hidden');
            } else {
                refresh.classList.add('hidden');
            }
        }
    }

    // syncURLToSelection reflects the current scope + selected node +
    // show-hidden flag into the URL bar via history.replaceState, so:
    //   - bookmarks / copy-paste of the URL re-open the same view
    //   - reload (e.g. after toggling admin mode, which forces a hard
    //     reload to pick up the elevated cookie) lands the user back
    //     on the same selection
    //
    // Uses replaceState (not pushState) so a long click sequence doesn't
    // pollute browser history. Scope changes (rescopeServer) still
    // pushState — that's the only "intentional" navigation step in the
    // SPA, and back/forward should walk between scopes, not selections.
    //
    // FS-API mode has no shareable URL, so this is a no-op there.
    function syncURLToSelection() {
        if (state.source !== 'server') return;
        var scope = state.currentPath || '/';
        if (!scope.endsWith('/')) scope += '/';

        var params = new URLSearchParams();
        var node = state.selectedId != null ? state.nodes.get(state.selectedId) : null;
        if (node) {
            var abs = tree.pathFor(node);
            var prefix = scope.replace(/\/$/, '');
            var rel = abs;
            if (prefix && abs.indexOf(prefix + '/') === 0) {
                rel = abs.slice(prefix.length + 1);
            }
            // Directory selections get a trailing slash so the URL
            // round-trips as a navigable folder reference.
            if (node.isDir && rel && !rel.endsWith('/')) rel += '/';
            if (rel) params.set('file', rel);
        }
        if (state.showHidden) params.set('hidden', '1');

        // URLSearchParams percent-encodes '/' to %2F; the server doesn't
        // care, but the URL bar reads better with raw slashes.
        var qs = params.toString().replace(/%2F/g, '/');
        var url = scope + (qs ? '?' + qs : '');
        try {
            history.replaceState({ zddcBrowse: true, path: url }, '', url);
        } catch (_e) { /* private browsing edge cases */ }
    }

    async function refreshListing() {
        // Snapshot expanded paths + selection BEFORE setRoot clears the
        // tree, then re-apply after the new root is in place. Keeps
        // the user's layout (which folders were open, which row was
        // highlighted, what the preview was pinned to) stable across
        // a refresh — including the auto-refresh triggered by the
        // "Show hidden files" toggle.
        var snap = tree.snapshotState();
        if (state.source === 'server') {
            var raw;
            try {
                raw = await loader.fetchServerChildren(state.currentPath);
            } catch (e) {
                statusError('Refresh failed: ' + e.message);
                return;
            }
            tree.setRoot(raw);
            await tree.restoreState(snap);
            tree.render();
            statusInfo('Refreshed (' + raw.length + ' item'
                + (raw.length === 1 ? '' : 's') + ')');
        } else if (state.source === 'fs' && state.rootHandle) {
            var raw2;
            try {
                raw2 = await loader.fetchFsChildren(state.rootHandle);
            } catch (e) {
                statusError('Refresh failed: ' + e.message);
                return;
            }
            tree.setRoot(raw2);
            await tree.restoreState(snap);
            tree.render();
            statusInfo('Refreshed');
        }
    }

    function init() {
        // Header buttons
        var btn = document.getElementById('addDirectoryBtn');
        if (btn) btn.addEventListener('click', pickLocalDir);

        var refresh = document.getElementById('refreshHeaderBtn');
        if (refresh) refresh.addEventListener('click', refreshListing);

        // Tree autofilter — parses input through zddc.filter.parse so
        // the same query grammar that the archive app uses (terms,
        // quotes, !negation, multi-word AND) works here. The AST is
        // cached on state.filterAST; tree.render reads it and skips
        // non-matching rows. Escape clears.
        var filterInput = document.getElementById('treeFilter');
        if (filterInput) {
            var filterDebounce = null;
            var applyFilter = function () {
                var raw = filterInput.value || '';
                state.filterText = raw;
                state.filterAST = raw ? window.zddc.filter.parse(raw) : null;
                filterInput.classList.toggle('filter-active', !!raw);
                tree.render();
            };
            filterInput.addEventListener('input', function () {
                if (filterDebounce) clearTimeout(filterDebounce);
                filterDebounce = setTimeout(applyFilter, 80);
            });
            filterInput.addEventListener('keydown', function (e) {
                if (e.key === 'Escape' && filterInput.value) {
                    e.preventDefault();
                    filterInput.value = '';
                    applyFilter();
                }
            });
        }

        // No view-mode buttons; mode is derived from the URL on every
        // scope change (resolveViewMode below). Pass-through for the
        // initial path.
        applyResolvedViewMode();

        // Pop-out preview button — opens the current preview in a separate window.
        var popout = document.getElementById('previewPopout');
        if (popout) popout.addEventListener('click', function () {
            var p = previewMod();
            if (p && state.lastPreviewedNodeId != null) {
                var n = state.nodes.get(state.lastPreviewedNodeId);
                if (n) p.showFilePreview(n, { popup: true });
            }
        });

        // Pane resizer (tree pane width). Drag horizontally; clamps to
        // [180, 60% of viewport]. State stays in-memory only — refresh
        // resets to the default 360px.
        var resizer = document.querySelector('.pane-resizer[data-resizer-for="tree-pane"]');
        var treePane = document.getElementById('treePane');
        if (resizer && treePane) {
            var dragging = false;
            var startX = 0;
            var startWidth = 0;
            resizer.addEventListener('mousedown', function (e) {
                dragging = true;
                resizer.classList.add('is-dragging');
                startX = e.clientX;
                startWidth = treePane.getBoundingClientRect().width;
                e.preventDefault();
            });
            document.addEventListener('mousemove', function (e) {
                if (!dragging) return;
                var dx = e.clientX - startX;
                var w = Math.max(180, Math.min(window.innerWidth * 0.6, startWidth + dx));
                treePane.style.width = w + 'px';
            });
            document.addEventListener('mouseup', function () {
                if (!dragging) return;
                dragging = false;
                resizer.classList.remove('is-dragging');
            });
        }

        // Tree-row clicks (event delegation on the tree body).
        // Click semantics on a folder row:
        //   - plain click  → toggle expand (deferred so dblclick wins)
        //   - shift-click  → recursive expand/collapse of the subtree
        //   - alt-click    → ALSO recursive
        //   - dblclick     → navigate into the folder
        // File rows: plain click → preview in right pane; modifier-click
        // and middle-click open in new tab.
        //
        // The plain-click toggle for folders is intentionally deferred
        // via setTimeout. Reason: toggling re-renders the tree, which
        // replaces the clicked row element. The browser detects a
        // double-click only when the second click lands on the same
        // target element as the first; replacing the row breaks that
        // continuity and the dblclick event never fires. The deferred
        // toggle lets a pending dblclick cancel it.
        var pendingFolderToggle = null;
        var treeBody = document.getElementById('treeBody');
        if (treeBody) {
            treeBody.addEventListener('click', function (e) {
                var row = e.target.closest('.tree-row');
                if (!row) return;
                var id = parseInt(row.dataset.id, 10);
                var node = state.nodes.get(id);
                if (!node) return;

                var isExpandable = row.dataset.isdir === 'true' || row.dataset.iszip === 'true';

                if (isExpandable) {
                    e.preventDefault();
                    if (e.shiftKey || e.altKey) {
                        // Modifier-click skips the dblclick race — it's
                        // an explicit recursive toggle, never followed
                        // by a dblclick.
                        if (node.expanded) tree.collapseSubtree(id);
                        else tree.expandSubtree(id);
                        return;
                    }
                    // ZIPs don't navigate-into; toggle immediately.
                    if (row.dataset.iszip === 'true') {
                        tree.toggleFolder(id);
                        return;
                    }
                    // Folder: defer the toggle so a pending dblclick
                    // can pre-empt it.
                    if (pendingFolderToggle) {
                        clearTimeout(pendingFolderToggle.timer);
                    }
                    pendingFolderToggle = {
                        id: id,
                        timer: setTimeout(function () {
                            pendingFolderToggle = null;
                            tree.toggleFolder(id);
                        }, 220)
                    };
                    return;
                }

                // File row: modifier-click → open URL in new tab if
                // available (server mode preserves the original URL,
                // useful for direct download / sharing).
                if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) {
                    if (node.url) window.open(node.url, '_blank', 'noopener');
                    return;
                }
                // Plain click → preview in the right pane.
                e.preventDefault();
                state.selectedId = id;
                state.lastPreviewedNodeId = id;
                tree.render(); // refresh selection highlight
                syncURLToSelection();
                var p = previewMod();
                if (p) p.showFilePreview(node);
            });

            // Double-click on a folder → "navigate into" it. Distinct
            // from single-click (which expands inline) so users keep
            // both UX models. Server mode jumps to the folder URL —
            // zddc-server returns a fresh browse instance scoped to
            // that directory. FS-API mode swaps state.rootHandle to
            // the folder's handle and re-loads, so the user sees
            // only that subtree at the root level.
            //
            // Files: dblclick is left alone — the single-click preview
            // is already a "look at this file" action; a separate
            // navigate-into doesn't apply.
            // ZIPs: skipped too — they're inspected via inline
            // expansion (JSZip), not navigated into.
            treeBody.addEventListener('dblclick', function (e) {
                var row = e.target.closest('.tree-row');
                if (!row) return;
                if (row.dataset.isdir !== 'true') return;
                var id = parseInt(row.dataset.id, 10);
                var node = state.nodes.get(id);
                if (!node) return;
                e.preventDefault();
                // Pre-empt the deferred single-click toggle so the user
                // doesn't see a flicker of expand/collapse before nav.
                if (pendingFolderToggle) {
                    clearTimeout(pendingFolderToggle.timer);
                    pendingFolderToggle = null;
                }
                navigateIntoFolder(node);
            });

            // Keyboard navigation in the tree. Document-level listener so
            // the user doesn't have to click into the tree first; bails
            // out cleanly when focus is in an editable field or when a
            // modal / context-menu owns the keys. Roving-tabindex-style
            // semantics, matching the W3C tree-view pattern:
            //
            //   ↓ / ↑           — move selection (auto-previews files)
            //   →               — expand if collapsed; jump to first child
            //                      if already expanded; no-op otherwise
            //   ←               — collapse if expanded; jump to parent
            //                      if collapsed/leaf
            //   Enter / Space   — preview file / toggle folder
            //   Home / End      — first / last visible row
            document.addEventListener('keydown', function (e) {
                // Skip editable contexts.
                var tag = (e.target && e.target.tagName) || '';
                if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
                if (e.target && e.target.isContentEditable) return;
                // Skip when a modal or context menu is open.
                if (document.querySelector('.modal-overlay, .zddc-menu')) return;
                // Skip if any modifier is pressed — lets Ctrl-F, Cmd-T,
                // Alt-arrow back/forward etc. fall through unchanged.
                if (e.ctrlKey || e.metaKey || e.altKey) return;

                var key = e.key;
                var navKey = key === 'ArrowDown' || key === 'ArrowUp'
                    || key === 'ArrowLeft' || key === 'ArrowRight'
                    || key === 'Home' || key === 'End'
                    || key === 'Enter' || key === ' ';
                if (!navKey) return;

                var visible = tree.visibleIds();
                if (!visible.length) return;

                // Commit to handling this key — preventDefault so the
                // browser doesn't also scroll on arrows / page-down on
                // Space. Selection / expand actions happen below.
                e.preventDefault();

                var curIdx = visible.indexOf(state.selectedId);
                var node = state.selectedId != null
                    ? state.nodes.get(state.selectedId) : null;
                var expandable = !!(node && (node.isDir || node.isZip));
                var nextId = null;
                var previewModule = previewMod();

                if (key === 'ArrowDown') {
                    nextId = curIdx < 0
                        ? visible[0]
                        : visible[Math.min(curIdx + 1, visible.length - 1)];
                } else if (key === 'ArrowUp') {
                    nextId = curIdx < 0
                        ? visible[visible.length - 1]
                        : visible[Math.max(curIdx - 1, 0)];
                } else if (key === 'Home') {
                    nextId = visible[0];
                } else if (key === 'End') {
                    nextId = visible[visible.length - 1];
                } else if (key === 'ArrowRight' && node) {
                    if (expandable && !node.expanded) {
                        tree.toggleFolder(node.id);
                        return;
                    }
                    if (expandable && node.expanded
                        && node.childIds && node.childIds.length) {
                        nextId = node.childIds[0];
                    }
                } else if (key === 'ArrowLeft' && node) {
                    if (expandable && node.expanded) {
                        tree.toggleFolder(node.id);
                        return;
                    }
                    if (node.parentId != null) {
                        nextId = node.parentId;
                    }
                } else if ((key === 'Enter' || key === ' ') && node) {
                    if (expandable) {
                        tree.toggleFolder(node.id);
                    } else if (previewModule) {
                        previewModule.showFilePreview(node);
                        state.lastPreviewedNodeId = node.id;
                    }
                    return;
                }

                if (nextId == null) return;
                state.selectedId = nextId;
                var nextNode = state.nodes.get(nextId);
                tree.render();
                syncURLToSelection();
                // Auto-preview files as the keyboard cursor lands on them
                // so the right pane keeps up with selection. Folders are
                // selection-only; their preview is "expand to see inside".
                if (nextNode && !nextNode.isDir && !nextNode.isZip
                    && previewModule) {
                    previewModule.showFilePreview(nextNode);
                    state.lastPreviewedNodeId = nextId;
                }
                // Scroll the now-selected row into view.
                var newRow = treeBody.querySelector(
                    '.tree-row[data-id="' + nextId + '"]');
                if (newRow) newRow.scrollIntoView({ block: 'nearest' });
            });

            // Right-click → context menu. Two surfaces:
            //   - on a tree row: per-row menu (Open, Rename, Delete, …)
            //   - on empty space in the pane: directory-scope menu
            //     (New folder, Refresh, Sort by, …)
            treeBody.addEventListener('contextmenu', function (e) {
                e.preventDefault();
                var row = e.target.closest('.tree-row');
                if (row) {
                    var id = parseInt(row.dataset.id, 10);
                    var node = state.nodes.get(id);
                    if (!node) return;
                    state.selectedId = id;
                    tree.render();
                    syncURLToSelection();
                    window.zddc.menu.open({
                        x: e.clientX,
                        y: e.clientY,
                        context: { node: node, row: row },
                        items: buildTreeRowMenu
                    });
                } else {
                    window.zddc.menu.open({
                        x: e.clientX,
                        y: e.clientY,
                        context: { dir: state.currentPath || '/' },
                        items: buildPaneMenu
                    });
                }
            });

            // Per-row drag-drop. Any row is a drop target — folders
            // upload into themselves; files upload into their parent
            // folder. Highlighting is purely visual; server-side ACL
            // is the source of truth (a 403 surfaces as an error toast).
            wirePerRowDrop(treeBody);
        }
    }

    // ── Per-row drag/drop targets ─────────────────────────────────────────

    // Translate a node into the directory that should receive uploads
    // dropped onto its row. Folders → themselves; files → their parent.
    // Returns a server path with a trailing slash, or null when there's
    // no usable destination (offline mode, virtual node, etc.).
    function targetDirForNode(node) {
        if (!node || node.virtual) return null;
        if (state.source !== 'server') return null;
        if (node.isZip) return null; // can't upload INTO a zip via PUT
        var dirNode = node;
        if (!node.isDir) {
            if (node.parentId == null) {
                // Top-level file → upload to current scope.
                return state.currentPath || '/';
            }
            dirNode = state.nodes.get(node.parentId);
            if (!dirNode) return null;
        }
        var p = tree.pathFor(dirNode);
        if (!p.endsWith('/')) p += '/';
        return p;
    }

    // True when this node is a file viewed through the synthetic
    // <workflow>/received/ window — the URL has a `received/` segment
    // that's NOT preceded by `archive/<party>/` (the canonical record
    // form). A drop here is a review-comment intent: server rewrites to
    // <workflow>/<base>+C<n><suffix>.
    function isVirtualReceivedFile(node) {
        if (!node || node.isDir || state.source !== 'server') return false;
        var url = tree.pathFor(node);
        var parts = url.replace(/^\/+/, '').split('/');
        var idx = parts.indexOf('received');
        if (idx < 2) return false;
        // Canonical form: parts[idx - 2] === 'archive'. Virtual form: anything else.
        return parts[idx - 2].toLowerCase() !== 'archive';
    }

    function dragHasFiles(e) {
        if (!e.dataTransfer || !e.dataTransfer.types) return false;
        var types = e.dataTransfer.types;
        for (var i = 0; i < types.length; i++) {
            if (types[i] === 'Files') return true;
        }
        return false;
    }

    function wirePerRowDrop(treeBody) {
        var lastOver = null;
        function clearHighlight() {
            if (lastOver) {
                lastOver.classList.remove('is-droptarget');
                lastOver = null;
            }
        }
        treeBody.addEventListener('dragover', function (e) {
            if (!dragHasFiles(e)) return;
            var row = e.target.closest('.tree-row');
            if (!row) { clearHighlight(); return; }
            var id = parseInt(row.dataset.id, 10);
            var node = state.nodes.get(id);
            if (!node) return;
            var dest = targetDirForNode(node);
            if (!dest) {
                if (e.dataTransfer) e.dataTransfer.dropEffect = 'none';
                clearHighlight();
                return;
            }
            e.preventDefault();           // signals "this is a drop target"
            e.stopPropagation();           // suppress doc-level overlay
            if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
            if (lastOver !== row) {
                clearHighlight();
                row.classList.add('is-droptarget');
                lastOver = row;
            }
        });
        treeBody.addEventListener('dragleave', function (e) {
            // dragleave fires on row crossings too — only clear when the
            // pointer actually leaves the tree body.
            if (!e.relatedTarget || !treeBody.contains(e.relatedTarget)) {
                clearHighlight();
            }
        });
        treeBody.addEventListener('drop', async function (e) {
            if (!dragHasFiles(e)) return;
            var row = e.target.closest('.tree-row');
            clearHighlight();
            if (!row) return;
            var id = parseInt(row.dataset.id, 10);
            var node = state.nodes.get(id);
            if (!node) return;
            // Comment-upload short-circuit: drop on a file that lives
            // under the virtual <workflow>/received/ window is a "comment
            // on this file" intent. PUT to the target's URL — the server
            // rewrites to <workflow>/<base>+C<n><suffix> and the canonical
            // record (WORM) stays untouched. Confirm first so the user
            // sees what's about to happen.
            if (!node.isDir && isVirtualReceivedFile(node)) {
                e.preventDefault();
                e.stopPropagation();
                if (!window.confirm("Drop bytes here as a review comment on '" + node.name + "'? The server will save it in the workflow folder with a +C<n> revision modifier.")) {
                    return;
                }
                var upMod = window.app.modules.upload;
                if (!upMod) return;
                var targetURL = tree.pathFor(node);
                try {
                    await upMod.uploadCommentToTarget(targetURL, e.dataTransfer);
                } catch (err) {
                    statusError('Comment upload failed: ' + (err.message || err));
                }
                return;
            }
            var dest = targetDirForNode(node);
            if (!dest) return;
            e.preventDefault();
            e.stopPropagation();           // pre-empt doc-level handler
            var up = window.app.modules.upload;
            if (!up) return;
            try {
                await up.uploadToDir(dest, e.dataTransfer);
            } catch (err) {
                statusError('Upload failed: ' + (err.message || err));
            }
        });
    }

    // ── Create new folder / file (server mode) ────────────────────────────

    // Reject names with path separators, leading dots, or empty input —
    // mirrors the server-side hidden-segment / no-traversal guards so
    // the user sees the rejection without a round-trip.
    function validateName(name) {
        name = (name || '').trim();
        if (!name) return { ok: false, msg: 'Name required.' };
        if (name.indexOf('/') !== -1) return { ok: false, msg: 'No slashes allowed.' };
        if (name === '.' || name === '..') return { ok: false, msg: 'Invalid name.' };
        if (name.charAt(0) === '.' || name.charAt(0) === '_') {
            return { ok: false, msg: 'Names beginning with "." or "_" are reserved.' };
        }
        return { ok: true, name: name };
    }

    // Resolve "the directory new items go into" for a given row.
    // Folders/zips: create inside them. Files: create alongside (in
    // their parent). Used by the row-context New menu items.
    function parentDirFor(node) {
        var parentDir;
        if (!node) {
            parentDir = state.currentPath || '/';
        } else if (node.isDir || node.isZip) {
            parentDir = tree.pathFor(node);
        } else if (node.parentId != null) {
            var parent = state.nodes.get(node.parentId);
            parentDir = parent ? tree.pathFor(parent) : (state.currentPath || '/');
        } else {
            parentDir = state.currentPath || '/';
        }
        if (!parentDir.endsWith('/')) parentDir += '/';
        return parentDir;
    }

    async function createInDir(parentDir, kind) {
        var up = window.app.modules.upload;
        if (!up) return;
        var promptMsg = kind === 'folder'
            ? 'New folder name (under ' + parentDir + '):'
            : 'New markdown filename (under ' + parentDir + '):';
        var defaultName = kind === 'folder' ? 'new-folder' : 'new.md';
        var raw = window.prompt(promptMsg, defaultName);
        if (raw == null) return;
        var v = validateName(raw);
        if (!v.ok) {
            statusError(v.msg);
            return;
        }
        try {
            if (kind === 'folder') {
                await up.makeDir(parentDir, v.name);
                statusInfo('Created folder ' + v.name);
            } else {
                var name = /\.(md|markdown)$/i.test(v.name) ? v.name : v.name + '.md';
                var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n';
                await up.makeFile(parentDir, name, template, 'text/markdown; charset=utf-8');
                statusInfo('Created ' + name);
            }
            await reloadDir(parentDir);
        } catch (e) {
            statusError('Create failed: ' + (e.message || e));
        }
    }

    function createInside(node, kind) { return createInDir(parentDirFor(node), kind); }

    // Reload a directory's children in the tree so a create/delete/
    // rename is reflected. Works for both the current scope (root)
    // and any expanded subdirectory.
    async function reloadDir(dirPath) {
        var loader = window.app.modules.loader;
        if (!loader) return;
        if (!dirPath.endsWith('/')) dirPath += '/';
        // Root-scope reload — refresh the visible top-level listing.
        if (dirPath === state.currentPath) {
            try {
                var es = state.source === 'server'
                    ? await loader.fetchServerChildren(dirPath)
                    : (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []);
                tree.setRoot(es);
            } catch (_e) { /* swallow */ }
            tree.render();
            return;
        }
        // Otherwise find the node whose path matches and reload it.
        var noSlash = dirPath.replace(/\/$/, '');
        var hit = null;
        state.nodes.forEach(function (n) {
            if (hit || !n.isDir) return;
            if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
        });
        if (hit) {
            try {
                var raw = state.source === 'server'
                    ? await loader.fetchServerChildren(dirPath)
                    : (hit.handle ? await loader.fetchFsChildren(hit.handle) : []);
                tree.setChildren(hit.id, raw);
                hit.expanded = true;
            } catch (_e) { /* swallow */ }
            tree.render();
        }
    }

    // ── Rename / Delete ───────────────────────────────────────────────────

    async function renameNode(node) {
        var up = window.app.modules.upload;
        if (!up || !up.canMutate(node)) return;
        var raw = window.prompt('Rename "' + node.name + '" to:', node.name);
        if (raw == null) return;
        var v = validateName(raw);
        if (!v.ok) { statusError(v.msg); return; }
        if (v.name === node.name) return;
        try {
            await up.renameNode(node, v.name);
            statusInfo('Renamed to ' + v.name);
            var parentPath = node.parentId != null
                ? tree.pathFor(state.nodes.get(node.parentId))
                : (state.currentPath || '/');
            await reloadDir(parentPath);
        } catch (e) {
            statusError('Rename failed: ' + (e.message || e));
        }
    }

    async function deleteNode(node) {
        var up = window.app.modules.upload;
        if (!up || !up.canMutate(node)) return;
        var what = node.isDir ? 'folder' : 'file';
        // Native confirm() is intentional — destructive actions
        // benefit from the browser's blocking, OS-styled dialog
        // (signals "this is serious"). A custom modal would look
        // friendlier; we want it to NOT look friendly.
        var msg = 'Permanently delete this ' + what + '?\n\n' + node.name;
        if (node.isDir) {
            msg += '\n\nThis will remove every file inside it.';
        }
        if (!window.confirm(msg)) return;
        try {
            await up.removeNode(node);
            statusInfo('Deleted ' + node.name);
            // Clear selection / preview when they pointed at the
            // now-gone node, so the right pane doesn't keep a ghost.
            if (state.selectedId === node.id) {
                state.selectedId = null;
                syncURLToSelection();
            }
            if (state.lastPreviewedNodeId === node.id) {
                state.lastPreviewedNodeId = null;
                var pb = document.getElementById('previewBody');
                if (pb) pb.innerHTML =
                    '<div class="preview-empty">Click a file in the tree to preview it.</div>';
                var pt = document.getElementById('previewTitle');
                if (pt) pt.textContent = 'No file selected';
                var pm = document.getElementById('previewMeta');
                if (pm) pm.textContent = '';
            }
            var parentPath = node.parentId != null
                ? tree.pathFor(state.nodes.get(node.parentId))
                : (state.currentPath || '/');
            await reloadDir(parentPath);
        } catch (e) {
            statusError('Delete failed: ' + (e.message || e));
        }
    }

    // Shared submenu (used by both the row menu and the pane menu).
    // Toggle items so the active sort is checked in both surfaces.
    var SORT_BY_ITEMS = [
        { label: 'Name',
          checked: function () { return state.sort.key === 'name'; },
          action:  function () { tree.setSortExplicit('name', 1); } },
        { label: 'Modified',
          checked: function () { return state.sort.key === 'date'; },
          action:  function () { tree.setSortExplicit('date', -1); } },
        { label: 'Size',
          checked: function () { return state.sort.key === 'size'; },
          action:  function () { tree.setSortExplicit('size', -1); } },
        { label: 'Type',
          checked: function () { return state.sort.key === 'ext'; },
          action:  function () { tree.setSortExplicit('ext', 1); } }
    ];

    // Row context menu — traditional file-manager layout:
    //   Open / Open in new tab / Pop out preview
    //   ─
    //   Download (label flips on type)
    //   ─
    //   New folder / New markdown file
    //   ─
    //   Rename / Delete                 (permission-gated, disabled
    //                                    when the row can't be mutated)
    //   ─
    //   Copy path / Copy name
    //   ─
    //   Expand / Collapse / Navigate into
    //   ─
    //   Sort by … / Show hidden files
    //
    // Items are kept VISIBLE but DISABLED when they don't apply, so
    // every menu has the same shape regardless of what the user
    // right-clicked. Predictable position = muscle memory.
    function buildTreeRowMenu(ctx) {
        var serverMode = state.source === 'server';
        var canMutate = function (c) {
            var up = window.app.modules.upload;
            return !!(up && up.canMutate(c.node));
        };
        return [
            // ── Open / preview cluster ──
            {
                label: function (c) {
                    if (c.node.isDir) return 'Open';
                    if (c.node.isZip) return 'Open archive';
                    return 'Preview';
                },
                disabled: function (c) { return !!c.node.virtual; },
                action: function (c) {
                    if (c.node.isDir || c.node.isZip) {
                        tree.toggleFolder(c.node.id);
                    } else {
                        var p = previewMod();
                        if (p) p.showFilePreview(c.node);
                    }
                }
            },
            {
                label: 'Open in new tab',
                accel: 'Ctrl+Click',
                disabled: function (c) { return !c.node.url; },
                action: function (c) {
                    if (c.node.url) window.open(c.node.url, '_blank', 'noopener');
                }
            },
            {
                label: 'Pop out preview',
                disabled: function (c) { return c.node.isDir || c.node.isZip; },
                action: function (c) {
                    var p = previewMod();
                    if (p) p.showFilePreview(c.node, { popup: true });
                }
            },
            { separator: true },

            // ── Download (single item; label flips on type) ──
            {
                label: function (c) { return c.node.isDir ? 'Download ZIP' : 'Download'; },
                icon: '⤓',
                disabled: function (c) { return !!c.node.virtual; },
                action: function (c) {
                    var d = window.app.modules.download;
                    if (!d) return;
                    if (c.node.isDir) d.downloadFolder(c.node);
                    else d.downloadFile(c.node);
                }
            },
            { separator: true },

            // ── Create new (in the row's parent folder) ──
            {
                label: 'New folder',
                disabled: !serverMode,
                action: function (c) { createInside(c.node, 'folder'); }
            },
            {
                label: 'New markdown file',
                disabled: !serverMode,
                action: function (c) { createInside(c.node, 'markdown'); }
            },
            { separator: true },

            // ── Rename + Delete (the permission-gated pair) ──
            {
                label: 'Rename…',
                disabled: function (c) { return !canMutate(c); },
                action: function (c) { renameNode(c.node); }
            },
            {
                label: 'Delete…',
                icon: '🗑',
                danger: true,
                disabled: function (c) { return !canMutate(c); },
                action: function (c) { deleteNode(c.node); }
            },
            { separator: true },

            // ── Clipboard / identifiers ──
            {
                label: 'Copy path',
                action: function (c) {
                    var path = tree.pathFor(c.node);
                    if (navigator.clipboard && navigator.clipboard.writeText) {
                        navigator.clipboard.writeText(path).then(
                            function () { statusInfo('Copied: ' + path); },
                            function () { statusError('Clipboard copy denied'); }
                        );
                    } else {
                        statusInfo(path);
                    }
                }
            },
            {
                label: 'Copy name',
                action: function (c) {
                    // Always include the file extension. node.name
                    // already does for normal listings, but re-joining
                    // via zddc.joinExtension is defensive against any
                    // upstream that ever returns the basename split.
                    var n = c.node.name;
                    var ext = c.node.ext;
                    if (!c.node.isDir && ext
                        && !n.toLowerCase().endsWith('.' + ext.toLowerCase())) {
                        n = window.zddc.joinExtension(n, ext);
                    }
                    if (navigator.clipboard && navigator.clipboard.writeText) {
                        navigator.clipboard.writeText(n);
                    }
                    statusInfo('Copied: ' + n);
                }
            },
            { separator: true },

            // ── Tree-view ops (folder/zip rows only) ──
            {
                label: 'Expand subtree',
                accel: 'Shift+Click',
                disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
                action: function (c) { tree.expandSubtree(c.node.id); }
            },
            {
                label: 'Collapse subtree',
                disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
                action: function (c) { tree.collapseSubtree(c.node.id); }
            },
            {
                label: 'Navigate into',
                accel: 'Dbl-click',
                disabled: function (c) { return !c.node.isDir; },
                action: function (c) { navigateIntoFolder(c.node); }
            },
            { separator: true },

            // ── Plan Review (received/<tracking>/ only, cascade-gated) ──
            {
                label: 'Plan Review…',
                visible: function (c) {
                    if (!serverMode) return false;
                    if (!state.scopeOnPlanReview) return false;
                    var pr = window.app.modules.planReview;
                    if (!pr) return false;
                    return pr.isReceivedTrackingFolder(c.node);
                },
                action: function (c) {
                    var pr = window.app.modules.planReview;
                    if (pr) pr.invoke(c.node);
                }
            },
            // ── Accept Transmittal (transmittal folder under incoming/) ──
            {
                label: 'Accept Transmittal…',
                visible: function (c) {
                    if (!serverMode) return false;
                    var at = window.app.modules.acceptTransmittal;
                    if (!at) return false;
                    return at.isAcceptableTransmittalFolder(c.node);
                },
                action: function (c) {
                    var at = window.app.modules.acceptTransmittal;
                    if (at) at.invoke(c.node);
                }
            },
            // ── Stage / Unstage (files under working/ or staging/) ──
            {
                label: 'Stage to…',
                visible: function (c) {
                    if (!serverMode) return false;
                    var s = window.app.modules.stage;
                    return !!(s && s.isStageableFile(c.node));
                },
                action: function (c) {
                    var s = window.app.modules.stage;
                    if (s) s.invokeStage(c.node);
                }
            },
            {
                label: 'Unstage to working/',
                visible: function (c) {
                    if (!serverMode) return false;
                    var s = window.app.modules.stage;
                    return !!(s && s.isUnstageableFile(c.node));
                },
                action: function (c) {
                    var s = window.app.modules.stage;
                    if (s) s.invokeUnstage(c.node);
                }
            },
            { separator: true },

            // ── View ──
            { label: 'Sort by',         items: SORT_BY_ITEMS },
            { label: 'Show hidden files',
              checked: function () { return !!state.showHidden; },
              action:  function () {
                  state.showHidden = !state.showHidden;
                  syncURLToSelection();
                  refreshListing();
              } }
        ];
    }

    // Right-click on empty space in the tree pane → directory-scope
    // menu. Operations apply to the current scope (state.currentPath),
    // not any specific row.
    function buildPaneMenu() {
        var serverMode = state.source === 'server';
        return [
            {
                label: 'New folder',
                disabled: !serverMode,
                action: function () { createInDir(state.currentPath || '/', 'folder'); }
            },
            {
                label: 'New markdown file',
                disabled: !serverMode,
                action: function () { createInDir(state.currentPath || '/', 'markdown'); }
            },
            // ── Create Transmittal folder (staging/ scope only) ──
            {
                label: 'Create Transmittal folder…',
                visible: function () {
                    return serverMode && state.scopeCanonicalFolder === 'staging';
                },
                action: function () {
                    var ct = window.app.modules.createTransmittal;
                    if (ct) ct.invoke();
                }
            },
            { separator: true },
            {
                label: 'Refresh',
                accel: 'F5',
                action: function () { refreshListing(); }
            },
            { separator: true },
            { label: 'Sort by',         items: SORT_BY_ITEMS },
            { label: 'Show hidden files',
              checked: function () { return !!state.showHidden; },
              action:  function () {
                  state.showHidden = !state.showHidden;
                  syncURLToSelection();
                  refreshListing();
              } }
        ];
    }

    // View mode is URL-driven, not UI-driven.
    //
    //   ?view=grid    → grid mode (only honored where classifier is
    //                   available; otherwise falls back to browse)
    //   ?view=browse  → browse mode (always)
    //   default       → path-based: grid when inside an incoming/
    //                   subtree, browse everywhere else
    //
    // resolveViewMode reads the current location and returns the mode
    // to render; applyResolvedViewMode toggles the panes accordingly.
    // Called on initial load and on every client-side rescope.
    function resolveViewMode() {
        var qs = new URLSearchParams(window.location.search);
        var explicit = (qs.get('view') || '').toLowerCase();
        var grid = window.app.modules.grid;
        var classifierHere = !!(grid && grid.availableHere && grid.availableHere());
        if (explicit === 'grid') return classifierHere ? 'grid' : 'browse';
        if (explicit === 'browse') return 'browse';
        return classifierHere ? 'grid' : 'browse';
    }

    function applyResolvedViewMode() {
        var mode = resolveViewMode();
        state.viewMode = mode;
        var browseView = document.getElementById('browseView');
        var gridView = document.getElementById('gridView');
        if (mode === 'grid') {
            if (browseView) browseView.classList.add('hidden');
            if (gridView) gridView.classList.remove('hidden');
            var grid = window.app.modules.grid;
            if (grid) {
                if (grid.reset) grid.reset();
                if (grid.activate) grid.activate();
            }
        } else {
            if (browseView) browseView.classList.remove('hidden');
            if (gridView) gridView.classList.add('hidden');
        }
    }

    async function navigateIntoFolder(node) {
        if (state.source === 'server') {
            // Rescope client-side rather than hard-navigating. A hard
            // nav would let zddc-server's auto-serve kick in and swap
            // us out of browse for canonical folders (e.g. /archive/
            // → archive tool, /staging/ → transmittal). Staying in
            // browse is what the user asked for; pushState keeps the
            // URL bar accurate so a reload would re-load browse at the
            // new scope.
            var url = window.app.modules.tree.pathFor(node);
            if (!url.endsWith('/')) url += '/';
            await rescopeServer(url, node.name);
            return;
        }
        if (state.source === 'fs') {
            if (!node.handle || node.handle.kind !== 'directory') return;
            state.rootHandle = node.handle;
            state.currentPath = node.handle.name + '/';
            var raw;
            try {
                raw = await loader.fetchFsChildren(node.handle);
            } catch (e) {
                statusError('Failed to enter ' + node.name + ': ' + e.message);
                return;
            }
            tree.setRoot(raw);
            tree.render();
            statusInfo('Entered ' + node.name);
        }
    }

    // Client-side rescope for server mode. Updates the URL via
    // history.pushState, fetches the new directory listing, and
    // re-renders the tree from scratch. Page DOES NOT reload.
    async function rescopeServer(url, displayName) {
        var entries;
        try {
            entries = await loader.fetchServerChildren(url);
        } catch (e) {
            statusError('Failed to enter ' + displayName + ': ' + (e.message || e));
            return;
        }
        state.currentPath = url;
        // Selection / preview belong to the old scope; clear them so
        // the new root doesn't carry stale highlight state.
        state.selectedId = null;
        state.lastPreviewedNodeId = null;
        // Virtual canonical folders are emitted by zddc-server itself
        // (so .zddc display: overrides apply uniformly); no client-side
        // merge needed.
        tree.setRoot(entries);
        tree.render();
        // Reset the preview pane so the user sees an "empty selection"
        // state at the new scope instead of the previous file.
        var previewBody = document.getElementById('previewBody');
        if (previewBody) previewBody.innerHTML = '';
        var previewTitle = document.getElementById('previewTitle');
        if (previewTitle) previewTitle.textContent = 'No file selected';
        var previewMeta = document.getElementById('previewMeta');
        if (previewMeta) previewMeta.textContent = '';
        // pushState so the URL bar reflects the new scope. A real
        // reload would re-load browse at this URL (trailing slash →
        // ServeDirectory → embedded browse SPA). Then immediately
        // replaceState via syncURLToSelection so the new URL also
        // carries ?hidden=1 if the toggle is on (selection is null
        // at the new scope; the query gets only `hidden`).
        try {
            history.pushState({ zddcBrowse: true, path: url }, '', url);
        } catch (_e) { /* private browsing edge cases */ }
        syncURLToSelection();
        statusInfo('Entered ' + displayName);
        // The new scope may have a different default view (grid inside
        // incoming/, browse elsewhere). Re-resolve from the URL now
        // that pushState has updated it.
        applyResolvedViewMode();
    }

    // Public API
    window.app.modules.events = {
        init: init,
        statusError: statusError,
        statusInfo: statusInfo,
        statusClear: statusClear,
        showBrowseRoot: showBrowseRoot,
        applyResolvedViewMode: applyResolvedViewMode
    };
})();
