/* =========================================================================
 *  CTWCAD — App shell, top bar, sync badge, root component, router
 * =======================================================================*/

function SyncBadge({ status, lastSync }) {
  const M = window.FramerMotion.motion;
  const label = {
    synced: "All synced",
    syncing: "Syncing…",
    offline: "Offline",
    error: "Sync error",
  }[status];
  return (
    <div className={"ct-sync ct-sync-" + status} title={lastSync ? `Last sync ${fmtRelative(lastSync)}` : ""}>
      <span className="ct-sync-dot">
        {status === "syncing" && <M.span
          className="ct-sync-pulse"
          animate={{ scale: [1, 1.8], opacity: [0.6, 0] }}
          transition={{ duration: 1.4, repeat: Infinity, ease: "easeOut" }}
        />}
      </span>
      <span className="ct-mono">{label}</span>
    </div>
  );
}

function TopBar({ user, onPalette, route, onNavigate, syncStatus, onSignOut }) {
  const { theme, toggle } = useTheme();
  const [menuOpen, setMenuOpen] = useState(false);
  const menuRef = useRef(null);
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;
  const admin = window.CTWCAD_ACCESS?.isAdmin(user?.email);

  useEffect(() => {
    if (!menuOpen) return;
    const onDoc = (e) => {
      if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);
    };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, [menuOpen]);

  return (
    <header className="ct-topbar">
      <div className="ct-topbar-left">
        <Brandmark size={20}/>
        <nav className="ct-topnav">
          <button className={route === "dashboard" ? "is-active" : ""} onClick={() => onNavigate("dashboard")}>Dashboard</button>
          <button data-tour="nav-files" className={route === "files" ? "is-active" : ""} onClick={() => onNavigate("files")}>Files</button>
          <button className={route === "trash" ? "is-active" : ""} onClick={() => onNavigate("trash")}>Trash</button>
          {admin && (
            <button className={"ct-topnav-admin " + (route === "admin" ? "is-active" : "")} onClick={() => onNavigate("admin")}>
              <Icon name="shield" size={12}/> Admin
            </button>
          )}
        </nav>
      </div>
      <button className="ct-topbar-search" onClick={onPalette}>
        <Icon name="search" size={13}/>
        <span className="ct-dim">Search files, projects…</span>
        <kbd className="ct-mono">⌘K</kbd>
      </button>
      <div className="ct-topbar-right">
        <SyncBadge status={syncStatus} lastSync={new Date().toISOString()} />
        <NotificationsBell user={user}/>
        <button className="ct-iconbtn" onClick={toggle}
                title="Toggle theme"
                aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"}>
          <Icon name={theme === "dark" ? "sun" : "moon"} size={14}/>
        </button>
        <button className="ct-iconbtn" onClick={() => onNavigate("settings")}
                title="Settings" aria-label="Settings">
          <Icon name="settings" size={14}/>
        </button>
        <div className="ct-profile-wrap" ref={menuRef} data-tour="profile-menu">
          <button
            className="ct-avatar-btn"
            onClick={() => setMenuOpen(o => !o)}
            aria-haspopup="menu"
            aria-expanded={menuOpen}
            title="Account"
          >
            <Avatar user={user}/>
          </button>
          <A>
            {menuOpen && (
              <M.div
                className="ct-profile-menu"
                initial={{ opacity: 0, y: -6, scale: 0.97 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, y: -4, scale: 0.97 }}
                transition={{ type: "spring", stiffness: 380, damping: 28 }}
                role="menu"
              >
                <div className="ct-profile-menu-head">
                  <Avatar user={user} size={32}/>
                  <div className="ct-profile-menu-id">
                    <div className="ct-profile-menu-name">{user?.name || "—"}</div>
                    <div className="ct-mono ct-dim ct-profile-menu-email">{user?.email || ""}</div>
                  </div>
                </div>
                <div className="ct-menu-divider" />
                <button className="ct-menu-item" onClick={() => { setMenuOpen(false); onNavigate("settings"); }} role="menuitem">
                  <Icon name="user" size={13}/>
                  <span>Account settings</span>
                </button>
                {admin && (
                  <button className="ct-menu-item" onClick={() => { setMenuOpen(false); onNavigate("admin"); }} role="menuitem">
                    <Icon name="shield" size={13}/>
                    <span>Admin · access control</span>
                  </button>
                )}
                <button className="ct-menu-item" onClick={() => { setMenuOpen(false); onNavigate("settings"); }} role="menuitem">
                  <Icon name="key-round" size={13}/>
                  <span>API tokens</span>
                </button>
                <button className="ct-menu-item" onClick={() => { setMenuOpen(false); onNavigate("my-diagnostics"); }} role="menuitem">
                  <Icon name="activity" size={13}/>
                  <span>My Diagnostics</span>
                </button>
                <button className="ct-menu-item" onClick={(e) => { setMenuOpen(false); toggle(e); }} role="menuitem">
                  <Icon name={theme === "dark" ? "sun" : "moon"} size={13}/>
                  <span>Switch to {theme === "dark" ? "light" : "dark"} theme</span>
                </button>
                <div className="ct-menu-divider" />
                <button className="ct-menu-item is-danger" onClick={() => { setMenuOpen(false); onSignOut(); }} role="menuitem">
                  <Icon name="log-out" size={13}/>
                  <span>Sign out</span>
                </button>
              </M.div>
            )}
          </A>
        </div>
      </div>
    </header>
  );
}

/* =========================================================================
 *  Path-based router (no react-router; we stay CDN-React per CLAUDE.md)
 *  -----------------------------------------------------------------------
 *  URL schema:
 *    /                                    → dashboard
 *    /dashboard                           → dashboard (alias)
 *    /files                               → file browser, no folder yet
 *    /files/<folderId>                    → that folder
 *    /files/<folderId>/<fileId>           → folder + drawer open on file
 *    /trash                               → trash
 *    /settings                            → settings
 *    /admin                               → admin (page itself enforces gate)
 *
 *  The connect flow (?connect=1&port=…) keeps working independently —
 *  it's a query-string concern handled separately in the App body.
 * =======================================================================*/
function parseRoute(pathname) {
  const parts = (pathname || "/").split("/").filter(Boolean);
  if (parts.length === 0)              return { route: "dashboard", folderId: null, fileId: null };
  const head = parts[0];
  if (head === "dashboard")            return { route: "dashboard", folderId: null, fileId: null };
  if (head === "files")                return { route: "files",     folderId: parts[1] || null, fileId: parts[2] || null };
  if (head === "trash")                return { route: "trash",     folderId: null, fileId: null };
  if (head === "settings")             return { route: "settings",  folderId: null, fileId: null };
  if (head === "admin")                return { route: "admin",     folderId: null, fileId: null };
  if (head === "install")              return { route: "install",   folderId: null, fileId: null };
  if (head === "my-diagnostics")       return { route: "my-diagnostics", folderId: null, fileId: null };
  return { route: "dashboard", folderId: null, fileId: null };
}

function pathFor(route, folderId, fileId) {
  if (route === "files") {
    if (fileId && folderId) return `/files/${folderId}/${fileId}`;
    if (folderId)           return `/files/${folderId}`;
    return "/files";
  }
  if (route === "dashboard") return "/";
  return "/" + route;
}

