// Roster Page — Council Phase 2 P5
// Owns: RosterPage + EditRosterEntryModal + MergeModal
// Source of truth: bookateetime-backend/hosting/admin.html lines 2366-2776
// Common deps: window.adminApi, window.AdminStoreContext, components from components.jsx
// SAFETY: no raw HTML injection; all user strings render as {value} (auto-escaped by JSX).

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

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

// 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
}

function RosterPage() {
  const toast = useToast();
  const store = React.useContext(AdminStoreContext);
  const [list, setList] = useState([]);
  const [loading, setLoading] = useState(true);
  const [search, setSearch] = useState('');
  const [editTarget, setEditTarget] = useState(null);
  const [mergeOpen, setMergeOpen] = useState(false);
  // Invite flow state
  const [inviteTarget, setInviteTarget] = useState(null); // { entry, role: 'member'|'admin' }
  const [inviteBusy, setInviteBusy] = useState(false);
  const [inviteStep, setInviteStep] = useState(''); // status text during 2-step admin flow
  const [adminClaimRecipe, setAdminClaimRecipe] = useState(null); // { name, email, code } shown after admin invite
  const [roleChangeBusy, setRoleChangeBusy] = useState(false); // shared in-flight guard for Promote/Demote (one at a time)

  async function loadList() {
    setLoading(true);
    try {
      const roster = await adminApi.getRoster();
      setList(roster);
      // HI-2: guard store.setRoster (context may not have it during HMR / cold start).
      if (store && typeof store.setRoster === 'function') {
        store.setRoster(roster.map(p => ({
          rosterId: p.rosterId,
          name: p.name,
          email: p.email,
          role: p.role,
          authStatus: p.authStatus
        })));
      }
    } catch (e) {
      toast('Load failed: ' + e.message, { icon: 'error' });
    } finally {
      setLoading(false);
    }
  }

  useOnMount(() => { loadList(); });

  // Refresh on tab visibility change (legacy parity)
  useEffect(() => {
    function onVisible() {
      if (document.visibilityState === 'visible') loadList();
    }
    document.addEventListener('visibilitychange', onVisible);
    return () => document.removeEventListener('visibilitychange', onVisible);
  }, []);

  // findDuplicates per legacy lines 2511-2519
  function findDuplicates(roster) {
    const groups = {};
    for (const p of roster) {
      const key = (p.email || '').toLowerCase().trim() || ('name:' + (p.name || '').toLowerCase().trim());
      if (!key || key === 'name:') continue;
      (groups[key] = groups[key] || []).push(p);
    }
    return Object.values(groups).filter(g => g.length > 1);
  }

  const duplicates = useMemo(() => findDuplicates(list), [list]);

  // Sort priority: no invite code (candidates) → unauth pending → auth → revoked.
  // Within each band, alphabetical by name. This surfaces the invite pool first.
  function inviteSortRank(p) {
    if (!p.inviteCode) return 0;
    if (p.authStatus === 'unauth') return 1;
    if (p.authStatus === 'auth') return 2;
    return 3; // revoked / unknown
  }

  const filtered = list
    .filter(p => {
      if (!search) return true;
      const s = search.toLowerCase();
      return (p.name || '').toLowerCase().includes(s)
        || (p.email || '').toLowerCase().includes(s)
        || (p.phone || '').includes(s);
    })
    .slice()
    .sort((a, b) => {
      const ra = inviteSortRank(a), rb = inviteSortRank(b);
      if (ra !== rb) return ra - rb;
      return (a.name || '').localeCompare(b.name || '');
    });

  // After edit/delete: refresh GroupBuilder picker if active
  function maybeRefreshGroupBuilder() {
    const date = sessionStorage.getItem('gbDate');
    if (date) {
      window.adminApi.apiRosterForDate(date).catch(() => {});
    }
  }

  // ---------- Add Person ----------
  const [addForm, setAddForm] = useState({ name: '', email: '', phone: '' });
  const [addErr, setAddErr] = useState('');

  async function submitAdd() {
    setAddErr('');
    if (!addForm.name.trim() || !addForm.email.trim()) {
      setAddErr('Name and email are required');
      return;
    }
    try {
      const normalized = {
        name: (addForm.name || '').trim(),
        email: (addForm.email || '').trim().toLowerCase(),
        phone: (addForm.phone || '').trim()
      };
      const res = await adminApi.addRosterEntry(normalized);
      toast('Added to roster', { icon: 'check' });
      try { store.logConfig && store.logConfig('person_added', (res && res.rosterId) || normalized.email, JSON.stringify({ name: normalized.name, email: normalized.email })); } catch (_) {}
      setAddForm({ name: '', email: '', phone: '' });
      await loadList();
      maybeRefreshGroupBuilder();
    } catch (e) {
      if (e.errorCode === 'PERSON_EXISTS') {
        setAddErr('A roster entry with this email already exists. See list below.');
      } else {
        setAddErr(e.message || 'Add failed');
      }
    }
  }

  // ---------- Delete Person ----------
  async function deleteRosterEntry(rosterId, name) {
    // KEEP confirm — destructive action deserves a hard pause.
    if (!confirm(`Delete ${name}? This cannot be undone.`)) return;
    try {
      await adminApi.deleteRosterEntry({ rosterId });
      toast('Removed from roster', { icon: 'check' });
      try { store.logConfig && store.logConfig('person_deleted', rosterId, JSON.stringify({ name })); } catch (_) {}
      await loadList();
      maybeRefreshGroupBuilder();
    } catch (e) {
      if (e.errorCode === 'PERSON_LINKED_TO_USER') {
        toast('Linked to an active member — revoke the user first via the Snipers tab.', { icon: 'error' });
      } else if (e.errorCode === 'PERSON_HAS_ACTIVE_ASSIGNMENTS') {
        toast(`In ${e.data?.activeCount || 'one or more'} active assignment(s). Cancel those first.`, { icon: 'error' });
      } else {
        toast('Delete failed: ' + (e.message || 'unknown'), { icon: 'error' });
      }
    }
  }

  // ---------- Invite from Roster ----------
  // Opens the confirm modal for inviting a roster entry as sniper (member) or admin.
  function openInvite(entry, role) {
    setInviteStep('');
    setInviteTarget({ entry, role });
  }

  async function confirmInvite() {
    if (!inviteTarget) return;
    const { entry, role } = inviteTarget;
    setInviteBusy(true);
    setInviteStep(role === 'admin' ? 'Step 1 of 2: Inviting…' : 'Inviting…');
    try {
      const payload = {
        name: (entry.name || '').trim(),
        email: (entry.email || '').trim().toLowerCase(),
        phone: (entry.phone || '').trim() || undefined,
        role: role === 'admin' ? 'admin' : 'member'
      };
      const res = await adminApi.invite(payload);
      const code = (res && res.code) || '(code minted)';
      try { store.logConfig && store.logConfig('roster_invited', entry.rosterId, JSON.stringify({ email: payload.email, role: payload.role, code })); } catch (_) {}

      if (role === 'admin') {
        // Step 2 — grant admin custom claim via the admin-Bearer-gated endpoint.
        // /api/promoteToAdmin lets the browser do this without exposing
        // SYNC_TOKEN. If the target hasn't signed in to Firebase Auth yet
        // (no auth user exists), the call returns AUTH_USER_NOT_FOUND —
        // fall back to surfacing the operator-runbook curl recipe.
        setInviteStep('Step 2 of 2: Granting admin claim…');
        try {
          await adminApi.promoteToAdmin({ email: payload.email });
          toast(`Invited ${payload.name} as admin — code ${code} emailed. Admin claim granted.`, { icon: 'check' });
          setInviteTarget(null);
          setInviteStep('');
        } catch (claimErr) {
          // Surface the runbook recipe so admin can finish manually. Most
          // common reason: the new admin hasn't signed in to Firebase Auth
          // even once, so there's no auth user to attach the claim to yet.
          console.warn('promoteToAdmin failed, falling back to recipe modal', claimErr.errorCode, claimErr.message);
          setInviteStep('');
          setInviteTarget(null);
          setAdminClaimRecipe({
            name: payload.name,
            email: payload.email,
            code,
            reason: claimErr.errorCode === 'AUTH_USER_NOT_FOUND'
              ? 'New admin must sign in to /admin via Firebase Auth (Google or email link) at least once before the claim can be granted. Then click "Invite as Admin" again, or run the curl recipe below.'
              : (claimErr.message || 'Step 2 failed — run the curl recipe below.'),
          });
          toast(`Invited as admin — code emailed. Step 2 needs follow-up.`, { icon: 'warning' });
        }
      } else {
        toast(`Invited ${payload.name} — code ${code} emailed`, { icon: 'check' });
        setInviteTarget(null);
      }
      await loadList();
    } catch (e) {
      if (e.errorCode === 'EMAIL_ALREADY_LINKED') {
        toast('Already linked. Revoke first.', { icon: 'error' });
      } else {
        toast('Invite failed: ' + (e.message || 'unknown'), { icon: 'error' });
      }
    } finally {
      setInviteBusy(false);
      setInviteStep('');
    }
  }

  async function handleResend(entry) {
    if (!entry.inviteCode) return;
    try {
      await adminApi.resend({ code: entry.inviteCode });
      toast(`Resent code to ${entry.email}`, { icon: 'check' });
    } catch (e) {
      toast('Resend failed: ' + (e.message || 'unknown'), { icon: 'error' });
    }
  }

  async function handleRevoke(entry) {
    if (!entry.userId && !entry.rosterId) return;
    if (!confirm(`Revoke ${entry.name}'s access? Their code will stop working immediately.`)) return;
    try {
      await adminApi.revokeUser({ userId: entry.userId || entry.rosterId });
      toast(`Revoked ${entry.name}`, { icon: 'check' });
      try { store.logConfig && store.logConfig('roster_revoked', entry.rosterId, JSON.stringify({ email: entry.email })); } catch (_) {}
      await loadList();
    } catch (e) {
      if (e.errorCode === 'PARTIAL_REVOKE') {
        toast('Partial revoke — some sessions may persist briefly.', { icon: 'warning' });
        await loadList();
      } else {
        toast('Revoke failed: ' + (e.message || 'unknown'), { icon: 'error' });
      }
    }
  }

  async function handleRevokeAndReissue(entry) {
    if (!entry.rosterId) return;
    if (!confirm(`Revoke ${entry.name}'s current code and email a fresh one?`)) return;
    try {
      const res = await adminApi.revokeAndReissueToken({ rosterId: entry.rosterId, reason: 'admin_reissue' });
      const code = (res && res.code) || '(new code emailed)';
      toast(`Reissued — new code ${code} emailed to ${entry.email}`, { icon: 'check' });
      try { store.logConfig && store.logConfig('roster_reissued', entry.rosterId, JSON.stringify({ email: entry.email, code })); } catch (_) {}
      await loadList();
    } catch (e) {
      toast('Reissue failed: ' + (e.message || 'unknown'), { icon: 'error' });
    }
  }

  // ---------- Revoke current session (per-jti) ----------
  // Kills the member's CURRENT token only — the invite code stays valid and
  // they can re-activate to get a fresh token. Distinct from "Revoke" (which
  // revokes the invite entirely) and "Revoke + Reissue" (which rotates the
  // code). Use for: a token leaked / a device needs to be logged out without
  // disrupting the member's standing. Calls /api/revokeSession.
  async function handleRevokeSession(entry) {
    if (!entry.inviteCode && !entry.rosterId) return;
    if (!confirm(`Revoke ${entry.name}'s current session? Their token stops working immediately, but their invite code stays valid — they can re-activate to get a fresh token.`)) return;
    try {
      // Prefer the invite code (= users doc id the server reads lastJti from);
      // fall back to rosterId, which the server resolves to the active code.
      await adminApi.revokeSession(entry.inviteCode ? { code: entry.inviteCode } : { rosterId: entry.rosterId });
      toast(`Revoked ${entry.name}'s session`, { icon: 'check' });
      try { store.logConfig && store.logConfig('roster_session_revoked', entry.rosterId, JSON.stringify({ email: entry.email })); } catch (_) {}
      await loadList();
    } catch (e) {
      if (e.errorCode === 'NO_ACTIVE_SESSION') {
        toast('No active session to revoke — use Revoke + Reissue to rotate the code.', { icon: 'warning' });
      } else {
        toast('Revoke session failed: ' + (e.message || 'unknown'), { icon: 'error' });
      }
    }
  }

  // ---------- Promote active sniper to admin ----------
  // Used by the Active Sniper row branch. Calls /api/promoteToAdmin which
  // grants the Firebase Auth admin custom claim WITHOUT churning the invite
  // code or credentials. If the target hasn't signed in to Firebase Auth yet
  // (AUTH_USER_NOT_FOUND), fall back to the operator runbook recipe modal —
  // same pattern as the 2-step admin invite flow at ~line 209.
  async function handlePromoteToAdmin(entry) {
    if (!entry.email) return;
    if (roleChangeBusy) return;
    if (!confirm(`Promote ${entry.name} to admin? This grants the admin claim on their existing account — invite code and credentials are preserved.`)) return;
    setRoleChangeBusy(true);
    try {
      await adminApi.promoteToAdmin({ email: entry.email });
      toast(`Promoted ${entry.name} to admin.`, { icon: 'check' });
      try { store.logConfig && store.logConfig('roster_promoted_to_admin', entry.rosterId, JSON.stringify({ email: entry.email })); } catch (_) {}
      // Optimistic local update — row re-renders into the admin-state branch.
      // Server truth reconciles on next loadList trigger (tab visibility, etc.).
      setList(prev => prev.map(p => p.rosterId === entry.rosterId ? { ...p, role: 'admin' } : p));
    } catch (e) {
      if (e.errorCode === 'AUTH_USER_NOT_FOUND') {
        // Sniper activated the extension but never signed into /admin via
        // Firebase Auth — no auth user exists to attach the claim to.
        setAdminClaimRecipe({
          name: entry.name,
          email: entry.email,
          code: entry.inviteCode || '(existing code)',
          reason: 'This sniper has not signed in to /admin via Firebase Auth (Google or email link) yet, so there is no auth user to attach the admin claim to. Have them sign in once, then click Promote to Admin again — or run the curl recipe below.',
        });
        toast('Promote needs follow-up — see runbook recipe.', { icon: 'warning' });
      } else {
        toast('Promote failed: ' + (e.message || 'unknown'), { icon: 'warning' });
      }
    } finally {
      setRoleChangeBusy(false);
    }
  }

  // ---------- Revoke admin (demote to sniper) ----------
  // Mirrors handlePromoteToAdmin. Calls /api/revokeAdmin which strips the
  // Firebase Auth admin custom claim. The member JWT and invite code are
  // preserved — the user remains an active sniper. brian@bmrkllc.com is
  // hardcoded-protected on the server (SUPER_ADMIN_PROTECTED).
  async function handleRevokeAdmin(entry) {
    if (!entry.email) return;
    if (roleChangeBusy) return;
    if (!confirm(`Demote ${entry.name} (${entry.email}) from admin back to sniper? They will lose admin console access. Their invite code and member JWT remain valid.`)) return;
    setRoleChangeBusy(true);
    try {
      await adminApi.revokeAdmin({ email: entry.email });
      toast(`Demoted ${entry.name} from admin.`, { icon: 'check' });
      try { store.logConfig && store.logConfig('roster_demoted_from_admin', entry.rosterId, JSON.stringify({ email: entry.email, name: entry.name })); } catch (_) {}
      setList(prev => prev.map(p => p.rosterId === entry.rosterId ? { ...p, role: 'sniper' } : p));
    } catch (e) {
      if (e.errorCode === 'SUPER_ADMIN_PROTECTED') {
        toast('Cannot demote the super-admin. This protection is hardcoded.', { icon: 'warning' });
      } else if (e.errorCode === 'AUTH_USER_NOT_FOUND') {
        toast('No Firebase Auth user — claim may already be revoked.', { icon: 'warning' });
      } else {
        toast('Demote failed: ' + (e.message || 'unknown'), { icon: 'warning' });
      }
    } finally {
      setRoleChangeBusy(false);
    }
  }

  // ---------- Bulk Import ----------
  const [bulkText, setBulkText] = useState('');
  const [bulkOpen, setBulkOpen] = useState(false);
  const [bulkProgress, setBulkProgress] = useState(null);
  const [bulkResult, setBulkResult] = useState(null);

  // HI-4 — comma-safe CSV parser. Handles `"Smith, John",jane@example.com,555` correctly.
  function parseCSVLine(line) {
    const result = [];
    let current = '';
    let inQuotes = false;
    for (let i = 0; i < line.length; i++) {
      const ch = line[i];
      if (ch === '"') {
        if (inQuotes && line[i + 1] === '"') { current += '"'; i++; }
        else { inQuotes = !inQuotes; }
      } else if (ch === ',' && !inQuotes) {
        result.push(current);
        current = '';
      } else {
        current += ch;
      }
    }
    result.push(current);
    return result;
  }

  function parseBulkCSV(text) {
    const lines = text.split(/\r?\n/).filter(l => l.trim());
    if (!lines.length) return [];
    const first = lines[0].toLowerCase();
    const startIdx = (first.includes('name') && first.includes('email')) ? 1 : 0;
    const rows = [];
    for (let i = startIdx; i < lines.length; i++) {
      const parts = parseCSVLine(lines[i]);
      if (parts.length < 2) continue;
      const [name, email, phone] = parts.map(s => s.trim());
      rows.push({ rowNum: i + 1, name, email, phone: phone || '' });
    }
    return rows;
  }

  async function runBulk() {
    const rows = parseBulkCSV(bulkText);
    if (rows.length === 0) { toast('No rows to import', { icon: 'error' }); return; }
    if (rows.length > 50) { toast('Maximum 50 rows per import', { icon: 'error' }); return; }
    let imported = 0, skipped = 0, failed = 0;
    const failures = [];
    for (let i = 0; i < rows.length; i++) {
      const r = rows[i];
      setBulkProgress({ current: i + 1, total: rows.length });
      if (!r.name || !r.email) {
        skipped++;
        failures.push({ row: r.rowNum, reason: 'missing name or email' });
        continue;
      }
      try {
        const normalized = {
          name: (r.name || '').trim(),
          email: (r.email || '').trim().toLowerCase(),
          phone: (r.phone || '').trim()
        };
        await adminApi.addRosterEntry(normalized);
        imported++;
      } catch (e) {
        if (e.errorCode === 'PERSON_EXISTS') {
          skipped++;
        } else {
          failed++;
          failures.push({ row: r.rowNum, reason: e.message });
        }
      }
    }
    setBulkProgress(null);
    setBulkResult({ imported, skipped, failed, failures });
    try { store.logConfig && store.logConfig('persons_bulk_imported', '—', JSON.stringify({ imported, skipped, failed })); } catch (_) {}
    await loadList();
    maybeRefreshGroupBuilder();
  }

  const [addOpen, setAddOpen] = useState(false);

  function openAdd() {
    setAddErr('');
    setAddForm({ name: '', email: '', phone: '' });
    setAddOpen(true);
  }

  async function submitAddAndClose() {
    await submitAdd();
    // submitAdd resets the form on success; close only if no error surfaced
    setAddErr(prev => {
      if (!prev) setAddOpen(false);
      return prev;
    });
  }

  return (
    <div className="page">
      <PageHeader
        title="Roster"
        subtitle={loading ? 'Loading…' : `${list.length} records`}
        actions={
          <>
            <button className="btn btn-tonal" onClick={() => setBulkOpen(true)}>
              <Icon name="upload" /> Bulk Import
            </button>
            {duplicates.length > 0 && (
              <button className="btn btn-outlined" onClick={() => setMergeOpen(true)}>
                <Icon name="merge_type" /> Merge ({duplicates.length})
              </button>
            )}
            <button className="btn btn-filled" onClick={openAdd}>
              <Icon name="add" /> Add to Roster
            </button>
          </>
        }
      />

      {/* Duplicates banner */}
      {duplicates.length > 0 && (
        <div style={{
          background: 'var(--md-sys-color-tertiary-container)',
          borderLeft: '4px solid var(--md-sys-color-tertiary)',
          padding: 12,
          borderRadius: 8,
          marginBottom: 16
        }}>
          <strong>{duplicates.length} possible duplicate group(s) detected.</strong> Use the Merge button to consolidate.
        </div>
      )}

      <Toolbar>
        <SearchInput value={search} onChange={setSearch} placeholder="Search roster" />
      </Toolbar>

      <div className="table-wrap">
        <table className="table data-table">
          <thead>
            <tr>
              <th>Name</th>
              <th>Email</th>
              <th>Phone</th>
              <th>Role</th>
              <th>Status</th>
              <th>Created</th>
              <th>Invite</th>
              <th>Actions</th>
            </tr>
          </thead>
          <tbody>
            {filtered.map(p => (
              <tr key={p.rosterId}>
                <td>{p.name || '—'}</td>
                <td>{p.email || <em>(none)</em>}</td>
                <td>{p.phone || '—'}</td>
                <td>
                  <Badge tone={
                    p.role === 'admin'  ? 'info'    :
                    p.role === 'sniper' ? 'success' :
                    p.role === 'guest'  ? 'warning' :
                    p.role === 'member' ? 'neutral' :
                    'neutral'
                  }>
                    {p.role || '—'}
                  </Badge>
                </td>
                <td>
                  <Badge tone={p.authStatus === 'auth' ? 'success' : (p.authStatus === 'revoked' ? 'danger' : 'warning')}>
                    {p.authStatus || 'unauth'}
                  </Badge>
                </td>
                <td>{p.createdAt ? formatShortDate(String(p.createdAt).slice(0, 10)) : '—'}</td>
                <td>
                  {/* Invite action area — state machine per row.
                      Reuses existing adminApi.invite/resend/revokeUser/revokeSession/revokeAndReissueToken. */}
                  {!p.inviteCode && p.role !== 'admin' && (
                    <div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-start' }}>
                      <button
                        className="btn btn-filled btn-sm"
                        style={{ background: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-on-primary)' }}
                        onClick={() => openInvite(p, 'member')}
                        disabled={!p.email}
                        title={p.email ? 'Mint code and email this person' : 'Email required'}
                      >
                        Invite as Sniper
                      </button>
                      <button
                        className="btn btn-text btn-sm"
                        onClick={() => openInvite(p, 'admin')}
                        disabled={!p.email}
                      >
                        Invite as Admin
                      </button>
                    </div>
                  )}
                  {p.inviteCode && p.authStatus === 'unauth' && p.role !== 'admin' && (
                    <div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-start' }}>
                      <span style={{
                        display: 'inline-block',
                        padding: '2px 8px',
                        borderRadius: 12,
                        background: 'var(--md-sys-color-tertiary-container)',
                        color: 'var(--md-sys-color-on-tertiary-container)',
                        fontSize: 12
                      }}>
                        🔑 Code sent: {String(p.inviteCode).slice(0, 9)}…
                      </span>
                      <div style={{ display: 'flex', gap: 4 }}>
                        <button className="btn btn-text btn-sm" onClick={() => handleResend(p)}>Resend</button>
                        <button
                          className="btn btn-text btn-sm"
                          style={{ color: 'var(--md-sys-color-error)' }}
                          onClick={() => handleRevoke(p)}
                        >
                          Revoke
                        </button>
                      </div>
                    </div>
                  )}
                  {p.inviteCode && p.authStatus === 'auth' && p.role !== 'admin' && (
                    <div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-start' }}>
                      <span style={{
                        display: 'inline-block',
                        padding: '2px 8px',
                        borderRadius: 12,
                        background: 'var(--md-sys-color-secondary)',
                        color: 'var(--md-sys-color-on-secondary)',
                        fontSize: 12
                      }}>
                        ✓ Active sniper
                      </span>
                      <div style={{ display: 'flex', gap: 4 }}>
                        <button
                          className="btn btn-text btn-sm"
                          onClick={() => handlePromoteToAdmin(p)}
                          disabled={roleChangeBusy}
                          title="Grant admin claim — keeps existing invite code and credentials"
                        >
                          Promote to Admin
                        </button>
                        <button
                          className="btn btn-text btn-sm"
                          onClick={() => handleRevokeSession(p)}
                          title="Kill the current token only — invite code stays valid; member can re-activate"
                        >
                          Revoke Session
                        </button>
                        <button className="btn btn-text btn-sm" onClick={() => handleRevokeAndReissue(p)}>
                          Revoke + Reissue
                        </button>
                        <button
                          className="btn btn-text btn-sm"
                          style={{ color: 'var(--md-sys-color-error)' }}
                          onClick={() => handleRevoke(p)}
                        >
                          Remove
                        </button>
                      </div>
                    </div>
                  )}
                  {p.inviteCode && p.role === 'admin' && p.authStatus !== 'revoked' && (
                    <div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-start' }}>
                      <span style={{
                        display: 'inline-block',
                        padding: '2px 8px',
                        borderRadius: 12,
                        background: 'var(--md-sys-color-tertiary-container)',
                        color: 'var(--md-sys-color-on-tertiary-container)',
                        fontSize: 12
                      }}>
                        👑 Admin
                      </span>
                      <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
                        {(p.email || '').toLowerCase() === 'brian@bmrkllc.com' ? (
                          <span style={{
                            fontSize: 11,
                            fontStyle: 'italic',
                            color: 'var(--md-sys-color-on-surface-variant)',
                            alignSelf: 'center'
                          }}>
                            super-admin · cannot demote
                          </span>
                        ) : (
                          <button
                            className="btn btn-text btn-sm"
                            onClick={() => handleRevokeAdmin(p)}
                            disabled={roleChangeBusy}
                            title="Strip admin claim — invite code and member JWT remain valid"
                          >
                            Demote to Sniper
                          </button>
                        )}
                        <button
                          className="btn btn-text btn-sm"
                          onClick={() => handleRevokeSession(p)}
                          title="Kill the current token only — invite code stays valid; member can re-activate"
                        >
                          Revoke Session
                        </button>
                        <button className="btn btn-text btn-sm" onClick={() => handleRevokeAndReissue(p)}>
                          Revoke + Reissue
                        </button>
                        <button
                          className="btn btn-text btn-sm"
                          style={{ color: 'var(--md-sys-color-error)' }}
                          onClick={() => handleRevoke(p)}
                        >
                          Remove
                        </button>
                      </div>
                    </div>
                  )}
                  {!p.inviteCode && p.role === 'admin' && p.authStatus !== 'revoked' && (
                    // Anomalous: admin role with no invite code. Render badge only.
                    <span style={{
                      display: 'inline-block',
                      padding: '2px 8px',
                      borderRadius: 12,
                      background: 'var(--md-sys-color-tertiary-container)',
                      color: 'var(--md-sys-color-on-tertiary-container)',
                      fontSize: 12
                    }}>
                      👑 Admin (no invite)
                    </span>
                  )}
                  {p.inviteCode && p.authStatus === 'revoked' && (
                    <span style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)' }}>revoked</span>
                  )}
                </td>
                <td>
                  <button className="btn btn-icon btn-sm" onClick={() => setEditTarget(p)} title="Edit" aria-label={`Edit ${p.name || 'entry'}`}>
                    <Icon name="edit" />
                  </button>
                  <button className="btn btn-icon btn-sm danger" onClick={() => deleteRosterEntry(p.rosterId, p.name)} title="Delete" aria-label={`Delete ${p.name || 'entry'}`}>
                    <Icon name="delete" />
                  </button>
                </td>
              </tr>
            ))}
            {!loading && filtered.length === 0 && (
              <tr><td colSpan="8" style={{ padding: 24, textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)' }}>
                No roster entries match. Use <strong>Add to Roster</strong> or <strong>Bulk Import</strong>.
              </td></tr>
            )}
          </tbody>
        </table>
      </div>

      {/* Add to Roster modal */}
      <Modal open={addOpen} onClose={() => setAddOpen(false)} title="Add to Roster">
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          <div className="field">
            <label>Name</label>
            <input className="input" type="text" placeholder="Name" value={addForm.name}
              onChange={e => setAddForm({ ...addForm, name: e.target.value })} />
          </div>
          <div className="field">
            <label>Email</label>
            <input className="input" type="email" placeholder="Email" value={addForm.email}
              onChange={e => setAddForm({ ...addForm, email: e.target.value })} />
          </div>
          <div className="field">
            <label>Phone</label>
            <input className="input" type="text" placeholder="Phone (optional)" value={addForm.phone}
              onChange={e => setAddForm({ ...addForm, phone: e.target.value })} />
          </div>
          {addErr && <div style={{ color: 'var(--md-sys-color-error)' }}>{addErr}</div>}
          <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
            <button className="btn btn-text" onClick={() => setAddOpen(false)}>Cancel</button>
            <button className="btn btn-filled" onClick={submitAddAndClose}>Add</button>
          </div>
        </div>
      </Modal>

      {/* Invite confirmation modal */}
      <Modal
        open={!!inviteTarget}
        onClose={() => { if (!inviteBusy) setInviteTarget(null); }}
        title={inviteTarget ? `Invite as ${inviteTarget.role === 'admin' ? 'Admin' : 'Sniper'}` : 'Invite'}
      >
        {inviteTarget && (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
            <div>
              Invite <strong>{inviteTarget.entry.name}</strong> ({inviteTarget.entry.email}) as a{' '}
              <strong>{inviteTarget.role === 'admin' ? 'admin' : 'sniper'}</strong>?
            </div>
            <div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>
              An invite code will be minted and emailed automatically.
              {inviteTarget.role === 'admin' && (
                <> Admin sign-in also requires the Firebase Auth admin claim — you'll see the operator runbook step after the invite is sent.</>
              )}
            </div>
            {inviteStep && (
              <div style={{ fontSize: 13, color: 'var(--md-sys-color-primary)' }}>{inviteStep}</div>
            )}
            <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
              <button className="btn btn-text" onClick={() => setInviteTarget(null)} disabled={inviteBusy}>Cancel</button>
              <button className="btn btn-filled" onClick={confirmInvite} disabled={inviteBusy}>
                {inviteBusy ? 'Sending…' : `Invite as ${inviteTarget.role === 'admin' ? 'Admin' : 'Sniper'}`}
              </button>
            </div>
          </div>
        )}
      </Modal>

      {/* Admin claim recipe modal — shown after a successful admin invite.
          /api/setAdminClaim requires SYNC_TOKEN (server-only secret), so the
          browser cannot complete step 2 directly. Operator must run the
          documented curl recipe. See docs/operations/admin-claim-bootstrap.md. */}
      <Modal
        open={!!adminClaimRecipe}
        onClose={() => setAdminClaimRecipe(null)}
        title="Finish admin provisioning"
        maxWidth={640}
      >
        {adminClaimRecipe && (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
            <div>
              Step 1 of 2 done — <strong>{adminClaimRecipe.name}</strong> ({adminClaimRecipe.email}) was emailed code{' '}
              <code>{adminClaimRecipe.code}</code>.
            </div>
            <div style={{
              background: 'var(--md-sys-color-tertiary-container)',
              color: 'var(--md-sys-color-on-tertiary-container)',
              borderLeft: '4px solid var(--md-sys-color-tertiary)',
              padding: 12,
              borderRadius: 8,
              fontSize: 13
            }}>
              <strong>Step 2 of 2 — grant the admin Auth claim.</strong> This must be run from a trusted shell
              (the SYNC_TOKEN secret is not available to the browser). Recipe:
            </div>
            <pre style={{
              background: 'var(--md-sys-color-surface-variant)',
              color: 'var(--md-sys-color-on-surface-variant)',
              padding: 12,
              borderRadius: 8,
              fontSize: 12,
              overflowX: 'auto',
              margin: 0
            }}>{`curl -X POST https://<host>/api/setAdminClaim \\
  -H "Content-Type: application/json" \\
  -H "X-Sync-Token: $SYNC_TOKEN" \\
  -d '{"email":"${adminClaimRecipe.email}"}'`}</pre>
            <div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>
              Full runbook: <code>docs/operations/admin-claim-bootstrap.md</code>. Once the claim is granted,{' '}
              {adminClaimRecipe.name} can sign in to admin via Firebase Auth.
            </div>
            <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
              <button className="btn btn-filled" onClick={() => setAdminClaimRecipe(null)}>Got it</button>
            </div>
          </div>
        )}
      </Modal>

      {/* Edit modal */}
      {editTarget && (
        <EditRosterEntryModal
          entry={editTarget}
          onClose={() => setEditTarget(null)}
          onSaved={() => { loadList(); maybeRefreshGroupBuilder(); }}
        />
      )}

      {/* Bulk Import modal */}
      <Modal
        open={bulkOpen}
        onClose={() => { setBulkOpen(false); setBulkResult(null); }}
        title="Bulk Import to Roster (max 50)"
      >
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          <p style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)', margin: 0 }}>
            Paste CSV with columns: <code>name, email, phone</code> (header optional). Max 50 rows.
          </p>
          <textarea
            value={bulkText}
            onChange={e => setBulkText(e.target.value)}
            rows={8}
            aria-label="CSV data: name, email, phone per row"
            style={{ width: '100%', fontFamily: 'monospace', fontSize: 13 }}
            placeholder={'name, email, phone\nFull Name, member@example.org, 2025550123'}
          />
          {bulkProgress && (
            <div>Importing {bulkProgress.current} of {bulkProgress.total}…</div>
          )}
          {bulkResult && (
            <div>
              <strong>Done.</strong> Imported {bulkResult.imported}. Skipped {bulkResult.skipped}. Failed {bulkResult.failed}.
              {bulkResult.failures.length > 0 && (
                <details style={{ marginTop: 8 }}>
                  <summary>{bulkResult.failures.length} failure(s)</summary>
                  <ul>
                    {bulkResult.failures.map((f, i) => (
                      <li key={i}>Row {f.row}: {f.reason}</li>
                    ))}
                  </ul>
                </details>
              )}
            </div>
          )}
          <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
            <button className="btn btn-text" onClick={() => setBulkOpen(false)}>Close</button>
            <button className="btn btn-filled" onClick={runBulk} disabled={!!bulkProgress}>Import</button>
          </div>
        </div>
      </Modal>

      {/* Merge modal */}
      <MergeModal
        open={mergeOpen}
        onClose={() => { setMergeOpen(false); loadList(); }}
        duplicates={duplicates}
      />
    </div>
  );
}

