/* SweetDeck Admin — admin.jsx
   Single-file CDN React admin tool over the daemon's /api/admin/alerts
   endpoints. Bump ADMIN_APP_VERSION on every edit.

   0.2.0 (PR 1) — Composer. Create-alert form with severity/message/
   target/dismissible/timing/dedupe-key, plus a live preview using
   AlertBanner ported verbatim from App.jsx. Handle→DID resolution
   via Bluesky's public typeahead endpoint. New daemon contracts:
   none; uses pre-existing POST /api/alerts. */

const ADMIN_APP_VERSION = "0.2.0";

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

// ─── Config ───────────────────────────────────────────────────────
// Daemon URL: production by default, localhost short-circuit for
// local testing. Mirrors the main App's resolution pattern.
const SCHEDULER_API = (() => {
  const h = (typeof window !== 'undefined' && window.location.hostname) || '';
  if (h === 'localhost' || h === '127.0.0.1') return 'http://localhost:5112';
  return 'https://ds-server-3akr.onrender.com';
})();

const STORAGE_KEY = 'sd_admin_session_v1';

// ─── Storage helpers ──────────────────────────────────────────────
function saveSession(session) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
  } catch (e) {
    console.warn('Failed to persist session:', e);
  }
}
function loadSession() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    return raw ? JSON.parse(raw) : null;
  } catch {
    return null;
  }
}
function clearSession() {
  try { localStorage.removeItem(STORAGE_KEY); } catch {}
}

// ─── Bluesky auth ─────────────────────────────────────────────────
async function bskyLogin(identifier, password) {
  const r = await fetch('https://bsky.social/xrpc/com.atproto.server.createSession', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ identifier: identifier.trim(), password }),
  });
  const data = await r.json().catch(() => ({}));
  if (!r.ok) {
    const msg = data?.message || data?.error || `HTTP ${r.status}`;
    throw new Error(msg);
  }
  return {
    accessJwt: data.accessJwt,
    refreshJwt: data.refreshJwt,
    did: data.did,
    handle: data.handle,
  };
}