function App() {
  // Read the URL once on mount so refresh / direct links land in the
  // right place. Subsequent navigations come from setState (with a URL
  // sync effect below) or from popstate (browser back/forward).
  const initialRoute = useMemo(() => parseRoute(window.location.pathname), []);
  const [user, setUser] = useState(null);
  const [signedIn, setSignedIn] = useState(() => localStorage.getItem("ctwcad.signedIn") === "1");
  const [deniedEmail, setDeniedEmail] = useState(null);
  const [route, setRoute] = useState(initialRoute.route);
  // In Firebase mode there is no shared seed data — every user has their own
  // private "My Files" project that we resolve after sign-in. In mock mode the
  // chassis-root fixture is the right default. URL takes precedence if set.
  const [folderId, setFolderId] = useState(() =>
    initialRoute.folderId
    || (window.CTWCAD_ACTIVE_ADAPTER === "firebase" ? null : "f_chassis_root")
  );
  // Wave-3 share-link entrypoint: `?file=<id>&share=<token>` opens the
  // drawer on that file. We honour it on first paint AND queue a
  // history.replaceState() to clean the URL once the drawer is open, so
  // the address bar settles to a normal /files/<folderId>/<fileId>
  // route. The token doesn't need to be persisted — Firestore enforces
  // the public-read rule once shareState is set.
  const initialQuery = useMemo(() => {
    try {
      const sp = new URLSearchParams(window.location.search || "");
      return { file: sp.get("file"), share: sp.get("share") };
    } catch { return { file: null, share: null }; }
  }, []);
  const [openFileId, setOpenFileId] = useState(initialRoute.fileId || initialQuery.file || null);
  const [paletteOpen, setPaletteOpen] = useState(false);
  const [recent, setRecent] = useState([]);
  const [syncStatus, setSyncStatus] = useState("synced");
  const [uploads, setUploads] = useState([]);
  const [toasts, setToasts] = useState([]);
  // Admin tooling: impersonation + view-all-files
  const [impersonating, setImpersonating] = useState(() => {
    try { return JSON.parse(localStorage.getItem("ctwcad.impersonating") || "null"); } catch { return null; }
  });
  const [viewAll, setViewAll] = useState(() => localStorage.getItem("ctwcad.viewAll") === "1");

  // Effective actor = impersonated user if set, else the signed-in user
  const actor = impersonating || user;

  // boot
  useEffect(() => {
    if (!signedIn) return;
    const stored = localStorage.getItem("ctwcad.user");
    if (stored) {
      try {
        const u = JSON.parse(stored);
        setUser(u);
      } catch {}
    } else {
      window.api.getCurrentUser().then(setUser);
    }
  }, [signedIn]);

  // Push the effective actor + viewAll flag into the data layer whenever
  // they change, then refresh the recent-files cache so the UI reflects
  // the new visibility scope.
  useEffect(() => {
    if (!actor) return;
    window.api.setActor?.(actor.email || actor.id);
    window.api.setViewAll?.(viewAll);
    window.api.listRecentFiles(20).then(setRecent);
  }, [actor?.email, actor?.id, viewAll]);

  // Resolve the user's personal "My Files" folder so the Files tab and
  // Upload buttons have somewhere real to land. Re-runs whenever
  // folderId becomes null (e.g. user hit back to /files), so a refresh
  // or back-navigation always lands on a populated folder. Only runs in
  // Firebase mode — mock fixtures already have a default folder.
  //
  // Optimization: cache the resolved folder ID per user in localStorage.
  // First sign-in pays the round-trip; every subsequent session reads
  // the cache and skips the network. We still fire the live resolver
  // in the background to repair if the cache went stale (e.g. another
  // device deleted/recreated the folder).
  useEffect(() => {
    if (window.CTWCAD_ACTIVE_ADAPTER !== "firebase") return;
    if (!user) return;
    if (folderId) return;
    const cacheKey = `ctwcad.myFolder.${user.id || user.email}`;
    const cached = localStorage.getItem(cacheKey);
    const applyFolder = (id, fromCache) => {
      if (!id) return;
      // If the URL is bare /files, fill in the folder ID without polluting
      // history. Use replaceState so back-button doesn't trap us in
      // /files ↔ /files/<id> loops.
      if (window.location.pathname === "/files") {
        try { history.replaceState(null, "", `/files/${id}` + (window.location.search || "")); }
        catch (e) {}
      }
      setFolderId((prev) => prev || id);
      if (!fromCache) localStorage.setItem(cacheKey, id);
    };
    if (cached) applyFolder(cached, /* fromCache */ true);
    // Background validation. If the folder still exists at that ID,
    // nothing changes. If it doesn't (or never existed), getOrCreate
    // returns the real one and we update the cache + state.
    window.api.getOrCreateMyFilesFolder?.().then((f) => {
      if (!f) return;
      if (cached && f.id === cached) return; // cache was correct
      localStorage.setItem(cacheKey, f.id);
      applyFolder(f.id, /* fromCache */ false);
    }).catch((err) => console.warn("[CTWCAD] could not resolve My Files folder:", err));
  }, [user?.id, user?.email, folderId]);

  // ⌘K opens the search palette. Escape closes overlays in priority
  // order (palette / shortcuts cheat sheet / onboarding tour first, then
  // drawer) so dismissing the topmost overlay doesn't also dismiss the
  // drawer that was already open behind it.
  useEffect(() => {
    const onKey = (e) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
        e.preventDefault();
        setPaletteOpen(v => !v);
      } else if (e.key === "Escape") {
        // Sniff the DOM for any modal-class overlay that owns its own
        // Esc handler. If one is up, that overlay will close itself —
        // we must NOT also close the drawer in the same keystroke.
        const someoneElseOwnsEsc = document.querySelector(
          ".ct-shortcuts, .ct-tour-pop, .ct-modal-rename, .ct-modal-move, .ct-modal-share"
        );
        setPaletteOpen((prev) => {
          if (prev) return false;             // palette was open → just close it
          if (someoneElseOwnsEsc) return prev;// some other overlay owns this Esc
          setOpenFileId(null);                // otherwise drop the drawer
          return prev;
        });
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  // page-wide drag-and-drop + upload pipeline
  useEffect(() => {
    const onUpload = async (e) => {
      const explicitFolder = e.detail?.folderId;
      const realFiles = e.detail?.files ? Array.from(e.detail.files) : [];
      // "Random-spot" upload: real files arrived but the caller didn't
      // pin a destination folder, AND we're not on /files (which would
      // imply the current folder is the target). Prompt the user to
      // pick a project before sending bytes anywhere.
      if (realFiles.length > 0 && !explicitFolder && route !== "files") {
        setUploadDestModal({ files: realFiles });
        return;
      }
      const targetFolderId = explicitFolder || folderId;
      if (!targetFolderId) {
        pushToast("Hang on — your folder is still loading. Try again in a sec.", "alert-circle");
        return;
      }
      const isFirebase = window.CTWCAD_ACTIVE_ADAPTER === "firebase";

      // Resolve destination name for the toast.
      let destName = "current folder";
      try {
        const folder = await window.api.getFolder(targetFolderId);
        destName = folder?.name || destName;
      } catch {}

      // ─── Real-upload path ───────────────────────────────────────────
      // Whenever we actually have File / Blob objects (drag-and-drop, the
      // file picker, the dashboard upload CTA after picking) and we're on
      // a real backend, send the bytes through to Firebase Storage. The
      // adapter creates the Firestore doc + Storage blob + activity entry.
      if (realFiles.length > 0 && isFirebase) {
        // Hard size preflight. Mirrors the Storage rule's 2 GB ceiling so
        // the user gets a friendly toast instead of an opaque
        // storage/unauthorized error after the upload starts.
        const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
        const oversized = realFiles.find(f => f.size > MAX_UPLOAD_BYTES);
        if (oversized) {
          pushToast(
            `${oversized.name} is ${fmtBytes(oversized.size)} — over the 2 GB upload cap.`,
            "alert-circle"
          );
          return;
        }
        // Storage-limit gate. Members default to 100 MB; admins/owner are
        // unlimited; explicit per-user overrides win. We refuse the whole
        // batch if the total would push the user past their limit — better
        // than uploading half and having the user wonder which ones made it.
        try {
          const usage = await window.api.getMyUsage();
          if (usage && usage.limit !== null) {
            const incoming = realFiles.reduce((s, f) => s + (f.size || 0), 0);
            const after = (usage.used || 0) + incoming;
            if (after > usage.limit) {
              const over = after - usage.limit;
              pushToast(
                `Storage limit reached — these files would put you ${fmtBytes(over)} over your ${fmtBytes(usage.limit)} limit. Free space or ask the workspace owner to raise it.`,
                "alert-circle",
              );
              return;
            }
          }
        } catch (e) {
          console.warn("[CTWCAD] storage-limit check failed:", e?.code || e?.message);
          // Fail-open: if the check itself errors, allow the upload. The
          // adapter will still write per the rules, and the user sees their
          // usage bar update in Settings — they're not silently exceeding
          // a real cap because the cap isn't enforced server-side anyway.
        }
        const startedAt = Date.now();
        const items = realFiles.map((f, i) => ({
          id: "up-" + Date.now() + "-" + i,
          name: f.name, size: f.size, progress: 0, folderId: targetFolderId,
          startedAt,
          // Speed sampling state — kept on the item so we don't need a
          // separate per-upload ref. lastTick/lastBytes update on every
          // progress callback that's >250ms after the previous sample.
          lastTickAt: startedAt, lastBytes: 0,
          speedBps: 0, etaSeconds: null,
        }));
        setUploads(prev => [...prev, ...items]);
        setSyncStatus("syncing");
        pushToast(`Uploading ${items.length} file${items.length > 1 ? "s" : ""} → ${destName}`, "upload-cloud");

        await Promise.all(realFiles.map(async (file, i) => {
          const it = items[i];
          try {
            await window.api.uploadFileToFolder(file, {
              folderId: targetFolderId,
              name: file.name,
              onProgress: (p) => {
                setUploads(prev => prev.map(u => {
                  if (u.id !== it.id) return u;
                  const now = Date.now();
                  const bytes = (file.size || 0) * p;
                  // Resample speed at most every ~300ms — too-frequent
                  // sampling makes the ETA jitter wildly between ticks.
                  if (now - u.lastTickAt < 300 && p < 1) {
                    return { ...u, progress: p };
                  }
                  const dt = (now - u.lastTickAt) / 1000;
                  const dBytes = Math.max(0, bytes - u.lastBytes);
                  // Exponential moving average so the ETA settles instead
                  // of swinging on a single slow chunk.
                  const instSpeed = dt > 0 ? dBytes / dt : u.speedBps;
                  const speedBps = u.speedBps > 0
                    ? u.speedBps * 0.6 + instSpeed * 0.4
                    : instSpeed;
                  const remainingBytes = Math.max(0, (file.size || 0) - bytes);
                  const etaSeconds = (speedBps > 0 && p < 1)
                    ? Math.ceil(remainingBytes / speedBps)
                    : null;
                  return {
                    ...u,
                    progress: p,
                    lastTickAt: now,
                    lastBytes: bytes,
                    speedBps,
                    etaSeconds,
                  };
                }));
              },
            });
            setUploads(prev => prev.map(u => u.id === it.id
              ? { ...u, progress: 1, etaSeconds: 0, speedBps: u.speedBps }
              : u));
            window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: targetFolderId } }));
          } catch (err) {
            // Log everything — the wrapped error from the adapter carries
            // a `stage` label (storage upload / file doc / version doc /
            // activity) so the console tells us which step blew up.
            console.error("[CTWCAD] upload failed:", err, {
              stage: err?.stage, code: err?.code, cause: err?.cause,
            });
            // If the bucket-level size cap rejected the file, surface that
            // instead of the raw rule-eval error code. The 1.9 GB threshold
            // sits just under the 2 GB rule cap so we don't mis-label
            // permission errors that happen to involve a large file.
            const ruleCapHit =
              (err?.code === "storage/unauthorized" ||
               err?.cause?.code === "storage/unauthorized") &&
              file.size > 1.9 * 1024 * 1024 * 1024;
            const msg = ruleCapHit
              ? `${file.name} was rejected by the server (size limit).`
              : (err?.message || err?.code || "error");
            pushToast(`Couldn't upload ${file.name}: ${msg}`, "alert-triangle");
            setUploads(prev => prev.filter(u => u.id !== it.id));
          }
        }));

        setSyncStatus("synced");
        // Refresh the dashboard's recent list so the new files appear there too.
        window.api.listRecentFiles(20).then(setRecent);
        return;
      }

      // ─── Mock / no-blob fallback path ─────────────────────────────
      // Used in dev (mock adapter) or when an event fires without File
      // objects. Simulates progress and calls finalizeUpload so the mock
      // store gets a record.
      const seeds = realFiles.length
        ? realFiles.map(f => ({ name: f.name, size: f.size }))
        : [{ name: "imported_part.step", size: 2400000 },
           { name: "sketch_outline.ctws", size: 88000 }];
      const items = seeds.map((s, i) => ({
        id: "up-" + Date.now() + "-" + i,
        name: s.name, size: s.size, progress: 0, folderId: targetFolderId,
      }));
      setUploads(prev => [...prev, ...items]);
      setSyncStatus("syncing");
      pushToast(`Uploading ${items.length} file${items.length > 1 ? "s" : ""} → ${destName}`, "upload-cloud");
      items.forEach((it) => {
        let p = 0;
        const tick = async () => {
          p = Math.min(1, p + 0.05 + Math.random() * 0.07);
          setUploads(prev => prev.map(u => u.id === it.id ? { ...u, progress: p } : u));
          if (p < 1) {
            setTimeout(tick, 220 + Math.random() * 200);
          } else {
            try {
              const ext = (it.name.split(".").pop() || "bin").toLowerCase();
              await window.api.finalizeUpload({
                folderId: it.folderId,
                name: it.name,
                kind: ext,
                sizeBytes: it.size,
              });
              window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: it.folderId } }));
            } catch (err) { console.warn("finalizeUpload failed", err); }
            setUploads(prev => {
              const allDone = prev.every(u => u.progress >= 1);
              if (allDone) setSyncStatus("synced");
              return prev;
            });
          }
        };
        setTimeout(tick, 200);
      });
    };
    window.addEventListener("ctwcad:upload", onUpload);
    return () => window.removeEventListener("ctwcad:upload", onUpload);
  }, [folderId]);

  // Programmatic file picker — used by the Upload buttons everywhere.
  const filePickerRef = useRef(null);
  const pickerTargetRef = useRef(null);
  useEffect(() => {
    const onPick = (e) => {
      pickerTargetRef.current = e.detail?.folderId || null;
      filePickerRef.current?.click();
    };
    window.addEventListener("ctwcad:pick-files", onPick);
    return () => window.removeEventListener("ctwcad:pick-files", onPick);
  }, []);

  // Window-level drag-and-drop — accepts files dropped anywhere in the app.
  // Routes to the current folder when on /files, otherwise to a sensible default.
  const [windowDrag, setWindowDrag] = useState(false);
  useEffect(() => {
    let depth = 0;
    const onEnter = (e) => {
      if (!e.dataTransfer || ![...(e.dataTransfer.types || [])].includes("Files")) return;
      depth++;
      setWindowDrag(true);
    };
    const onLeave = () => { depth = Math.max(0, depth - 1); if (depth === 0) setWindowDrag(false); };
    const onOver  = (e) => { if (e.dataTransfer?.types && [...e.dataTransfer.types].includes("Files")) e.preventDefault(); };
    const onDrop  = (e) => {
      if (!e.dataTransfer?.files?.length) return;
      e.preventDefault();
      depth = 0;
      setWindowDrag(false);
      // Only auto-target when on /files. Dropping anywhere else lets the
      // upload handler prompt the user for a destination project.
      const isFirebase = window.CTWCAD_ACTIVE_ADAPTER === "firebase";
      const target = route === "files"
        ? folderId
        : (isFirebase ? null : "f_chassis_root");
      window.dispatchEvent(new CustomEvent("ctwcad:upload", {
        detail: { folderId: target, files: e.dataTransfer.files },
      }));
    };
    window.addEventListener("dragenter", onEnter);
    window.addEventListener("dragleave", onLeave);
    window.addEventListener("dragover",  onOver);
    window.addEventListener("drop",      onDrop);
    return () => {
      window.removeEventListener("dragenter", onEnter);
      window.removeEventListener("dragleave", onLeave);
      window.removeEventListener("dragover",  onOver);
      window.removeEventListener("drop",      onDrop);
    };
  }, [route, folderId]);

  // New project modal state
  const [newProjectOpen, setNewProjectOpen] = useState(false);
  // Upload-destination picker. Set when an upload event fires outside
  // a specific folder context (dashboard upload button, window drag-drop
  // from anywhere outside /files). User picks a project; we resolve its
  // root folder and re-dispatch the upload with that folderId.
  const [uploadDestModal, setUploadDestModal] = useState(null); // { files: File[] } | null
  useEffect(() => {
    const onNew = () => setNewProjectOpen(true);
    window.addEventListener("ctwcad:new-project", onNew);
    return () => window.removeEventListener("ctwcad:new-project", onNew);
  }, []);
  const handleCreateProject = async (name) => {
    try {
      const p = await window.api.createProject({ name });
      pushToast(`Created project “${p.name}”`, "folder-plus");
      setNewProjectOpen(false);
      window.dispatchEvent(new CustomEvent("ctwcad:project-created", { detail: { projectId: p.id } }));
      // Navigate to the new project's root folder.
      if (p.rootFolderId) {
        setFolderId(p.rootFolderId);
        setRoute("files");
      }
    } catch (err) {
      pushToast("Could not create project", "alert-circle");
    }
  };

  // ----- Context-menu actions ----------------------------------------
  // Modal state for rename / move / share.
  const [renameTarget, setRenameTarget] = useState(null); // { kind, id, name }
  const [moveTarget,   setMoveTarget]   = useState(null); // { kind, id, currentParentId }
  const [shareTarget,  setShareTarget]  = useState(null); // { kind, id, name }

  useEffect(() => {
    const onAction = async (e) => {
      const { action, kind, file, folder, project, version } = e.detail || {};
      const item = (kind === "file" || kind === "trashed-file") ? file
                 : kind === "version" ? version
                 : kind === "folder"  ? folder
                 : project;
      if (!item) return;

      switch (action) {
        case "open": {
          if (kind === "file" || kind === "trashed-file") setOpenFileId(item.id);
          else if (kind === "folder") { setFolderId(item.id); setRoute("files"); }
          else if (kind === "project") {
            const root = await window.api.getRootFolderForProject(item.id);
            if (root) { setFolderId(root.id); setRoute("files"); }
          }
          return;
        }
        case "open-cad": {
          if (kind === "file" || kind === "trashed-file") {
            // Same anchor pattern the drawer footer uses so the browser
            // shows its protocol-allow dialog cleanly.
            window.location.href = `ctwcad://open?file_id=${item.id}`;
          }
          return;
        }
        case "restore": {
          if (kind !== "trashed-file" && kind !== "file") return;
          try {
            await window.api.restoreFile(item.id);
            pushToast(`Restored ${item.name}`, "undo-2");
            window.dispatchEvent(new CustomEvent("ctwcad:trash-changed"));
            window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: item.folderId } }));
          } catch (err) {
            pushToast(`Couldn't restore: ${err?.code || err?.message || "error"}`, "alert-circle");
          }
          return;
        }
        case "delete-forever": {
          if (!window.confirm(`Permanently delete "${item.name}"? This can't be undone.`)) return;
          try {
            await window.api.deleteFilePermanently(item.id);
            pushToast(`Deleted ${item.name} forever`, "trash-2");
            window.dispatchEvent(new CustomEvent("ctwcad:trash-changed"));
            if (openFileId === item.id) setOpenFileId(null);
          } catch (err) {
            pushToast(`Couldn't delete: ${err?.code || err?.message || "error"}`, "alert-circle");
          }
          return;
        }
        case "download-version": {
          // Reuse the same blob-fetch pattern the file download uses so
          // the browser actually saves to disk instead of opening inline.
          const url = item?.downloadURL;
          const name = (file?.name || "file").replace(/(\.[^.]+)?$/, ` (v${item.number})$1`);
          if (!url) { pushToast("No download URL on this version", "alert-circle"); return; }
          try {
            pushToast(`Downloading v${item.number}…`, "download");
            const res = await fetch(url);
            if (!res.ok) throw new Error("HTTP " + res.status);
            const blob = await res.blob();
            const u = URL.createObjectURL(blob);
            const a = document.createElement("a");
            a.href = u; a.download = name;
            document.body.appendChild(a); a.click(); document.body.removeChild(a);
            setTimeout(() => URL.revokeObjectURL(u), 1000);
          } catch (err) {
            pushToast(`Couldn't download v${item.number}`, "alert-triangle");
          }
          return;
        }
        case "restore-version": {
          if (!file || !item) return;
          try {
            const res = await window.api.restoreVersion(file.id, item.number);
            const newNum = res?.newVersion;
            pushToast(
              newNum
                ? `Restored v${item.number} as v${newNum}`
                : `v${item.number} is now current`,
              "undo-2"
            );
            window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: file.folderId } }));
          } catch (err) {
            pushToast(`Couldn't restore version: ${err?.code || err?.message || "error"}`, "alert-circle");
          }
          return;
        }
        case "download": {
          if (kind !== "file") {
            pushToast(`${kind === "folder" ? "Folder" : "Project"} ZIP download is coming soon`, "info");
            return;
          }
          if (!item.downloadURL) {
            pushToast("File hasn't finished uploading yet", "alert-circle");
            return;
          }
          // Force a real download by streaming the response into a
          // blob ourselves. Reading via getReader lets us emit progress
          // events to the LoadingIndicator (top-left brandmark whose
          // border IS the progress bar). Without this, the cross-origin
          // URL would just open inline because Firebase Storage serves
          // Content-Disposition: inline.
          (async () => {
            const fire = (detail) => window.dispatchEvent(
              new CustomEvent("ctwcad:part-loading", { detail })
            );
            try {
              pushToast(`Downloading ${item.name}…`, "download");
              fire({ progress: 0 });
              const res = await fetch(item.downloadURL);
              if (!res.ok) throw new Error("HTTP " + res.status);
              const total = parseInt(res.headers.get("Content-Length") || "0", 10);
              if (res.body && res.body.getReader) {
                const reader = res.body.getReader();
                const chunks = [];
                let received = 0;
                while (true) {
                  const { done, value } = await reader.read();
                  if (done) break;
                  chunks.push(value);
                  received += value.length;
                  if (total > 0) fire({ progress: received / total });
                  else fire({ progress: null }); // indeterminate
                }
                const blob = new Blob(chunks);
                const url = URL.createObjectURL(blob);
                const a = document.createElement("a");
                a.href = url; a.download = item.name;
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
                setTimeout(() => URL.revokeObjectURL(url), 1000);
              } else {
                // Old browsers without streaming — fall back to the
                // simpler blob() path with no progress.
                const blob = await res.blob();
                const url = URL.createObjectURL(blob);
                const a = document.createElement("a");
                a.href = url; a.download = item.name;
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
                setTimeout(() => URL.revokeObjectURL(url), 1000);
              }
              fire({ done: true });
            } catch (err) {
              console.warn("download failed", err);
              pushToast(`Couldn't download ${item.name}`, "alert-triangle");
              fire({ done: true });
            }
          })();
          return;
        }
        case "share": {
          setShareTarget({ kind, id: item.id, name: item.name });
          return;
        }
        case "rename": {
          setRenameTarget({ kind, id: item.id, name: item.name });
          return;
        }
        case "filter-by-tag": {
          // Click on a tag chip in the file browser: route to the active
          // folder (already there) and apply this tag as a filter.
          const tag = e.detail?.tag || "";
          if (!tag) return;
          if (route !== "files") setRoute("files");
          window.dispatchEvent(new CustomEvent("ctwcad:filter-by-tag", { detail: { tag } }));
          return;
        }
        case "set-tags": {
          // Wave-5 tag updates. Detail: { kind: "file", file, tags: string[] }.
          // Fire-and-forget: persists onto file doc, refreshes folder so the
          // browser re-renders the chip row, keeps the open drawer in sync.
          if (kind !== "file") return;
          const tags = e.detail?.tags || [];
          try {
            await window.api.setFileTags(item.id, tags);
            window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: item.folderId } }));
          } catch (err) {
            pushToast(`Couldn't update tags: ${err?.code || err?.message || "error"}`, "alert-circle");
          }
          return;
        }
        case "duplicate": {
          try {
            if (kind === "file") {
              const dup = await window.api.duplicateFile(item.id);
              pushToast(`Duplicated → ${dup.name}`, "copy");
              window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: dup.folderId } }));
            } else if (kind === "folder") {
              const dup = await window.api.duplicateFolder(item.id);
              pushToast(`Duplicated folder → ${dup.name}`, "copy");
              window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: dup.parentId } }));
            } else if (kind === "project") {
              const dup = await window.api.duplicateProject(item.id);
              pushToast(`Duplicated project → ${dup.name}`, "copy");
              window.dispatchEvent(new CustomEvent("ctwcad:project-created", { detail: { projectId: dup.id } }));
            }
          } catch { pushToast("Couldn’t duplicate", "alert-circle"); }
          return;
        }
        case "move": {
          if (kind === "file") setMoveTarget({ kind, id: item.id, currentParentId: item.folderId });
          else if (kind === "folder") setMoveTarget({ kind, id: item.id, currentParentId: item.parentId });
          return;
        }
        case "trash": {
          try {
            if (kind === "file") {
              await window.api.trashFile(item.id);
              pushToast(`Moved to Trash · ${item.name}`, "trash-2");
              window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: item.folderId } }));
              if (openFileId === item.id) setOpenFileId(null);
            } else if (kind === "folder") {
              await window.api.trashFolder(item.id);
              pushToast(`Moved folder to Trash · ${item.name}`, "trash-2");
              window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: item.parentId } }));
              if (folderId === item.id) setFolderId(item.parentId || "f_chassis_root");
            }
          } catch { pushToast("Couldn’t move to Trash", "alert-circle"); }
          return;
        }
        case "pin": {
          try {
            await window.api.pinProject(item.id, !item.pinned);
            pushToast(item.pinned ? "Unpinned from dashboard" : "Pinned to dashboard", "star");
            window.dispatchEvent(new CustomEvent("ctwcad:project-created"));
          } catch {}
          return;
        }
        case "new-folder": {
          pushToast("New-folder coming soon — drop files instead", "info");
          return;
        }
        case "upload": {
          // Route to the targeted folder or project root.
          if (kind === "folder") {
            window.dispatchEvent(new CustomEvent("ctwcad:pick-files", { detail: { folderId: item.id } }));
          } else if (kind === "project") {
            const root = await window.api.getRootFolderForProject(item.id);
            if (root) window.dispatchEvent(new CustomEvent("ctwcad:pick-files", { detail: { folderId: root.id } }));
          }
          return;
        }
        case "members":
        case "settings":
        case "archive": {
          pushToast(`${action[0].toUpperCase() + action.slice(1)} — coming soon`, "info");
          return;
        }
        default: return;
      }
    };
    window.addEventListener("ctwcad:item-action", onAction);
    return () => window.removeEventListener("ctwcad:item-action", onAction);
  }, [folderId, openFileId, route]);

  const handleRenameSubmit = async (newName) => {
    if (!renameTarget) return;
    const { kind, id } = renameTarget;
    try {
      if (kind === "file") {
        const f = await window.api.renameFile(id, newName);
        pushToast(`Renamed → ${f?.name}`, "edit-3");
        if (f) window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: f.folderId } }));
      } else if (kind === "folder") {
        const f = await window.api.renameFolder(id, newName);
        pushToast(`Renamed folder → ${f?.name}`, "edit-3");
        if (f) window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: f.parentId } }));
      } else if (kind === "project") {
        const p = await window.api.renameProject(id, newName);
        pushToast(`Renamed project → ${p?.name}`, "edit-3");
        window.dispatchEvent(new CustomEvent("ctwcad:project-created"));
      }
    } catch { pushToast("Couldn’t rename", "alert-circle"); }
    setRenameTarget(null);
  };

  const handleMoveSubmit = async (targetFolderId) => {
    if (!moveTarget) return;
    const { kind, id, currentParentId } = moveTarget;
    try {
      if (kind === "file") {
        const f = await window.api.moveFile(id, targetFolderId);
        pushToast(`Moved → ${f ? f.name : "file"}`, "folder-input");
        window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: currentParentId } }));
        window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: targetFolderId } }));
      } else if (kind === "folder") {
        const f = await window.api.moveFolder(id, targetFolderId);
        if (!f) { pushToast("Cannot move folder into itself", "alert-circle"); return; }
        pushToast(`Moved folder → ${f.name}`, "folder-input");
        window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: currentParentId } }));
        window.dispatchEvent(new CustomEvent("ctwcad:folder-changed", { detail: { folderId: targetFolderId } }));
      }
    } catch { pushToast("Couldn’t move", "alert-circle"); }
    setMoveTarget(null);
  };

  // Listen for ctwcad:toast events so any component can request a toast
  // without needing pushToast threaded through props.
  useEffect(() => {
    const onToast = (e) => {
      const { message, icon } = e.detail || {};
      if (message) pushToast(message, icon || "check");
    };
    window.addEventListener("ctwcad:toast", onToast);
    return () => window.removeEventListener("ctwcad:toast", onToast);
  }, []);

  // "Sign out everywhere else" stale-session check. When another session
  // (or this user via Settings) calls revokeAllOtherSessions, the user
  // doc's `sessionsValidSince` timestamp is bumped. Any browser whose
  // local sessionStartedAt is BEFORE that timestamp signs itself out.
  // Runs on sign-in and every 60 s while the app is open.
  useEffect(() => {
    if (!signedIn) return;
    let started = parseInt(localStorage.getItem("ctwcad.sessionStartedAt") || "0", 10);
    if (!started) {
      started = Date.now();
      localStorage.setItem("ctwcad.sessionStartedAt", String(started));
    }
    let cancelled = false;
    const check = async () => {
      try {
        const since = await window.api.getSessionsValidSince?.();
        if (cancelled || !since) return;
        if (since > started) {
          console.warn("[CTWCAD] this session was invalidated by 'Sign out everywhere else' — signing out");
          handleSignOut();
        }
      } catch {}
    };
    check();
    const t = setInterval(check, 60_000);
    return () => { cancelled = true; clearInterval(t); };
  }, [signedIn]);

  // Suppress the browser's native right-click menu globally. Custom React
  // onContextMenu handlers fire first and call preventDefault on the same
  // event; this listener catches any spots that don't have a handler so
  // Chrome's menu never appears anywhere on the page.
  useEffect(() => {
    const onCtx = (e) => e.preventDefault();
    document.addEventListener("contextmenu", onCtx);
    return () => document.removeEventListener("contextmenu", onCtx);
  }, []);

  /* ------------------------------------------------------------------
   * Path-based routing — keep the URL in sync with React state.
   * ------------------------------------------------------------------*/
  // Synchronous read of the public-mode flag from localStorage. Used by
  // the gate below — when the site is in public mode, all paths are
  // reachable. In private mode, only / is reachable to unsigned users.
  const isPublicMode = () => window.CTWCAD_ACCESS?.loadPublic?.() === true;

  // State → URL (push, but only when the URL actually needs to change).
  // Preserve any query string (e.g. ?connect=1&port=…) so the desktop-
  // app connect flow keeps working through navigation.
  useEffect(() => {
    const want = pathFor(route, folderId, openFileId);
    const search = window.location.search || "";
    if (window.location.pathname !== want) {
      try { history.pushState(null, "", want + search); }
      catch (e) { /* hosting environment may not support pushState */ }
    }
  }, [route, folderId, openFileId]);

  // Wave-3: strip ?file=&share= from the URL once we've consumed them
  // (they were used to seed openFileId on initial mount). Leaves any
  // unrelated query params (e.g. ?connect=1&port=…) intact.
  //
  // Wave-5 caveat: if the visitor is anonymous (the AnonymousShareView
  // path), KEEP the params — refresh / direct-paste needs them. They
  // get stripped automatically on first sign-in, when this effect
  // re-runs due to signedIn flipping (we don't add it as a dep, so we
  // gate inline instead).
  useEffect(() => {
    if (!initialQuery.file) return;
    if (!signedIn) return; // anonymous viewer needs the params to survive
    try {
      const sp = new URLSearchParams(window.location.search || "");
      let touched = false;
      if (sp.has("file"))  { sp.delete("file");  touched = true; }
      if (sp.has("share")) { sp.delete("share"); touched = true; }
      if (touched) {
        const qs = sp.toString();
        history.replaceState(null, "",
          window.location.pathname + (qs ? "?" + qs : ""));
      }
    } catch {}
  }, [signedIn]);

  // URL → state (browser back / forward / programmatic CTWCAD_NAV).
  useEffect(() => {
    const onPop = () => {
      const next = parseRoute(window.location.pathname);
      // Gate: an unsigned visitor can only land on "/". Every other
      // path (including /install) gets rewritten back to "/" so the
      // sign-in card is the only thing reachable. Public mode bypasses
      // this — when the site is published, every path is reachable.
      if (!signedIn && !isPublicMode() && next.route !== "dashboard") {
        try { history.replaceState(null, "", "/" + (window.location.search || "")); }
        catch (e) {}
        setRoute("dashboard");
        setFolderId(null);
        setOpenFileId(null);
        return;
      }
      setRoute(next.route);
      // Sync folder + drawer to whatever the URL says. When navigating
      // to /files (no folder), clear folderId so the My Files resolver
      // re-runs and we don't loop pushState'ing back to /files/<id>.
      if (next.route === "files") {
        setFolderId(next.folderId || null);
        setOpenFileId(next.fileId || null);
      } else {
        setOpenFileId(null);
      }
    };
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, [signedIn]);

  // Initial-load guard: if the user lands on any non-root URL while
  // signed out (refresh, bookmark, shared link), bounce them to "/" so
  // the sign-in card is the only thing they see. Public mode is the one
  // exception.
  useEffect(() => {
    if (signedIn) return;
    if (isPublicMode()) return;
    if (window.location.pathname === "/") return;
    try { history.replaceState(null, "", "/" + (window.location.search || "")); }
    catch (e) {}
    setRoute("dashboard");
    setFolderId(null);
    setOpenFileId(null);
  }, [signedIn]);

  // Programmatic navigation hook for code outside React (e.g. the mobile
  // bootstrap, which lives in its own React root and can't call setState
  // here directly). Calling this is the equivalent of a user clicking
  // a navigation link.
  useEffect(() => {
    window.CTWCAD_NAV = (path) => {
      try { history.pushState(null, "", path + (window.location.search || "")); }
      catch (e) {}
      window.dispatchEvent(new PopStateEvent("popstate"));
    };
    return () => { delete window.CTWCAD_NAV; };
  }, []);

  // Notify code rendered in other React roots (mobile-bootstrap's
  // bottom tab bar, especially) about route + shell state. Replacing
  // a body-subtree MutationObserver in mobile-bootstrap that was firing
  // on every framer-motion tick — the per-tick querySelector cost was
  // freezing the tab when the file browser mounted.
  useEffect(() => {
    const hasApp = (signedIn || isPublicMode()) && route !== "install";
    window.dispatchEvent(new CustomEvent("ctwcad:route-changed", {
      detail: { route, hasApp },
    }));
  }, [route, signedIn]);

  // gentle sync flicker every ~30s
  useEffect(() => {
    const id = setInterval(() => {
      setSyncStatus("syncing");
      setTimeout(() => setSyncStatus("synced"), 1400);
    }, 28000);
    return () => clearInterval(id);
  }, []);

  // Background Firestore writes surface their errors via ctwcad:save-error
  // (see admin.jsx). Show them as toasts so the user knows the change
  // didn't persist.
  useEffect(() => {
    const onSaveError = (e) => {
      const what  = e.detail?.what  || "data";
      const error = e.detail?.error || "unknown error";
      pushToast(`Couldn't save ${what}: ${error}`, "alert-triangle");
      setSyncStatus("error");
      setTimeout(() => setSyncStatus("synced"), 3000);
    };
    window.addEventListener("ctwcad:save-error", onSaveError);
    return () => window.removeEventListener("ctwcad:save-error", onSaveError);
  }, []);

  function pushToast(message, icon) {
    const t = { id: "t-" + Date.now() + Math.random(), message, icon };
    setToasts(prev => [...prev, t]);
  }
  function dismissToast(id) {
    setToasts(prev => prev.filter(t => t.id !== id));
  }

  function navigate(r, payload) {
    if (r === "file") { setOpenFileId(payload.id); return; }
    if (r === "upload") {
      // Resolve a destination ONLY when we're already in a specific
      // folder (route === "files"). From the dashboard / trash /
      // settings / window-level drag, leave folderId null and let the
      // upload handler open the project-picker modal — that's the
      // behavior the user asked for: "random-spot uploads should
      // prompt me to pick a project".
      const isFirebase = window.CTWCAD_ACTIVE_ADAPTER === "firebase";
      const target = (route === "files" && folderId)
        ? folderId
        : (payload?.folderId
            || (isFirebase ? null : "f_chassis_root")); // mock fallback only
      window.dispatchEvent(new CustomEvent("ctwcad:pick-files", { detail: { folderId: target } }));
      return;
    }
    if (r === "new-project") { setNewProjectOpen(true); return; }
    setRoute(r);
    // Mock fixtures use chassis-root as a default folder. In Firebase
    // mode the user's "My Files" folder is resolved by the effect above;
    // setting "f_chassis_root" here would point them at a non-existent
    // folder and crash the file browser.
    if (r === "files" && !folderId && window.CTWCAD_ACTIVE_ADAPTER !== "firebase") {
      setFolderId("f_chassis_root");
    }
  }

  const handleSignIn = async (account) => {
    if (!account) { setDeniedEmail("(unknown)"); return; }
    const isFirebase = window.CTWCAD_ACTIVE_ADAPTER === "firebase";
    let allowed = window.CTWCAD_ACCESS.isAllowed(account.email);

    // Optimistic path: if the localStorage cache says allowed, let the
    // user in immediately and refresh the cache in the background. The
    // cache is correct ~99% of the time (it's mirrored on every admin
    // write). If the live check disagrees later, we sign them out.
    if (allowed && isFirebase && window.api?.getAccessConfig) {
      window.api.getAccessConfig().then((cfg) => {
        if (!cfg) return;
        if (Array.isArray(cfg.allowlist)) {
          localStorage.setItem("ctwcad.allowlist", JSON.stringify(cfg.allowlist));
        }
        if (cfg.public) localStorage.setItem("ctwcad.public", "1");
        else            localStorage.removeItem("ctwcad.public");
        const emailLower = account.email.toLowerCase();
        const onAllowlist = (cfg.allowlist || []).some(e => e.email.toLowerCase() === emailLower);
        const liveAllowed = !!cfg.public || onAllowlist || window.CTWCAD_ACCESS.isOwner(account.email);
        if (!liveAllowed) {
          // Cache was stale and the user has been removed. Force-sign-out.
          console.warn("[CTWCAD] live access check disagreed with cache — signing out");
          handleSignOut();
          setDeniedEmail(account.email);
        }
      }).catch((e) => console.warn("[CTWCAD] background access-config check failed:", e?.code || e?.message));
    } else if (!allowed && isFirebase && window.api?.getAccessConfig) {
      // Cache rejected. The user may have been added on another device;
      // do one live check before giving up.
      try {
        const cfg = await window.api.getAccessConfig();
        if (cfg) {
          if (Array.isArray(cfg.allowlist)) {
            localStorage.setItem("ctwcad.allowlist", JSON.stringify(cfg.allowlist));
          }
          if (cfg.public) localStorage.setItem("ctwcad.public", "1");
          else            localStorage.removeItem("ctwcad.public");
        }
        const emailLower = account.email.toLowerCase();
        const onAllowlist = (cfg?.allowlist || []).some(e => e.email.toLowerCase() === emailLower);
        allowed = !!cfg?.public || onAllowlist || window.CTWCAD_ACCESS.isOwner(account.email);
      } catch (e) {
        console.warn("[CTWCAD] live access-config check failed; falling back to cache:", e);
      }
    }

    if (!allowed) { setDeniedEmail(account.email); return; }

    // Guest expiration check. The allowlist entry for a guest carries
    // an `expiresAt` ISO timestamp; if it's past, deny access here even
    // though they're otherwise allowed. Lets admins hand out time-bound
    // access without having to remember to revoke later.
    try {
      const cached = JSON.parse(localStorage.getItem("ctwcad.allowlist") || "[]");
      const emailLower = account.email.toLowerCase();
      const entry = cached.find(e => e.email && e.email.toLowerCase() === emailLower);
      if (entry && entry.role === "guest" && entry.expiresAt) {
        const exp = new Date(entry.expiresAt).getTime();
        if (!isNaN(exp) && exp < Date.now()) {
          console.warn("[CTWCAD] guest access expired for", emailLower, "at", entry.expiresAt);
          setDeniedEmail(account.email);
          return;
        }
      }
    } catch (e) { /* don't block sign-in on a parse error */ }

    const u = {
      id: "u_" + account.email,
      name: account.name || account.email.split("@")[0],
      email: account.email,
      avatarHue: account.hue || 200,
      initials: account.initials || (account.name ? account.name[0].toUpperCase() : account.email[0].toUpperCase()),
    };
    setUser(u);
    localStorage.setItem("ctwcad.user", JSON.stringify(u));
    setSignedIn(true);
    localStorage.setItem("ctwcad.signedIn", "1");
    // Pin this session's start time so the stale-session check above
    // can later distinguish this session from older ones if the user
    // hits "Sign out everywhere else" elsewhere.
    localStorage.setItem("ctwcad.sessionStartedAt", String(Date.now()));
    setDeniedEmail(null);
    pushToast(`Signed in as ${u.name}`, "check");

    // Pre-warm the 3D viewer in the background — first preview open
    // shouldn't have to wait on a multi-MB download.
    if (typeof window.ensureO3DV === "function" && !window.OV) {
      window.ensureO3DV().catch(() => {});
    }
  };

  const handleSignOut = async () => {
    if (window.CTWCAD_AUTH) {
      try { await window.CTWCAD_AUTH.signOut(); } catch {}
    }
    localStorage.removeItem("ctwcad.signedIn");
    localStorage.removeItem("ctwcad.user");
    localStorage.removeItem("ctwcad.impersonating");
    localStorage.removeItem("ctwcad.viewAll");
    localStorage.removeItem("ctwcad.sessionStartedAt");
    setUser(null);
    setImpersonating(null);
    setViewAll(false);
    setOpenFileId(null);
    setPaletteOpen(false);
    setRoute("dashboard");
    setSignedIn(false);
    setDeniedEmail(null);
  };

  // Admin actions — admins can impersonate / use admin view; only the owner
  // can target the owner's own account.
  const startImpersonate = (target) => {
    const me = user?.email;
    if (!window.CTWCAD_ACCESS?.isAdmin(me)) return;
    if (!target) return;
    if (window.CTWCAD_ACCESS.isOwner(target.email) && !window.CTWCAD_ACCESS.isOwner(me)) {
      pushToast("Only the owner can impersonate the owner", "shield");
      return;
    }
    const t = { id: target.id, name: target.name, email: target.email, avatarHue: target.avatarHue, initials: target.initials };
    setImpersonating(t);
    localStorage.setItem("ctwcad.impersonating", JSON.stringify(t));
    setRoute("dashboard");
    setOpenFileId(null);
    pushToast(`Viewing as ${t.name}`, "user-check");
  };
  const stopImpersonate = () => {
    setImpersonating(null);
    localStorage.removeItem("ctwcad.impersonating");
    setRoute("admin");
    pushToast("Returned to your own account", "shield-check");
  };
  const toggleViewAll = (on) => {
    if (!window.CTWCAD_ACCESS?.isAdmin(user?.email)) return;
    setViewAll(on);
    if (on) localStorage.setItem("ctwcad.viewAll", "1");
    else    localStorage.removeItem("ctwcad.viewAll");
    pushToast(on ? "Admin view ON — you can see every file" : "Admin view OFF", on ? "eye" : "eye-off");
  };

  // Desktop-app device-link flow. Reached when CTWCAD on Windows opens
  // https://ctwcad.com/?connect=1&port=NNNN&state=XXX&name=Device. Render
  // a dedicated view so the regular dashboard never flashes — credentials
  // get POSTed straight to the loopback server.
  const isConnectFlow = useMemo(
    () => new URLSearchParams(window.location.search).get("connect") === "1",
    []
  );
  if (isConnectFlow) {
    if (deniedEmail) return <AccessDeniedView email={deniedEmail} onBack={() => setDeniedEmail(null)} />;
    return <ConnectView signedIn={signedIn} user={user} onSignIn={handleSignIn} />;
  }

  // Wave-5: anonymous public-share entrypoint. If the visitor arrived at
  // /?file=<id>&share=<token> WITHOUT being signed in, route to a
  // dedicated read-only viewer. Firestore rules already allow anyone
  // (signed-in or not) to read /files/<id> when shareState=="public-read",
  // so this surface needs no auth at all. Comments / rename / trash are
  // disabled — the user can preview, download, and sign in for more.
  if (!signedIn && initialQuery.file && initialQuery.share) {
    return (
      <AnonymousShareView
        fileId={initialQuery.file}
        shareToken={initialQuery.share}
        onSignIn={handleSignIn}
      />
    );
  }

  // The signed-in app shell. Everything beyond / requires sign-in
  // (public mode is the override).
  if (!signedIn && !isPublicMode()) {
    if (deniedEmail) return <AccessDeniedView email={deniedEmail} onBack={() => setDeniedEmail(null)} />;
    return <Landing onSignIn={handleSignIn} />;
  }

  // /install is the marketing surface for the desktop app — gated like
  // the rest of the app, so signed-in users can reach it but anonymous
  // visitors can't. (Public mode lets it through too.)
  if (route === "install") {
    return <InstallView signedIn={signedIn} />;
  }

  return (
    <div className="ct-app" data-route={route} data-impersonating={impersonating ? "1" : "0"}>
      {(impersonating || viewAll) && (
        <ImpersonationBanner
          impersonating={impersonating}
          viewAll={viewAll}
          owner={user}
          onExit={() => { if (impersonating) stopImpersonate(); else toggleViewAll(false); }}
        />
      )}
      <TopBar user={actor} ownerUser={user} route={route} onNavigate={navigate}
              onPalette={() => setPaletteOpen(true)}
              syncStatus={syncStatus}
              onSignOut={handleSignOut}/>
      <main className="ct-main">
        {route === "dashboard" && (
          <DashboardView
            user={actor}
            onOpenFile={(id) => setOpenFileId(id)}
            onOpenProject={async (p) => {
              const root = await window.api.getRootFolderForProject(p.id);
              if (root) { setFolderId(root.id); setRoute("files"); }
            }}
            onUpload={() => navigate("upload")}
            onNewProject={() => setNewProjectOpen(true)}
          />
        )}
        {route === "files" && (
          <FileBrowserView
            initialFolderId={folderId}
            onOpenFile={(id) => setOpenFileId(id)}
            onChangeFolder={(f) => setFolderId(f.id)}
          />
        )}
        {route === "settings" && <SettingsView user={actor}/>}
        {route === "trash" && <TrashView onOpenFile={(id) => setOpenFileId(id)} />}
        {route === "my-diagnostics" && <DiagnosticsView user={actor}/>}
        {route === "admin" && (
          <AdminView
            user={user}
            onToast={pushToast}
            viewAll={viewAll}
            onToggleViewAll={toggleViewAll}
            onImpersonate={startImpersonate}
            impersonating={impersonating}
            onOpenFile={(id) => setOpenFileId(id)}
          />
        )}
      </main>

      <FileDrawer fileId={openFileId} onClose={() => setOpenFileId(null)} />
      <GlobalSearchPalette
        open={paletteOpen}
        onClose={() => setPaletteOpen(false)}
        onOpenFile={(id) => setOpenFileId(id)}
      />
      <UploadManager uploads={uploads} onDismiss={() => setUploads([])} />
      <Toasts items={toasts} onDismiss={dismissToast} />
      {/* First-sign-in walkthrough. Renders inline so it can read the
          live actor (post-impersonation toggle) and dismiss as soon as
          the user signs out. The component itself no-ops on /install
          and the desktop ?connect=1 handoff page. */}
      <OnboardingTour user={user} />

      {/* Hidden file picker, triggered programmatically by Upload buttons. */}
      <input
        ref={filePickerRef}
        type="file"
        multiple
        style={{ display: "none" }}
        accept=".ctwcad,.ctws,.step,.stp,.iges,.igs,.stl,.pdf,.dxf,.dwg,.x_t,.x_b,.sat,.f3d,.3mf,.obj,.glb,.gltf,.png,.jpg,.jpeg,.gif,.webp,.svg,.bmp"
        onChange={(e) => {
          const fs = e.target.files;
          if (fs && fs.length) {
            // Mirror the navigate() / onDrop logic: only carry a target
            // forward if it was set explicitly. Otherwise leave null so
            // the upload handler's project picker fires.
            const isFirebase = window.CTWCAD_ACTIVE_ADAPTER === "firebase";
            const target = pickerTargetRef.current
              || (route === "files" ? folderId
                                    : (isFirebase ? null : "f_chassis_root"));
            window.dispatchEvent(new CustomEvent("ctwcad:upload", {
              detail: { folderId: target, files: fs },
            }));
          }
          // Reset so picking the same file twice still fires.
          e.target.value = "";
          pickerTargetRef.current = null;
        }}
      />

      <NewProjectModal
        open={newProjectOpen}
        onClose={() => setNewProjectOpen(false)}
        onCreate={handleCreateProject}
      />

      <UploadDestinationModal
        open={!!uploadDestModal}
        files={uploadDestModal?.files || []}
        onCancel={() => setUploadDestModal(null)}
        onPick={async (project) => {
          try {
            const root = await window.api.getRootFolderForProject(project.id);
            if (!root) {
              pushToast(`Couldn't find a root folder for "${project.name}"`, "alert-circle");
              return;
            }
            const files = uploadDestModal?.files || [];
            setUploadDestModal(null);
            // Re-dispatch with the picked folder. The upload handler
            // sees explicitFolder=root.id and goes through directly.
            window.dispatchEvent(new CustomEvent("ctwcad:upload", {
              detail: { folderId: root.id, files },
            }));
          } catch (err) {
            pushToast(`Couldn't open project (${err?.code || err?.message})`, "alert-circle");
          }
        }}
      />

      <RenameModal
        target={renameTarget}
        onClose={() => setRenameTarget(null)}
        onSubmit={handleRenameSubmit}
      />

      <MoveModal
        target={moveTarget}
        onClose={() => setMoveTarget(null)}
        onSubmit={handleMoveSubmit}
      />

      <ShareModal
        target={shareTarget}
        onClose={() => setShareTarget(null)}
      />

      <GlobalDropOverlay show={windowDrag} route={route} folderId={folderId} />
    </div>
  );
}

