// studybuddy — Notion-clean. Wired to Supabase + Cloudflare Worker.
//
// Globals from previously-loaded modules:
//   sb, useAuth, signIn, signUp, signOut, getProfile,
//     updateDisplayName, uploadAvatar, removeAvatar             (auth.js)
//   parseFile, fileKindFromExt                                  (parsers.js)
//   loadGuides, findGuide, findGuideSync, addGuide, deleteGuide,
//     useGuides, newGuideId, stageGeneration, popStagedGeneration,
//     peekStagedGeneration                                      (state.js)
//   loadSubjects, getSubjectSync, subjectsLoaded, addSubject,
//     updateSubject, deleteSubject, useSubjects                 (subjects.js)
//   loadEvents, addEvent, addEventsBulk, deleteEvent, useEvents,
//     todayISO, daysBetween, upcomingEvents, parseImport,
//     importFromURL                                             (events.js)
//   loadNotes, findNote, findNoteSync, addNote, updateNote,
//     deleteNote, useNotes                                       (notes.js)
//   loadUnits, unitsForSubject, addUnit, updateUnit, deleteUnit,
//     useUnits                                                   (units.js)
//   API_BASE, generate, generateStudyGuide, STUDY_GUIDE_SCHEMA  (api.js)

// ─── Tiny hash router ──────────────────────────────────────────────────────

function parseHash() {
  const h = window.location.hash;
  return h.startsWith('#') ? (h.slice(1) || '/') : '/';
}
function navigate(p) {
  if (window.location.hash !== '#' + p) window.location.hash = p;
}
function useRoute() {
  const [path, setPath] = React.useState(parseHash());
  React.useEffect(() => {
    const on = () => setPath(parseHash());
    window.addEventListener('hashchange', on);
    if (!window.location.hash) navigate('/');
    return () => window.removeEventListener('hashchange', on);
  }, []);
  return path;
}
function match(pat, path) {
  const pp = pat.split('/').filter(Boolean);
  const ap = path.split('/').filter(Boolean);
  if (pp.length !== ap.length) return null;
  const params = {};
  for (let i = 0; i < pp.length; i++) {
    if (pp[i].startsWith(':')) params[pp[i].slice(1)] = decodeURIComponent(ap[i]);
    else if (pp[i] !== ap[i]) return null;
  }
  return params;
}

// ─── Helpers ───────────────────────────────────────────────────────────────

function relativeTime(iso) {
  if (!iso) return '';
  const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
  if (sec < 60)    return 'just now';
  if (sec < 3600)  return Math.floor(sec / 60) + 'm ago';
  if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
  return Math.floor(sec / 86400) + 'd ago';
}
function prettyDateLong(iso) {
  return new Date(iso + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
}
function formatChars(n) {
  if (n < 1000) return n + ' chars';
  if (n < 1000000) return (n / 1000).toFixed(1) + 'k chars';
  return (n / 1000000).toFixed(2) + 'M chars';
}
function formatBytes(b) {
  if (b < 1024) return b + ' B';
  if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
  return (b / 1024 / 1024).toFixed(1) + ' MB';
}

// Lazy-load jsPDF (only when the user actually clicks Download PDF). Tries
// multiple CDNs since some host paths return 404 for newer versions.
let _jspdfPromise = null;
function _loadJsPdf() {
  if (window.jspdf?.jsPDF) return Promise.resolve(window.jspdf.jsPDF);
  if (_jspdfPromise) return _jspdfPromise;
  const urls = [
    'https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js',
    'https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js',
    'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js',
  ];
  const tryOne = (url) => new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = url;
    s.onload = () => {
      if (window.jspdf?.jsPDF) resolve(window.jspdf.jsPDF);
      else reject(new Error(`${url} loaded but jspdf.jsPDF missing`));
    };
    s.onerror = () => reject(new Error(`Failed to fetch ${url}`));
    document.head.appendChild(s);
  });
  _jspdfPromise = (async () => {
    let lastErr;
    for (const u of urls) {
      try { return await tryOne(u); }
      catch (e) { lastErr = e; console.warn('[jsPDF]', e.message); }
    }
    throw lastErr || new Error('All CDNs failed');
  })();
  return _jspdfPromise;
}

// Build and trigger a PDF download for a study guide. Programmatic text
// layout (no canvas rasterization) so the resulting PDF is small and
// text-selectable.
async function downloadGuidePdf({ title, subjectName, subjectHint, bigIdea, sections = [], flashcards = [], quiz = [] }) {
  let JsPDF;
  try { JsPDF = await _loadJsPdf(); }
  catch (e) { alert('Could not load PDF library: ' + e.message); return; }

  const doc = new JsPDF({ unit: 'pt', format: 'letter' });
  const PAGE_W = 612;
  const PAGE_H = 792;
  const M = 54;                          // 0.75in margin
  const CONTENT_W = PAGE_W - 2 * M;
  let y = M;

  const ensureSpace = (h) => {
    if (y + h > PAGE_H - M) { doc.addPage(); y = M; }
  };
  const setFont = (size, weight) => {
    doc.setFontSize(size);
    doc.setFont('helvetica', weight === 'bold' ? 'bold' : (weight === 'italic' ? 'italic' : 'normal'));
  };
  // Lay out a block of text at the current cursor, wrapping to CONTENT_W.
  // Advances y. Adds page breaks as needed. trailing = extra space after.
  const writeBlock = (text, { size = 11, weight = 'normal', indent = 0, trailing = 4, color = [0, 0, 0] } = {}) => {
    if (!text && text !== 0) return;
    setFont(size, weight);
    doc.setTextColor(color[0], color[1], color[2]);
    const lines = doc.splitTextToSize(String(text), CONTENT_W - indent);
    const lineHeight = size * 1.35;
    for (const line of lines) {
      ensureSpace(lineHeight);
      doc.text(line, M + indent, y + size * 0.85);
      y += lineHeight;
    }
    y += trailing;
  };

  // — Header —
  if (subjectName || subjectHint) {
    writeBlock(`${subjectName || ''}${subjectHint ? ' · ' + subjectHint : ''}`.toUpperCase(), {
      size: 9, color: [110, 110, 110], trailing: 6,
    });
  }
  writeBlock(title || 'Untitled guide', { size: 22, weight: 'bold', trailing: 14 });

  // — Big idea —
  if (bigIdea) {
    writeBlock('SUMMARY', { size: 9, weight: 'bold', color: [110, 110, 110], trailing: 2 });
    writeBlock(bigIdea, { size: 11, weight: 'italic', trailing: 16 });
  }

  // — Sections —
  for (let si = 0; si < sections.length; si++) {
    const s = sections[si];
    const num = String(s.number || si + 1).padStart(2, '0');
    ensureSpace(40);
    writeBlock(`${num}. ${s.title || '(untitled section)'}`, {
      size: 15, weight: 'bold', trailing: 8,
    });
    if (s.body) {
      const paras = String(s.body).split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
      for (const p of paras) writeBlock(p, { size: 11, trailing: 8 });
    }
    if ((s.terms || []).length > 0) {
      writeBlock('Key terms', { size: 12, weight: 'bold', trailing: 4 });
      for (const t of s.terms) {
        writeBlock(t.term || '', { size: 11, weight: 'bold', trailing: 1 });
        writeBlock(t.def || '', { size: 11, indent: 14, trailing: 6 });
      }
    }
    y += 6;
  }

  // — Flashcards —
  if (flashcards.length > 0) {
    ensureSpace(40);
    writeBlock(`Flashcards · ${flashcards.length}`, { size: 15, weight: 'bold', trailing: 8 });
    flashcards.forEach((card) => {
      writeBlock(`Q. ${card.front || ''}`, { size: 11, weight: 'bold', trailing: 1 });
      writeBlock(`A. ${card.back || ''}`,  { size: 11, trailing: 8 });
    });
    y += 4;
  }

  // — Quiz —
  if (quiz.length > 0) {
    ensureSpace(40);
    writeBlock(`Quiz · ${quiz.length}`, { size: 15, weight: 'bold', trailing: 8 });
    quiz.forEach((q, i) => {
      writeBlock(`${i + 1}. ${q.question || ''}`, { size: 11, weight: 'bold', trailing: 2 });
      (q.options || []).forEach((opt, j) => {
        const correct = j === q.correctIndex;
        const marker = String.fromCharCode(65 + j);
        writeBlock(`${marker}. ${opt || ''}${correct ? '   ✓' : ''}`, {
          size: 11, weight: correct ? 'bold' : 'normal', indent: 14, trailing: 1,
        });
      });
      if (q.hint) {
        writeBlock(`Hint: ${q.hint}`, { size: 10, weight: 'italic', indent: 14, color: [110, 110, 110], trailing: 8 });
      } else {
        y += 6;
      }
    });
  }

  const safe = (title || 'study-guide').replace(/[^\w\s\-]/g, '').trim().replace(/\s+/g, '-') || 'study-guide';
  doc.save(`${safe}.pdf`);
}

// ─── Sidebar ───────────────────────────────────────────────────────────────

// Notes folder tree. Renders custom folders first, then auto subject folders,
// then an "Unsorted" bucket. Folders are collapsible (default open). Custom
// folders show inline rename/delete buttons.
function NotesFolderTree({ notes, subjects, activeNoteId, isNotesIndex }) {
  const { folders, assignments } = useNoteFolders();
  const [expanded, setExpanded] = React.useState({});
  const [adding, setAdding] = React.useState(false);
  const [newName, setNewName] = React.useState('');

  const toggle = (id) => setExpanded((e) => ({ ...e, [id]: !e[id] }));
  const isOpen = (id) => expanded[id] !== false; // default open

  const inCustom = (n) => !!assignments[n.id];
  const customNotes = (fid) => notes.filter((n) => assignments[n.id] === fid);
  const subjectNotes = (sid) => notes.filter((n) => !inCustom(n) && n.subject_id === sid);
  const unsortedNotes = notes.filter((n) => !inCustom(n) && !n.subject_id);
  const activeSubjects = subjects.filter((s) => subjectNotes(s.id).length > 0);

  const submitNew = () => {
    const trimmed = newName.trim();
    if (!trimmed) { setAdding(false); setNewName(''); return; }
    try { addNoteFolder(trimmed); setNewName(''); setAdding(false); }
    catch (e) { alert(e.message); }
  };

  return (
    <>
      {folders.map((f) => (
        <FolderGroup
          key={f.id}
          type="custom"
          folder={f}
          notes={customNotes(f.id)}
          isOpen={isOpen(f.id)}
          onToggle={() => toggle(f.id)}
          activeNoteId={activeNoteId}
        />
      ))}

      {activeSubjects.map((s) => {
        const id = `subj-${s.id}`;
        return (
          <FolderGroup
            key={id}
            type="subject"
            subject={s}
            notes={subjectNotes(s.id)}
            isOpen={isOpen(id)}
            onToggle={() => toggle(id)}
            activeNoteId={activeNoteId}
          />
        );
      })}

      {unsortedNotes.length > 0 && (
        <FolderGroup
          type="unsorted"
          notes={unsortedNotes}
          isOpen={isOpen('_unsorted')}
          onToggle={() => toggle('_unsorted')}
          activeNoteId={activeNoteId}
        />
      )}

      {adding ? (
        <div style={{ display: 'flex', gap: 4, padding: '4px 14px' }}>
          <input
            autoFocus
            value={newName}
            onChange={(e) => setNewName(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === 'Enter') submitNew();
              else if (e.key === 'Escape') { setAdding(false); setNewName(''); }
            }}
            onBlur={submitNew}
            placeholder="Folder name"
            style={{ flex: 1, fontSize: 12, padding: '3px 6px', border: '1px solid var(--hairline-bold)', borderRadius: 4, background: 'var(--page)', outline: 'none' }}
          />
        </div>
      ) : (
        <div className="nav-item" onClick={() => setAdding(true)}
          style={{ color: 'var(--ink-mute)', fontSize: 12 }}>
          <span className="glyph" style={{ opacity: 0.6 }}>＋</span>
          <span className="label">New folder</span>
        </div>
      )}
    </>
  );
}

// Move a note to a folder target. Custom = localStorage assignment; subject /
// unsorted = update the note row + clear any custom-folder override.
async function moveNoteToTarget(noteId, target) {
  if (!noteId) return;
  if (target.type === 'custom') {
    assignNoteToFolder(noteId, target.folderId);
    return;
  }
  // Subject folder or Unsorted: clear any custom-folder override and persist
  // the new subject_id (null for unsorted).
  assignNoteToFolder(noteId, null);
  try {
    await updateNote(noteId, { subject_id: target.type === 'subject' ? target.subjectId : null });
  } catch (e) { alert('Move failed: ' + e.message); }
}

// Create a new note inside a folder target and navigate to it.
async function addNoteToTarget(target) {
  try {
    const note = await addNote({
      subjectId: target.type === 'subject' ? target.subjectId : null,
      title: '',
    });
    if (target.type === 'custom') assignNoteToFolder(note.id, target.folderId);
    navigate(`/note/${note.id}`);
  } catch (e) { alert('Create failed: ' + e.message); }
}

function FolderGroup({ type, folder, subject, notes, isOpen, onToggle, activeNoteId }) {
  const label = type === 'custom' ? folder.name : (type === 'subject' ? subject.name : 'Unsorted');
  const glyph = type === 'custom' ? '🗂' : (type === 'subject' ? subject.emoji : '◔');
  const [dropHover, setDropHover] = React.useState(false);

  const target = type === 'custom'
    ? { type: 'custom', folderId: folder.id }
    : (type === 'subject'
        ? { type: 'subject', subjectId: subject.id }
        : { type: 'unsorted' });

  const onRename = (e) => {
    e.stopPropagation();
    if (type !== 'custom') return;
    const next = window.prompt('Rename folder', folder.name);
    if (next == null) return;
    const trimmed = next.trim();
    if (!trimmed || trimmed === folder.name) return;
    try { renameNoteFolder(folder.id, trimmed); }
    catch (err) { alert(err.message); }
  };

  const onDelete = (e) => {
    e.stopPropagation();
    if (type !== 'custom') return;
    if (!confirm(`Delete folder "${folder.name}"? Notes inside will move to their subject folder or Unsorted.`)) return;
    try { deleteNoteFolder(folder.id); }
    catch (err) { alert(err.message); }
  };

  const onAddNote = (e) => {
    e.stopPropagation();
    addNoteToTarget(target);
  };

  const onDragOver = (e) => {
    if (e.dataTransfer.types.includes('text/note-id')) {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move';
      if (!dropHover) setDropHover(true);
    }
  };
  const onDragLeave = () => { if (dropHover) setDropHover(false); };
  const onDrop = (e) => {
    e.preventDefault();
    setDropHover(false);
    const noteId = e.dataTransfer.getData('text/note-id');
    if (noteId) moveNoteToTarget(noteId, target);
  };

  const dropStyle = dropHover
    ? { background: 'var(--accent-tint, rgba(37, 64, 217, 0.08))', outline: '1px dashed var(--accent)', outlineOffset: -1 }
    : null;

  return (
    <div onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop} style={dropStyle}>
      <div className="nav-folder-row" onClick={onToggle}
        style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 12px', cursor: 'pointer', color: 'var(--ink)', fontSize: 13.5, fontWeight: 500 }}>
        <span style={{ width: 10, fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--ink-mute)' }}>{isOpen ? '▾' : '▸'}</span>
        <span style={{ fontSize: 14 }}>{glyph}</span>
        <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
        <span style={{ fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--ink-whisper)' }}>{notes.length}</span>
        <button className="icon-btn" title="New note in this folder" onClick={onAddNote}
          style={{ width: 18, height: 18, fontSize: 12 }}>＋</button>
        {type === 'custom' && (
          <>
            <button className="icon-btn" title="Rename" onClick={onRename} style={{ width: 18, height: 18, fontSize: 10 }}>✎</button>
            <button className="icon-btn" title="Delete folder" onClick={onDelete} style={{ width: 18, height: 18, fontSize: 12 }}>×</button>
          </>
        )}
      </div>
      {isOpen && notes.map((n) => (
        <div key={n.id} className={"nav-item" + (n.id === activeNoteId ? ' active' : '')}
          draggable={true}
          onDragStart={(e) => {
            e.dataTransfer.setData('text/note-id', n.id);
            e.dataTransfer.effectAllowed = 'move';
          }}
          onClick={() => navigate(`/note/${n.id}`)}
          style={{ paddingLeft: 28, color: 'var(--ink-soft)' }}>
          <span className="glyph" style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--ink-whisper)' }}>¶</span>
          <span className="label">{n.title || 'Untitled note'}</span>
        </div>
      ))}
    </div>
  );
}

// User-resizable sidebar width, persisted in localStorage. Module-level state
// + listener set so multiple consumers (Page sets the .app grid columns,
// Sidebar mounts the resize handle) stay in sync without prop-drilling.
const SIDEBAR_WIDTH_KEY = 'studybuddy.sidebar.width';
const SIDEBAR_MIN_W = 200;
const SIDEBAR_MAX_W = 460;
const SIDEBAR_DEFAULT_W = 280;
let _sidebarWidth = (() => {
  try {
    const v = parseInt(localStorage.getItem(SIDEBAR_WIDTH_KEY) || '', 10);
    return (isFinite(v) && v >= SIDEBAR_MIN_W && v <= SIDEBAR_MAX_W) ? v : SIDEBAR_DEFAULT_W;
  } catch { return SIDEBAR_DEFAULT_W; }
})();
const _sidebarListeners = new Set();
function _setSidebarWidth(w) {
  _sidebarWidth = Math.max(SIDEBAR_MIN_W, Math.min(SIDEBAR_MAX_W, Math.round(w)));
  try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(_sidebarWidth)); } catch {}
  for (const fn of _sidebarListeners) fn();
}
function useSidebarWidth() {
  const [, force] = React.useReducer((x) => x + 1, 0);
  React.useEffect(() => {
    _sidebarListeners.add(force);
    return () => _sidebarListeners.delete(force);
  }, []);
  return _sidebarWidth;
}

function SidebarResizer() {
  const [dragging, setDragging] = React.useState(false);
  const onDown = (e) => {
    e.preventDefault();
    const startX = e.clientX;
    const startW = _sidebarWidth;
    setDragging(true);
    const onMove = (ev) => _setSidebarWidth(startW + (ev.clientX - startX));
    const onUp = () => {
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
      document.body.style.cursor = '';
      document.body.style.userSelect = '';
      setDragging(false);
    };
    document.addEventListener('mousemove', onMove);
    document.addEventListener('mouseup', onUp);
    document.body.style.cursor = 'col-resize';
    document.body.style.userSelect = 'none';
  };
  // Double-click resets to default width — quick escape hatch if a user drags
  // it too narrow or too wide.
  const onDouble = () => _setSidebarWidth(SIDEBAR_DEFAULT_W);
  return (
    <div
      className={"sidebar-resizer" + (dragging ? ' dragging' : '')}
      onMouseDown={onDown}
      onDoubleClick={onDouble}
      title="Drag to resize · Double-click to reset"
    />
  );
}

function Sidebar({ path }) {
  const auth = useAuth();
  const subjects = useSubjects();
  const userGuides = useGuides();
  const events = useEvents();
  const notes = useNotes();
  // Surface my email/id to shares.js helpers (cheap globals — used to filter
  // "shared with me" rows from the same shares cache).
  if (typeof window !== 'undefined') {
    window._lastAuthEmail  = auth.user?.email || null;
    window._lastAuthUserId = auth.user?.id || null;
  }
  const sharedNoteIds = useSharedResourceIds('notes');
  const sharedNotes = sharedNoteIds
    .map((id) => notes.find((n) => n.id === id))
    .filter(Boolean);
  const [showAddSubject, setShowAddSubject] = React.useState(false);
  const [showLogoutConfirm, setShowLogoutConfirm] = React.useState(false);

  const isHome = path === '/';
  const isLibrary = path === '/library';
  const isCalendar = path === '/calendar';
  const isNotesIndex = path === '/notes';
  const subjMatch = path.match(/^\/subject\/([^/]+)/);
  const activeSubj = subjMatch ? subjMatch[1] : null;
  const noteMatch = path.match(/^\/note\/([^/]+)/);
  const activeNote = noteMatch ? noteMatch[1] : null;

  const guideCountBy = {};
  for (const g of userGuides) guideCountBy[g.subjectId] = (guideCountBy[g.subjectId] || 0) + 1;

  const profile = getProfile(auth.user);
  const userEmail = profile.email;
  const userLabel = profile.displayName || userEmail || 'Signed in';
  const initials = (profile.displayName || userEmail || 'SB').slice(0, 2).toUpperCase();

  const handleNewGuide = () => {
    window.openNewGuide?.();
  };

  return (
    <aside className="sidebar">
      <div className="sidebar-head">
        <div className="brand" onClick={() => navigate('/')}>
          <span className="brand-mark">Tung.scholar</span>
          <img src="logo.png" alt="" className="brand-logo" />
        </div>
        <button className="icon-btn" title="Settings" onClick={() => navigate('/settings')}>⚙</button>
      </div>

      <div className="search-box" onClick={() => window.openSearch?.()} role="button" tabIndex={0}
        onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); window.openSearch?.(); } }}>
        <span style={{ fontFamily: 'var(--mono)', fontSize: 11 }}>⌕</span>
        <span>Search</span>
        <span className="kbd">⌘K</span>
      </div>

      <div className="nav-section" style={{ padding: '10px 10px 4px' }}>
        <button className="btn primary" onClick={handleNewGuide}
             style={{ width: '100%', justifyContent: 'center', gap: 8 }}>
          <span>＋ New guide</span>
        </button>
      </div>

      <div className="nav-section">
        <div className={"nav-item" + (isHome ? ' active' : '')} onClick={() => navigate('/')}>
          <span className="glyph">◆</span>
          <span className="label">Dashboard</span>
        </div>
        <div className={"nav-item" + (isLibrary ? ' active' : '')} onClick={() => navigate('/library')}>
          <span className="glyph">⊟</span>
          <span className="label">All guides</span>
          {userGuides.length > 0 && <span className="badge">{userGuides.length}</span>}
        </div>
        <div className={"nav-item" + (isCalendar ? ' active' : '')} onClick={() => navigate('/calendar')}>
          <span className="glyph">▦</span>
          <span className="label">Calendar</span>
          {events.length > 0 && <span className="badge">{events.length}</span>}
        </div>
      </div>

      <div className="nav-section">
        <div className="nav-section-label">
          <span>Subjects</span>
          <button className="icon-btn" style={{ width: 18, height: 18, fontSize: 13 }} title="Add subject"
            onClick={() => setShowAddSubject(true)}>＋</button>
        </div>
        {subjects.length === 0 ? (
          <div style={{ padding: '4px 14px', fontSize: 12, color: 'var(--ink-mute)', lineHeight: 1.5 }}>
            No subjects yet.{' '}
            <a href="#" onClick={(e) => { e.preventDefault(); setShowAddSubject(true); }}
               style={{ color: 'var(--accent)', fontWeight: 500 }}>Add one</a> to start.
          </div>
        ) : (
          <div>
            {subjects.map((s) => {
              const isActive = activeSubj === s.id;
              const count = guideCountBy[s.id] || 0;
              return (
                <div key={s.id} className={"nav-item" + (isActive ? ' active' : '')}
                  onClick={() => navigate(`/subject/${s.id}`)} title={s.name}>
                  <span className="glyph">{s.emoji}</span>
                  <span className="label">{s.name}</span>
                  {count > 0 && <span className="badge">{count}</span>}
                </div>
              );
            })}
          </div>
        )}
      </div>

      <div className="nav-section">
        <div className="nav-section-label notes-label">
          <span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 8 }}>
            <span>Notes</span>
            {notes.length > 0 && (
              <span style={{ fontFamily: 'var(--mono)', fontSize: 10, fontWeight: 500, color: 'var(--ink-whisper)' }}>
                {notes.length}
              </span>
            )}
          </span>
          <button className="icon-btn" style={{ width: 18, height: 18, fontSize: 13 }} title="New note"
            onClick={() => window.openNewNote?.()}>＋</button>
        </div>
        <NotesFolderTree
          notes={notes}
          subjects={subjects}
          activeNoteId={activeNote}
          isNotesIndex={isNotesIndex}
        />
      </div>

      {sharedNotes.length > 0 && (
        <div className="nav-section">
          <div className="nav-section-label">
            <span>Shared with me</span>
            <span style={{ fontFamily: 'var(--mono)', fontSize: 10.5, color: 'var(--ink-whisper)' }}>{sharedNotes.length}</span>
          </div>
          {sharedNotes.slice(0, 6).map((n) => {
            const isActive = activeNote === n.id;
            return (
              <div key={n.id} className={"nav-item" + (isActive ? ' active' : '')} onClick={() => navigate(`/note/${n.id}`)}>
                <span className="glyph" style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--ink-whisper)' }}>↦</span>
                <span className="label">{n.title || 'Untitled note'}</span>
              </div>
            );
          })}
        </div>
      )}

      <div className="sidebar-spacer" />

      <div className="sidebar-foot">
        <div className="user-card" onClick={() => navigate('/settings')}>
          <div className="avatar">
            {profile.avatarUrl ? <img src={profile.avatarUrl} alt="" /> : initials}
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontSize: 12.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{userLabel}</div>
            <div style={{ fontSize: 11, color: 'var(--ink-mute)' }}>Personal · free plan</div>
          </div>
          <button className="icon-btn" title="Sign out" onClick={(e) => { e.stopPropagation(); setShowLogoutConfirm(true); }}>⏻</button>
        </div>
      </div>

      {showAddSubject && <SubjectDialog onClose={() => setShowAddSubject(false)} />}
      {showLogoutConfirm && (
        <ConfirmDialog
          title="Sign out?"
          message={`You'll be signed out of ${userLabel || 'this account'}. Your data stays on the server — sign back in any time.`}
          confirmLabel="Sign out"
          tone="danger"
          onConfirm={async () => { await signOut(); setShowLogoutConfirm(false); }}
          onCancel={() => setShowLogoutConfirm(false)}
        />
      )}

      <SidebarResizer />
    </aside>
  );
}

// ─── Page chrome ───────────────────────────────────────────────────────────

function Topbar({ crumbs, actions, leadingActions }) {
  return (
    <div className="topbar">
      <div className="bar-left">
        {leadingActions && <div className="bar-leading">{leadingActions}</div>}
        <div className="crumbs">
          {(crumbs || []).map((c, i) => {
            const isLast = i === crumbs.length - 1;
            const label = typeof c === 'object' ? c.label : c;
            const to    = typeof c === 'object' ? c.to    : null;
            return (
              <React.Fragment key={i}>
                {i > 0 && <span className="sep">/</span>}
                <span
                  className={"crumb" + (isLast ? ' last' : '')}
                  onClick={() => to && navigate(to)}
                >{label}</span>
              </React.Fragment>
            );
          })}
        </div>
      </div>
      <div className="bar-actions">{actions}</div>
    </div>
  );
}

function Page({ children, crumbs, actions, leadingActions, hideChrome, hideSidebar }) {
  if (hideChrome) {
    return <div className="app no-chrome"><div className="app-main">{children}</div></div>;
  }
  const path = useRoute();
  const sidebarWidth = useSidebarWidth();
  const gridStyle = !hideSidebar ? { gridTemplateColumns: `${sidebarWidth}px 1fr` } : null;
  return (
    <div className={"app" + (hideSidebar ? ' no-sidebar' : '')} style={gridStyle}>
      {!hideSidebar && <Sidebar path={path} />}
      <div className="app-main">
        <Topbar crumbs={crumbs} actions={actions} leadingActions={leadingActions} />
        {children}
      </div>
    </div>
  );
}

// ─── Splash + Login ────────────────────────────────────────────────────────

function Splash() {
  return (
    <div style={{
      minHeight: '100vh', display: 'grid', placeItems: 'center',
      background: 'var(--page)', color: 'var(--ink-mute)', fontSize: 13,
    }}>
      <span className="pulse">studybuddy · loading…</span>
    </div>
  );
}

function LoginScreen() {
  const [mode, setMode] = React.useState('login');
  const [email, setEmail] = React.useState('');
  const [password, setPassword] = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const [info, setInfo] = React.useState(null);

  const submit = async (e) => {
    e.preventDefault();
    setError(null);
    setInfo(null);
    if (!email || !password) { setError('Email and password are required.'); return; }
    if (password.length < 6) { setError('Password must be at least 6 characters.'); return; }
    setBusy(true);
    try {
      const fn = mode === 'signup' ? signUp : signIn;
      const { data, error: err } = await fn(email, password);
      if (err) {
        setError(err.message);
      } else if (mode === 'signup' && !data.session) {
        setInfo('Account created. Check your email for a confirmation link, then sign in.');
        setMode('login');
      }
      // On success, the auth state listener swaps the screen automatically.
    } catch (err) {
      setError(err.message || String(err));
    } finally {
      setBusy(false);
    }
  };

  return (
    <Page hideChrome>
      <div className="login-shell">
        <div className="login-left">
          <div>
            <div style={{ fontFamily: 'var(--serif)', fontStyle: 'italic', fontSize: 16, color: 'var(--ink-strong)' }}>
              <span style={{ color: 'var(--accent)' }}>Tung</span>.scholar
            </div>
            <div className="eyebrow" style={{ marginTop: 4 }}>§ a sharp tool for self-studying</div>
          </div>
          <div className="login-display">
            study with <span style={{ color: 'var(--accent)' }}>Tung</span><br />
            get 100s
          </div>
          <div style={{ display: 'flex', gap: 18, fontSize: 12, color: 'var(--ink-mute)' }}>
            <div>
              <div className="eyebrow">Step 01</div>
              <div style={{ marginTop: 2 }}>Drop your PDFs, slides, or notes.</div>
            </div>
            <div>
              <div className="eyebrow">Step 02</div>
              <div style={{ marginTop: 2 }}>A model reads, distills, and structures.</div>
            </div>
            <div>
              <div className="eyebrow">Step 03</div>
              <div style={{ marginTop: 2 }}>Read, study cards, run a quiz.</div>
            </div>
          </div>
        </div>

        <div className="login-right">
          <div className="eyebrow" style={{ marginBottom: 14 }}>{mode === 'login' ? 'Welcome back' : 'New here'}</div>
          <h1 style={{ fontFamily: 'var(--serif)', fontWeight: 500, fontSize: 32, letterSpacing: '-0.02em', margin: '0 0 6px', color: 'var(--ink-strong)' }}>
            {mode === 'login' ? 'Sign in.' : 'Create your account.'}
          </h1>
          <div style={{ fontSize: 13.5, color: 'var(--ink-soft)', marginBottom: 28 }}>
            {mode === 'login' ? 'Pick up where you left off.' : 'Personal use, no card required.'}
          </div>

          <form onSubmit={submit}>
            <label className="field">
              <span>Email</span>
              <input className="input" type="email" required placeholder="you@example.com"
                value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" />
            </label>
            <label className="field">
              <span>Password</span>
              <input className="input" type="password" required minLength={6} placeholder="6+ characters"
                value={password} onChange={(e) => setPassword(e.target.value)}
                autoComplete={mode === 'signup' ? 'new-password' : 'current-password'} />
            </label>

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

            <button className="btn primary" type="submit" disabled={busy}
              style={{ width: '100%', height: 38, justifyContent: 'center', marginTop: 8, opacity: busy ? 0.7 : 1, cursor: busy ? 'wait' : 'pointer' }}>
              {busy ? 'Working…' : (mode === 'login' ? 'Sign in →' : 'Create account →')}
            </button>
          </form>

          <div style={{ marginTop: 18, fontSize: 13, color: 'var(--ink-mute)', textAlign: 'center' }}>
            {mode === 'login' ? "No account? " : 'Already have an account? '}
            <a href="#" onClick={(e) => { e.preventDefault(); setError(null); setInfo(null); setMode(mode === 'login' ? 'signup' : 'login'); }} style={{ color: 'var(--ink-strong)', fontWeight: 500, textDecoration: 'underline' }}>
              {mode === 'login' ? 'Sign up' : 'Sign in'}
            </a>
          </div>
        </div>
      </div>
    </Page>
  );
}