async function bskyRefresh(refreshJwt) {
  const r = await fetch('https://bsky.social/xrpc/com.atproto.server.refreshSession', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${refreshJwt}` },
  });
  if (!r.ok) return null;
  const data = await r.json().catch(() => null);
  if (!data || !data.accessJwt) return null;
  return {
    accessJwt: data.accessJwt,
    refreshJwt: data.refreshJwt,
    did: data.did,
    handle: data.handle,
  };
}

// ─── Daemon fetch wrapper ─────────────────────────────────────────
// Adds Authorization Bearer + ?did=<myDid>, retries once on 401 by
// refreshing the JWT. On refresh failure, throws an AuthError so the
// caller can route to logout.
class AuthError extends Error { constructor(m) { super(m); this.name = 'AuthError'; } }
class AdminError extends Error { constructor(m) { super(m); this.name = 'AdminError'; } }

async function daemonFetch(path, opts, sessionRef, onSessionUpdate) {
  const session = sessionRef.current;
  if (!session?.accessJwt) throw new AuthError('No session');

  const url = new URL(SCHEDULER_API + path);
  // Tells require_auth to use the cached/verified path instead of
  // making a getSession round-trip per request.
  if (!url.searchParams.has('did')) url.searchParams.set('did', session.did);

  const headers = {
    ...(opts?.headers || {}),
    'Authorization': `Bearer ${session.accessJwt}`,
  };
  if (opts?.body && !headers['Content-Type']) {
    headers['Content-Type'] = 'application/json';
  }

  let r = await fetch(url.toString(), { ...opts, headers });

  if (r.status === 401 && session.refreshJwt) {
    // Try one refresh + retry.
    const refreshed = await bskyRefresh(session.refreshJwt);
    if (!refreshed?.accessJwt) {
      throw new AuthError('Session expired and refresh failed');
    }
    const next = { ...session, ...refreshed };
    onSessionUpdate(next);
    headers['Authorization'] = `Bearer ${next.accessJwt}`;
    r = await fetch(url.toString(), { ...opts, headers });
  }

  if (r.status === 401) throw new AuthError('Unauthorized');
  if (r.status === 403) throw new AdminError('Not authorized — admin tier required');

  let data = null;
  try { data = await r.json(); } catch {}

  if (!r.ok) {
    const msg = data?.error || `HTTP ${r.status}`;
    throw new Error(msg);
  }
  return data;
}

// ─── Time / status utilities ──────────────────────────────────────
function fmtAbs(ms) {
  if (!ms) return '—';
  const d = new Date(ms);
  return d.toLocaleString(undefined, {
    year: 'numeric', month: 'short', day: 'numeric',
    hour: '2-digit', minute: '2-digit',
  });
}
function fmtRel(ms, nowMs) {
  if (!ms) return '—';
  const delta = ms - nowMs;
  const abs = Math.abs(delta);
  const past = delta < 0;
  const units = [
    [60 * 1000, 'sec', 1000],
    [60 * 60 * 1000, 'min', 60 * 1000],
    [24 * 60 * 60 * 1000, 'hr', 60 * 60 * 1000],
    [30 * 24 * 60 * 60 * 1000, 'd', 24 * 60 * 60 * 1000],
    [12 * 30 * 24 * 60 * 60 * 1000, 'mo', 30 * 24 * 60 * 60 * 1000],
  ];
  let label = 'now';
  for (const [limit, unit, divisor] of units) {
    if (abs < limit) {
      const n = Math.max(1, Math.round(abs / divisor));
      label = `${n}${unit}`;
      break;
    }
  }
  if (label === 'now') label = `${Math.round(abs / (365 * 24 * 60 * 60 * 1000))}y`;
  return past ? `${label} ago` : `in ${label}`;
}

// Client-side status derivation. Lets us tick badges without
// hitting the daemon — daemon's is_active/is_scheduled/is_expired
// are pure clock comparisons, easy to reproduce.
function deriveStatus(alert, nowMs) {
  if (alert.expires_at && alert.expires_at <= nowMs) return 'expired';
  if (alert.starts_at && alert.starts_at > nowMs) return 'scheduled';
  return 'active';
}

function shortDid(did) {
  if (!did) return '';
  if (did.length <= 18) return did;
  return did.slice(0, 14) + '…';
}

// ─── AlertBanner (port from App.jsx 1.7.299) ──────────────────────
// Verbatim port of the user-facing alert banner. Used by the composer
// live preview so what the operator sees while drafting matches what
// the user will see when the alert fires. Severity → palette mapping
// uses CSS variables that admin.css mirrors from App.css (locked in
// S32). Dismissible flag controls X affordances on both edges.
const AlertBanner = ({ alert, onDismiss }) => {
  const severity = alert.severity || 'info';
  const palette = severity === 'error'
    ? { bg: 'var(--danger)', fg: '#ffffff' }
    : severity === 'warning'
    ? { bg: 'var(--warning-bg)', fg: 'var(--warning-text)' }
    : { bg: 'var(--info-bg)', fg: 'var(--info-text)' };

  const dismissBtn = (key) => (
    <button
      key={key}
      type="button"
      onClick={() => onDismiss && onDismiss(alert.id)}
      aria-label="Dismiss alert"
      style={{
        width: 22, height: 22, borderRadius: '50%',
        background: palette.fg, color: palette.bg,
        border: 'none', cursor: onDismiss ? 'pointer' : 'default',
        fontSize: 15, lineHeight: 1, padding: 0,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        flexShrink: 0, fontWeight: 700,
      }}
    >
      ×
    </button>
  );

  return (
    <div
      role="status"
      style={{
        display: 'flex', alignItems: 'center', gap: 8,
        padding: '8px 12px',
        background: palette.bg, color: palette.fg,
        borderRadius: 6, fontSize: 14, lineHeight: 1.4,
      }}
    >
      {alert.dismissible ? dismissBtn('left') : null}
      <span style={{ flex: 1, wordBreak: 'break-word' }}>
        {alert.message || <span style={{ opacity: 0.55, fontStyle: 'italic' }}>(message preview will appear here)</span>}
      </span>
      {alert.dismissible ? dismissBtn('right') : null}
    </div>
  );
};

// ─── Components ───────────────────────────────────────────────────

function LoginScreen({ onLogin, busy, error }) {
  const [identifier, setIdentifier] = useState('');
  const [password, setPassword] = useState('');

  const submit = (e) => {
    e?.preventDefault();
    if (!identifier.trim() || !password) return;
    onLogin(identifier, password);
  };

  return (
    <div className="login-shell">
      <form className="login-card" onSubmit={submit}>
        <h1>SweetDeck Admin</h1>
        <p className="subtitle">Admin-tier access only</p>

        <label htmlFor="handle">Handle or email</label>
        <input
          id="handle"
          type="text"
          autoComplete="username"
          autoFocus
          value={identifier}
          onChange={(e) => setIdentifier(e.target.value)}
          placeholder="you.bsky.social"
          disabled={busy}
        />

        <label htmlFor="password">App password</label>
        <input
          id="password"
          type="password"
          autoComplete="current-password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          placeholder="xxxx-xxxx-xxxx-xxxx"
          disabled={busy}
        />

        {error && <div className="login-error">{error}</div>}

        <button type="submit" className="login-btn" disabled={busy || !identifier.trim() || !password}>
          {busy ? 'Authenticating…' : 'Sign in'}
        </button>

        <div className="hint">
          Use a Bluesky <code>app password</code> (Settings → App passwords on
          bsky.app), not your main account password. Scoped, named, and
          revocable independently.
        </div>
      </form>
    </div>
  );
}

function NotAuthorizedScreen({ handle, onLogout }) {
  return (
    <div className="not-authorized">
      <div className="not-authorized__card">
        <h1 className="not-authorized__title">Not authorized</h1>
        <p className="not-authorized__detail">
          The account <span className="not-authorized__handle">@{handle}</span> is
          authenticated but does not have admin tier. Only admin-tier accounts
          can use the admin app.
        </p>
        <button className="modal-btn" onClick={onLogout}>Sign out</button>
      </div>
    </div>
  );
}

function StatusBadge({ status }) {
  return (
    <span className={`badge badge--status-${status}`}>{status}</span>
  );
}
function SeverityBadge({ severity }) {
  return (
    <span className={`badge badge--severity-${severity}`}>{severity}</span>
  );
}

function ScopeCell({ alert }) {
  if (alert.target_did) {
    return (
      <td className="cell-scope">
        <div className="cell-scope__kind">User</div>
        <div className="cell-scope__detail" title={alert.target_did}>
          {shortDid(alert.target_did)}
        </div>
      </td>
    );
  }
  const tiers = alert.target_tier_filter || 'all';
  return (
    <td className="cell-scope">
      <div className="cell-scope__kind">Broadcast</div>
      <div className="cell-scope__detail">{tiers}</div>
    </td>
  );
}

// Inline-editable message cell. Click to edit; Enter to save (no
// commit on blur — too easy to lose work by accident); Esc to cancel.
function MessageCell({ alert, onSave, busy }) {
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(alert.message);
  const taRef = useRef(null);

  useEffect(() => {
    if (editing && taRef.current) {
      taRef.current.focus();
      taRef.current.setSelectionRange(taRef.current.value.length, taRef.current.value.length);
    }
  }, [editing]);

  const startEdit = () => {
    setDraft(alert.message);
    setEditing(true);
  };
  const cancel = () => { setDraft(alert.message); setEditing(false); };
  const save = async () => {
    const trimmed = draft.trim();
    if (!trimmed || trimmed === alert.message) { cancel(); return; }
    const ok = await onSave(alert.id, trimmed);
    if (ok) setEditing(false);
  };
  const onKey = (e) => {
    if (e.key === 'Escape') { e.preventDefault(); cancel(); }
    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); save(); }
  };

  return (
    <td className="cell-message">
      {editing ? (
        <>
          <textarea
            ref={taRef}
            className="cell-message__edit"
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={onKey}
            disabled={busy}
          />
          <div className="cell-message__hint">
            Enter saves · Shift+Enter newline · Esc cancels
          </div>
        </>
      ) : (
        <div
          className="cell-message__text"
          onClick={startEdit}
          title="Click to edit"
        >
          {alert.message}
        </div>
      )}
    </td>
  );
}

function MetaCell({ alert, nowMs }) {
  return (
    <td className="cell-meta">
      <span className="cell-meta__line">
        <span className="cell-meta__label">CREATED</span>
        <span title={fmtAbs(alert.created_at)}>{fmtRel(alert.created_at, nowMs)}</span>
      </span>
      {alert.starts_at != null && (
        <span className="cell-meta__line">
          <span className="cell-meta__label">STARTS</span>
          <span title={fmtAbs(alert.starts_at)}>{fmtRel(alert.starts_at, nowMs)}</span>
        </span>
      )}
      <span className="cell-meta__line">
        <span className="cell-meta__label">EXPIRES</span>
        <span title={alert.expires_at ? fmtAbs(alert.expires_at) : 'never'}>
          {alert.expires_at ? fmtRel(alert.expires_at, nowMs) : 'never'}
        </span>
      </span>
      {alert.dedupe_key && (
        <span className="cell-meta__line">
          <span className="cell-meta__label">DEDUPE</span>
          <span title={alert.dedupe_key}>
            {alert.dedupe_key.length > 18 ? alert.dedupe_key.slice(0, 16) + '…' : alert.dedupe_key}
          </span>
        </span>
      )}
      {alert.dismissal_count > 0 && (
        <span className="cell-meta__line">
          <span className="cell-meta__label">DISMISSED</span>
          <span>{alert.dismissal_count}</span>
        </span>
      )}
    </td>
  );
}

function ActionsCell({ alert, busy, onAction, onRequestDelete }) {
  const noExpiry = alert.expires_at == null;
  const alreadyExpired = alert.expires_at != null && alert.expires_at <= Date.now();
  return (
    <td className="cell-actions">
      <button
        className="action-btn"
        onClick={() => onAction(alert.id, 'expire_now')}
        disabled={busy || alreadyExpired}
        title={alreadyExpired ? 'Already expired' : 'Set expires_at to now'}
      >
        Expire now
      </button>
      <button
        className="action-btn"
        onClick={() => onAction(alert.id, 'extend_1h')}
        disabled={busy || noExpiry}
        title={noExpiry ? 'No expires_at set; nothing to extend' : 'Push expires_at out by one hour'}
      >
        +1h
      </button>
      <button
        className="action-btn action-btn--danger"
        onClick={() => onRequestDelete(alert)}
        disabled={busy}
        title="Hard delete"
      >
        Delete
      </button>
    </td>
  );
}

function AlertRow({ alert, nowMs, busy, onAction, onMessageSave, onRequestDelete }) {
  const status = deriveStatus(alert, nowMs);
  return (
    <tr>
      <td className="cell-tags">
        <StatusBadge status={status} />
        <SeverityBadge severity={alert.severity} />
        <button
          className={`flag-toggle ${alert.dismissible ? 'flag-toggle--on' : 'flag-toggle--off'}`}
          onClick={() => onAction(alert.id, 'toggle_dismissible')}
          disabled={busy}
          title="Toggle dismissible flag"
        >
          {alert.dismissible ? '✓ dismissible' : '✗ persistent'}
        </button>
      </td>
      <ScopeCell alert={alert} />
      <MessageCell alert={alert} onSave={onMessageSave} busy={busy} />
      <MetaCell alert={alert} nowMs={nowMs} />
      <ActionsCell
        alert={alert}
        busy={busy}
        onAction={onAction}
        onRequestDelete={onRequestDelete}
      />
    </tr>
  );
}

function FilterBar({ filters, setFilters, onRefresh, refreshing, count, total, onToggleComposer, composerOpen }) {
  const setStatus = (v) => setFilters({ ...filters, status: v });
  const setSeverity = (v) => setFilters({ ...filters, severity: v });
  const setScope = (v) => setFilters({ ...filters, scope: v });
  const setSearch = (v) => setFilters({ ...filters, search: v });

  const Pill = ({ active, label, onClick }) => (
    <button
      className={`filter-pill ${active ? 'active' : ''}`}
      onClick={onClick}
    >
      {label}
    </button>
  );

  return (
    <div className="filter-bar">
      <div className="filter-group">
        <span className="filter-group__label">Status</span>
        <Pill active={filters.status === 'all'}        label="All"        onClick={() => setStatus('all')} />
        <Pill active={filters.status === 'active'}     label="Active"     onClick={() => setStatus('active')} />
        <Pill active={filters.status === 'scheduled'}  label="Scheduled"  onClick={() => setStatus('scheduled')} />
        <Pill active={filters.status === 'expired'}    label="Expired"    onClick={() => setStatus('expired')} />
      </div>

      <div className="filter-group">
        <span className="filter-group__label">Severity</span>
        <Pill active={filters.severity === 'all'}     label="All"     onClick={() => setSeverity('all')} />
        <Pill active={filters.severity === 'info'}    label="Info"    onClick={() => setSeverity('info')} />
        <Pill active={filters.severity === 'warning'} label="Warning" onClick={() => setSeverity('warning')} />
        <Pill active={filters.severity === 'error'}   label="Error"   onClick={() => setSeverity('error')} />
      </div>

      <div className="filter-group">
        <span className="filter-group__label">Scope</span>
        <Pill active={filters.scope === 'all'}       label="All"       onClick={() => setScope('all')} />
        <Pill active={filters.scope === 'broadcast'} label="Broadcast" onClick={() => setScope('broadcast')} />
        <Pill active={filters.scope === 'user'}      label="User"      onClick={() => setScope('user')} />
      </div>

      <input
        type="text"
        className="filter-search"
        value={filters.search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search messages…"
      />

      <div className="filter-bar__spacer" />

      <span className="filter-bar__count">
        {count}{count !== total ? ` of ${total}` : ''} alert{total === 1 ? '' : 's'}
      </span>

      <button
        className="filter-bar__refresh"
        onClick={onToggleComposer}
        title={composerOpen ? 'Close composer' : 'Open create-alert composer'}
        style={{
          marginRight: 6,
          background: composerOpen ? 'var(--accent, #4a8cff)' : undefined,
          color: composerOpen ? '#ffffff' : undefined,
          borderColor: composerOpen ? 'var(--accent, #4a8cff)' : undefined,
        }}
      >
        {composerOpen ? '× Close' : '+ New alert'}
      </button>

      <button
        className="filter-bar__refresh"
        onClick={onRefresh}
        disabled={refreshing}
        title="Refetch from daemon"
      >
        {refreshing ? '⟳ Refreshing…' : '⟳ Refresh'}
      </button>
    </div>
  );
}

// ─── Composer (PR 1 / 0.2.0) ──────────────────────────────────────
// Create-alert form. Encapsulates all field state internally; raises
// onSubmit({...body}, onSuccess) to App when the operator hits
// Create. App owns the daemon call, toast, and refetch — composer
// only responsible for collecting and validating inputs.
//
// Tier filter UX is implicit-all-on-empty: zero chips selected = no
// target_tier_filter sent (= all tiers); otherwise CSV of selected.
// This mirrors the FilterBar's existing severity-pill grammar where
// "All" is a distinct chip; here we collapse "all" into "no chips
// selected" to reduce one click for the most common case.

const ALL_TIERS = ['free', 'pro', 'elite', 'beta', 'admin'];

// Bluesky public typeahead — no auth required. Returns up to 8
// suggestions of {handle, did, displayName}. We use the public
// endpoint over the authenticated bsky.social variant because the
// operator only needs handle resolution, not access to private
// user data; public endpoint is simpler and avoids spending the
// admin's own session on lookups.
async function searchHandles(q) {
  if (!q || q.trim().length < 2) return [];
  const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q.trim())}&limit=8`;
  try {
    const r = await fetch(url);
    if (!r.ok) return [];
    const data = await r.json();
    return data.actors || [];
  } catch {
    return [];
  }
}

// PresetTimePicker — chips for the locked presets plus a "Custom…"
// chip that reveals a native datetime-local input. Returns ms
// timestamp via onChange (or null for "now"/"never" presets that
// translate to "no value sent").
//
// `mode='starts'` → Now / +1h / +24h. `mode='expires'` → 1h / 4h /
// 24h / Never. The native datetime-local input handles timezone
// display via the browser; we convert to UTC ms client-side at
// pick time.
function PresetTimePicker({ mode, valueMs, onChange }) {
  const presets = mode === 'starts'
    ? [
        { key: 'now', label: 'Now', toMs: () => null },
        { key: '+1h', label: '+1h', toMs: () => Date.now() + 60 * 60 * 1000 },
        { key: '+24h', label: '+24h', toMs: () => Date.now() + 24 * 60 * 60 * 1000 },
      ]
    : [
        { key: '1h', label: '1h', toMs: () => Date.now() + 60 * 60 * 1000 },
        { key: '4h', label: '4h', toMs: () => Date.now() + 4 * 60 * 60 * 1000 },
        { key: '24h', label: '24h', toMs: () => Date.now() + 24 * 60 * 60 * 1000 },
        { key: 'never', label: 'Never', toMs: () => null },
      ];

  const [activeKey, setActiveKey] = useState(mode === 'starts' ? 'now' : 'never');
  const [customStr, setCustomStr] = useState('');

  const pickPreset = (p) => {
    setActiveKey(p.key);
    onChange(p.toMs());
  };
  const pickCustom = () => {
    setActiveKey('custom');
    if (customStr) {
      const ms = new Date(customStr).getTime();
      if (!Number.isNaN(ms)) onChange(ms);
    } else {
      onChange(null);
    }
  };
  const onCustomChange = (e) => {
    const s = e.target.value;
    setCustomStr(s);
    if (s) {
      const ms = new Date(s).getTime();
      if (!Number.isNaN(ms)) onChange(ms);
    } else {
      onChange(null);
    }
  };

  return (
    <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 6 }}>
      {presets.map(p => (
        <button
          key={p.key}
          type="button"
          onClick={() => pickPreset(p)}
          className={`filter-pill ${activeKey === p.key ? 'active' : ''}`}
        >
          {p.label}
        </button>
      ))}
      <button
        type="button"
        onClick={pickCustom}
        className={`filter-pill ${activeKey === 'custom' ? 'active' : ''}`}
      >
        Custom…
      </button>
      {activeKey === 'custom' && (
        <input
          type="datetime-local"
          value={customStr}
          onChange={onCustomChange}
          style={{
            background: 'var(--bg-input, #0e1116)',
            color: 'var(--text-primary, #e6e8eb)',
            border: '1px solid var(--border, #2a2f37)',
            borderRadius: 4, padding: '4px 6px', fontSize: 13,
          }}
        />
      )}
    </div>
  );
}