/* ---------- Upload destination picker -------------------------------
   Fired when the user drops files / triggers an upload from a "random"
   place (dashboard, window background, anywhere outside /files). Shows
   the user's projects so they can pick a target instead of having
   files silently land in My Files. */
function UploadDestinationModal({ open, files, onPick, onCancel }) {
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;
  const [projects, setProjects] = useState(null);
  const [creating, setCreating] = useState(false);
  const [newName, setNewName] = useState("");

  useEffect(() => {
    if (!open) return;
    setCreating(false);
    setNewName("");
    window.api.listAllProjects().then(setProjects).catch(() => setProjects([]));
  }, [open]);

  useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === "Escape") onCancel(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, onCancel]);

  if (!open) return null;
  const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);

  const submitNewProject = async (e) => {
    e?.preventDefault();
    const name = newName.trim();
    if (!name) return;
    try {
      const p = await window.api.createProject({ name });
      // Trigger refresh elsewhere in the UI.
      window.dispatchEvent(new CustomEvent("ctwcad:project-created", { detail: { projectId: p.id } }));
      onPick(p);
    } catch (err) {
      console.warn("createProject failed", err);
    }
  };

  return (
    <A>
      <M.div
        className="ct-modal-scrim"
        initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
        transition={{ duration: 0.12 }}
        onClick={onCancel}
      >
        <M.div
          className="ct-modal ct-modal-upload-dest"
          initial={{ opacity: 0, y: 10, scale: 0.97 }}
          animate={{ opacity: 1, y: 0, scale: 1 }}
          exit={{ opacity: 0, y: 6, scale: 0.98 }}
          transition={{ type: "spring", stiffness: 320, damping: 28 }}
          onClick={(e) => e.stopPropagation()}
        >
          <div className="ct-modal-eyebrow ct-mono">upload to project</div>
          <div className="ct-modal-title" style={{ marginBottom: 4 }}>
            Pick a destination
          </div>
          <div className="ct-mono ct-dim" style={{ fontSize: 11, marginBottom: 14 }}>
            {files.length} file{files.length === 1 ? "" : "s"} · {fmtBytes(totalBytes)}
          </div>

          {!projects && (
            <div className="ct-mono ct-dim" style={{ padding: 18, textAlign: "center" }}>
              loading projects…
            </div>
          )}

          {projects && projects.length > 0 && !creating && (
            <ul className="ct-upload-dest-list">
              {projects.map((p) => (
                <li key={p.id}>
                  <button
                    className="ct-upload-dest-item"
                    onClick={() => onPick(p)}>
                    <span className="ct-tree-dot" style={{
                      background: `oklch(0.72 0.12 ${p.coverHue || 200})`,
                    }}/>
                    <span className="ct-upload-dest-name">{p.name}</span>
                    <span className="ct-mono ct-dim">
                      {p.kind === "myfiles" ? "personal" : "project"}
                    </span>
                    <Icon name="chevron-right" size={12}/>
                  </button>
                </li>
              ))}
            </ul>
          )}

          {projects && projects.length === 0 && !creating && (
            <div className="ct-mono ct-dim" style={{ padding: 14 }}>
              No projects yet. Create one below.
            </div>
          )}

          {!creating && (
            <button
              className="ct-btn ct-btn-ghost"
              style={{ marginTop: 12, width: "100%", justifyContent: "center" }}
              onClick={() => setCreating(true)}>
              <Icon name="plus" size={13}/>New project for these files
            </button>
          )}

          {creating && (
            <form onSubmit={submitNewProject} className="ct-upload-dest-newproj">
              <label className="ct-eyebrow ct-mono">PROJECT NAME</label>
              <input
                className="ct-input"
                placeholder="e.g. Bench-vise jaws"
                autoFocus
                value={newName}
                onChange={(e) => setNewName(e.target.value)}
                maxLength={80}
              />
              <div className="ct-modal-actions">
                <button type="button" className="ct-btn ct-btn-ghost" onClick={() => setCreating(false)}>
                  Back
                </button>
                <button type="submit" className="ct-btn ct-btn-primary" disabled={!newName.trim()}>
                  Create &amp; upload
                </button>
              </div>
            </form>
          )}

          {!creating && (
            <div className="ct-modal-actions" style={{ marginTop: 12 }}>
              <button type="button" className="ct-btn ct-btn-ghost" onClick={onCancel}>Cancel</button>
            </div>
          )}
        </M.div>
      </M.div>
    </A>
  );
}