function EditRosterEntryModal({ entry, onClose, onSaved }) {
  const toast = useToast();
  const store = React.useContext(AdminStoreContext);
  const [form, setForm] = useState({
    name: entry.name || '',
    email: entry.email || '',
    phone: entry.phone || ''
  });
  const [err, setErr] = useState('');
  const [busy, setBusy] = useState(false);

  async function save() {
    setErr('');
    setBusy(true);
    try {
      const payload = {
        rosterId: entry.rosterId,
        name: (form.name || '').trim(),
        email: (form.email || '').trim().toLowerCase(),
        phone: (form.phone || '').trim()
      };
      await adminApi.updateRosterEntry(payload);
      toast('Saved', { icon: 'check' });
      try { store && store.logConfig && store.logConfig('person_updated', entry.rosterId, JSON.stringify({ name: payload.name, email: payload.email })); } catch (_) {}
      onSaved();
      onClose();
    } catch (e) {
      if (e.errorCode === 'PERSON_EXISTS_BY_EMAIL') {
        setErr('Email already used by another roster entry');
      } else {
        setErr(e.message || 'Save failed');
      }
    } finally {
      setBusy(false);
    }
  }

  return (
    <Modal open={true} onClose={onClose} title="Edit Roster Entry">
      <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
        <div className="field">
          <label>Name</label>
          <input className="input" type="text" placeholder="Name" value={form.name}
            onChange={e => setForm({ ...form, name: e.target.value })} />
        </div>
        <div className="field">
          <label>Email</label>
          <input className="input" type="email" placeholder="Email" value={form.email}
            onChange={e => setForm({ ...form, email: e.target.value })} />
        </div>
        <div className="field">
          <label>Phone</label>
          <input className="input" type="text" placeholder="Phone (optional)" value={form.phone}
            onChange={e => setForm({ ...form, phone: e.target.value })} />
        </div>
        {err && <div style={{ color: 'var(--md-sys-color-error)' }}>{err}</div>}
        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
          <button className="btn btn-text" onClick={onClose}>Cancel</button>
          <button className="btn btn-filled" onClick={save} disabled={busy}>
            {busy ? 'Saving…' : 'Save'}
          </button>
        </div>
      </div>
    </Modal>
  );
}

