// upload.js — drag-drop file upload into the current scope.
//
// Active only in server mode and only at paths where the cascade
// declares drop_target: true (see zddc/internal/zddc/lookups.go
// DropTargetAt + defaults.zddc.yaml). The loader captures the
// X-ZDDC-Drop-Target response header on every directory listing
// fetch and stamps state.scopeDropTarget; this module just reads it.
//
// At scopes where drop_target is false (or unset), the handlers
// stay armed but ignore drops silently — no visible drop-zone
// overlay. An operator can flip working/staging/incoming on or
// extend the cascade to mark additional dirs as drop targets via
// .zddc; the client follows automatically without code change.
//
// Wire model:
//   - dragenter on the document raises a counter; first-enter shows
//     the overlay.
//   - dragleave decrements; reaching zero hides the overlay.
//   - drop short-circuits: prevent default, PUT each file under the
//     current state.currentPath, surface per-file toast results,
//     refetch the listing on completion.
//
// The PUT uses fetch(`<currentPath><filename>`, method: 'PUT'). The
// server's authorizeAction enforces write ACL on the parent; a 403
// surfaces as an error toast and the rest of the batch proceeds.
//
// Per-file size cap (UPLOAD_MAX_BYTES): files larger than the cap
// are rejected client-side with a clear toast — the server would
// accept them in chunks but browse's v1 PUT is a single body, and
// dropping a 4 GB CAD bundle into the browser tab as a Blob is a
// poor experience. Operators with larger uploads should use a
// dedicated client (zddc-cli or the cache/mirror downstream).
(function () {
    'use strict';

    if (!window.app || !window.app.modules) return;

    var UPLOAD_MAX_BYTES = 256 * 1024 * 1024;       // 256 MiB per file
    var state = window.app.state;
    var enterCount = 0;
    var overlayEl = null;

    function ensureOverlay() {
        if (overlayEl) return overlayEl;
        overlayEl = document.createElement('div');
        overlayEl.className = 'upload-overlay';
        overlayEl.setAttribute('aria-hidden', 'true');
        overlayEl.innerHTML =
            '<div class="upload-overlay__panel">'
            + '<div class="upload-overlay__icon">⤴</div>'
            + '<div class="upload-overlay__title">Drop to upload</div>'
            + '<div class="upload-overlay__path" id="uploadOverlayPath"></div>'
            + '</div>';
        document.body.appendChild(overlayEl);
        return overlayEl;
    }

    function currentScopeAllows() {
        if (!state || state.source !== 'server') return false;
        // state.scopeDropTarget is set by the loader on every listing
        // fetch from the X-ZDDC-Drop-Target response header; it's a
        // boolean read of the cascade's effective drop_target flag at
        // the current path. Defaults to false when the header is
        // absent (older server or non-server response).
        return !!state.scopeDropTarget;
    }

    function showOverlay() {
        var el = ensureOverlay();
        var pathEl = el.querySelector('#uploadOverlayPath');
        if (pathEl) pathEl.textContent = state.currentPath || '/';
        el.classList.add('is-active');
    }

    function hideOverlay() {
        if (overlayEl) overlayEl.classList.remove('is-active');
    }

    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;
    }

    // Join a directory path and a relative path safely. dir is expected
    // to be /-prefixed and may or may not have a trailing /; rel is a
    // forward-slash relative path (no leading /). Each segment is
    // URI-encoded so spaces and friends survive the round trip.
    function joinUrl(dir, rel) {
        var base = dir || '/';
        if (!base.endsWith('/')) base += '/';
        return base + rel.split('/').map(encodeURIComponent).join('/');
    }

    async function uploadOne(file, destDir, relPath) {
        if (file.size > UPLOAD_MAX_BYTES) {
            return {
                file: file,
                ok: false,
                status: 0,
                message: 'too large (max ' + Math.round(UPLOAD_MAX_BYTES / 1024 / 1024) + ' MiB)'
            };
        }
        try {
            var resp = await fetch(joinUrl(destDir, relPath), {
                method: 'PUT',
                body: file,
                credentials: 'same-origin',
                headers: {
                    'Content-Type': file.type || 'application/octet-stream'
                }
            });
            return {
                file: file,
                ok: resp.ok,
                status: resp.status,
                message: resp.ok ? '' : ('HTTP ' + resp.status)
            };
        } catch (e) {
            return {
                file: file,
                ok: false,
                status: 0,
                message: (e && e.message) ? e.message : 'network error'
            };
        }
    }

    // ── Folder-upload helpers (webkitGetAsEntry recursion) ─────────────────
    // Browsers expose dropped folders only through the entries API.
    // walkEntry flattens a tree into [{ relPath, file }] so uploadOne
    // can PUT each file individually. The server's PUT auto-creates
    // intermediate directories, so no explicit mkdir is needed.

    function readAllEntries(reader) {
        return new Promise(function (resolve, reject) {
            var collected = [];
            function loop() {
                reader.readEntries(function (batch) {
                    if (batch.length === 0) return resolve(collected);
                    collected = collected.concat(batch);
                    loop();
                }, reject);
            }
            loop();
        });
    }

    function entryToFile(entry) {
        return new Promise(function (resolve, reject) {
            entry.file(resolve, reject);
        });
    }

    async function walkEntry(entry, prefix, out) {
        if (entry.isFile) {
            try {
                var f = await entryToFile(entry);
                out.push({ relPath: prefix + entry.name, file: f });
            } catch (_e) { /* skip unreadable file */ }
        } else if (entry.isDirectory) {
            var reader = entry.createReader();
            var kids = await readAllEntries(reader);
            for (var i = 0; i < kids.length; i++) {
                await walkEntry(kids[i], prefix + entry.name + '/', out);
            }
        }
    }

    // Extract { relPath, file } pairs from a DataTransfer. Uses
    // webkitGetAsEntry when available (so folder uploads work);
    // falls back to dataTransfer.files for cases where entries
    // aren't exposed (some browsers / cross-origin).
    async function collectUploads(dt) {
        var out = [];
        if (dt.items && dt.items.length) {
            var entries = [];
            for (var i = 0; i < dt.items.length; i++) {
                var item = dt.items[i];
                if (item.kind !== 'file') continue;
                var entry = typeof item.webkitGetAsEntry === 'function'
                    ? item.webkitGetAsEntry()
                    : null;
                if (entry) {
                    entries.push(entry);
                } else {
                    var f = item.getAsFile();
                    if (f) out.push({ relPath: f.name, file: f });
                }
            }
            for (var j = 0; j < entries.length; j++) {
                await walkEntry(entries[j], '', out);
            }
            if (out.length) return out;
        }
        if (dt.files) {
            for (var k = 0; k < dt.files.length; k++) {
                out.push({ relPath: dt.files[k].name, file: dt.files[k] });
            }
        }
        return out;
    }

    // Run a batch of uploads against an arbitrary destination directory.
    // Surfaces per-file errors as toasts; refreshes the tree afterward
    // so newly-uploaded entries appear. Returns { ok, fail } counts.
    async function uploadBatch(uploads, destDir) {
        var note = window.zddc && window.zddc.toast;
        if (note) {
            note('Uploading ' + uploads.length + ' item'
                + (uploads.length === 1 ? '' : 's') + '…', 'info');
        }
        var ok = 0, fail = 0;
        for (var i = 0; i < uploads.length; i++) {
            var u = uploads[i];
            var res = await uploadOne(u.file, destDir, u.relPath);
            if (res.ok) ok++;
            else {
                fail++;
                if (note) {
                    note('Upload failed: ' + u.relPath + ' — ' + res.message, 'error');
                }
            }
        }
        if (note) {
            if (fail === 0) {
                note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's')
                    + ' → ' + destDir, 'success');
            } else if (ok === 0) {
                note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error');
            } else {
                note(ok + ' uploaded, ' + fail + ' failed', 'warning');
            }
        }
        return { ok: ok, fail: fail };
    }

    // Comment upload: PUT each dropped file's bytes to the target URL.
    // The server detects the virtual <workflow>/received/ context and
    // rewrites the destination to <workflow>/<base>+C<n><suffix>, surfacing
    // the resolved path in X-ZDDC-Resolved-Path so the status line can
    // tell the user where the bytes landed.
    async function uploadCommentToTarget(targetURL, dataTransfer) {
        var note = window.zddc && window.zddc.toast;
        var files = [];
        if (dataTransfer.files && dataTransfer.files.length) {
            for (var k = 0; k < dataTransfer.files.length; k++) {
                files.push(dataTransfer.files[k]);
            }
        }
        if (files.length === 0) {
            if (note) note('No files to upload.', 'warning');
            return;
        }
        var ok = 0;
        var lastResolved = '';
        for (var i = 0; i < files.length; i++) {
            var f = files[i];
            if (f.size > UPLOAD_MAX_BYTES) {
                if (note) note('Skipped (too large): ' + f.name, 'error');
                continue;
            }
            try {
                var resp = await fetch(targetURL, {
                    method: 'PUT',
                    body: f,
                    credentials: 'same-origin',
                    headers: { 'Content-Type': f.type || 'application/octet-stream' }
                });
                if (resp.ok) {
                    ok++;
                    var hdr = resp.headers.get('X-ZDDC-Resolved-Path') || '';
                    if (hdr) lastResolved = hdr;
                } else if (note) {
                    note('Comment upload failed (' + resp.status + ')', 'error');
                }
            } catch (e) {
                if (note) note('Comment upload error: ' + (e && e.message), 'error');
            }
        }
        if (note && ok > 0) {
            var msg = 'Saved ' + ok + ' comment' + (ok === 1 ? '' : 's');
            if (lastResolved) msg += ' — last at ' + lastResolved;
            note(msg, 'success');
        }
        // Reload the current listing so the new +Cn file appears in the
        // tree. Best-effort.
        try {
            var ev = window.app.modules.events;
            if (ev && typeof ev.refreshListing === 'function') ev.refreshListing();
        } catch (_e) { /* refresh is best-effort */ }
    }

    // ── Write-permission escalation (FS-API mode) ──────────────────────────
    // The local folder is picked read-only (showDirectoryPicker mode:read)
    // so browsing never prompts. The first mutation escalates to readwrite
    // via the FS-Access permission prompt; granting on the picked root
    // covers every descendant handle. Must run under a user gesture — every
    // caller is reached from a click/menu action. No-op in server mode or
    // on browsers without the permission API.
    async function ensureWritable() {
        if (state.source !== 'fs') return;
        var root = state.rootHandle;
        if (!root || typeof root.requestPermission !== 'function') return;
        var opts = { mode: 'readwrite' };
        if ((await root.queryPermission(opts)) === 'granted') return;
        if ((await root.requestPermission(opts)) === 'granted') return;
        throw new Error('Write permission denied — grant edit access to the folder when prompted.');
    }

    // handleForDir resolves a directory PATH (FS-API mode) to its
    // FileSystemDirectoryHandle: the picked root for the current scope,
    // else the matching expanded node's handle. Returns null if unknown.
    function handleForDir(dirPath) {
        var tree = window.app.modules.tree;
        if (!dirPath.endsWith('/')) dirPath += '/';
        if (dirPath === state.currentPath) return state.rootHandle;
        var noSlash = dirPath.replace(/\/$/, '');
        var hit = null;
        state.nodes.forEach(function (n) {
            if (hit || !n.isDir || !n.handle) return;
            if (tree && tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
        });
        return hit ? hit.handle : null;
    }

    // ── Create-new helpers ────────────────────────────────────────────────
    // Server mode: PUT for files (empty/template body) and POST +
    // X-ZDDC-Op: mkdir for directories; the server's ACL is the source of
    // truth. FS-API mode: create directly in the picked tree via
    // getDirectoryHandle/getFileHandle({create:true}) + createWritable —
    // limited only by the filesystem permission the user granted.

    async function makeDir(parentDir, name) {
        if (state.source === 'server') {
            var url = joinUrl(parentDir, name);
            if (!url.endsWith('/')) url += '/';
            var resp = await fetch(url, {
                method: 'POST',
                credentials: 'same-origin',
                headers: { 'X-ZDDC-Op': 'mkdir' }
            });
            if (!resp.ok) throw new Error('HTTP ' + resp.status);
            return;
        }
        var parent = handleForDir(parentDir);
        if (!parent) throw new Error('No directory handle for ' + parentDir);
        await ensureWritable();
        await parent.getDirectoryHandle(name, { create: true });
    }

    async function makeFile(parentDir, name, body, contentType) {
        if (state.source === 'server') {
            var resp = await fetch(joinUrl(parentDir, name), {
                method: 'PUT',
                credentials: 'same-origin',
                headers: { 'Content-Type': contentType || 'application/octet-stream' },
                body: body == null ? '' : body
            });
            if (!resp.ok) throw new Error('HTTP ' + resp.status);
            return;
        }
        var parent = handleForDir(parentDir);
        if (!parent) throw new Error('No directory handle for ' + parentDir);
        await ensureWritable();
        var fh = await parent.getFileHandle(name, { create: true });
        var w = await fh.createWritable();
        await w.write(body == null ? '' : body);
        await w.close();
    }

    // ── Delete + rename ─────────────────────────────────────────────────────
    // Both run through the same FS Access API + file-API endpoints used
    // by the create helpers above:
    //   - Server mode: DELETE / POST X-ZDDC-Op: move. ACL is enforced
    //     server-side; a 403/405 surfaces as an error toast.
    //   - FS-API mode: FileSystemHandle.remove({recursive:true}) and
    //     .move(newName) — both are Chromium-110+ features. We feature-
    //     detect at the handle level; callers see a clear "not supported"
    //     error message if the browser is too old.

    function pathForNode(node) {
        var tree = window.app.modules.tree;
        return tree ? tree.pathFor(node) : '';
    }

    function isZipMember(node) {
        if (node.handle && node.handle.isZipEntry) return true;
        if (node.url && state.source === 'server' && /\.zip\//i.test(node.url)) {
            return true;
        }
        return false;
    }

    // True when this node's write API is reachable. The server can
    // still refuse the action on ACL grounds; this only gates the
    // menu's disabled-state for the cases where there's clearly no
    // write target at all.
    function canMutate(node) {
        if (!node || node.virtual) return false;
        if (isZipMember(node)) return false;
        if (state.source === 'server') return true;
        if (node.handle && typeof node.handle.remove === 'function') return true;
        return false;
    }

    async function removeNode(node) {
        if (!node) throw new Error('no node');
        if (isZipMember(node)) {
            throw new Error('Cannot delete a file inside a zip archive.');
        }
        if (node.virtual) {
            throw new Error('Virtual folder — nothing on disk to delete.');
        }
        if (state.source === 'server') {
            var url = pathForNode(node);
            if (node.isDir && !url.endsWith('/')) url += '/';
            var resp = await fetch(url, {
                method: 'DELETE',
                credentials: 'same-origin'
            });
            if (!resp.ok) {
                if (resp.status === 403) throw new Error('Permission denied (403).');
                if (resp.status === 405) throw new Error('Delete not allowed for this entry.');
                throw new Error('HTTP ' + resp.status);
            }
            return;
        }
        // FS-API path. FileSystemHandle.remove() is Chromium 110+
        // (browsers that didn't ship it expose no equivalent — the
        // legacy removeEntry() lives on the PARENT directory handle
        // and we don't retain ancestor handles).
        if (node.handle && typeof node.handle.remove === 'function') {
            await ensureWritable();
            await node.handle.remove({ recursive: !!node.isDir });
            return;
        }
        throw new Error('Delete not supported by this browser in offline mode.');
    }

    async function renameNode(node, newName) {
        if (!node) throw new Error('no node');
        if (!newName) throw new Error('Name required.');
        if (newName === node.name) return;
        if (isZipMember(node)) {
            throw new Error('Cannot rename a file inside a zip archive.');
        }
        if (node.virtual) {
            throw new Error('Virtual folder — nothing on disk to rename.');
        }
        if (state.source === 'server') {
            var src = pathForNode(node);
            if (node.isDir && !src.endsWith('/')) src += '/';
            // Destination = same parent, new basename.
            var lastSlash = src.replace(/\/$/, '').lastIndexOf('/');
            var parent = lastSlash >= 0 ? src.substring(0, lastSlash + 1) : '/';
            var dst = parent + encodeURIComponent(newName) + (node.isDir ? '/' : '');
            var resp = await fetch(src, {
                method: 'POST',
                credentials: 'same-origin',
                headers: {
                    'X-ZDDC-Op': 'move',
                    'X-ZDDC-Destination': dst
                }
            });
            if (!resp.ok) {
                if (resp.status === 403) throw new Error('Permission denied (403).');
                if (resp.status === 409) throw new Error('A file with that name already exists.');
                throw new Error('HTTP ' + resp.status);
            }
            return;
        }
        // FS-API: handle.move(newName) is Chromium 110+.
        if (node.handle && typeof node.handle.move === 'function') {
            await ensureWritable();
            await node.handle.move(newName);
            return;
        }
        throw new Error('Rename not supported by this browser in offline mode.');
    }

    // Refresh either the root listing (when the upload targeted the
    // current scope) or just one folder node's children (when the
    // upload targeted a subfolder via a per-row drop).
    async function refreshAfterUpload(targetDir) {
        var loader = window.app.modules.loader;
        var tree = window.app.modules.tree;
        if (!loader || !tree) return;
        if (state.currentPath && targetDir === state.currentPath) {
            try {
                var es = await loader.fetchServerChildren(state.currentPath);
                tree.setRoot(es);
                tree.render();
            } catch (_e) { /* swallow */ }
            return;
        }
        // Find any tree node whose path matches targetDir and reload
        // its children. Walks state.nodes flat — n is small enough for
        // a linear scan.
        var dirNoSlash = (targetDir || '').replace(/\/$/, '');
        var hit = null;
        state.nodes.forEach(function (n) {
            if (hit || !n.isDir) return;
            if (tree.pathFor(n).replace(/\/$/, '') === dirNoSlash) hit = n;
        });
        if (hit && hit.expanded) {
            try {
                var raw = await loader.fetchServerChildren(targetDir);
                tree.setChildren(hit.id, raw);
                tree.render();
            } catch (_e) { /* swallow */ }
        }
    }

    // Document-level drop: targets the currently-viewed scope. The
    // per-row drop (events.js) calls uploadToDir directly with a
    // different destination.
    async function handleDrop(e) {
        e.preventDefault();
        e.stopPropagation();
        enterCount = 0;
        hideOverlay();

        if (!currentScopeAllows()) return;
        var dt = e.dataTransfer;
        if (!dt) return;
        var uploads = await collectUploads(dt);
        if (!uploads.length) return;
        await uploadBatch(uploads, state.currentPath);
        await refreshAfterUpload(state.currentPath);
    }

    // Public entry for per-row drops or programmatic uploads. destDir
    // must be a server path (/-prefixed, slash-terminated optional).
    async function uploadToDir(destDir, dataTransfer) {
        var uploads = await collectUploads(dataTransfer);
        if (!uploads.length) return { ok: 0, fail: 0 };
        var res = await uploadBatch(uploads, destDir);
        await refreshAfterUpload(destDir);
        return res;
    }

    function onEnter(e) {
        if (!dragHasFiles(e)) return;
        enterCount++;
        if (enterCount === 1 && currentScopeAllows()) {
            showOverlay();
        }
    }

    function onLeave(e) {
        if (!dragHasFiles(e)) return;
        enterCount = Math.max(0, enterCount - 1);
        if (enterCount === 0) hideOverlay();
    }

    function onOver(e) {
        if (!dragHasFiles(e)) return;
        // preventDefault on dragover is required for drop to fire.
        e.preventDefault();
        if (e.dataTransfer && currentScopeAllows()) {
            e.dataTransfer.dropEffect = 'copy';
        } else if (e.dataTransfer) {
            e.dataTransfer.dropEffect = 'none';
        }
    }

    function init() {
        document.addEventListener('dragenter', onEnter, false);
        document.addEventListener('dragleave', onLeave, false);
        document.addEventListener('dragover',  onOver,  false);
        document.addEventListener('drop',      handleDrop, false);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    window.app.modules.upload = {
        currentScopeAllows: currentScopeAllows,
        uploadToDir: uploadToDir,
        uploadCommentToTarget: uploadCommentToTarget,
        makeDir: makeDir,
        makeFile: makeFile,
        removeNode: removeNode,
        renameNode: renameNode,
        canMutate: canMutate,
        ensureWritable: ensureWritable,
        UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES
    };
})();