/* ---------- New project modal --------------------------------------- */
function NewProjectModal({ open, onClose, onCreate }) {
  const [name, setName] = useState("");
  const inputRef = useRef(null);
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;
  useEffect(() => {
    if (open) {
      setName("");
      setTimeout(() => inputRef.current?.focus(), 50);
    }
  }, [open]);
  useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, onClose]);
  const submit = (e) => {
    e?.preventDefault();
    const v = name.trim();
    if (!v) return;
    onCreate(v);
  };
  return (
    <A>
      {open && (
        <>
          <M.div className="ct-backdrop"
            initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
            transition={{ duration: 0.18 }}
            onClick={onClose} />
          <M.div className="ct-modal" role="dialog" aria-modal="true"
            initial={{ opacity: 0, y: 10, scale: 0.97 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 6, scale: 0.98 }}
            transition={{ type: "spring", stiffness: 320, damping: 28 }}>
            <form onSubmit={submit}>
              <div className="ct-modal-head">
                <Icon name="folder-plus" size={16}/>
                <div>
                  <div className="ct-modal-title">New project</div>
                  <div className="ct-dim ct-modal-sub">Creates a project + root folder you can drop files into.</div>
                </div>
              </div>
              <div className="ct-modal-body">
                <label className="ct-eyebrow ct-mono" htmlFor="np-name">PROJECT NAME</label>
                <input
                  id="np-name"
                  ref={inputRef}
                  className="ct-input"
                  placeholder="e.g. Bench-vise jaws"
                  value={name}
                  onChange={(e) => setName(e.target.value)}
                  maxLength={80}
                />
              </div>
              <div className="ct-modal-foot">
                <button type="button" className="ct-btn ct-btn-ghost" onClick={onClose}>Cancel</button>
                <button type="submit" className="ct-btn ct-btn-primary" disabled={!name.trim()}>
                  <Icon name="plus" size={13}/> Create project
                </button>
              </div>
            </form>
          </M.div>
        </>
      )}
    </A>
  );
}

