/* =========================================================================
 *  CTWCAD — Context menu, upload manager, toasts, search, notifications,
 *  keyboard shortcuts cheat sheet.
 *
 *  (The pre-wave-6 CommandPalette has been retired in favor of
 *  GlobalSearchPalette below — file/project search lives there now.
 *  Top-bar Cmd+K still binds to the same overlay; nothing else changed.)
 * =======================================================================*/
function ContextMenu({ menu, onClose }) {
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;
  if (!menu) return null;

  const fileItems = [
    { id: "open",       icon: "eye",           label: "Preview" },
    { id: "open-cad",   icon: "external-link", label: "Open in CTWCAD" },
    { id: "download",   icon: "download",      label: "Download" },
    { id: "share",      icon: "share-2",       label: "Share…" },
    { divider: true },
    { id: "rename",     icon: "edit-3",        label: "Rename" },
    { id: "move",       icon: "folder-input",  label: "Move…" },
    { id: "duplicate",  icon: "copy",          label: "Duplicate" },
    { divider: true },
    { id: "trash",      icon: "trash-2",       label: "Move to Trash", danger: true },
  ];
  // Items shown when right-clicking a file that's already in the trash.
  const trashedFileItems = [
    { id: "open",            icon: "eye",      label: "Preview" },
    { id: "download",        icon: "download", label: "Download" },
    { divider: true },
    { id: "restore",         icon: "undo-2",   label: "Restore" },
    { id: "delete-forever",  icon: "trash-2",  label: "Delete forever", danger: true },
  ];
  // Items shown when right-clicking a version row in the file drawer.
  const versionItems = [
    { id: "download-version", icon: "download", label: "Download this version" },
    { id: "restore-version",  icon: "undo-2",   label: "Make this current" },
  ];
  const folderItems = [
    { id: "open",       icon: "folder-open", label: "Open" },
    { id: "new-folder", icon: "folder-plus", label: "New folder inside" },
    { id: "upload",     icon: "upload",      label: "Upload here…" },
    { divider: true },
    { id: "rename",     icon: "edit-3",       label: "Rename" },
    { id: "move",       icon: "folder-input", label: "Move…" },
    { id: "duplicate",  icon: "copy",         label: "Duplicate folder" },
    { id: "share",      icon: "share-2",      label: "Share folder…" },
    { divider: true },
    { id: "download",   icon: "download",     label: "Download as ZIP" },
    { id: "archive",    icon: "archive",      label: "Archive" },
    { id: "trash",      icon: "trash-2",      label: "Move to Trash", danger: true },
  ];
  // Pin label flips based on current state. The action id stays "pin" —
  // app.jsx's handler reads !item.pinned and toggles, so the same id
  // covers both directions. Icon swap (pin / pin-off) is just visual.
  const projectIsPinned = !!menu?.project?.pinned;
  const projectItems = [
    { id: "open",       icon: "folder-open", label: "Open project" },
    { id: "new-folder", icon: "folder-plus", label: "New folder" },
    { id: "upload",     icon: "upload",      label: "Upload to project…" },
    { divider: true },
    { id: "rename",     icon: "edit-3",     label: "Rename project" },
    { id: "members",    icon: "users",      label: "Manage members…" },
    { id: "settings",   icon: "settings",   label: "Project settings" },
    { id: "pin",
      icon: projectIsPinned ? "pin-off" : "pin",
      label: projectIsPinned ? "Unpin from dashboard" : "Pin to dashboard" },
    { divider: true },
    { id: "duplicate",  icon: "copy",       label: "Duplicate project" },
    { id: "download",   icon: "download",   label: "Export as ZIP" },
    { id: "archive",    icon: "archive",    label: "Archive project", danger: true },
  ];

  const items = menu.kind === "folder"        ? folderItems
              : menu.kind === "project"       ? projectItems
              : menu.kind === "trashed-file"  ? trashedFileItems
              : menu.kind === "version"       ? versionItems
              : fileItems;

  const title = menu.kind === "folder"        ? menu.folder?.name
              : menu.kind === "project"       ? menu.project?.name
              : menu.kind === "version"       ? `Version ${menu.version?.number ?? "?"}`
              : menu.file?.name;
  const titleIcon = menu.kind === "folder"        ? "folder"
                  : menu.kind === "project"       ? "box"
                  : menu.kind === "version"       ? "git-commit"
                  : menu.kind === "trashed-file"  ? "trash-2"
                  : "file";

  const dispatch = (it) => {
    if (it.divider || !it.id) return;
    window.dispatchEvent(new CustomEvent("ctwcad:item-action", {
      detail: {
        action: it.id,
        kind: menu.kind || "file",
        file: menu.file,
        folder: menu.folder,
        project: menu.project,
        version: menu.version,
      },
    }));
    onClose();
  };

  return (
    <A>
      <M.div
        className="ct-menu"
        style={{ left: menu.x, top: menu.y, transformOrigin: `${menu.ox}% ${menu.oy}%` }}
        initial={{ opacity: 0, scale: 0.92 }}
        animate={{ opacity: 1, scale: 1 }}
        exit={{ opacity: 0, scale: 0.95 }}
        transition={{ type: "spring", stiffness: 380, damping: 28 }}
        onMouseLeave={onClose}
      >
        {title && (
          <>
            <div className="ct-menu-title">
              <Icon name={titleIcon} size={12} />
              <span className="ct-mono">{title}</span>
            </div>
            <div className="ct-menu-divider" />
          </>
        )}
        {items.map((it, i) => it.divider
          ? <div className="ct-menu-divider" key={i} />
          : (
            <button key={i} className={"ct-menu-item " + (it.danger ? "is-danger" : "")} onClick={() => dispatch(it)}>
              <Icon name={it.icon} size={13}/>
              <span>{it.label}</span>
            </button>
          ))}
      </M.div>
    </A>
  );
}