// TierChips — toggle multiselect over the five tiers. Empty selection
// is "all tiers" by convention (no target_tier_filter sent). The
// inline counter clarifies the empty-set semantics.
function TierChips({ selected, onChange }) {
  const toggle = (t) => {
    const next = new Set(selected);
    if (next.has(t)) next.delete(t); else next.add(t);
    onChange(next);
  };
  return (
    <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 6 }}>
      {ALL_TIERS.map(t => (
        <button
          key={t}
          type="button"
          onClick={() => toggle(t)}
          className={`filter-pill ${selected.has(t) ? 'active' : ''}`}
        >
          {t}
        </button>
      ))}
      <span style={{
        alignSelf: 'center', fontSize: 12,
        color: 'var(--text-muted, #8a929c)', marginLeft: 4,
      }}>
        {selected.size === 0 ? 'all tiers' : `${selected.size} tier${selected.size === 1 ? '' : 's'}`}
      </span>
    </div>
  );
}

// HandleSearch — debounced typeahead via Bluesky's public endpoint.
// Resolves the typed handle to a DID. The DID is what gets sent to
// the daemon (target_did); the handle is purely UX.
function HandleSearch({ value, onResolve }) {
  const [q, setQ] = useState(value || '');
  const [results, setResults] = useState([]);
  const [resolved, setResolved] = useState(null);
  const [showResults, setShowResults] = useState(false);
  const searchTimeout = useRef(null);

  const onChange = (e) => {
    const v = e.target.value;
    setQ(v);
    setResolved(null);
    onResolve(null);
    if (searchTimeout.current) clearTimeout(searchTimeout.current);
    if (v.trim().length < 2) {
      setResults([]);
      setShowResults(false);
      return;
    }
    searchTimeout.current = setTimeout(async () => {
      const actors = await searchHandles(v);
      setResults(actors);
      setShowResults(true);
    }, 200);
  };

  const pick = (actor) => {
    setResolved(actor);
    setQ(actor.handle);
    setShowResults(false);
    onResolve(actor);
  };

  return (
    <div style={{ position: 'relative' }}>
      <input
        type="text"
        value={q}
        onChange={onChange}
        onFocus={() => { if (results.length > 0 && !resolved) setShowResults(true); }}
        onBlur={() => setTimeout(() => setShowResults(false), 150)}
        placeholder="user.bsky.social"
        style={{
          width: '100%', maxWidth: 360,
          background: 'var(--bg-input, #0e1116)',
          color: 'var(--text-primary, #e6e8eb)',
          border: '1px solid var(--border, #2a2f37)',
          borderRadius: 4, padding: '6px 8px', fontSize: 13,
          fontFamily: 'inherit',
        }}
      />
      {showResults && results.length > 0 && (
        <div
          style={{
            position: 'absolute', top: '100%', left: 0,
            width: '100%', maxWidth: 360, marginTop: 2,
            background: 'var(--bg-elevated, #161a21)',
            border: '1px solid var(--border, #2a2f37)',
            borderRadius: 4, zIndex: 20,
            maxHeight: 240, overflow: 'auto',
          }}
        >
          {results.map(actor => (
            <div
              key={actor.did}
              onMouseDown={() => pick(actor)}
              style={{
                padding: '6px 10px', cursor: 'pointer',
                borderBottom: '1px solid var(--border, #2a2f37)',
                fontSize: 13,
              }}
              onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-hover, #1f242c)'}
              onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
            >
              <div style={{ color: 'var(--text-primary, #e6e8eb)' }}>
                {actor.displayName || actor.handle}
              </div>
              <div style={{ color: 'var(--text-muted, #8a929c)', fontSize: 11 }}>
                @{actor.handle}
              </div>
            </div>
          ))}
        </div>
      )}
      {resolved && (
        <div style={{
          marginTop: 4, fontSize: 11,
          color: 'var(--text-muted, #8a929c)', fontFamily: 'monospace',
        }}>
          → {resolved.did}
        </div>
      )}
    </div>
  );
}