/* ---------- Global drop overlay ------------------------------------- */
function GlobalDropOverlay({ show, route, folderId }) {
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;
  const [destName, setDestName] = useState(null);
  useEffect(() => {
    if (!show) return;
    const target = route === "files" ? folderId : "f_chassis_root";
    window.api.getFolder(target).then(f => setDestName(f?.name || null)).catch(() => setDestName(null));
  }, [show, route, folderId]);
  return (
    <A>
      {show && (
        <M.div className="ct-drop-overlay ct-drop-overlay-window"
          initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
          transition={{ duration: 0.16 }}>
          <div className="ct-drop-card">
            <Icon name="upload-cloud" size={28}/>
            <div className="ct-drop-title">Drop to upload</div>
            <div className="ct-dim">
              into <span className="ct-mono">{destName || "current folder"}</span>
            </div>
          </div>
        </M.div>
      )}
    </A>
  );
}

/* ---------- Rename modal ------------------------------------------- */
function RenameModal({ target, onClose, onSubmit }) {
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;
  const [value, setValue] = React.useState("");
  const inputRef = React.useRef(null);

  React.useEffect(() => {
    if (target) {
      setValue(target.name || "");
      // focus + select after mount
      const t = setTimeout(() => {
        inputRef.current?.focus();
        inputRef.current?.select();
      }, 40);
      return () => clearTimeout(t);
    }
  }, [target]);

  React.useEffect(() => {
    if (!target) return;
    const onKey = (e) => {
      if (e.key === "Escape") onClose();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [target, onClose]);

  const submit = (e) => {
    e?.preventDefault();
    const v = value.trim();
    if (!v || v === target.name) { onClose(); return; }
    onSubmit(v);
    onClose();
  };

  return (
    <A>
      {target && (
        <M.div
          className="ct-modal-scrim"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.12 }}
          onClick={onClose}
        >
          <M.form
            className="ct-modal ct-modal-rename"
            initial={{ opacity: 0, y: 8, scale: 0.98 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 4, scale: 0.98 }}
            transition={{ type: "spring", stiffness: 320, damping: 28 }}
            onClick={(e) => e.stopPropagation()}
            onSubmit={submit}
          >
            <div className="ct-modal-eyebrow ct-mono">
              rename {target.kind}
            </div>
            <input
              ref={inputRef}
              className="ct-modal-input"
              value={value}
              onChange={(e) => setValue(e.target.value)}
              spellCheck={false}
              autoComplete="off"
            />
            <div className="ct-modal-actions">
              <button type="button" className="ct-btn ct-btn-ghost" onClick={onClose}>Cancel</button>
              <button type="submit" className="ct-btn ct-btn-primary">Rename</button>
            </div>
          </M.form>
        </M.div>
      )}
    </A>
  );
}