function UploadManager({ uploads, onDismiss }) {
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;
  const [open, setOpen] = useState(true);
  if (!uploads || uploads.length === 0) return null;
  const done = uploads.every(u => u.progress >= 1);
  return (
    <M.div
      className="ct-upload-manager"
      initial={{ y: 30, opacity: 0 }} animate={{ y: 0, opacity: 1 }}
      exit={{ y: 30, opacity: 0 }}
      transition={{ type: "spring", stiffness: 280, damping: 28 }}
    >
      <div className="ct-upload-head" onClick={() => setOpen(o => !o)}>
        <span className="ct-upload-title">
          {done ? "Uploads complete" : `Uploading ${uploads.filter(u => u.progress < 1).length} of ${uploads.length}`}
        </span>
        <div>
          {done && <button className="ct-iconbtn" onClick={(e) => { e.stopPropagation(); onDismiss(); }}><Icon name="x" size={13}/></button>}
          <button className="ct-iconbtn"><Icon name={open ? "chevron-down" : "chevron-up"} size={13}/></button>
        </div>
      </div>
      <A>
        {open && (
          <M.ul
            className="ct-upload-list"
            initial={{ height: 0 }} animate={{ height: "auto" }} exit={{ height: 0 }}
            transition={{ duration: 0.25 }}
          >
            {uploads.map(u => {
              const done = u.progress >= 1;
              const eta = (typeof u.etaSeconds === "number") ? u.etaSeconds : null;
              const speedTxt = (u.speedBps > 0)
                ? fmtBytes(u.speedBps) + "/s"
                : null;
              const etaTxt = done ? "done"
                : (eta !== null
                    ? (eta >= 60
                        ? `${Math.floor(eta/60)}m ${eta%60}s left`
                        : `${eta}s left`)
                    : "estimating…");
              return (
                <li key={u.id} className="ct-upload-item">
                  <div className="ct-upload-name">{u.name}</div>
                  <div className="ct-upload-bar">
                    <M.div
                      className="ct-upload-fill"
                      animate={{ width: (u.progress * 100).toFixed(1) + "%" }}
                      transition={{ ease: [0.22, 1, 0.36, 1], duration: 0.4 }}
                    />
                    <span className="ct-upload-sheen" />
                  </div>
                  <div className="ct-upload-meta ct-mono ct-dim">
                    <span>{Math.round(u.progress * 100)}%</span>
                    {!done && speedTxt && <span className="ct-upload-sep">·</span>}
                    {!done && speedTxt && <span>{speedTxt}</span>}
                    <span className="ct-upload-sep">·</span>
                    <span>{etaTxt}</span>
                  </div>
                </li>
              );
            })}
          </M.ul>
        )}
      </A>
    </M.div>
  );
}