// DedupeKeyInput — free-text input with combobox dropdown sourced
// from existing alerts' dedupe_keys. Soft warning if the typed key
// matches a live alert sharing the same scope (operator can still
// submit; daemon is idempotent and returns deduped=true).
function DedupeKeyInput({ value, onChange, existingKeys, conflictAlert }) {
  const [showSuggest, setShowSuggest] = useState(false);

  const filtered = useMemo(() => {
    const q = (value || '').trim().toLowerCase();
    if (!q) return existingKeys.slice(0, 8);
    return existingKeys.filter(k => k.toLowerCase().includes(q)).slice(0, 8);
  }, [value, existingKeys]);

  return (
    <div style={{ position: 'relative' }}>
      <input
        type="text"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        onFocus={() => setShowSuggest(true)}
        onBlur={() => setTimeout(() => setShowSuggest(false), 150)}
        placeholder="(optional) e.g. trial-ending-soon"
        style={{
          width: '100%', maxWidth: 360,
          background: 'var(--bg-input, #0e1116)',
          color: 'var(--text-primary, #e6e8eb)',
          border: '1px solid var(--border, #2a2f37)',
          borderRadius: 4, padding: '6px 8px', fontSize: 13,
          fontFamily: 'monospace',
        }}
      />
      {showSuggest && filtered.length > 0 && (
        <div
          style={{
            position: 'absolute', top: '100%', left: 0,
            width: '100%', maxWidth: 360, marginTop: 2,
            background: 'var(--bg-elevated, #161a21)',
            border: '1px solid var(--border, #2a2f37)',
            borderRadius: 4, zIndex: 20,
            maxHeight: 200, overflow: 'auto',
          }}
        >
          {filtered.map(k => (
            <div
              key={k}
              onMouseDown={() => onChange(k)}
              style={{
                padding: '5px 10px', cursor: 'pointer', fontSize: 13,
                color: 'var(--text-primary, #e6e8eb)',
                borderBottom: '1px solid var(--border, #2a2f37)',
                fontFamily: 'monospace',
              }}
              onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-hover, #1f242c)'}
              onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
            >
              {k}
            </div>
          ))}
        </div>
      )}
      {conflictAlert && (
        <div style={{
          marginTop: 6, fontSize: 12,
          color: 'var(--warning-text, #d4a200)',
        }}>
          ⚠ Live alert <code>#{conflictAlert.id}</code> already uses this key
          {conflictAlert.expires_at
            ? ` (expires in ~${Math.max(0, Math.round((conflictAlert.expires_at - Date.now()) / 60000))}m)`
            : ' (no expiry)'}.
          Submitting will return <code>deduped=true</code>; no new alert created.
        </div>
      )}
    </div>
  );
}