// ─── Dashboard ─────────────────────────────────────────────────────────────

// Random scatter of user-supplied background images sitting behind the
// dashboard. Files live in `icons/Dashboard Backgroudn icons/` (folder name
// preserved as-is, including the typo, so URLs match the file system). To
// add or remove images: drop new files into that folder and update the
// BG_FILES list below. Positions/rotations seed once per mount via useMemo.
const BG_DIR = 'icons/Dashboard%20Backgroudn%20icons/';
const BG_FILES = [
  '054d5e2942c112155bb22f97124fc86b.jpg',
  '06610fcdf4cd0626a411d0900b63afd2.jpg',
  '0a1557d0777783851cc0c3b870a84cb4.jpg',
  '1ef94ad5b95b1be45c43a86c03fa3f24.jpg',
  '5f5c06952a832481e3048795fd9891cc.jpg',
  '83754a455cfbfcfffa77f9b0b3f02fd3.jpg',
  '9791ebc40d4af60080061884bc597db3.jpg',
  'a8d4b5613eebf5ac6f8807eb4eeb5a80.jpg',
  'd6cea13a9b65d33451d8f0f944d296a3.jpg',
];

function DashboardDoodles() {
  const items = React.useMemo(() => {
    // Grid-based placement: divides the dashboard area into COLS×ROWS cells
    // and drops one icon into each, jittered within an inner margin so it
    // doesn't kiss the cell edges. This guarantees a minimum spacing between
    // any two icons (set by the cell size) without needing collision retries,
    // and gives even coverage across the whole dashboard.
    const COLS = 5;
    const ROWS = 5;
    const CELL_MARGIN = 0.18; // 0..0.5; lower = icons can drift closer to cell edges
    const COUNT = COLS * ROWS;

    // Cycle through shuffled copies of BG_FILES so every image appears once
    // before any duplicates, fresh each mount.
    const cycle = [];
    while (cycle.length < COUNT) {
      cycle.push(...BG_FILES.slice().sort(() => Math.random() - 0.5));
    }

    const out = [];
    const cellW = 100 / COLS;
    const cellH = 100 / ROWS;
    for (let i = 0; i < COUNT; i++) {
      const c = i % COLS;
      const r = Math.floor(i / COLS);
      const left = c * cellW + cellW * (CELL_MARGIN + Math.random() * (1 - 2 * CELL_MARGIN));
      const top  = r * cellH + cellH * (CELL_MARGIN + Math.random() * (1 - 2 * CELL_MARGIN));
      out.push({
        file:    cycle[i],
        src:     BG_DIR + cycle[i],
        top:     top + '%',
        left:    left + '%',
        rotate:  Math.floor(Math.random() * 60) - 30,
        size:    140 + Math.floor(Math.random() * 24), // 140–164px (tight, similar)
        opacity: 0.36 + Math.random() * 0.18,         // 0.36–0.54 (similar tones)
      });
    }
    return out;
  }, []);
  return (
    <div className="dashboard-doodles" style={{
      position: 'absolute', inset: 0, overflow: 'hidden',
      pointerEvents: 'none', zIndex: 0,
    }}>
      {items.map((d, i) => (
        <img
          key={i}
          src={d.src}
          alt=""
          loading="eager"
          draggable="false"
          style={{
            position: 'absolute', top: d.top, left: d.left,
            width: d.size, height: 'auto',
            transform: `translate(-50%, -50%) rotate(${d.rotate}deg)`,
            opacity: d.opacity,
            // `darken` drops out white / near-white backgrounds completely
            // (min(base, overlay) — white is 1, so it always loses) while
            // keeping the icons' dark linework visible. Cleaner background
            // removal than `multiply`, which subtly tints the page behind
            // dark strokes.
            mixBlendMode: 'darken',
            userSelect: 'none',
          }}
        />
      ))}
    </div>
  );
}

// Default dashboard layout — 12-col grid, rowHeight=60. Edit here to change
// out-of-the-box positions/sizes; saved layouts in localStorage win for users
// who've already dragged anything.
const DASHBOARD_DEFAULT_LAYOUT = [
  { i: 'kpi-subjects',  x: 0,  y: 0, w: 3, h: 1, minW: 2, minH: 1 },
  { i: 'kpi-guides',    x: 3,  y: 0, w: 3, h: 1, minW: 2, minH: 1 },
  { i: 'kpi-cards',     x: 6,  y: 0, w: 3, h: 1, minW: 2, minH: 1 },
  { i: 'kpi-week',      x: 9,  y: 0, w: 3, h: 1, minW: 2, minH: 1 },
  { i: 'calendar',      x: 0,  y: 1, w: 8, h: 6, minW: 4, minH: 4 },
  { i: 'upcoming',      x: 0,  y: 7, w: 8, h: 4, minW: 4, minH: 3 },
  { i: 'upnext',        x: 8,  y: 1, w: 4, h: 3, minW: 3, minH: 2 },
  { i: 'quick-newguide',x: 8,  y: 4, w: 2, h: 2, minW: 2, minH: 2 },
  { i: 'quick-upload',  x: 10, y: 4, w: 2, h: 2, minW: 2, minH: 2 },
  { i: 'quick-library', x: 8,  y: 6, w: 2, h: 2, minW: 2, minH: 2 },
  { i: 'quick-calendar',x: 10, y: 6, w: 2, h: 2, minW: 2, minH: 2 },
];

function GridBox({ children, noPad }) {
  return (
    <>
      <div className="grid-drag-handle" title="Drag to move" />
      <div className={"grid-box-content" + (noPad ? ' no-pad' : '')}>{children}</div>
    </>
  );
}

function Dashboard() {
  const auth = useAuth();
  const subjects = useSubjects();
  const userGuides = useGuides();
  const events = useEvents();
  const today = todayISO();
  const upcoming = upcomingEvents(events, 60);
  const next = upcoming[0];
  const totalCards = userGuides.reduce((n, g) => n + ((g.content?.flashcards || []).length), 0);
  const inSeven = upcoming.filter((e) => daysBetween(today, e.date) <= 7);

  const [cursor, setCursor] = React.useState(() => {
    const d = new Date();
    return { y: d.getFullYear(), m: d.getMonth() };
  });

  // Delay the background doodles until the dashboard's own fade-in animation
  // has had time to land — keeps the foreground content as the visual entry
  // point and lets the icons drift in afterwards.
  const [showDoodles, setShowDoodles] = React.useState(false);
  React.useEffect(() => {
    const t = setTimeout(() => setShowDoodles(true), 600);
    return () => clearTimeout(t);
  }, []);

  const handleNewGuide = () => window.openNewGuide?.();

  const profile = getProfile(auth.user);
  const userEmail = profile.email;
  const userName = profile.displayName
    ? profile.displayName.split(' ')[0]
    : (userEmail
        ? userEmail.split('@')[0].charAt(0).toUpperCase() + userEmail.split('@')[0].slice(1)
        : 'there');
  const [greeting] = React.useState(() => pickGreeting());

  // Per-user layout, persisted in localStorage so the arrangement survives
  // reloads. Anonymous (signed-out) users share an 'anon' bucket.
  const userId = auth.user?.id || 'anon';
  const STORAGE_KEY = `studybuddy:dashboard:layout:${userId}`;
  const [layout, setLayout] = React.useState(() => {
    try {
      const saved = localStorage.getItem(STORAGE_KEY);
      if (saved) {
        const parsed = JSON.parse(saved);
        if (Array.isArray(parsed) && parsed.length) return parsed;
      }
    } catch {}
    return DASHBOARD_DEFAULT_LAYOUT;
  });

  // If the auth user changes (login / logout), swap to that user's saved layout.
  React.useEffect(() => {
    try {
      const saved = localStorage.getItem(STORAGE_KEY);
      if (saved) {
        const parsed = JSON.parse(saved);
        if (Array.isArray(parsed) && parsed.length) { setLayout(parsed); return; }
      }
    } catch {}
    setLayout(DASHBOARD_DEFAULT_LAYOUT);
  }, [STORAGE_KEY]);

  const onLayoutChange = (newLayout) => {
    setLayout(newLayout);
    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(newLayout)); } catch {}
  };

  const resetLayout = () => {
    if (!confirm('Reset dashboard layout to default?')) return;
    setLayout(DASHBOARD_DEFAULT_LAYOUT);
    try { localStorage.removeItem(STORAGE_KEY); } catch {}
  };

  // RGL is loaded via CDN; wait for it to attach to window before rendering
  // the grid. Babel-in-browser scripts execute after deferred scripts, so the
  // global usually exists by first render — poll briefly as a safety net.
  const [rglReady, setRglReady] = React.useState(!!window.ReactGridLayout);
  React.useEffect(() => {
    if (window.ReactGridLayout) { setRglReady(true); return; }
    const interval = setInterval(() => {
      if (window.ReactGridLayout) { setRglReady(true); clearInterval(interval); }
    }, 100);
    return () => clearInterval(interval);
  }, []);

  const Grid = React.useMemo(() => {
    if (!window.ReactGridLayout) return null;
    return window.ReactGridLayout.WidthProvider(window.ReactGridLayout);
  }, [rglReady]);

  return (
    <Page
      crumbs={['Dashboard']}
      actions={
        <>
          <button className="btn ghost sm" onClick={resetLayout} title="Reset dashboard layout">↺ Reset layout</button>
          <button className="btn sm" onClick={() => navigate('/calendar')}>Open calendar</button>
          <button className="btn accent sm" onClick={handleNewGuide}>＋ New guide</button>
        </>
      }
    >
      <div className="scroll">
        <div style={{ position: 'relative' }}>
          {showDoodles && <DashboardDoodles />}
          <div className="page-pad fade-in" style={{ position: 'relative', zIndex: 1 }}>
            <div className="eyebrow">{prettyDateLong(today)}</div>
            <h1 className="page-title" style={{ marginTop: 6 }}>
              {greeting}, <em>{userName}.</em>
            </h1>
            <div className="page-subtitle">
              <span>You have <strong style={{ color: 'var(--ink-strong)' }}>{inSeven.length}</strong> {inSeven.length === 1 ? 'thing' : 'things'} on the calendar this week, and <strong style={{ color: 'var(--ink-strong)' }}>{userGuides.length}</strong> guides in your library.</span>
            </div>

            {!Grid ? (
              <div className="grid-loading">Loading layout…</div>
            ) : (
              <Grid
                className="dashboard-grid"
                layout={layout}
                cols={12}
                rowHeight={60}
                margin={[16, 16]}
                containerPadding={[0, 16]}
                onLayoutChange={onLayoutChange}
                draggableHandle=".grid-drag-handle"
                compactType={null}
                preventCollision={false}
                isBounded={false}
                style={{ marginTop: 24 }}
              >
                <div key="kpi-subjects"><GridBox><Kpi label="Subjects" num={subjects.length} sub="tracked" /></GridBox></div>
                <div key="kpi-guides"><GridBox><Kpi label="Guides" num={userGuides.length} sub="generated" /></GridBox></div>
                <div key="kpi-cards"><GridBox><Kpi label="Cards" num={totalCards} sub="across decks" /></GridBox></div>
                <div key="kpi-week"><GridBox><Kpi label="This week" num={inSeven.length} sub="events" tone={inSeven.length > 0 ? 'accent' : null} /></GridBox></div>

                <div key="calendar"><GridBox noPad><MiniCalendar cursor={cursor} setCursor={setCursor} events={events} /></GridBox></div>

                <div key="upcoming">
                  <GridBox>
                    <div className="upcoming-mini" style={{ border: 'none', padding: 0 }}>
                      <div className="upcoming-mini-head">
                        <h3>Upcoming</h3>
                        <a onClick={(e) => { e.preventDefault(); navigate('/calendar'); }}>Open calendar →</a>
                      </div>
                      {upcoming.length === 0 ? (
                        <div className="upcoming-mini-empty">Nothing scheduled.</div>
                      ) : (
                        upcoming.slice(0, 5).map((ev) => {
                          const subj = subjects.find((s) => s.id === ev.subject_id);
                          const days = daysBetween(today, ev.date);
                          const dayLabel = days === 0 ? 'Today' : days === 1 ? 'Tomorrow' : `in ${days}d`;
                          return (
                            <div key={ev.id} className="upcoming-mini-row" onClick={() => navigate('/calendar')}>
                              <span className={"dot " + ev.kind} />
                              <span style={{ fontSize: 13 }}>{subj?.emoji || '·'}</span>
                              <span className="title">{ev.title}</span>
                              <span className="when">{dayLabel}</span>
                            </div>
                          );
                        })
                      )}
                    </div>
                  </GridBox>
                </div>

                <div key="upnext">
                  <GridBox>
                    {next ? (
                      <div className="upnext" style={{ border: 'none', padding: 0, background: 'transparent' }}>
                        <div className="label">Up next</div>
                        <div className="title">{next.title}</div>
                        <div className="meta">
                          {(() => {
                            const subj = subjects.find((s) => s.id === next.subject_id);
                            return subj ? `${subj.emoji} ${subj.name} · ${next.kind}` : next.kind;
                          })()}
                        </div>
                        <div className="days">
                          {daysBetween(today, next.date) === 0 ? 'Today' : daysBetween(today, next.date) + 'd'}
                          <span>{daysBetween(today, next.date) === 0 ? '' : 'away'}</span>
                        </div>
                      </div>
                    ) : (
                      <div style={{ display: 'grid', placeItems: 'center', height: '100%', color: 'var(--ink-mute)', fontSize: 13 }}>
                        <div style={{ textAlign: 'center' }}>
                          <div className="eyebrow" style={{ marginBottom: 6 }}>Up next</div>
                          <div>Nothing scheduled.</div>
                        </div>
                      </div>
                    )}
                  </GridBox>
                </div>

                <div key="quick-newguide"><GridBox noPad><QuickTile primary glyph="＋" label="New guide" sub="upload + generate" onClick={handleNewGuide} /></GridBox></div>
                <div key="quick-upload"><GridBox noPad><QuickTile glyph="↑" label="Upload files" sub="add to a subject" onClick={() => window.openUploadFiles?.()} /></GridBox></div>
                <div key="quick-library"><GridBox noPad><QuickTile glyph="◆" label="Library" sub={`${userGuides.length} guide${userGuides.length === 1 ? '' : 's'}`} onClick={() => navigate('/library')} /></GridBox></div>
                <div key="quick-calendar"><GridBox noPad><QuickTile glyph="▦" label="Calendar" sub={`${events.length} event${events.length === 1 ? '' : 's'}`} onClick={() => navigate('/calendar')} /></GridBox></div>
              </Grid>
            )}
          </div>
        </div>
      </div>
    </Page>
  );
}

function Kpi({ label, num, sub, tone }) {
  return (
    <div className="kpi">
      <div className="label">{label}</div>
      <div className={"num" + (tone === 'accent' ? ' accent' : '')}>{num}</div>
      <div className="sub">{sub}</div>
    </div>
  );
}

function QuickTile({ primary, glyph, label, sub, onClick }) {
  return (
    <button className={"quick-tile" + (primary ? ' primary' : '')} onClick={onClick}>
      <div className="qt-glyph">{glyph}</div>
      <div>
        <div className="qt-label">{label}</div>
        <div className="qt-sub">{sub}</div>
      </div>
    </button>
  );
}