/* ---------- Move modal --------------------------------------------- */
function MoveModal({ target, onClose, onSubmit }) {
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;
  const [folders, setFolders] = React.useState([]);
  const [destId, setDestId] = React.useState(null);

  React.useEffect(() => {
    if (!target) { setFolders([]); return; }
    setDestId(target.currentParentId ?? null);
    let cancelled = false;
    (async () => {
      try {
        if (target.kind === "file") {
          // Files can move across projects, so load every project's
          // folder tree and combine into a single forest. The modal's
          // render path already handles multiple roots — each project
          // becomes its own top-level branch.
          const projects = await window.api.listAllProjects();
          const trees = await Promise.all(projects.map(p =>
            window.api.getFolderTree(p.id).catch(() => [])));
          if (cancelled) return;
          const all = [];
          trees.forEach(t => all.push(...(t || [])));
          setFolders(all);
        } else if (target.kind === "folder") {
          // Folder moves stay within the same project — moving a folder
          // across projects would need a cascade rewrite of every
          // descendant's projectId/hubId, which we don't support yet.
          const f = await window.api.getFolder(target.id);
          const projectId = f?.projectId;
          if (!projectId) return;
          const tree = await window.api.getFolderTree(projectId);
          if (!cancelled) setFolders(tree || []);
        }
      } catch (e) { /* swallow — modal still works against empty list */ }
    })();
    return () => { cancelled = true; };
  }, [target]);

  React.useEffect(() => {
    if (!target) return;
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [target, onClose]);

  if (!target) return null;

  // Build folder tree, excluding target itself and its descendants if it's a folder.
  const excluded = new Set();
  if (target.kind === "folder") {
    const collect = (id) => {
      excluded.add(id);
      folders.filter((f) => f.parentId === id).forEach((f) => collect(f.id));
    };
    collect(target.id);
  }

  // Find the project root (parentId === null in this project's tree).
  const roots = folders.filter((f) => f.parentId === null && !excluded.has(f.id));

  const renderTree = (parentId, depth) => {
    return folders
      .filter((f) => f.parentId === parentId && !excluded.has(f.id))
      .map((f) => (
        <React.Fragment key={f.id}>
          <button
            type="button"
            className={"ct-move-row" + (destId === f.id ? " is-active" : "")}
            style={{ paddingLeft: 12 + depth * 18 }}
            onClick={() => setDestId(f.id)}
          >
            <Icon name="folder" size={14}/>
            <span className="ct-move-name">{f.name}</span>
          </button>
          {renderTree(f.id, depth + 1)}
        </React.Fragment>
      ));
  };

  const submit = () => {
    if (destId === (target.currentParentId ?? null)) { onClose(); return; }
    onSubmit(destId);
    onClose();
  };

  return (
    <A>
      <M.div
        className="ct-modal-scrim"
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
        transition={{ duration: 0.12 }}
        onClick={onClose}
      >
        <M.div
          className="ct-modal ct-modal-move"
          initial={{ opacity: 0, y: 8, scale: 0.98 }}
          animate={{ opacity: 1, y: 0, scale: 1 }}
          exit={{ opacity: 0, y: 4, scale: 0.98 }}
          transition={{ type: "spring", stiffness: 320, damping: 28 }}
          onClick={(e) => e.stopPropagation()}
        >
          <div className="ct-modal-eyebrow ct-mono">
            move <span className="ct-dim">{target.name}</span> to
          </div>
          <div className="ct-move-tree">
            {roots.length === 0 && (
              <div className="ct-move-empty ct-mono ct-dim">no destinations</div>
            )}
            {roots.map((r) => (
              <React.Fragment key={r.id}>
                <button
                  type="button"
                  className={"ct-move-row" + (destId === r.id ? " is-active" : "")}
                  style={{ paddingLeft: 12 }}
                  onClick={() => setDestId(r.id)}
                >
                  <Icon name="folder" size={14}/>
                  <span className="ct-move-name">{r.name}</span>
                  <span className="ct-mono ct-dim ct-move-tag">root</span>
                </button>
                {renderTree(r.id, 1)}
              </React.Fragment>
            ))}
          </div>
          <div className="ct-modal-actions">
            <button type="button" className="ct-btn ct-btn-ghost" onClick={onClose}>Cancel</button>
            <button type="button" className="ct-btn ct-btn-primary" onClick={submit}>Move here</button>
          </div>
        </M.div>
      </M.div>
    </A>
  );
}

/* ---------- Share modal -------------------------------------------- */
function ShareModal({ target, onClose }) {
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;
  const [copied, setCopied] = React.useState(false);

  React.useEffect(() => {
    if (!target) return;
    setCopied(false);
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [target, onClose]);

  if (!target) return null;

  const link = `https://ctwcad.app/p/${target.id}`;
  const copy = async () => {
    try {
      await navigator.clipboard.writeText(link);
      setCopied(true);
      setTimeout(() => setCopied(false), 1400);
    } catch (e) {}
  };

  return (
    <A>
      <M.div
        className="ct-modal-scrim"
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
        transition={{ duration: 0.12 }}
        onClick={onClose}
      >
        <M.div
          className="ct-modal ct-modal-share"
          initial={{ opacity: 0, y: 8, scale: 0.98 }}
          animate={{ opacity: 1, y: 0, scale: 1 }}
          exit={{ opacity: 0, y: 4, scale: 0.98 }}
          transition={{ type: "spring", stiffness: 320, damping: 28 }}
          onClick={(e) => e.stopPropagation()}
        >
          <div className="ct-modal-eyebrow ct-mono">share {target.kind}</div>
          <div className="ct-share-name">{target.name}</div>
          <div className="ct-share-row">
            <span className="ct-mono ct-dim">link</span>
            <code className="ct-share-link">{link}</code>
            <button type="button" className="ct-btn ct-btn-ghost ct-share-copy" onClick={copy}>
              <Icon name={copied ? "check" : "copy"} size={12}/>
              {copied ? "Copied" : "Copy"}
            </button>
          </div>
          <div className="ct-share-note ct-mono ct-dim">
            invites &amp; permissions land in a later pass — link is read-only for now
          </div>
          <div className="ct-modal-actions">
            <button type="button" className="ct-btn ct-btn-primary" onClick={onClose}>Done</button>
          </div>
        </M.div>
      </M.div>
    </A>
  );
}

function Landing({ onSignIn }) {
  // Private beta — no marketing surface yet. Straight to sign-in.
  return <SignInView onSignIn={onSignIn} />;
}

/* =========================================================================
 *  Anonymous public-share viewer (wave 5)
 *  ─────────────────────────────────────
 *  Reached when an unauthenticated visitor opens `/?file=<id>&share=<tok>`.
 *  Firestore rules permit reads on /files/<id> whenever
 *  shareState=="public-read", regardless of auth — so we skip every
 *  signed-in code path and render a minimal preview surface.
 *
 *  Disabled deliberately:
 *    - lastOpenedAt write (would need auth)
 *    - comments compose (parent FileDrawer skips it via `readOnly`)
 *    - all mutations (rename, trash, share toggle, tags)
 * =======================================================================*/
function AnonymousShareView({ fileId, shareToken, onSignIn }) {
  const [file, setFile] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!fileId) return;
    let cancelled = false;
    // Direct getDoc — bypass adapter helpers that assume a signed-in user.
    if (!window.firebaseDb || !window.firebaseFns) {
      setError("Firebase isn't initialized in this build.");
      return;
    }
    const fb = window.firebaseFns;
    fb.getDoc(fb.doc(window.firebaseDb, "files", fileId))
      .then((snap) => {
        if (cancelled) return;
        if (!snap.exists()) { setError("This file no longer exists."); return; }
        const d = snap.data();
        if (d.shareState !== "public-read") {
          setError("This share link has been disabled. Ask the owner for a new one.");
          return;
        }
        if (d.shareToken && d.shareToken !== shareToken) {
          setError("This share link has expired. Ask the owner for a new one.");
          return;
        }
        const tsToISO = (v) => {
          if (!v) return new Date().toISOString();
          if (typeof v === "string") return v;
          if (v.toDate) return v.toDate().toISOString();
          if (v.seconds) return new Date(v.seconds * 1000).toISOString();
          return new Date(v).toISOString();
        };
        setFile({
          id: snap.id, ...d,
          createdAt: tsToISO(d.createdAt),
          updatedAt: tsToISO(d.updatedAt),
        });
      })
      .catch((e) => {
        if (cancelled) return;
        setError(`Couldn't load this file: ${e?.code || e?.message || "unknown error"}`);
      });
    return () => { cancelled = true; };
  }, [fileId, shareToken]);

  // Preserve the share URL so refresh / direct paste keeps working.
  return (
    <div className="ct-anon-share">
      <header className="ct-anon-bar">
        <Brandmark size={18} />
        <div className="ct-anon-bar-spacer" />
        <button className="ct-btn ct-btn-primary ct-anon-signin"
                onClick={() => {
                  if (typeof window.CTWCAD_AUTH?.signInWithGoogle === "function") {
                    window.CTWCAD_AUTH.signInWithGoogle()
                      .then((acct) => acct && onSignIn?.(acct))
                      .catch(() => {});
                  }
                }}>
          <Icon name="log-in" size={13} /> Sign in
        </button>
      </header>
      <main className="ct-anon-main">
        {error ? (
          <div className="ct-empty">
            <Icon name="link-2-off" size={36} />
            <div className="ct-empty-title">Link unavailable</div>
            <div className="ct-dim" style={{ maxWidth: 420, textAlign: "center" }}>{error}</div>
          </div>
        ) : !file ? (
          <div className="ct-anon-loading">
            <div className="ct-skeleton-block" style={{ height: 380, maxWidth: 720, margin: "0 auto" }} />
          </div>
        ) : (
          <div className="ct-anon-content">
            <div className="ct-anon-header">
              <div className="ct-anon-title">
                <h1>{file.name}</h1>
                <div className="ct-mono ct-dim">
                  .{file.kind} · v{file.versionCount} · {fmtBytes(file.sizeBytes)}
                </div>
              </div>
              <div className="ct-anon-actions">
                {file.downloadURL && (
                  <a className="ct-btn ct-btn-ghost" href={file.downloadURL}
                     target="_blank" rel="noopener" download={file.name}>
                    <Icon name="download" size={13} /> Download
                  </a>
                )}
                <button className="ct-btn ct-btn-primary"
                        onClick={() => {
                          if (typeof window.CTWCAD_AUTH?.signInWithGoogle === "function") {
                            window.CTWCAD_AUTH.signInWithGoogle()
                              .then((acct) => acct && onSignIn?.(acct))
                              .catch(() => {});
                          }
                        }}>
                  <Icon name="log-in" size={13} /> Sign in to do more
                </button>
              </div>
            </div>
            <div className="ct-anon-preview">
              <FilePreview file={file} height={520} />
            </div>
            <div className="ct-anon-hint ct-mono ct-dim">
              You're viewing a public share link. Sign in with Google to
              comment, download other versions, or open in CTWCAD.
            </div>
          </div>
        )}
      </main>
    </div>
  );
}

