// Calendar grid, all-events list, AI-event editor, single-event dialog,
// import flow (file / URL / AI paste / saved sources), and the saved-sources
// CRUD. Extracted from app.jsx in Wave 5.
//
// Exports (via Object.assign(window, { ... }) at bottom):
//   CalendarScreen, EventsListScreen, AIEventEditDialog, EventDialog,
//   ImportDialog, SourcesPanel
//
// Also re-publishes window.aiExtractEvents (preserved from app.jsx for
// sources.js to consume the cached extractor).
//
// File-private (not exported, only used inside this file):
//   KINDS, AI_EDIT_SCHEMA, aiEditEvents, PlanGroup, PlanRow,
//   _aiCacheKey, _aiCacheGet, _aiCacheSet, aiExtractEvents,
//   SourceRow, SourceForm, ScheduleBtn, TabBtn
//
// External deps consumed (must be window.* by the time these render):
//   from data modules: useEvents, eventsLoaded, deleteEvent,
//     deleteEventsBulk, updateEventsBulk, addEventsBulk, addEvent,
//     updateEvent, useSubjects, todayISO, parseImport, importFromURL,
//     useCalendarSources, addCalendarSource, updateCalendarSource,
//     deleteCalendarSource, runSource, describeSchedule, DAY_NAMES,
//     DAYS_DAILY, DAYS_WEEKDAYS, sb (Supabase client),
//     API_BASE, fetchWithTimeout, generate
//   from helpers.jsx: Page, navigate, ModalShell, ModalHead, AiGlyph,
//     useSkeletonGate, monthCells, monthOffset, dateISO, NAV_ICONS,
//     relativeTime
//   from Wave 3 promotion: FilterChip

(function () {

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 skEvents = useSkeletonGate(eventsLoaded());
  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 = React.useMemo(() => monthCells(cursor.y, cursor.m), [cursor.y, cursor.m]);
  const eventsByDate = React.useMemo(() => {
    const out = {};
    for (const e of events) (out[e.date] = out[e.date] || []).push(e);
    return out;
  }, [events]);


  const eventClass = (kind) =>
    kind === 'exam' || kind === 'assignment' ? 'cd-ev coral'
    : kind === 'study' ? 'cd-ev blue'
    : kind === 'other' ? 'cd-ev amber'
    : 'cd-ev';

  return (
    <Page
      crumbs={['Calendar']}
      actions={
        <>
          <button className="cd-icon-btn" onClick={() => setCursor(monthOffset(cursor, -1))} aria-label="Previous month" title="Previous month">{NAV_ICONS.chevLeft}</button>
          <button className="btn" onClick={() => { const d = new Date(); setCursor({ y: d.getFullYear(), m: d.getMonth() }); }} title="Jump to current month">Today</button>
          <button className="cd-icon-btn" onClick={() => setCursor(monthOffset(cursor, 1))} aria-label="Next month" title="Next month">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 18l6-6-6-6"/></svg>
          </button>
          <button className="btn" onClick={() => navigate('/events')}>≡ Events</button>
          <button className="btn" onClick={() => setAiOpen(true)}><AiGlyph />AI edit</button>
          <button className="btn" onClick={() => setImporting(true)}>Import</button>
          <button className="btn dark" onClick={() => setEditing({ mode: 'create', date: today })}>＋ Event</button>
        </>
      }
    >
      <div className="cd-page">
        <div className="cd-page-head">
          <div className="cd-collage" aria-hidden="true">
            <span className="s1"></span><span className="s2"></span><span className="s3"></span>
          </div>
          <div className="cd-page-eyebrow">§ Calendar</div>
          <h1 className="cd-page-title">{monthName}</h1>
          <p className="cd-page-sub">
            <b>{events.length}</b> events tracked.
            <span className="dot">·</span>
            Click any day to add. Click an event to view or delete.
          </p>
        </div>

        <div className="cd-cal-card">
          <div className="cd-cal-grid">
            {['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map((d) => <div key={d} className="cd-dow">{d}</div>)}
            {(() => {
              const loaded = !skEvents;
              return cells.map((cell, i) => {
                const dayEvents = eventsByDate[cell.date] || [];
                const isToday = cell.date === today;
                const inMonth = cell.month === cursor.m;
                return (
                  <div key={cell.date}
                    className={"cd-cell" + (inMonth ? '' : ' dim') + (isToday ? ' today' : '')}
                    onClick={() => setEditing({ mode: 'create', date: cell.date })}>
                    <div className="num">{cell.day}</div>
                    {!loaded && inMonth && dayEvents.length === 0 && (i % 5 === 0 || i % 5 === 2) && (
                      <span className="sk sk-line sm" style={{ marginTop: 4, width: '70%' }} />
                    )}
                    {dayEvents.slice(0, 3).map((e) => (
                      <div key={e.id} className={eventClass(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(--cd-muted)', fontFamily: 'var(--mono)', paddingLeft: 4 }}>
                        +{dayEvents.length - 3}
                      </div>
                    )}
                  </div>
                );
              });
            })()}
          </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" onClick={() => navigate('/calendar')}>← Calendar grid</button>
          <button className="btn dark" onClick={() => setAiOpen(true)}><AiGlyph />AI edit</button>
        </>
      }
    >
      <div className="cd-page" style={{ overflow: 'auto' }}>
        <div className="cd-page-head">
          <div className="cd-collage" aria-hidden="true">
            <span className="s1"></span><span className="s2"></span><span className="s3"></span>
          </div>
          <div className="cd-page-eyebrow">§ Events list</div>
          <h1 className="cd-page-title">All events</h1>
          <p className="cd-page-sub">
            <b>{filtered.length}</b> {filtered.length === 1 ? 'event' : 'events'}
            <span className="dot">·</span>
            Multi-select to edit or delete in bulk. Click a row to view / edit one event.
          </p>
        </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>

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

// (Legend dropped — dead code with no callers.)

// ─── 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() }));
    // Evict oldest entries above 50 to keep localStorage bounded. Each entry
    // lives at its own key (sb-ai-cal-<hash>); scan, sort by `t`, drop the
    // tail. Cheap because there are at most ~50 keys at steady state.
    const keys = [];
    for (let i = 0; i < localStorage.length; i++) {
      const k = localStorage.key(i);
      if (k && k.startsWith('sb-ai-cal-')) keys.push(k);
    }
    if (keys.length > 50) {
      const ts = keys.map((k) => {
        try {
          const obj = JSON.parse(localStorage.getItem(k) || '{}');
          return { k, t: Date.parse(obj.t) || 0 };
        } catch { return { k, t: 0 }; }
      });
      ts.sort((a, b) => a.t - b.t);
      const drop = ts.slice(0, ts.length - 50);
      for (const x of drop) localStorage.removeItem(x.k);
    }
  } 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.
// (Preserved from app.jsx Wave-5 extraction — sources.js calls
// window.aiExtractEvents directly.)
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)}`;
        let res;
        try {
          res = await window.fetchWithTimeout(proxyUrl, {
            headers: { Authorization: `Bearer ${session.access_token}` },
          }, 25000);
        } catch (err) {
          if (err && err.name === 'AbortError') {
            throw new Error('Fetch timed out (25s) — try a different source or retry.');
          }
          throw err;
        }
        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><AiGlyph />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>
  );
}

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

Object.assign(window, {
  CalendarScreen,
  EventsListScreen,
  AIEventEditDialog,
  EventDialog,
  ImportDialog,
  SourcesPanel,
});

})();