function MiniCalendar({ cursor, setCursor, events }) {
  const today = todayISO();
  const monthName = new Date(cursor.y, cursor.m, 1).toLocaleString(undefined, { month: 'long', year: 'numeric' });
  const cells = monthCells(cursor.y, cursor.m);
  const eventsByDate = {};
  for (const e of events) (eventsByDate[e.date] = eventsByDate[e.date] || []).push(e);

  return (
    <div className="mini-cal">
      <div className="mini-cal-head">
        <div className="month">{monthName}</div>
        <div className="ctrls">
          <button className="btn ghost sm" onClick={() => setCursor(monthOffset(cursor, -1))}>←</button>
          <button className="btn ghost sm" onClick={() => { const d = new Date(); setCursor({ y: d.getFullYear(), m: d.getMonth() }); }}>Today</button>
          <button className="btn ghost sm" onClick={() => setCursor(monthOffset(cursor, 1))}>→</button>
          <button className="btn sm" onClick={() => navigate('/calendar')}>Open →</button>
        </div>
      </div>
      <div className="mini-cal-grid">
        {['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map((d) => <div key={d} className="mini-dow">{d}</div>)}
        {cells.map((cell) => {
          const dayEvents = eventsByDate[cell.date] || [];
          const isToday = cell.date === today;
          const inMonth = cell.month === cursor.m;
          return (
            <div key={cell.date} className={"mini-cell" + (inMonth ? '' : ' dim') + (isToday ? ' today' : '')} onClick={() => navigate('/calendar')}>
              <span className="day-num">{cell.day}</span>
              {dayEvents.slice(0, 2).map((e) => (
                <div key={e.id} className={"mini-event " + e.kind} title={e.title}>{e.title}</div>
              ))}
              {dayEvents.length > 2 && (
                <div style={{ fontSize: 9.5, color: 'var(--ink-mute)', fontFamily: 'var(--mono)', paddingLeft: 4 }}>
                  +{dayEvents.length - 2}
                </div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ─── Library ───────────────────────────────────────────────────────────────

function LibraryScreen() {
  const userGuides = useGuides();
  const subjects = useSubjects();
  const [filter, setFilter] = React.useState('all');
  const filtered = filter === 'all' ? userGuides : userGuides.filter((g) => g.subjectId === filter);

  const handleNewGuide = () => window.openNewGuide?.();

  return (
    <Page
      crumbs={['All guides']}
      actions={<button className="btn accent sm" onClick={handleNewGuide}>＋ New guide</button>}
    >
      <div className="scroll">
        <div className="page-pad fade-in">
          <div className="eyebrow">§ Library</div>
          <h1 className="page-title">All guides</h1>
          <div className="page-subtitle">
            <span><strong style={{ color: 'var(--ink-strong)' }}>{filtered.length}</strong> {filtered.length === 1 ? 'guide' : 'guides'}</span>
            <span style={{ color: 'var(--ink-whisper)' }}>·</span>
            <span>Click a row to open. Filter by subject to narrow.</span>
          </div>

          <div style={{ display: 'flex', gap: 6, marginBottom: 18, flexWrap: 'wrap' }}>
            <FilterChip active={filter === 'all'} onClick={() => setFilter('all')}>All</FilterChip>
            {subjects.map((s) => (
              <FilterChip key={s.id} active={filter === s.id} onClick={() => setFilter(s.id)}>
                <span style={{ marginRight: 4 }}>{s.emoji}</span>{s.name}
              </FilterChip>
            ))}
          </div>

          {userGuides.length === 0 ? (
            <div style={{ padding: '36px 18px', textAlign: 'center', border: '1px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)' }}>
              <div style={{ fontFamily: 'var(--serif)', fontSize: 22, color: 'var(--ink-strong)', fontWeight: 500 }}>No guides yet</div>
              <div style={{ fontSize: 13, color: 'var(--ink-mute)', margin: '8px 0 16px' }}>
                {subjects.length === 0
                  ? 'Add a subject from the sidebar, then upload files to generate your first guide.'
                  : 'Pick a subject and upload files to generate your first guide.'}
              </div>
              {subjects.length > 0 && (
                <button className="btn accent sm" onClick={handleNewGuide}>＋ Generate a guide</button>
              )}
            </div>
          ) : (
            <div className="lib-table">
              <div className="lib-row head">
                <span></span>
                <span>Subject</span>
                <span>Title</span>
                <span style={{ textAlign: 'right' }}>Sources</span>
                <span style={{ textAlign: 'right' }}>Cards</span>
                <span style={{ textAlign: 'right' }}>Created</span>
              </div>
              {filtered.map((g, i) => {
                const subj = subjects.find((s) => s.id === g.subjectId);
                return (
                  <div key={g.id} className="lib-row" onClick={() => navigate(`/subject/${g.subjectId}/guide/${g.id}`)}>
                    <span className="num" style={{ color: 'var(--ink-whisper)' }}>{String(i + 1).padStart(2, '0')}</span>
                    <span style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--ink-mute)' }}>
                      <span>{subj?.emoji || '📄'}</span>{subj?.name || '(unknown)'}
                    </span>
                    <span className="lib-title">{g.title}</span>
                    <span className="num" style={{ textAlign: 'right' }}>{g.sources || 0}</span>
                    <span className="num" style={{ textAlign: 'right' }}>{(g.content?.flashcards || []).length}</span>
                    <span className="num" style={{ textAlign: 'right' }}>{relativeTime(g.generatedAt)}</span>
                  </div>
                );
              })}
              {filtered.length === 0 && (
                <div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--ink-mute)', fontSize: 13 }}>
                  No guides yet for this subject.
                </div>
              )}
            </div>
          )}
        </div>
      </div>
    </Page>
  );
}

function FilterChip({ active, onClick, children }) {
  return (
    <button onClick={onClick} className={"filter-chip" + (active ? ' active' : '')}>{children}</button>
  );
}

// ─── Unit helpers (used by SubjectDetailScreen) ────────────────────────────

function UnitPicker({ value, units, onChange }) {
  return (
    <select
      value={value || ''}
      onClick={(e) => e.stopPropagation()}
      onChange={(e) => onChange(e.target.value || null)}
      style={{
        fontSize: 11,
        fontFamily: 'var(--mono)',
        padding: '2px 6px',
        border: '1px solid var(--hairline)',
        borderRadius: 4,
        background: 'var(--page)',
        color: 'var(--ink-soft)',
        cursor: 'pointer',
      }}
    >
      <option value="">— No unit —</option>
      {units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
    </select>
  );
}

function UnitHeader({ unit, allowEdit }) {
  const [editing, setEditing] = React.useState(false);
  const [name, setName] = React.useState(unit?.name || '');

  if (!unit) {
    return (
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '14px 0 6px', borderBottom: '1px dashed var(--hairline)', marginBottom: 8 }}>
        <span style={{ fontFamily: 'var(--mono)', fontSize: 10.5, color: 'var(--ink-mute)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Unassigned</span>
      </div>
    );
  }

  const cancel = () => { setEditing(false); setName(unit.name); };
  const save = async () => {
    const trimmed = name.trim();
    if (!trimmed) return cancel();
    if (trimmed === unit.name) return cancel();
    try { await updateUnit(unit.id, { name: trimmed }); setEditing(false); }
    catch (e) { alert('Rename failed: ' + e.message); }
  };
  const remove = async () => {
    if (!confirm(`Delete unit "${unit.name}"? Items in this unit will become unassigned.`)) return;
    try { await deleteUnit(unit.id); }
    catch (e) { alert('Delete failed: ' + e.message); }
  };

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '14px 0 6px', borderBottom: '1px solid var(--hairline)', marginBottom: 8 }}>
      {editing ? (
        <>
          <input
            autoFocus
            className="input"
            value={name}
            onChange={(e) => setName(e.target.value)}
            onKeyDown={(e) => { if (e.key === 'Enter') save(); else if (e.key === 'Escape') cancel(); }}
            style={{ flex: 1 }}
          />
          <button className="btn sm" onClick={save}>Save</button>
          <button className="btn ghost sm" onClick={cancel}>Cancel</button>
        </>
      ) : (
        <>
          <h3 style={{ fontFamily: 'var(--serif)', fontSize: 16, fontWeight: 500, color: 'var(--ink-strong)', flex: 1, margin: 0 }}>{unit.name}</h3>
          {allowEdit && (
            <>
              <button className="btn ghost sm" onClick={() => setEditing(true)}>Rename</button>
              <button className="icon-btn" title="Delete unit" onClick={remove}>×</button>
            </>
          )}
        </>
      )}
    </div>
  );
}

function UnitGuideGroup({ unit, guides, subj, subjUnits }) {
  return (
    <div style={{ marginBottom: 18 }}>
      <UnitHeader unit={unit} allowEdit={true} />
      {guides.length === 0 ? (
        <div style={{ padding: '8px 0', fontSize: 12, color: 'var(--ink-whisper)', fontStyle: 'italic' }}>No guides in this unit yet.</div>
      ) : (
        <div>
          {guides.map((g) => (
            <div key={g.id} className="row" onClick={() => navigate(`/subject/${subj.id}/guide/${g.id}`)}>
              <span className="row-emoji" style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--ink-whisper)' }}>¶</span>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontWeight: 500, color: 'var(--ink-strong)' }}>{g.title}</div>
                <div style={{ fontSize: 11.5, color: 'var(--ink-mute)' }}>
                  {(g.content?.sections || []).length} sections · {(g.content?.flashcards || []).length} cards · {g.sources || 0} sources
                </div>
              </div>
              <UnitPicker
                value={g.unitId}
                units={subjUnits}
                onChange={async (uid) => {
                  try { await updateGuide(g.id, { unit_id: uid }); }
                  catch (e) { alert('Move failed: ' + e.message); }
                }}
              />
              <span className="row-meta">{relativeTime(g.generatedAt)}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

function UnitFileGroup({ unit, files, subjUnits }) {
  return (
    <div style={{ marginBottom: 18 }}>
      <UnitHeader unit={unit} allowEdit={false} />
      {files.length === 0 ? (
        <div style={{ padding: '8px 0', fontSize: 12, color: 'var(--ink-whisper)', fontStyle: 'italic' }}>No files in this unit yet.</div>
      ) : (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
          {files.map((d) => <DocRow key={d.id} doc={d} subjUnits={subjUnits} />)}
        </div>
      )}
    </div>
  );
}

// ─── Subject detail ────────────────────────────────────────────────────────

function SubjectDetailScreen({ subjectId }) {
  const subjects = useSubjects();
  const userGuides = useGuides();
  const events = useEvents();
  const documents = useDocuments();
  const allNotes = useNotes();
  const allUnits = useUnits();
  const [showEdit, setShowEdit] = React.useState(false);
  const [addingUnit, setAddingUnit] = React.useState(false);
  const [newUnitName, setNewUnitName] = React.useState('');

  const subj = subjects.find((s) => s.id === subjectId);
  if (!subj) {
    const loading = !subjectsLoaded();
    return (
      <Page crumbs={[{ label: 'Dashboard', to: '/' }, loading ? 'Loading…' : 'Not found']}>
        <div style={{ flex: 1, display: 'grid', placeItems: 'center', color: 'var(--ink-mute)', fontSize: 13 }}>
          {loading ? <span className="pulse">Loading subject…</span> : (
            <div style={{ textAlign: 'center' }}>
              <div style={{ fontSize: 14, marginBottom: 12 }}>Subject not found.</div>
              <button className="btn sm" onClick={() => navigate('/')}>Back to dashboard</button>
            </div>
          )}
        </div>
      </Page>
    );
  }

  const subjGuides = userGuides.filter((g) => g.subjectId === subj.id);
  const subjEvents = events.filter((e) => e.subject_id === subj.id && e.date >= todayISO());
  const subjDocs   = documents.filter((d) => d.subject_id === subj.id);
  const subjNotes  = allNotes.filter((n) => n.subject_id === subj.id);
  const subjUnits  = allUnits.filter((u) => u.subject_id === subj.id);
  const totalCards = subjGuides.reduce((n, g) => n + ((g.content?.flashcards || []).length), 0);

  const cancelNewUnit = () => { setAddingUnit(false); setNewUnitName(''); };
  const submitNewUnit = async () => {
    const name = newUnitName.trim();
    if (!name) return cancelNewUnit();
    try { await addUnit({ subjectId: subj.id, name }); cancelNewUnit(); }
    catch (e) { alert('Add unit failed: ' + e.message); }
  };

  return (
    <Page
      crumbs={[{ label: 'Dashboard', to: '/' }, subj.name]}
      actions={
        <>
          <button className="btn ghost sm" onClick={() => setShowEdit(true)}>Edit</button>
          <button className="btn sm" onClick={() => window.openNewNote?.(subj.id)}>＋ Note</button>
        </>
      }
    >
      <div className="scroll">
        <div className="page-pad fade-in">
          <div style={{ display: 'flex', alignItems: 'flex-end', gap: 20, marginBottom: 24 }}>
            <div style={{ fontSize: 56, lineHeight: 1 }}>{subj.emoji}</div>
            <div style={{ flex: 1 }}>
              <div className="eyebrow">§ Subject</div>
              <h1 className="page-title">{subj.name}</h1>
              <div className="page-subtitle" style={{ marginBottom: 0 }}>
                Created {relativeTime(subj.created_at)} · {subjGuides.length} guides · {subjEvents.length} upcoming events
              </div>
            </div>
            <div className="stat-cluster">
              <div className="stat"><div className="lbl">Guides</div><div className="val">{subjGuides.length}</div></div>
              <div className="stat"><div className="lbl">Notes</div><div className="val">{subjNotes.length}</div></div>
              <div className="stat"><div className="lbl">Cards</div><div className="val">{totalCards}</div></div>
              <div className="stat"><div className="lbl">Upcoming</div><div className="val" style={{ color: subjEvents.length > 0 ? 'var(--brick)' : null }}>{subjEvents.length}</div></div>
            </div>
          </div>

          <div className="subject-sections-grid">
            <section className="subject-section">
              <div className="section-row">
                <h2>Upcoming</h2>
                <span className="right">{subjEvents.length} {subjEvents.length === 1 ? 'event' : 'events'}</span>
              </div>
              <div style={{ padding: '0 14px' }}>
                {subjEvents.length === 0 && <div style={{ padding: '12px 0', fontSize: 13, color: 'var(--ink-mute)' }}>Nothing scheduled.</div>}
                {subjEvents.slice(0, 6).map((ev) => {
                  const days = daysBetween(todayISO(), ev.date);
                  return (
                    <div key={ev.id} className="row" onClick={() => navigate('/calendar')}>
                      <span className={"dot " + ev.kind} />
                      <div style={{ flex: 1, minWidth: 0 }}>
                        <div style={{ fontWeight: 500, color: 'var(--ink-strong)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ev.title}</div>
                        <div style={{ fontSize: 11.5, color: 'var(--ink-mute)' }}>{ev.date} · {ev.kind}</div>
                      </div>
                      <span className="row-meta">{days === 0 ? 'Today' : `in ${days}d`}</span>
                    </div>
                  );
                })}
              </div>
            </section>

            <section className="subject-section">
              <div className="section-row">
                <h2>Study guides / Subject map <span className="num" style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--ink-mute)', fontWeight: 500, marginLeft: 6 }}>· {subjGuides.length} {subjGuides.length === 1 ? 'guide' : 'guides'}</span></h2>
                <button
                  className="btn accent sm"
                  onClick={() => window.openNewGuide?.(subj.id)}
                  title="Generate a new study guide for this subject"
                >✦ Generate guide</button>
              </div>
              {addingUnit && (
                <div style={{ display: 'flex', gap: 8, padding: '0 14px 12px' }}>
                  <input
                    autoFocus
                    className="input"
                    value={newUnitName}
                    onChange={(e) => setNewUnitName(e.target.value)}
                    onKeyDown={(e) => { if (e.key === 'Enter') submitNewUnit(); else if (e.key === 'Escape') cancelNewUnit(); }}
                    placeholder="Unit name (e.g., Unit 1: Cell biology)"
                    style={{ flex: 1 }}
                  />
                  <button className="btn sm" onClick={submitNewUnit}>Add</button>
                  <button className="btn ghost sm" onClick={cancelNewUnit}>Cancel</button>
                </div>
              )}
              <div style={{ padding: '0 14px' }}>
                {subjUnits.length === 0 && subjGuides.length === 0 ? (
                  <div style={{ padding: '24px', textAlign: 'center', border: '1px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)' }}>
                    <div className="eyebrow">No units yet</div>
                    <div style={{ fontFamily: 'var(--serif)', fontSize: 18, color: 'var(--ink-strong)', margin: '6px 0 12px', fontWeight: 500 }}>
                      Create units to organize your study guides.
                    </div>
                    <button className="btn accent sm" onClick={() => setAddingUnit(true)}>＋ Add unit</button>
                  </div>
                ) : (
                  <>
                    {[...subjUnits, null].map((unit) => {
                      const guides = subjGuides.filter((g) => (g.unitId || null) === (unit?.id || null));
                      if (!unit && guides.length === 0) return null;
                      return (
                        <UnitGuideGroup key={unit?.id || '_unassigned'} unit={unit} guides={guides} subj={subj} subjUnits={subjUnits} />
                      );
                    })}
                    {!addingUnit && (
                      <div style={{ padding: '8px 0 4px' }}>
                        <button className="btn ghost sm" onClick={() => setAddingUnit(true)}>＋ Add unit</button>
                      </div>
                    )}
                  </>
                )}
              </div>
            </section>

            <section className="subject-section">
              <div className="section-row">
                <h2>Files <span className="num" style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--ink-mute)', fontWeight: 500, marginLeft: 6 }}>· {subjDocs.length}</span></h2>
              </div>
              <div style={{ padding: '0 14px' }}>
                {subjUnits.length === 0 && subjDocs.length === 0 ? (
                  <div style={{ padding: '24px', textAlign: 'center', border: '1px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)' }}>
                    <div style={{ fontSize: 13, color: 'var(--ink-mute)', marginBottom: 10 }}>No files yet for this subject.</div>
                    <button className="btn accent sm" onClick={() => window.openUploadFiles?.(subj.id)}>↑ Add files</button>
                  </div>
                ) : (
                  [...subjUnits, null].map((unit) => {
                    const files = subjDocs.filter((d) => (d.unit_id || null) === (unit?.id || null));
                    if (!unit && files.length === 0) return null;
                    return (
                      <UnitFileGroup key={unit?.id || '_unassigned'} unit={unit} files={files} subjUnits={subjUnits} />
                    );
                  })
                )}
              </div>
            </section>

            <section className="subject-section">
              <div className="section-row">
                <h2>Notes <span className="num" style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--ink-mute)', fontWeight: 500, marginLeft: 6 }}>· {subjNotes.length}</span></h2>
              </div>
              {subjNotes.length === 0 ? (
                <div style={{ padding: '24px', textAlign: 'center', border: '1px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)' }}>
                  <div style={{ fontFamily: 'var(--serif)', fontSize: 17, color: 'var(--ink-strong)', fontWeight: 500, marginBottom: 4 }}>No notes for this subject yet</div>
                  <div style={{ fontSize: 13, color: 'var(--ink-mute)', marginBottom: 12 }}>Write something free-form — autosaves as you type.</div>
                  <button className="btn accent sm" onClick={() => window.openNewNote?.(subj.id)}>＋ New note</button>
                </div>
              ) : (
                <div>
                  {subjNotes.slice(0, 6).map((n) => {
                    const snippet = (n.body_text || '').replace(/\s+/g, ' ').trim().slice(0, 140);
                    return (
                      <div key={n.id} className="note-row" onClick={() => navigate(`/note/${n.id}`)}>
                        <div style={{ minWidth: 0 }}>
                          <div className="note-row-title">{n.title || 'Untitled note'}</div>
                          <div className="note-row-snippet">
                            {snippet || <span style={{ color: 'var(--ink-whisper)' }}>Empty note</span>}
                          </div>
                        </div>
                        <div className="note-row-meta">{relativeTime(n.updated_at)}</div>
                      </div>
                    );
                  })}
                  {subjNotes.length > 6 && (
                    <div style={{ marginTop: 8 }}>
                      <a href="#" onClick={(e) => { e.preventDefault(); navigate('/notes'); }}
                        style={{ fontSize: 13, color: 'var(--ink-soft)' }}>
                        View all {subjNotes.length} notes →
                      </a>
                    </div>
                  )}
                </div>
              )}
            </section>
          </div>
        </div>
      </div>

      {showEdit && <SubjectDialog existing={subj} onClose={(opts) => {
        setShowEdit(false);
        if (opts?.deleted) navigate('/');
      }} />}
    </Page>
  );
}

function DocRow({ doc, subjUnits }) {
  const kindGlyph = doc.kind === 'image' ? 'IMG' : (doc.ext || 'TXT').toUpperCase().slice(0, 4);
  const kindClass = doc.kind === 'image' ? 'img' : (['pdf','docx','doc'].includes(doc.ext) ? (doc.ext === 'pdf' ? 'pdf' : 'doc') : 'txt');
  const snippet = doc.text_content ? doc.text_content.slice(0, 110).replace(/\s+/g, ' ').trim() + (doc.text_content.length > 110 ? '…' : '') : null;
  const sizeLabel = doc.kind === 'image'
    ? (doc.bytes ? formatBytes(doc.bytes) : 'image')
    : (doc.char_count ? formatChars(doc.char_count) : (doc.bytes ? formatBytes(doc.bytes) : ''));

  // Image rows store metadata only (no bytes), so we can't reconstruct a
  // parsed file payload for the model. Disable Generate for images.
  const canGenerate = doc.kind !== 'image' && !!doc.text_content;

  const onGenerate = (e) => {
    e.stopPropagation();
    if (!canGenerate) return;
    const parsed = [{ name: doc.name, kind: 'text', text: doc.text_content }];
    stageGeneration(doc.subject_id, { files: parsed, notes: '', mode: 'full' });
    const newId = newGuideId();
    navigate(`/subject/${doc.subject_id}/guide/${newId}/generate`);
  };

  return (
    <div className="doc-row" onClick={() => {}}
      style={{ display: 'grid', gridTemplateColumns: subjUnits ? '32px 1fr auto auto auto' : '32px 1fr auto auto', gap: 12, alignItems: 'flex-start', padding: '10px 12px', border: '1px solid var(--hairline)', borderRadius: 'var(--r-sm)', background: 'var(--page)', cursor: 'default' }}>
      <div className={"file-glyph " + kindClass} style={{ marginTop: 2 }}>{kindGlyph}</div>
      <div style={{ minWidth: 0 }}>
        <div style={{ fontWeight: 500, color: 'var(--ink-strong)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{doc.name}</div>
        {snippet && <div style={{ fontSize: 12, color: 'var(--ink-soft)', lineHeight: 1.5, marginTop: 4 }}>{snippet}</div>}
        <div style={{ display: 'flex', gap: 8, fontSize: 11, color: 'var(--ink-mute)', fontFamily: 'var(--mono)', textTransform: 'uppercase', letterSpacing: '0.04em', marginTop: 4 }}>
          <span>{sizeLabel}</span>
          <span>·</span>
          <span>{relativeTime(doc.created_at)}</span>
        </div>
      </div>
      {subjUnits && (
        <div style={{ marginTop: 2 }}>
          <UnitPicker
            value={doc.unit_id}
            units={subjUnits}
            onChange={async (uid) => {
              try { await updateDocument(doc.id, { unit_id: uid }); }
              catch (err) { alert('Move failed: ' + err.message); }
            }}
          />
        </div>
      )}
      <button
        className="btn ghost sm"
        title={canGenerate ? 'Generate a study guide from this file' : 'Image files cannot be regenerated (bytes not stored)'}
        disabled={!canGenerate}
        onClick={onGenerate}
        style={{ marginTop: 1 }}
      >
        ✦ Generate
      </button>
      <button className="icon-btn" title="Delete file"
        onClick={async (e) => {
          e.stopPropagation();
          if (!confirm(`Delete "${doc.name}" from this subject's library?`)) return;
          try { await deleteDocument(doc.id); }
          catch (err) { alert('Delete failed: ' + err.message); }
        }}>×</button>
    </div>
  );
}

// ─── Upload ────────────────────────────────────────────────────────────────

function UploadScreen({ subjectId }) {
  const subjects = useSubjects();
  const subj = subjects.find((s) => s.id === subjectId);
  if (!subj) {
    const loading = !subjectsLoaded();
    return (
      <Page crumbs={[{ label: 'Dashboard', to: '/' }, loading ? 'Loading…' : 'Subject not found']}>
        <div style={{ padding: 32 }}>
          {loading ? <div className="pulse" style={{ fontSize: 13, color: 'var(--ink-mute)' }}>Loading subject…</div> : (
            <>
              <div style={{ fontSize: 14 }}>Subject not found.</div>
              <button className="btn sm" style={{ marginTop: 12 }} onClick={() => navigate('/')}>Back to dashboard</button>
            </>
          )}
        </div>
      </Page>
    );
  }
  return <UploadBody subject={subj} />;
}

function UploadBody({ subject }) {
  const back = `/subject/${subject.id}`;
  const [entries, setEntries] = React.useState([]);
  const [dragOver, setDragOver] = React.useState(false);
  const [notes, setNotes] = React.useState('');
  const [mode, setMode] = React.useState('full');
  const inputRef = React.useRef(null);
  const idCounter = React.useRef(0);

  const addFiles = async (filesList) => {
    const files = Array.from(filesList);
    const fresh = files.map((file) => ({
      id: ++idCounter.current,
      file,
      name: file.name,
      kind: fileKindFromExt(file.name.split('.').pop()),
      size: formatBytes(file.size),
      status: 'parsing',
      parsed: null,
      error: null,
    }));
    setEntries((prev) => [...prev, ...fresh]);

    for (const e of fresh) {
      try {
        const parsed = await parseFile(e.file);
        setEntries((prev) => prev.map((x) => x.id === e.id ? { ...x, status: 'done', parsed } : x));
      } catch (err) {
        setEntries((prev) => prev.map((x) => x.id === e.id ? { ...x, status: 'error', error: err.message } : x));
      }
    }
  };

  const onDrop = (ev) => {
    ev.preventDefault();
    setDragOver(false);
    if (ev.dataTransfer?.files?.length) addFiles(ev.dataTransfer.files);
  };

  const remove = (id) => setEntries((prev) => prev.filter((e) => e.id !== id));

  const okFiles = entries.filter((e) => e.status === 'done');
  const parsing = entries.some((e) => e.status === 'parsing');
  const canGenerate = okFiles.length > 0 && !parsing;
  const totalChars = okFiles.reduce((n, e) => n + (e.parsed?.text?.length || 0), 0);

  const startGenerate = async () => {
    const parsed = okFiles.map((e) => e.parsed);
    stageGeneration(subject.id, { files: parsed, notes, mode });

    // Persist parsed files to the subject's document library so the Files
    // section stays comprehensive. Awaited so the row is in the cache before
    // we navigate away — fire-and-forget can silently fail and leave Files
    // empty.
    const docRows = okFiles.map((e) => ({
      name: e.name,
      ext: (e.name.split('.').pop() || '').toLowerCase(),
      kind: e.parsed?.kind === 'image' ? 'image' : 'text',
      text_content: e.parsed?.kind === 'text' ? e.parsed.text : null,
      bytes: e.file?.size ?? null,
    }));
    try { await addDocumentsBulk(docRows, { subjectId: subject.id }); }
    catch (err) { console.warn('document save failed', err); }

    const newId = newGuideId();
    navigate(`/subject/${subject.id}/guide/${newId}/generate`);
  };

  return (
    <Page
      crumbs={[
        { label: 'Dashboard', to: '/' },
        { label: subject.name, to: back },
        'Add files',
      ]}
      actions={<button className="btn ghost sm" onClick={() => navigate(back)}>Cancel</button>}
    >
      <div className="scroll">
        <div className="page-pad fade-in">
          <div className="eyebrow">§ Upload</div>
          <h1 className="page-title">Drop your sources.</h1>
          <div className="page-subtitle">
            PDF, DOCX, TXT, Markdown, or images. Parsed in your browser before being sent to the model.
          </div>

          <div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: 36 }}>
            <div>
              <div
                className="drop"
                onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
                onDragLeave={() => setDragOver(false)}
                onDrop={onDrop}
                onClick={() => inputRef.current?.click()}
              >
                <input ref={inputRef} type="file" multiple
                  accept=".pdf,.docx,.txt,.md,.png,.jpg,.jpeg,.webp,.gif"
                  style={{ display: 'none' }}
                  onChange={(e) => { if (e.target.files?.length) addFiles(e.target.files); e.target.value = ''; }}
                />
                <div className="drop-mark">drop or click</div>
                <div className="drop-cta">{dragOver ? 'Release to add' : 'Drop files here, or click to browse.'}</div>
                <div className="drop-sub">PDF · DOCX · TXT · MD · PNG · JPG. PPTX → export as PDF first.</div>
              </div>

              {entries.length > 0 && (
                <div style={{ marginTop: 24 }}>
                  <div className="section-row">
                    <h2>Sources</h2>
                    <span className="right" style={{ fontFamily: 'var(--mono)', fontSize: 11.5 }}>
                      {okFiles.length} of {entries.length} ready
                    </span>
                  </div>
                  <div>
                    {entries.map((e) => (
                      <UploadRow key={e.id} entry={e} onRemove={() => remove(e.id)} />
                    ))}
                  </div>
                </div>
              )}
            </div>

            <div>
              <div className="section-row">
                <h2>Generate</h2>
              </div>

              <div style={{ marginBottom: 14 }}>
                <div className="eyebrow" style={{ marginBottom: 6 }}>Output</div>
                <div style={{ display: 'flex', gap: 8 }}>
                  <ModeBtn active={mode === 'full'}        onClick={() => setMode('full')}        title="Study guide" sub="sections · cards · quiz" />
                  <ModeBtn active={mode === 'flashcards'} onClick={() => setMode('flashcards')}   title="Flashcards"   sub="every term, no caps" />
                </div>
              </div>

              <label className="field">
                <span>Instructions for the AI (optional)</span>
                <textarea className="input" rows={5} value={notes} onChange={(e) => setNotes(e.target.value)}
                  placeholder="e.g. focus on chapters 3–5; skip the historical background; explain like I'm new to the field." />
              </label>

              <div style={{ borderTop: '1px solid var(--hairline)', paddingTop: 14, marginTop: 14, fontSize: 12.5, color: 'var(--ink-mute)' }}>
                <div style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0' }}>
                  <span>Model</span>
                  <span className="mono" style={{ color: 'var(--ink-strong)' }}>gpt-4o-mini · vision</span>
                </div>
                <div style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0' }}>
                  <span>Sources ready</span>
                  <span className="mono" style={{ color: 'var(--ink-strong)' }}>{okFiles.length}</span>
                </div>
                <div style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0' }}>
                  <span>Total text</span>
                  <span className="mono" style={{ color: 'var(--ink-strong)' }}>{formatChars(totalChars)}</span>
                </div>
              </div>

              <button className="btn accent" disabled={!canGenerate} onClick={startGenerate}
                style={{ width: '100%', height: 40, justifyContent: 'center', marginTop: 16, fontSize: 14, opacity: canGenerate ? 1 : 0.5, cursor: canGenerate ? 'pointer' : 'not-allowed' }}>
                ✦ {mode === 'flashcards' ? 'Generate flashcards' : 'Generate study guide'}
              </button>
              {parsing && <div style={{ fontSize: 11.5, color: 'var(--ink-mute)', textAlign: 'center', marginTop: 6 }}>Parsing files…</div>}
            </div>
          </div>
        </div>
      </div>
    </Page>
  );
}

function UploadRow({ entry, onRemove }) {
  const { name, kind, size, status, error, parsed } = entry;
  const stageText =
    status === 'parsing' ? 'Parsing…' :
    status === 'error'   ? `Failed: ${error}` :
    parsed?.kind === 'image' ? 'Ready · vision input' :
    parsed?.text ? `Ready · ${formatChars(parsed.text.length)}` :
    'Ready';
  const statusClass = status === 'error' ? 'error' : status === 'done' ? 'done' : '';
  return (
    <div className="file-row">
      <div className={"file-glyph " + kind}>{kind.toUpperCase()}</div>
      <div style={{ minWidth: 0 }}>
        <div style={{ fontWeight: 500, color: 'var(--ink-strong)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</div>
        <div style={{ fontSize: 11.5, color: status === 'error' ? 'var(--brick)' : 'var(--ink-mute)' }}>{stageText}</div>
      </div>
      <span className={"file-status " + statusClass}>{status === 'parsing' ? 'parsing' : status}</span>
      <span className="row-meta" style={{ textAlign: 'right' }}>{size}</span>
      <button className="icon-btn" title="Remove" onClick={onRemove}>×</button>
    </div>
  );
}

function ModeBtn({ active, onClick, title, sub }) {
  return (
    <button onClick={onClick} className={"mode-btn" + (active ? ' active' : '')}>
      <div className="t">{title}</div>
      <div className="s">{sub}</div>
    </button>
  );
}

// ─── Generation ────────────────────────────────────────────────────────────

function GenerationScreen({ subjectId, guideId }) {
  const subjects = useSubjects();
  const subj = subjects.find((s) => s.id === subjectId);
  const back = subj ? `/subject/${subj.id}` : '/';

  // phase: 'preflight' | 'sending' | 'waiting' | 'done' | 'error' | 'no-files'
  const [phase, setPhase] = React.useState('preflight');
  const [error, setError] = React.useState(null);
  const [elapsed, setElapsed] = React.useState(0);
  const [staged] = React.useState(() => peekStagedGeneration(subjectId));
  const files = staged?.files || [];
  const notes = staged?.notes || '';
  const mode = staged?.mode || 'full';
  const [savedTitle, setSavedTitle] = React.useState(null);

  React.useEffect(() => {
    let cancelled = false;
    const tick = setInterval(() => setElapsed((e) => e + 1), 1000);

    (async () => {
      if (!files || files.length === 0) {
        setPhase('no-files');
        return;
      }
      popStagedGeneration(subjectId);
      setPhase('sending');
      await new Promise((r) => setTimeout(r, 300));
      if (cancelled) return;
      setPhase('waiting');
      try {
        const content = await generateStudyGuide(files, { subjectName: subj?.name, notes, mode });
        if (cancelled) return;
        const saved = await addGuide({
          subjectId,
          title: content.title || 'Untitled guide',
          sources: files.length,
          content,
        });
        if (cancelled) return;
        setSavedTitle(saved.title);
        setPhase('done');
        const realPath = `/subject/${subjectId}/guide/${saved.id}`;
        setTimeout(() => { if (!cancelled) navigate(realPath); }, 700);
      } catch (err) {
        if (cancelled) return;
        console.error('generation failed', err);
        setError(err.message || String(err));
        setPhase('error');
      }
    })();

    return () => { cancelled = true; clearInterval(tick); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const stages = [
    { label: 'Reading',    sub: 'parse files',          state: phase === 'preflight' ? 'working' : 'done' },
    { label: 'Sending',    sub: 'to gpt-4o-mini',       state: phase === 'preflight' ? 'queued' : (phase === 'sending' ? 'working' : 'done') },
    { label: 'Generating', sub: 'structured guide',     state: ['preflight','sending'].includes(phase) ? 'queued' : (phase === 'waiting' ? 'working' : (phase === 'error' ? 'error' : 'done')) },
    { label: 'Done',       sub: 'saved · ready to read', state: phase === 'done' ? 'done' : (phase === 'error' ? 'error' : 'queued') },
  ];

  return (
    <Page
      crumbs={[
        { label: 'Dashboard', to: '/' },
        { label: subj?.name || 'Subject', to: back },
        phase === 'done' && savedTitle ? savedTitle : 'New guide',
      ]}
      actions={
        <>
          {phase === 'error' && <button className="btn ghost sm" onClick={() => navigate(`${back}/upload`)}>Try again</button>}
          <button className="btn ghost sm" onClick={() => navigate(back)}>
            {(['done','error','no-files'].includes(phase)) ? 'Back' : 'Cancel'}
          </button>
        </>
      }
    >
      <div className="scroll">
        <div className="page-pad fade-in">
          {phase === 'no-files' ? (
            <div style={{ padding: '36px', textAlign: 'center', border: '1px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)' }}>
              <div style={{ fontFamily: 'var(--serif)', fontSize: 22, color: 'var(--ink-strong)', fontWeight: 500 }}>No files staged</div>
              <div style={{ fontSize: 13, color: 'var(--ink-mute)', margin: '8px 0 16px' }}>The page was likely reloaded mid-flow. Re-upload to continue.</div>
              <button className="btn accent sm" onClick={() => navigate(`${back}/upload`)}>↑ Upload files</button>
            </div>
          ) : (
            <>
              <div className="eyebrow">§ Generation</div>
              <h1 className="page-title">
                {phase === 'done' ? <>Done — <em>{savedTitle}.</em></> :
                 phase === 'error' ? 'Generation failed.' :
                 <>Generating from <em>{files.length} source{files.length === 1 ? '' : 's'}</em>.</>}
              </h1>
              <div className="page-subtitle">
                <span>{files.length} source{files.length === 1 ? '' : 's'} · elapsed {elapsed}s · phase <strong style={{ color: 'var(--ink-strong)' }}>{phase}</strong></span>
              </div>

              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 24 }}>
                {stages.map((s) => <StageBox key={s.label} {...s} />)}
              </div>

              {error && (
                <div style={{ background: 'var(--brick-tint)', border: '1px solid var(--brick)', borderRadius: 'var(--r-md)', padding: 14, marginBottom: 18 }}>
                  <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--brick)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.06em' }}>Error</div>
                  <div style={{ fontSize: 12.5, color: 'var(--brick)', fontFamily: 'var(--mono)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{error}</div>
                  <div style={{ fontSize: 11.5, color: 'var(--ink-mute)', marginTop: 8 }}>
                    Common causes: OpenAI key missing/invalid on the Worker, rate limit, or input too large.
                  </div>
                </div>
              )}

              <div style={{ border: '1px solid var(--hairline)', borderRadius: 'var(--r-md)', padding: 14, background: 'var(--page-tint)' }}>
                <div className="eyebrow" style={{ marginBottom: 8 }}>Sources</div>
                {files.length === 0 ? (
                  <div style={{ fontSize: 12.5, color: 'var(--ink-mute)' }}>None</div>
                ) : files.map((f, i) => (
                  <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', fontSize: 12.5 }}>
                    <span className="mono" style={{ color: 'var(--ink-mute)', width: 36, textTransform: 'uppercase', fontSize: 10 }}>{f.kind === 'image' ? 'IMG' : 'TXT'}</span>
                    <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
                    <span className="mono" style={{ fontSize: 10.5, color: 'var(--ink-mute)' }}>
                      {f.kind === 'image' ? 'image' : `${(f.text || '').length} ch`}
                    </span>
                  </div>
                ))}
              </div>
            </>
          )}
        </div>
      </div>
    </Page>
  );
}

function StageBox({ label, sub, state }) {
  const color = state === 'done' ? 'var(--moss)' : state === 'working' ? 'var(--accent)' : state === 'error' ? 'var(--brick)' : 'var(--ink-whisper)';
  const detail = state === 'done' ? 'done' : state === 'working' ? 'in progress…' : state === 'error' ? 'failed' : 'queued';
  return (
    <div style={{ borderLeft: `3px solid ${color}`, paddingLeft: 12, paddingTop: 4, paddingBottom: 4 }}>
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
        <div style={{ fontFamily: 'var(--serif)', fontSize: 16, fontWeight: 500, color: 'var(--ink-strong)' }}>{label}</div>
        <div className="mono" style={{ fontSize: 11, color }}>{state === 'done' ? '✓' : state === 'error' ? '✗' : state === 'working' ? '…' : '—'}</div>
      </div>
      <div style={{ fontSize: 11, color: 'var(--ink-mute)', fontFamily: 'var(--mono)' }}>{sub}</div>
      <div className={state === 'working' ? 'pulse' : ''} style={{ fontSize: 11.5, marginTop: 6, color: state === 'queued' ? 'var(--ink-mute)' : color }}>{detail}</div>
    </div>
  );
}

// ─── Calendar ──────────────────────────────────────────────────────────────

const KINDS = [
  { value: 'exam',       label: 'Exam'       },
  { value: 'assignment', label: 'Assignment' },
  { value: 'study',      label: 'Study'      },
  { value: 'other',      label: 'Other'      },
];

function CalendarScreen() {
  const events = useEvents();
  const subjects = useSubjects();
  const [cursor, setCursor] = React.useState(() => {
    const d = new Date();
    return { y: d.getFullYear(), m: d.getMonth() };
  });
  const [editing, setEditing] = React.useState(null);
  const [importing, setImporting] = React.useState(false);
  const [aiOpen, setAiOpen] = React.useState(false);

  const today = todayISO();
  const monthName = new Date(cursor.y, cursor.m, 1).toLocaleString(undefined, { month: 'long', year: 'numeric' });
  const cells = monthCells(cursor.y, cursor.m);
  const eventsByDate = {};
  for (const e of events) (eventsByDate[e.date] = eventsByDate[e.date] || []).push(e);


  return (
    <Page
      crumbs={['Calendar']}
      actions={
        <>
          <button className="btn ghost sm" onClick={() => setCursor(monthOffset(cursor, -1))}>←</button>
          <button className="btn ghost sm" onClick={() => { const d = new Date(); setCursor({ y: d.getFullYear(), m: d.getMonth() }); }}>Today</button>
          <button className="btn ghost sm" onClick={() => setCursor(monthOffset(cursor, 1))}>→</button>
          <button className="btn sm" onClick={() => navigate('/events')}>⊟ Events</button>
          <button className="btn sm" onClick={() => setAiOpen(true)}>✦ AI edit</button>
          <button className="btn sm" onClick={() => setImporting(true)}>Import</button>
          <button className="btn accent sm" onClick={() => setEditing({ mode: 'create', date: today })}>＋ Event</button>
        </>
      }
    >
      <div className="scroll">
        <div className="page-pad fade-in">
          <div className="eyebrow">§ Calendar</div>
          <h1 className="page-title" style={{ marginTop: 4 }}>{monthName}</h1>
          <div className="page-subtitle">
            <span><strong style={{ color: 'var(--ink-strong)' }}>{events.length}</strong> events tracked.</span>
            <span style={{ color: 'var(--ink-whisper)' }}>·</span>
            <span>Click any day to add. Click an event to view or delete.</span>
          </div>

          <div className="cal-grid">
            {['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map((d) => <div key={d} className="cal-dow">{d}</div>)}
            {cells.map((cell) => {
              const dayEvents = eventsByDate[cell.date] || [];
              const isToday = cell.date === today;
              const inMonth = cell.month === cursor.m;
              return (
                <div key={cell.date}
                  className={"cal-cell" + (inMonth ? '' : ' dim') + (isToday ? ' today' : '')}
                  onClick={() => setEditing({ mode: 'create', date: cell.date })}>
                  <span className="day-num">{cell.day}</span>
                  {dayEvents.slice(0, 3).map((e) => (
                    <div key={e.id} className={"cal-event " + e.kind} title={e.title}
                      onClick={(ev) => { ev.stopPropagation(); setEditing({ mode: 'view', event: e }); }}>
                      {e.title}
                    </div>
                  ))}
                  {dayEvents.length > 3 && (
                    <div style={{ fontSize: 10, color: 'var(--ink-mute)', fontFamily: 'var(--mono)', paddingLeft: 4 }}>
                      +{dayEvents.length - 3}
                    </div>
                  )}
                </div>
              );
            })}
          </div>

          <div style={{ marginTop: 28, display: 'flex', gap: 24, alignItems: 'center' }}>
            <div className="eyebrow">Legend</div>
            <Legend kind="exam" label="Exam" />
            <Legend kind="assignment" label="Assignment" />
            <Legend kind="study" label="Study" />
            <Legend kind="other" label="Other" />
          </div>
        </div>
      </div>

      {editing && <EventDialog editing={editing} subjects={subjects} onClose={() => setEditing(null)} />}
      {importing && <ImportDialog subjects={subjects} onClose={() => setImporting(false)} />}
      {aiOpen && <AIEventEditDialog events={events} subjects={subjects} onClose={() => setAiOpen(false)} />}
    </Page>
  );
}

// ─── Events list (mass edit / delete) ──────────────────────────────────────

function EventsListScreen() {
  const events = useEvents();
  const subjects = useSubjects();
  const [selected, setSelected] = React.useState(() => new Set());
  const [filterKind, setFilterKind] = React.useState('all');
  const [filterSubj, setFilterSubj] = React.useState('all');
  const [editing, setEditing] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [bulkSubjId, setBulkSubjId] = React.useState('');
  const [bulkKind, setBulkKind] = React.useState('');
  const [aiOpen, setAiOpen] = React.useState(false);

  const filtered = events
    .filter((e) => filterKind === 'all' || e.kind === filterKind)
    .filter((e) => filterSubj === 'all' || (filterSubj === 'none' ? !e.subject_id : e.subject_id === filterSubj))
    .slice()
    .sort((a, b) => b.date.localeCompare(a.date));

  const allVisibleSelected = filtered.length > 0 && filtered.every((e) => selected.has(e.id));
  const someSelected = filtered.some((e) => selected.has(e.id));

  const toggleOne = (id) => setSelected((s) => {
    const n = new Set(s);
    if (n.has(id)) n.delete(id); else n.add(id);
    return n;
  });
  const toggleAll = () => setSelected((s) => {
    if (allVisibleSelected) {
      const n = new Set(s);
      filtered.forEach((e) => n.delete(e.id));
      return n;
    } else {
      const n = new Set(s);
      filtered.forEach((e) => n.add(e.id));
      return n;
    }
  });
  const clearSelection = () => setSelected(new Set());

  const selectedIds = Array.from(selected);

  const doDelete = async () => {
    if (selectedIds.length === 0) return;
    if (!confirm(`Delete ${selectedIds.length} event${selectedIds.length === 1 ? '' : 's'}? This can't be undone.`)) return;
    setBusy(true); setErr(null);
    try { await deleteEventsBulk(selectedIds); clearSelection(); }
    catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  };

  const doReassignSubject = async () => {
    if (selectedIds.length === 0) return;
    setBusy(true); setErr(null);
    try {
      await updateEventsBulk(selectedIds, { subject_id: bulkSubjId || null });
      // Keep selection — useful when chaining several bulk actions
    } catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  };

  const doChangeKind = async () => {
    if (selectedIds.length === 0 || !bulkKind) return;
    setBusy(true); setErr(null);
    try { await updateEventsBulk(selectedIds, { kind: bulkKind }); }
    catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  };

  return (
    <Page
      crumbs={[{ label: 'Calendar', to: '/calendar' }, 'All events']}
      actions={
        <>
          <button className="btn sm" onClick={() => navigate('/calendar')}>← Calendar grid</button>
          <button className="btn accent sm" onClick={() => setAiOpen(true)}>✦ AI edit</button>
        </>
      }
    >
      <div className="scroll">
        <div className="page-pad fade-in">
          <div className="eyebrow">§ Events list</div>
          <h1 className="page-title">All events</h1>
          <div className="page-subtitle">
            <span><strong style={{ color: 'var(--ink-strong)' }}>{filtered.length}</strong> {filtered.length === 1 ? 'event' : 'events'}</span>
            <span style={{ color: 'var(--ink-whisper)' }}>·</span>
            <span>Multi-select to edit or delete in bulk. Click a row to view / edit one event.</span>
          </div>

          {/* Filters */}
          <div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
            <FilterChip active={filterKind === 'all'} onClick={() => setFilterKind('all')}>All kinds</FilterChip>
            {KINDS.map((k) => (
              <FilterChip key={k.value} active={filterKind === k.value} onClick={() => setFilterKind(k.value)}>
                <span className={"dot " + k.value} style={{ marginRight: 5 }} />
                {k.label}
              </FilterChip>
            ))}
          </div>
          <div style={{ display: 'flex', gap: 6, marginBottom: 18, flexWrap: 'wrap' }}>
            <FilterChip active={filterSubj === 'all'}  onClick={() => setFilterSubj('all')}>All subjects</FilterChip>
            <FilterChip active={filterSubj === 'none'} onClick={() => setFilterSubj('none')}>Unassigned</FilterChip>
            {subjects.map((s) => (
              <FilterChip key={s.id} active={filterSubj === s.id} onClick={() => setFilterSubj(s.id)}>
                <span style={{ marginRight: 4 }}>{s.emoji}</span>{s.name}
              </FilterChip>
            ))}
          </div>

          {/* Bulk action bar — only when something is selected */}
          {selectedIds.length > 0 && (
            <div style={{
              display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10,
              padding: '10px 14px', marginBottom: 12,
              border: '1px solid var(--ink-strong)', borderRadius: 'var(--r-md)',
              background: 'var(--page-tint)',
            }}>
              <span style={{ fontFamily: 'var(--mono)', fontSize: 11.5, fontWeight: 600, color: 'var(--ink-strong)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
                {selectedIds.length} selected
              </span>

              <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                <span style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>Reassign →</span>
                <select className="input" value={bulkSubjId} onChange={(e) => setBulkSubjId(e.target.value)} style={{ width: 'auto', height: 28, padding: '0 8px', fontSize: 12 }}>
                  <option value="">Unassigned</option>
                  {subjects.map((s) => <option key={s.id} value={s.id}>{s.emoji} {s.name}</option>)}
                </select>
                <button type="button" className="btn sm" onClick={doReassignSubject} disabled={busy}>Apply</button>
              </span>

              <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                <span style={{ fontSize: 11.5, color: 'var(--ink-soft)' }}>Kind →</span>
                <select className="input" value={bulkKind} onChange={(e) => setBulkKind(e.target.value)} style={{ width: 'auto', height: 28, padding: '0 8px', fontSize: 12 }}>
                  <option value="">— pick —</option>
                  {KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
                </select>
                <button type="button" className="btn sm" onClick={doChangeKind} disabled={busy || !bulkKind}>Apply</button>
              </span>

              <div style={{ flex: 1 }} />
              <button type="button" className="btn ghost sm" onClick={clearSelection} disabled={busy}>Clear</button>
              <button type="button" className="btn danger sm" onClick={doDelete} disabled={busy}>{busy ? '…' : `🗑 Delete ${selectedIds.length}`}</button>
            </div>
          )}

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

          {filtered.length === 0 ? (
            <div style={{ padding: '36px 18px', textAlign: 'center', border: '1px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)' }}>
              <div style={{ fontFamily: 'var(--serif)', fontSize: 22, color: 'var(--ink-strong)', fontWeight: 500 }}>
                {events.length === 0 ? 'No events yet' : 'Nothing matches these filters'}
              </div>
              <div style={{ fontSize: 13, color: 'var(--ink-mute)', margin: '8px 0' }}>
                {events.length === 0 ? 'Add events from the calendar grid or import them.' : 'Try changing the filter chips above.'}
              </div>
            </div>
          ) : (
            <div className="lib-table">
              <div className="lib-row head" style={{ gridTemplateColumns: '32px 90px 70px 130px 1fr 90px 24px' }}>
                <span><input type="checkbox" checked={allVisibleSelected} ref={(el) => { if (el) el.indeterminate = someSelected && !allVisibleSelected; }} onChange={toggleAll} /></span>
                <span>Date</span>
                <span>Kind</span>
                <span>Subject</span>
                <span>Title</span>
                <span style={{ textAlign: 'right' }}>Created</span>
                <span></span>
              </div>
              {filtered.map((e) => {
                const subj = subjects.find((s) => s.id === e.subject_id);
                const isSelected = selected.has(e.id);
                return (
                  <div key={e.id} className="lib-row"
                    style={{ gridTemplateColumns: '32px 90px 70px 130px 1fr 90px 24px', background: isSelected ? 'var(--accent-faint)' : null }}
                    onClick={() => setEditing({ mode: 'view', event: e })}>
                    <span onClick={(ev) => ev.stopPropagation()}>
                      <input type="checkbox" checked={isSelected} onChange={() => toggleOne(e.id)} />
                    </span>
                    <span className="num">{e.date}</span>
                    <span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
                      <span className={"dot " + e.kind} />
                      <span style={{ fontSize: 12, color: 'var(--ink-soft)', textTransform: 'capitalize' }}>{e.kind}</span>
                    </span>
                    <span style={{ display: 'flex', alignItems: 'center', gap: 6, color: subj ? 'var(--ink)' : 'var(--ink-mute)', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                      {subj ? <><span>{subj.emoji}</span><span>{subj.name}</span></> : <span style={{ fontStyle: 'italic' }}>—</span>}
                    </span>
                    <span className="lib-title">{e.title}</span>
                    <span className="num" style={{ textAlign: 'right' }}>{relativeTime(e.created_at)}</span>
                    <button
                      type="button"
                      className="row-delete"
                      title="Delete event"
                      onClick={async (ev) => {
                        ev.stopPropagation();
                        // Optimistic, instant delete — no confirm. Selection state cleared too.
                        setSelected((s) => { const n = new Set(s); n.delete(e.id); return n; });
                        try { await deleteEvent(e.id); }
                        catch (err) { setErr(`Delete "${e.title}" failed: ${err.message}`); }
                      }}>×</button>
                  </div>
                );
              })}
            </div>
          )}
        </div>
      </div>

      {editing && <EventDialog editing={editing} subjects={subjects} onClose={() => setEditing(null)} />}
      {aiOpen && <AIEventEditDialog events={events} subjects={subjects} onClose={() => setAiOpen(false)} />}
    </Page>
  );
}

// ─── AI event editor: prompt → preview → apply ─────────────────────────────

const AI_EDIT_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['summary', 'deletes', 'updates', 'creates'],
  properties: {
    summary: { type: 'string' },
    deletes: { type: 'array', items: { type: 'string' } },
    updates: {
      type: 'array',
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['ids', 'subject_id', 'kind', 'date', 'title', 'notes'],
        properties: {
          ids: { type: 'array', items: { type: 'string' } },
          subject_id: { type: ['string', 'null'] },
          kind: { type: ['string', 'null'] },
          date: { type: ['string', 'null'] },
          title: { type: ['string', 'null'] },
          notes: { type: ['string', 'null'] },
        },
      },
    },
    creates: {
      type: 'array',
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['title', 'date', 'kind', 'subject_id', 'notes'],
        properties: {
          title: { type: 'string' },
          date: { type: 'string' },
          kind: { type: 'string' },
          subject_id: { type: ['string', 'null'] },
          notes: { type: ['string', 'null'] },
        },
      },
    },
  },
};

async function aiEditEvents(prompt, events, subjects) {
  const today = todayISO();
  const sysPrompt =
    `You are a calendar editor. Translate the user's plain-English command into a JSON plan that can be executed against their existing events. The user always sees a preview and confirms before anything runs — so trust their intent and act on it.\n\n` +
    `OUTPUT FIELDS:\n` +
    `- summary: 1-sentence plain-English description of what will happen.\n` +
    `- deletes: array of event IDs to delete. Populate freely whenever the user's command implies removal: "delete", "remove", "drop", "clear", "get rid of", "cancel", "wipe", "nuke", etc.\n` +
    `- updates: array of update groups. Each has \`ids\` (events affected) and a patch with five fields: subject_id, kind, date, title, notes. Set a field to null if it should NOT be changed for those ids; otherwise the new value.\n` +
    `- creates: array of new events. Each must have title, date (YYYY-MM-DD), kind. subject_id and notes can be null.\n\n` +
    `RULES:\n` +
    `- IDs must come from the input events. Never invent IDs.\n` +
    `- subject_id values must be one of the subject IDs provided (or null to unassign).\n` +
    `- kind must be exam | assignment | study | other.\n` +
    `- TODAY is ${today}. Resolve relative phrases ("next Monday", "in 2 weeks") to absolute YYYY-MM-DD using TODAY.\n` +
    `- For deletion commands, match liberally — substring matches in titles, kind/subject filters, date ranges all count. The user reviews before apply.\n` +
    `- If the command is genuinely ambiguous (no matching events), return empty arrays and explain in summary.\n` +
    `- Group ids that share the same patch into one update item.\n\n` +
    `SUBJECT MATCHING (important):\n` +
    `When the user asks to assign events to subjects, OR for any "auto-assign" / "categorize" / "tag" / "sort by subject" command, scan every event's title and match it to the best subject by keyword overlap, ignoring case. Match liberally:\n` +
    `- "Geometry: Unit 8 Test"          → a subject containing "Geometry" / "Math" / "Algebra" / "Calc"\n` +
    `- "Biology: Bioethics Presentation" → a subject containing "Bio" / "Biology" / "Science"\n` +
    `- "Spanish II: U5 Vocabulary"      → a subject containing "Spanish"\n` +
    `- "Eng 9: Poetry One-Pager"        → a subject containing "Eng" / "English" / "Lit" / "Literature"\n` +
    `- "World History: Unit 3 Test"     → a subject containing "History" / "World"\n` +
    `- "Chinese 2: Reading Test"        → a subject containing "Chinese" / "Mandarin"\n` +
    `- "AP Bio midterm" / "AP bio review" → "AP Biology" if it exists\n` +
    `Course prefixes ("Geometry:", "Biology:", "Eng 9:", "World History:") are the strongest signal — use them. If multiple subjects could match, prefer the one whose name shares the most letters with the prefix. If NO subject is a reasonable match, leave subject_id as null for that event (do not force a wrong assignment). Group all events that map to the same subject into one update item to keep the plan compact.`;

  const userPayload = {
    today,
    subjects: subjects.map((s) => ({ id: s.id, name: s.name, emoji: s.emoji })),
    events: events.map((e) => ({
      id: e.id, date: e.date, title: e.title, kind: e.kind,
      subject_id: e.subject_id, notes: e.notes || null,
    })),
    command: prompt,
  };

  const payload = {
    model: 'gpt-4o-mini',
    messages: [
      { role: 'system', content: sysPrompt },
      { role: 'user', content: JSON.stringify(userPayload) },
    ],
    response_format: {
      type: 'json_schema',
      json_schema: { name: 'edit_plan', strict: true, schema: AI_EDIT_SCHEMA },
    },
  };

  const res = await window.generate(payload, 'extract');  // counts toward 'extract' kind in usage cap
  const content = res?.choices?.[0]?.message?.content;
  if (!content) throw new Error('Empty response from model');
  let plan;
  try { plan = JSON.parse(content); }
  catch (e) { throw new Error('Model returned invalid JSON: ' + e.message); }

  // Validate IDs are real
  const eventIdSet = new Set(events.map((e) => e.id));
  const subjectIdSet = new Set(subjects.map((s) => s.id));
  plan.deletes = (plan.deletes || []).filter((id) => eventIdSet.has(id));
  plan.updates = (plan.updates || []).map((u) => ({
    ...u,
    ids: (u.ids || []).filter((id) => eventIdSet.has(id)),
    subject_id: u.subject_id && !subjectIdSet.has(u.subject_id) ? null : u.subject_id,
  })).filter((u) => u.ids.length > 0);
  plan.creates = (plan.creates || []).filter((c) => c && c.title && /^\d{4}-\d{2}-\d{2}$/.test(c.date));
  return plan;
}

function AIEventEditDialog({ events, subjects, onClose }) {
  const [prompt, setPrompt] = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [plan, setPlan] = React.useState(null);   // null until first extraction
  const [applied, setApplied] = React.useState(null);

  const examples = [
    'Auto-assign every event to its subject by title',
    'Delete all spring break events',
    'Move all geometry events forward by one week',
    'Add a study session every Sunday in May for Linear Algebra',
  ];

  const submit = async (e) => {
    e?.preventDefault?.();
    if (!prompt.trim()) return;
    setBusy(true); setErr(null); setPlan(null); setApplied(null);
    try {
      const result = await aiEditEvents(prompt, events, subjects);
      setPlan(result);
    } catch (e2) {
      setErr(e2.message);
    } finally { setBusy(false); }
  };

  const apply = async () => {
    if (!plan) return;
    setBusy(true); setErr(null);
    try {
      let nDel = 0, nUpd = 0, nNew = 0;
      // Deletes first
      if (plan.deletes.length) {
        await deleteEventsBulk(plan.deletes);
        nDel = plan.deletes.length;
      }
      // Updates
      for (const u of plan.updates) {
        const patch = {};
        if (u.subject_id !== null && u.subject_id !== undefined) patch.subject_id = u.subject_id;
        if (u.kind !== null && u.kind !== undefined && u.kind) patch.kind = u.kind;
        if (u.date !== null && u.date !== undefined && u.date) patch.date = u.date;
        if (u.title !== null && u.title !== undefined && u.title) patch.title = u.title;
        if (u.notes !== null && u.notes !== undefined) patch.notes = u.notes;
        if (Object.keys(patch).length === 0) continue;
        await updateEventsBulk(u.ids, patch);
        nUpd += u.ids.length;
      }
      // Creates
      if (plan.creates.length) {
        const rows = plan.creates.map((c) => ({
          title: c.title,
          date: c.date,
          kind: c.kind,
          subjectId: c.subject_id || null,
          notes: c.notes || null,
        }));
        await addEventsBulk(rows, {});
        nNew = rows.length;
      }
      setApplied({ nDel, nUpd, nNew });
      setPlan(null);
      setPrompt('');
    } catch (e2) {
      setErr(e2.message);
    } finally { setBusy(false); }
  };

  const totalChanges = plan ? (plan.deletes.length + plan.updates.reduce((n, u) => n + u.ids.length, 0) + plan.creates.length) : 0;
  const eventById = React.useMemo(() => Object.fromEntries(events.map((e) => [e.id, e])), [events]);
  const subjectById = React.useMemo(() => Object.fromEntries(subjects.map((s) => [s.id, s])), [subjects]);

  return (
    <ModalShell width={620} onClose={onClose}>
      <ModalHead title="✦ AI edit events" sub="Plain-English commands → preview → apply. Always shows you a plan first." onClose={onClose} />

      <form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
        <textarea className="input" rows={3} value={prompt} onChange={(e) => setPrompt(e.target.value)}
          placeholder="e.g. delete all 'Cakes and Candles' events; or, move every geometry test forward by 2 days"
          style={{ fontFamily: 'var(--sans)', fontSize: 13.5 }}
          autoFocus
        />
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', fontSize: 11.5 }}>
          {examples.map((ex) => (
            <button type="button" key={ex} onClick={() => setPrompt(ex)}
              className="filter-chip" style={{ fontSize: 11.5 }}>
              {ex}
            </button>
          ))}
        </div>
        <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
          <button type="submit" className="btn accent sm" disabled={busy || !prompt.trim()}>
            {busy && !plan ? 'Reading…' : '✦ Plan changes'}
          </button>
          <span style={{ fontSize: 11.5, color: 'var(--ink-mute)' }}>
            {events.length} event{events.length === 1 ? '' : 's'} sent · gpt-4o-mini · counts toward usage cap
          </span>
        </div>
      </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>
      )}

      {applied && !plan && (
        <div style={{ padding: '12px 14px', background: 'var(--moss-tint)', border: '1px solid var(--moss)', borderRadius: 'var(--r-md)', fontSize: 13, color: 'var(--moss)' }}>
          Done: {applied.nDel} deleted · {applied.nUpd} updated · {applied.nNew} created.
        </div>
      )}

      {plan && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflow: 'hidden', minHeight: 0 }}>
          <div style={{ padding: '10px 12px', background: 'var(--page-tint)', border: '1px solid var(--hairline-bold)', borderRadius: 'var(--r-md)' }}>
            <div className="eyebrow" style={{ marginBottom: 4 }}>Plan summary</div>
            <div style={{ fontSize: 13.5, color: 'var(--ink-strong)', fontFamily: 'var(--serif)' }}>{plan.summary}</div>
          </div>

          <div style={{ overflowY: 'auto', maxHeight: 320, display: 'flex', flexDirection: 'column', gap: 10 }}>
            {plan.deletes.length > 0 && (
              <PlanGroup tone="brick" title={`Delete · ${plan.deletes.length}`}>
                {plan.deletes.map((id) => {
                  const ev = eventById[id]; if (!ev) return null;
                  return <PlanRow key={id} left={<span className="num">{ev.date}</span>} title={ev.title} right={<span className="chip" style={{ textTransform: 'capitalize' }}>{ev.kind}</span>} />;
                })}
              </PlanGroup>
            )}
            {plan.updates.map((u, i) => {
              const changedFields = ['subject_id','kind','date','title','notes'].filter((f) => u[f] !== null && u[f] !== undefined && u[f] !== '');
              const changeDescs = changedFields.map((f) => {
                if (f === 'subject_id') return `subject → ${u.subject_id ? `${subjectById[u.subject_id]?.emoji || ''} ${subjectById[u.subject_id]?.name || u.subject_id}` : 'unassigned'}`;
                if (f === 'kind') return `kind → ${u.kind}`;
                if (f === 'date') return `date → ${u.date}`;
                if (f === 'title') return `title → "${u.title}"`;
                if (f === 'notes') return `notes → "${(u.notes || '').slice(0, 30)}…"`;
                return f;
              });
              return (
                <PlanGroup key={'u'+i} tone="accent" title={`Update · ${u.ids.length}`} sub={changeDescs.join(' · ')}>
                  {u.ids.map((id) => {
                    const ev = eventById[id]; if (!ev) return null;
                    return <PlanRow key={id} left={<span className="num">{ev.date}</span>} title={ev.title} />;
                  })}
                </PlanGroup>
              );
            })}
            {plan.creates.length > 0 && (
              <PlanGroup tone="moss" title={`Create · ${plan.creates.length}`}>
                {plan.creates.map((c, i) => (
                  <PlanRow key={'c'+i}
                    left={<span className="num">{c.date}</span>}
                    title={c.title}
                    right={<span className="chip" style={{ textTransform: 'capitalize' }}>{c.kind}</span>} />
                ))}
              </PlanGroup>
            )}
            {totalChanges === 0 && (
              <div style={{ padding: '24px', textAlign: 'center', border: '1px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)', color: 'var(--ink-mute)' }}>
                The model couldn't find anything to change. Try rephrasing.
              </div>
            )}
          </div>

          <div style={{ display: 'flex', gap: 8 }}>
            <button type="button" className="btn ghost sm" onClick={() => setPlan(null)} disabled={busy}>Discard plan</button>
            <div style={{ flex: 1 }} />
            <button type="button" className="btn sm" onClick={onClose}>Cancel</button>
            <button type="button" className="btn accent sm" onClick={apply} disabled={busy || totalChanges === 0}>
              {busy ? 'Applying…' : `✓ Apply ${totalChanges} change${totalChanges === 1 ? '' : 's'}`}
            </button>
          </div>
        </div>
      )}
    </ModalShell>
  );
}

function PlanGroup({ tone, title, sub, children }) {
  const colors = {
    brick: { bg: 'var(--brick-tint)', border: 'var(--brick)', label: 'var(--brick)' },
    accent: { bg: 'var(--accent-faint)', border: 'var(--accent)', label: 'var(--accent)' },
    moss: { bg: 'var(--moss-tint)', border: 'var(--moss)', label: 'var(--moss)' },
  }[tone] || { bg: 'var(--page-tint)', border: 'var(--hairline-bold)', label: 'var(--ink-soft)' };
  return (
    <div style={{ border: `1px solid ${colors.border}`, borderRadius: 'var(--r-md)', overflow: 'hidden' }}>
      <div style={{ padding: '8px 12px', background: colors.bg, fontFamily: 'var(--mono)', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: colors.label }}>
        {title}{sub ? <span style={{ marginLeft: 8, fontWeight: 500, textTransform: 'none', letterSpacing: 'normal', color: 'var(--ink-soft)', fontFamily: 'var(--sans)', fontSize: 12 }}>{sub}</span> : null}
      </div>
      <div style={{ background: 'var(--page)' }}>{children}</div>
    </div>
  );
}

function PlanRow({ left, title, right }) {
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 12px', borderBottom: '1px solid var(--hairline-soft)', fontSize: 12.5 }}>
      {left}
      <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--ink-strong)' }}>{title}</span>
      {right}
    </div>
  );
}

function Legend({ kind, label }) {
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12.5, color: 'var(--ink-soft)' }}>
      <span className={"dot " + kind} />
      <span>{label}</span>
    </div>
  );
}

function monthCells(y, m) {
  const first = new Date(y, m, 1);
  const startDay = first.getDay();
  const out = [];
  for (let i = startDay - 1; i >= 0; i--) {
    const d = new Date(y, m, -i);
    out.push({ day: d.getDate(), month: d.getMonth(), date: dateISO(d) });
  }
  const last = new Date(y, m + 1, 0).getDate();
  for (let d = 1; d <= last; d++) {
    const dt = new Date(y, m, d);
    out.push({ day: d, month: m, date: dateISO(dt) });
  }
  while (out.length % 7 !== 0) {
    const dt = new Date(out[out.length - 1].date + 'T00:00:00');
    dt.setDate(dt.getDate() + 1);
    out.push({ day: dt.getDate(), month: dt.getMonth(), date: dateISO(dt) });
  }
  return out;
}
function dateISO(d) {
  const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, '0'), day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}
function monthOffset({ y, m }, n) {
  const d = new Date(y, m + n, 1);
  return { y: d.getFullYear(), m: d.getMonth() };
}

// ─── Guide reader ──────────────────────────────────────────────────────────

function GuideScreen({ subjectId, guideId }) {
  const subjects = useSubjects();
  const subj = subjects.find((s) => s.id === subjectId) || { id: subjectId, name: '(unknown subject)', emoji: '📄' };
  const [guide, setGuide] = React.useState(() => findGuideSync(guideId));
  const [tried, setTried] = React.useState(!!findGuideSync(guideId));
  const [aiPanelOpen, setAiPanelOpen] = React.useState(false);

  React.useEffect(() => {
    if (tried) return;
    let cancelled = false;
    findGuide(guideId).then((g) => {
      if (cancelled) return;
      setGuide(g);
      setTried(true);
    });
    return () => { cancelled = true; };
  }, [guideId, tried]);

  // ── Editable working copy + autosave (debounced 1s) ──────────────────────
  const [working, setWorking] = React.useState(null);
  React.useEffect(() => {
    if (guide && !working) {
      setWorking({
        title: guide.title || '',
        content: guide.content
          ? JSON.parse(JSON.stringify(guide.content))
          : { sections: [], flashcards: [], quiz: [] },
      });
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [guide]);

  const [saveState, setSaveState] = React.useState('idle'); // idle|dirty|saving|saved|error
  const [savedAt, setSavedAt] = React.useState(null);
  const [saveError, setSaveError] = React.useState(null);
  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 dbPatch = {};
      if (patch.title != null) dbPatch.title = patch.title;
      if (patch.content != null) {
        dbPatch.content = {
          subjectId: guide?.subjectId,
          sources: guide?.sources,
          guide: patch.content,
        };
      }
      const updated = await updateGuide(guideId, dbPatch);
      setGuide(updated);
      setSaveState('saved');
      setSavedAt(new Date());
      setSaveError(null);
    } catch (e) {
      console.error('guide save failed', e);
      setSaveError(e.message || String(e));
      setSaveState('error');
    }
  }, [guide, guideId]);

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

  const updateContent = React.useCallback((mutator) => {
    setWorking((w) => {
      if (!w) return w;
      const nextContent = JSON.parse(JSON.stringify(w.content || {}));
      mutator(nextContent);
      queueSave({ content: nextContent });
      return { ...w, content: nextContent };
    });
  }, [queueSave]);

  const updateTitle = React.useCallback((v) => {
    setWorking((w) => w ? { ...w, title: v } : w);
    queueSave({ title: (v || '').trim() || 'Untitled guide' });
  }, [queueSave]);

  React.useEffect(() => {
    const onBeforeUnload = () => {
      if (timerRef.current) { clearTimeout(timerRef.current); flush(); }
    };
    window.addEventListener('beforeunload', onBeforeUnload);
    return () => {
      window.removeEventListener('beforeunload', onBeforeUnload);
      if (timerRef.current) { clearTimeout(timerRef.current); flush(); }
    };
  }, [flush]);

  // Flashcards are now generated on demand from the guide content (the initial
  // study-guide generation produces an empty flashcards array).
  const [genCardsState, setGenCardsState] = React.useState('idle'); // idle|generating|error
  const generateFlashcards = React.useCallback(async () => {
    if (!working) return;
    const existing = (working.content?.flashcards || []).length;
    if (existing > 0 && !confirm(`This will replace the ${existing} existing card${existing === 1 ? '' : 's'} with a fresh deck. Continue?`)) {
      return;
    }
    setGenCardsState('generating');
    try {
      const subjName = subj?.name;
      const cards = await generateFlashcardsForGuide(
        { title: working.title, content: working.content },
        { subjectName: subjName }
      );
      updateContent((cc) => { cc.flashcards = cards; });
      setGenCardsState('idle');
    } catch (e) {
      console.error('flashcard generation failed', e);
      alert('Flashcard generation failed: ' + (e.message || String(e)));
      setGenCardsState('error');
    }
  }, [working, subj?.name, updateContent]);

  const guidePath = `/subject/${subjectId}/guide/${guideId}`;

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

  if (!guide || !working) {
    return (
      <Page crumbs={[{ label: 'Dashboard', to: '/' }, 'Not found']}>
        <div style={{ padding: 32 }}>
          <div style={{ fontSize: 14 }}>Guide not found.</div>
          <button className="btn sm" style={{ marginTop: 12 }} onClick={() => navigate('/library')}>Browse all guides</button>
        </div>
      </Page>
    );
  }

  const c = working.content || {};
  const sections = c.sections || [];
  const flashcards = c.flashcards || [];
  const quiz = c.quiz || [];
  const firstQ = quiz[0];

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

  return (
    <Page
      crumbs={[
        { label: 'Dashboard', to: '/' },
        { label: subj.name, to: `/subject/${subj.id}` },
        working.title || 'Untitled guide',
      ]}
      actions={
        <>
          <span className={"save-status " + (saveState === 'saving' ? 'saving' : saveState === 'error' ? 'error' : '')}
            title={saveError || ''}>{statusLabel}</span>
          <button className={"btn sm" + (aiPanelOpen ? ' active' : '')} onClick={() => setAiPanelOpen((o) => !o)}>✦ AI</button>
          <button className="btn sm" onClick={() => downloadGuidePdf({
            title: working.title,
            subjectName: subj.name,
            subjectHint: c.subjectHint,
            bigIdea: c.bigIdea,
            sections,
            flashcards,
            quiz,
          })} title="Download as PDF">↓ PDF</button>
          {quiz.length > 0 && <button className="btn sm" onClick={() => navigate(`${guidePath}/quiz`)}>Quiz</button>}
        </>
      }
    >
      <div className="reader-grid">
        <aside className="toc">
          <div className="toc-label">Contents</div>
          {sections.map((s, i) => (
            <div key={i} className="toc-item">
              <span className="num">{String(s.number || i + 1).padStart(2, '0')}</span>
              <span style={{ flex: 1 }}>{s.title || '(untitled section)'}</span>
            </div>
          ))}
          {(flashcards.length > 0 || quiz.length > 0) && (
            <div style={{ borderTop: '1px solid var(--hairline)', margin: '12px 8px' }} />
          )}
          {flashcards.length > 0 && (
            <div className="toc-item" onClick={() => navigate(`${guidePath}/flashcards`)}>
              <span className="num">★</span><span>Flashcards · {flashcards.length}</span>
            </div>
          )}
          {quiz.length > 0 && (
            <div className="toc-item" onClick={() => navigate(`${guidePath}/quiz`)}>
              <span className="num">?</span><span>Quiz · {quiz.length}</span>
            </div>
          )}
        </aside>

        <main className="reader fade-in">
          <div className="reader-inner editable-guide">
            <div className="eyebrow">{subj.name}{c.subjectHint ? ` · ${c.subjectHint}` : ''}</div>
            <h1>
              <input
                className="bare-input"
                value={working.title}
                placeholder="Untitled guide"
                onChange={(e) => updateTitle(e.target.value)}
              />
            </h1>
            <div className="eyebrow-row">
              <span className="mono">¶ {guide.sources || 0} source{guide.sources === 1 ? '' : 's'}</span>
              <span style={{ color: 'var(--ink-whisper)' }}>·</span>
              <span>generated {relativeTime(guide.generatedAt)}</span>
            </div>

            <div className="big-idea">
              <div className="label">Summary</div>
              <div className="body">
                <AutoTextarea
                  className="bare-textarea"
                  value={c.bigIdea || ''}
                  placeholder="A one-sentence summary of the big idea…"
                  onChange={(v) => updateContent((cc) => { cc.bigIdea = v; })}
                />
              </div>
            </div>

            {sections.map((s, si) => (
              <section key={si}>
                <h2>
                  <span className="num">{String(s.number || si + 1).padStart(2, '0')}</span>
                  <input
                    className="bare-input"
                    style={{ flex: 1 }}
                    value={s.title || ''}
                    placeholder="Section title"
                    onChange={(e) => updateContent((cc) => { cc.sections[si].title = e.target.value; })}
                  />
                </h2>
                <AutoTextarea
                  className="bare-textarea reader-body"
                  value={s.body || ''}
                  placeholder="Body — separate paragraphs with a blank line."
                  onChange={(v) => updateContent((cc) => { cc.sections[si].body = v; })}
                />
                {(s.terms || []).length > 0 && <h3>Key terms</h3>}
                {(s.terms || []).map((t, ti) => (
                  <div key={ti} className="term editable-term">
                    <input
                      className="bare-input t-term"
                      value={t.term || ''}
                      placeholder="Term"
                      onChange={(e) => updateContent((cc) => { cc.sections[si].terms[ti].term = e.target.value; })}
                    />
                    <AutoTextarea
                      className="bare-textarea t-def"
                      value={t.def || ''}
                      placeholder="Definition"
                      onChange={(v) => updateContent((cc) => { cc.sections[si].terms[ti].def = v; })}
                    />
                    <button
                      type="button"
                      className="row-x"
                      title="Remove term"
                      onClick={() => updateContent((cc) => { cc.sections[si].terms.splice(ti, 1); })}
                    >×</button>
                  </div>
                ))}
                <div style={{ marginTop: 8 }}>
                  <button
                    type="button"
                    className="btn ghost sm"
                    onClick={() => updateContent((cc) => {
                      cc.sections[si].terms = cc.sections[si].terms || [];
                      cc.sections[si].terms.push({ term: '', def: '' });
                    })}
                  >＋ Add term</button>
                </div>
              </section>
            ))}

            <section>
              <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
                <h2>
                  <span className="num">★</span>
                  Flashcards <span className="mono" style={{ fontSize: 12, color: 'var(--ink-mute)', fontWeight: 400, marginLeft: 6 }}>· {flashcards.length}</span>
                </h2>
                <button
                  type="button"
                  className="btn sm"
                  disabled={genCardsState === 'generating'}
                  onClick={generateFlashcards}
                  title={flashcards.length > 0 ? 'Regenerate from this guide (replaces existing cards)' : 'Generate flashcards from this guide'}
                  style={{ opacity: genCardsState === 'generating' ? 0.6 : 1 }}
                >
                  {genCardsState === 'generating'
                    ? 'Generating…'
                    : (flashcards.length > 0 ? '✦ Regenerate' : '✦ Generate')}
                </button>
              </div>

              {flashcards.length === 0 ? (
                <div style={{ padding: '24px', marginTop: 12, textAlign: 'center', border: '1px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)', background: 'var(--page-tint)' }}>
                  <div style={{ fontSize: 13, color: 'var(--ink-mute)', marginBottom: 10 }}>
                    No flashcards yet.{' '}
                    <span style={{ color: 'var(--ink-soft)' }}>
                      Click <strong>Generate</strong> to build a deck from this guide's terms and concepts,
                      or add cards manually.
                    </span>
                  </div>
                  <button
                    type="button"
                    className="btn ghost sm"
                    onClick={() => updateContent((cc) => {
                      cc.flashcards = cc.flashcards || [];
                      cc.flashcards.push({ front: '', back: '' });
                    })}
                  >＋ Add card manually</button>
                </div>
              ) : (
                <>
                  <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
                    {flashcards.map((card, i) => (
                      <div key={i} className="editable-card">
                        <span className="mono card-num">{i + 1}</span>
                        <input
                          className="bare-input card-front"
                          value={card.front || ''}
                          placeholder="Front (term/question)"
                          onChange={(e) => updateContent((cc) => { cc.flashcards[i].front = e.target.value; })}
                        />
                        <AutoTextarea
                          className="bare-textarea card-back"
                          value={card.back || ''}
                          placeholder="Back (answer)"
                          onChange={(v) => updateContent((cc) => { cc.flashcards[i].back = v; })}
                        />
                        <button
                          type="button"
                          className="row-x"
                          title="Remove card"
                          onClick={() => updateContent((cc) => { cc.flashcards.splice(i, 1); })}
                        >×</button>
                      </div>
                    ))}
                  </div>
                  <div style={{ marginTop: 10 }}>
                    <button
                      type="button"
                      className="btn ghost sm"
                      onClick={() => updateContent((cc) => {
                        cc.flashcards = cc.flashcards || [];
                        cc.flashcards.push({ front: '', back: '' });
                      })}
                    >＋ Add card</button>
                  </div>
                </>
              )}
            </section>

            {firstQ && (
              <div className="quick-check" style={{ marginTop: 32 }}>
                <div className="qc-label">? Quick check</div>
                <div className="qc-q">{firstQ.question}</div>
                {(firstQ.options || []).map((o, i) => (
                  <div key={i} className="qc-opt">
                    <span className="letter">{String.fromCharCode(65 + i)}</span>
                    <span>{o}</span>
                  </div>
                ))}
                <div style={{ textAlign: 'right', marginTop: 10 }}>
                  <button className="btn ghost sm" onClick={() => navigate(`${guidePath}/quiz`)}>Open full quiz →</button>
                </div>
              </div>
            )}
          </div>
        </main>

        <aside className="meta-rail">
          <div className="eyebrow" style={{ marginBottom: 10 }}>About</div>
          <div className="row-pair"><span>Sources</span><span className="num">{guide.sources || 0}</span></div>
          <div className="row-pair"><span>Sections</span><span className="num">{sections.length}</span></div>
          <div className="row-pair"><span>Flashcards</span><span className="num">{flashcards.length}</span></div>
          <div className="row-pair"><span>Quiz items</span><span className="num">{quiz.length}</span></div>
          <div className="row-pair"><span>Generated</span><span className="num" style={{ fontSize: 11 }}>{relativeTime(guide.generatedAt)}</span></div>

          <div style={{ marginTop: 24 }}>
            <div className="eyebrow" style={{ marginBottom: 8 }}>Actions</div>
            {flashcards.length > 0 && <button className="btn sm" style={{ width: '100%', justifyContent: 'center', marginBottom: 6 }} onClick={() => navigate(`${guidePath}/flashcards`)}>★ Study cards</button>}
          </div>

          <button className="btn danger sm" style={{ width: '100%', justifyContent: 'center', marginTop: 18 }}
            onClick={async () => {
              if (!confirm('Delete this generated guide? This cannot be undone.')) return;
              try { await deleteGuide(guide.id); navigate(`/subject/${subj.id}`); }
              catch (e) { alert('Delete failed: ' + e.message); }
            }}>
            Delete guide
          </button>
        </aside>
      </div>
      {aiPanelOpen && <GuideAIPanel guide={guide} subject={subj} onClose={() => setAiPanelOpen(false)} />}
    </Page>
  );
}

// Auto-growing textarea — height tracks content. Used throughout the editable
// guide so paragraphs and definitions don't show a scrollbar.
function AutoTextarea({ value, onChange, className, placeholder, ...rest }) {
  const ref = React.useRef(null);
  const resize = () => {
    const el = ref.current;
    if (!el) return;
    el.style.height = 'auto';
    el.style.height = el.scrollHeight + 'px';
  };
  React.useEffect(() => { resize(); }, [value]);
  return (
    <textarea
      ref={ref}
      className={className}
      value={value || ''}
      placeholder={placeholder}
      rows={1}
      onChange={(e) => { onChange(e.target.value); }}
      onInput={resize}
      {...rest}
    />
  );
}

// ─── Flashcards ────────────────────────────────────────────────────────────

// ─── Flashcard mode picker ────────────────────────────────────────────────
//
// Two modes:
//   Cram      — stateless. Pass/Fail, 2-consecutive-pass to clear a card,
//               failed cards re-queue, session ends when all clear or time up.
//               No DB writes.
//   Long-term — FSRS-4.5 spaced repetition. Per-card stability/difficulty/
//               next_review persisted in flashcard_reviews (RLS-scoped).
function FlashcardScreen({ subjectId, guideId }) {
  const subjects = useSubjects();
  const subj = subjects.find((s) => s.id === subjectId) || { id: subjectId, name: '(unknown subject)' };
  const [guide, setGuide] = React.useState(() => findGuideSync(guideId));
  React.useEffect(() => {
    if (guide) return;
    let cancelled = false;
    findGuide(guideId).then((g) => { if (!cancelled) setGuide(g); });
    return () => { cancelled = true; };
  }, [guideId]);

  const guidePath = `/subject/${subjectId}/guide/${guideId}`;
  const cards = guide ? (guide.content?.flashcards || []) : [];

  return (
    <Page
      crumbs={[
        { label: 'Dashboard', to: '/' },
        { label: subj.name, to: `/subject/${subjectId}` },
        { label: guide?.title || 'Guide', to: guidePath },
        'Flashcards',
      ]}
      actions={<button className="btn ghost sm" onClick={() => navigate(guidePath)}>Exit</button>}
    >
      <div className="scroll">
        <div className="page-pad fade-in" style={{ maxWidth: 880, margin: '0 auto' }}>
          <div className="eyebrow">§ Study mode</div>
          <h1 className="page-title">Pick a mode.</h1>
          <div className="page-subtitle">
            <span><strong style={{ color: 'var(--ink-strong)' }}>{cards.length}</strong> {cards.length === 1 ? 'card' : 'cards'} in this deck.</span>
            <span style={{ color: 'var(--ink-whisper)' }}>·</span>
            <span>Cram for a deadline, or learn long-term with spaced repetition.</span>
          </div>

          {cards.length === 0 ? (
            <div style={{ padding: '36px 18px', textAlign: 'center', border: '1px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)' }}>
              <div style={{ fontFamily: 'var(--serif)', fontSize: 22, color: 'var(--ink-strong)', fontWeight: 500 }}>No flashcards yet</div>
              <div style={{ fontSize: 13, color: 'var(--ink-mute)', margin: '8px 0 16px' }}>
                Open the guide and click <strong>✦ Generate</strong> next to Flashcards to build a deck.
              </div>
              <button className="btn accent sm" onClick={() => navigate(guidePath)}>← Back to guide</button>
            </div>
          ) : (
            <div className="mode-grid">
              <button
                className="mode-tile"
                onClick={() => navigate(`${guidePath}/flashcards/cram`)}
              >
                <div className="mode-glyph">⚡</div>
                <div className="mode-name">Cram</div>
                <div className="mode-tag">Timed · stateless</div>
                <div className="mode-desc">
                  Drill the whole deck before a deadline. Mark each card <strong>Pass</strong> or
                  <strong> Fail</strong>; failed cards re-queue, and a card clears after 2 passes in a row.
                </div>
                <div className="mode-meta">
                  <span>{cards.length} cards</span>
                  <span>·</span>
                  <span>no progress saved</span>
                </div>
              </button>

              <button
                className="mode-tile"
                onClick={() => navigate(`${guidePath}/flashcards/longterm`)}
              >
                <div className="mode-glyph">∞</div>
                <div className="mode-name">Long-term</div>
                <div className="mode-tag">Spaced repetition · FSRS</div>
                <div className="mode-desc">
                  Learn for retention. Rate each card <strong>Again / Hard / Good / Easy</strong>;
                  the FSRS scheduler picks when you'll see it next. New cards capped at 10/day.
                </div>
                <div className="mode-meta">
                  <span>persistent state</span>
                  <span>·</span>
                  <span>per-card schedule</span>
                </div>
              </button>
            </div>
          )}
        </div>
      </div>
    </Page>
  );
}

// ─── Cram mode ────────────────────────────────────────────────────────────
const CRAM_DURATIONS = [5, 10, 15, 20, 30]; // minutes

function CramScreen({ subjectId, guideId }) {
  const subjects = useSubjects();
  const subj = subjects.find((s) => s.id === subjectId) || { id: subjectId, name: '(unknown subject)' };
  const [guide, setGuide] = React.useState(() => findGuideSync(guideId));
  React.useEffect(() => {
    if (guide) return;
    let cancelled = false;
    findGuide(guideId).then((g) => { if (!cancelled) setGuide(g); });
    return () => { cancelled = true; };
  }, [guideId]);

  const guidePath = `/subject/${subjectId}/guide/${guideId}`;
  const flashcardsPath = `${guidePath}/flashcards`;
  const cards = guide ? (guide.content?.flashcards || []) : [];

  // Phase: 'pick' (choose duration) → 'session' → 'done'
  const [phase, setPhase] = React.useState('pick');
  const [durationMin, setDurationMin] = React.useState(10);
  const [endsAt, setEndsAt] = React.useState(null);
  const [now, setNow] = React.useState(Date.now());
  const [queue, setQueue] = React.useState([]); // [{ idx, card, passStreak, attempts }]
  const [doneCount, setDoneCount] = React.useState(0);
  const [flipped, setFlipped] = React.useState(false);
  const [endedReason, setEndedReason] = React.useState(null); // 'cleared' | 'timeup' | 'exit'

  // Snapshot of original deck size at session start (for progress %).
  const totalCardsRef = React.useRef(0);

  const startSession = (mins) => {
    const dur = mins || durationMin;
    setDurationMin(dur);
    const initial = cards.map((card, i) => ({ idx: i, card, passStreak: 0, attempts: 0 }));
    // Shuffle so order isn't deterministic between sessions.
    for (let i = initial.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [initial[i], initial[j]] = [initial[j], initial[i]];
    }
    setQueue(initial);
    setDoneCount(0);
    setFlipped(false);
    totalCardsRef.current = initial.length;
    setEndsAt(Date.now() + dur * 60 * 1000);
    setEndedReason(null);
    setPhase('session');
  };

  // Tick + auto-end at time-up
  React.useEffect(() => {
    if (phase !== 'session') return;
    const t = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(t);
  }, [phase]);
  React.useEffect(() => {
    if (phase === 'session' && endsAt && now >= endsAt) {
      setEndedReason('timeup');
      setPhase('done');
    }
  }, [phase, endsAt, now]);

  const current = phase === 'session' ? queue[0] : null;

  const handlePass = () => {
    setQueue((q) => {
      if (q.length === 0) return q;
      const [head, ...rest] = q;
      const nextStreak = head.passStreak + 1;
      const nextAttempts = head.attempts + 1;
      if (nextStreak >= 2) {
        // Done — drop card.
        setDoneCount((d) => d + 1);
        if (rest.length === 0) {
          setEndedReason('cleared');
          setPhase('done');
        }
        return rest;
      }
      // Re-queue at end with bumped streak.
      return [...rest, { ...head, passStreak: nextStreak, attempts: nextAttempts }];
    });
    setFlipped(false);
  };

  const handleFail = () => {
    setQueue((q) => {
      if (q.length === 0) return q;
      const [head, ...rest] = q;
      // Reset streak; re-queue at end.
      return [...rest, { ...head, passStreak: 0, attempts: head.attempts + 1 }];
    });
    setFlipped(false);
  };

  // Keyboard: Space flips, F/J fail/pass (or 1/2)
  React.useEffect(() => {
    if (phase !== 'session') return;
    const onKey = (e) => {
      if (e.target?.tagName === 'INPUT' || e.target?.tagName === 'TEXTAREA') return;
      if (e.key === ' ') { e.preventDefault(); setFlipped((f) => !f); }
      else if (e.key === '1' || e.key === 'f' || e.key === 'F') { e.preventDefault(); handleFail(); }
      else if (e.key === '2' || e.key === 'j' || e.key === 'J') { e.preventDefault(); handlePass(); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [phase]);

  const remainingMs = endsAt ? Math.max(0, endsAt - now) : 0;
  const mm = Math.floor(remainingMs / 60000);
  const ss = Math.floor((remainingMs % 60000) / 1000);
  const timerLabel = `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
  const remainingCards = queue.length;
  const progressPct = totalCardsRef.current > 0
    ? Math.round((doneCount / totalCardsRef.current) * 100)
    : 0;

  const crumbs = [
    { label: 'Dashboard', to: '/' },
    { label: subj.name, to: `/subject/${subjectId}` },
    { label: guide?.title || 'Guide', to: guidePath },
    { label: 'Flashcards', to: flashcardsPath },
    'Cram',
  ];

  // ── Pick phase ──────────────────────────────────────────────────────────
  if (phase === 'pick') {
    return (
      <Page crumbs={crumbs}
        actions={<button className="btn ghost sm" onClick={() => navigate(flashcardsPath)}>Back</button>}>
        <div className="scroll">
          <div className="page-pad fade-in" style={{ maxWidth: 560, margin: '0 auto' }}>
            <div className="eyebrow">⚡ Cram</div>
            <h1 className="page-title">How long?</h1>
            <div className="page-subtitle">
              <span>Pick a session length. Cards re-queue on Fail; clear after 2 passes in a row.</span>
            </div>

            <div className="cram-durations">
              {CRAM_DURATIONS.map((m) => (
                <button key={m}
                  className={"cram-dur" + (m === durationMin ? ' on' : '')}
                  onClick={() => setDurationMin(m)}>
                  <div className="num">{m}</div>
                  <div className="lbl">min</div>
                </button>
              ))}
            </div>

            <button
              className="btn accent"
              style={{ width: '100%', marginTop: 18, height: 44, justifyContent: 'center', fontSize: 14 }}
              onClick={() => startSession(durationMin)}
              disabled={cards.length === 0}
            >
              Start cram session →
            </button>

            <div style={{ marginTop: 14, fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--ink-mute)', textTransform: 'uppercase', letterSpacing: '0.06em', textAlign: 'center' }}>
              {cards.length} cards · no progress saved
            </div>
          </div>
        </div>
      </Page>
    );
  }

  // ── Done phase ──────────────────────────────────────────────────────────
  if (phase === 'done') {
    const cleared = endedReason === 'cleared';
    return (
      <Page crumbs={crumbs}
        actions={<button className="btn ghost sm" onClick={() => navigate(flashcardsPath)}>Back to modes</button>}>
        <div className="scroll">
          <div className="page-pad fade-in" style={{ maxWidth: 560, margin: '0 auto', textAlign: 'center', paddingTop: 80 }}>
            <div className="eyebrow">⚡ Cram · {endedReason === 'timeup' ? 'time up' : 'cleared'}</div>
            <h1 className="page-title" style={{ fontSize: 48, marginTop: 6 }}>
              {cleared ? <>You cleared the deck.</> : <>Time's up.</>}
            </h1>
            <div className="page-subtitle" style={{ justifyContent: 'center' }}>
              <span><strong style={{ color: 'var(--ink-strong)' }}>{doneCount}</strong> of <strong style={{ color: 'var(--ink-strong)' }}>{totalCardsRef.current}</strong> cards cleared in {durationMin} min.</span>
            </div>
            <div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16 }}>
              <button className="btn sm" onClick={() => startSession(durationMin)}>Restart</button>
              <button className="btn accent sm" onClick={() => navigate(flashcardsPath)}>Back to modes</button>
            </div>
          </div>
        </div>
      </Page>
    );
  }

  // ── Session phase ───────────────────────────────────────────────────────
  return (
    <Page crumbs={crumbs}
      actions={
        <>
          <span className="mono" style={{ fontSize: 13, color: remainingMs < 60000 ? 'var(--brick)' : 'var(--ink-strong)', marginRight: 6 }}>{timerLabel}</span>
          <span className="mono" style={{ fontSize: 12, color: 'var(--ink-mute)' }}>{remainingCards} left · {doneCount} done</span>
          <button className="btn ghost sm" onClick={() => { setEndedReason('exit'); setPhase('done'); }}>End</button>
        </>
      }
    >
      <div className="cram-stage">
        <div className="cram-progress">
          <div className="cram-progress-bar" style={{ width: progressPct + '%' }} />
        </div>

        {!current ? (
          <div style={{ padding: 32 }}>Loading…</div>
        ) : (
          <>
            <div className="flash-deck" onClick={() => setFlipped((f) => !f)} style={{ cursor: 'pointer' }}>
              <div className="flash-shadow s1" />
              <div className="flash-shadow s2" />
              <div className="flash-card">
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
                  <span className="chip">{subj.name}{guide ? ` · ${guide.title}` : ''}</span>
                  <span className="flash-front-label">
                    {flipped ? 'Back' : 'Front'} · streak {current.passStreak}/2
                  </span>
                </div>
                <div style={{ textAlign: 'center' }}>
                  <div className="flash-front-label" style={{ marginBottom: 14 }}>{flipped ? 'Answer' : 'Question'}</div>
                  <div className="flash-body">{flipped ? current.card.back : current.card.front}</div>
                </div>
                <div style={{ textAlign: 'center', color: 'var(--ink-whisper)', fontSize: 11.5 }}>
                  <span className="kbd">Space</span> flip · <span className="kbd">F</span> fail · <span className="kbd">J</span> pass
                </div>
              </div>
            </div>

            <div className="cram-rate">
              <button className="cram-rate-btn fail" onClick={handleFail}>
                <span className="lbl">Fail</span>
                <span className="hint">re-queue</span>
                <span className="k kbd">F</span>
              </button>
              <button className="cram-rate-btn pass" onClick={handlePass}>
                <span className="lbl">Pass</span>
                <span className="hint">{current.passStreak === 1 ? 'clears card' : 'streak +1'}</span>
                <span className="k kbd">J</span>
              </button>
            </div>
          </>
        )}
      </div>
    </Page>
  );
}

// ─── Long-term (FSRS) mode ────────────────────────────────────────────────
const FSRS_RATING_LABELS = [
  { id: 1, name: 'Again', cls: 'again' },
  { id: 2, name: 'Hard',  cls: 'hard'  },
  { id: 3, name: 'Good',  cls: 'good'  },
  { id: 4, name: 'Easy',  cls: 'easy'  },
];

function LongTermScreen({ subjectId, guideId }) {
  const subjects = useSubjects();
  const subj = subjects.find((s) => s.id === subjectId) || { id: subjectId, name: '(unknown subject)' };
  const [guide, setGuide] = React.useState(() => findGuideSync(guideId));
  React.useEffect(() => {
    if (guide) return;
    let cancelled = false;
    findGuide(guideId).then((g) => { if (!cancelled) setGuide(g); });
    return () => { cancelled = true; };
  }, [guideId]);

  const reviewsMap = useReviewsForGuide(guideId);
  const [loadError, setLoadError] = React.useState(null);
  React.useEffect(() => {
    loadReviewsForGuide(guideId).catch((e) => {
      console.error('reviews load failed', e);
      setLoadError(e?.message || String(e));
    });
  }, [guideId]);

  const guidePath = `/subject/${subjectId}/guide/${guideId}`;
  const flashcardsPath = `${guidePath}/flashcards`;
  const cards = guide ? (guide.content?.flashcards || []) : [];

  // Build the day's queue from cards + cached review rows. Re-built when
  // either side changes. Items come out in priority order.
  const queue = React.useMemo(
    () => buildDueQueue(cards, reviewsMap, { newCap: 10 }),
    [cards, reviewsMap]
  );

  const [pos, setPos] = React.useState(0);
  const [flipped, setFlipped] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const [stats, setStats] = React.useState({ done: 0, again: 0 });

  // Reset on guide change.
  React.useEffect(() => { setPos(0); setFlipped(false); setStats({ done: 0, again: 0 }); }, [guideId]);

  const current = queue[pos];

  const previews = React.useMemo(() => {
    if (!current) return null;
    return window.FSRS.previewIntervals(current.review || null);
  }, [current]);

  const grade = async (rating) => {
    if (!current || busy) return;
    setBusy(true);
    try {
      const next = window.FSRS.schedule(current.review || null, rating);
      await upsertReview(guideId, current.index, next);
      setStats((s) => ({
        done: s.done + 1,
        again: s.again + (rating === 1 ? 1 : 0),
      }));
      setFlipped(false);
      setPos((p) => p + 1);
    } catch (e) {
      console.error('grade failed', e);
      alert('Save failed: ' + (e.message || String(e)));
    } finally {
      setBusy(false);
    }
  };

  React.useEffect(() => {
    const onKey = (e) => {
      if (e.target?.tagName === 'INPUT' || e.target?.tagName === 'TEXTAREA') return;
      if (e.key === ' ') { e.preventDefault(); setFlipped((f) => !f); }
      else if (flipped && '1234'.includes(e.key)) { e.preventDefault(); grade(parseInt(e.key, 10)); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [flipped, current, busy]);

  const crumbs = [
    { label: 'Dashboard', to: '/' },
    { label: subj.name, to: `/subject/${subjectId}` },
    { label: guide?.title || 'Guide', to: guidePath },
    { label: 'Flashcards', to: flashcardsPath },
    'Long-term',
  ];

  // ── Migration-needed error: catches the case when the Supabase table
  //    doesn't exist yet (typical first-run after deploying this feature).
  if (loadError && /relation .*flashcard_reviews|does not exist|not found/i.test(loadError)) {
    return (
      <Page crumbs={crumbs}
        actions={<button className="btn ghost sm" onClick={() => navigate(flashcardsPath)}>Back</button>}>
        <div className="page-pad" style={{ maxWidth: 560, margin: '0 auto', textAlign: 'center', paddingTop: 60 }}>
          <div className="eyebrow">∞ Long-term</div>
          <h1 className="page-title">DB migration required.</h1>
          <div style={{ fontSize: 13.5, color: 'var(--ink-soft)', lineHeight: 1.6, marginTop: 12 }}>
            The <code>flashcard_reviews</code> table doesn't exist yet. Open the Supabase dashboard's
            SQL editor and paste the contents of <strong>migrations/2026-05-01_flashcard_reviews.sql</strong>,
            then reload this page.
          </div>
        </div>
      </Page>
    );
  }

  if (cards.length === 0) {
    return (
      <Page crumbs={crumbs}
        actions={<button className="btn ghost sm" onClick={() => navigate(flashcardsPath)}>Back</button>}>
        <div className="page-pad" style={{ maxWidth: 560, margin: '0 auto', textAlign: 'center', paddingTop: 80 }}>
          <h1 className="page-title">No flashcards yet.</h1>
          <button className="btn accent sm" style={{ marginTop: 14 }} onClick={() => navigate(guidePath)}>← Back to guide</button>
        </div>
      </Page>
    );
  }

  // Done with today's queue.
  if (pos >= queue.length) {
    // Compute the "next due" date from cached reviews for a heads-up.
    let soonest = null;
    for (const r of reviewsMap.values()) {
      if (!r.next_review) continue;
      const t = new Date(r.next_review).getTime();
      if (t > Date.now() && (soonest == null || t < soonest)) soonest = t;
    }
    const nextLabel = soonest
      ? `Next card due ${relativeTime(new Date(soonest).toISOString()).replace('ago', 'from now')}`
      : 'Add more cards or come back tomorrow.';

    return (
      <Page crumbs={crumbs}
        actions={<button className="btn ghost sm" onClick={() => navigate(flashcardsPath)}>Back to modes</button>}>
        <div className="page-pad fade-in" style={{ maxWidth: 560, margin: '0 auto', textAlign: 'center', paddingTop: 80 }}>
          <div className="eyebrow">∞ Long-term · done for today</div>
          <h1 className="page-title" style={{ fontSize: 48, marginTop: 6 }}>All caught up.</h1>
          <div className="page-subtitle" style={{ justifyContent: 'center' }}>
            <span><strong style={{ color: 'var(--ink-strong)' }}>{stats.done}</strong> reviews · <strong style={{ color: 'var(--ink-strong)' }}>{stats.again}</strong> Again.</span>
          </div>
          <div style={{ fontSize: 13, color: 'var(--ink-mute)', marginTop: 14 }}>{nextLabel}</div>
        </div>
      </Page>
    );
  }

  const card = current.card;
  const isNew = !current.review || current.review.state === 'new';

  return (
    <Page crumbs={crumbs}
      actions={
        <>
          <span className="mono" style={{ fontSize: 12, color: 'var(--ink-mute)' }}>
            {pos + 1} / {queue.length} · {stats.done} done
          </span>
          <button className="btn ghost sm" onClick={() => navigate(flashcardsPath)}>End</button>
        </>
      }
    >
      <div className="flash-stage">
        <div className="flash-deck" onClick={() => setFlipped((f) => !f)} style={{ cursor: 'pointer' }}>
          <div className="flash-shadow s1" />
          <div className="flash-shadow s2" />
          <div className="flash-card">
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
              <span className="chip">{subj.name}{guide ? ` · ${guide.title}` : ''}</span>
              <span className="flash-front-label">
                {isNew ? 'NEW' : (current.review?.state || 'review').toUpperCase()} · {flipped ? 'Back' : 'Front'}
              </span>
            </div>
            <div style={{ textAlign: 'center' }}>
              <div className="flash-front-label" style={{ marginBottom: 14 }}>{flipped ? 'Answer' : 'Question'}</div>
              <div className="flash-body">{flipped ? card.back : card.front}</div>
            </div>
            <div style={{ textAlign: 'center', color: 'var(--ink-whisper)', fontSize: 11.5 }}>
              {flipped
                ? <><span className="kbd">1</span>–<span className="kbd">4</span> rate</>
                : <><span className="kbd">Space</span> flip to rate</>}
            </div>
          </div>
        </div>

        {flipped && previews && (
          <div className="flash-rate">
            {FSRS_RATING_LABELS.map((r) => (
              <button
                key={r.id}
                className={"flash-rate-btn " + r.cls}
                disabled={busy}
                onClick={() => grade(r.id)}
              >
                <span className="lbl">{r.name}</span>
                <span className="ivl">{previews[r.id]}</span>
                <span className="k kbd">{r.id}</span>
              </button>
            ))}
          </div>
        )}
      </div>
    </Page>
  );
}

// ─── Quiz ──────────────────────────────────────────────────────────────────

function QuizScreen({ subjectId, guideId }) {
  const subjects = useSubjects();
  const subj = subjects.find((s) => s.id === subjectId) || { id: subjectId, name: '(unknown subject)' };
  const [guide, setGuide] = React.useState(() => findGuideSync(guideId));
  React.useEffect(() => {
    if (guide) return;
    let cancelled = false;
    findGuide(guideId).then((g) => { if (!cancelled) setGuide(g); });
    return () => { cancelled = true; };
  }, [guideId]);

  const guidePath = `/subject/${subjectId}/guide/${guideId}`;
  const questions = guide ? (guide.content?.quiz || []) : [];
  const [idx, setIdx] = React.useState(0);
  const [picks, setPicks] = React.useState([]);
  const [revealed, setRevealed] = React.useState(false);
  const [showHint, setShowHint] = React.useState(false);

  const q = questions[idx];
  const pick = picks[idx];
  const isCorrect = q && pick === q.correctIndex;
  const score = picks.reduce((n, p, i) => n + (p === questions[i]?.correctIndex ? 1 : 0), 0);
  const answered = picks.filter((p) => p != null).length;

  const choose = (i) => {
    if (revealed) return;
    setPicks((arr) => { const next = arr.slice(); next[idx] = i; return next; });
  };

  return (
    <Page
      crumbs={[
        { label: 'Dashboard', to: '/' },
        { label: subj.name, to: `/subject/${subjectId}` },
        { label: guide?.title || 'Guide', to: guidePath },
        'Quiz',
      ]}
      actions={
        <>
          <span className="mono" style={{ fontSize: 12, color: 'var(--ink-mute)', marginRight: 8 }}>
            {questions.length === 0 ? '0 questions' : `${answered} / ${questions.length} · score ${score}`}
          </span>
          <button className="btn ghost sm" onClick={() => navigate(guidePath)}>Exit</button>
        </>
      }
    >
      <div className="scroll">
        <div className="page-pad" style={{ paddingTop: 36 }}>
          <div style={{ maxWidth: 620, margin: '0 auto' }}>
            {questions.length === 0 ? (
              <div style={{ padding: '36px', textAlign: 'center', border: '1px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)' }}>
                <div style={{ fontFamily: 'var(--serif)', fontSize: 22, color: 'var(--ink-strong)', fontWeight: 500 }}>No quiz</div>
                <div style={{ fontSize: 13, color: 'var(--ink-mute)', margin: '8px 0 16px' }}>This guide doesn't have a quiz. Generate one from your own files.</div>
                <button className="btn accent sm" onClick={() => navigate(`/subject/${subjectId}/upload`)}>↑ Upload files</button>
              </div>
            ) : (
              <>
                <div className="quiz-progress">
                  {questions.map((_, i) => {
                    const p = picks[i];
                    const right = p != null && p === questions[i].correctIndex;
                    const wrong = p != null && p !== questions[i].correctIndex;
                    const cls = right ? 'right' : wrong ? 'wrong' : i === idx ? 'cur' : '';
                    return <div key={i} className={"seg " + cls} />;
                  })}
                </div>

                <div className="eyebrow">Question {String(idx + 1).padStart(2, '0')} of {String(questions.length).padStart(2, '0')} · multiple choice</div>
                <h1 className="quiz-q">{q.question}</h1>

                <div>
                  {q.options.map((o, i) => {
                    const sel = pick === i;
                    const isAnswer = revealed && i === q.correctIndex;
                    const isWrong  = revealed && sel && i !== q.correctIndex;
                    let cls = '';
                    if (isAnswer) cls = 'right';
                    else if (isWrong) cls = 'wrong';
                    else if (sel) cls = 'sel';
                    return (
                      <div key={i} className={"quiz-opt " + cls} onClick={() => choose(i)}>
                        <div className="letter">{isAnswer ? '✓' : isWrong ? '✗' : String.fromCharCode(65 + i)}</div>
                        <span>{o}</span>
                      </div>
                    );
                  })}
                </div>

                {q.hint && !revealed && (
                  <div style={{ marginTop: 16, padding: '12px 14px', border: '1px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)' }}>
                    <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
                      <span className="eyebrow">¿ Hint</span>
                      {!showHint ? <button className="btn ghost sm" onClick={() => setShowHint(true)}>Show</button> : null}
                    </div>
                    {showHint && <div style={{ fontSize: 13, color: 'var(--ink-soft)', marginTop: 6, fontStyle: 'italic' }}>{q.hint}</div>}
                  </div>
                )}

                {revealed && (
                  <div style={{
                    marginTop: 16, padding: '14px 16px',
                    background: isCorrect ? 'var(--moss-tint)' : 'var(--brick-tint)',
                    border: '1px solid ' + (isCorrect ? 'var(--moss)' : 'var(--brick)'),
                    borderRadius: 'var(--r-md)',
                  }}>
                    <div style={{ fontWeight: 600, color: isCorrect ? 'var(--moss)' : 'var(--brick)' }}>
                      {isCorrect ? '✓ Correct' : '✗ Not quite'}
                    </div>
                    <div style={{ fontSize: 13, color: 'var(--ink-soft)', marginTop: 4 }}>
                      Correct answer: <strong style={{ color: 'var(--ink-strong)' }}>{q.options[q.correctIndex]}</strong>
                    </div>
                  </div>
                )}

                <div style={{ marginTop: 24, display: 'flex', justifyContent: 'space-between' }}>
                  <button className="btn ghost sm"
                    onClick={() => { setRevealed(false); setShowHint(false); setIdx((i) => Math.max(i - 1, 0)); }}
                    disabled={idx === 0} style={{ opacity: idx === 0 ? 0.4 : 1 }}>← Previous</button>
                  {!revealed
                    ? <button className="btn accent" disabled={pick == null} onClick={() => setRevealed(true)} style={{ opacity: pick == null ? 0.4 : 1 }}>Check answer</button>
                    : idx < questions.length - 1
                      ? <button className="btn accent" onClick={() => { setRevealed(false); setShowHint(false); setIdx((i) => i + 1); }}>Next →</button>
                      : <button className="btn accent" onClick={() => navigate(guidePath)}>Finish · {score} / {questions.length}</button>
                  }
                </div>
              </>
            )}
          </div>
        </div>
      </div>
    </Page>
  );
}

// ─── Subject dialog (CRUD) ─────────────────────────────────────────────────

const EMOJI_SUGGESTIONS = ['📚','📐','📖','🧬','⚗️','📜','🌍','🎨','🎵','💻','🧮','🧠','⚖️','🔬','🌱'];

function SubjectDialog({ existing, onClose }) {
  const isEdit = !!existing;
  const [name, setName] = React.useState(existing?.name || '');
  const [emoji, setEmoji] = React.useState(existing?.emoji || '📚');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const submit = async (e) => {
    e.preventDefault();
    setBusy(true); setErr(null);
    try {
      if (isEdit) {
        await updateSubject(existing.id, { name, emoji });
        onClose();
      } else {
        const created = await addSubject({ name, emoji });
        onClose({ created });
      }
    } catch (e2) {
      setErr(e2.message);
    } finally { setBusy(false); }
  };

  const remove = async () => {
    if (!confirm(`Delete "${existing.name}"? Guides linked to it will become orphaned but not deleted.`)) return;
    setBusy(true);
    try { await deleteSubject(existing.id); onClose({ deleted: true }); }
    catch (e) { setErr(e.message); setBusy(false); }
  };

  return (
    <ModalShell onClose={() => onClose()}>
      <form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        <ModalHead title={isEdit ? 'Edit subject' : 'New subject'} onClose={() => onClose()} />

        <label className="field">
          <span>Name</span>
          <input className="input" type="text" value={name} onChange={(e) => setName(e.target.value)}
            placeholder="e.g. AP Biology" required autoFocus />
        </label>

        <label className="field">
          <span>Emoji</span>
          <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
            {EMOJI_SUGGESTIONS.map((em) => (
              <button key={em} type="button" onClick={() => setEmoji(em)}
                style={{
                  width: 34, height: 34, fontSize: 16, lineHeight: 1, padding: 0,
                  border: '1.5px solid ' + (emoji === em ? 'var(--accent)' : 'var(--hairline-bold)'),
                  background: emoji === em ? 'var(--accent-faint)' : 'var(--page)',
                  borderRadius: 'var(--r-sm)', cursor: 'pointer',
                }}>{em}</button>
            ))}
            <input type="text" value={emoji} onChange={(e) => setEmoji(e.target.value.slice(0, 4))}
              maxLength={4}
              style={{ width: 56, height: 34, padding: '0 8px', border: '1px solid var(--hairline-bold)', borderRadius: 'var(--r-sm)', fontSize: 16, textAlign: 'center', fontFamily: 'inherit', background: 'var(--page)' }}
            />
          </div>
        </label>

        {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 style={{ display: 'flex', gap: 8, marginTop: 4 }}>
          {isEdit && <button type="button" className="btn danger sm" onClick={remove} disabled={busy}>Delete</button>}
          <div style={{ flex: 1 }} />
          <button type="button" className="btn ghost sm" onClick={() => onClose()}>Cancel</button>
          <button type="submit" className="btn accent sm" disabled={busy}>{busy ? 'Saving…' : (isEdit ? 'Save' : 'Create')}</button>
        </div>
      </form>
    </ModalShell>
  );
}

// ─── New-guide dialog: subject picker + file importer + settings ───────────

function NewGuideDialog({ defaultSubjectId, onClose }) {
  const subjects = useSubjects();
  const [subjectId, setSubjectId] = React.useState(defaultSubjectId || (subjects[0]?.id || ''));
  const [showAddSubject, setShowAddSubject] = React.useState(false);
  const [entries, setEntries] = React.useState([]);
  const [dragOver, setDragOver] = React.useState(false);
  const [notes, setNotes] = React.useState('');
  const [mode, setMode] = React.useState('full');
  const inputRef = React.useRef(null);
  const idCounter = React.useRef(0);

  // Auto-select on first load if a subject became available
  React.useEffect(() => {
    if (!subjectId && subjects.length > 0) setSubjectId(subjects[0].id);
  }, [subjects.length]);

  const addFiles = async (filesList) => {
    const files = Array.from(filesList);
    const fresh = files.map((file) => ({
      id: ++idCounter.current,
      file,
      name: file.name,
      kind: fileKindFromExt(file.name.split('.').pop()),
      size: formatBytes(file.size),
      status: 'parsing',
      parsed: null,
      error: null,
    }));
    setEntries((prev) => [...prev, ...fresh]);

    for (const e of fresh) {
      try {
        const parsed = await parseFile(e.file);
        setEntries((prev) => prev.map((x) => x.id === e.id ? { ...x, status: 'done', parsed } : x));
      } catch (err) {
        setEntries((prev) => prev.map((x) => x.id === e.id ? { ...x, status: 'error', error: err.message } : x));
      }
    }
  };

  const onDrop = (ev) => {
    ev.preventDefault();
    setDragOver(false);
    if (ev.dataTransfer?.files?.length) addFiles(ev.dataTransfer.files);
  };

  const remove = (id) => setEntries((prev) => prev.filter((e) => e.id !== id));

  const okFiles = entries.filter((e) => e.status === 'done');
  const parsing = entries.some((e) => e.status === 'parsing');
  const totalChars = okFiles.reduce((n, e) => n + (e.parsed?.text?.length || 0), 0);
  const canGenerate = subjectId && okFiles.length > 0 && !parsing;

  const startGenerate = async () => {
    const parsed = okFiles.map((e) => e.parsed);
    stageGeneration(subjectId, { files: parsed, notes, mode });

    // Persist parsed files to the subject's document library so the Files
    // section stays comprehensive. Awaited so the row is in the cache before
    // we navigate away.
    const docRows = okFiles.map((e) => ({
      name: e.name,
      ext: (e.name.split('.').pop() || '').toLowerCase(),
      kind: e.parsed?.kind === 'image' ? 'image' : 'text',
      text_content: e.parsed?.kind === 'text' ? e.parsed.text : null,
      bytes: e.file?.size ?? null,
    }));
    try { await addDocumentsBulk(docRows, { subjectId }); }
    catch (err) { console.warn('document save failed', err); }

    const newId = newGuideId();
    onClose();
    navigate(`/subject/${subjectId}/guide/${newId}/generate`);
  };

  return (
    <ModalShell width={620} onClose={onClose}>
      <ModalHead title="New guide" sub="Pick a subject, drop your sources, and generate." onClose={onClose} />

      <label className="field" style={{ marginBottom: 0 }}>
        <span>Subject</span>
        <div style={{ display: 'flex', gap: 8 }}>
          <select className="input" value={subjectId} onChange={(e) => setSubjectId(e.target.value)} style={{ flex: 1 }}>
            {subjects.length === 0 && <option value="">— no subjects yet —</option>}
            {subjects.map((s) => <option key={s.id} value={s.id}>{s.emoji} {s.name}</option>)}
          </select>
          <button type="button" className="btn sm" onClick={() => setShowAddSubject(true)}>＋ New</button>
        </div>
      </label>

      <label className="field" style={{ marginBottom: 0 }}>
        <span>Sources</span>
        <div
          onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
          onDragLeave={() => setDragOver(false)}
          onDrop={onDrop}
          onClick={() => inputRef.current?.click()}
          style={{
            border: '1.5px dashed ' + (dragOver ? 'var(--accent)' : 'var(--hairline-bold)'),
            borderRadius: 'var(--r-md)',
            padding: '20px 16px',
            textAlign: 'center',
            background: dragOver ? 'var(--accent-faint)' : 'var(--page-tint)',
            cursor: 'pointer',
            transition: 'all 0.12s',
          }}
        >
          <input ref={inputRef} type="file" multiple
            accept=".pdf,.docx,.txt,.md,.png,.jpg,.jpeg,.webp,.gif"
            style={{ display: 'none' }}
            onChange={(e) => { if (e.target.files?.length) addFiles(e.target.files); e.target.value = ''; }}
          />
          <div style={{ fontFamily: 'var(--serif)', fontSize: 17, color: 'var(--ink-strong)', fontWeight: 500 }}>
            {dragOver ? 'Release to add' : 'Drop files here, or click to browse.'}
          </div>
          <div style={{ fontSize: 11, color: 'var(--ink-mute)', marginTop: 4, fontFamily: 'var(--mono)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
            PDF · DOCX · TXT · MD · PNG · JPG
          </div>
        </div>
      </label>

      {entries.length > 0 && (
        <div style={{ maxHeight: 180, overflowY: 'auto', border: '1px solid var(--hairline)', borderRadius: 'var(--r-sm)', background: 'var(--page-tint)' }}>
          {entries.map((e) => <UploadRow key={e.id} entry={e} onRemove={() => remove(e.id)} />)}
        </div>
      )}

      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
        <label className="field" style={{ marginBottom: 0 }}>
          <span>Output</span>
          <div style={{ display: 'flex', gap: 6 }}>
            <ModeBtn active={mode === 'full'}        onClick={() => setMode('full')}        title="Study guide" sub="sections + cards + quiz" />
            <ModeBtn active={mode === 'flashcards'} onClick={() => setMode('flashcards')}   title="Flashcards"   sub="every term" />
          </div>
        </label>

        <label className="field" style={{ marginBottom: 0 }}>
          <span>Instructions for the AI (optional)</span>
          <textarea className="input" rows={3} value={notes} onChange={(e) => setNotes(e.target.value)}
            placeholder="e.g. focus on chapters 3–5; explain like I'm new to the field" style={{ minHeight: 60 }} />
        </label>
      </div>

      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 11.5, color: 'var(--ink-mute)', fontFamily: 'var(--mono)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
        <span>{okFiles.length} of {entries.length || 0} ready · {formatChars(totalChars)}</span>
        <span>gpt-4o-mini · vision</span>
      </div>

      <div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
        <button type="button" className="btn ghost sm" onClick={onClose}>Cancel</button>
        <div style={{ flex: 1 }} />
        <button type="button" className="btn accent" disabled={!canGenerate} onClick={startGenerate}
          style={{ height: 36, padding: '0 18px', opacity: canGenerate ? 1 : 0.5, cursor: canGenerate ? 'pointer' : 'not-allowed' }}>
          ✦ {mode === 'flashcards' ? 'Generate flashcards' : 'Generate guide'}
        </button>
      </div>

      {showAddSubject && (
        <SubjectDialog onClose={(opts) => {
          setShowAddSubject(false);
          if (opts?.created) setSubjectId(opts.created.id);
        }} />
      )}
    </ModalShell>
  );
}

// ─── Upload-files dialog: just save to subject's library, no generation ────

function UploadFilesDialog({ defaultSubjectId, onClose }) {
  const subjects = useSubjects();
  const [subjectId, setSubjectId] = React.useState(defaultSubjectId || (subjects[0]?.id || ''));
  const [showAddSubject, setShowAddSubject] = React.useState(false);
  const [entries, setEntries] = React.useState([]);
  const [dragOver, setDragOver] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const inputRef = React.useRef(null);
  const idCounter = React.useRef(0);

  React.useEffect(() => {
    if (!subjectId && subjects.length > 0) setSubjectId(subjects[0].id);
  }, [subjects.length]);

  const addFiles = async (filesList) => {
    const files = Array.from(filesList);
    const fresh = files.map((file) => ({
      id: ++idCounter.current,
      file,
      name: file.name,
      ext: (file.name.split('.').pop() || '').toLowerCase(),
      kind: fileKindFromExt(file.name.split('.').pop()),
      size: formatBytes(file.size),
      bytes: file.size,
      status: 'parsing',
      parsed: null,
      error: null,
    }));
    setEntries((prev) => [...prev, ...fresh]);

    for (const e of fresh) {
      try {
        const parsed = await parseFile(e.file);
        setEntries((prev) => prev.map((x) => x.id === e.id ? { ...x, status: 'done', parsed } : x));
      } catch (err) {
        setEntries((prev) => prev.map((x) => x.id === e.id ? { ...x, status: 'error', error: err.message } : x));
      }
    }
  };

  const onDrop = (ev) => {
    ev.preventDefault();
    setDragOver(false);
    if (ev.dataTransfer?.files?.length) addFiles(ev.dataTransfer.files);
  };

  const remove = (id) => setEntries((prev) => prev.filter((e) => e.id !== id));

  const okFiles = entries.filter((e) => e.status === 'done');
  const parsing = entries.some((e) => e.status === 'parsing');
  const canSave = subjectId && okFiles.length > 0 && !parsing && !busy;

  const save = async () => {
    setBusy(true); setErr(null);
    try {
      const rows = okFiles.map((e) => ({
        name: e.name,
        ext: e.ext,
        kind: e.parsed?.kind === 'image' ? 'image' : 'text',
        text_content: e.parsed?.kind === 'text' ? e.parsed.text : null,
        bytes: e.bytes,
      }));
      await addDocumentsBulk(rows, { subjectId });
      onClose();
    } catch (e) {
      setErr(e.message);
      setBusy(false);
    }
  };

  return (
    <ModalShell width={580} onClose={onClose}>
      <ModalHead title="Upload files" sub="Add sources to a subject's library — without generating a guide." onClose={onClose} />

      <label className="field" style={{ marginBottom: 0 }}>
        <span>Subject</span>
        <div style={{ display: 'flex', gap: 8 }}>
          <select className="input" value={subjectId} onChange={(e) => setSubjectId(e.target.value)} style={{ flex: 1 }}>
            {subjects.length === 0 && <option value="">— no subjects yet —</option>}
            {subjects.map((s) => <option key={s.id} value={s.id}>{s.emoji} {s.name}</option>)}
          </select>
          <button type="button" className="btn sm" onClick={() => setShowAddSubject(true)}>＋ New</button>
        </div>
      </label>

      <label className="field" style={{ marginBottom: 0 }}>
        <span>Files</span>
        <div
          onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
          onDragLeave={() => setDragOver(false)}
          onDrop={onDrop}
          onClick={() => inputRef.current?.click()}
          style={{
            border: '1.5px dashed ' + (dragOver ? 'var(--accent)' : 'var(--hairline-bold)'),
            borderRadius: 'var(--r-md)',
            padding: '20px 16px',
            textAlign: 'center',
            background: dragOver ? 'var(--accent-faint)' : 'var(--page-tint)',
            cursor: 'pointer',
            transition: 'all 0.12s',
          }}
        >
          <input ref={inputRef} type="file" multiple
            accept=".pdf,.docx,.txt,.md,.png,.jpg,.jpeg,.webp,.gif"
            style={{ display: 'none' }}
            onChange={(e) => { if (e.target.files?.length) addFiles(e.target.files); e.target.value = ''; }}
          />
          <div style={{ fontFamily: 'var(--serif)', fontSize: 17, color: 'var(--ink-strong)', fontWeight: 500 }}>
            {dragOver ? 'Release to add' : 'Drop files here, or click to browse.'}
          </div>
          <div style={{ fontSize: 11, color: 'var(--ink-mute)', marginTop: 4, fontFamily: 'var(--mono)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
            PDF · DOCX · TXT · MD · PNG · JPG
          </div>
        </div>
      </label>

      {entries.length > 0 && (
        <div style={{ maxHeight: 220, overflowY: 'auto', border: '1px solid var(--hairline)', borderRadius: 'var(--r-sm)', background: 'var(--page-tint)' }}>
          {entries.map((e) => <UploadRow key={e.id} entry={e} onRemove={() => remove(e.id)} />)}
        </div>
      )}

      {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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 11.5, color: 'var(--ink-mute)', fontFamily: 'var(--mono)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
        <span>{okFiles.length} of {entries.length || 0} ready</span>
        <span>image bytes not stored</span>
      </div>

      <div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
        <button type="button" className="btn ghost sm" onClick={onClose}>Cancel</button>
        <div style={{ flex: 1 }} />
        <button type="button" className="btn accent" disabled={!canSave} onClick={save}
          style={{ height: 36, padding: '0 18px', opacity: canSave ? 1 : 0.5, cursor: canSave ? 'pointer' : 'not-allowed' }}>
          {busy ? 'Saving…' : `↑ Save ${okFiles.length || ''} file${okFiles.length === 1 ? '' : 's'}`.trim()}
        </button>
      </div>

      {showAddSubject && (
        <SubjectDialog onClose={(opts) => {
          setShowAddSubject(false);
          if (opts?.created) setSubjectId(opts.created.id);
        }} />
      )}
    </ModalShell>
  );
}

// ─── Event dialog (create/view) ────────────────────────────────────────────

function EventDialog({ editing, subjects, onClose }) {
  const initial = (editing.mode === 'view' || editing.mode === 'edit') ? editing.event : null;
  const [mode, setMode] = React.useState(editing.mode);  // 'create' | 'view' | 'edit' — local so view → edit can flip in-place
  const [title, setTitle] = React.useState(initial?.title || '');
  const [kind, setKind] = React.useState(initial?.kind || 'study');
  const [date, setDate] = React.useState(initial?.date || editing.date || '');
  const [subjectId, setSubjectId] = React.useState(initial?.subject_id || '');
  const [notes, setNotes] = React.useState(initial?.notes || '');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const isView   = mode === 'view';
  const isEdit   = mode === 'edit';
  const isCreate = mode === 'create';

  const submit = async (e) => {
    e.preventDefault();
    setBusy(true); setErr(null);
    try {
      if (isEdit) {
        await updateEvent(initial.id, { title, kind, date, subjectId: subjectId || null, notes });
        setMode('view');                   // back to read-only view; user can re-enter edit via the pencil
      } else {
        await addEvent({ title, kind, date, subjectId: subjectId || null, notes });
        onClose();
      }
    } catch (e2) {
      setErr(e2.message);
    } finally { setBusy(false); }
  };

  const remove = async () => {
    if (!confirm(`Delete "${initial.title}"?`)) return;
    setBusy(true);
    try { await deleteEvent(initial.id); onClose(); }
    catch (e) { setErr(e.message); setBusy(false); }
  };

  // When user cancels an edit: if there's an initial (came from view), restore
  // its values and flip back to view. If creating fresh, just close.
  const cancelEdit = () => {
    if (!initial) { onClose(); return; }
    setTitle(initial.title || '');
    setKind(initial.kind || 'study');
    setDate(initial.date || '');
    setSubjectId(initial.subject_id || '');
    setNotes(initial.notes || '');
    setErr(null);
    setMode('view');
  };

  return (
    <ModalShell onClose={onClose}>
      <form onSubmit={isView ? (e) => e.preventDefault() : submit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        <ModalHead title={isView ? 'Event' : isEdit ? 'Edit event' : 'New event'} onClose={onClose} />

        <label className="field">
          <span>Title</span>
          <input className="input" type="text" value={title} onChange={(e) => setTitle(e.target.value)} disabled={isView} required />
        </label>
        <label className="field">
          <span>Date</span>
          <input className="input" type="date" value={date} onChange={(e) => setDate(e.target.value)} disabled={isView} required />
        </label>
        <label className="field">
          <span>Kind</span>
          <div style={{ display: 'flex', gap: 6 }}>
            {KINDS.map((k) => (
              <button key={k.value} type="button" disabled={isView} onClick={() => setKind(k.value)}
                style={{
                  flex: 1, padding: '7px 10px',
                  border: '1.5px solid ' + (kind === k.value ? 'var(--accent)' : 'var(--hairline-bold)'),
                  background: kind === k.value ? 'var(--accent-faint)' : 'var(--page)',
                  color: kind === k.value ? 'var(--accent)' : 'var(--ink-soft)',
                  borderRadius: 'var(--r-sm)', fontSize: 12, fontWeight: 500, cursor: isView ? 'default' : 'pointer',
                  fontFamily: 'var(--sans)',
                }}>{k.label}</button>
            ))}
          </div>
        </label>
        <label className="field">
          <span>Subject (optional)</span>
          <select className="input" value={subjectId} onChange={(e) => setSubjectId(e.target.value)} disabled={isView}>
            <option value="">— none —</option>
            {subjects.map((s) => <option key={s.id} value={s.id}>{s.emoji} {s.name}</option>)}
          </select>
        </label>
        <label className="field">
          <span>Notes (optional)</span>
          <textarea className="input" rows={3} value={notes || ''} onChange={(e) => setNotes(e.target.value)} disabled={isView} />
        </label>

        {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 style={{ display: 'flex', gap: 8, marginTop: 4 }}>
          {isView && (
            <>
              <button type="button" className="btn danger sm" onClick={remove} disabled={busy}>Delete</button>
              <div style={{ flex: 1 }} />
              <button type="button" className="btn sm" onClick={() => setMode('edit')} disabled={busy}>✎ Edit</button>
              <button type="button" className="btn ghost sm" onClick={onClose}>Close</button>
            </>
          )}
          {isEdit && (
            <>
              <button type="button" className="btn ghost sm" onClick={cancelEdit} disabled={busy}>Cancel</button>
              <div style={{ flex: 1 }} />
              <button type="submit" className="btn accent sm" disabled={busy}>{busy ? 'Saving…' : 'Save changes'}</button>
            </>
          )}
          {isCreate && (
            <>
              <button type="button" className="btn ghost sm" onClick={onClose}>Cancel</button>
              <div style={{ flex: 1 }} />
              <button type="submit" className="btn accent sm" disabled={busy}>{busy ? 'Saving…' : 'Save event'}</button>
            </>
          )}
        </div>
      </form>
    </ModalShell>
  );
}

// ─── Import dialog (.ics / .csv / .json / URL) ─────────────────────────────

// AI extractor for messy calendar paste (Google Sheets CSV grids, free text, etc.)
// Calls the Worker → OpenAI structured-output → returns event rows compatible
// with addEventsBulk. Results are cached in localStorage by content hash so
// repeated imports of the same URL/text don't re-spend tokens.

function _aiCacheKey(text) {
  // djb2-ish — tiny, deterministic, no async crypto needed
  let h = 5381;
  for (let i = 0; i < text.length; i++) h = ((h * 33) ^ text.charCodeAt(i)) >>> 0;
  return 'sb-ai-cal-' + h.toString(36);
}

function _aiCacheGet(text) {
  try {
    const raw = localStorage.getItem(_aiCacheKey(text));
    if (!raw) return null;
    const obj = JSON.parse(raw);
    if (obj && Array.isArray(obj.rows)) return obj;
  } catch {}
  return null;
}

function _aiCacheSet(text, rows) {
  try { localStorage.setItem(_aiCacheKey(text), JSON.stringify({ rows, t: new Date().toISOString() })); } catch {}
}

// Returns { rows, fromCache, cachedAt }
// Optional `notes` are appended to the system prompt as user-supplied instructions
// (e.g. "ignore Spring Break", "all events are kind=exam", etc.).
// The notes are also folded into the cache key so different notes produce
// different cached results.
async function aiExtractEvents(text, { force = false, notes = '' } = {}) {
  const today = todayISO();
  const trimmedNotes = (notes || '').trim();
  const sysPrompt =
    `You extract calendar events from messy text (often a Google Sheets CSV calendar with weeks across columns and events stacked under each date). Return only events with EXPLICIT dates.\n\n` +
    `Each event needs:\n` +
    `- title: short and descriptive (e.g. "Geometry Unit Test", "Hope Lingers On Summative")\n` +
    `- date: YYYY-MM-DD (today is ${today}; if the year is missing, infer the most reasonable school-year context)\n` +
    `- kind: one of "exam" | "assignment" | "study" | "other"\n` +
    `   exam: test, quiz, midterm, final, exam, summative\n` +
    `   assignment: HW, homework, due, essay, project, paper, presentation, lab\n` +
    `   study: study, review, reading, practice\n` +
    `   other: events, ceremonies, meetings, anything else\n` +
    `- notes: a short context line if useful, otherwise null\n\n` +
    `Skip non-events: schedule labels like "A Day"/"B Day", words like "Chill", empty cells, "FALSE", header rows, weekday names. If a single row has multiple events for one date (separated by line breaks or in adjacent cells), emit each as its own event. Return an empty array if nothing usable.` +
    (trimmedNotes
      ? `\n\nADDITIONAL USER INSTRUCTIONS — these override the defaults above when they conflict, follow them carefully:\n${trimmedNotes}`
      : '');

  const schema = {
    type: 'object',
    additionalProperties: false,
    required: ['events'],
    properties: {
      events: {
        type: 'array',
        items: {
          type: 'object',
          additionalProperties: false,
          required: ['title', 'date', 'kind', 'notes'],
          properties: {
            title: { type: 'string' },
            date: { type: 'string' },
            kind: { type: 'string', enum: ['exam', 'assignment', 'study', 'other'] },
            notes: { type: ['string', 'null'] },
          },
        },
      },
    },
  };

  // Truncate to keep token cost bounded
  const input = text.length > 60000 ? text.slice(0, 60000) + '\n[...truncated]' : text;

  // Cache key folds in notes — different notes = different result
  const cacheKey = trimmedNotes ? `${input}\n###NOTES###\n${trimmedNotes}` : input;

  const payload = {
    model: 'gpt-4o-mini',
    messages: [
      { role: 'system', content: sysPrompt },
      { role: 'user', content: input },
    ],
    response_format: {
      type: 'json_schema',
      json_schema: { name: 'events', strict: true, schema },
    },
  };

  // Cache hit: skip API call entirely
  if (!force) {
    const hit = _aiCacheGet(cacheKey);
    if (hit) return { rows: hit.rows, fromCache: true, cachedAt: hit.t };
  }

  const res = await window.generate(payload, 'extract');
  const content = res?.choices?.[0]?.message?.content;
  if (!content) throw new Error('Empty response from model');
  let parsed;
  try { parsed = JSON.parse(content); }
  catch (e) { throw new Error('Model returned invalid JSON: ' + e.message); }

  const rows = (parsed.events || [])
    .filter((e) => e && e.date && /^\d{4}-\d{2}-\d{2}$/.test(e.date) && e.title && e.title.trim())
    .map((e) => ({
      title: e.title.trim(),
      date: e.date,
      kind: ['exam', 'assignment', 'study', 'other'].includes(e.kind) ? e.kind : 'other',
      notes: (e.notes && String(e.notes).trim()) || null,
    }));

  _aiCacheSet(cacheKey, rows);
  return { rows, fromCache: false, cachedAt: new Date().toISOString() };
}

// Exposed so sources.js can re-use the same cached extractor.
if (typeof window !== 'undefined') window.aiExtractEvents = aiExtractEvents;

function ImportDialog({ subjects, onClose }) {
  const [tab, setTab] = React.useState('file');
  const [url, setUrl] = React.useState('');
  const [aiText, setAiText] = React.useState('');
  const [aiNotes, setAiNotes] = React.useState('');
  const [aiSmartUrl, setAiSmartUrl] = React.useState(true);
  const [lastAIInput, setLastAIInput] = React.useState(null); // remembers source text so "Re-extract" can force-fresh
  const [lastAINotes, setLastAINotes] = React.useState('');
  const [cacheInfo, setCacheInfo] = React.useState(null);
  const aiFileRef = React.useRef(null);
  const [pending, setPending] = React.useState(null);
  const [sourceLabel, setSourceLabel] = React.useState('');
  const [subjectId, setSubjectId] = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const fileRef = React.useRef(null);

  const handleFile = async (file) => {
    if (!file) return;
    setBusy(true); setErr(null); setPending(null);
    try {
      const text = await file.text();
      const ext = (file.name.split('.').pop() || '').toLowerCase();
      const rows = parseImport(text, ext);
      if (rows.length === 0) throw new Error('No events found in file');
      setPending(rows);
      setSourceLabel(file.name);
    } catch (e) {
      setErr(e.message);
    } finally { setBusy(false); }
  };

  const handleURL = async (opts = {}) => {
    setBusy(true); setErr(null); setPending(null); setCacheInfo(null);
    try {
      if (aiSmartUrl) {
        // Fetch raw text via Worker proxy, then send to AI extractor (cached).
        let normalized = url.trim().replace(/^webcal:\/\//i, 'https://');
        if (!/^https?:\/\//i.test(normalized)) throw new Error('URL must start with http(s)://');
        const { data: { session } } = await window.sb.auth.getSession();
        if (!session) throw new Error('Not signed in');
        const proxyUrl = `${window.API_BASE}/api/proxy?url=${encodeURIComponent(normalized)}`;
        const res = await fetch(proxyUrl, {
          headers: { Authorization: `Bearer ${session.access_token}` },
        });
        const text = await res.text();
        if (!res.ok) {
          let detail = text;
          try { detail = JSON.parse(text).error || detail; } catch {}
          throw new Error(`Fetch failed: ${detail}`);
        }
        const result = await aiExtractEvents(text, { force: !!opts.force, notes: aiNotes });
        if (result.rows.length === 0) throw new Error('AI found no events with explicit dates at this URL.');
        setPending(result.rows);
        setLastAIInput(text);
        setLastAINotes(aiNotes);
        setCacheInfo({ fromCache: result.fromCache, cachedAt: result.cachedAt, source: 'url' });
        setSourceLabel(`${url} · AI`);
      } else {
        const rows = await importFromURL(url);
        if (rows.length === 0) throw new Error('No events found at URL');
        setPending(rows);
        setSourceLabel(url);
      }
    } catch (e) {
      setErr(e.message);
    } finally { setBusy(false); }
  };

  const handleAI = async (text, label, opts = {}) => {
    const input = (text || aiText || '').trim();
    if (!input) { setErr('Paste something first.'); return; }
    setBusy(true); setErr(null); setPending(null); setCacheInfo(null);
    try {
      const result = await aiExtractEvents(input, { force: !!opts.force, notes: aiNotes });
      if (result.rows.length === 0) throw new Error('Model found no events with explicit dates.');
      setPending(result.rows);
      setLastAIInput(input);
      setLastAINotes(aiNotes);
      setCacheInfo({ fromCache: result.fromCache, cachedAt: result.cachedAt, source: 'paste' });
      setSourceLabel(label || `AI · ${input.length} chars`);
    } catch (e) {
      setErr(e.message);
    } finally { setBusy(false); }
  };

  const reExtract = async () => {
    if (!lastAIInput) return;
    setBusy(true); setErr(null);
    try {
      const result = await aiExtractEvents(lastAIInput, { force: true, notes: lastAINotes });
      if (result.rows.length === 0) throw new Error('AI found no events on re-extract.');
      setPending(result.rows);
      setCacheInfo({ fromCache: false, cachedAt: result.cachedAt, source: cacheInfo?.source });
    } catch (e) {
      setErr(e.message);
    } finally { setBusy(false); }
  };

  const handleAIFile = async (file) => {
    if (!file) return;
    try {
      const text = await file.text();
      setAiText(text);
      await handleAI(text, file.name);
    } catch (e) {
      setErr(e.message);
    }
  };

  const doImport = async () => {
    setBusy(true); setErr(null);
    try {
      await addEventsBulk(pending, { subjectId: subjectId || null });
      onClose();
    } catch (e) { setErr(e.message); setBusy(false); }
  };

  const reset = () => { setPending(null); setSourceLabel(''); setErr(null); setCacheInfo(null); setLastAIInput(null); };

  return (
    <ModalShell width={500} onClose={onClose}>
      <ModalHead title="Import events" sub="Clean .ics/.csv/.json file, calendar URL, or messy paste handled by AI." onClose={onClose} />

      {!pending && (
        <>
          <div style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--hairline)', marginBottom: 14 }}>
            <TabBtn active={tab === 'file'} onClick={() => setTab('file')}>File</TabBtn>
            <TabBtn active={tab === 'url'} onClick={() => setTab('url')}>URL</TabBtn>
            <TabBtn active={tab === 'ai'} onClick={() => setTab('ai')}>AI smart</TabBtn>
            <TabBtn active={tab === 'sources'} onClick={() => setTab('sources')}>Sources</TabBtn>
          </div>

          {tab === 'file' && (
            <div>
              <div onClick={() => fileRef.current?.click()}
                onDragOver={(e) => e.preventDefault()}
                onDrop={(e) => { e.preventDefault(); handleFile(e.dataTransfer.files?.[0]); }}
                style={{ border: '1.5px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)', padding: '24px 16px', textAlign: 'center', cursor: 'pointer', background: 'var(--page-tint)' }}>
                <div style={{ fontFamily: 'var(--serif)', fontSize: 18, color: 'var(--ink-strong)', fontWeight: 500 }}>
                  {busy ? 'Parsing…' : 'Click or drop a file'}
                </div>
                <div style={{ fontSize: 11.5, color: 'var(--ink-mute)', marginTop: 6, fontFamily: 'var(--mono)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
                  .ics · .csv (title,date,kind,notes) · .json
                </div>
              </div>
              <input ref={fileRef} type="file"
                accept=".ics,.ical,.csv,.json,text/calendar,text/csv,application/json"
                style={{ display: 'none' }}
                onChange={(e) => handleFile(e.target.files?.[0])}
              />
            </div>
          )}

          {tab === 'url' && (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
              <input className="input" type="url"
                placeholder="https://docs.google.com/spreadsheets/.../export?format=csv  ·  or .ics feed"
                value={url} onChange={(e) => setUrl(e.target.value)}
                onKeyDown={(e) => { if (e.key === 'Enter' && url.trim()) handleURL(); }}
              />
              <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--ink-soft)', cursor: 'pointer' }}>
                <input type="checkbox" checked={aiSmartUrl} onChange={(e) => setAiSmartUrl(e.target.checked)} />
                <span>✦ Smart-extract with AI</span>
                <span style={{ color: 'var(--ink-mute)', fontSize: 11.5 }}>
                  — handles messy Google Sheets · result cached so re-fetching the same content costs nothing
                </span>
              </label>
              {aiSmartUrl && (
                <label className="field" style={{ marginBottom: 0 }}>
                  <span>Notes for the AI (optional)</span>
                  <textarea className="input" rows={2}
                    placeholder='e.g. "skip Spring Break entries", "treat all Cakes and Candles as kind=other"'
                    value={aiNotes} onChange={(e) => setAiNotes(e.target.value)}
                    style={{ minHeight: 48 }}
                  />
                </label>
              )}
              <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
                <button type="button" className="btn sm" onClick={() => handleURL()} disabled={busy || !url.trim()}>
                  {busy ? (aiSmartUrl ? 'Fetching + parsing…' : 'Fetching…') : (aiSmartUrl ? '✦ Fetch & extract' : 'Fetch & preview')}
                </button>
                <span style={{ fontSize: 11.5, color: 'var(--ink-mute)' }}>
                  {aiSmartUrl ? 'Public CSV / TSV / .ics — anything readable' : 'Public .ics feeds (Google, Outlook, etc.)'}
                </span>
              </div>
            </div>
          )}

          {tab === 'ai' && (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
              <textarea className="input" rows={7}
                placeholder={'Paste a messy spreadsheet, calendar export, or note. The model extracts every dated event.\n\nExample: a Google Sheet exported as CSV with weeks across columns and events stacked under each date.'}
                value={aiText} onChange={(e) => setAiText(e.target.value)}
                style={{ minHeight: 110, fontFamily: 'var(--mono)', fontSize: 12 }}
              />
              <label className="field" style={{ marginBottom: 0 }}>
                <span>Notes for the AI (optional)</span>
                <textarea className="input" rows={2}
                  placeholder='e.g. "skip Spring Break entries", "treat all Cakes and Candles as kind=other", "the school year is 2025–2026"'
                  value={aiNotes} onChange={(e) => setAiNotes(e.target.value)}
                  style={{ minHeight: 48 }}
                />
              </label>
              <input ref={aiFileRef} type="file" accept=".csv,.txt,.tsv,.md,text/csv,text/plain,text/tab-separated-values"
                style={{ display: 'none' }}
                onChange={(e) => { handleAIFile(e.target.files?.[0]); e.target.value = ''; }}
              />
              <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
                <button type="button" className="btn accent sm" onClick={() => handleAI()} disabled={busy || !aiText.trim()}>
                  {busy ? 'Reading…' : '✦ Extract events'}
                </button>
                <button type="button" className="btn sm" onClick={() => aiFileRef.current?.click()} disabled={busy}>
                  ↑ Upload .csv / .txt
                </button>
                <span style={{ fontSize: 11.5, color: 'var(--ink-mute)' }}>
                  Uses gpt-4o-mini · ~5–15s
                </span>
              </div>
            </div>
          )}

          {tab === 'sources' && (
            <SourcesPanel subjects={subjects} onAfterRun={onClose} />
          )}
        </>
      )}

      {pending && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
          <div style={{ fontSize: 12.5, color: 'var(--ink-soft)', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
            <strong style={{ color: 'var(--ink-strong)' }}>{pending.length}</strong>
            <span>event{pending.length === 1 ? '' : 's'} found</span>
            {sourceLabel && <span style={{ color: 'var(--ink-mute)' }}>· {sourceLabel}</span>}
            {cacheInfo && (
              <span style={{
                marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 8,
                fontSize: 11, fontFamily: 'var(--mono)', textTransform: 'uppercase', letterSpacing: '0.06em',
                color: cacheInfo.fromCache ? 'var(--moss)' : 'var(--ink-mute)',
              }}>
                <span>{cacheInfo.fromCache ? '⚡ cached · no API call' : '✦ fresh extract'}</span>
                {lastAIInput && (
                  <button type="button" onClick={reExtract} disabled={busy}
                    style={{ border: '1px solid var(--hairline-bold)', background: 'var(--page)', borderRadius: 4, padding: '2px 8px', fontFamily: 'inherit', fontSize: 10.5, color: 'var(--ink-soft)', cursor: 'pointer', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
                    {busy ? '…' : 'Re-extract'}
                  </button>
                )}
              </span>
            )}
          </div>

          <label className="field" style={{ marginBottom: 0 }}>
            <span>Assign all to subject (optional)</span>
            <select className="input" value={subjectId} onChange={(e) => setSubjectId(e.target.value)}>
              <option value="">— none —</option>
              {subjects.map((s) => <option key={s.id} value={s.id}>{s.emoji} {s.name}</option>)}
            </select>
          </label>

          <div style={{ border: '1px solid var(--hairline)', borderRadius: 'var(--r-sm)', overflowY: 'auto', maxHeight: 240, background: 'var(--page-tint)' }}>
            {pending.slice(0, 50).map((r, i) => (
              <div key={i} style={{ padding: '6px 12px', borderBottom: '1px solid var(--hairline-soft)', display: 'flex', alignItems: 'center', gap: 10, fontSize: 12.5 }}>
                <span className={"dot " + r.kind} />
                <span className="mono" style={{ fontSize: 11, color: 'var(--ink-mute)', width: 80 }}>{r.date}</span>
                <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
                <span className="mono" style={{ fontSize: 10, color: 'var(--ink-mute)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{r.kind}</span>
              </div>
            ))}
            {pending.length > 50 && (
              <div style={{ padding: '6px 12px', fontSize: 11, color: 'var(--ink-mute)', textAlign: 'center' }}>
                +{pending.length - 50} more…
              </div>
            )}
          </div>
        </div>
      )}

      {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 style={{ display: 'flex', gap: 8 }}>
        {pending ? (
          <>
            <button type="button" className="btn ghost sm" onClick={reset} disabled={busy}>Back</button>
            <div style={{ flex: 1 }} />
            <button type="button" className="btn sm" onClick={onClose} disabled={busy}>Cancel</button>
            <button type="button" className="btn accent sm" onClick={doImport} disabled={busy}>
              {busy ? 'Importing…' : `Import ${pending.length} event${pending.length === 1 ? '' : 's'}`}
            </button>
          </>
        ) : (
          <>
            <div style={{ flex: 1 }} />
            <button type="button" className="btn sm" onClick={onClose}>Cancel</button>
          </>
        )}
      </div>
    </ModalShell>
  );
}

// ─── Saved calendar sources (auto-fetch on schedule) ──────────────────────

function SourcesPanel({ subjects, onAfterRun }) {
  const sources = useCalendarSources();
  const [editing, setEditing] = React.useState(null); // null | 'new' | source object
  const [running, setRunning] = React.useState(null); // source.id while running
  const [err, setErr] = React.useState(null);

  const runNow = async (s) => {
    setRunning(s.id); setErr(null);
    try {
      const r = await runSource(s);
      await updateCalendarSource(s.id, {
        last_run_at: new Date().toISOString(),
        last_status: `ok · ${r.added} new (of ${r.total}${r.fromCache ? ' · cached' : ''})`,
        last_event_count: r.added,
      });
      if (r.added > 0 && onAfterRun) onAfterRun();
    } catch (e) {
      setErr(`${s.name}: ${e.message}`);
      try {
        await updateCalendarSource(s.id, {
          last_run_at: new Date().toISOString(),
          last_status: 'error: ' + (e.message || String(e)).slice(0, 200),
        });
      } catch {}
    } finally {
      setRunning(null);
    }
  };

  const remove = async (s) => {
    if (!confirm(`Delete source "${s.name}"? Events already imported stay; nothing future will auto-fetch.`)) return;
    try { await deleteCalendarSource(s.id); }
    catch (e) { setErr(e.message); }
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
      <div style={{ fontSize: 12.5, color: 'var(--ink-soft)', lineHeight: 1.55 }}>
        Save a calendar URL with a schedule. On every app open, due sources fetch + AI-extract + dedupe + import — only spending tokens when the source content actually changed (cached by content hash).
      </div>

      {sources.length === 0 && editing !== 'new' && (
        <div style={{ padding: '24px 16px', textAlign: 'center', border: '1px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)' }}>
          <div style={{ fontFamily: 'var(--serif)', fontSize: 17, color: 'var(--ink-strong)', fontWeight: 500 }}>No saved sources yet</div>
          <div style={{ fontSize: 12.5, color: 'var(--ink-mute)', margin: '6px 0 12px' }}>Add a Google Sheet CSV or .ics URL and a schedule.</div>
          <button type="button" className="btn accent sm" onClick={() => setEditing('new')}>＋ Add source</button>
        </div>
      )}

      {sources.length > 0 && editing !== 'new' && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          {sources.map((s) => (
            <SourceRow key={s.id}
              source={s}
              subjects={subjects}
              running={running === s.id}
              onRun={() => runNow(s)}
              onEdit={() => setEditing(s)}
              onDelete={() => remove(s)}
            />
          ))}
          <button type="button" className="btn sm" style={{ alignSelf: 'flex-start', marginTop: 4 }}
            onClick={() => setEditing('new')}>＋ Add source</button>
        </div>
      )}

      {(editing === 'new' || (editing && typeof editing === 'object')) && (
        <SourceForm
          existing={typeof editing === 'object' ? editing : null}
          subjects={subjects}
          onCancel={() => setEditing(null)}
          onSaved={() => setEditing(null)}
        />
      )}

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

function SourceRow({ source, subjects, running, onRun, onEdit, onDelete }) {
  const subj = subjects.find((s) => s.id === source.default_subject_id);
  const lastRun = source.last_run_at ? relativeTime(source.last_run_at) : 'never';
  const status = source.last_status || '—';
  const isError = /^error/i.test(status);
  return (
    <div style={{ border: '1px solid var(--hairline-bold)', borderRadius: 'var(--r-md)', padding: '12px 14px', background: 'var(--page)' }}>
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
        <div style={{ minWidth: 0, flex: 1 }}>
          <div style={{ fontFamily: 'var(--serif)', fontSize: 16, fontWeight: 500, color: 'var(--ink-strong)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{source.name}</div>
          <div style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--ink-mute)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginTop: 2 }}>{source.url}</div>
        </div>
        <div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
          <button type="button" className="btn accent sm" onClick={onRun} disabled={running}>{running ? 'Running…' : '▶ Run now'}</button>
          <button type="button" className="btn sm" onClick={onEdit}>Edit</button>
          <button type="button" className="btn ghost sm danger" onClick={onDelete} title="Delete source">×</button>
        </div>
      </div>
      <div style={{ display: 'flex', gap: 12, marginTop: 8, fontSize: 11.5, color: 'var(--ink-soft)', flexWrap: 'wrap' }}>
        <span><strong style={{ color: 'var(--ink-strong)' }}>Schedule:</strong> {describeSchedule(source.days_mask)}</span>
        {subj && <span>· {subj.emoji} {subj.name}</span>}
        <span>· last run {lastRun}</span>
        <span style={{ color: isError ? 'var(--brick)' : (source.last_status ? 'var(--moss)' : 'var(--ink-mute)') }}>· {status}</span>
      </div>
    </div>
  );
}

function SourceForm({ existing, subjects, onCancel, onSaved }) {
  const [name, setName] = React.useState(existing?.name || '');
  const [url, setUrl] = React.useState(existing?.url || '');
  const [scheduleMode, setScheduleMode] = React.useState(() => {
    const m = existing?.days_mask;
    if (m == null || m === DAYS_DAILY) return 'daily';
    if (m === DAYS_WEEKDAYS) return 'weekdays';
    return 'custom';
  });
  const [customDays, setCustomDays] = React.useState(() => {
    const m = existing?.days_mask ?? DAYS_DAILY;
    return Array.from({ length: 7 }, (_, i) => !!(m & (1 << i)));
  });
  const [subjectId, setSubjectId] = React.useState(existing?.default_subject_id || '');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const computeMask = () => {
    if (scheduleMode === 'daily') return DAYS_DAILY;
    if (scheduleMode === 'weekdays') return DAYS_WEEKDAYS;
    return customDays.reduce((m, on, i) => on ? (m | (1 << i)) : m, 0);
  };

  const submit = async (e) => {
    e.preventDefault();
    setBusy(true); setErr(null);
    try {
      const mask = computeMask();
      const payload = { name, url, days_mask: mask, default_subject_id: subjectId || null };
      if (existing) await updateCalendarSource(existing.id, payload);
      else          await addCalendarSource(payload);
      onSaved();
    } catch (e2) { setErr(e2.message); }
    finally { setBusy(false); }
  };

  return (
    <form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 14, border: '1px solid var(--hairline-bold)', borderRadius: 'var(--r-md)', background: 'var(--page-tint)' }}>
      <div style={{ fontFamily: 'var(--serif)', fontSize: 17, fontWeight: 500, color: 'var(--ink-strong)' }}>
        {existing ? 'Edit source' : 'New source'}
      </div>

      <label className="field" style={{ marginBottom: 0 }}>
        <span>Name</span>
        <input className="input" type="text" value={name} onChange={(e) => setName(e.target.value)}
          required autoFocus placeholder="e.g. Co29 Summative Calendar" />
      </label>

      <label className="field" style={{ marginBottom: 0 }}>
        <span>URL (CSV / ICS)</span>
        <input className="input" type="url" value={url} onChange={(e) => setUrl(e.target.value)}
          required placeholder="https://docs.google.com/.../export?format=csv" />
      </label>

      <div className="field" style={{ marginBottom: 0 }}>
        <span style={{ fontFamily: 'var(--mono)', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--ink-soft)' }}>Schedule</span>
        <div style={{ display: 'flex', gap: 6, marginTop: 5 }}>
          <ScheduleBtn active={scheduleMode === 'daily'}    onClick={() => setScheduleMode('daily')}>Daily</ScheduleBtn>
          <ScheduleBtn active={scheduleMode === 'weekdays'} onClick={() => setScheduleMode('weekdays')}>Weekdays</ScheduleBtn>
          <ScheduleBtn active={scheduleMode === 'custom'}   onClick={() => setScheduleMode('custom')}>Custom</ScheduleBtn>
        </div>
        {scheduleMode === 'custom' && (
          <div style={{ display: 'flex', gap: 4, marginTop: 8, flexWrap: 'wrap' }}>
            {DAY_NAMES.map((dn, i) => (
              <button key={dn} type="button"
                onClick={() => setCustomDays((d) => d.map((v, j) => j === i ? !v : v))}
                style={{
                  padding: '4px 10px', borderRadius: 999, fontSize: 12, cursor: 'pointer',
                  border: '1px solid ' + (customDays[i] ? 'var(--accent)' : 'var(--hairline-bold)'),
                  background: customDays[i] ? 'var(--accent-faint)' : 'var(--page)',
                  color: customDays[i] ? 'var(--accent)' : 'var(--ink-soft)',
                  fontWeight: customDays[i] ? 600 : 500,
                  fontFamily: 'var(--sans)',
                }}>{dn}</button>
            ))}
          </div>
        )}
      </div>

      <label className="field" style={{ marginBottom: 0 }}>
        <span>Default subject (optional)</span>
        <select className="input" value={subjectId} onChange={(e) => setSubjectId(e.target.value)}>
          <option value="">— none —</option>
          {subjects.map((s) => <option key={s.id} value={s.id}>{s.emoji} {s.name}</option>)}
        </select>
      </label>

      {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 style={{ display: 'flex', gap: 8 }}>
        <button type="button" className="btn ghost sm" onClick={onCancel} disabled={busy}>Cancel</button>
        <div style={{ flex: 1 }} />
        <button type="submit" className="btn accent sm" disabled={busy}>
          {busy ? 'Saving…' : (existing ? 'Save' : '＋ Add source')}
        </button>
      </div>
    </form>
  );
}

function ScheduleBtn({ active, onClick, children }) {
  return (
    <button type="button" onClick={onClick}
      style={{
        padding: '5px 12px', borderRadius: 999, fontSize: 12.5, cursor: 'pointer',
        border: '1px solid ' + (active ? 'var(--ink-strong)' : 'var(--hairline-bold)'),
        background: active ? 'var(--ink-strong)' : 'var(--page)',
        color: active ? 'white' : 'var(--ink)',
        fontWeight: 500, fontFamily: 'var(--sans)',
      }}>{children}</button>
  );
}

function TabBtn({ active, onClick, children }) {
  return (
    <button type="button" onClick={onClick}
      style={{
        padding: '8px 14px', border: 'none', background: 'transparent',
        fontSize: 13, fontWeight: 500, cursor: 'pointer',
        color: active ? 'var(--ink-strong)' : 'var(--ink-mute)',
        borderBottom: active ? '2px solid var(--ink-strong)' : '2px solid transparent',
        marginBottom: -1,
      }}
    >{children}</button>
  );
}

// ─── Settings ──────────────────────────────────────────────────────────────

function SettingsScreen() {
  const auth = useAuth();
  const profile = getProfile(auth.user);
  const [showLogoutConfirm, setShowLogoutConfirm] = React.useState(false);

  // Display-name section
  const [name, setName] = React.useState(profile.displayName || '');
  const [savingName, setSavingName] = React.useState(false);
  const [nameError, setNameError] = React.useState(null);
  const [nameSavedAt, setNameSavedAt] = React.useState(null);

  // Avatar section
  const [dragOver, setDragOver] = React.useState(false);
  const [uploading, setUploading] = React.useState(false);
  const [avatarError, setAvatarError] = React.useState(null);
  const fileRef = React.useRef(null);

  // If profile reloads from auth (e.g. another tab), keep field in sync only
  // when user hasn't edited it yet.
  React.useEffect(() => {
    setName((current) => current || profile.displayName || '');
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [profile.displayName]);

  const dirty = (name || '').trim() !== (profile.displayName || '').trim();

  const saveName = async (e) => {
    if (e) e.preventDefault();
    if (!dirty) return;
    setSavingName(true); setNameError(null);
    try {
      await updateDisplayName(name);
      setNameSavedAt(new Date());
    } catch (err) {
      setNameError(err.message || String(err));
    } finally {
      setSavingName(false);
    }
  };

  const pickFile = (file) => {
    if (!file) return;
    setAvatarError(null);
    setUploading(true);
    uploadAvatar(file)
      .catch((err) => setAvatarError(err.message || String(err)))
      .finally(() => setUploading(false));
  };

  const onDrop = (e) => {
    e.preventDefault();
    setDragOver(false);
    const file = e.dataTransfer?.files?.[0];
    if (file) pickFile(file);
  };

  const handleRemoveAvatar = async () => {
    if (!profile.avatarUrl) return;
    if (!confirm('Remove your profile picture?')) return;
    setAvatarError(null);
    setUploading(true);
    try {
      await removeAvatar();
    } catch (err) {
      setAvatarError(err.message || String(err));
    } finally {
      setUploading(false);
    }
  };

  const initials = (profile.displayName || profile.email || 'SB').slice(0, 2).toUpperCase();

  return (
    <Page
      crumbs={['Settings']}
    >
      <div className="scroll">
        <div className="page-pad fade-in" style={{ maxWidth: 720, margin: '0 auto' }}>
          <div className="eyebrow">§ Settings</div>
          <h1 className="page-title">Profile.</h1>
          <div className="page-subtitle">
            <span>How you appear in studybuddy. Only you can see this account — these are personal preferences.</span>
          </div>

          {/* Profile picture */}
          <div className="settings-card">
            <div className="card-head">
              <h2 className="card-title">Profile picture</h2>
              <span className="card-sub">PNG · JPG · WEBP · GIF · ≤ 5MB</span>
            </div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 18 }}>
              <div className="avatar-large">
                {profile.avatarUrl
                  ? <img src={profile.avatarUrl} alt="Profile picture" />
                  : <span>{initials}</span>}
              </div>
              <div
                className={"avatar-drop" + (dragOver ? ' dragover' : '')}
                onClick={() => fileRef.current?.click()}
                onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
                onDragLeave={() => setDragOver(false)}
                onDrop={onDrop}
              >
                <div className="head">
                  {uploading ? 'Uploading…' : (dragOver ? 'Release to upload' : 'Drop an image, or click to browse.')}
                </div>
                <div className="sub">stored privately · public read URL</div>
                <input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp,image/gif"
                  style={{ display: 'none' }}
                  onChange={(e) => { pickFile(e.target.files?.[0]); e.target.value = ''; }}
                />
              </div>
            </div>

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

            {profile.avatarUrl && (
              <div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
                <button className="btn ghost sm" onClick={() => fileRef.current?.click()} disabled={uploading}>Replace</button>
                <button className="btn danger sm" onClick={handleRemoveAvatar} disabled={uploading}>Remove</button>
              </div>
            )}
          </div>

          {/* Display name */}
          <div className="settings-card">
            <div className="card-head">
              <h2 className="card-title">Display name</h2>
              <span className="card-sub">Shows in the sidebar and dashboard greeting</span>
            </div>
            <form onSubmit={saveName} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
              <input
                className="input"
                type="text"
                value={name}
                placeholder={profile.email ? profile.email.split('@')[0] : 'Your name'}
                onChange={(e) => { setName(e.target.value); setNameError(null); setNameSavedAt(null); }}
                style={{ flex: 1 }}
                maxLength={80}
              />
              <button type="submit" className="btn accent sm" disabled={!dirty || savingName}>
                {savingName ? 'Saving…' : 'Save'}
              </button>
            </form>
            {nameError && (
              <div style={{ marginTop: 10, padding: '8px 12px', background: 'var(--brick-tint)', border: '1px solid var(--brick)', borderRadius: 6, fontSize: 12.5, color: 'var(--brick)' }}>
                {nameError}
              </div>
            )}
            {nameSavedAt && !nameError && !dirty && (
              <div style={{ marginTop: 10, fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--ink-mute)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
                Saved {relativeTime(nameSavedAt.toISOString())}
              </div>
            )}
          </div>

          {/* Account (read-only) */}
          <div className="settings-card">
            <div className="card-head">
              <h2 className="card-title">Account</h2>
              <span className="card-sub">Identity in Supabase auth</span>
            </div>
            <div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', fontSize: 13.5 }}>
              <span style={{ color: 'var(--ink-soft)' }}>Email</span>
              <span className="mono" style={{ color: 'var(--ink-strong)' }}>{profile.email || '—'}</span>
            </div>
            <div style={{ marginTop: 14, display: 'flex', justifyContent: 'flex-end' }}>
              <button className="btn sm" onClick={() => setShowLogoutConfirm(true)}>Sign out</button>
            </div>
          </div>
        </div>
      </div>

      {showLogoutConfirm && (
        <ConfirmDialog
          title="Sign out?"
          message={`You'll be signed out of ${profile.email || 'this account'}. Your data stays on the server — sign back in any time.`}
          confirmLabel="Sign out"
          tone="danger"
          onConfirm={async () => { await signOut(); setShowLogoutConfirm(false); }}
          onCancel={() => setShowLogoutConfirm(false)}
        />
      )}
    </Page>
  );
}

// ─── Notes ─────────────────────────────────────────────────────────────────

function NotesIndexScreen() {
  const notes = useNotes();
  const subjects = useSubjects();
  const [filter, setFilter] = React.useState('all');
  const filtered = filter === 'all' ? notes : notes.filter((n) => n.subject_id === filter);

  return (
    <Page
      crumbs={['All notes']}
      actions={<button className="btn accent sm" onClick={() => window.openNewNote?.()}>＋ New note</button>}
    >
      <div className="scroll">
        <div className="page-pad fade-in">
          <div className="eyebrow">§ Notes</div>
          <h1 className="page-title">All notes</h1>
          <div className="page-subtitle">
            <span><strong style={{ color: 'var(--ink-strong)' }}>{filtered.length}</strong> {filtered.length === 1 ? 'note' : 'notes'}</span>
            <span style={{ color: 'var(--ink-whisper)' }}>·</span>
            <span>Click a row to open. Notes autosave as you write.</span>
          </div>

          <div style={{ display: 'flex', gap: 6, marginBottom: 18, flexWrap: 'wrap' }}>
            <FilterChip active={filter === 'all'} onClick={() => setFilter('all')}>All</FilterChip>
            {subjects.map((s) => (
              <FilterChip key={s.id} active={filter === s.id} onClick={() => setFilter(s.id)}>
                <span style={{ marginRight: 4 }}>{s.emoji}</span>{s.name}
              </FilterChip>
            ))}
          </div>

          {filtered.length === 0 ? (
            <div style={{ padding: '36px 18px', textAlign: 'center', border: '1px dashed var(--hairline-bold)', borderRadius: 'var(--r-md)' }}>
              <div style={{ fontFamily: 'var(--serif)', fontSize: 22, color: 'var(--ink-strong)', fontWeight: 500 }}>
                {notes.length === 0 ? 'No notes yet' : 'Nothing for this subject'}
              </div>
              <div style={{ fontSize: 13, color: 'var(--ink-mute)', margin: '8px 0 16px' }}>
                Write something free-form. Generate a study guide from it later.
              </div>
              <button className="btn accent sm" onClick={() => window.openNewNote?.()}>＋ New note</button>
            </div>
          ) : (
            <div>
              {filtered.map((n) => {
                const subj = subjects.find((s) => s.id === n.subject_id);
                const snippet = (n.body_text || '').replace(/\s+/g, ' ').trim().slice(0, 140);
                return (
                  <div key={n.id} className="note-row" onClick={() => navigate(`/note/${n.id}`)}>
                    <div style={{ minWidth: 0 }}>
                      <div className="note-row-title">{n.title || 'Untitled note'}</div>
                      <div className="note-row-snippet">
                        {subj ? <span style={{ color: 'var(--ink-soft)' }}>{subj.emoji} {subj.name} · </span> : null}
                        {snippet || <span style={{ color: 'var(--ink-whisper)' }}>Empty note</span>}
                      </div>
                    </div>
                    <div className="note-row-meta">{relativeTime(n.updated_at)}</div>
                  </div>
                );
              })}
            </div>
          )}
        </div>
      </div>
    </Page>
  );
}

// Folder picker for the note editor. Lets the user assign the note to a
// custom (localStorage-backed) folder, or create a new one inline. A note
// in a custom folder shows up in that folder in the sidebar instead of its
// subject folder.
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);

  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);
      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.
  React.useEffect(() => {
    const onBeforeUnload = () => {
      if (timerRef.current) { clearTimeout(timerRef.current); flush(); }
    };
    window.addEventListener('beforeunload', onBeforeUnload);
    return () => {
      window.removeEventListener('beforeunload', onBeforeUnload);
      if (timerRef.current) { clearTimeout(timerRef.current); flush(); }
    };
  }, [flush]);

  // ── 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 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
      hideSidebar={!view.showSidebar}
      crumbs={[
        { label: 'All notes', to: '/notes' },
        note.title || 'Untitled note',
      ]}
      leadingActions={
        <button
          className="icon-btn sidebar-toggle"
          onClick={() => view.setShowSidebar(!view.showSidebar)}
          title={view.showSidebar ? 'Hide sidebar' : 'Show sidebar'}
          aria-pressed={view.showSidebar}
        >
          {view.showSidebar ? '⊟' : '☰'}
        </button>
      }
      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)}>✦ 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>

            <input className="note-title-input" type="text" value={note.title || ''} placeholder="Untitled note"
              onChange={(e) => setTitle(e.target.value)} />

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

// ── Generic AI chat panel ──────────────────────────────────────────────────
// Used by NoteScreen + GuideScreen. `context` describes the chat target:
//   { kind: 'note'|'guide'|'general', id?, title?, color?, systemPrompt, presets? }
// Messages are persisted via chats.js (one row per user × context).
// Uses window.generate (counts toward usage cap).
function AIChatPanel({ context, onClose }) {
  const STORAGE_KEY = 'sb.aipanel.layout';
  const _saved = (() => {
    try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; }
  })();
  const [mode, setMode]       = React.useState(_saved.mode || 'docked'); // 'docked' | 'floating'
  const [pos,  setPos]        = React.useState(_saved.pos  || { x: 120, y: 96 });
  const [size, setSize]       = React.useState(_saved.size || { w: 380, h: 540 });
  const [dockedWidth, setDockedWidth] = React.useState(_saved.dockedWidth || 380);
  const [messages, setMessages, _ready] = window.useChat({ kind: context.kind, id: context.id || null });
  const [input, setInput]     = React.useState('');
  const [busy, setBusy]       = React.useState(false);
  const [err, setErr]         = React.useState(null);
  const bodyRef = React.useRef(null);

  // Persist layout choice per session.
  React.useEffect(() => {
    try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ mode, pos, size, dockedWidth })); } catch {}
  }, [mode, pos, size, dockedWidth]);

  // Auto-scroll on new message.
  React.useEffect(() => {
    if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
  }, [messages.length, busy]);

  const submit = async (e) => {
    e?.preventDefault?.();
    const q = input.trim();
    if (!q || busy) return;
    setMessages((m) => [...m, { role: 'user', content: q }]);
    setInput('');
    setBusy(true); setErr(null);
    try {
      // Cap history sent to OpenAI at 20 — full history still rendered in UI.
      const history = messages.slice(-20).map((m) => ({ role: m.role, content: m.content }));
      const payload = {
        model: 'gpt-4o-mini',
        messages: [
          { role: 'system', content: context.systemPrompt },
          ...history,
          { role: 'user', content: q },
        ],
      };
      const res = await window.generate(payload, 'extract');
      const reply = res?.choices?.[0]?.message?.content || '(no response)';
      setMessages((m) => [...m, { role: 'assistant', content: reply }]);
    } catch (e2) {
      setErr(e2.message);
    } finally { setBusy(false); }
  };

  const clearAll = async () => {
    if (!messages.length) return;
    if (!confirm('Clear this chat history?')) return;
    setMessages([]);
  };

  // Drag (floating mode only) — track mousemove on window so the cursor can
  // leave the header without breaking the drag.
  const startDrag = (e) => {
    if (mode !== 'floating') return;
    e.preventDefault();
    const startX = e.clientX, startY = e.clientY;
    const startPos = { ...pos };
    const move = (ev) => {
      const x = Math.max(0, Math.min(window.innerWidth  - size.w, startPos.x + (ev.clientX - startX)));
      const y = Math.max(0, Math.min(window.innerHeight - 60,     startPos.y + (ev.clientY - startY)));
      setPos({ x, y });
    };
    const up = () => {
      window.removeEventListener('mousemove', move);
      window.removeEventListener('mouseup', up);
    };
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
  };

  // Resize from the bottom-right corner (floating mode only).
  const startResize = (e) => {
    if (mode !== 'floating') return;
    e.preventDefault(); e.stopPropagation();
    const startX = e.clientX, startY = e.clientY;
    const startSize = { ...size };
    const move = (ev) => {
      setSize({
        w: Math.max(280, Math.min(720, startSize.w + (ev.clientX - startX))),
        h: Math.max(320, Math.min(900, startSize.h + (ev.clientY - startY))),
      });
    };
    const up = () => {
      window.removeEventListener('mousemove', move);
      window.removeEventListener('mouseup', up);
    };
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
  };

  // Resize the docked panel by dragging its LEFT edge. Panel is right-pinned,
  // so dragging left makes it wider. Width is clamped + persisted to
  // localStorage with the rest of the layout.
  const startDockResize = (e) => {
    if (mode !== 'docked') return;
    e.preventDefault(); e.stopPropagation();
    const startX = e.clientX;
    const startWidth = dockedWidth;
    const move = (ev) => {
      const max = Math.min(900, window.innerWidth - 80);
      setDockedWidth(Math.max(280, Math.min(max, startWidth - (ev.clientX - startX))));
    };
    const up = () => {
      window.removeEventListener('mousemove', move);
      window.removeEventListener('mouseup', up);
      document.body.style.cursor = '';
      document.body.style.userSelect = '';
    };
    document.body.style.cursor = 'ew-resize';
    document.body.style.userSelect = 'none';
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
  };

  const shellStyle = mode === 'docked' ? {
    position: 'fixed', right: 0, top: 0, bottom: 0, width: dockedWidth,
    borderLeft: '1px solid var(--hairline-bold)',
    background: 'var(--page)',
    boxShadow: '-8px 0 24px -8px rgba(0, 0, 0, 0.06)',
  } : {
    position: 'fixed', left: pos.x, top: pos.y,
    width: size.w, height: size.h,
    border: '1px solid var(--hairline-bold)',
    borderRadius: 'var(--r-md)',
    background: 'var(--page)',
    boxShadow: '0 16px 48px -8px rgba(0, 0, 0, 0.22), 0 4px 12px rgba(0, 0, 0, 0.08)',
  };

  const presets = context.presets || [
    'Summarize in 3 bullets',
    'Explain like I\'m new to the topic',
    'Quiz me — 5 questions',
    'What\'s missing here?',
  ];

  return (
    <div className="ai-panel" style={{ ...shellStyle, zIndex: 200, display: 'flex', flexDirection: 'column' }}>
      {mode === 'docked' && (
        <div
          className="ai-panel-resize-w"
          onMouseDown={startDockResize}
          title="Drag to resize"
        />
      )}
      <div
        className="ai-panel-head"
        onMouseDown={startDrag}
        style={{
          display: 'flex', alignItems: 'center', gap: 6,
          padding: '8px 10px',
          borderBottom: '1px solid var(--hairline)',
          cursor: mode === 'floating' ? 'grab' : 'default',
          userSelect: 'none',
          background: 'var(--page-tint)',
          borderRadius: mode === 'floating' ? 'var(--r-md) var(--r-md) 0 0' : 0,
        }}>
        {/* Title chip — color-tagged when context provides one (subject color
            for guides). Falls back to plain ✦ AI · mode label. */}
        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flex: 1, minWidth: 0 }}>
          {context.color && (
            <span style={{ width: 8, height: 8, borderRadius: 999, background: context.color, flexShrink: 0 }} />
          )}
          <span style={{ fontFamily: 'var(--mono)', fontSize: 10.5, fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--ink-soft)' }}>
            ✦ AI
          </span>
          {context.title && (
            <span style={{ fontSize: 12, color: 'var(--ink-strong)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
              · {context.title}
            </span>
          )}
        </span>
        <button className="icon-btn" title="Clear chat" onClick={clearAll} disabled={!messages.length}>⌫</button>
        <button className="icon-btn" title={mode === 'docked' ? 'Pop out' : 'Dock right'}
          onClick={() => setMode((m) => m === 'docked' ? 'floating' : 'docked')}>
          {mode === 'docked' ? '⇱' : '⇲'}
        </button>
        <button className="icon-btn" title="Close" onClick={onClose}>×</button>
      </div>

      <div ref={bodyRef} style={{ flex: 1, overflowY: 'auto', padding: '14px 14px 8px' }}>
        {messages.length === 0 ? (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
            <div style={{ fontSize: 13, color: 'var(--ink-soft)', lineHeight: 1.55 }}>
              {context.emptyHint ||
                (context.kind === 'guide' ? `Ask anything about ${context.title || 'this guide'}. The AI is grounded in the guide's content.` :
                 context.kind === 'note'  ? 'Ask the AI about this note. It can summarize, rephrase, expand, quiz you, or suggest what\'s missing.' :
                 'Ask the AI anything.')}
            </div>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
              {presets.map((p) => (
                <button key={p} type="button"
                  onClick={() => setInput(p)}
                  className="filter-chip"
                  style={{ textAlign: 'left', justifyContent: 'flex-start', fontFamily: 'var(--sans)', textTransform: 'none', letterSpacing: 0, fontSize: 12.5, padding: '6px 10px' }}>
                  {p}
                </button>
              ))}
            </div>
          </div>
        ) : (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
            {messages.map((m, i) => (
              <div key={i}>
                <div style={{ fontFamily: 'var(--mono)', fontSize: 10, fontWeight: 600, color: m.role === 'user' ? 'var(--ink-soft)' : 'var(--accent)', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>
                  {m.role === 'user' ? 'You' : 'AI'}
                </div>
                <div style={{ fontSize: 13.5, color: 'var(--ink-strong)', whiteSpace: 'pre-wrap', lineHeight: 1.55 }}>{m.content}</div>
              </div>
            ))}
            {busy && (
              <div style={{ fontSize: 12, color: 'var(--ink-mute)' }}>
                <span className="pulse">Thinking…</span>
              </div>
            )}
          </div>
        )}
        {err && (
          <div style={{ marginTop: 10, padding: '8px 10px', background: 'var(--brick-tint)', border: '1px solid var(--brick)', borderRadius: 6, fontSize: 12, color: 'var(--brick)' }}>{err}</div>
        )}
      </div>

      <form onSubmit={submit}
        style={{ borderTop: '1px solid var(--hairline)', padding: 10, display: 'flex', gap: 6, alignItems: 'flex-end' }}>
        <textarea
          className="input"
          rows={2}
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(e); } }}
          placeholder={context.placeholder || 'Ask anything…  (Enter sends, Shift+Enter newline)'}
          style={{ flex: 1, minHeight: 44, fontSize: 13, fontFamily: 'var(--sans)', padding: '8px 10px', resize: 'none' }}
        />
        <button type="submit" className="btn accent sm" disabled={busy || !input.trim()} style={{ height: 44, padding: '0 14px' }}>
          {busy ? '…' : '↑'}
        </button>
      </form>

      {mode === 'floating' && (
        <div onMouseDown={startResize}
          title="Drag to resize"
          style={{
            position: 'absolute', right: 0, bottom: 0,
            width: 14, height: 14, cursor: 'nwse-resize',
            background: 'linear-gradient(135deg, transparent 50%, var(--ink-faint) 50%)',
            borderRadius: '0 0 var(--r-md) 0',
          }}
        />
      )}
    </div>
  );
}

// Note-context wrapper for AIChatPanel — keeps existing NoteScreen call site
// unchanged. Builds the note system prompt the same way the panel did before.
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} />;
}

// Guide-context wrapper for AIChatPanel — used by GuideScreen. The system
// prompt embeds the guide's bigIdea, sections + key terms (NOT flashcards/quiz
// to keep token use bounded). The pill colors itself with the subject color.
function GuideAIPanel({ guide, subject, onClose }) {
  const context = React.useMemo(() => {
    const c = guide?.content || {};
    const sectionsBlob = (c.sections || []).map((s) => {
      const terms = (s.terms || []).map((t) => `  - ${t.term}: ${t.def}`).join('\n');
      return `## ${s.number}. ${s.title}\n${s.body || ''}${terms ? '\n\nKey terms:\n' + terms : ''}`;
    }).join('\n\n').slice(0, 12000);
    const sysPrompt =
      `You are a study assistant for the guide titled "${guide?.title || 'Untitled guide'}"` +
      (subject?.name ? ` (subject: ${subject.name})` : '') + `.\n\n` +
      `Use this material to answer questions. If asked something outside this material, ` +
      `answer helpfully but note that it is not in the guide.\n\n` +
      (c.bigIdea ? `BIG IDEA:\n${c.bigIdea}\n\n` : '') +
      (sectionsBlob ? `SECTIONS & KEY TERMS:\n${sectionsBlob}` : '');
    return {
      kind: 'guide',
      id: guide?.id || null,
      title: guide?.title || 'Untitled guide',
      color: subject?.color || null,
      systemPrompt: sysPrompt,
      placeholder: 'Ask anything about this guide…  (Enter sends, Shift+Enter newline)',
      presets: [
        'Quiz me — 5 mixed questions',
        'Summarize the big idea in 1 paragraph',
        'List the 5 most important terms',
        'What might be on a test from this?',
      ],
      emptyHint: `Ask anything about ${guide?.title || 'this guide'}. The AI is grounded in the guide's content (sections + key terms).`,
    };
  }, [guide?.id, guide?.title, guide?.content, subject?.id, subject?.color, subject?.name]);
  return <AIChatPanel context={context} onClose={onClose} />;
}

// ── Share-note dialog: add collaborators by email + manage existing ────────
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();
              const color = window.colorForUserId?.(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 ────────────────────────────────────
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() || {};
  // Default ON — sidebar shows unless the user explicitly hid it.
  const [showSidebar, setShowSidebar] = React.useState(initial.showSidebar !== false);
  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({ showSidebar, wide, fontSizeId, textColorId }));
    } catch {}
  }, [showSidebar, 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 {
    showSidebar, setShowSidebar,
    wide, setWide,
    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);

  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);
    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);
    return () => window.removeEventListener('liveblocks-ready', onR);
  }, []);

  // Surface the load error so the user isn't stuck on a spinner
  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>
    );
  }

  // 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')".
  const [providerReady, setProviderReady] = React.useState(false);
  // 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);
        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 10s, surface a failed
        // state so the UI can offer a Retry button. We don't auto-retry the
        // room — Liveblocks does its own reconnect; we just signal the user.
        syncTimer = setTimeout(() => {
          if (cancelled || providerSynced) return;
          console.warn('[liveblocks] sync timeout (10s) — flagging as failed');
          reportStatus('failed');
        }, 10000);

        // 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');
          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()) {
            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) console.log('[liveblocks] room left:', `note-${noteId}`);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ready, collabReady, noteId, user?.id, isShared]);

  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 ? (
        <div
          className="note-editor"
          style={{ borderRadius: 'var(--r-md)', minHeight: 80, cursor: useCollab ? 'wait' : 'auto' }}
          // initialHTML is the user's own saved content; TipTap output has a
          // fixed schema (headings/lists/marks) — no script tags possible.
          dangerouslySetInnerHTML={{ __html: initialHTML || '<p></p>' }}
        />
      ) : (
        <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>
  );
}

