// Monitor pages — Events (live via onSnapshot), Log (configLog), Config (release info + override)
// Owned by P4 partition. No dangerouslySetInnerHTML anywhere. JSX auto-escapes {value}.
//
// (sniper-activity-only view — admin/system rows live in admin Audit tab elsewhere)
// 2026-05-09: configLog merge removed per user feedback. Timeline is users/{uid}/events ONLY.
// Columns: When | Sniper | Slot | Group | Outcome | Type | Detail/Payload.

const {
  Icon, Checkbox, Avatar, Badge, Stat, PageHeader, BulkBar, Toolbar, Chip,
  SearchInput, Modal, Drawer, DetailRow, EmptyState,
  ToastProvider, useToast,
  Sidebar, Topbar,
  MigrationBanner, ConflictBanner, RestoredSessionNotice,
  DateHeader, AssignmentDetailDrawer,
  EventSummary, composeEventSentence,
  formatShortDate, formatRelTime,
  AdminStoreContext, adminApi
} = window;

const { useState, useEffect, useRef, useMemo, useCallback } = React;

// AE-10 — URL-hash filter persistence (source filter dropped 2026-05-09).
function _readEventsHash() {
  const h = (window.location.hash || '').replace(/^#/, '');
  const ix = h.indexOf('?');
  if (ix < 0) return {};
  const out = {};
  h.slice(ix + 1).split('&').forEach(function (kv) {
    const [k, v] = kv.split('=');
    if (k) out[decodeURIComponent(k)] = decodeURIComponent(v || '');
  });
  return out;
}

// AE-8 — local copy of date-key helper (components.jsx has its own; not exported on window).
function _clubDateKey(date, clubTz) {
  try {
    const opts = clubTz ? { timeZone: clubTz } : {};
    const yyyy = new Intl.DateTimeFormat('en-US', { ...opts, year: 'numeric' }).format(date);
    const mm = new Intl.DateTimeFormat('en-US', { ...opts, month: '2-digit' }).format(date);
    const dd = new Intl.DateTimeFormat('en-US', { ...opts, day: '2-digit' }).format(date);
    return `${yyyy}-${mm}-${dd}`;
  } catch (_) {
    return date.toISOString().slice(0, 10);
  }
}

function _writeEventsHash(state) {
  const base = (window.location.hash || '').replace(/^#/, '').split('?')[0] || 'events';
  const params = Object.keys(state)
    .filter(function (k) { return state[k] !== '' && state[k] != null && state[k] !== 'all' && state[k] !== false; })
    .map(function (k) { return encodeURIComponent(k) + '=' + encodeURIComponent(String(state[k])); })
    .join('&');
  const newHash = '#' + base + (params ? '?' + params : '');
  if (newHash !== window.location.hash) {
    try { history.replaceState(null, '', newHash); } catch (_) { window.location.hash = newHash; }
  }
}

// W4-12 — Local copy of useOnMount (Babel script tags don't share scope cleanly).
function useOnMount(fn) {
  const ref = useRef(false);
  useEffect(() => {
    if (ref.current) return;
    ref.current = true;
    fn();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
}

// Format an assignment date+time pair as "Sat, May 10 · 7:00 AM" in the club TZ.
function _fmtSlot(asn, clubTz) {
  if (!asn || asn._missing || asn._error) return '';
  const dStr = asn.date || '';
  const tStr = asn.time || '';
  if (!dStr) return '';
  // Build a Date at noon of the assignment date so TZ never flips the day.
  const iso = dStr + 'T12:00:00';
  const d = new Date(iso);
  let datePart = dStr;
  if (!isNaN(d.getTime())) {
    try {
      const opts = clubTz ? { timeZone: clubTz } : {};
      datePart = new Intl.DateTimeFormat('en-US', { ...opts, weekday: 'short', month: 'short', day: 'numeric' }).format(d);
    } catch (_) { /* keep dStr */ }
  }
  return tStr ? `${datePart} · ${tStr}` : datePart;
}

// Format an event timestamp as "12:34 PM ET" or "7:00:02 AM ET" (sub-minute
// precision when seconds are non-zero). clubTz controls the timezone; we
// label all formatted times with the trailing zone code from the formatter.
function _fmtEventTime(date, clubTz) {
  if (!date) return '';
  try {
    const opts = clubTz ? { timeZone: clubTz, timeZoneName: 'short' } : { timeZoneName: 'short' };
    const showSeconds = date.getSeconds() !== 0;
    const fmt = new Intl.DateTimeFormat('en-US', {
      ...opts,
      hour: 'numeric',
      minute: '2-digit',
      ...(showSeconds ? { second: '2-digit' } : {}),
      hour12: true,
    });
    return fmt.format(date);
  } catch (_) {
    return date.toLocaleTimeString();
  }
}

// Outcome derivation — combines event auditType + assignment.status into a single badge label.
// Returns { label, tone, color } where color is a CSS variable name reference.
function _outcomeOf(eventType, asn, hasAid) {
  if (!hasAid) return null;
  const status = asn && !asn._missing && !asn._error ? asn.status : '';
  const isConfirmed =
    eventType === 'BOOKING_CONFIRMED' ||
    eventType === 'SLOT_CLICK_CONFIRMED' ||
    status === 'confirmed';
  const isFailed =
    eventType === 'BOOKING_FAILED' ||
    eventType === 'BOOKING_REJECTED' ||
    eventType === 'ASSIGNED_PLAYER_FILL_FAILED' ||
    status === 'failed';
  const isTimedOut =
    eventType === 'SNIPER_TIMEOUT' ||
    eventType === 'BOOKING_CONFIRMATION_NOT_DETECTED' ||
    status === 'timed_out';
  if (isConfirmed) return { label: '✓ Confirmed', color: 'var(--md-sys-color-secondary)' };
  if (isFailed)   return { label: '✗ Failed',    color: 'var(--md-sys-color-error)' };
  if (isTimedOut) return { label: '⏱ Timed Out', color: 'var(--md-sys-color-tertiary)' };
  return { label: '… In Progress', color: 'var(--md-sys-color-outline)' };
}

// ─────────────────────────────────────────────────────────────────────────────
// EventsPage — real-time via onSnapshot(collectionGroup(db, 'events'))
// Sniper-activity-only view. configLog rows do NOT appear here.
// ─────────────────────────────────────────────────────────────────────────────
function EventsPage() {
  const store = React.useContext(AdminStoreContext);
  const { db, firestore: F } = store;
  const [events, setEvents] = useState([]);
  const [roster, setRoster] = useState([]);             // AE-1
  const [users, setUsers] = useState([]);                 // bridge: invite-code → rosterId / email
  const [assignmentsById, setAssignmentsById] = useState({}); // AE-2/3 + Slot/Group/Outcome cache
  const [paused, setPaused] = useState(false);
  const [loading, setLoading] = useState(true);
  const [drawerAsnId, setDrawerAsnId] = useState(null);   // AE-9
  const [clubTz, setClubTz] = useState('America/New_York'); // AE-8 default

  // AE-10 — restore filter state from URL hash on mount (source filter removed 2026-05-09).
  const hashInit = _readEventsHash();
  const [filter, setFilter] = useState(hashInit.filter || 'all');
  const [search, setSearch] = useState(hashInit.q || '');
  const [memberFilter, setMemberFilter] = useState(hashInit.member || '');

  // Persist filter state to URL hash.
  useEffect(() => {
    _writeEventsHash({
      filter,
      q: search,
      member: memberFilter,
    });
  }, [filter, search, memberFilter]);

  // AE-1 — pre-load persons + users on mount.
  // Users provides the bridge between invite codes (event.userId) and persons.
  // Multiple resolution paths so every invite code resolves to a real name —
  // there are no "unknown members" in the system.
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const [rosterDocs, userDocs] = await Promise.all([
          adminApi.getRoster(),
          adminApi.getUsers(),
        ]);
        if (!cancelled) {
          setRoster(rosterDocs || []);
          setUsers(userDocs || []);
        }
      } catch (e) { console.warn('events: getRoster/getUsers failed', e); }
    })();
    return () => { cancelled = true; };
  }, []);

  // AE-8 — pull club timezone from /health (already exposed by Wave 2).
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const h = await adminApi.getHealth();
        const tz = (h && (h.clubTimezone || h.clubTz)) || '';
        if (!cancelled && tz) setClubTz(tz);
      } catch (_) { /* keep default */ }
    })();
    return () => { cancelled = true; };
  }, []);

  // Person resolution caches.
  // - rosterById: rosterId → person doc (canonical name source)
  // - rosterByEmail: lowercased email → person doc (email-bridge fallback)
  // - usersByCode: invite code → user doc (bridge: code → rosterId / email / name)
  const rosterById = useMemo(() => {
    const m = {};
    (roster || []).forEach(function (p) { if (p && p.rosterId) m[p.rosterId] = p; });
    return m;
  }, [roster]);
  const rosterByEmail = useMemo(() => {
    const m = {};
    (roster || []).forEach(function (p) {
      const e = p && p.email && String(p.email).toLowerCase();
      if (e) m[e] = p;
    });
    return m;
  }, [roster]);
  const usersByCode = useMemo(() => {
    const m = {};
    (users || []).forEach(function (u) {
      // adminApi.getUsers maps doc.id onto the row (no explicit code field).
      const code = u && (u.id || u.code);
      if (code) m[code] = u;
    });
    return m;
  }, [users]);

  // Resolve an invite code (or fall back to an event's userEmail) to a member name.
  // Walks every available join path. Returns null only if no name source exists
  // anywhere — in which case caller should render the invite code itself
  // (NOT a "<unknown member>" placeholder — every code is a real person).
  function resolveMemberName(inviteCode, eventUserEmail) {
    const u = inviteCode && usersByCode[inviteCode];
    // 1. users/{code}.rosterId → persons/{rosterId}.name
    if (u && u.rosterId && rosterById[u.rosterId] && rosterById[u.rosterId].name) {
      return rosterById[u.rosterId].name;
    }
    // 2. users/{code}.name (Wave 1 /invite writes this directly)
    if (u && u.name) return u.name;
    // 3. event.userEmail → persons-by-email
    const eml1 = eventUserEmail && String(eventUserEmail).toLowerCase();
    if (eml1 && rosterByEmail[eml1] && rosterByEmail[eml1].name) {
      return rosterByEmail[eml1].name;
    }
    // 4. users/{code}.email → persons-by-email
    const eml2 = u && u.email && String(u.email).toLowerCase();
    if (eml2 && rosterByEmail[eml2] && rosterByEmail[eml2].name) {
      return rosterByEmail[eml2].name;
    }
    return null;
  }

  // Live event stream (existing pattern, kept).
  useEffect(() => {
    if (paused) return;
    setLoading(true);
    const q = F.query(
      F.collectionGroup(db, 'events'),
      F.orderBy('receivedAt', 'desc'),
      F.limit(200)
    );
    const unsub = F.onSnapshot(
      q,
      (snap) => {
        const evts = snap.docs.map((d) => {
          const data = d.data();
          const tsMs =
            data.receivedAt?.toMillis?.() ||
            (data.receivedAt?._seconds ? data.receivedAt._seconds * 1000 : null);
          return {
            id: d.id,
            type: data.type || data.auditType || 'UNKNOWN',
            userId: data.userId || (d.ref.parent.parent ? d.ref.parent.parent.id : ''),
            version: data.version || '',
            actor: data.actor || '', // still captured for backend analysis; not rendered.
            receivedAt: tsMs,
            tsMs: tsMs || 0,
            ts: data.ts || '',
            payload: data,
          };
        });
        setEvents(evts);
        setLoading(false);
      },
      (err) => {
        console.error('events onSnapshot:', err);
        setLoading(false);
      }
    );
    return unsub;
  }, [paused, db, F]);

  // AE-2/3 — assignment cache. Whenever events change, fetch any new assignmentIds.
  useEffect(() => {
    const need = new Set();
    events.forEach(function (e) {
      const aid = e.payload && e.payload.assignmentId;
      if (aid && !assignmentsById[aid]) need.add(aid);
    });
    if (need.size === 0) return;
    let cancelled = false;
    (async () => {
      const updates = {};
      for (const aid of need) {
        try {
          const snap = await F.getDoc(F.doc(db, 'assignments', aid));
          if (snap.exists()) updates[aid] = { id: aid, ...snap.data() };
          else updates[aid] = { id: aid, _missing: true };
        } catch (e) {
          updates[aid] = { id: aid, _error: true };
        }
      }
      if (!cancelled && Object.keys(updates).length) {
        setAssignmentsById(prev => ({ ...prev, ...updates }));
      }
    })();
    return () => { cancelled = true; };
  }, [events, db, F]); // eslint-disable-line react-hooks/exhaustive-deps

  function eventTone(type) {
    // Canonical names per bookateetime-extension/tools/canonical-audit-types.json.
    if (type === 'SLOT_CLICK_CONFIRMED' || type === 'BOOKING_CONFIRMED') return 'success';
    if (
      type === 'SNIPER_TIMEOUT' ||
      type === 'BOOKING_FAILED' ||
      type === 'BOOKING_REJECTED' ||
      type === 'BOOKING_CONFIRMATION_NOT_DETECTED' ||
      type === 'ASSIGNED_PLAYER_FILL_FAILED' ||
      type === 'ASSIGNED_PLAYER_FILL_NOT_VERIFIED'
    )
      return 'danger';
    if (
      type === 'FALLBACK_SLOT_CLICKED' ||
      type === 'FALLBACK_SLOT_SELECTED' ||
      (type && type.includes('WARN'))
    )
      return 'warning';
    return 'info';
  }

  // Sniper-only timeline — events only, no configLog merge.
  const filtered = events.filter((row) => {
    // Tone chip filter.
    if (filter !== 'all' && eventTone(row.type) !== filter) return false;
    // AE-10 member filter.
    if (memberFilter && row.userId !== memberFilter) return false;
    // Search across type / userId / resolved name.
    if (search) {
      const s = search.toLowerCase();
      const nm = resolveMemberName(row.userId, row.payload && row.payload.userEmail) || '';
      const hay = [row.type, row.userId, nm].join(' ').toLowerCase();
      if (!hay.includes(s)) return false;
    }
    return true;
  });

  // AE-10 — unique members for the member dropdown.
  const memberOptions = useMemo(() => {
    const seen = new Set();
    const out = [];
    events.forEach(function (r) {
      if (r.userId && !seen.has(r.userId)) {
        seen.add(r.userId);
        const nm = resolveMemberName(r.userId, r.payload && r.payload.userEmail);
        out.push({ id: r.userId, label: nm || r.userId });
      }
    });
    out.sort(function (a, b) { return a.label.localeCompare(b.label); });
    return out;
  }, [events, rosterById, rosterByEmail, usersByCode]);

  const today = new Date().toDateString();
  const todayCount = events.filter(
    (e) => e.receivedAt && new Date(e.receivedAt).toDateString() === today
  ).length;
  const lockedCount = events.filter(
    (e) => e.type === 'SLOT_CLICK_CONFIRMED' || e.type === 'BOOKING_CONFIRMED'
  ).length;
  const fallbackCount = events.filter(
    (e) => e.type === 'FALLBACK_SLOT_CLICKED' || e.type === 'FALLBACK_SLOT_SELECTED'
  ).length;
  const timeoutCount = events.filter((e) => e.type === 'SNIPER_TIMEOUT').length;

  return (
    <div className="page">
      <PageHeader
        title="Live Events"
        subtitle={paused ? 'Paused' : loading ? 'Loading…' : `${events.length} recent sniper events`}
        actions={
          <>
            <span
              style={{
                display: 'inline-flex',
                alignItems: 'center',
                gap: 6,
                padding: '4px 10px',
                background: paused ? 'transparent' : 'var(--md-sys-color-secondary-container)',
                borderRadius: 12,
              }}
            >
              <span
                style={{
                  width: 8,
                  height: 8,
                  borderRadius: '50%',
                  background: paused ? 'var(--md-sys-color-outline)' : 'var(--md-sys-color-secondary)',
                  animation: paused ? 'none' : 'pulse 2s infinite',
                }}
              />
              {paused ? 'Paused' : 'Live'}
            </span>
            <button className="btn btn-outlined" onClick={() => setPaused(!paused)}>
              {paused ? 'Resume' : 'Pause'}
            </button>
          </>
        }
      />

      <div
        className="stat-grid"
        style={{ gridTemplateColumns: 'repeat(4, 1fr)', marginBottom: 16 }}
      >
        <Stat label="Today" value={todayCount} icon="today" />
        <Stat label="Locked" value={lockedCount} tone="success" icon="lock" />
        <Stat label="Fallback" value={fallbackCount} tone="warning" icon="alt_route" />
        <Stat label="Timeout" value={timeoutCount} tone="danger" icon="timer_off" />
      </div>

      <Toolbar>
        <SearchInput value={search} onChange={setSearch} placeholder="Filter timeline" wide />
        <div className="filter-chips">
          <Chip active={filter === 'all'} onClick={() => setFilter('all')}>All</Chip>
          <Chip active={filter === 'success'} onClick={() => setFilter('success')}>Success</Chip>
          <Chip active={filter === 'warning'} onClick={() => setFilter('warning')}>Warning</Chip>
          <Chip active={filter === 'danger'} onClick={() => setFilter('danger')}>Errors</Chip>
        </div>
      </Toolbar>

      {/* Member filter. (Source filter + legacy toggle removed.) */}
      <div className="filter-chips" style={{ marginBottom: 8, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
        <span className="text-xs muted">Member:</span>
        <select
          className="input"
          style={{ padding: '4px 8px', fontSize: 12, height: 'auto' }}
          value={memberFilter}
          onChange={(e) => setMemberFilter(e.target.value)}
        >
          <option value="">(all)</option>
          {memberOptions.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
        </select>
      </div>

      {/* Vertical list — one natural-language sentence per event.
          Replaces the 7-column table (When | Sniper | Slot | Group | Outcome |
          Type | Detail) with a [time | sniper — sentence] reading order that
          scans like a log book. composeEventSentence() folds slot/group/outcome
          and friendly failure reason into the sentence itself; the canonical
          auditType is hidden inside the raw-payload <details>. */}
      <div style={{ display: 'flex', flexDirection: 'column' }}>
        {(() => {
          const rows = [];
          let lastDateKey = null;
          filtered.forEach((row) => {
            const when = row.tsMs ? new Date(row.tsMs) : null;

            // Date header break — separates events by club-TZ date.
            if (when) {
              const key = _clubDateKey(when, clubTz);
              if (key !== lastDateKey) {
                rows.push(<DateHeader key={'dh-' + key} date={when} clubTz={clubTz} />);
                lastDateKey = key;
              }
            }

            const p = row.payload || {};
            const aid = p.assignmentId || '';
            const rawAsn = aid ? assignmentsById[aid] : null;

            // Hydrate assignment players with canonical names so
            // composeEventSentence can render "with Brian, Jane, Mike".
            // We pass through a shallow-cloned assignment with player names
            // resolved via rosterById; this keeps the helper pure & data-only.
            let asn = rawAsn;
            if (rawAsn && !rawAsn._missing && !rawAsn._error && Array.isArray(rawAsn.players)) {
              asn = {
                ...rawAsn,
                players: rawAsn.players.map(function (pl) {
                  const pid = (pl && pl.rosterId) || pl;
                  const pd = pid && rosterById[pid];
                  if (pd && pd.name) return { ...(typeof pl === 'object' ? pl : {}), rosterId: pid, name: pd.name };
                  if (pl && typeof pl === 'object' && pl.name) return pl;
                  return { rosterId: pid, name: pid || '?' };
                })
              };
            }

            const rosterName = resolveMemberName(row.userId, p && p.userEmail);
            const sniperLabel = rosterName || row.userId || '—';

            const composed = composeEventSentence(row, asn, sniperLabel, clubTz) || {};
            const icon = composed.icon || '';
            const iconColor = composed.iconColor || '';
            const sentence = composed.sentence || String(row.type || 'event');
            const secondary = Array.isArray(composed.secondary) ? composed.secondary : [];

            const clickable = !!aid;
            const timeLabel = when ? _fmtEventTime(when, clubTz) : '—';
            const fullTitle = when ? when.toLocaleString() : '';

            rows.push(
              <div
                key={row.id}
                onClick={clickable ? () => setDrawerAsnId(aid) : undefined}
                title={fullTitle}
                style={{
                  display: 'grid',
                  gridTemplateColumns: '140px 1fr',
                  gap: 12,
                  padding: '8px 12px',
                  borderBottom: '1px solid var(--md-sys-color-outline-variant)',
                  cursor: clickable ? 'pointer' : 'default',
                  alignItems: 'baseline',
                }}
              >
                <div
                  className="mono text-xs"
                  style={{ color: 'var(--md-sys-color-on-surface-variant)', whiteSpace: 'nowrap' }}
                >
                  {timeLabel}
                </div>
                <div>
                  <div style={{ lineHeight: 1.5 }}>
                    <strong>{sniperLabel}</strong>
                    <span> — </span>
                    {icon && (
                      <span style={{ color: iconColor || 'inherit', fontWeight: 600 }}>
                        {icon}{' '}
                      </span>
                    )}
                    <span>{sentence}</span>
                  </div>
                  {secondary.map((ln, i) => (
                    <div
                      key={i}
                      className={ln.mono ? 'mono text-xs' : 'text-xs'}
                      style={{
                        color: ln.color || 'var(--md-sys-color-on-surface-variant)',
                        marginTop: 2,
                        ...(ln.truncate ? { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } : {}),
                      }}
                    >
                      {ln.text}
                    </div>
                  ))}
                  <details
                    style={{ marginTop: 4 }}
                    onClick={(ev) => ev.stopPropagation()}
                  >
                    <summary
                      className="text-xs"
                      style={{ cursor: 'pointer', color: 'var(--md-sys-color-on-surface-variant)' }}
                    >
                      ▸ raw payload
                    </summary>
                    <pre
                      className="mono text-xs"
                      style={{
                        margin: '4px 0 0 0',
                        padding: 8,
                        background: 'var(--md-sys-color-surface-container-low)',
                        color: 'var(--md-sys-color-on-surface-variant)',
                        borderRadius: 6,
                        whiteSpace: 'pre-wrap',
                        wordBreak: 'break-all',
                        maxHeight: 220,
                        overflow: 'auto',
                      }}
                    >
                      {JSON.stringify({ type: row.type, ...p }, null, 2)}
                    </pre>
                  </details>
                </div>
              </div>
            );
          });
          return rows;
        })()}
      </div>
      {!loading && filtered.length === 0 && (
        <EmptyState icon="bolt" title="No sniper events">
          Events appear here as the extension fires.
        </EmptyState>
      )}

      {/* AE-9 — assignment detail drawer */}
      <AssignmentDetailDrawer
        open={!!drawerAsnId}
        onClose={() => setDrawerAsnId(null)}
        assignment={drawerAsnId ? assignmentsById[drawerAsnId] : null}
        events={drawerAsnId
          ? events.filter(ev => ev.payload && ev.payload.assignmentId === drawerAsnId)
          : []}
        rosterById={rosterById}
        clubTz={clubTz}
      />
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
// LogPage — one-time getDocs from configLog with manual refresh
// ─────────────────────────────────────────────────────────────────────────────
function LogPage() {
  const store = React.useContext(AdminStoreContext);
  const { db, firestore: F } = store;
  const [log, setLog] = useState([]);
  const [loading, setLoading] = useState(true);

  async function load() {
    setLoading(true);
    try {
      const q = F.query(
        F.collection(db, 'configLog'),
        F.orderBy('changedAt', 'desc'),
        F.limit(200)
      );
      const snap = await F.getDocs(q);
      setLog(snap.docs.map((d) => ({ id: d.id, ...d.data() })));
    } catch (e) {
      console.error('log load:', e);
    } finally {
      setLoading(false);
    }
  }
  useOnMount(() => {
    load();
  });

  function actionTone(action) {
    if (!action) return 'neutral';
    if (
      action.includes('cancel') ||
      action.includes('revoke') ||
      action.includes('delete')
    )
      return 'danger';
    if (action.includes('overwrite') || action.includes('updated')) return 'warning';
    if (
      action.includes('dispatched') ||
      action.includes('approved') ||
      action.includes('seeded') ||
      action.includes('created') ||
      action.includes('migrated')
    )
      return 'success';
    return 'info';
  }

  return (
    <div className="page">
      <PageHeader
        title="Activity Log"
        subtitle={loading ? 'Loading…' : `${log.length} entries`}
        actions={
          <button className="btn btn-outlined" onClick={load}>
            <Icon name="refresh" /> Refresh
          </button>
        }
      />
      <div className="table-wrap">
        <table className="table">
          <thead>
            <tr>
              <th>Action</th>
              <th>User</th>
              <th>Detail</th>
              <th>By</th>
              <th>When</th>
            </tr>
          </thead>
          <tbody>
            {log.map((l) => {
              const when = l.changedAt?.toMillis
                ? new Date(l.changedAt.toMillis())
                : l.actorTimestamp?.toMillis
                ? new Date(l.actorTimestamp.toMillis())
                : null;
              const detailStr =
                typeof l.detail === 'string'
                  ? l.detail
                  : JSON.stringify(l.detail || {}).slice(0, 200);
              return (
                <tr key={l.id}>
                  <td>
                    <Badge tone={actionTone(l.action)}>{l.action || '—'}</Badge>
                  </td>
                  <td>{l.user || l.targetUserId || '—'}</td>
                  <td className="text-sm">{detailStr}</td>
                  <td>{l.changedBy || l.actorEmail || '—'}</td>
                  <td>{when ? formatRelTime(when) : '—'}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      {!loading && log.length === 0 && (
        <EmptyState icon="history" title="No log entries" />
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
// ConfigPage — manual override (config/app) + GitHub release info + system health
// ─────────────────────────────────────────────────────────────────────────────
function ConfigPage() {
  const store = React.useContext(AdminStoreContext);
  const { db, firestore: F, migrationV2Complete } = store;
  const toast = useToast();
  const [release, setRelease] = useState(null);
  const [yaml, setYaml] = useState('');
  const DEFAULT_CLUB_URL = 'https://www.bethesdacountryclub.org';
  const DEFAULT_TUNING = { pollInterval: 60000, rampMinutes: 5 };
  const DEFAULT_RELEASE_RULES = {
    0: { daysAhead: 3, hour: 9, minute: 0 },
    1: { daysAhead: 14, hour: 7, minute: 0 },
    2: { daysAhead: 14, hour: 7, minute: 0 },
    3: { daysAhead: 14, hour: 7, minute: 0 },
    4: { daysAhead: 14, hour: 7, minute: 0 },
    5: { daysAhead: 14, hour: 7, minute: 0 },
    6: { daysAhead: 2, hour: 9, minute: 0 },
  };
  const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

  const [config, setConfig] = useState({
    latestVersion: '',
    updateUrl: '',
    message: '',
    clubUrl: '',
    tuning: { ...DEFAULT_TUNING },
    releaseRules: { ...DEFAULT_RELEASE_RULES },
  });
  const [busy, setBusy] = useState(false);

  useOnMount(() => {
    window.adminApi
      .getReleaseInfo()
      .then((r) => {
        setRelease(r.release || null);
        setYaml(r.yaml || '');
      })
      .catch((e) => console.error('releaseInfo:', e));

    F.getDoc(F.doc(db, 'config', 'app'))
      .then((snap) => {
        if (snap.exists()) {
          const d = snap.data();
          const mergedRules = { ...DEFAULT_RELEASE_RULES };
          if (d.releaseRules && typeof d.releaseRules === 'object') {
            for (let i = 0; i < 7; i++) {
              const r = d.releaseRules[i] || d.releaseRules[String(i)];
              if (r) {
                mergedRules[i] = {
                  daysAhead: Number.isFinite(r.daysAhead) ? r.daysAhead : mergedRules[i].daysAhead,
                  hour: Number.isFinite(r.hour) ? r.hour : mergedRules[i].hour,
                  minute: Number.isFinite(r.minute) ? r.minute : mergedRules[i].minute,
                };
              }
            }
          }
          setConfig({
            latestVersion: d.latestVersion || '',
            updateUrl: d.updateUrl || '',
            message: d.message || '',
            clubUrl: d.clubUrl || '',
            tuning: {
              pollInterval: Number.isFinite(d.tuning?.pollInterval) ? d.tuning.pollInterval : DEFAULT_TUNING.pollInterval,
              rampMinutes: Number.isFinite(d.tuning?.rampMinutes) ? d.tuning.rampMinutes : DEFAULT_TUNING.rampMinutes,
            },
            releaseRules: mergedRules,
          });
        }
      })
      .catch((e) => console.error('config load:', e));
  });

  async function save() {
    setBusy(true);
    try {
      await F.setDoc(
        F.doc(db, 'config', 'app'),
        {
          latestVersion: config.latestVersion,
          updateUrl: config.updateUrl,
          message: config.message,
          clubUrl: config.clubUrl,
          tuning: config.tuning,
          releaseRules: config.releaseRules,
        },
        { merge: true }
      );
      toast('Saved', { icon: 'check' });
      try {
        store.logConfig && store.logConfig(
          'config_updated',
          '—',
          JSON.stringify({
            latestVersion: config.latestVersion,
            updateUrl: config.updateUrl,
            message: config.message,
            clubUrl: config.clubUrl,
            tuning: config.tuning,
            releaseRules: config.releaseRules,
          })
        );
      } catch (_) {}
    } catch (e) {
      toast('Save failed: ' + e.message, { icon: 'error' });
    } finally {
      setBusy(false);
    }
  }

  function updateRule(dow, patch) {
    setConfig((prev) => ({
      ...prev,
      releaseRules: {
        ...prev.releaseRules,
        [dow]: { ...prev.releaseRules[dow], ...patch },
      },
    }));
  }

  function timeToString(hour, minute) {
    return String(hour).padStart(2, '0') + ':' + String(minute).padStart(2, '0');
  }

  // M3: tri-state string from /health — 'complete' | 'incomplete' | 'unknown'
  const migrationLabel =
    migrationV2Complete === 'complete'
      ? 'COMPLETE'
      : migrationV2Complete === 'incomplete'
      ? '⚠ NOT YET RUN — dispatches blocked'
      : 'unknown';

  return (
    <div className="page">
      <PageHeader title="Config" subtitle="Global settings + release info" />
      <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
        <div>
          <h3>Manual Override (config/app)</h3>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
            <div className="field">
              <label>Latest Version</label>
              <input
                className="input"
                value={config.latestVersion}
                onChange={(e) => setConfig({ ...config, latestVersion: e.target.value })}
              />
            </div>
            <div className="field">
              <label>Update URL</label>
              <input
                className="input"
                value={config.updateUrl}
                onChange={(e) => setConfig({ ...config, updateUrl: e.target.value })}
              />
            </div>
            <div className="field">
              <label>Message</label>
              <textarea
                className="input"
                value={config.message}
                onChange={(e) => setConfig({ ...config, message: e.target.value })}
              />
            </div>
          </div>
        </div>

        <div>
          <h3>Club Settings</h3>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
            <div className="field">
              <label>Club URL</label>
              <input
                className="input"
                value={config.clubUrl}
                placeholder={DEFAULT_CLUB_URL}
                onChange={(e) => setConfig({ ...config, clubUrl: e.target.value })}
              />
            </div>
          </div>
        </div>

        <div>
          <h3>Operational Tuning</h3>
          <p className="muted">How often the extension polls /config; minutes before release window to ramp up activity.</p>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
            <div className="field">
              <label>Poll Interval (ms)</label>
              <input
                className="input"
                type="number"
                min={5000}
                step={1000}
                value={config.tuning.pollInterval}
                onChange={(e) =>
                  setConfig({
                    ...config,
                    tuning: { ...config.tuning, pollInterval: Number(e.target.value) || 0 },
                  })
                }
              />
            </div>
            <div className="field">
              <label>Ramp Minutes</label>
              <input
                className="input"
                type="number"
                min={0}
                max={60}
                value={config.tuning.rampMinutes}
                onChange={(e) =>
                  setConfig({
                    ...config,
                    tuning: { ...config.tuning, rampMinutes: Number(e.target.value) || 0 },
                  })
                }
              />
            </div>
          </div>
        </div>

        <div>
          <h3>Release Schedule</h3>
          <p className="muted">Per day-of-week: how many days ahead the tee window opens and the local release time.</p>
          <table className="table" style={{ width: '100%' }}>
            <thead>
              <tr>
                <th style={{ textAlign: 'left' }}>Day</th>
                <th style={{ textAlign: 'left' }}>Days Ahead</th>
                <th style={{ textAlign: 'left' }}>Release Time</th>
              </tr>
            </thead>
            <tbody>
              {DOW_LABELS.map((label, dow) => {
                const rule = config.releaseRules[dow] || DEFAULT_RELEASE_RULES[dow];
                return (
                  <tr key={dow}>
                    <td style={{ fontWeight: 500 }}>{label}</td>
                    <td>
                      <input
                        className="input"
                        type="number"
                        min={0}
                        max={30}
                        style={{ width: 100 }}
                        value={rule.daysAhead}
                        onChange={(e) => updateRule(dow, { daysAhead: Number(e.target.value) || 0 })}
                      />
                    </td>
                    <td>
                      <input
                        className="input"
                        type="time"
                        style={{ width: 140 }}
                        value={timeToString(rule.hour, rule.minute)}
                        onChange={(e) => {
                          const [h, m] = (e.target.value || '00:00').split(':');
                          updateRule(dow, { hour: +h || 0, minute: +m || 0 });
                        }}
                      />
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>

        <div>
          <button className="btn btn-filled" onClick={save} disabled={busy}>
            {busy ? 'Saving…' : 'Save All'}
          </button>
        </div>

        <div>
          <h3>System Health</h3>
          <DetailRow label="Migration v2">{migrationLabel}</DetailRow>
          <h3 style={{ marginTop: 24 }}>Latest Release</h3>
          {release ? (
            <>
              <DetailRow label="Tag">{release.tag_name || '—'}</DetailRow>
              <DetailRow label="Published">
                {release.published_at ? new Date(release.published_at).toLocaleString() : '—'}
              </DetailRow>
              <DetailRow label="Assets">
                {(release.assets || []).map((a) => a.name).join(', ') || '—'}
              </DetailRow>
            </>
          ) : (
            <div className="muted">Loading…</div>
          )}
          {yaml && (
            <details style={{ marginTop: 12 }}>
              <summary>release.yml</summary>
              <pre
                style={{
                  fontSize: 12,
                  background: 'var(--md-sys-color-surface-variant)',
                  padding: 12,
                  borderRadius: 8,
                  overflow: 'auto',
                }}
              >
                {yaml}
              </pre>
            </details>
          )}
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { EventsPage, LogPage, ConfigPage });