function Toasts({ items, onDismiss }) {
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;
  return (
    <div className="ct-toasts">
      <A>
        {items.map((t) => (
          <M.div
            key={t.id} className="ct-toast"
            initial={{ y: 20, opacity: 0 }}
            animate={{ y: 0, opacity: 1 }}
            exit={{ x: 60, opacity: 0 }}
            transition={{ type: "spring", stiffness: 320, damping: 28 }}
          >
            <Icon name={t.icon || "check"} size={14} />
            <span>{t.message}</span>
            <svg className="ct-toast-ring" width="18" height="18" viewBox="0 0 18 18">
              <circle cx="9" cy="9" r="7" stroke="currentColor" strokeOpacity="0.2" strokeWidth="1.5" fill="none"/>
              <M.circle cx="9" cy="9" r="7"
                stroke="currentColor" strokeWidth="1.5" fill="none"
                pathLength="1"
                strokeDasharray="1"
                initial={{ strokeDashoffset: 0 }}
                animate={{ strokeDashoffset: 1 }}
                transition={{ duration: t.duration || 4, ease: "linear" }}
                onAnimationComplete={() => onDismiss(t.id)}
                style={{ transformOrigin: "center", transform: "rotate(-90deg)" }}
              />
            </svg>
          </M.div>
        ))}
      </A>
    </div>
  );
}

/* Sticky bottom action bar surfaced when the user has multi-selected files
 * or folders in the browser. Bulk actions fan out to per-item dispatches
 * via ctwcad:item-action so they reuse the existing handlers in app.jsx
 * (folder-changed events, toasts, error handling are all free).
 * Animates in from below; mirrors the Dropbox / Drive bottom-action-bar
 * pattern. */
