// Note screen + editor + share/AI dialogs + collab presence widgets.
// Extracted from app.jsx in Wave 4 of the component-extraction refactor.
// See CLAUDE.md "File layout" for the full split rule.
//
// Exports (via Object.assign(window, { ... }) at bottom):
//   NoteScreen, NoteEditor, NoteTextStylePopover, ShareNoteDialog,
//   NoteAIPanel, NoteFolderSelect, NoteTOC, CollabStatusPill,
//   PresenceStrip, GenerateInstructionsDialog
//
// Private (file-local, only used by exported components in this file):
//   NOTE_VIEW_KEY, FONT_SIZES, TEXT_COLORS, useNoteViewPrefs
//
// External deps consumed (must be window.* by the time these render):
//   from data modules: useAuth, useSubjects, useNotes, findNote,
//     findNoteSync, updateNote, deleteNote, useSharesFor, addShare,
//     removeShare, stageGeneration, newGuideId, useNoteFolders,
//     addNoteFolder, assignNoteToFolder, colorForUserId,
//     liveblocksReady, enterNoteRoom, prefetchNoteAuth, ensureTiptapLoaded
//   from helpers.jsx: Page, navigate, relativeTime, AiGlyph, ModalShell,
//     ModalHead
//   from Wave 3 promotion: IconPickerPopover
//   from app.jsx (still top-level there): AIChatPanel
//   browser/CDN globals: window.DOMPurify, window.TiptapEditor,
//     window.TiptapStarterKit, window.Y, window.LiveblocksYjsProvider,
//     window.TiptapCollaboration, window.TiptapCollaborationCursor