/* ---------- Impersonation / admin-view banner --------------------- */
function ImpersonationBanner({ impersonating, viewAll, owner, onExit }) {
  const M = window.FramerMotion.motion;
  return (
    <M.div
      className={"ct-imp-banner " + (impersonating ? "is-imp" : "is-god")}
      initial={{ y: -32, opacity: 0 }}
      animate={{ y: 0, opacity: 1 }}
      exit={{ y: -32, opacity: 0 }}
      transition={{ type: "spring", stiffness: 280, damping: 26 }}
    >
      <div className="ct-imp-banner-inner">
        <span className="ct-imp-stripes" aria-hidden="true"/>
        <Icon name={impersonating ? "user-check" : "eye"} size={14}/>
        {impersonating ? (
          <>
            <span className="ct-imp-text">
              Viewing as <strong>{impersonating.name}</strong>
              <span className="ct-mono ct-dim"> · {impersonating.email}</span>
            </span>
            <span className="ct-imp-actor ct-mono">
              you are <strong>{owner?.name}</strong>
            </span>
          </>
        ) : (
          <>
            <span className="ct-imp-text">
              <strong>Admin view</strong> — files from every user are visible
            </span>
            <span className="ct-imp-actor ct-mono">read-only across owners</span>
          </>
        )}
        <button className="ct-imp-exit" onClick={onExit}>
          <Icon name="x" size={12}/>
          {impersonating ? "Exit impersonation" : "Turn off"}
        </button>
      </div>
    </M.div>
  );
}

Object.assign(window, { App, TopBar, SyncBadge, Landing, ImpersonationBanner, AnonymousShareView });