function BulkActionBar({ count, onMove, onTrash, onShare, onDownload, onCancel }) {
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;
  return (
    <A>
      {count > 0 && (
        <M.div
          className="ct-bulkbar"
          initial={{ y: 80, opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
          exit={{ y: 80, opacity: 0 }}
          transition={{ type: "spring", stiffness: 320, damping: 28 }}
          role="toolbar"
          aria-label="Bulk actions"
        >
          <div className="ct-bulkbar-count">
            <span className="ct-bulkbar-dot" />
            <span className="ct-mono">{count}</span>
            <span className="ct-dim">selected</span>
          </div>
          <div className="ct-bulkbar-actions">
            <button className="ct-btn" onClick={onDownload}>
              <Icon name="download" size={13}/>Download
            </button>
            <button className="ct-btn" onClick={onShare}>
              <Icon name="share-2" size={13}/>Share
            </button>
            <button className="ct-btn" onClick={onMove}>
              <Icon name="folder-input" size={13}/>Move
            </button>
            <button className="ct-btn is-danger" onClick={onTrash}>
              <Icon name="trash-2" size={13}/>Trash
            </button>
            <div className="ct-bulkbar-sep" />
            <button className="ct-iconbtn" title="Clear selection (Esc)" onClick={onCancel}>
              <Icon name="x" size={14}/>
            </button>
          </div>
        </M.div>
      )}
    </A>
  );
}

/* =========================================================================
 *  Wave 6 — Global file search palette (Cmd/Ctrl+K)
 *  -----------------------------------------------------------------------
 *  Searches every file the signed-in user can read (or every file system-
 *  wide when admin viewAll is on). Client-side fuzzy filter, debounced
 *  200 ms. Top 12 hits, keyboard navigation, Enter opens the file drawer.
 * =======================================================================*/
function GlobalSearchPalette({ open, onClose, onOpenFile }) {
  const [q, setQ] = useState("");
  const [debounced, setDebounced] = useState("");
  const [files, setFiles] = useState(null);  // null = first load, [] = empty
  const [fetchedAt, setFetchedAt] = useState(0);
  const [projectsById, setProjectsById] = useState({});
  const [foldersById, setFoldersById] = useState({});
  const [highlight, setHighlight] = useState(0);
  const inputRef = useRef(null);
  // Remember which element had focus before the palette opened so we can
  // restore it on close — keyboard users shouldn't get dropped at <body>.
  const lastFocusRef = useRef(null);
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;

  // Refresh files cache on open if we have nothing or it's >30 s stale.
  useEffect(() => {
    if (!open) {
      // On close, restore focus to whatever was active before we opened.
      const t = lastFocusRef.current;
      if (t && typeof t.focus === "function") {
        try { t.focus(); } catch {}
      }
      lastFocusRef.current = null;
      return;
    }
    lastFocusRef.current = document.activeElement;
    setQ("");
    setHighlight(0);
    setTimeout(() => inputRef.current?.focus(), 50);
    const stale = !files || (Date.now() - fetchedAt > 30_000);
    if (!stale) return;
    let cancelled = false;
    window.api.listAllOwnedFiles(500).then((rows) => {
      if (cancelled) return;
      setFiles(rows || []);
      setFetchedAt(Date.now());
      // Resolve project + folder names for context strings. One pass each.
      const projectIds = Array.from(new Set(rows.map(r => r.projectId).filter(Boolean)));
      const folderIds  = Array.from(new Set(rows.map(r => r.folderId ).filter(Boolean)));
      Promise.all(projectIds.map(id =>
        window.api.getProject?.(id).catch(() => null))).then((ps) => {
        if (cancelled) return;
        const map = {};
        ps.forEach((p, i) => { if (p) map[projectIds[i]] = p; });
        setProjectsById(map);
      });
      Promise.all(folderIds.map(id =>
        window.api.getFolder?.(id).catch(() => null))).then((fs) => {
        if (cancelled) return;
        const map = {};
        fs.forEach((f, i) => { if (f) map[folderIds[i]] = f; });
        setFoldersById(map);
      });
    }).catch(() => { if (!cancelled) setFiles([]); });
    return () => { cancelled = true; };
  }, [open]);

  // 200 ms debounce on the query.
  useEffect(() => {
    const t = setTimeout(() => setDebounced(q), 200);
    return () => clearTimeout(t);
  }, [q]);

  // Reset highlighted row whenever filter changes.
  useEffect(() => { setHighlight(0); }, [debounced]);

  // Filter + score. Files matching the query in their name (or tags) rise to
  // the top. We rank by: name-prefix > name-substring > tag-substring.
  const results = useMemo(() => {
    if (!files) return [];
    const needle = (debounced || "").trim().toLowerCase();
    if (!needle) return [];
    const out = [];
    for (const f of files) {
      const name = (f.name || "").toLowerCase();
      const tags = Array.isArray(f.tags) ? f.tags : [];
      let score = 0;
      if (name.startsWith(needle)) score = 3;
      else if (name.includes(needle)) score = 2;
      else if (tags.some(t => (t || "").toLowerCase().includes(needle))) score = 1;
      if (score > 0) out.push({ f, score });
    }
    out.sort((a, b) => {
      if (b.score !== a.score) return b.score - a.score;
      return new Date(b.f.updatedAt || 0) - new Date(a.f.updatedAt || 0);
    });
    return out.slice(0, 12).map(o => o.f);
  }, [files, debounced]);

  // Keyboard nav. Up / Down / Enter / Esc all swallowed when palette open.
  useEffect(() => {
    if (!open) return;
    const onKey = (e) => {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        setHighlight(i => Math.min(results.length - 1, i + 1));
      } else if (e.key === "ArrowUp") {
        e.preventDefault();
        setHighlight(i => Math.max(0, i - 1));
      } else if (e.key === "Enter") {
        e.preventDefault();
        const pick = results[highlight];
        if (pick) {
          onOpenFile?.(pick.id);
          onClose();
        }
      } else if (e.key === "Escape") {
        e.preventDefault();
        onClose();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, results, highlight, onOpenFile, onClose]);

  if (!open) return null;

  // Renders a name with the matching span underlined. Falls back to plain
  // text when no match (e.g. tag-only hits).
  const highlightMatch = (name) => {
    const needle = (debounced || "").trim().toLowerCase();
    if (!needle) return name;
    const lower = name.toLowerCase();
    const idx = lower.indexOf(needle);
    if (idx < 0) return name;
    return (
      <>
        {name.slice(0, idx)}
        <strong className="ct-gsearch-hit">
          {name.slice(idx, idx + needle.length)}
        </strong>
        {name.slice(idx + needle.length)}
      </>
    );
  };

  const contextOf = (f) => {
    const proj = f.projectId ? projectsById[f.projectId] : null;
    const fold = f.folderId  ? foldersById[f.folderId]   : null;
    const parts = [];
    if (proj?.name) parts.push(proj.name);
    if (fold?.name && fold.name !== proj?.name) parts.push(fold.name);
    return parts.join(" › ");
  };

  const empty = files && files.length === 0;

  return (
    <A>
      <M.div className="ct-backdrop"
        initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
        transition={{ duration: 0.12 }}
        onClick={onClose}
      />
      <M.div
        className="ct-gsearch"
        initial={{ y: -16, opacity: 0, scale: 0.98 }}
        animate={{ y: 0, opacity: 1, scale: 1 }}
        exit={{ y: -10, opacity: 0, scale: 0.98 }}
        transition={{ type: "spring", stiffness: 360, damping: 30 }}
        onClick={(e) => e.stopPropagation()}
        role="dialog" aria-modal="true"
      >
        <div className="ct-gsearch-input">
          <Icon name="search" size={14}/>
          <input
            ref={inputRef}
            value={q}
            onChange={(e) => setQ(e.target.value)}
            placeholder="Search across all your files…"
            spellCheck={false}
            autoComplete="off"
          />
          <span className="ct-mono ct-dim">esc</span>
        </div>
        <div className="ct-gsearch-body">
          {!debounced && (
            <div className="ct-gsearch-empty ct-mono ct-dim">
              {empty
                ? "No files yet — upload something to start searching."
                : (files === null
                    ? "loading your files…"
                    : "Type to search across all your files.")}
            </div>
          )}
          {debounced && results.length === 0 && (
            <div className="ct-gsearch-empty ct-mono ct-dim">
              No files match “{debounced}”.
            </div>
          )}
          {results.length > 0 && (
            <ul className="ct-gsearch-list" role="listbox">
              {results.map((f, i) => {
                const ctx = contextOf(f);
                const active = i === highlight;
                return (
                  <li
                    key={f.id}
                    className={"ct-gsearch-row" + (active ? " is-active" : "")}
                    role="option"
                    aria-selected={active}
                    onMouseEnter={() => setHighlight(i)}
                    onClick={() => { onOpenFile?.(f.id); onClose(); }}
                  >
                    <div className="ct-gsearch-thumb"><CADThumb file={f}/></div>
                    <div className="ct-gsearch-meta">
                      <div className="ct-gsearch-name">{highlightMatch(f.name)}</div>
                      <div className="ct-gsearch-ctx ct-mono ct-dim">
                        {ctx || "—"}
                      </div>
                    </div>
                    <KindBadge kind={f.kind || "bin"}/>
                  </li>
                );
              })}
            </ul>
          )}
        </div>
        <div className="ct-gsearch-foot ct-mono ct-dim">
          <span><kbd>↑↓</kbd> navigate</span>
          <span><kbd>enter</kbd> open</span>
          <span><kbd>esc</kbd> close</span>
        </div>
      </M.div>
    </A>
  );
}

/* =========================================================================
 *  Wave 6 — Notifications bell
 *  -----------------------------------------------------------------------
 *  Top-right bell with an unread count badge. Click opens a dropdown that
 *  lists the last ~10 relevant activity events. Live via onSnapshot.
 *  Marks-as-read by writing /users/{uid}.notificationLastReadAt on open.
 * =======================================================================*/
function NotificationsBell({ user }) {
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;
  const [open, setOpen] = useState(false);
  const [events, setEvents] = useState([]);
  const [actorMap, setActorMap] = useState({});
  const [filesById, setFilesById] = useState({});
  // Per-user "last read" timestamp. Stored as ms-since-epoch so date math
  // is trivial. Re-fetched on mount; bumped to Date.now() locally when
  // the dropdown opens (the server write follows asynchronously).
  const [lastReadAt, setLastReadAt] = useState(0);
  // Local opt-out from Settings → Notifications. We still hold the
  // listener subscription so the unread state is correct the moment the
  // user re-enables; we just hide the bell from the top bar.
  const [bellEnabled, setBellEnabled] = useState(() => localStorage.getItem("ctwcad.notif.bell") !== "0");
  useEffect(() => {
    const onPref = () => setBellEnabled(localStorage.getItem("ctwcad.notif.bell") !== "0");
    window.addEventListener("ctwcad:notif-pref-changed", onPref);
    return () => window.removeEventListener("ctwcad:notif-pref-changed", onPref);
  }, []);
  const wrapRef = useRef(null);
  const meUid = window.firebaseAuth?.currentUser?.uid || null;

  // Initial lastRead fetch.
  useEffect(() => {
    let cancelled = false;
    window.api.getNotificationLastReadAt().then((iso) => {
      if (cancelled) return;
      setLastReadAt(iso ? new Date(iso).getTime() : 0);
    });
    return () => { cancelled = true; };
  }, [user?.id, user?.email]);

  // Live activity listener. We re-establish whenever the actor changes.
  useEffect(() => {
    if (!user) return;
    const unsub = window.api.watchUserActivity((rows) => {
      setEvents(rows || []);
    }, 50);
    return () => { try { unsub?.(); } catch {} };
  }, [user?.id, user?.email]);

  // Resolve actor profiles + file names for the events we'll display.
  useEffect(() => {
    if (!events.length) return;
    const actorIds = Array.from(new Set(events.map(e => e.actorUid).filter(Boolean)));
    const fileIds  = Array.from(new Set(events.map(e => e.fileId  ).filter(Boolean)));
    let cancelled = false;
    window.api.resolveUserProfiles(actorIds).then((m) => {
      if (!cancelled) setActorMap(prev => ({ ...prev, ...m }));
    });
    Promise.all(fileIds.map(id =>
      window.api.getFile?.(id).catch(() => null))).then((rows) => {
      if (cancelled) return;
      const m = {};
      rows.forEach((r, i) => { if (r) m[fileIds[i]] = r; });
      setFilesById(prev => ({ ...prev, ...m }));
    });
    return () => { cancelled = true; };
  }, [events]);

  // Filter to events that should notify THIS user:
  //   - skip events the user themselves caused
  //   - keep events on files this user owns OR is a project member of
  //     (the server already enforces this via the rules — if the user
  //     can read the file, they can see the activity).
  // We also drop events whose fileId we couldn't resolve (file might be
  // deleted / out-of-scope).
  const relevant = useMemo(() => {
    return events.filter((ev) => {
      if (!ev || !ev.fileId) return false;
      if (meUid && ev.actorUid === meUid) return false;
      const f = filesById[ev.fileId];
      if (!f) return false; // can't resolve → not authorized → drop
      return true;
    });
  }, [events, filesById, meUid]);

  // Unread = count of relevant events newer than lastReadAt. Capped 9+.
  const unread = useMemo(() => {
    if (!lastReadAt) return Math.min(relevant.length, 99);
    return relevant.filter(ev => {
      const t = ev.at ? new Date(ev.at).getTime() : 0;
      return t > lastReadAt;
    }).length;
  }, [relevant, lastReadAt]);

  // Click-outside to close.
  useEffect(() => {
    if (!open) return;
    const onDoc = (e) => {
      if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
    };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, [open]);

  const onToggle = () => {
    const willOpen = !open;
    setOpen(willOpen);
    if (willOpen) {
      // Optimistically mark read locally so the badge clears immediately;
      // server write follows in the background. If the write fails the
      // badge will repopulate on next reload, which is the correct
      // pessimistic fallback.
      const now = Date.now();
      setLastReadAt(now);
      window.api.setNotificationLastReadAt().catch(() => {});
    }
  };

  // Friendly message generator. Falls back to the raw type for unknown
  // events so we never render a blank line.
  const messageFor = (ev, fileName) => {
    const map = {
      "version.created": `saved a new version of ${fileName}`,
      "file.created":    `uploaded ${fileName}`,
      "file.uploaded":   `uploaded ${fileName}`,
      "file.renamed":    `renamed ${fileName}`,
      "file.moved":      `moved ${fileName}`,
      "file.trashed":    `moved ${fileName} to Trash`,
      "file.restored":   `restored ${fileName}`,
      "file.shared":     `shared ${fileName}`,
      "file.commented":  `commented on ${fileName}`,
    };
    return map[ev.type] || `${ev.type || "did something with"} ${fileName}`;
  };

  // Hidden when the user has opted out via Settings → Notifications.
  // The listener / unread tracking is still wired up so re-enabling
  // shows the correct state immediately.
  if (!bellEnabled) return null;

  return (
    <div className="ct-notif-wrap" ref={wrapRef}>
      <button
        className="ct-iconbtn ct-notif-btn"
        onClick={onToggle}
        title={unread ? `${unread} new notification${unread === 1 ? "" : "s"}` : "Notifications"}
        aria-haspopup="menu"
        aria-expanded={open}
      >
        <Icon name="bell" size={14}/>
        {unread > 0 && (
          <span className="ct-notif-badge ct-mono">
            {unread > 9 ? "9+" : unread}
          </span>
        )}
      </button>
      <A>
        {open && (
          <M.div
            className="ct-notif-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-notif-head">
              <Icon name="bell" size={13}/>
              <span>Notifications</span>
              {unread > 0 && (
                <span className="ct-notif-head-pill ct-mono">
                  {unread > 9 ? "9+" : unread} new
                </span>
              )}
            </div>
            <div className="ct-menu-divider" />
            {relevant.length === 0 && (
              <div className="ct-notif-empty">
                <Icon name="inbox" size={20}/>
                <div className="ct-mono ct-dim">No notifications yet.</div>
                <div className="ct-mono ct-dim" style={{ fontSize: 10.5 }}>
                  When teammates upload, comment, or save versions, you'll see them here.
                </div>
              </div>
            )}
            {relevant.length > 0 && (
              <ul className="ct-notif-list">
                {relevant.slice(0, 10).map((ev) => {
                  const actor = actorMap[ev.actorUid] || null;
                  const file  = filesById[ev.fileId]  || null;
                  const fname = file?.name || "a file";
                  const t = ev.at ? new Date(ev.at).getTime() : 0;
                  const isUnread = t > lastReadAt;
                  return (
                    <li
                      key={ev.id}
                      className={"ct-notif-row" + (isUnread ? " is-unread" : "")}
                      onClick={() => {
                        if (file) {
                          window.dispatchEvent(new CustomEvent("ctwcad:item-action", {
                            detail: { action: "open", kind: "file", file },
                          }));
                          setOpen(false);
                        }
                      }}
                    >
                      <Avatar user={actor || { initials: "?", avatarHue: 200 }} size={26}/>
                      <div className="ct-notif-meta">
                        <div className="ct-notif-text">
                          <strong>{actor?.name || "Someone"}</strong>{" "}
                          <span>{messageFor(ev, fname)}</span>
                        </div>
                        <div className="ct-mono ct-dim ct-notif-when">
                          {ev.at ? fmtRelative(ev.at) : ""}
                        </div>
                      </div>
                      {isUnread && <span className="ct-notif-dot" aria-label="unread"/>}
                    </li>
                  );
                })}
              </ul>
            )}
          </M.div>
        )}
      </A>
    </div>
  );
}

/* =========================================================================
 *  Keyboard shortcuts cheat sheet
 *  -----------------------------------------------------------------------
 *  Mounted at the app root. Listens for the bare "?" key while no input
 *  is focused, and for the synthetic "ctwcad:open-shortcuts" event so
 *  the Settings page can open it directly. Esc / backdrop / close-button
 *  all dismiss. Pure presentational — no Firestore reads.
 * =======================================================================*/
function KeyboardShortcuts() {
  const M = window.FramerMotion.motion;
  const A = window.FramerMotion.AnimatePresence;
  const [open, setOpen] = useState(false);
  // Restore focus to whatever element opened the cheat sheet on close.
  const lastFocusRef = useRef(null);
  useEffect(() => {
    if (open) {
      lastFocusRef.current = document.activeElement;
    } else {
      const t = lastFocusRef.current;
      if (t && typeof t.focus === "function") {
        try { t.focus(); } catch {}
      }
      lastFocusRef.current = null;
    }
  }, [open]);

  useEffect(() => {
    const isEditable = (el) => {
      if (!el) return false;
      const tag = (el.tagName || "").toLowerCase();
      if (tag === "input" || tag === "textarea" || tag === "select") return true;
      if (el.isContentEditable) return true;
      return false;
    };
    const onKey = (e) => {
      if (e.key === "Escape") { setOpen(false); return; }
      // "?" — Shift+/ on US layout. Only act if no editable element is
      // focused (text inputs, the global search box, rename fields, etc.).
      if (e.key === "?" && !isEditable(document.activeElement) && !e.metaKey && !e.ctrlKey && !e.altKey) {
        e.preventDefault();
        setOpen((v) => !v);
      }
    };
    const onOpen = () => setOpen(true);
    window.addEventListener("keydown", onKey);
    window.addEventListener("ctwcad:open-shortcuts", onOpen);
    return () => {
      window.removeEventListener("keydown", onKey);
      window.removeEventListener("ctwcad:open-shortcuts", onOpen);
    };
  }, []);

  // Detect macOS once. "⌘" looks wrong on Windows / Linux; show "Ctrl"
  // instead. Falls back to userAgent if navigator.platform is empty
  // (it's deprecated but still populated on every shipping browser).
  const isMac = useMemo(() => {
    const p = (navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || "");
    return /mac|iphone|ipad|ipod/i.test(p);
  }, []);
  const cmdKey = isMac ? "⌘" : "Ctrl";

  const groups = [
    {
      title: "Navigation",
      rows: [
        { keys: [cmdKey, "K"],            label: "Open global search" },
        { keys: ["/"],                    label: "Focus folder search" },
        { keys: ["G", "D"],               label: "Go to dashboard" },
        { keys: ["G", "F"],               label: "Go to files" },
        { keys: ["G", "T"],               label: "Go to trash" },
        { keys: ["G", "S"],               label: "Go to settings" },
      ],
    },
    {
      title: "Files & folders",
      rows: [
        { keys: ["Enter"],                label: "Open selected item" },
        { keys: ["F2"],                   label: "Rename" },
        { keys: ["Del"],                  label: "Move to trash" },
        { keys: [cmdKey, "Click"],        label: "Add to selection" },
        { keys: ["Shift", "Click"],       label: "Select range" },
      ],
    },
    {
      title: "Overlays",
      rows: [
        { keys: ["Esc"],                  label: "Close drawer / modal" },
        { keys: ["?"],                    label: "Show this cheat sheet" },
      ],
    },
  ];

  return (
    <A>
      {open && (
        <>
          <M.div className="ct-backdrop"
            initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
            transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
            onClick={() => setOpen(false)} />
          <M.div className="ct-shortcuts"
            initial={{ opacity: 0, y: 12, scale: 0.97 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 6, scale: 0.98 }}
            transition={{ type: "spring", stiffness: 360, damping: 28 }}
            role="dialog" aria-label="Keyboard shortcuts"
          >
            <div className="ct-shortcuts-head">
              <div>
                <div className="ct-eyebrow ct-mono">SHORTCUTS</div>
                <h3 style={{margin: "2px 0 0 0"}}>Keyboard shortcuts</h3>
              </div>
              <button className="ct-iconbtn" onClick={() => setOpen(false)} title="Close">
                <Icon name="x" size={14}/>
              </button>
            </div>
            <div className="ct-shortcuts-body">
              {groups.map((g) => (
                <div key={g.title} className="ct-shortcuts-group">
                  <div className="ct-shortcuts-grouptitle ct-mono ct-dim">{g.title}</div>
                  <ul className="ct-shortcuts-list">
                    {g.rows.map((r, i) => (
                      <li key={i}>
                        <span>{r.label}</span>
                        <span className="ct-shortcuts-keys">
                          {r.keys.map((k, j) => (
                            <kbd key={j} className="ct-mono">{k}</kbd>
                          ))}
                        </span>
                      </li>
                    ))}
                  </ul>
                </div>
              ))}
            </div>
            <div className="ct-shortcuts-foot ct-mono ct-dim">
              Press <kbd>?</kbd> any time · Esc to close
            </div>
          </M.div>
        </>
      )}
    </A>
  );
}

Object.assign(window, {
  ContextMenu, UploadManager, Toasts, BulkActionBar,
  GlobalSearchPalette, NotificationsBell, KeyboardShortcuts,
});