(function () {

function NoteFolderSelect({ noteId }) {
  const { folders, assignments } = useNoteFolders();
  const current = assignments[noteId] || '';
  const onChange = (e) => {
    const val = e.target.value;
    if (val === '__new') {
      const name = window.prompt('New folder name');
      if (!name) return;
      try {
        const id = addNoteFolder(name);
        assignNoteToFolder(noteId, id);
      } catch (err) { alert(err.message); }
      return;
    }
    assignNoteToFolder(noteId, val || null);
  };
  return (
    <select className="input" value={current} onChange={onChange}
      style={{ height: 28, width: 'auto', fontSize: 12, padding: '0 10px' }}
      title="Move to a custom folder (overrides the subject folder in the sidebar)">
      <option value="">— no folder —</option>
      {folders.map((f) => <option key={f.id} value={f.id}>🗂 {f.name}</option>)}
      <option value="__new">＋ New folder…</option>
    </select>
  );
}

function NoteScreen({ noteId }) {
  const subjects = useSubjects();
  const allNotes = useNotes();
  const auth = useAuth();
  const collaborators = useSharesFor(noteId, 'notes');
  const isShared = collaborators.length > 0;
  const [note, setNote] = React.useState(() => findNoteSync(noteId));
  const [tried, setTried] = React.useState(!!findNoteSync(noteId));
  const [presence, setPresence] = React.useState([]);
  const [shareOpen, setShareOpen] = React.useState(false);
  // Init based on isShared so non-shared notes don't briefly flash "Connecting"
  // before the editor reports back "Solo". For shared notes the editor will
  // transition this to 'live' (or 'failed') once Liveblocks syncs.
  const [collabStatus, setCollabStatus] = React.useState(isShared ? 'connecting' : 'solo');
  const [collabRetry, setCollabRetry] = React.useState(0);
  const retryCollab = () => { setCollabStatus('connecting'); setCollabRetry((k) => k + 1); };
  const [aiPanelOpen, setAiPanelOpen] = React.useState(false);
  const [genInstructionsOpen, setGenInstructionsOpen] = React.useState(false);

  // Start downloading the Tiptap UMD bundle in parallel with findNote
  // instead of waiting until NoteEditor mounts (which only happens after
  // the note row arrives). Shaves the editor-load segment off the cold
  // open critical path.
  React.useEffect(() => { window.ensureTiptapLoaded?.(); }, []);

  // Prefetch the Liveblocks auth token for this note's room in parallel
  // with findNote + the Tiptap load. By the time NoteEditor mounts and
  // enterNoteRoom runs, the Liveblocks client's authEndpoint can hand
  // back a cached response instead of waiting on a fresh Worker call.
  // Fire-and-forget — fails close (drops cache entry, authEndpoint retries).
  React.useEffect(() => {
    if (!noteId || !auth.user) return;
    window.prefetchNoteAuth?.(noteId);
  }, [noteId, auth.user?.id]);

  React.useEffect(() => {
    if (tried) return;
    let cancelled = false;
    findNote(noteId).then((n) => {
      if (cancelled) return;
      setNote(n);
      setTried(true);
    });
    return () => { cancelled = true; };
  }, [noteId, tried]);

  // Keep local copy in sync if cache updates from elsewhere (e.g. another tab).
  React.useEffect(() => {
    const fresh = allNotes.find((n) => n.id === noteId);
    if (fresh && note && fresh.updated_at !== note.updated_at && !dirtyRef.current) {
      setNote(fresh);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [allNotes, noteId]);

  // 'idle' | 'dirty' | 'saving' | 'saved' | 'error'
  const [saveState, setSaveState] = React.useState('idle');
  const [savedAt, setSavedAt] = React.useState(null);
  const [saveError, setSaveError] = React.useState(null);
  const dirtyRef = React.useRef(false);
  const pendingRef = React.useRef(null);
  const timerRef = React.useRef(null);

  const flush = React.useCallback(async () => {
    if (!pendingRef.current) return;
    const patch = pendingRef.current;
    pendingRef.current = null;
    setSaveState('saving');
    try {
      const updated = await updateNote(noteId, patch);
      if (!updated) throw new Error('note no longer exists');
      dirtyRef.current = false;
      setSaveState('saved');
      setSavedAt(new Date(updated.updated_at));
      setSaveError(null);
    } catch (e) {
      console.error('note save failed', e);
      setSaveError(e.message || String(e));
      setSaveState('error');
    }
  }, [noteId]);

  const queueSave = React.useCallback((patch) => {
    pendingRef.current = { ...(pendingRef.current || {}), ...patch };
    dirtyRef.current = true;
    setSaveState('dirty');
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(flush, 1000);
  }, [flush]);

  // Flush pending edits before unmount or page unload. Use a ref for flush
  // so we don't tear down + re-attach the listener (and re-trigger cleanup
  // flush) every time `flush` recreates from a noteId change.
  const flushRef = React.useRef(flush);
  React.useEffect(() => { flushRef.current = flush; }, [flush]);
  React.useEffect(() => {
    const onBeforeUnload = () => {
      if (timerRef.current) { clearTimeout(timerRef.current); flushRef.current(); }
    };
    window.addEventListener('beforeunload', onBeforeUnload);
    return () => {
      window.removeEventListener('beforeunload', onBeforeUnload);
      if (timerRef.current) { clearTimeout(timerRef.current); flushRef.current(); }
    };
  }, []);

  // ── All hooks MUST be declared above the early returns below ──────────────
  // Outline rail state — populated by NoteEditor on every transaction.
  const [headings, setHeadings] = React.useState([]);
  const editorApiRef = React.useRef(null);
  const onApi = React.useCallback((api) => { editorApiRef.current = api; }, []);
  // View preferences (persisted in localStorage; apply to the editor surface).
  const view = useNoteViewPrefs();

  // Browser fullscreen toggle. Tracks `fullscreenchange` so the button label
  // and pressed-state stay in sync with what the OS / browser actually shows
  // (incl. Esc to exit and other apps grabbing fullscreen).
  const [isFullscreen, setIsFullscreen] = React.useState(
    typeof document !== 'undefined' && !!document.fullscreenElement
  );
  React.useEffect(() => {
    const onChange = () => setIsFullscreen(!!document.fullscreenElement);
    document.addEventListener('fullscreenchange', onChange);
    return () => document.removeEventListener('fullscreenchange', onChange);
  }, []);
  const toggleFullscreen = React.useCallback(() => {
    if (document.fullscreenElement) {
      document.exitFullscreen?.().catch(() => {});
    } else {
      document.documentElement.requestFullscreen?.().catch((err) => {
        console.warn('fullscreen request failed', err);
      });
    }
  }, []);

  if (!tried) {
    return (
      <Page crumbs={[{ label: 'All notes', to: '/notes' }, 'Loading…']}>
        <div style={{ flex: 1, display: 'grid', placeItems: 'center', color: 'var(--ink-mute)', fontSize: 13 }}>
          <span className="pulse">Loading note…</span>
        </div>
      </Page>
    );
  }

  if (!note) {
    return (
      <Page crumbs={[{ label: 'All notes', to: '/notes' }, 'Not found']}>
        <div style={{ padding: 32 }}>
          <div style={{ fontSize: 14 }}>Note not found.</div>
          <button className="btn sm" style={{ marginTop: 12 }} onClick={() => navigate('/notes')}>Back to notes</button>
        </div>
      </Page>
    );
  }

  const subj = subjects.find((s) => s.id === note.subject_id);
  const setTitle = (v) => {
    setNote((n) => ({ ...n, title: v }));
    queueSave({ title: (v || '').trim() || 'Untitled note' });
  };
  const setSubject = (sid) => {
    const next = sid || null;
    setNote((n) => ({ ...n, subject_id: next }));
    queueSave({ subject_id: next });
  };
  const setIcon = ({ emoji: em, iconPath }) => {
    setNote((n) => ({ ...n, emoji: em, icon_path: iconPath }));
    queueSave({ emoji: em || null, icon_path: iconPath || null });
  };
  const onEditorChange = ({ html, text }) => {
    setNote((n) => ({ ...n, body_html: html, body_text: text }));
    queueSave({ body_html: html, body_text: text });
  };

  const jumpTo = (idx) => editorApiRef.current?.scrollToHeading(idx);

  const openGenerate = async () => {
    if (timerRef.current) { clearTimeout(timerRef.current); await flush(); }
    if (!note.subject_id) {
      alert('Pick a subject first — guides are organized by subject.');
      return;
    }
    const text = (note.body_text || '').trim();
    if (!text) {
      alert('This note is empty. Write something first.');
      return;
    }
    setGenInstructionsOpen(true);
  };

  const generate = async (instructions) => {
    const text = (note.body_text || '').trim();
    const fileName = (note.title || 'Untitled note').trim() + '.md';
    const parsed = [{ name: fileName, kind: 'text', text }];
    stageGeneration(note.subject_id, { files: parsed, notes: instructions || '', mode: 'full' });
    const newId = newGuideId();
    navigate(`/subject/${note.subject_id}/guide/${newId}/generate`);
  };

  const remove = async () => {
    if (!confirm(`Delete "${note.title || 'this note'}"? This cannot be undone.`)) return;
    if (timerRef.current) clearTimeout(timerRef.current);
    pendingRef.current = null;
    try {
      await deleteNote(note.id);
      navigate('/notes');
    } catch (e) {
      alert('Delete failed: ' + e.message);
    }
  };

  const statusLabel =
    saveState === 'saving' ? 'Saving…' :
    saveState === 'dirty'  ? 'Unsaved' :
    saveState === 'error'  ? 'Save failed' :
    savedAt ? `Saved ${relativeTime(savedAt.toISOString())}` :
    `Saved ${relativeTime(note.updated_at)}`;

  const editorWrapStyle = {
    '--note-font-size': view.fontSizePx + 'px',
    '--note-text-color': view.textColor || 'var(--ink)',
  };

  return (
    <Page
      crumbs={[
        { label: 'All notes', to: '/notes' },
        note.title || 'Untitled note',
      ]}
      actions={
        <>
          <CollabStatusPill status={collabStatus} onRetry={retryCollab} />
          <PresenceStrip me={auth.user} others={presence} />
          <span className={"save-status " + (saveState === 'saving' ? 'saving' : saveState === 'error' ? 'error' : '')}
            title={saveError || ''}>{statusLabel}</span>
          <button
            className={"btn sm wide-toggle" + (isFullscreen ? ' active' : '')}
            onClick={toggleFullscreen}
            title={isFullscreen ? 'Exit full screen (Esc)' : 'Enter full screen'}
            aria-pressed={isFullscreen}
          >⛶ {isFullscreen ? 'Exit full screen' : 'Full screen'}</button>
          <button className={"btn sm" + (aiPanelOpen ? ' active' : '')} onClick={() => setAiPanelOpen((o) => !o)}><AiGlyph />AI</button>
          <button className="btn sm" onClick={() => setShareOpen(true)}>↥ Share</button>
          <button className="btn ghost sm" onClick={remove}>Delete</button>
          <button className="btn accent sm" onClick={openGenerate}>✦ Generate study guide</button>
        </>
      }
    >
      <div className="note-fullscreen">
        <aside className="note-toc-rail">
          <NoteTOC headings={headings} onJump={jumpTo} />
        </aside>
        <main className="note-main">
          <div className={"note-main-inner fade-in" + (view.wide ? ' wide' : '')} style={editorWrapStyle}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14, flexWrap: 'wrap' }}>
              <div className="eyebrow">§ Note</div>
              <select className="input" value={note.subject_id || ''} onChange={(e) => setSubject(e.target.value)}
                style={{ height: 28, width: 'auto', fontSize: 12, padding: '0 10px' }}>
                <option value="">— no subject —</option>
                {subjects.map((s) => <option key={s.id} value={s.id}>{s.emoji} {s.name}</option>)}
              </select>
              <NoteFolderSelect noteId={noteId} />
            </div>

            <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
              <IconPickerPopover
                emoji={note.emoji}
                iconPath={note.icon_path}
                onChange={setIcon}
                size={48}
              />
              <input className="note-title-input" type="text" value={note.title || ''} placeholder="Untitled note"
                onChange={(e) => setTitle(e.target.value)} style={{ flex: 1 }} />
            </div>

            <div className="note-editor-wrap" style={{ marginTop: 22 }}>
              <NoteEditor
                /* remount on note switch OR Retry click so initial content + collab restart cleanly */
                key={`${noteId}-${collabRetry}`}
                initialHTML={note.body_html || ''}
                onChange={onEditorChange}
                onHeadings={setHeadings}
                onApi={onApi}
                view={view}
                noteId={noteId}
                user={auth.user}
                isShared={isShared}
                onPresenceChange={setPresence}
                onCollabStatus={setCollabStatus}
              />
            </div>

            {!note.subject_id && (
              <div style={{ marginTop: 14, fontSize: 12, color: 'var(--ink-mute)' }}>
                Pick a subject above to enable “Generate study guide”.
              </div>
            )}
          </div>
        </main>
      </div>
      {shareOpen && <ShareNoteDialog note={note} ownedByMe={note.user_id === auth.user?.id} onClose={() => setShareOpen(false)} />}
      {aiPanelOpen && <NoteAIPanel note={note} onClose={() => setAiPanelOpen(false)} />}
      {genInstructionsOpen && (
        <GenerateInstructionsDialog
          noteTitle={note.title || 'Untitled note'}
          onCancel={() => setGenInstructionsOpen(false)}
          onConfirm={(instructions) => { setGenInstructionsOpen(false); generate(instructions); }}
        />
      )}
    </Page>
  );
}

// ── Pre-generation instructions dialog (note → guide flow) ────────────────
// The dialog/upload paths already have an instructions textarea inline; the
// note → guide button is a one-click action with no surface for them, so this
// modal fills the gap.
function GenerateInstructionsDialog({ noteTitle, onCancel, onConfirm }) {
  const [instructions, setInstructions] = React.useState('');
  const submit = (e) => { e.preventDefault(); onConfirm(instructions.trim()); };
  return (
    <ModalShell onClose={onCancel}>
      <form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        <ModalHead title="Generate study guide" onClose={onCancel} />
        <div style={{ fontSize: 13, color: 'var(--ink-soft)', lineHeight: 1.5 }}>
          Generating from <strong style={{ color: 'var(--ink-strong)' }}>{noteTitle}</strong>.
        </div>
        <label className="field">
          <span>Instructions for the AI (optional)</span>
          <textarea
            className="input"
            rows={5}
            autoFocus
            value={instructions}
            onChange={(e) => setInstructions(e.target.value)}
            placeholder="e.g. focus on the section about cell respiration; explain like I'm new to the field; skip the historical background."
          />
        </label>
        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
          <button type="button" className="btn ghost sm" onClick={onCancel}>Cancel</button>
          <button type="submit" className="btn accent sm">✦ Generate</button>
        </div>
      </form>
    </ModalShell>
  );
}

// ── Collab connection status pill ──────────────────────────────────────────
// status: 'connecting' | 'live' | 'reconnecting' | 'disconnected' | 'failed' | 'solo'
function CollabStatusPill({ status, onRetry }) {
  const config = {
    connecting:   { label: 'Connecting',   tone: 'connecting', hint: 'Setting up live sync…' },
    live:         { label: 'Live',         tone: 'live',       hint: 'Real-time sync is on. Edits sync instantly.' },
    reconnecting: { label: 'Reconnecting', tone: 'connecting', hint: 'Lost the live connection — trying to recover.' },
    disconnected: { label: 'Offline',      tone: 'failed',     hint: 'No live connection. Edits stay local until reconnect.' },
    failed:       { label: 'Not connected',tone: 'failed',     hint: "Couldn't connect to live sync. Editor still works in solo mode." },
    solo:         { label: 'Solo',         tone: 'solo',       hint: 'Live sync is off — share this note to enable real-time collab.' },
  }[status] || { label: status, tone: 'solo', hint: '' };
  const showRetry = status === 'failed' || status === 'disconnected';
  return (
    <span className={"collab-status-pill " + config.tone}>
      <span className="dot" />
      <span>{config.label}</span>
      {showRetry && (
        <button type="button" className="retry" onClick={onRetry} title="Retry the connection">Retry</button>
      )}
      <span className="collab-status-tip" role="tooltip">{config.hint}</span>
    </span>
  );
}

// ── Presence avatar strip — small circles for each other user in the room ──
function PresenceStrip({ me, others }) {
  if (!others || others.length === 0) return null;
  const visible = others.slice(0, 4);
  const overflow = others.length - visible.length;
  return (
    <div style={{ display: 'inline-flex', alignItems: 'center', marginRight: 6 }} title={`${others.length} ${others.length === 1 ? 'person' : 'people'} editing`}>
      {visible.map((u, i) => {
        const initials = (u.name || u.email || '?').slice(0, 2).toUpperCase();
        const ringColor = u.color || '#888';
        return (
          <div key={u.id || u.email || i} title={u.email || u.name}
            style={{
              width: 24, height: 24, borderRadius: '50%',
              background: u.avatarUrl ? 'var(--page)' : ringColor,
              color: 'white',
              display: 'grid', placeItems: 'center',
              fontFamily: 'var(--mono)', fontSize: 9.5, fontWeight: 600,
              border: `2px solid ${ringColor}`,
              marginLeft: i === 0 ? 0 : -6,
              boxShadow: '0 0 0 1px var(--page)',
              overflow: 'hidden',
              backgroundClip: 'padding-box',
            }}>
            {u.avatarUrl ? (
              <img src={u.avatarUrl} alt=""
                style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
                onError={(e) => { e.currentTarget.style.display = 'none'; }} />
            ) : initials}
          </div>
        );
      })}
      {overflow > 0 && (
        <div style={{
          width: 22, height: 22, borderRadius: '50%',
          background: 'var(--page-soft)', color: 'var(--ink-soft)',
          display: 'grid', placeItems: 'center',
          fontFamily: 'var(--mono)', fontSize: 9.5, fontWeight: 600,
          border: '2px solid var(--page)', marginLeft: -6,
        }}>+{overflow}</div>
      )}
    </div>
  );
}

// ── Note-context wrapper for AIChatPanel ───────────────────────────────────
// AIChatPanel itself stays in app.jsx until Wave 8 — note-screen.jsx
// references it as a bare identifier here. That resolves through the global
// script scope because AIChatPanel is a top-level `function` declaration in
// app.jsx (non-IIFE), so it lands on window automatically.
function NoteAIPanel({ note, onClose }) {
  const context = React.useMemo(() => ({
    kind: 'note',
    id: note?.id || null,
    title: note?.title || 'Untitled note',
    systemPrompt:
      `You are an AI study assistant inside a notes app. The user is working on a note titled "${note?.title || 'Untitled'}". ` +
      `Be concise and grounded in the note. If the note is empty, help them outline ideas.\n\n` +
      `Note content (plain text):\n${(note?.body_text || '(empty)').slice(0, 8000)}`,
    placeholder: 'Ask anything about this note…  (Enter sends, Shift+Enter newline)',
    presets: [
      'Summarize this note in 3 bullets',
      'Explain like I\'m new to the topic',
      'Quiz me on this — 5 questions',
      'What\'s missing from this note?',
    ],
  }), [note?.id, note?.title, note?.body_text]);
  return <AIChatPanel context={context} onClose={onClose} />;
}

// ── Share dialog ──────────────────────────────────────────────────────────

function ShareNoteDialog({ note, ownedByMe, onClose }) {
  const collaborators = useSharesFor(note.id, 'notes');
  const [email, setEmail] = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const submit = async (e) => {
    e?.preventDefault?.();
    if (!ownedByMe) { setErr('Only the owner can change collaborators.'); return; }
    setBusy(true); setErr(null);
    try {
      await addShare({ resource_id: note.id, recipient_email: email });
      setEmail('');
    } catch (e2) { setErr(e2.message); }
    finally { setBusy(false); }
  };

  const remove = async (id) => {
    setBusy(true); setErr(null);
    try { await removeShare(id); }
    catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  };

  return (
    <ModalShell width={460} onClose={onClose}>
      <ModalHead title="Share note" sub={`"${note.title || 'Untitled note'}" — collaborators can view + edit in real time.`} onClose={onClose} />

      {!ownedByMe && (
        <div style={{ padding: '8px 12px', background: 'var(--page-tint)', border: '1px solid var(--hairline-bold)', borderRadius: 6, fontSize: 12.5, color: 'var(--ink-soft)' }}>
          You're a collaborator on this note, not the owner. Only the owner can add or remove collaborators.
        </div>
      )}

      {ownedByMe && (
        <form onSubmit={submit} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
          <label className="field" style={{ marginBottom: 0, flex: 1 }}>
            <span>Add by email</span>
            <input className="input" type="email" required placeholder="collaborator@example.com"
              value={email} onChange={(e) => setEmail(e.target.value)} autoFocus />
          </label>
          <button type="submit" className="btn accent sm" disabled={busy || !email.trim()}>
            {busy ? '…' : '＋ Invite'}
          </button>
        </form>
      )}

      {err && (
        <div style={{ padding: '8px 12px', background: 'var(--brick-tint)', border: '1px solid var(--brick)', borderRadius: 6, fontSize: 12.5, color: 'var(--brick)' }}>{err}</div>
      )}

      <div>
        <div className="eyebrow" style={{ marginBottom: 6 }}>
          Collaborators · {collaborators.length}
        </div>
        {collaborators.length === 0 ? (
          <div style={{ fontSize: 13, color: 'var(--ink-mute)', padding: '8px 0' }}>No collaborators yet.</div>
        ) : (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
            {collaborators.map((s) => {
              const initials = String(s.recipient_email || '?').slice(0, 2).toUpperCase();
              // Hash by user_id so this avatar matches the same user's color
              // shown in Liveblocks live cursors (which key on user.id).
              const color = window.colorForUserId?.(s.recipient_user_id || s.recipient_email) || '#888';
              return (
                <div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', border: '1px solid var(--hairline)', borderRadius: 'var(--r-sm)' }}>
                  <div style={{ width: 24, height: 24, borderRadius: '50%', background: color, color: 'white', display: 'grid', placeItems: 'center', fontFamily: 'var(--mono)', fontSize: 10, fontWeight: 600 }}>{initials}</div>
                  <div style={{ flex: 1, minWidth: 0, fontSize: 13, color: 'var(--ink-strong)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.recipient_email}</div>
                  <span style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--ink-mute)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>{s.permission}</span>
                  {ownedByMe && (
                    <button className="row-delete" title="Remove collaborator" onClick={() => remove(s.id)} disabled={busy}>×</button>
                  )}
                </div>
              );
            })}
          </div>
        )}
      </div>

      <div style={{ display: 'flex' }}>
        <div style={{ flex: 1 }} />
        <button type="button" className="btn ghost sm" onClick={onClose}>Done</button>
      </div>
    </ModalShell>
  );
}

// ── View preferences for the note editor ────────────────────────────────────
// File-private: only useNoteViewPrefs reads NOTE_VIEW_KEY/FONT_SIZES/TEXT_COLORS,
// and only NoteScreen + NoteTextStylePopover consume the hook. Not exported.

const NOTE_VIEW_KEY = 'studybuddy.note.view';
const FONT_SIZES = [
  { id: 'sm', label: 'Small',  px: 13 },
  { id: 'md', label: 'Medium', px: 15 },
  { id: 'lg', label: 'Large',  px: 18 },
  { id: 'xl', label: 'XL',     px: 22 },
];
const TEXT_COLORS = [
  { id: 'default', label: 'Default', value: '' },               // empty = inherit --ink
  { id: 'gray',    label: 'Gray',    value: '#787774' },
  { id: 'brown',   label: 'Brown',   value: '#9c5d3f' },
  { id: 'red',     label: 'Red',     value: '#b0473d' },
  { id: 'orange',  label: 'Orange',  value: '#cc782f' },
  { id: 'amber',   label: 'Amber',   value: '#a26d1f' },
  { id: 'green',   label: 'Green',   value: '#5a7d4f' },
  { id: 'blue',    label: 'Blue',    value: '#2540d9' },
  { id: 'purple',  label: 'Purple',  value: '#7c5dbf' },
];

function useNoteViewPrefs() {
  const load = () => {
    try {
      const raw = localStorage.getItem(NOTE_VIEW_KEY);
      if (!raw) return null;
      return JSON.parse(raw);
    } catch { return null; }
  };
  const initial = load() || {};
  const [wide,        setWide]        = React.useState(!!initial.wide);
  const [fontSizeId,  setFontSizeId]  = React.useState(initial.fontSizeId || 'md');
  const [textColorId, setTextColorId] = React.useState(initial.textColorId || 'default');

  React.useEffect(() => {
    try {
      localStorage.setItem(NOTE_VIEW_KEY, JSON.stringify({ wide, fontSizeId, textColorId }));
    } catch {}
  }, [wide, fontSizeId, textColorId]);

  const fontSize = FONT_SIZES.find((f) => f.id === fontSizeId) || FONT_SIZES[1];
  const textColor = TEXT_COLORS.find((c) => c.id === textColorId) || TEXT_COLORS[0];
  return {
    wide,
    fontSizeId, setFontSizeId, fontSizePx: fontSize.px, fontSizeLabel: fontSize.label,
    textColorId, setTextColorId, textColor: textColor.value, textColorLabel: textColor.label,
  };
}

// Outline rail for the fullscreen note view. Renders a flat list of H1/H2/H3
// with progressive indentation to imply nesting; clicking scrolls the editor.
function NoteTOC({ headings, onJump }) {
  return (
    <div className="note-toc">
      <div className="note-toc-head">Outline</div>
      {(!headings || headings.length === 0) ? (
        <div className="note-toc-empty">
          Add an <span className="kbd">H1</span>, <span className="kbd">H2</span>, or <span className="kbd">H3</span> heading
          and it'll show up here, nested by level.
        </div>
      ) : (
        <div className="toc-tree">
          {headings.map((h) => (
            <div
              key={h.idx}
              className={`toc-tree-item lvl-${h.level}`}
              onClick={() => onJump(h.idx)}
              title={h.text}
            >
              <span className="toc-tree-dot" />
              <span className="toc-tree-label">{h.text}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// Tiptap-based rich-text editor. Tiptap loads via ESM in index.html and
// attaches Editor + StarterKit to window. We render a placeholder until ready.
function NoteEditor({ initialHTML, onChange, onHeadings, onApi, view, noteId, user, isShared, onPresenceChange, onCollabStatus }) {
  const hostRef = React.useRef(null);
  const editorRef = React.useRef(null);
  const onChangeRef = React.useRef(onChange);
  const onHeadingsRef = React.useRef(onHeadings);
  const onApiRef = React.useRef(onApi);
  const onPresenceChangeRef = React.useRef(onPresenceChange);
  const onCollabStatusRef = React.useRef(onCollabStatus);
  onChangeRef.current = onChange;
  onHeadingsRef.current = onHeadings;
  onApiRef.current = onApi;
  onPresenceChangeRef.current = onPresenceChange;
  onCollabStatusRef.current = onCollabStatus;

  const [ready, setReady] = React.useState(!!window.TiptapEditor);
  const [loadErr, setLoadErr] = React.useState(window.TiptapLoadError || null);
  // Track when Liveblocks libs finish loading so collab mode can wake up
  // even if the user opened a note before the dynamic import resolved.
  const [collabReady, setCollabReady] = React.useState(!!window.liveblocksReady?.());
  const [, force] = React.useReducer((x) => x + 1, 0);
  // True when the editor is mounted and ready to type into. For solo mode
  // this flips on synchronously inside the effect. For collab mode it waits
  // for the Yjs provider's first 'sync' — mounting CollaborationCursor before
  // the provider has a doc throws "Cannot read properties of undefined (reading 'doc')".
  // NOTE: declared up here (alongside the other useState calls) so all hooks
  // run unconditionally before any early return — Rules of Hooks.
  const [providerReady, setProviderReady] = React.useState(false);

  React.useEffect(() => {
    if (window.TiptapEditor) { setReady(true); return; }
    if (window.TiptapLoadError) { setLoadErr(window.TiptapLoadError); return; }
    const onReady  = () => setReady(true);
    const onFailed = (e) => setLoadErr((e?.detail?.message) || (window.TiptapLoadError) || 'Failed to load editor');
    window.addEventListener('tiptap-ready', onReady);
    window.addEventListener('tiptap-failed', onFailed);
    // Kick the lazy loader. No-op if already in flight or done.
    window.ensureTiptapLoaded?.();
    return () => {
      window.removeEventListener('tiptap-ready', onReady);
      window.removeEventListener('tiptap-failed', onFailed);
    };
  }, []);

  React.useEffect(() => {
    if (window.liveblocksReady?.()) { setCollabReady(true); return; }
    const onR = () => setCollabReady(true);
    window.addEventListener('liveblocks-ready', onR);
    // Kick the lazy loader for collab extensions. NoteEditor is the only
    // place we need them, so loading here keeps the dashboard/library fast.
    window.ensureLiveblocksLoaded?.();
    return () => window.removeEventListener('liveblocks-ready', onR);
  }, []);

  // Skip live sync when the note isn't shared with anyone — saves Liveblocks
  // room slots and keeps the "Solo" pill honest. Re-enables automatically the
  // moment a collaborator is added (effect deps include isShared).
  const useCollab = !!(noteId && user && collabReady && isShared && window.liveblocksReady?.());

  React.useEffect(() => {
    if (!ready || !hostRef.current) return;
    const Editor = window.TiptapEditor;
    const StarterKit = window.TiptapStarterKit;

    let roomHandle = null;
    let ydoc = null;
    let yProvider = null;
    let editor = null;
    let cancelled = false;
    let presenceUnsub = null;
    let statusUnsub = null;
    let syncTimer = null;

    const computeHeadings = (editor) => {
      const list = [];
      let idx = 0;
      editor.state.doc.descendants((node) => {
        if (node.type.name === 'heading') {
          list.push({
            idx: idx++,
            level: node.attrs.level || 1,
            text: (node.textContent || '').trim() || '(untitled)',
          });
        }
      });
      onHeadingsRef.current?.(list);
    };

    const scrollToHeading = (idx) => {
      const els = hostRef.current?.querySelectorAll('h1, h2, h3, h4, h5, h6');
      const el = els?.[idx];
      if (!el) return;
      el.scrollIntoView({ behavior: 'smooth', block: 'start' });
    };

    const buildEditor = (extensions, opts = {}) => {
      editor = new Editor({
        element: hostRef.current,
        extensions,
        ...opts,
        editorProps: {
          attributes: {
            'data-placeholder': 'Start writing… use the toolbar for formatting.',
          },
        },
        onCreate: ({ editor }) => { computeHeadings(editor); },
        onUpdate: ({ editor }) => {
          const html = editor.getHTML();
          const text = editor.getText();
          onChangeRef.current?.({ html, text });
          computeHeadings(editor);
          force();
        },
        onSelectionUpdate: () => force(),
        onTransaction: () => force(),
      });
      editorRef.current = editor;
      onApiRef.current?.({ scrollToHeading });
      setProviderReady(true);
    };

    if (useCollab) {
      const Y = window.Y;
      const LBY = window.LiveblocksYjsProvider;
      const Collab = window.TiptapCollaboration;
      const CollabCursor = window.TiptapCollaborationCursor;

      const reportStatus = (s) => onCollabStatusRef.current?.(s);
      reportStatus('connecting');

      ydoc = new Y.Doc();
      roomHandle = window.enterNoteRoom({ noteId, user });
      if (roomHandle) {
        yProvider = new LBY(roomHandle.room, ydoc);
        if (window._sbDebug) console.log('[liveblocks] room entered:', `note-${noteId}`);

        // Liveblocks room exposes connection status — bubble it up so the
        // user can see Live / Reconnecting / Disconnected at a glance.
        let lastRoomStatus = 'connecting';
        let providerSynced = false;
        try {
          statusUnsub = roomHandle.room.subscribe('status', (s) => {
            lastRoomStatus = s;
            if (cancelled) return;
            if (s === 'connected')        reportStatus(providerSynced ? 'live' : 'connecting');
            else if (s === 'reconnecting') reportStatus('reconnecting');
            else if (s === 'disconnected') reportStatus('disconnected');
            else                           reportStatus('connecting');
          });
        } catch {}

        // Sync timeout: if we don't hear back within 4s, fall back to a
        // solo editor so the user isn't stuck staring at the static preview
        // forever, and surface a 'failed' status so the Retry button shows.
        // Liveblocks does its own reconnect — if it eventually syncs, the
        // user can hit Retry to reload the page into collab mode.
        syncTimer = setTimeout(() => {
          if (cancelled || providerSynced) return;
          console.warn('[liveblocks] sync timeout (4s) — falling back to solo');
          reportStatus('failed');
          if (editorRef.current) return; // already mounted somehow
          buildEditor(
            [StarterKit.configure({ heading: { levels: [1, 2, 3] } })],
            { content: initialHTML || '<p></p>' }
          );
        }, 4000);

        // Defer Tiptap editor creation until the provider has synced. Until
        // then `yProvider.doc` is undefined and CollaborationCursor crashes.
        const onSync = () => {
          if (cancelled || editorRef.current) return;
          providerSynced = true;
          clearTimeout(syncTimer);
          reportStatus(lastRoomStatus === 'connected' ? 'live' : 'connecting');
          if (window._sbDebug) console.log('[liveblocks] provider synced — mounting editor');
          buildEditor([
            // Both `history` (v2 name) AND `undoRedo` (v3 name) MUST be off
            // when using Yjs — the Y.Doc owns undo/redo.
            StarterKit.configure({ history: false, undoRedo: false, heading: { levels: [1, 2, 3] } }),
            Collab.configure({ document: ydoc }),
            CollabCursor.configure({
              provider: yProvider,
              user: { name: roomHandle.userInfo.name, color: roomHandle.userInfo.color },
              // Custom caret: thin colored vertical line + name label that only
              // appears on hover. Replaces the default block-style highlight.
              render: (user) => {
                const caret = document.createElement('span');
                caret.classList.add('collab-caret');
                caret.setAttribute('style', `border-color: ${user.color}`);
                const label = document.createElement('span');
                label.classList.add('collab-caret-label');
                label.setAttribute('style', `background-color: ${user.color}`);
                label.textContent = user.name || 'anon';
                caret.appendChild(label);
                return caret;
              },
              // Kill the default selection background so we don't paint big
              // colored blocks behind whatever a remote user is highlighting.
              selectionRender: (user) => ({
                nodeName: 'span',
                class: 'collab-caret-selection',
                style: 'background-color: transparent',
              }),
            }),
          ]);

          // Seed the room from the Supabase snapshot ONLY if the Y.Doc is
          // empty (first user to open a freshly-created room). Subsequent
          // joiners receive the synced state from Liveblocks instead of
          // re-seeding (which would clobber other users' changes).
          const ed = editorRef.current;
          if (ed && ed.isEmpty && initialHTML && initialHTML.replace(/<[^>]+>/g, '').trim()) {
            if (window._sbDebug) console.log('[liveblocks] seeding empty Y.Doc with snapshot');
            ed.commands.setContent(initialHTML, false);
          }
        };
        // Some provider builds expose 'sync', others 'synced'. Liveblocks's
        // Yjs provider also sets `synced=true` on the property after sync.
        if (yProvider.synced) onSync();
        else { yProvider.on('sync', onSync); yProvider.on?.('synced', onSync); }

        // Presence subscription — independent of editor lifecycle.
        const broadcast = () => {
          const others = roomHandle.room.getOthers();
          onPresenceChangeRef.current?.(
            others.map((o) => o.info?.user || o.presence?.user || null).filter(Boolean)
          );
        };
        presenceUnsub = roomHandle.room.subscribe('others', broadcast);
        broadcast();
      } else {
        // enterNoteRoom failed — fall back to solo so the user isn't stuck.
        reportStatus('failed');
        buildEditor(
          [StarterKit.configure({ heading: { levels: [1, 2, 3] } })],
          { content: initialHTML || '<p></p>' }
        );
      }
    } else {
      // No collab requested OR collab libs never loaded — silent solo mode.
      onCollabStatusRef.current?.(useCollab ? 'failed' : 'solo');
      buildEditor(
        [StarterKit.configure({ heading: { levels: [1, 2, 3] } })],
        { content: initialHTML || '<p></p>' }
      );
    }

    return () => {
      cancelled = true;
      setProviderReady(false);
      if (syncTimer) { try { clearTimeout(syncTimer); } catch {} }
      try { statusUnsub?.(); } catch {}
      try { editor?.destroy(); } catch {}
      editorRef.current = null;
      try { presenceUnsub?.(); } catch {}
      try { yProvider?.destroy?.(); } catch {}
      try { ydoc?.destroy?.(); } catch {}
      try { roomHandle?.leave?.(); } catch {}
      if (useCollab && window._sbDebug) console.log('[liveblocks] room left:', `note-${noteId}`);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ready, collabReady, noteId, user?.id, isShared]);

  // Surface the load error so the user isn't stuck on a spinner. Placed
  // here (after every hook above) so toggling loadErr doesn't change the
  // hook count between renders.
  if (loadErr) {
    return (
      <div style={{ padding: '24px', border: '1px solid var(--brick)', background: 'var(--brick-tint)', borderRadius: 'var(--r-md)', color: 'var(--brick)', fontSize: 13 }}>
        <div style={{ fontWeight: 600, marginBottom: 6 }}>Couldn't load the editor</div>
        <div style={{ fontFamily: 'var(--mono)', fontSize: 11.5, marginBottom: 10, wordBreak: 'break-word' }}>{loadErr}</div>
        <div style={{ color: 'var(--ink-soft)' }}>Reload the page. If this keeps happening, the CDN may be having trouble — try again in a minute.</div>
      </div>
    );
  }

  if (!ready) {
    return (
      <div className="note-editor" style={{ borderRadius: 'var(--r-md)' }}>
        <span className="pulse" style={{ fontSize: 13, color: 'var(--ink-mute)' }}>Loading editor…</span>
      </div>
    );
  }

  const ed = editorRef.current;
  const isActive = (name, attrs) => !!ed?.isActive(name, attrs);
  const cmd = (fn) => () => { fn(ed?.chain().focus()).run(); };

  // Until the editor is mounted (solo: ~1 frame; collab: until Liveblocks
  // syncs) render the cached body_html as a read-only preview so the user
  // sees content instantly. The host div sits hidden underneath, so the ref
  // is live for buildEditor to attach to as soon as it's ready.
  const showPreview = !providerReady;

  return (
    <div>
      {showPreview ? (
        // Shared notes are writable by collaborators via PostgREST, so
        // body_html cannot be trusted just because it usually comes from
        // TipTap. Sanitize on every render via DOMPurify. If the lib failed
        // to load, fail closed — don't render body_html at all (a regex
        // escape isn't a sanitizer).
        window.DOMPurify ? (
          <div
            className="note-editor"
            style={{ borderRadius: 'var(--r-md)', minHeight: 80, cursor: useCollab ? 'wait' : 'auto' }}
            dangerouslySetInnerHTML={{
              __html: window.DOMPurify.sanitize(initialHTML || '<p></p>', { USE_PROFILES: { html: true } }),
            }}
          />
        ) : (
          <div
            className="note-editor"
            style={{ borderRadius: 'var(--r-md)', minHeight: 80, color: 'var(--ink-mute)', fontStyle: 'italic' }}
          >
            Note preview unavailable (sanitizer failed to load). Reload the page to try again.
          </div>
        )
      ) : (
        <div className="note-toolbar">
          <button type="button" className={"tb-btn bold" + (isActive('bold') ? ' active' : '')}
            title="Bold (⌘B)" onClick={cmd((c) => c.toggleBold())}>B</button>
          <button type="button" className={"tb-btn italic" + (isActive('italic') ? ' active' : '')}
            title="Italic (⌘I)" onClick={cmd((c) => c.toggleItalic())}>I</button>
          <span className="tb-sep" />
          <button type="button" className={"tb-btn" + (isActive('heading', { level: 1 }) ? ' active' : '')}
            title="Heading 1" onClick={cmd((c) => c.toggleHeading({ level: 1 }))}>H1</button>
          <button type="button" className={"tb-btn" + (isActive('heading', { level: 2 }) ? ' active' : '')}
            title="Heading 2" onClick={cmd((c) => c.toggleHeading({ level: 2 }))}>H2</button>
          <button type="button" className={"tb-btn" + (isActive('heading', { level: 3 }) ? ' active' : '')}
            title="Heading 3" onClick={cmd((c) => c.toggleHeading({ level: 3 }))}>H3</button>
          <span className="tb-sep" />
          <button type="button" className={"tb-btn" + (isActive('bulletList') ? ' active' : '')}
            title="Bullet list" onClick={cmd((c) => c.toggleBulletList())}>•</button>
          <button type="button" className={"tb-btn code" + (isActive('codeBlock') ? ' active' : '')}
            title="Code block" onClick={cmd((c) => c.toggleCodeBlock())}>{'</>'}</button>

          {view && (
            <>
              <span className="tb-sep" />
              <NoteTextStylePopover view={view} />
            </>
          )}
        </div>
      )}
      <div
        ref={hostRef}
        className="note-editor"
        style={showPreview ? { display: 'none' } : null}
        onMouseDown={(e) => {
          // Clicks in the editor's padding (not on existing content / toolbar)
          // should focus the editor at end so the user doesn't have to aim at
          // the first line. ProseMirror handles clicks on its own content
          // natively — this only fires when the click target is the wrapper
          // div itself (i.e. empty padding around the prose).
          if (e.target !== e.currentTarget) return;
          e.preventDefault();
          editorRef.current?.commands.focus('end');
        }}
      />
    </div>
  );
}

// Inline popover anchored to the editor toolbar — exposes font size + text
// color. Persists via the same `view` prefs hook as the page-level layout
// toggles.
function NoteTextStylePopover({ view }) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);

  React.useEffect(() => {
    if (!open) return;
    const onClick = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
    const onKey   = (e) => { if (e.key === 'Escape') setOpen(false); };
    document.addEventListener('mousedown', onClick);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onClick);
      document.removeEventListener('keydown', onKey);
    };
  }, [open]);

  return (
    <div className="tb-pop-anchor" ref={ref}>
      <button type="button"
        className={"tb-btn aa" + (open ? ' active' : '')}
        title="Text style"
        onClick={() => setOpen((o) => !o)}>
        <span style={{ fontFamily: 'var(--serif)', fontStyle: 'italic', fontWeight: 500 }}>Aa</span>
        <span className="tb-pop-caret">▾</span>
      </button>
      {open && (
        <div className="tb-pop" role="menu">
          <div className="view-pop-row">
            <span className="view-pop-label">Font size · {view.fontSizeLabel}</span>
            <div className="view-pop-sizes">
              {FONT_SIZES.map((f) => (
                <button key={f.id} type="button"
                  className={"size-btn" + (view.fontSizeId === f.id ? ' on' : '')}
                  onClick={() => view.setFontSizeId(f.id)}
                  title={`${f.label} · ${f.px}px`}>
                  <span className="size-glyph" style={{ fontSize: Math.max(11, f.px - 4) + 'px' }}>A</span>
                </button>
              ))}
            </div>
          </div>

          <div className="view-pop-divider" />

          <div className="view-pop-row">
            <span className="view-pop-label">Text color · {view.textColorLabel}</span>
            <div className="view-pop-colors">
              {TEXT_COLORS.map((c) => (
                <button key={c.id} type="button"
                  className={"color-swatch" + (view.textColorId === c.id ? ' on' : '') + (c.id === 'default' ? ' default' : '')}
                  onClick={() => view.setTextColorId(c.id)}
                  title={c.label}
                  style={c.value ? { background: c.value } : undefined}>
                  {c.id === 'default' && <span className="default-glyph">A</span>}
                </button>
              ))}
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

// ─── Exports ───────────────────────────────────────────────────────────────

Object.assign(window, {
  NoteFolderSelect,
  NoteScreen,
  GenerateInstructionsDialog,
  CollabStatusPill,
  PresenceStrip,
  NoteAIPanel,
  ShareNoteDialog,
  NoteTOC,
  NoteEditor,
  NoteTextStylePopover,
});

})();
