// Files-overlay UI behavior. Activated only on overlay detail pages whose
// `<div class="files-manager">` exists (set by the template when the
// overlay is type='files' and the user can edit). The script binds:
//
//   * Per-row hover actions: + new file, + new folder, ⬇ zip, ✕ on
//     folders; ⬇ (download), ✕ on files. Clicking the filename opens
//     the editor (binary fallback for non-editable files).
//   * Drag-and-drop: dragging from the OS uploads (one POST per file,
//     queue with concurrency 3); dragging a row inside the tree moves
//     (rename/move via /files/move).
//   * Editor modal: text mode for editable files; binary "details +
//     replace upload" mode for everything else; doubles as new-file
//     dialog. Filename input is the rename surface.
//   * New-folder modal, conflict-resolution modal, delete-confirm modal.
//   * Upload progress panel with per-file rows.
//
// All operations use the `/overlays/<id>/files/...` JSON / multipart
// endpoints. CSRF token comes from the <meta name="csrf-token"> tag.

(function () {
  "use strict";

  const manager = document.querySelector(".files-manager");
  if (!manager) return;

  const overlayId = manager.dataset.overlayId;
  const baseUrl = manager.dataset.baseUrl; // /overlays/<id>
  const treeRoot = manager.querySelector(".files-tree-root");
  const csrfToken =
    document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || "";

  const editorDialog = document.getElementById("files-editor-modal");
  const newFolderDialog = document.getElementById("files-new-folder-modal");
  const conflictDialog = document.getElementById("files-conflict-modal");
  const deleteDialog = document.getElementById("files-delete-modal");
  const uploadsPanel = document.querySelector(".files-uploads");
  const uploadsList = uploadsPanel?.querySelector(".files-uploads-list");
  const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear");

  // ---------- helpers ------------------------------------------------------

  function joinPath(folder, leaf) {
    folder = (folder || "").replace(/^\/+|\/+$/g, "");
    leaf = (leaf || "").replace(/^\/+|\/+$/g, "");
    if (folder && leaf) return folder + "/" + leaf;
    return folder || leaf;
  }

  function parentOf(rel) {
    const i = (rel || "").lastIndexOf("/");
    return i < 0 ? "" : rel.slice(0, i);
  }

  function basename(rel) {
    const i = (rel || "").lastIndexOf("/");
    return i < 0 ? rel : rel.slice(i + 1);
  }

  function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, (c) => ({
      "&": "&amp;",
      "<": "&lt;",
      ">": "&gt;",
      '"': "&quot;",
      "'": "&#39;",
    })[c]);
  }

  function humanSize(bytes) {
    if (bytes === undefined || bytes === null) return "";
    if (bytes < 1024) return bytes + " B";
    const units = ["KB", "MB", "GB", "TB"];
    let v = bytes / 1024;
    let u = "KB";
    for (const next of units) {
      u = next;
      if (v < 1024) break;
      v /= 1024;
    }
    return v.toFixed(1) + " " + u;
  }

  async function fetchJson(url, options) {
    options = options || {};
    options.headers = Object.assign({ Accept: "application/json" }, options.headers || {});
    if (options.method && options.method !== "GET") {
      options.headers["X-CSRF-Token"] = csrfToken;
    }
    options.credentials = "same-origin";
    const response = await fetch(url, options);
    let body = null;
    let rawText = "";
    try {
      rawText = await response.text();
      body = rawText ? JSON.parse(rawText) : null;
    } catch (_e) {
      body = null;
    }
    return { ok: response.ok, status: response.status, body, rawText };
  }

  async function postJson(url, payload) {
    return fetchJson(url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
  }

  async function postForm(url, formData) {
    return fetchJson(url, { method: "POST", body: formData });
  }

  // ---------- tree refresh ------------------------------------------------

  // Re-fetch a folder's listing partial and swap it into the tree.
  // `path === ""` refreshes the overlay root container.
  async function refreshFolder(path) {
    if (!treeRoot) return;
    const url = `${baseUrl}/files?path=${encodeURIComponent(path || "")}`;
    let html;
    try {
      const r = await fetch(url, {
        headers: { Accept: "text/html" },
        credentials: "same-origin",
      });
      if (!r.ok) {
        if (r.status === 404) {
          // Folder no longer exists — refresh its parent instead.
          if (path) await refreshFolder(parentOf(path));
          return;
        }
        return;
      }
      html = await r.text();
    } catch (_e) {
      return;
    }

    if (!path) {
      // Overlay root — swap into the synthetic root row's children div.
      const target = manager.querySelector(".files-root-children");
      if (!target) return;
      const empty = target.querySelector(".files-empty");
      if (empty) empty.remove();
      const existingUl = target.querySelector(":scope > ul.file-tree");
      if (existingUl) existingUl.remove();
      target.insertAdjacentHTML("beforeend", html);
      // If the new content is also empty, restore the placeholder.
      const newUl = target.querySelector(":scope > ul.file-tree");
      if (newUl && newUl.children.length === 0) {
        newUl.remove();
        const p = document.createElement("p");
        p.className = "muted files-empty";
        p.textContent = 'Empty — drop files here, or click "+ new file" on this row.';
        target.appendChild(p);
      }
      return;
    }

    // Sub-folder: find the matching folder row and swap its children div.
    const row = findRowByPath(path, "dir");
    if (!row) return;
    const childrenDiv = row.querySelector(":scope > .file-tree-children");
    const toggleBtn = row.querySelector(":scope > .file-tree-toggle");
    if (!childrenDiv || !toggleBtn) return;
    childrenDiv.innerHTML = html;
    childrenDiv.hidden = false;
    toggleBtn.setAttribute("aria-expanded", "true");
    toggleBtn.dataset.loaded = "1";
  }

  function findRowByPath(path, kind) {
    const sel = kind
      ? `[data-target-path="${cssEscape(path)}"][data-row-kind="${kind}"]`
      : `[data-target-path="${cssEscape(path)}"]`;
    return treeRoot ? treeRoot.querySelector(sel) : null;
  }

  function cssEscape(s) {
    if (window.CSS && window.CSS.escape) return window.CSS.escape(s);
    return String(s).replace(/["\\]/g, "\\$&");
  }

  // Debounce per-folder refreshes so a flurry of finishes coalesces.
  const pendingRefresh = new Map();
  function scheduleRefresh(path) {
    const key = path || "";
    if (pendingRefresh.has(key)) return;
    const timer = setTimeout(() => {
      pendingRefresh.delete(key);
      refreshFolder(key);
    }, 50);
    pendingRefresh.set(key, timer);
  }

  // ---------- conflict modal ----------------------------------------------

  function askConflict(path) {
    return new Promise((resolve) => {
      conflictDialog.querySelector(".files-conflict-path").textContent = path;
      const handler = (event) => {
        const action = event.target?.dataset?.filesConflictAction;
        if (!action) return;
        conflictDialog.removeEventListener("click", handler);
        conflictDialog.close();
        resolve(action);
      };
      conflictDialog.addEventListener("click", handler);
      conflictDialog.showModal();
    });
  }

  // Attach a path-collision suffix: foo.txt → foo (1).txt
  function withCollisionSuffix(path) {
    const dot = path.lastIndexOf(".");
    const slash = path.lastIndexOf("/");
    if (dot > slash + 0 && dot > -1) {
      return path.slice(0, dot) + " (1)" + path.slice(dot);
    }
    return path + " (1)";
  }

  // ---------- delete modal ------------------------------------------------

  function openDelete(targetPath, kind, name) {
    deleteDialog.querySelector(".files-delete-name").textContent = name;
    const errEl = deleteDialog.querySelector(".files-delete-error");
    errEl.hidden = true;
    errEl.textContent = "";

    const confirmBtn = deleteDialog.querySelector(".files-delete-confirm");
    const onConfirm = async () => {
      const fd = new FormData();
      fd.append("path", targetPath);
      fd.append("csrf_token", csrfToken);
      const r = await postForm(`${baseUrl}/files/delete`, fd);
      if (r.ok) {
        deleteDialog.close();
        scheduleRefresh(parentOf(targetPath));
      } else {
        errEl.hidden = false;
        errEl.textContent = (r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`;
      }
    };
    confirmBtn.replaceWith(confirmBtn.cloneNode(true));
    deleteDialog.querySelector(".files-delete-confirm").addEventListener("click", onConfirm);
    deleteDialog.showModal();
  }

  // ---------- editor modal ------------------------------------------------

  // Editor state. Only one editor is open at a time.
  const editor = {
    mode: null, // "text" | "binary"
    creating: false,
    originalPath: null,
    folder: null,
    queuedReplacement: null, // File object
  };

  const editorEls = {
    title: editorDialog.querySelector(".files-editor-title-text"),
    filename: editorDialog.querySelector(".files-editor-filename"),
    renameHint: editorDialog.querySelector(".files-editor-rename-hint"),
    renameFrom: editorDialog.querySelector(".files-rename-from"),
    renameTo: editorDialog.querySelector(".files-rename-to"),
    textPanel: editorDialog.querySelector(".files-editor-text"),
    contentBox: editorDialog.querySelector(".files-editor-content"),
    byteCount: editorDialog.querySelector(".files-editor-byte-count"),
    binaryPanel: editorDialog.querySelector(".files-editor-binary"),
    binarySize: editorDialog.querySelector(".files-editor-binary-size"),
    replaceZone: editorDialog.querySelector(".files-editor-replace-zone"),
    replaceIdle: editorDialog.querySelector(".files-editor-replace-idle"),
    replaceQueued: editorDialog.querySelector(".files-editor-replace-queued"),
    replaceName: editorDialog.querySelector(".files-editor-replace-name"),
    replaceSize: editorDialog.querySelector(".files-editor-replace-size"),
    replaceClear: editorDialog.querySelector(".files-editor-replace-clear"),
    replaceBrowse: editorDialog.querySelector(".files-editor-replace-browse"),
    replaceInput: editorDialog.querySelector(".files-editor-replace-input"),
    deleteBtn: editorDialog.querySelector(".files-editor-delete"),
    downloadBtn: editorDialog.querySelector(".files-editor-download"),
    saveBtn: editorDialog.querySelector(".files-editor-save"),
  };

  // Bridge to the CodeMirror 6 controller, set up by static/js/editor.js
  // on the .files-editor-content textarea. Falls back to the textarea
  // directly if the bundle didn't load (no-JS fallback / file open
  // before the controller has been mounted).
  function getEditorValue() {
    return (window.__filesEditor && window.__filesEditor.getValue)
      ? window.__filesEditor.getValue()
      : editorEls.contentBox.value;
  }
  function setEditorValue(text) {
    if (window.__filesEditor && window.__filesEditor.setContent) {
      window.__filesEditor.setContent(text);
    } else {
      editorEls.contentBox.value = text;
    }
  }

  function setEditorTitle(text) {
    editorEls.title.textContent = text;
  }

  function updateByteCount() {
    const bytes = new TextEncoder().encode(getEditorValue()).length;
    editorEls.byteCount.textContent = `UTF-8 · ${bytes} bytes`;
  }

  function updateRenameHint() {
    const current = editorEls.filename.value.trim();
    const original = basename(editor.originalPath || "");
    if (editor.creating || !current || current === original) {
      editorEls.renameHint.hidden = true;
      return;
    }
    editorEls.renameFrom.textContent = original;
    editorEls.renameTo.textContent = current;
    editorEls.renameHint.hidden = false;
  }

  function updateSaveEnabled() {
    if (editor.mode === "binary" && !editor.creating) {
      const filenameChanged =
        editorEls.filename.value.trim() !== basename(editor.originalPath || "");
      const hasReplacement = !!editor.queuedReplacement;
      editorEls.saveBtn.disabled = !filenameChanged && !hasReplacement;
      editorEls.saveBtn.textContent = "Save";
    } else if (editor.creating) {
      editorEls.saveBtn.disabled = !editorEls.filename.value.trim();
      editorEls.saveBtn.textContent = "Create";
    } else {
      editorEls.saveBtn.disabled = false;
      editorEls.saveBtn.textContent = "Save";
    }
  }

  function setQueuedReplacement(file) {
    editor.queuedReplacement = file;
    if (file) {
      editorEls.replaceIdle.hidden = true;
      editorEls.replaceQueued.hidden = false;
      editorEls.replaceName.textContent = file.name;
      editorEls.replaceSize.textContent = humanSize(file.size);
    } else {
      editorEls.replaceIdle.hidden = false;
      editorEls.replaceQueued.hidden = true;
    }
    updateSaveEnabled();
  }

  function openEditorTextNew(folder) {
    editor.mode = "text";
    editor.creating = true;
    editor.originalPath = null;
    editor.folder = folder;
    editor.queuedReplacement = null;

    setEditorTitle(`${folder ? folder + "/" : ""}…new file`);
    editorEls.filename.value = "";
    editorEls.filename.disabled = false;
    setEditorValue("");
    editorEls.contentBox.disabled = false;
    editorEls.renameHint.hidden = true;
    editorEls.textPanel.hidden = false;
    editorEls.binaryPanel.hidden = true;
    editorEls.deleteBtn.hidden = true;
    editorEls.downloadBtn.hidden = true;
    editorEls.saveBtn.textContent = "Create";
    updateByteCount();
    updateSaveEnabled();
    editorDialog.showModal();
    setTimeout(() => editorEls.filename.focus(), 0);
  }

  async function openEditorForFile(path, isEditable) {
    editor.creating = false;
    editor.originalPath = path;
    editor.folder = parentOf(path);
    editor.queuedReplacement = null;
    setQueuedReplacement(null);

    editorEls.filename.value = basename(path);
    editorEls.filename.disabled = false;
    editorEls.renameHint.hidden = true;
    editorEls.deleteBtn.hidden = false;
    editorEls.downloadBtn.hidden = false;
    editorEls.downloadBtn.href = `${baseUrl}/files/download?path=${encodeURIComponent(path)}`;
    setEditorTitle(path);

    if (isEditable) {
      editor.mode = "text";
      editorEls.textPanel.hidden = false;
      editorEls.binaryPanel.hidden = true;
      setEditorValue("Loading…");
      editorEls.contentBox.disabled = true;

      const r = await fetchJson(
        `${baseUrl}/files/content?path=${encodeURIComponent(path)}`
      );
      if (r.ok && r.body) {
        setEditorValue(r.body.content);
        editorEls.contentBox.disabled = false;
        updateByteCount();
        updateSaveEnabled();
        editorDialog.showModal();
        setTimeout(() => editorEls.contentBox.focus(), 0);
        return;
      }

      // Fallback: server says not editable. Re-open as binary.
      editorEls.contentBox.disabled = false;
      editor.mode = "binary";
    } else {
      editor.mode = "binary";
    }

    // Binary mode setup.
    editorEls.textPanel.hidden = true;
    editorEls.binaryPanel.hidden = false;
    editorEls.binarySize.textContent = "—"; // server gave us no size; cosmetic
    updateSaveEnabled();
    editorDialog.showModal();
    setTimeout(() => editorEls.filename.focus(), 0);
  }

  editorEls.filename.addEventListener("input", () => {
    updateRenameHint();
    updateSaveEnabled();
  });
  editorEls.contentBox.addEventListener("input", () => {
    updateByteCount();
    updateSaveEnabled();
  });
  editorEls.contentBox.addEventListener("keydown", (event) => {
    if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
      event.preventDefault();
      editorEls.saveBtn.click();
    }
  });
  editorEls.replaceClear.addEventListener("click", () => setQueuedReplacement(null));
  editorEls.replaceBrowse.addEventListener("click", () => editorEls.replaceInput.click());
  editorEls.replaceInput.addEventListener("change", () => {
    const f = editorEls.replaceInput.files && editorEls.replaceInput.files[0];
    if (f) setQueuedReplacement(f);
  });
  editorEls.replaceZone.addEventListener("dragover", (event) => {
    if (Array.from(event.dataTransfer.types).includes("Files")) {
      event.preventDefault();
      editorEls.replaceZone.classList.add("is-drop-target");
    }
  });
  editorEls.replaceZone.addEventListener("dragleave", () => {
    editorEls.replaceZone.classList.remove("is-drop-target");
  });
  editorEls.replaceZone.addEventListener("drop", (event) => {
    if (!Array.from(event.dataTransfer.types).includes("Files")) return;
    event.preventDefault();
    editorEls.replaceZone.classList.remove("is-drop-target");
    const f = event.dataTransfer.files && event.dataTransfer.files[0];
    if (f) setQueuedReplacement(f);
  });
  editorDialog.addEventListener("close", () => {
    setQueuedReplacement(null);
  });

  editorEls.saveBtn.addEventListener("click", async () => {
    const filename = editorEls.filename.value.trim();
    if (!filename) return;
    if (filename.includes("/")) {
      alert("Filename can't contain '/'. Use drag-to-move to relocate.");
      return;
    }
    const folder = editor.folder || "";
    const newRel = joinPath(folder, filename);

    if (editor.creating) {
      // Text-flavor create → /save with no new_path.
      const r = await postJson(`${baseUrl}/files/save`, {
        path: newRel,
        content: getEditorValue(),
      });
      if (r.ok) {
        editorDialog.close();
        scheduleRefresh(folder);
      } else if (r.status === 409) {
        const action = await askConflict(newRel);
        if (action === "overwrite") {
          // Re-call /save (no overwrite flag — /save just writes); skip
          // the conflict by writing in-place which is what users want.
          // First delete the colliding entry to avoid the implicit
          // "destination is not a file" branch when it's a directory.
          // For files, a plain /save overwrite is fine.
          const r2 = await postJson(`${baseUrl}/files/save`, {
            path: newRel,
            content: getEditorValue(),
          });
          if (r2.ok) {
            editorDialog.close();
            scheduleRefresh(folder);
          } else {
            alert(
              (r2.body && r2.body.error) ||
                `Save failed (HTTP ${r2.status}).`
            );
          }
        } else if (action === "keep-both") {
          const altered = withCollisionSuffix(newRel);
          const r2 = await postJson(`${baseUrl}/files/save`, {
            path: altered,
            content: getEditorValue(),
          });
          if (r2.ok) {
            editorDialog.close();
            scheduleRefresh(folder);
          }
        }
      } else {
        alert((r.body && r.body.error) || `Save failed (HTTP ${r.status}).`);
      }
      return;
    }

    const renaming = newRel !== editor.originalPath;
    if (editor.mode === "text") {
      const payload = {
        path: editor.originalPath,
        content: getEditorValue(),
      };
      if (renaming) payload.new_path = newRel;
      const r = await postJson(`${baseUrl}/files/save`, payload);
      if (r.ok) {
        editorDialog.close();
        scheduleRefresh(folder);
      } else {
        alert(
          (r.body && r.body.error) || `Save failed (HTTP ${r.status}).`
        );
      }
      return;
    }

    // Binary mode.
    const fd = new FormData();
    fd.append("path", editor.originalPath);
    fd.append("csrf_token", csrfToken);
    if (renaming) fd.append("new_path", newRel);
    if (editor.queuedReplacement) {
      fd.append("file", editor.queuedReplacement);
      const r = await postForm(`${baseUrl}/files/replace`, fd);
      if (r.ok) {
        editorDialog.close();
        scheduleRefresh(folder);
      } else {
        alert(
          (r.body && r.body.error) || `Replace failed (HTTP ${r.status}).`
        );
      }
    } else if (renaming) {
      // Rename only via /move (no content change).
      const r = await postJson(`${baseUrl}/files/move`, {
        src: editor.originalPath,
        dst: newRel,
      });
      if (r.ok) {
        editorDialog.close();
        scheduleRefresh(folder);
      } else {
        alert(
          (r.body && r.body.error) || `Rename failed (HTTP ${r.status}).`
        );
      }
    }
  });

  editorEls.deleteBtn.addEventListener("click", async () => {
    if (!editor.originalPath) return;
    if (!confirm(`Delete ${editor.originalPath}?`)) return;
    const fd = new FormData();
    fd.append("path", editor.originalPath);
    fd.append("csrf_token", csrfToken);
    const r = await postForm(`${baseUrl}/files/delete`, fd);
    if (r.ok) {
      editorDialog.close();
      scheduleRefresh(parentOf(editor.originalPath));
    } else {
      alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`);
    }
  });

  // ---------- modal-content save / delete (URL-addressable modal) ----------
  // The new server-rendered editor (loaded into #modal-content) has its own
  // .files-editor-save and .files-editor-delete buttons. Those are not the
  // same elements as editorEls.saveBtn / editorEls.deleteBtn (which live in
  // the old inline dialog still present for create-new-file / binary flows).
  // Use event delegation so the handlers fire on dynamically swapped content.

  document.addEventListener("click", async (event) => {
    const modalContent = document.getElementById("modal-content");
    if (!modalContent) return;

    const saveBtn = event.target.closest(".files-editor-save");
    if (saveBtn && modalContent.contains(saveBtn)) {
      const ta = modalContent.querySelector("textarea[data-rel-path]");
      if (!ta) return;
      const relPath = ta.dataset.relPath;
      if (!relPath) return;
      const content = (window.__filesEditor && window.__filesEditor.getValue)
        ? window.__filesEditor.getValue()
        : ta.value;

      // Rename-on-save: if the user edited the filename input, compose the
      // new path (sibling rename only — joining parent of relPath with the
      // new filename). Send payload.new_path so the server moves and writes
      // atomically. Matches the legacy save handler's contract.
      const filenameInput = modalContent.querySelector("[data-editor-filename]");
      const editedFilename = filenameInput ? filenameInput.value.trim() : "";
      const originalLeaf = relPath.split("/").pop() || relPath;
      const parent = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
      let newPath = null;
      if (editedFilename && editedFilename !== originalLeaf) {
        newPath = parent ? `${parent}/${editedFilename}` : editedFilename;
      }

      const payload = { path: relPath, content };
      if (newPath) payload.new_path = newPath;

      const r = await postJson(`${baseUrl}/files/save`, payload);
      if (r.ok) {
        if (typeof window.closeModal === "function") window.closeModal();
        scheduleRefresh(parentOf(newPath || relPath));
      } else if (r.status === 409) {
        // Conflict (destination already exists) — show error and keep modal
        // open so the user can pick a different filename.
        alert(r.rawText || `Conflict: destination already exists.`);
        return;
      } else {
        alert((r.body && r.body.error) || r.rawText || `Save failed (HTTP ${r.status}).`);
      }
      return;
    }

    const deleteBtn = event.target.closest(".files-editor-delete");
    if (deleteBtn && modalContent.contains(deleteBtn)) {
      const ta = modalContent.querySelector("textarea[data-rel-path]");
      if (!ta) return;
      const relPath = ta.dataset.relPath;
      if (!relPath) return;
      if (!confirm(`Delete ${relPath}?`)) return;
      const fd = new FormData();
      fd.append("path", relPath);
      fd.append("csrf_token", csrfToken);
      const r = await postForm(`${baseUrl}/files/delete`, fd);
      if (r.ok) {
        if (typeof window.closeModal === "function") window.closeModal();
        scheduleRefresh(parentOf(relPath));
      } else {
        alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`);
      }
      return;
    }
  });

  // ---------- new-folder modal --------------------------------------------

  function openNewFolder(targetFolder) {
    const folder = targetFolder || "";
    newFolderDialog.querySelector(".files-new-folder-target").textContent =
      folder ? folder + "/" : "/";
    const input = newFolderDialog.querySelector(".files-new-folder-name");
    const errEl = newFolderDialog.querySelector(".files-new-folder-error");
    input.value = "";
    errEl.hidden = true;
    errEl.textContent = "";

    const createBtn = newFolderDialog.querySelector(".files-new-folder-create");
    const fresh = createBtn.cloneNode(true);
    createBtn.replaceWith(fresh);
    fresh.addEventListener("click", async () => {
      const name = input.value.trim().replace(/^\/+|\/+$/g, "");
      if (!name) return;
      const fullPath = joinPath(folder, name);
      const r = await postJson(`${baseUrl}/files/mkdir`, { path: fullPath });
      if (r.ok) {
        newFolderDialog.close();
        scheduleRefresh(folder);
      } else {
        errEl.hidden = false;
        errEl.textContent =
          (r.body && r.body.error) ||
          (r.status === 409
            ? "A file or folder with that name already exists."
            : `Failed (HTTP ${r.status}).`);
      }
    });

    newFolderDialog.showModal();
    setTimeout(() => input.focus(), 0);
  }

  // Enter on the new-folder input submits — bound once at module init so
  // it survives multiple openings of the dialog. (A previous version used
  // `{once: true}` inside openNewFolder, which was consumed by the first
  // letter the user typed and never saw Enter.)
  newFolderDialog
    .querySelector(".files-new-folder-name")
    .addEventListener("keydown", (event) => {
      if (event.key !== "Enter") return;
      event.preventDefault();
      newFolderDialog.querySelector(".files-new-folder-create")?.click();
    });

  // ---------- upload queue + progress panel -------------------------------

  const uploadQueue = [];
  let uploadActive = 0;
  const UPLOAD_CONCURRENCY = 3;

  function showPanel() {
    if (uploadsPanel) uploadsPanel.hidden = false;
  }

  function maybeHidePanel() {
    if (!uploadsList) return;
    const anyActive = uploadsList.querySelector("[data-state='active'], [data-state='queued']");
    if (!anyActive && uploadsList.children.length === 0) {
      uploadsPanel.hidden = true;
      if (uploadsClearBtn) uploadsClearBtn.hidden = true;
    }
    const anyDone = uploadsList.querySelector("[data-state='done'], [data-state='error']");
    if (uploadsClearBtn) uploadsClearBtn.hidden = !anyDone;
  }

  function buildUploadRow(item) {
    const li = document.createElement("li");
    li.className = "files-uploads-row";
    li.dataset.state = "queued";
    li.innerHTML = `
      <div class="files-uploads-row-meta">
        <span class="files-uploads-row-name"></span>
        <span class="files-uploads-row-target muted"></span>
      </div>
      <div class="files-uploads-row-progress"><div class="files-uploads-bar"></div></div>
      <div class="files-uploads-row-status">
        <span class="files-uploads-row-state">queued</span>
        <button type="button" class="link-button files-uploads-row-cancel" aria-label="Cancel">✕</button>
      </div>
    `;
    li.querySelector(".files-uploads-row-name").textContent = item.relative;
    li.querySelector(".files-uploads-row-target").textContent = `→ ${item.targetFolder || "(root)"}`;
    li.querySelector(".files-uploads-row-cancel").addEventListener("click", () => cancelUpload(item));
    return li;
  }

  function setRowState(item, state, percent) {
    if (!item.row) return;
    item.row.dataset.state = state;
    const stateEl = item.row.querySelector(".files-uploads-row-state");
    const cancelBtn = item.row.querySelector(".files-uploads-row-cancel");
    const bar = item.row.querySelector(".files-uploads-bar");
    if (state === "queued") {
      stateEl.textContent = "queued";
      bar.style.width = "0%";
    } else if (state === "active") {
      stateEl.textContent = `${Math.round(percent || 0)}%`;
      bar.style.width = `${percent || 0}%`;
    } else if (state === "done") {
      stateEl.textContent = "done";
      bar.style.width = "100%";
      bar.classList.add("is-done");
      cancelBtn.textContent = "✓";
      cancelBtn.disabled = true;
    } else if (state === "cancelled") {
      stateEl.textContent = "cancelled";
      bar.classList.add("is-cancelled");
      cancelBtn.disabled = true;
    } else if (state === "error") {
      stateEl.textContent = item.errorText || "error";
      bar.classList.add("is-error");
      cancelBtn.textContent = "✕";
    } else if (state === "conflict") {
      stateEl.textContent = "conflict — overwrite / keep both";
    }
  }

  function cancelUpload(item) {
    if (item.xhr && item.row.dataset.state === "active") {
      item.cancelled = true;
      item.xhr.abort();
    }
    if (item.row.dataset.state === "queued") {
      const idx = uploadQueue.indexOf(item);
      if (idx >= 0) uploadQueue.splice(idx, 1);
      setRowState(item, "cancelled");
    }
  }

  uploadsClearBtn?.addEventListener("click", () => {
    uploadsList.querySelectorAll("[data-state='done'], [data-state='error'], [data-state='cancelled']").forEach((row) => row.remove());
    maybeHidePanel();
  });

  function enqueueUpload(file, targetFolder, relativePath) {
    const item = {
      file,
      targetFolder,
      relative: relativePath || file.name,
      cancelled: false,
      xhr: null,
      errorText: null,
    };
    item.row = buildUploadRow(item);
    uploadsList.prepend(item.row);
    uploadQueue.push(item);
    showPanel();
    pump();
    return item;
  }

  function pump() {
    while (uploadActive < UPLOAD_CONCURRENCY && uploadQueue.length) {
      const item = uploadQueue.shift();
      runUpload(item);
    }
  }

  function runUpload(item, overwriteFlag) {
    uploadActive += 1;
    const fd = new FormData();
    fd.append("target_path", item.targetFolder || "");
    fd.append("relative_path", item.relative);
    fd.append("csrf_token", csrfToken);
    if (overwriteFlag) fd.append("overwrite", "1");
    fd.append("file", item.file, item.file.name);

    const xhr = new XMLHttpRequest();
    item.xhr = xhr;
    xhr.open("POST", `${baseUrl}/files/upload`);
    xhr.setRequestHeader("X-CSRF-Token", csrfToken);
    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) {
        const pct = (event.loaded / event.total) * 100;
        setRowState(item, "active", pct);
      }
    };
    setRowState(item, "active", 0);
    xhr.onload = () => {
      uploadActive -= 1;
      if (xhr.status >= 200 && xhr.status < 300) {
        setRowState(item, "done");
        scheduleRefresh(joinPath(item.targetFolder || "", parentOf(item.relative)));
      } else if (xhr.status === 409 && !overwriteFlag) {
        setRowState(item, "conflict");
        const collidingPath = joinPath(item.targetFolder || "", item.relative);
        askConflict(collidingPath).then((action) => {
          if (action === "overwrite") {
            runUpload(item, true);
          } else if (action === "keep-both") {
            item.relative = withCollisionSuffix(item.relative);
            item.row.querySelector(".files-uploads-row-name").textContent = item.relative;
            runUpload(item, false);
          } else {
            setRowState(item, "cancelled");
          }
        });
      } else {
        item.errorText = `HTTP ${xhr.status}`;
        try {
          const text = xhr.responseText;
          if (text) item.errorText = `HTTP ${xhr.status}: ${text.slice(0, 80)}`;
        } catch (_e) {}
        setRowState(item, "error");
      }
      maybeHidePanel();
      pump();
    };
    xhr.onerror = () => {
      uploadActive -= 1;
      item.errorText = item.cancelled ? "cancelled" : "network error";
      setRowState(item, item.cancelled ? "cancelled" : "error");
      maybeHidePanel();
      pump();
    };
    xhr.onabort = () => {
      uploadActive -= 1;
      setRowState(item, "cancelled");
      maybeHidePanel();
      pump();
    };
    xhr.send(fd);
  }

  // ---------- drag-drop on tree rows --------------------------------------

  function rowFromEvent(event) {
    return event.target.closest("[data-row-kind]");
  }

  function isInternalDrag(event) {
    return Array.from(event.dataTransfer.types).includes("application/x-files-overlay");
  }

  function isExternalDrag(event) {
    const types = Array.from(event.dataTransfer.types);
    return types.includes("Files");
  }

  // Expose containers (rows AND the empty-state placeholder + tree-root)
  // as drop targets — both for OS files and internal moves.
  if (treeRoot) {
    treeRoot.addEventListener("dragstart", (event) => {
      const row = rowFromEvent(event);
      if (!row) return;
      const path = row.dataset.targetPath;
      if (!path) return; // overlay root row isn't draggable
      event.dataTransfer.setData("application/x-files-overlay", path);
      event.dataTransfer.setData("text/plain", path);
      event.dataTransfer.effectAllowed = "move";
      row.classList.add("is-drag-source");
    });
    treeRoot.addEventListener("dragend", () => {
      treeRoot.querySelectorAll(".is-drag-source").forEach((el) => el.classList.remove("is-drag-source"));
      treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
    });

    treeRoot.addEventListener("dragover", (event) => {
      // Only react to file or row drags.
      if (!isExternalDrag(event) && !isInternalDrag(event)) return;
      const row = rowFromEvent(event);
      // Find the closest folder row, or the tree-root container itself for
      // root-level drops.
      const target = row && row.dataset.rowKind === "dir" ? row : null;
      const fallbackRoot = !row || row.dataset.rowKind !== "dir" ? treeRoot : null;
      const dropEl = target || fallbackRoot;
      if (!dropEl) return;
      event.preventDefault();
      event.dataTransfer.dropEffect = isInternalDrag(event) ? "move" : "copy";
      // Highlight the dropEl exclusively.
      treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
      dropEl.classList.add("is-drop-target");
    });
    treeRoot.addEventListener("dragleave", (event) => {
      // Leaving the whole tree clears highlights.
      if (!treeRoot.contains(event.relatedTarget)) {
        treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
      }
    });

    treeRoot.addEventListener("drop", async (event) => {
      const internal = isInternalDrag(event);
      const external = isExternalDrag(event);
      if (!internal && !external) return;
      event.preventDefault();
      const row = rowFromEvent(event);
      const dropFolder =
        row && row.dataset.rowKind === "dir" ? row.dataset.targetPath || "" : "";

      treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));

      if (internal) {
        const src = event.dataTransfer.getData("application/x-files-overlay");
        if (!src) return;
        const dst = joinPath(dropFolder, basename(src));
        if (src === dst) return;
        // Cycle guard: refuse moving a folder into itself or descendant.
        if (dropFolder === src || dropFolder.startsWith(src + "/")) return;
        const r = await postJson(`${baseUrl}/files/move`, { src, dst });
        if (r.ok) {
          scheduleRefresh(parentOf(src));
          if (parentOf(src) !== dropFolder) scheduleRefresh(dropFolder);
        } else if (r.status === 409) {
          const action = await askConflict(dst);
          if (action === "overwrite") {
            const r2 = await postJson(`${baseUrl}/files/move`, { src, dst, overwrite: true });
            if (r2.ok) {
              scheduleRefresh(parentOf(src));
              scheduleRefresh(dropFolder);
            }
          }
        } else {
          alert((r.body && r.body.error) || `Move failed (HTTP ${r.status}).`);
        }
        return;
      }

      // External drop — collect entries via webkitGetAsEntry where it
      // returns an Entry (real OS drag with folder support), and fall back
      // to getAsFile() for any item whose entry is null (synthetic events,
      // browsers without the API, or items that have no folder structure).
      const items = event.dataTransfer.items;
      const files = [];
      const tasks = [];
      if (items && items.length) {
        for (let i = 0; i < items.length; i++) {
          const item = items[i];
          if (typeof item.webkitGetAsEntry === "function") {
            const entry = item.webkitGetAsEntry();
            if (entry) {
              tasks.push(walkEntry(entry, "", files));
              continue;
            }
          }
          // Fallback: treat as a flat file.
          if (typeof item.getAsFile === "function") {
            const f = item.getAsFile();
            if (f) files.push({ file: f, rel: f.name });
          }
        }
      } else if (event.dataTransfer.files) {
        for (let i = 0; i < event.dataTransfer.files.length; i++) {
          files.push({ file: event.dataTransfer.files[i], rel: event.dataTransfer.files[i].name });
        }
      }
      await Promise.all(tasks);
      for (const f of files) {
        enqueueUpload(f.file, dropFolder, f.rel);
      }
    });
  }

  function walkEntry(entry, prefix, out) {
    return new Promise((resolve) => {
      if (entry.isFile) {
        entry.file((f) => {
          out.push({ file: f, rel: prefix + entry.name });
          resolve();
        });
      } else if (entry.isDirectory) {
        const reader = entry.createReader();
        const children = [];
        const readBatch = () => {
          reader.readEntries((batch) => {
            if (!batch.length) {
              Promise.all(
                children.map((c) => walkEntry(c, prefix + entry.name + "/", out))
              ).then(() => resolve());
              return;
            }
            for (const c of batch) children.push(c);
            readBatch();
          });
        };
        readBatch();
      } else {
        resolve();
      }
    });
  }

  // ---------- click delegation: action buttons ----------------------------

  document.addEventListener("click", (event) => {
    const action = event.target?.closest?.(
      ".files-row-action[data-action], .file-tree-name-button[data-action]"
    );
    if (!action) return;
    if (!manager.contains(action)) return;
    const op = action.dataset.action;
    const path = action.dataset.targetPath || "";
    if (op === "new-file") {
      openEditorTextNew(path);
    } else if (op === "new-folder") {
      openNewFolder(path);
    } else if (op === "zip") {
      const url = `${baseUrl}/files/download_zip?path=${encodeURIComponent(path)}`;
      window.location.href = url;
    } else if (op === "edit") {
      const editable = action.dataset.editable === "1";
      if (editable) {
        // Editable text files: open via URL-addressable modal.
        const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`;
        if (typeof window.openModal === "function") {
          window.openModal(editUrl);
        } else {
          // Graceful fallback if modal-router didn't load — full-page navigation
          // still hits the same route and renders the standalone editor page.
          window.location.href = editUrl;
        }
      } else {
        // Binary files: keep old inline dialog (binary replace deferred from pilot).
        openEditorForFile(path, false);
      }
    } else if (op === "delete") {
      const kind = action.dataset.rowKind;
      const name = action.dataset.rowName || basename(path);
      openDelete(path, kind, name);
    }
  });
})();