// Composer — the create-alert form. Top-level container for the
// new-alert UI. Self-contained state; raises onSubmit(body, reset)
// to App.
function Composer({ open, onCancel, onSubmit, existingAlerts, busy }) {
  const [severity, setSeverity] = useState('info');
  const [message, setMessage] = useState('');
  const [targetType, setTargetType] = useState('broadcast');
  const [resolvedActor, setResolvedActor] = useState(null);
  const [tierSelected, setTierSelected] = useState(() => new Set());
  const [dismissible, setDismissible] = useState(true);
  const [startsAt, setStartsAt] = useState(null);
  const [expiresAt, setExpiresAt] = useState(null);
  const [dedupeKey, setDedupeKey] = useState('');

  const reset = useCallback(() => {
    setSeverity('info');
    setMessage('');
    setTargetType('broadcast');
    setResolvedActor(null);
    setTierSelected(new Set());
    setDismissible(true);
    setStartsAt(null);
    setExpiresAt(null);
    setDedupeKey('');
  }, []);

  // Distinct dedupe keys from current alerts (combobox suggestions).
  const existingKeys = useMemo(() => {
    const s = new Set();
    for (const a of existingAlerts) {
      if (a.dedupe_key) s.add(a.dedupe_key);
    }
    return Array.from(s).sort();
  }, [existingAlerts]);

  // Conflict detection — same dedupe_key + same scope (target_did or
  // null-broadcast) + still live (no expiry, or expiry in the future).
  // Mirrors the daemon's dedupe SELECT semantics from admin_create_alert.
  const conflictAlert = useMemo(() => {
    const k = dedupeKey.trim();
    if (!k) return null;
    const targetDid = targetType === 'user' ? resolvedActor?.did : null;
    return existingAlerts.find(a => {
      if (a.dedupe_key !== k) return false;
      if ((a.target_did || null) !== (targetDid || null)) return false;
      if (a.expires_at && a.expires_at <= Date.now()) return false;
      return true;
    }) || null;
  }, [dedupeKey, existingAlerts, targetType, resolvedActor]);

  const messageOver = message.length > 200;
  const dedupeOver = dedupeKey.length > 64;

  const canSubmit = (
    !!severity &&
    !!message.trim() &&
    (targetType === 'broadcast' || (targetType === 'user' && !!resolvedActor?.did))
  );

  const submit = () => {
    if (!canSubmit) return;
    const body = {
      severity,
      message: message.trim(),
      dismissible,
    };
    if (targetType === 'user') {
      body.target_did = resolvedActor.did;
    } else if (tierSelected.size > 0) {
      body.target_tier_filter = Array.from(tierSelected).join(',');
    }
    if (startsAt != null) body.starts_at = startsAt;
    if (expiresAt != null) body.expires_at = expiresAt;
    const k = dedupeKey.trim();
    if (k) body.dedupe_key = k;
    onSubmit(body, reset);
  };

  if (!open) return null;

  // Preview alert object — synthesized from current form state for
  // AlertBanner. id=0 placeholder (never reaches the daemon).
  const previewAlert = {
    id: 0,
    severity,
    message,
    dismissible,
  };

  // Metadata strip below the preview banner — the locked answer to
  // "what does live preview render for tier-filtered broadcasts."
  // Banner = visual truth for users (identical regardless of tier
  // filter); strip = operator's metadata for context.
  let scopeText;
  if (targetType === 'user') {
    scopeText = resolvedActor
      ? `User: @${resolvedActor.handle} (${shortDid(resolvedActor.did)})`
      : 'User: (no handle resolved yet)';
  } else if (tierSelected.size === 0) {
    scopeText = 'Broadcast — all tiers';
  } else {
    scopeText = `Broadcast — ${Array.from(tierSelected).join(', ')}`;
  }
  const startsText = startsAt == null ? 'Live now' : `Scheduled for ${fmtAbs(startsAt)}`;
  const expiresText = expiresAt == null ? 'Never expires' : `Auto-expires ${fmtAbs(expiresAt)}`;

  // Local style atoms — kept in component scope rather than admin.css
  // since admin.css wasn't part of this PR's edit set. Tokens reference
  // CSS variables that admin.css already defines (mirrored from App.css
  // per S32 locked decisions); fallbacks shown in case a token name
  // drifts.
  const fieldLabel = {
    fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
    textTransform: 'uppercase', color: 'var(--text-muted, #8a929c)',
    marginBottom: 4, display: 'block',
  };
  const fieldRow = { display: 'flex', flexDirection: 'column', gap: 4 };
  const sectionGap = { marginBottom: 14 };
  const inputBase = {
    background: 'var(--bg-input, #0e1116)',
    color: 'var(--text-primary, #e6e8eb)',
    border: '1px solid var(--border, #2a2f37)',
    borderRadius: 4, padding: '6px 8px', fontSize: 13,
    width: '100%', fontFamily: 'inherit',
  };

  return (
    <div
      style={{
        margin: '12px 16px',
        padding: 16,
        background: 'var(--bg-elevated, #161a21)',
        border: '1px solid var(--border, #2a2f37)',
        borderRadius: 8,
      }}
    >
      <div style={{ display: 'flex', alignItems: 'center', marginBottom: 14 }}>
        <h2 style={{
          margin: 0, fontSize: 16, fontWeight: 600,
          color: 'var(--text-primary, #e6e8eb)',
        }}>
          New alert
        </h2>
        <div style={{ flex: 1 }} />
        <button className="modal-btn" onClick={onCancel} disabled={busy}>
          Cancel
        </button>
      </div>

      {/* Severity */}
      <div style={{ ...fieldRow, ...sectionGap }}>
        <label style={fieldLabel}>Severity</label>
        <div style={{ display: 'flex', gap: 6 }}>
          {['info', 'warning', 'error'].map(s => (
            <button
              key={s}
              type="button"
              onClick={() => setSeverity(s)}
              className={`filter-pill ${severity === s ? 'active' : ''}`}
            >
              {s}
            </button>
          ))}
        </div>
      </div>

      {/* Message */}
      <div style={{ ...fieldRow, ...sectionGap }}>
        <label style={fieldLabel}>Message</label>
        <textarea
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          placeholder="What should users see?"
          rows={2}
          style={{ ...inputBase, resize: 'vertical' }}
        />
        <div style={{
          fontSize: 11,
          color: messageOver ? 'var(--warning-text, #d4a200)' : 'var(--text-muted, #8a929c)',
        }}>
          {message.length} / 200 chars
          {messageOver ? ' — banner is one row; long text wraps awkwardly.' : ''}
        </div>
      </div>

      {/* Target */}
      <div style={{ ...fieldRow, ...sectionGap }}>
        <label style={fieldLabel}>Target</label>
        <div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
          <button
            type="button"
            onClick={() => setTargetType('broadcast')}
            className={`filter-pill ${targetType === 'broadcast' ? 'active' : ''}`}
          >
            Broadcast
          </button>
          <button
            type="button"
            onClick={() => setTargetType('user')}
            className={`filter-pill ${targetType === 'user' ? 'active' : ''}`}
          >
            Specific user
          </button>
        </div>
        {targetType === 'broadcast' ? (
          <TierChips selected={tierSelected} onChange={setTierSelected} />
        ) : (
          <HandleSearch value={resolvedActor?.handle || ''} onResolve={setResolvedActor} />
        )}
      </div>

      {/* Dismissible */}
      <div style={{ ...fieldRow, ...sectionGap }}>
        <label style={fieldLabel}>Dismissible</label>
        <div style={{ display: 'flex', gap: 6 }}>
          <button
            type="button"
            onClick={() => setDismissible(true)}
            className={`filter-pill ${dismissible ? 'active' : ''}`}
          >
            ✓ User can dismiss
          </button>
          <button
            type="button"
            onClick={() => setDismissible(false)}
            className={`filter-pill ${!dismissible ? 'active' : ''}`}
          >
            ✗ Persistent
          </button>
        </div>
      </div>

      {/* Starts at */}
      <div style={{ ...fieldRow, ...sectionGap }}>
        <label style={fieldLabel}>Starts</label>
        <PresetTimePicker mode="starts" valueMs={startsAt} onChange={setStartsAt} />
      </div>

      {/* Expires at */}
      <div style={{ ...fieldRow, ...sectionGap }}>
        <label style={fieldLabel}>Expires</label>
        <PresetTimePicker mode="expires" valueMs={expiresAt} onChange={setExpiresAt} />
      </div>

      {/* Dedupe key */}
      <div style={{ ...fieldRow, ...sectionGap }}>
        <label style={fieldLabel}>Dedupe key (optional)</label>
        <DedupeKeyInput
          value={dedupeKey}
          onChange={setDedupeKey}
          existingKeys={existingKeys}
          conflictAlert={conflictAlert}
        />
        {dedupeOver && (
          <div style={{ fontSize: 11, color: 'var(--warning-text, #d4a200)' }}>
            {dedupeKey.length} / 64 chars — long keys are harder to grep in logs.
          </div>
        )}
      </div>

      {/* Live preview */}
      <div style={{ ...sectionGap }}>
        <label style={fieldLabel}>Preview</label>
        <AlertBanner alert={previewAlert} onDismiss={null} />
        <div style={{
          marginTop: 6, fontSize: 12,
          color: 'var(--text-muted, #8a929c)',
          display: 'flex', flexDirection: 'column', gap: 2,
        }}>
          <div>{scopeText}</div>
          <div>{startsText} · {expiresText}</div>
          {dedupeKey.trim() && (
            <div>Dedupe key: <code>{dedupeKey.trim()}</code></div>
          )}
        </div>
      </div>

      {/* Submit */}
      <div style={{
        display: 'flex', justifyContent: 'flex-end',
        gap: 8, marginTop: 8,
      }}>
        <button className="modal-btn" onClick={onCancel} disabled={busy}>
          Cancel
        </button>
        <button
          className="modal-btn"
          onClick={submit}
          disabled={!canSubmit || busy}
          style={{
            background: canSubmit ? 'var(--accent, #4a8cff)' : 'var(--bg-input, #0e1116)',
            color: canSubmit ? '#ffffff' : 'var(--text-muted, #8a929c)',
            cursor: canSubmit && !busy ? 'pointer' : 'not-allowed',
            borderColor: canSubmit ? 'var(--accent, #4a8cff)' : 'var(--border, #2a2f37)',
          }}
        >
          {busy ? 'Creating…' : 'Create alert'}
        </button>
      </div>
    </div>
  );
}

