// 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++;
        // ZIP files are treated as folders for tree purposes — the
        // chevron lets the user expand them inline. The actual
        // contents are loaded on first expand via JSZip.
        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,
            zipFile: null,           // cached JSZip instance
            zipPath: raw.zipPath || null,  // path within zip (for virtual children)
            zipParentId: raw.zipParentId || null,  // ancestor zip's node id (for nested entries)
            // 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
        };
        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.
    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;
                out.push(ids[i]);
                if ((n.isDir || n.isZip) && 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;
    }

    // ── 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;');
    }

    // 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 = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄');
        var chevronClass = 'tree-name__chevron'
            + (expandable ? '' : ' tree-name__chevron--leaf');
        var selected = state.selectedId === node.id ? ' is-selected' : '';
        var virtualCls = node.virtual ? ' tree-row--virtual' : '';
        var virtualHint = node.virtual
            ? '<span class="tree-name__hint" title="Folder not yet created on disk — opens an empty workspace">(empty)</span>'
            : '';
        return ''
            + '<div class="tree-row ' + (node.expanded ? '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 + '"></span>'
            + '<span class="tree-name__icon">' + iconChar + '</span>'
            + '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">'
                + escapeHtml(node.displayName || node.name) + '</span>'
            + 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;
        updateCount();
        renderBreadcrumbs();
    }

    // Count nodes that render at the root + every expanded subtree.
    function expandedSetSize() {
        var n = 0;
        function walk(ids) {
            for (var i = 0; i < ids.length; i++) {
                n++;
                var node = state.nodes.get(ids[i]);
                if (node && (node.isDir || node.isZip) && node.expanded) {
                    walk(node.childIds);
                }
            }
        }
        walk(state.rootIds);
        return n;
    }

    function updateCount() {
        var el = document.getElementById('entryCount');
        if (!el) return;
        var total = expandedSetSize();
        el.textContent = total + ' item' + (total === 1 ? '' : 's');
    }

    // ── 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.

    // Load a folder's children (lazy; idempotent re-loads). Dispatches
    // by node kind:
    //   - regular folder → server JSON listing OR FS-API enumeration
    //   - zip file       → fetch+JSZip; entries become virtual children
    //   - zip child dir  → already-listed entries from the parent zip
    //                       (zips are enumerated whole, so child dirs
    //                       are pre-populated when the zip expands)
    async function loadChildren(node) {
        if (node.loaded) return;
        try {
            if (node.isZip) {
                await loadZipChildren(node);
            } else if (node._zipSyntheticDir) {
                // Synthetic dir node materialized when a zip's entry
                // list referenced "a/b/file" but had no "a/" entry.
                // Re-walk the owning zip's flat entry list with the
                // dir's full prefix.
                var owner = state.nodes.get(node.zipParentId);
                if (!owner || !owner.zipEntries) {
                    throw new Error('zip parent not loaded');
                }
                setZipDirChildren(node, owner, node.zipPath + '/');
            } 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);
        }
    }

    // Fetch a zip's bytes, parse with JSZip, and materialize its
    // entries as a tree of virtual nodes. JSZip's entry list is flat
    // (full paths); we reconstruct the directory hierarchy on top.
    async function loadZipChildren(zipNode) {
        await loader.ensureJSZip();
        var arrayBuffer;
        if (state.source === 'server' && zipNode.url) {
            var resp = await fetch(zipNode.url);
            if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + zipNode.url);
            arrayBuffer = await resp.arrayBuffer();
        } else if (zipNode.handle) {
            // FS-API: top-level zip in a local folder.
            var f = await zipNode.handle.getFile();
            arrayBuffer = await f.arrayBuffer();
        } else if (zipNode.zipParentId != null) {
            // Nested zip inside another zip — read from parent JSZip.
            var parent = state.nodes.get(zipNode.zipParentId);
            if (!parent || !parent.zipFile) {
                throw new Error('parent zip not loaded');
            }
            arrayBuffer = await parent.zipFile.file(zipNode.zipPath).async('arraybuffer');
        } else {
            throw new Error('cannot fetch zip bytes (no source)');
        }
        var zip = await window.JSZip.loadAsync(arrayBuffer);
        zipNode.zipFile = zip;

        // Build a path → raw-entry map. Entry paths are
        // "dir/sub/file.ext" or "dir/" for directories. We slice
        // to immediate children of zipNode (i.e. zero slashes after
        // a leading prefix). For nested directories, we synthesize
        // folder nodes that lazy-expand to the next level via the
        // same raw-entry list — keep it on the zipNode for replay.
        zipNode.zipEntries = []; // for re-walk on expand of subdirs
        zip.forEach(function (relPath, entry) {
            zipNode.zipEntries.push({
                path: relPath.replace(/\/$/, ''),
                isDir: entry.dir,
                size: (entry._data && entry._data.uncompressedSize) || 0,
                modTime: entry.date instanceof Date ? entry.date : null,
                rawPath: relPath
            });
        });

        // Now seed top-level children of the zip itself.
        setZipDirChildren(zipNode, zipNode, '');
    }

    // Populate node's childIds with the entries directly under
    // pathPrefix (relative to the owning zip). Directory entries
    // become folder nodes whose own children are seeded on first
    // expand by this same function (recursively descending zipPath).
    function setZipDirChildren(node, zipOwner, pathPrefix) {
        var seen = new Map(); // immediate child name → raw entry
        zipOwner.zipEntries.forEach(function (e) {
            if (!e.path.startsWith(pathPrefix)) return;
            var rest = e.path.substring(pathPrefix.length);
            if (rest === '') return;
            // Take the FIRST segment of the remaining path
            var slash = rest.indexOf('/');
            var firstSeg = slash === -1 ? rest : rest.substring(0, slash);
            var isImmediateFile = !e.isDir && slash === -1;
            var isImmediateDir = e.isDir && slash === -1;
            // For deeply-nested entries (rest contains a slash), we
            // surface only the first segment as a synthetic folder
            // entry. For immediate entries, we emit the entry as-is.
            if (isImmediateFile || isImmediateDir) {
                // Immediate entry — use the real metadata.
                seen.set(firstSeg, {
                    name: firstSeg,
                    isDir: e.isDir,
                    size: e.size,
                    modTime: e.modTime,
                    ext: e.isDir ? '' : loader.splitExt(firstSeg),
                    url: null,
                    handle: null,
                    zipPath: e.path,
                    zipParentId: zipOwner.id
                });
            } else if (slash !== -1 && !seen.has(firstSeg)) {
                // Deeper entry, no explicit dir entry yet — synthesize.
                seen.set(firstSeg, {
                    name: firstSeg,
                    isDir: true,
                    size: 0,
                    modTime: null,
                    ext: '',
                    url: null,
                    handle: null,
                    zipPath: pathPrefix + firstSeg,
                    zipParentId: zipOwner.id
                });
            }
        });
        // Drop existing children (re-load case)
        node.childIds.forEach(function (id) { state.nodes.delete(id); });
        node.childIds = [];
        seen.forEach(function (raw) {
            var n = newNode(raw, node.id, node.depth + 1);
            // Synthetic dir nodes inside zip don't have a dedicated
            // load path — they re-walk zipEntries on expand. Mark
            // them so the dispatcher knows.
            if (raw.isDir && !n.isZip) {
                n._zipSyntheticDir = true;
            }
            node.childIds.push(n.id);
        });
        sortNodes(node.childIds);
        node.loaded = true;
    }

    // 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('/');
    }

    // Public API
    window.app.modules.tree = {
        setRoot: setRoot,
        setChildren: setChildren,
        render: render,
        toggleFolder: toggleFolder,
        expandSubtree: expandSubtree,
        collapseSubtree: collapseSubtree,
        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
    };
})();