function NewNoteDialog({ defaultSubjectId, onClose }) {
  const subjects = useSubjects();
  const [title, setTitle] = React.useState('');
  const [subjectId, setSubjectId] = React.useState(defaultSubjectId || '');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const submit = async (e) => {
    e.preventDefault();
    setBusy(true); setErr(null);
    try {
      const created = await addNote({ subjectId: subjectId || null, title });
      onClose();
      navigate(`/note/${created.id}`);
    } catch (e2) {
      setErr(e2.message); setBusy(false);
    }
  };

  return (
    <ModalShell width={460} onClose={onClose}>
      <ModalHead title="New note" sub="Pick a subject (optional). You can change it later." onClose={onClose} />
      <form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        <label className="field" style={{ marginBottom: 0 }}>
          <span>Title</span>
          <input className="input" type="text" value={title} onChange={(e) => setTitle(e.target.value)}
            placeholder="e.g. Lecture 4 — Krebs cycle" autoFocus />
        </label>

        <label className="field" style={{ marginBottom: 0 }}>
          <span>Subject</span>
          <select className="input" value={subjectId} onChange={(e) => setSubjectId(e.target.value)}>
            <option value="">— none —</option>
            {subjects.map((s) => <option key={s.id} value={s.id}>{s.emoji} {s.name}</option>)}
          </select>
        </label>

        {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 style={{ display: 'flex', gap: 8, marginTop: 4 }}>
          <button type="button" className="btn ghost sm" onClick={onClose}>Cancel</button>
          <div style={{ flex: 1 }} />
          <button type="submit" className="btn accent sm" disabled={busy}>{busy ? 'Creating…' : 'Create note →'}</button>
        </div>
      </form>
    </ModalShell>
  );
}

// ─── Search palette ────────────────────────────────────────────────────────
//
// ⌘K (or Ctrl+K) anywhere opens a Notion-style command palette that searches
// across subjects, guides, and notes. Selecting a result navigates there.

function _scoreMatch(query, ...fields) {
  const q = query.trim().toLowerCase();
  if (!q) return 0;
  let best = 0;
  for (const raw of fields) {
    if (!raw) continue;
    const text = String(raw).toLowerCase();
    if (text === q) { best = Math.max(best, 1000); continue; }
    if (text.startsWith(q)) { best = Math.max(best, 500); continue; }
    const idx = text.indexOf(q);
    if (idx >= 0) { best = Math.max(best, 200 - Math.min(idx, 199)); continue; }
    const tokens = text.split(/[\s\-_./]+/);
    if (tokens.some((t) => t.startsWith(q))) best = Math.max(best, 100);
  }
  return best;
}

function SearchPalette({ onClose }) {
  const subjects = useSubjects();
  const userGuides = useGuides();
  const notes = useNotes();

  const [query, setQuery] = React.useState('');
  const [active, setActive] = React.useState(0);
  const inputRef = React.useRef(null);
  const listRef = React.useRef(null);

  React.useEffect(() => { inputRef.current?.focus(); }, []);

  const subjectById = React.useMemo(() => {
    const m = new Map();
    for (const s of subjects) m.set(s.id, s);
    return m;
  }, [subjects]);

  const results = React.useMemo(() => {
    const q = query.trim();
    const out = [];

    if (!q) {
      for (const n of notes.slice(0, 4)) {
        const subj = subjectById.get(n.subject_id);
        out.push({
          id: 'n:' + n.id, kind: 'note', glyph: '¶',
          title: n.title || 'Untitled note',
          sub: subj ? `${subj.emoji} ${subj.name}` : 'No subject',
          path: `/note/${n.id}`,
          meta: relativeTime(n.updated_at),
        });
      }
      for (const g of userGuides.slice(0, 4)) {
        const subj = subjectById.get(g.subjectId);
        out.push({
          id: 'g:' + g.id, kind: 'guide', glyph: '◆',
          title: g.title,
          sub: subj ? `${subj.emoji} ${subj.name}` : '(unknown subject)',
          path: `/subject/${g.subjectId}/guide/${g.id}`,
          meta: relativeTime(g.generatedAt),
        });
      }
      for (const s of subjects.slice(0, 4)) {
        out.push({
          id: 's:' + s.id, kind: 'subject', glyph: s.emoji,
          title: s.name, sub: 'Subject',
          path: `/subject/${s.id}`, meta: '',
        });
      }
      return out.slice(0, 12);
    }

    const scored = [];
    for (const s of subjects) {
      const score = _scoreMatch(q, s.name);
      if (score > 0) scored.push({ score, item: {
        id: 's:' + s.id, kind: 'subject', glyph: s.emoji,
        title: s.name, sub: 'Subject',
        path: `/subject/${s.id}`, meta: '',
      } });
    }
    for (const g of userGuides) {
      const subj = subjectById.get(g.subjectId);
      const sectionTitles = (g.content?.sections || []).map((x) => x.title).join(' ');
      const terms = (g.content?.sections || []).flatMap((x) => (x.terms || []).map((t) => t.term)).join(' ');
      const score = _scoreMatch(q, g.title, subj?.name, g.content?.bigIdea, sectionTitles, terms);
      if (score > 0) scored.push({ score, item: {
        id: 'g:' + g.id, kind: 'guide', glyph: '◆',
        title: g.title,
        sub: subj ? `${subj.emoji} ${subj.name}` : '(unknown subject)',
        path: `/subject/${g.subjectId}/guide/${g.id}`,
        meta: relativeTime(g.generatedAt),
      } });
    }
    for (const n of notes) {
      const subj = subjectById.get(n.subject_id);
      const score = _scoreMatch(q, n.title, n.body_text, subj?.name);
      if (score > 0) scored.push({ score, item: {
        id: 'n:' + n.id, kind: 'note', glyph: '¶',
        title: n.title || 'Untitled note',
        sub: subj ? `${subj.emoji} ${subj.name}` : 'No subject',
        path: `/note/${n.id}`,
        meta: relativeTime(n.updated_at),
      } });
    }
    scored.sort((a, b) => b.score - a.score);
    return scored.slice(0, 20).map((x) => x.item);
  }, [query, subjects, userGuides, notes, subjectById]);

  React.useEffect(() => { setActive(0); }, [query]);

  React.useEffect(() => {
    const el = listRef.current?.querySelector(`[data-row="${active}"]`);
    el?.scrollIntoView({ block: 'nearest' });
  }, [active]);

  const choose = (item) => {
    if (!item) return;
    onClose();
    navigate(item.path);
  };

  const onKey = (e) => {
    if (e.key === 'ArrowDown') { e.preventDefault(); setActive((i) => Math.min(i + 1, results.length - 1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)); }
    else if (e.key === 'Enter')   { e.preventDefault(); choose(results[active]); }
    else if (e.key === 'Escape')  { e.preventDefault(); onClose(); }
  };

  return (
    <div className="palette-backdrop" onClick={onClose}>
      <div className="palette" onClick={(e) => e.stopPropagation()}>
        <div className="palette-input-row">
          <span className="palette-glyph">⌕</span>
          <input
            ref={inputRef}
            className="palette-input"
            type="text"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            onKeyDown={onKey}
            placeholder="Search subjects, guides, notes…"
          />
          <span className="kbd" title="Close">esc</span>
        </div>

        <div className="palette-results" ref={listRef}>
          {results.length === 0 ? (
            <div className="palette-empty">
              {query.trim()
                ? <>No matches for <em>"{query.trim()}"</em>.</>
                : 'Nothing here yet. Add a subject or write a note.'}
            </div>
          ) : (
            results.map((r, i) => (
              <div
                key={r.id}
                data-row={i}
                className={"palette-row" + (i === active ? ' active' : '')}
                onMouseEnter={() => setActive(i)}
                onClick={() => choose(r)}
              >
                <span className="palette-row-glyph">{r.glyph}</span>
                <div className="palette-row-body">
                  <div className="palette-row-title">{r.title}</div>
                  <div className="palette-row-sub">{r.sub}</div>
                </div>
                <span className="palette-row-kind">{r.kind}</span>
                {r.meta && <span className="palette-row-meta">{r.meta}</span>}
              </div>
            ))
          )}
        </div>

        <div className="palette-foot">
          <span><span className="kbd">↑</span><span className="kbd">↓</span> navigate</span>
          <span><span className="kbd">↵</span> open</span>
          <span><span className="kbd">esc</span> close</span>
        </div>
      </div>
    </div>
  );
}

// ─── Modal primitives ──────────────────────────────────────────────────────

function ModalShell({ children, width = 420, onClose }) {
  return (
    <div onClick={onClose}
      style={{
        position: 'fixed', inset: 0, background: 'rgba(20, 22, 33, 0.4)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        zIndex: 200, padding: 20, backdropFilter: 'blur(4px)',
      }}>
      <div onClick={(e) => e.stopPropagation()}
        style={{
          width, maxWidth: '95vw', maxHeight: '90vh',
          background: 'var(--page)', borderRadius: 'var(--r-lg)',
          border: '1px solid var(--hairline-bold)',
          boxShadow: '0 20px 60px -10px rgba(0, 0, 0, 0.25), 0 8px 16px rgba(0, 0, 0, 0.08)',
          padding: 22, display: 'flex', flexDirection: 'column', gap: 14, overflow: 'hidden',
        }}>
        {children}
      </div>
    </div>
  );
}

function ModalHead({ title, sub, onClose }) {
  return (
    <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
      <div>
        <h2 style={{ margin: 0, fontFamily: 'var(--serif)', fontSize: 20, fontWeight: 500, color: 'var(--ink-strong)', letterSpacing: '-0.01em' }}>{title}</h2>
        {sub && <div style={{ fontSize: 12.5, color: 'var(--ink-mute)', marginTop: 4 }}>{sub}</div>}
      </div>
      <button type="button" onClick={onClose}
        style={{ border: 'none', background: 'transparent', fontSize: 20, cursor: 'pointer', color: 'var(--ink-mute)', padding: 0, lineHeight: 1 }}>×</button>
    </div>
  );
}

// ─── Reusable confirm dialog ───────────────────────────────────────────────

function ConfirmDialog({ title, message, confirmLabel = 'Confirm', cancelLabel = 'Cancel', tone = 'default', onConfirm, onCancel }) {
  const [busy, setBusy] = React.useState(false);
  const submit = async () => {
    setBusy(true);
    try { await onConfirm(); }
    finally { setBusy(false); }
  };
  return (
    <ModalShell width={380} onClose={onCancel}>
      <ModalHead title={title} onClose={onCancel} />
      {message && (
        <div style={{ fontSize: 13.5, color: 'var(--ink-soft)', lineHeight: 1.55 }}>{message}</div>
      )}
      <div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
        <button type="button" className="btn ghost sm" onClick={onCancel} disabled={busy}>{cancelLabel}</button>
        <div style={{ flex: 1 }} />
        <button type="button" className={"btn sm " + (tone === 'danger' ? 'danger' : 'accent')}
          onClick={submit} disabled={busy} autoFocus>
          {busy ? '…' : confirmLabel}
        </button>
      </div>
    </ModalShell>
  );
}

// ─── App entry ─────────────────────────────────────────────────────────────

function App() {
  const auth = useAuth();
  const path = useRoute();
  const [newGuide, setNewGuide] = React.useState(null);
  const [uploadFiles, setUploadFiles] = React.useState(null);
  const [newNote, setNewNote] = React.useState(null);
  const [searchOpen, setSearchOpen] = React.useState(false);

  // Global dialog openers — any component calls window.openX() without prop-drilling.
  React.useEffect(() => {
    window.openNewGuide    = (subjectId) => setNewGuide({ subjectId: subjectId || null });
    window.openUploadFiles = (subjectId) => setUploadFiles({ subjectId: subjectId || null });
    window.openNewNote     = (subjectId) => setNewNote({ subjectId: subjectId || null });
    window.openSearch      = () => setSearchOpen(true);
    return () => {
      delete window.openNewGuide;
      delete window.openUploadFiles;
      delete window.openNewNote;
      delete window.openSearch;
    };
  }, []);

  // Global ⌘K / Ctrl+K to open the search palette.
  React.useEffect(() => {
    const onKey = (e) => {
      if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
        e.preventDefault();
        setSearchOpen(true);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  if (!auth.ready) return <Splash />;
  if (!auth.user) return <LoginScreen />;

  let p, screen;
  if (path === '/') screen = <Dashboard />;
  else if (path === '/library') screen = <LibraryScreen />;
  else if (path === '/calendar') screen = <CalendarScreen />;
  else if (path === '/events') screen = <EventsListScreen />;
  else if (path === '/notes') screen = <NotesIndexScreen />;
  else if (path === '/settings') screen = <SettingsScreen />;
  // key={paramId} forces a fresh component mount when the route param changes.
  // Without this, useState initializers (e.g. findNoteSync(noteId)) only run on
  // first mount, so navigating between two notes/guides keeps stale state.
  else if ((p = match('/note/:noteId', path)))                                               screen = <NoteScreen          key={p.noteId}  noteId={p.noteId} />;
  else if ((p = match('/subject/:subjectId/upload', path)))                                  screen = <UploadScreen     key={p.subjectId} subjectId={p.subjectId} />;
  else if ((p = match('/subject/:subjectId/guide/:guideId/generate', path)))                 screen = <GenerationScreen key={p.guideId}   subjectId={p.subjectId} guideId={p.guideId} />;
  else if ((p = match('/subject/:subjectId/guide/:guideId/flashcards/cram', path)))          screen = <CramScreen       key={p.guideId}   subjectId={p.subjectId} guideId={p.guideId} />;
  else if ((p = match('/subject/:subjectId/guide/:guideId/flashcards/longterm', path)))      screen = <LongTermScreen   key={p.guideId}   subjectId={p.subjectId} guideId={p.guideId} />;
  else if ((p = match('/subject/:subjectId/guide/:guideId/flashcards', path)))               screen = <FlashcardScreen  key={p.guideId}   subjectId={p.subjectId} guideId={p.guideId} />;
  else if ((p = match('/subject/:subjectId/guide/:guideId/quiz', path)))                     screen = <QuizScreen       key={p.guideId}   subjectId={p.subjectId} guideId={p.guideId} />;
  else if ((p = match('/subject/:subjectId/guide/:guideId', path)))                          screen = <GuideScreen      key={p.guideId}   subjectId={p.subjectId} guideId={p.guideId} />;
  else if ((p = match('/subject/:subjectId', path)))                                         screen = <SubjectDetailScreen key={p.subjectId} subjectId={p.subjectId} />;
  else screen = <Dashboard />;

  return (
    <>
      {screen}
      {newGuide    && <NewGuideDialog    defaultSubjectId={newGuide.subjectId}    onClose={() => setNewGuide(null)} />}
      {uploadFiles && <UploadFilesDialog defaultSubjectId={uploadFiles.subjectId} onClose={() => setUploadFiles(null)} />}
      {newNote     && <NewNoteDialog     defaultSubjectId={newNote.subjectId}     onClose={() => setNewNote(null)} />}
      {searchOpen  && <SearchPalette                                              onClose={() => setSearchOpen(false)} />}
    </>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