function ConfirmDialog({ open, title, detail, confirmLabel, danger, onConfirm, onCancel }) {
  if (!open) return null;
  return (
    <div className="modal-overlay" onClick={onCancel}>
      <div className="modal-card" onClick={(e) => e.stopPropagation()}>
        <h2 className="modal-card__title">{title}</h2>
        <div className="modal-card__detail">{detail}</div>
        <div className="modal-card__buttons">
          <button className="modal-btn" onClick={onCancel}>Cancel</button>
          <button
            className={`modal-btn ${danger ? 'modal-btn--danger' : ''}`}
            onClick={onConfirm}
            autoFocus
          >
            {confirmLabel}
          </button>
        </div>
      </div>
    </div>
  );
}

function ToastStack({ toasts, onDismiss }) {
  return (
    <div className="toast-stack">
      {toasts.map((t) => (
        <div key={t.id} className={`toast toast--${t.kind}`} onClick={() => onDismiss(t.id)}>
          {t.message}
        </div>
      ))}
    </div>
  );
}

// ─── Top-level App ────────────────────────────────────────────────

function App() {
  const [session, setSession] = useState(() => loadSession());
  const sessionRef = useRef(session);
  useEffect(() => { sessionRef.current = session; }, [session]);

  const [authState, setAuthState] = useState('checking');
    // 'checking' | 'logged_out' | 'authed' | 'not_admin'
  const [loginBusy, setLoginBusy] = useState(false);
  const [loginError, setLoginError] = useState(null);

  const [alerts, setAlerts] = useState([]);
  const [listError, setListError] = useState(null);
  const [refreshing, setRefreshing] = useState(false);
  const [busyIds, setBusyIds] = useState(() => new Set());
  const [pendingDelete, setPendingDelete] = useState(null);

  const [filters, setFilters] = useState({
    status: 'all', severity: 'all', scope: 'all', search: '',
  });

  const [composerOpen, setComposerOpen] = useState(false);
  const [composerBusy, setComposerBusy] = useState(false);

  const [toasts, setToasts] = useState([]);
  const toastIdRef = useRef(1);
  const pushToast = useCallback((kind, message) => {
    const id = toastIdRef.current++;
    setToasts((prev) => [...prev, { id, kind, message }]);
    setTimeout(() => {
      setToasts((prev) => prev.filter((t) => t.id !== id));
    }, 4500);
  }, []);
  const dismissToast = useCallback((id) => {
    setToasts((prev) => prev.filter((t) => t.id !== id));
  }, []);

  // Clock tick for client-side status badge updates.
  const [nowMs, setNowMs] = useState(() => Date.now());
  useEffect(() => {
    const id = setInterval(() => setNowMs(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);

  const handleSessionUpdate = useCallback((next) => {
    setSession(next);
    saveSession(next);
  }, []);

  const logout = useCallback(() => {
    clearSession();
    setSession(null);
    setAuthState('logged_out');
    setAlerts([]);
    setListError(null);
  }, []);

  const fetchAlerts = useCallback(async () => {
    if (!sessionRef.current?.accessJwt) return;
    setRefreshing(true);
    setListError(null);
    try {
      const data = await daemonFetch(
        '/api/admin/alerts', { method: 'GET' },
        sessionRef, handleSessionUpdate,
      );
      setAlerts(data.alerts || []);
      setAuthState('authed');
    } catch (e) {
      if (e instanceof AuthError) {
        logout();
      } else if (e instanceof AdminError) {
        setAuthState('not_admin');
      } else {
        setListError(e.message || 'Failed to load alerts');
      }
    } finally {
      setRefreshing(false);
    }
  }, [handleSessionUpdate, logout]);

  // Boot effect: if we have a session, try to load alerts. The
  // first /api/admin/alerts call is itself the admin gate — 403
  // routes us to the not-authorized screen.
  useEffect(() => {
    if (!session?.accessJwt) {
      setAuthState('logged_out');
      return;
    }
    fetchAlerts();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [session?.accessJwt]);

  // Refetch on tab focus.
  useEffect(() => {
    if (authState !== 'authed') return;
    const onVis = () => {
      if (document.visibilityState === 'visible') fetchAlerts();
    };
    document.addEventListener('visibilitychange', onVis);
    return () => document.removeEventListener('visibilitychange', onVis);
  }, [authState, fetchAlerts]);

  const handleLogin = useCallback(async (identifier, password) => {
    setLoginBusy(true);
    setLoginError(null);
    try {
      const sess = await bskyLogin(identifier, password);
      setSession(sess);
      saveSession(sess);
      setAuthState('checking');  // fetchAlerts effect will resolve to authed/not_admin
    } catch (e) {
      setLoginError(e.message || 'Login failed');
    } finally {
      setLoginBusy(false);
    }
  }, []);

  const setRowBusy = useCallback((id, busy) => {
    setBusyIds((prev) => {
      const next = new Set(prev);
      if (busy) next.add(id); else next.delete(id);
      return next;
    });
  }, []);

  const patchAlert = useCallback(async (id, body) => {
    try {
      const data = await daemonFetch(
        `/api/admin/alerts/${id}`,
        { method: 'PATCH', body: JSON.stringify(body) },
        sessionRef, handleSessionUpdate,
      );
      // PATCH returns the refreshed row. Splice into local state so
      // we don't need a full refetch — but the merge keeps fields
      // the GET returned (dismissal_count, is_*) since those aren't
      // in the PATCH response.
      setAlerts((prev) => prev.map((a) => {
        if (a.id !== id) return a;
        return { ...a, ...data.alert };
      }));
      return true;
    } catch (e) {
      if (e instanceof AuthError) { logout(); return false; }
      pushToast('error', e.message || 'Update failed');
      return false;
    }
  }, [handleSessionUpdate, logout, pushToast]);

  const handleAction = useCallback(async (id, action) => {
    setRowBusy(id, true);
    try {
      const a = alerts.find((x) => x.id === id);
      if (!a) return;
      let body = null;
      let toastMsg = null;
      if (action === 'expire_now') {
        body = { expires_at: Date.now() };
        toastMsg = `Alert #${id} expired`;
      } else if (action === 'extend_1h') {
        const base = (a.expires_at && a.expires_at > Date.now())
          ? a.expires_at
          : Date.now();
        body = { expires_at: base + 60 * 60 * 1000 };
        toastMsg = `Alert #${id} extended by 1h`;
      } else if (action === 'toggle_dismissible') {
        body = { dismissible: !a.dismissible };
        toastMsg = `Alert #${id} ${!a.dismissible ? 'dismissible' : 'persistent'}`;
      } else {
        return;
      }
      const ok = await patchAlert(id, body);
      if (ok) pushToast('success', toastMsg);
    } finally {
      setRowBusy(id, false);
    }
  }, [alerts, patchAlert, pushToast, setRowBusy]);

  const handleMessageSave = useCallback(async (id, message) => {
    setRowBusy(id, true);
    try {
      const ok = await patchAlert(id, { message });
      if (ok) pushToast('success', `Alert #${id} message updated`);
      return ok;
    } finally {
      setRowBusy(id, false);
    }
  }, [patchAlert, pushToast, setRowBusy]);

  const handleDeleteConfirmed = useCallback(async () => {
    const a = pendingDelete;
    if (!a) return;
    setPendingDelete(null);
    setRowBusy(a.id, true);
    try {
      await daemonFetch(
        `/api/admin/alerts/${a.id}`,
        { method: 'DELETE' },
        sessionRef, handleSessionUpdate,
      );
      setAlerts((prev) => prev.filter((x) => x.id !== a.id));
      pushToast('success', `Alert #${a.id} deleted`);
    } catch (e) {
      if (e instanceof AuthError) { logout(); return; }
      pushToast('error', e.message || 'Delete failed');
    } finally {
      setRowBusy(a.id, false);
    }
  }, [pendingDelete, handleSessionUpdate, logout, pushToast, setRowBusy]);

  // Composer submit. POSTs to /api/alerts (the daemon's create endpoint;
  // admin gate is internal to the handler, not in the path). Distinguishes
  // deduped=true responses (idempotent no-op) from new creations so the
  // toast can communicate the operator's request was a no-op when that
  // happened. On success, refetches so the new row appears in the list
  // immediately — locked decision was no polling, so this is the explicit
  // refresh.
  const handleCreateAlert = useCallback(async (body, resetForm) => {
    setComposerBusy(true);
    try {
      const data = await daemonFetch(
        '/api/alerts',
        { method: 'POST', body: JSON.stringify(body) },
        sessionRef, handleSessionUpdate,
      );
      if (data.deduped) {
        pushToast(
          'success',
          `Dedupe matched alert #${data.alert_id} — no new alert created`,
        );
        // Don't close or reset — operator may want to adjust the key
        // and resubmit. Per scoping decision.
      } else {
        pushToast('success', `Alert #${data.alert_id} created`);
        resetForm && resetForm();
        setComposerOpen(false);
      }
      // Refetch in both cases — the existing-matched alert may have been
      // edited since last fetch, and the new alert needs to land in the
      // list to be actionable.
      fetchAlerts();
    } catch (e) {
      if (e instanceof AuthError) { logout(); return; }
      pushToast('error', e.message || 'Create failed');
    } finally {
      setComposerBusy(false);
    }
  }, [handleSessionUpdate, logout, pushToast, fetchAlerts]);

  // Apply filters.
  const filtered = useMemo(() => {
    const q = filters.search.trim().toLowerCase();
    return alerts.filter((a) => {
      const status = deriveStatus(a, nowMs);
      if (filters.status !== 'all' && status !== filters.status) return false;
      if (filters.severity !== 'all' && a.severity !== filters.severity) return false;
      if (filters.scope === 'broadcast' && a.target_did) return false;
      if (filters.scope === 'user' && !a.target_did) return false;
      if (q && !(a.message || '').toLowerCase().includes(q)) return false;
      return true;
    });
  }, [alerts, filters, nowMs]);

  // ─── Render ───
  if (authState === 'checking' && !session) {
    return (
      <div className="boot-splash">
        <div className="boot-splash__pulse" />
        <div className="boot-splash__label">Loading…</div>
      </div>
    );
  }

  if (authState === 'logged_out') {
    return (
      <LoginScreen
        onLogin={handleLogin}
        busy={loginBusy}
        error={loginError}
      />
    );
  }

  if (authState === 'not_admin') {
    return (
      <NotAuthorizedScreen handle={session?.handle || '?'} onLogout={logout} />
    );
  }

  return (
    <>
      <div className="topbar">
        <div className="topbar__brand">
          <span className="topbar__brand-name">SweetDeck</span>
          <span className="topbar__brand-tag">Admin</span>
        </div>
        <span className="topbar__version">v{ADMIN_APP_VERSION}</span>
        <div className="topbar__spacer" />
        <span className="topbar__handle">@{session?.handle}</span>
        <button className="topbar__logout" onClick={logout}>Sign out</button>
      </div>

      <FilterBar
        filters={filters}
        setFilters={setFilters}
        onRefresh={fetchAlerts}
        refreshing={refreshing}
        count={filtered.length}
        total={alerts.length}
        onToggleComposer={() => setComposerOpen((v) => !v)}
        composerOpen={composerOpen}
      />

      <Composer
        open={composerOpen}
        onCancel={() => setComposerOpen(false)}
        onSubmit={handleCreateAlert}
        existingAlerts={alerts}
        busy={composerBusy}
      />

      {listError && <div className="error-banner">⚠ {listError}</div>}

      <div className="alerts-shell">
        {authState === 'checking' ? (
          <div className="loading-row">Loading alerts…</div>
        ) : filtered.length === 0 ? (
          <div className="empty-state">
            <div className="empty-state__icon">⌗</div>
            <div className="empty-state__title">
              {alerts.length === 0 ? 'No alerts' : 'No alerts match your filters'}
            </div>
            <div className="empty-state__detail">
              {alerts.length === 0
                ? 'When you create an alert, it will show up here.'
                : 'Adjust the filters above to see more.'}
            </div>
          </div>
        ) : (
          <table className="alerts-table">
            <thead>
              <tr>
                <th>Tags</th>
                <th>Scope</th>
                <th>Message</th>
                <th>Timing</th>
                <th style={{ textAlign: 'right' }}>Actions</th>
              </tr>
            </thead>
            <tbody>
              {filtered.map((a) => (
                <AlertRow
                  key={a.id}
                  alert={a}
                  nowMs={nowMs}
                  busy={busyIds.has(a.id)}
                  onAction={handleAction}
                  onMessageSave={handleMessageSave}
                  onRequestDelete={(alert) => setPendingDelete(alert)}
                />
              ))}
            </tbody>
          </table>
        )}
      </div>

      <ConfirmDialog
        open={!!pendingDelete}
        title="Delete alert?"
        detail={
          pendingDelete
            ? <>This permanently removes alert <code>#{pendingDelete.id}</code> ({pendingDelete.severity}). Audit log preserves the record. This cannot be undone.</>
            : null
        }
        confirmLabel="Delete"
        danger
        onConfirm={handleDeleteConfirmed}
        onCancel={() => setPendingDelete(null)}
      />

      <ToastStack toasts={toasts} onDismiss={dismissToast} />
    </>
  );
}

// ─── Mount ────────────────────────────────────────────────────────
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