function MergeModal({ open, onClose, duplicates }) {
  const toast = useToast();
  const store = React.useContext(AdminStoreContext);
  const [keepIds, setKeepIds] = useState({});
  const [busy, setBusy] = useState(false);

  async function mergeGroup(group, idx) {
    const keepId = keepIds[idx];
    if (!keepId) {
      toast('Choose which record to keep', { icon: 'warning' });
      return;
    }
    const others = group.filter(p => p.rosterId !== keepId);
    setBusy(true);
    let mergedCount = 0;
    for (const other of others) {
      try {
        await adminApi.mergeRosterEntries({ keepRosterId: keepId, mergeRosterId: other.rosterId });
        mergedCount++;
        try { store && store.logConfig && store.logConfig('person_merged', keepId, JSON.stringify({ mergedFrom: other.rosterId })); } catch (_) {}
      } catch (e) {
        if (e.errorCode === 'MERGE_DUAL_AUTH') {
          toast('Both records are linked to active invite codes. Revoke one first.', { icon: 'error' });
        } else {
          toast('Merge failed: ' + e.message, { icon: 'error' });
        }
        break;
      }
    }
    setBusy(false);
    if (mergedCount > 0) toast(`Merged ${mergedCount} record(s)`, { icon: 'check' });
    onClose();
  }

  return (
    <Modal
      open={open}
      onClose={onClose}
      title={`Merge Duplicates (${duplicates.length} group${duplicates.length > 1 ? 's' : ''})`}
      maxWidth={720}
    >
      <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
        {duplicates.map((group, gi) => (
          <div key={gi} style={{
            border: '1px solid var(--md-sys-color-outline-variant)',
            borderRadius: 8,
            padding: 12
          }}>
            <strong>Group {gi + 1}:</strong> {group.length} records with same {group[0].email ? 'email' : 'name'}
            <div style={{ marginTop: 8 }}>
              {group.map(p => (
                <label key={p.rosterId} style={{
                  display: 'flex',
                  alignItems: 'center',
                  gap: 8,
                  padding: 4
                }}>
                  <input
                    type="radio"
                    name={`group-${gi}`}
                    value={p.rosterId}
                    checked={keepIds[gi] === p.rosterId}
                    onChange={() => setKeepIds({ ...keepIds, [gi]: p.rosterId })}
                  />
                  <div>
                    <strong>{p.name}</strong>
                    {p.email ? ' · ' + p.email : ''}
                    {p.phone ? ' · ' + p.phone : ''}
                    <br />
                    <small>
                      {p.role || 'guest'} · {p.authStatus || 'unauth'} · {p.inviteCode || '(no code)'} · created {formatShortDate(String(p.createdAt || '').slice(0, 10))}
                    </small>
                  </div>
                </label>
              ))}
            </div>
            <button
              className="btn btn-tonal"
              style={{ marginTop: 8 }}
              onClick={() => mergeGroup(group, gi)}
              disabled={busy || !keepIds[gi]}
            >
              Merge this group
            </button>
          </div>
        ))}
      </div>
    </Modal>
  );
}

Object.assign(window, { RosterPage });
