// admin/pages.jsx — Council Phase 2 P3 partition
// Owns: SnipersPage, AssignmentsPage (with GroupBuilder + B3 stabilization), RequestsPage, ReadinessPage, CalendarView
// Common components are imported from window globals (P2 partition).
// API client is window.adminApi (P1 partition).
// Store is window.AdminStoreContext.

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

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

// ── Helpers ──────────────────────────────────────────────────────────────
// Local formatRelTime (defined here rather than destructured from window to avoid TDZ if components.jsx loads after).
function formatRelTime(ts) {
  if (!ts) return '—';
  let d;
  try {
    if (typeof ts === 'string' || typeof ts === 'number') d = new Date(ts);
    else if (ts.toDate) d = ts.toDate();
    else if (ts.seconds) d = new Date(ts.seconds * 1000);
    else d = new Date(ts);
  } catch (e) { return '—'; }
  if (!d || isNaN(d.getTime())) return '—';
  const diffMs = Date.now() - d.getTime();
  const sec = Math.round(diffMs / 1000);
  if (sec < 60) return 'just now';
  const min = Math.round(sec / 60);
  if (min < 60) return `${min}m ago`;
  const hr = Math.round(min / 60);
  if (hr < 24) return `${hr}h ago`;
  const day = Math.round(hr / 24);
  return `${day}d ago`;
}

function _shortDate(d) {
  if (typeof window.formatShortDate === 'function') return window.formatShortDate(d);
  if (!d) return '';
  return String(d);
}

// W4-12 — Run a function exactly once on mount, StrictMode-safe.
// Intent: clean up the `useEffect(() => { loadX(); }, [])` pattern that
// would otherwise need eslint-disable for exhaustive-deps. The ref guard
// also prevents duplicate work in StrictMode dev double-invocation.
function useOnMount(fn) {
  const ref = useRef(false);
  useEffect(() => {
    if (ref.current) return;
    ref.current = true;
    fn();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
}

// W2-6 / M11 helpers — surface Wave 1 assignment fields (dispatchedAt, acknowledgedAt,
// terminalAt, terminalAuditType, confirmationNumber, firingDeviceId).

function _toDate(ts) {
  if (!ts) return null;
  try {
    if (typeof ts === 'string' || typeof ts === 'number') return new Date(ts);
    if (ts.toDate) return ts.toDate();
    if (ts.toMillis) return new Date(ts.toMillis());
    if (ts.seconds) return new Date(ts.seconds * 1000);
    if (ts._seconds) return new Date(ts._seconds * 1000);
    return new Date(ts);
  } catch (e) { return null; }
}

// Compact delta between dispatch and the extension picking the assignment up.
// "ack" was programmer shorthand for "acknowledged" and was leaking into the
// admin UI verbatim — replaced with "Picked up in Xs" so operators read it
// as a human sentence, not a debug log line.
function _ackDelta(dispatchedAt, acknowledgedAt) {
  const d = _toDate(dispatchedAt);
  const a = _toDate(acknowledgedAt);
  if (!d || !a || isNaN(d.getTime()) || isNaN(a.getTime())) return '';
  const ms = a.getTime() - d.getTime();
  if (ms < 0) return '';
  if (ms < 60_000) return `Picked up ${Math.max(1, Math.round(ms / 1000))}s after send`;
  if (ms < 3_600_000) return `Picked up ${Math.round(ms / 60_000)} min after send`;
  return `Picked up ${Math.round(ms / 3_600_000)}h after send`;
}

// "Awaiting extension pickup (5 min ago)" when dispatched but extension
// hasn't received/acknowledged it yet. Replaces the earlier "no ack yet"
// programmer-speak with operator-readable copy.
function _noAckLabel(dispatchedAt) {
  const d = _toDate(dispatchedAt);
  if (!d || isNaN(d.getTime())) return '';
  const ms = Date.now() - d.getTime();
  if (ms < 60_000) return `Awaiting extension pickup (${Math.max(1, Math.round(ms / 1000))}s ago)`;
  if (ms < 3_600_000) return `Awaiting extension pickup (${Math.round(ms / 60_000)} min ago)`;
  return `Awaiting extension pickup (${Math.round(ms / 3_600_000)}h ago)`;
}

function _shortHHMM(ts) {
  const d = _toDate(ts);
  if (!d || isNaN(d.getTime())) return '';
  return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

// Render a stored 24-hour "HH:MM" assignment time as 12-hour AM/PM for the
// admin UI. Display-only — stored values and UTC stay 24-hour. Pass-through if
// already AM/PM or unparseable.
function _time12h(t) {
  if (!t || typeof t !== 'string') return t || '';
  if (/[ap]\.?m/i.test(t)) return t.trim();
  const m = t.trim().match(/^(\d{1,2}):(\d{2})$/);
  if (!m) return t;
  let h = parseInt(m[1], 10);
  const min = m[2];
  const period = h >= 12 ? 'PM' : 'AM';
  h = h % 12; if (h === 0) h = 12;
  return `${h}:${min} ${period}`;
}

// Absolute date+time stamp, e.g. "Jun 8 · 1:15 PM" — used for the dispatch
// (sent) and acknowledged columns so admins read exact times, not a relative
// "X min after send" latency.
function _stamp(ts) {
  const d = _toDate(ts);
  if (!d || isNaN(d.getTime())) return '';
  return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} · ${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
}

// Last 8 chars of a device id, monospace; full id on hover via title attr.
function FiringDeviceCell({ id }) {
  if (!id) return null;
  const tail = String(id).slice(-8);
  return (
    <span className="mono text-xs" title={id}
      style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>
      dev:{tail}
    </span>
  );
}

// Tone colour for terminal status per architectural principle:
// confirmed → secondary (Augusta green), failed → error,
// timed_out → tertiary (sage), cancelled → outline.
function _terminalColor(status) {
  if (status === 'confirmed' || status === 'completed' || status === 'booked')
    return 'var(--md-sys-color-secondary)';
  if (status === 'failed') return 'var(--md-sys-color-error)';
  if (status === 'timed_out' || status === 'timeout')
    return 'var(--md-sys-color-tertiary)';
  if (status === 'cancelled' || status === 'expired') return 'var(--md-sys-color-outline)';
  return 'var(--md-sys-color-on-surface-variant)';
}

// Compact terminal label: "Confirmed at 14:22" / "Failed at 14:22 (BOOKING_FAILED)".
function TerminalCell({ status, terminalAt, terminalAuditType }) {
  const t = _shortHHMM(terminalAt);
  if (!t) return null;
  const color = _terminalColor(status);
  let label;
  if (status === 'confirmed' || status === 'booked') label = `Confirmed at ${t}`;
  else if (status === 'completed') label = `Completed (admin) at ${t}`;
  else if (status === 'expired') label = `Expired — no outcome reported (${t})`;
  else if (status === 'failed') label = `Failed at ${t}`;
  else if (status === 'timed_out' || status === 'timeout') label = `Timed out at ${t}`;
  else if (status === 'cancelled') label = `Cancelled at ${t}`;
  else label = `Terminal at ${t}`;
  return (
    <span className="text-xs" style={{ color }}>
      {label}
      {terminalAuditType && status === 'failed' ? ` (${terminalAuditType})` : ''}
    </span>
  );
}

// ============ SNIPERS PAGE ============
function SnipersPage() {
  const store = React.useContext(window.AdminStoreContext) || {};
  const { assignments = [] } = store;
  const toast = window.useToast ? window.useToast() : (() => {});

  const [search, setSearch] = useState('');
  const [filter, setFilter] = useState('all');
  const [inviteOpen, setInviteOpen] = useState(false);
  const [inviteForm, setInviteForm] = useState({ name: '', email: '', role: 'sniper' });
  const [users, setUsers] = useState([]);

  const reloadUsers = useCallback(() => {
    if (window.adminApi && window.adminApi.getUsers) {
      window.adminApi.getUsers().then(u => setUsers(Array.isArray(u) ? u : (u?.users || []))).catch(() => {});
    }
  }, []);

  useEffect(() => { reloadUsers(); }, [reloadUsers]);

  function latestAssignmentFor(user) {
    return assignments.find(a => a.sniperRosterId === user.rosterId);
  }

  const filtered = users.filter(u => {
    if (filter === 'admin' && u.role !== 'admin') return false;
    if (filter === 'sniper' && u.role !== 'member' && u.role !== 'sniper') return false;
    if (search) {
      const s = search.toLowerCase();
      if (!(u.name || '').toLowerCase().includes(s) && !(u.email || '').toLowerCase().includes(s)) return false;
    }
    return true;
  });

  // Two-level revoke (cert model):
  //   - 'token'  → invalidate all current JWTs, mint + email a new invite code, identity preserved
  //   - 'remove' → invalidate all current JWTs, mark person revoked, no new code
  // Setting revokeTarget opens the confirm modal; null closes it.
  const [revokeTarget, setRevokeTarget] = useState(null);
  const [revokeMode, setRevokeMode] = useState('token');
  const [revokeReason, setRevokeReason] = useState('');
  const [revokeBusy, setRevokeBusy] = useState(false);
  // Persistent code-handoff modal — surfaces when reissue succeeds but the
  // email send failed (admin must copy the new code and deliver it manually).
  // Also opens on successful email send, but auto-dismissable from there.
  const [handoff, setHandoff] = useState(null); // { name, email, newCode, emailSent, copied }

  function openRevoke(u) {
    setRevokeTarget(u);
    setRevokeMode('token');
    setRevokeReason('');
  }
  function closeRevoke() {
    if (revokeBusy) return;
    setRevokeTarget(null);
  }

  async function confirmRevoke() {
    if (!revokeTarget) return;
    const userId = revokeTarget.id;
    const name = revokeTarget.name || revokeTarget.email || userId;
    setRevokeBusy(true);
    try {
      if (revokeMode === 'token') {
        const res = await adminApi.revokeAndReissueToken({ userId, reason: revokeReason || null });
        if (res && res.ok) {
          // Open persistent handoff modal showing the new code. Auto-copy to
          // clipboard on open. If email failed, the modal stays until admin
          // explicitly dismisses it (cannot be missed).
          let copied = false;
          try {
            await navigator.clipboard.writeText(res.newCode);
            copied = true;
          } catch (_) { /* clipboard may be denied — modal still shows code */ }
          setHandoff({
            name: revokeTarget.name || revokeTarget.email || userId,
            email: revokeTarget.email || '',
            newCode: res.newCode,
            emailSent: !!res.emailSent,
            rotatedAssignmentIds: res.rotatedAssignmentIds || [],
            copied,
          });
          try { store.logConfig && store.logConfig('token_revoked_and_reissued', userId, JSON.stringify({ newCode: res.newCode, revokedCount: (res.revokedCodes || []).length, emailSent: !!res.emailSent, rotatedAssignmentIds: res.rotatedAssignmentIds || [] })); } catch (_) {}
        } else {
          toast('Reissue failed', { icon: 'error' });
        }
      } else {
        const res = await adminApi.revokeUser({ userId, reason: revokeReason || null });
        if (res && res.errorCode === 'PARTIAL_REVOKE') toast('Partial revoke — check activity log', { icon: 'warning' });
        else toast(`${name} revoked and removed`, { icon: 'block' });
        try { store.logConfig && store.logConfig('user_revoked', userId, JSON.stringify({ revokedCount: (res && res.usersRevoked || []).length })); } catch (_) {}
      }
      setRevokeTarget(null);
      reloadUsers();
    } catch (e) {
      toast('Revoke failed: ' + (e.message || 'unknown'), { icon: 'error' });
    } finally {
      setRevokeBusy(false);
    }
  }

  async function revokeOne(userId) {
    // Legacy single-confirm path retained for compatibility — new code uses openRevoke().
    if (!window.confirm('Revoke this user? This cancels their assignments.')) return;
    try {
      const res = await adminApi.revokeUser({ userId });
      if (res && res.errorCode === 'PARTIAL_REVOKE') toast('Partial revoke — check activity log', { icon: 'warning' });
      else toast('User revoked', { icon: 'block' });
      try { store.logConfig && store.logConfig('user_revoked', userId, ''); } catch (_) {}
      reloadUsers();
    } catch (e) {
      toast('Revoke failed: ' + (e.message || 'unknown'), { icon: 'error' });
    }
  }

  async function resendInvite(code) {
    try {
      await adminApi.resend({ code });
      toast('Invite resent', { icon: 'mail' });
      try { store.logConfig && store.logConfig('invite_resent', code, ''); } catch (_) {}
    } catch (e) {
      toast('Resend failed: ' + (e.message || 'unknown'), { icon: 'error' });
    }
  }

  function copyCode(code) {
    if (navigator.clipboard) navigator.clipboard.writeText(code);
    toast('Copied', { icon: 'check' });
  }

  async function submitInvite() {
    if (!inviteForm.email) { toast('Email required', { icon: 'error' }); return; }
    try {
      const res = await adminApi.invite(inviteForm);
      const code = (res && res.code) || '';
      toast(`Invite sent — code ${code}`, { icon: 'check' });
      try { store.logConfig && store.logConfig('invite_sent', code, JSON.stringify({ name: inviteForm.name, email: inviteForm.email, role: inviteForm.role })); } catch (_) {}
      setInviteOpen(false);
      setInviteForm({ name: '', email: '', role: 'sniper' });
      reloadUsers();
    } catch (e) {
      toast('Invite failed: ' + (e.message || 'unknown'), { icon: 'error' });
    }
  }

  const Icon = window.Icon, Modal = window.Modal, Toolbar = window.Toolbar, Chip = window.Chip;
  const SearchInput = window.SearchInput, Badge = window.Badge, PageHeader = window.PageHeader;

  return (
    <div className="page">
      <PageHeader title="TeeTime Snipers" subtitle={`${users.length} active`}
        actions={<button className="btn btn-filled" onClick={() => setInviteOpen(true)}><Icon name="add" /> Send Invite</button>} />
      <Toolbar>
        <SearchInput value={search} onChange={setSearch} placeholder="Search by name or email" />
        <div className="filter-chips">
          <Chip active={filter === 'all'} onClick={() => setFilter('all')}>All</Chip>
          <Chip active={filter === 'sniper'} onClick={() => setFilter('sniper')}>Sniper</Chip>
          <Chip active={filter === 'admin'} onClick={() => setFilter('admin')}>Admin</Chip>
        </div>
      </Toolbar>
      <div className="table-wrap">
      <table className="table data-table">
        <thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last seen</th><th>Latest assignment</th><th>Acknowledged</th><th>Actions</th></tr></thead>
        <tbody>
          {filtered.map(u => {
            const asn = latestAssignmentFor(u);
            const lastSeen = u.lastSeen ? formatRelTime(u.lastSeen) : '—';
            const isStale = asn && asn.dispatchedAt && (Date.now() - new Date(asn.dispatchedAt).getTime()) / (1000 * 60 * 60 * 24) > 30;
            return (
              <tr key={u.id}>
                <td>{u.name || '—'}</td>
                <td>{u.email || '—'}</td>
                <td><Badge tone={u.role === 'admin' ? 'info' : 'neutral'}>{u.role || '—'}</Badge></td>
                <td><Badge tone={u.status === 'active' ? 'success' : u.status === 'revoked' ? 'danger' : 'warning'}>{u.status || 'pending'}</Badge></td>
                <td>{lastSeen}</td>
                <td style={isStale ? { color: 'var(--md-sys-color-on-surface-variant)' } : {}}>
                  {asn ? (
                    <>
                      <div>{`${_shortDate(asn.date)} · ${_time12h(asn.time)}`}</div>
                      {asn.dispatchedAt && (
                        <div className="text-xs" style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>
                          Sent {_stamp(asn.dispatchedAt)}
                        </div>
                      )}
                    </>
                  ) : '—'}
                </td>
                <td>
                  {asn ? (
                    asn.acknowledgedAt
                      ? <span style={{ color: 'var(--md-sys-color-secondary)' }}>{_stamp(asn.acknowledgedAt)}</span>
                      : (asn.dispatchedAt && !['confirmed','completed','booked','failed','timed_out','timeout','cancelled','expired'].includes(asn.status)
                          ? <span className="text-xs" style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Awaiting pickup</span>
                          : '—')
                  ) : '—'}
                </td>
                <td>
                  <button className="btn btn-icon btn-sm" onClick={() => copyCode(u.id)} title="Copy invite code" aria-label={`Copy invite code for ${u.name || u.email || 'user'}`}><Icon name="content_copy" /></button>
                  <button className="btn btn-icon btn-sm" onClick={() => resendInvite(u.id)} title="Resend invite" aria-label={`Resend invite to ${u.name || u.email || 'user'}`}><Icon name="mail" /></button>
                  <button className="btn btn-icon btn-sm danger" onClick={() => openRevoke(u)} title="Revoke" aria-label={`Revoke ${u.name || u.email || 'user'}`}><Icon name="block" /></button>
                </td>
              </tr>
            );
          })}
          {filtered.length === 0 && (
            <tr><td colSpan="8" style={{ padding: 24, textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)' }}>No snipers match.</td></tr>
          )}
        </tbody>
      </table>
      </div>

      <Modal
        open={!!revokeTarget}
        onClose={closeRevoke}
        title={revokeTarget ? `Revoke ${revokeTarget.name || revokeTarget.email || revokeTarget.id}?` : 'Revoke'}
      >
        {revokeTarget && (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
            <p style={{ margin: 0, color: 'var(--md-sys-color-on-surface-variant)', fontSize: 13 }}>
              Two levels — like certificate revocation. Pick one.
            </p>

            <label style={{
              display: 'block',
              padding: 12,
              border: `2px solid ${revokeMode === 'token' ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline-variant)'}`,
              borderRadius: 8,
              cursor: 'pointer',
              background: revokeMode === 'token' ? 'color-mix(in srgb, var(--md-sys-color-primary) 6%, transparent)' : 'transparent'
            }}>
              <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
                <input
                  type="radio"
                  name="revoke-mode"
                  checked={revokeMode === 'token'}
                  onChange={() => setRevokeMode('token')}
                  style={{ marginTop: 4 }}
                />
                <div style={{ flex: 1 }}>
                  <strong>Revoke + Reissue Token</strong>
                  <div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 4 }}>
                    Invalidates every active session/JWT for this person, mints a new invite code, and emails it. Identity, role, and history are preserved. Use for: lost device, suspected token compromise, testing handoff.
                  </div>
                </div>
              </div>
            </label>

            <label style={{
              display: 'block',
              padding: 12,
              border: `2px solid ${revokeMode === 'remove' ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-outline-variant)'}`,
              borderRadius: 8,
              cursor: 'pointer',
              background: revokeMode === 'remove' ? 'color-mix(in srgb, var(--md-sys-color-error) 6%, transparent)' : 'transparent'
            }}>
              <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
                <input
                  type="radio"
                  name="revoke-mode"
                  checked={revokeMode === 'remove'}
                  onChange={() => setRevokeMode('remove')}
                  style={{ marginTop: 4 }}
                />
                <div style={{ flex: 1 }}>
                  <strong style={{ color: 'var(--md-sys-color-error)' }}>Revoke + Remove</strong>
                  <div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 4 }}>
                    Invalidates every active session/JWT, cancels their live assignments, and marks the person revoked (kept in history, won't appear active). No new code is issued. Use for: offboarding, policy violation. Re-invite manually later if needed.
                  </div>
                </div>
              </div>
            </label>

            <div className="field">
              <label>Reason (optional)</label>
              <input
                className="input"
                type="text"
                placeholder="e.g. lost laptop, testing handoff, offboarded"
                value={revokeReason}
                onChange={e => setRevokeReason(e.target.value)}
              />
            </div>

            <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
              <button className="btn btn-text" onClick={closeRevoke} disabled={revokeBusy}>Cancel</button>
              <button
                className={revokeMode === 'remove' ? 'btn btn-filled danger' : 'btn btn-filled'}
                onClick={confirmRevoke}
                disabled={revokeBusy}
              >
                {revokeBusy ? 'Working…' : (revokeMode === 'token' ? 'Revoke + Reissue' : 'Revoke + Remove')}
              </button>
            </div>
          </div>
        )}
      </Modal>

      {/* Persistent code-handoff modal — opens after a successful reissue.
          On email-fail this is the ONLY surface that shows the new code,
          so we make it impossible to miss: cannot be dismissed by clicking
          outside, requires explicit acknowledgment. The new code is also
          auto-copied to clipboard on open (when grants allow). */}
      <Modal
        open={!!handoff}
        onClose={() => { /* intentionally no-op — admin must acknowledge */ }}
        title={handoff && handoff.emailSent ? 'Token reset — new code emailed' : 'Token reset — email FAILED, hand off code manually'}
      >
        {handoff && (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
            {!handoff.emailSent && (
              <div style={{
                padding: 12,
                border: '2px solid var(--md-sys-color-error)',
                borderRadius: 8,
                background: 'color-mix(in srgb, var(--md-sys-color-error) 8%, transparent)',
                color: 'var(--md-sys-color-on-surface)',
              }}>
                <strong style={{ color: 'var(--md-sys-color-error)' }}>Email send failed.</strong> Copy this code and deliver it to {handoff.name} manually (text / Signal / phone). The old code is invalidated; the new code is the only valid one.
              </div>
            )}
            {handoff.emailSent && (
              <div style={{ fontSize: 14 }}>
                Emailed to <strong>{handoff.email}</strong>. The code below is also copied to your clipboard for your records.
              </div>
            )}

            <div>
              <label style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', display: 'block', marginBottom: 6 }}>
                New invite code
              </label>
              <div style={{
                fontFamily: 'var(--md-sys-typescale-mono)',
                fontSize: 22,
                fontWeight: 600,
                letterSpacing: 2,
                padding: 16,
                background: 'var(--md-sys-color-surface-container-high)',
                border: '1px solid var(--md-sys-color-outline-variant)',
                borderRadius: 8,
                textAlign: 'center',
                userSelect: 'all',
              }}>
                {handoff.newCode}
              </div>
              <div style={{ fontSize: 12, color: handoff.copied ? 'var(--md-sys-color-secondary)' : 'var(--md-sys-color-on-surface-variant)', marginTop: 6 }}>
                {handoff.copied ? '✓ Copied to clipboard' : 'Click to select; clipboard access not granted'}
              </div>
            </div>

            <div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>
              Recipient: <strong>{handoff.name}</strong>
              {handoff.email && <span> · {handoff.email}</span>}
            </div>

            {handoff.rotatedAssignmentIds && handoff.rotatedAssignmentIds.length > 0 && (
              <div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>
                {handoff.rotatedAssignmentIds.length} in-flight assignment{handoff.rotatedAssignmentIds.length === 1 ? '' : 's'} were rotated to the new code. Member's extension will pick them up on next /config poll.
              </div>
            )}

            <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
              <button
                className="btn btn-text"
                onClick={async () => {
                  try {
                    await navigator.clipboard.writeText(handoff.newCode);
                    setHandoff({ ...handoff, copied: true });
                  } catch (_) {}
                }}
              >
                {handoff.copied ? 'Copy again' : 'Copy code'}
              </button>
              <button
                className="btn btn-filled"
                onClick={() => { setHandoff(null); setRevokeTarget(null); reloadUsers(); }}
              >
                I've delivered the code
              </button>
            </div>
          </div>
        )}
      </Modal>

      <Modal open={inviteOpen} onClose={() => setInviteOpen(false)} title="Send Invite">
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          <div className="field"><label>Name</label>
            <input className="input" type="text" placeholder="Name" value={inviteForm.name} onChange={e => setInviteForm({ ...inviteForm, name: e.target.value })} /></div>
          <div className="field"><label>Email</label>
            <input className="input" type="email" placeholder="Email" value={inviteForm.email} onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })} /></div>
          <div className="field"><label>Role</label>
            <select className="input" value={inviteForm.role} onChange={e => setInviteForm({ ...inviteForm, role: e.target.value })}>
              <option value="sniper">Sniper</option>
              <option value="admin">Admin</option>
            </select></div>
          <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
            <button className="btn btn-text" onClick={() => setInviteOpen(false)}>Cancel</button>
            <button className="btn btn-filled" onClick={submitInvite}>Send</button>
          </div>
        </div>
      </Modal>
    </div>
  );
}

// ============ ASSIGNMENTS PAGE + GROUP BUILDER ============
function AssignmentsPage() {
  const store = React.useContext(window.AdminStoreContext) || {};
  const { assignments = [], refreshAssignments = () => {} } = store;
  const toast = window.useToast ? window.useToast() : (() => {});

  const [tab, setTab] = useState('active');
  const [filter, setFilter] = useState('all');
  const [builderOpen, setBuilderOpen] = useState(false);
  const [editTarget, setEditTarget] = useState(null);
  // Phase 4: tracks the assignment whose resend-email request is in flight, so we can
  // disable the button + show a "Sending…" label. null = no resend in progress.
  const [resendingId, setResendingId] = useState(null);
  // Phase 4 / L1.3 — gates the Mark-Complete row action so double-click
  // can't fire two markAssignmentCompleted calls in quick succession.
  const [completingId, setCompletingId] = useState(null);
  // E.6.2 — when the operator clicks [Edit that group] in the conflict banner,
  // GroupBuilder writes the in-progress build to sessionStorage and then calls
  // openEditFromConflict(). After the Edit modal closes, we re-open the
  // GroupBuilder (which re-hydrates from sessionStorage automatically).
  const editFromConflictRef = useRef(false);

  const adminEmail = (store && store.auth && store.auth.email) || '';

  // Called by GroupBuilder when the operator clicks [Edit that group] on a conflict.
  function openEditFromConflict(assignmentId) {
    const all = (store && store.assignments) || [];
    const target = all.find(a => a.id === assignmentId);
    if (!target) {
      toast(`Could not find assignment ${assignmentId} to edit.`, { icon: 'warning' });
      return;
    }
    editFromConflictRef.current = true;
    // Close the live GroupBuilder so it unmounts; sessionStorage already has
    // the build snapshot it wrote before invoking us.
    setBuilderOpen(false);
    setEditTarget(target);
  }
  function closeEditAndMaybeRestore(refresh) {
    setEditTarget(null);
    if (refresh) refreshAssignments();
    if (editFromConflictRef.current) {
      editFromConflictRef.current = false;
      // Re-open GroupBuilder; its useMemo initializer reads sessionStorage.
      setBuilderOpen(true);
    }
  }

  const filtered = assignments.filter(a => {
    const isActive = ['armed', 'pending', 'acknowledged', 'dispatched'].includes(a.status);
    if (tab === 'active' && !isActive) return false;
    if (tab === 'history' && isActive) return false;
    // "armed" is a rollup, not a stored assignment status — no doc is ever
    // written with status:'armed' (that's a sniper RUN status). A dispatched-
    // and-not-yet-terminal assignment IS armed. Match the dashboard semantics
    // (see userArmed/stats.armed) so the chip stops showing an empty list.
    if (filter === 'armed') return ['dispatched', 'acknowledged'].includes(a.status);
    if (filter !== 'all' && a.status !== filter) return false;
    return true;
  });

  // Phase 4 / L2.3 — operator-driven email retry. The original dispatch (or any
  // prior automated send) stamped `lastEmailSent: false` + `lastEmailError` on
  // the assignment doc; we surface that with a badge and let the admin call the
  // POST /api/resendAssignmentEmail endpoint via the api.js wrapper.
  // Phase 4 / L1.3 — force-complete an assignment without waiting for the
  // hourly markAssignmentsCompleted scheduler. Backend refuses on
  // status==='cancelled' (409); idempotent on already-completed (200).
  async function handleForceComplete(a) {
    if (!a || !a.id) return;
    if (completingId) return;
    const who = a.assignedToName || a.sniperName || a.assignedTo || 'the sniper';
    if (!window.confirm(`Mark ${who}'s ${a.date} ${_time12h(a.time)} assignment as completed?\n\nThis is an admin override of the hourly auto-complete job. Use when the booking window has clearly passed and you want to free up the picker / conflict detector immediately.`)) return;
    const reason = window.prompt('Optional reason (logged to configLog):', 'admin-override') || 'admin-override';
    setCompletingId(a.id);
    try {
      const res = await window.adminApi.markAssignmentCompleted({ assignmentId: a.id, reason });
      if (res && res.alreadyCompleted) {
        toast('Already completed.', { icon: 'info' });
      } else {
        toast('Marked complete.', { icon: 'check' });
      }
      refreshAssignments();
    } catch (e) {
      if (e.errorCode === 'ASSIGNMENT_CANCELLED') {
        toast('Cannot complete a cancelled assignment.', { icon: 'warning' });
      } else if (e.errorCode === 'ASSIGNMENT_NOT_FOUND') {
        toast('Assignment not found — refresh and try again.', { icon: 'warning' });
      } else {
        toast('Force complete failed: ' + (e.message || 'unknown'), { icon: 'warning' });
      }
    } finally {
      setCompletingId(null);
    }
  }

  async function handleResendEmail(a) {
    if (!a || !a.id) return;
    if (resendingId) return;  // already in flight — guard against double-clicks
    setResendingId(a.id);
    try {
      await window.adminApi.resendAssignmentEmail({ assignmentId: a.id });
      toast(`Email resent to ${a.assignedToName || 'sniper'}.`, { icon: 'check' });
      refreshAssignments();
    } catch (e) {
      if (e.errorCode === 'RESEND_RATE_LIMITED') {
        toast('Slow down — wait a minute between resend attempts.', { icon: 'warning' });
      } else if (e.errorCode === 'SNIPER_NO_EMAIL') {
        toast('Sniper has no email on file. Add an email to the roster entry first.', { icon: 'warning' });
      } else if (e.errorCode === 'ASSIGNMENT_NOT_LIVE') {
        toast(`Cannot resend: ${e.message}`, { icon: 'warning' });
      } else if (e.errorCode === 'EMAIL_SEND_FAILED') {
        toast(`Email send failed: ${e.message || 'provider error'}. Try again in a minute.`, { icon: 'warning' });
      } else {
        toast(`Resend failed: ${e.message || 'unknown'}`, { icon: 'warning' });
      }
    } finally {
      setResendingId(null);
    }
  }

  async function cancelAssignment(id) {
    const reason = window.prompt('Reason for cancelling? (optional)', '');
    if (reason === null) return;
    try {
      await window.adminApi.cancelAssignment({ id, reason });
      toast('Assignment cancelled', { icon: 'block' });
      refreshAssignments();
    } catch (e) {
      if (e.errorCode === 'ASSIGNMENT_ALREADY_CANCELLED') {
        toast('Already cancelled', { icon: 'info' });
        refreshAssignments();
      } else {
        toast('Cancel failed: ' + (e.message || 'unknown'), { icon: 'error' });
      }
    }
  }

  const Icon = window.Icon, Toolbar = window.Toolbar, Chip = window.Chip;
  const Badge = window.Badge, Stat = window.Stat, PageHeader = window.PageHeader;

  function statusBadge(s) {
    const map = {
      armed: ['warning', 'Armed'], pending: ['neutral', 'Pending'],
      acknowledged: ['info', 'Acknowledged'], dispatched: ['info', 'Dispatched'],
      completed: ['success', 'Completed'], booked: ['success', 'Booked'],
      timeout: ['danger', 'Timeout'], failed: ['danger', 'Failed'],
      cancelled: ['neutral', 'Cancelled']
    };
    const [tone, label] = map[s] || ['neutral', s || '—'];
    return <Badge tone={tone}>{label}</Badge>;
  }

  return (
    <div className="page">
      <PageHeader title="Assignments" subtitle="Dispatch booking assignments to snipers' extensions."
        actions={
          <button className="btn btn-filled" onClick={() => setBuilderOpen(true)}>
            <Icon name="add" /> New Tee Time Group
          </button>
        } />

      <div className="stat-grid">
        <Stat label="Armed" value={assignments.filter(a => ['dispatched', 'acknowledged'].includes(a.status)).length} icon="bolt" tone="accent" />
        <Stat label="Acknowledged" value={assignments.filter(a => a.status === 'acknowledged').length} icon="how_to_reg" />
        <Stat label="Dispatched" value={assignments.filter(a => a.status === 'dispatched').length} icon="send" />
        <Stat label="Failed" value={assignments.filter(a => a.status === 'failed' || a.status === 'timed_out' || a.status === 'timeout').length} icon="warning" tone="danger" />
      </div>

      {builderOpen && (
        <GroupBuilder
          onClose={() => setBuilderOpen(false)}
          onDispatched={() => { refreshAssignments(); }}
          onRequestEdit={openEditFromConflict}
          currentAdminEmail={adminEmail}
        />
      )}

      {editTarget && window.Modal && (
        <window.Modal
          open={!!editTarget}
          onClose={() => closeEditAndMaybeRestore(false)}
          title="Edit Assignment"
          wide
        >
          <GroupBuilder
            mode="edit"
            initialAssignment={editTarget}
            onClose={() => closeEditAndMaybeRestore(false)}
            onUpdated={() => closeEditAndMaybeRestore(true)}
            currentAdminEmail={adminEmail}
          />
        </window.Modal>
      )}

      <div className="subtabs">
        <div className={`subtab ${tab === 'active' ? 'active' : ''}`} onClick={() => setTab('active')}><Icon name="bolt" /> Active</div>
        <div className={`subtab ${tab === 'history' ? 'active' : ''}`} onClick={() => setTab('history')}><Icon name="history" /> History</div>
        <div className={`subtab ${tab === 'calendar' ? 'active' : ''}`} onClick={() => setTab('calendar')}><Icon name="calendar_month" /> Calendar</div>
      </div>

      {tab === 'calendar' ? (
        <CalendarView assignments={assignments} />
      ) : (
        <>
          <Toolbar>
            <div className="filter-chips">
              <Chip active={filter === 'all'} onClick={() => setFilter('all')}>All</Chip>
              <Chip active={filter === 'armed'} onClick={() => setFilter('armed')}>Armed</Chip>
              <Chip active={filter === 'pending'} onClick={() => setFilter('pending')}>Pending</Chip>
              <Chip active={filter === 'dispatched'} onClick={() => setFilter('dispatched')}>Dispatched</Chip>
              <Chip active={filter === 'completed'} onClick={() => setFilter('completed')}>Completed</Chip>
              <Chip active={filter === 'expired'} onClick={() => setFilter('expired')}>Expired</Chip>
              <Chip active={filter === 'cancelled'} onClick={() => setFilter('cancelled')}>Cancelled</Chip>
            </div>
            <div className="toolbar-spacer"></div>
            <button className="btn btn-icon btn-sm" onClick={refreshAssignments} title="Refresh" aria-label="Refresh assignments"><Icon name="refresh" /></button>
          </Toolbar>

          <div className="table-wrap">
            <table className="table">
              <thead><tr>
                <th>Sniper</th><th>Date</th><th>Time</th><th>Players</th>
                <th>Auto-arm</th><th>Status</th>
                <th>Dispatched</th>
                <th title="Auto-ack delta and firing-device claim winner (last 8 chars of deviceId)">Ack / Device</th>
                <th title="Terminal state + confirmation number">Outcome</th>
                <th></th>
              </tr></thead>
              <tbody>
                {filtered.length === 0 && (
                  <tr><td colSpan="10" style={{ padding: 24, textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)' }}>No assignments.</td></tr>
                )}
                {filtered.map(a => {
                  const players = (a.players || []).map(p => (p && typeof p === 'object') ? (p.name || p.legacyName || '') : String(p)).filter(Boolean);
                  const isTerminal = ['confirmed', 'completed', 'booked', 'failed', 'timed_out', 'timeout', 'cancelled', 'expired'].includes(a.status);
                  const ackLabel = a.acknowledgedAt
                    ? _ackDelta(a.dispatchedAt, a.acknowledgedAt)
                    : (a.dispatchedAt && !isTerminal ? _noAckLabel(a.dispatchedAt) : '');
                  const ackTone = a.acknowledgedAt
                    ? 'var(--md-sys-color-secondary)'
                    : 'var(--md-sys-color-on-surface-variant)';
                  return (
                    <tr key={a.id}>
                      <td>{a.assignedToName || a.sniperName || a.assignedTo || '—'}</td>
                      <td className="mono text-sm">{_shortDate(a.date)}</td>
                      <td className="mono">{_time12h(a.time)}</td>
                      <td className="text-sm">{players.join(', ')}</td>
                      <td>{a.autoArm ? <Icon name="check" size={18} /> : <span className="muted">—</span>}</td>
                      <td>
                        <div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
                          {statusBadge(a.status)}
                          {/* Phase 4 / L2.3 — strict `=== false` because `undefined` means no
                              email attempt has been recorded (e.g. legacy docs) and should NOT
                              flag as failed. */}
                          {a.lastEmailSent === false && (
                            <span
                              title={`Email failed: ${a.lastEmailError || 'unknown error'}\nResend to retry.`}
                              style={{
                                display: 'inline-flex',
                                alignItems: 'center',
                                gap: 4,
                                padding: '2px 6px',
                                borderRadius: 10,
                                background: 'var(--md-sys-color-error-container)',
                                color: 'var(--md-sys-color-on-error-container)',
                                fontSize: 11,
                                fontWeight: 600
                              }}
                            >
                              📨 Email failed
                            </span>
                          )}
                        </div>
                      </td>
                      <td className="muted text-sm">{formatRelTime(a.dispatchedAt)}</td>
                      <td className="text-xs">
                        {ackLabel && <div style={{ color: ackTone }}>{ackLabel}</div>}
                        {a.firingDeviceId && <div style={{ marginTop: 2 }}><FiringDeviceCell id={a.firingDeviceId} /></div>}
                        {!ackLabel && !a.firingDeviceId && <span className="muted">—</span>}
                      </td>
                      <td className="text-xs">
                        {isTerminal && (
                          <TerminalCell
                            status={a.status}
                            terminalAt={a.terminalAt}
                            terminalAuditType={a.terminalAuditType}
                          />
                        )}
                        {a.confirmationNumber && (
                          <div className="mono"
                            style={{ marginTop: 2, color: 'var(--md-sys-color-secondary)' }}
                            title="Confirmation number">
                            #{a.confirmationNumber}
                          </div>
                        )}
                        {!isTerminal && !a.confirmationNumber && <span className="muted">—</span>}
                      </td>
                      <td>
                        {['dispatched', 'acknowledged'].includes(a.status) && (
                          <button
                            className="btn btn-text btn-sm"
                            onClick={() => setEditTarget(a)}
                            title="Edit players, sniper, time, or notes — preserves invite code and date"
                          >Edit</button>
                        )}
                        {/* Phase 4 / L2.3 — resend button is live while the assignment is in flight
                            (dispatched or acknowledged). Backend enforces a 60s rate limit + only-live
                            check; we just block double-click via `resendingId`. */}
                        {['dispatched', 'acknowledged'].includes(a.status) && (
                          <button
                            className="btn btn-text btn-sm"
                            onClick={() => handleResendEmail(a)}
                            disabled={resendingId === a.id}
                            title="Resend the assignment email to the current sniper"
                          >
                            {resendingId === a.id ? 'Sending…' : 'Resend email'}
                          </button>
                        )}
                        {a.status === 'dispatched' && (
                          <button className="btn btn-text btn-sm danger" onClick={() => cancelAssignment(a.id)}>Cancel</button>
                        )}
                        {/* Phase 4 / L1.3 — manual force-complete. Use when the
                            booking window has passed but the hourly auto-complete
                            job hasn't run yet (or you want to clear a stuck row
                            immediately so the picker grayout self-corrects). */}
                        {['dispatched', 'acknowledged'].includes(a.status) && (
                          <button
                            className="btn btn-text btn-sm"
                            onClick={() => handleForceComplete(a)}
                            disabled={completingId === a.id}
                            title="Force-complete this assignment (admin override of the hourly auto-complete job)"
                          >
                            {completingId === a.id ? 'Completing…' : 'Mark complete'}
                          </button>
                        )}
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
        </>
      )}
    </div>
  );
}

// ============ GROUP BUILDER (ports legacy admin.html with B3 stabilization features) ============
function GroupBuilder({ onClose, onDispatched, onUpdated, mode = 'dispatch', initialAssignment = null, onRequestEdit = null, currentAdminEmail = '' }) {
  const store = React.useContext(window.AdminStoreContext) || {};
  const { refreshAssignments = () => {} } = store;
  const toast = window.useToast ? window.useToast() : (() => {});
  const isEdit = mode === 'edit' && initialAssignment;
  const editSessionKey = isEdit ? `gbEditSlot:${initialAssignment.id}` : null;

  // Hydrate session in initializer (avoids first-frame flash)
  const initialState = useMemo(() => {
    // ---- Edit mode: build a single slot from the assignment doc; prefer in-progress session if present ----
    if (isEdit) {
      const a = initialAssignment;
      // E.5.1: take ALL players from the doc (even if > 3 — render every one as deletable)
      const docPlayers = Array.isArray(a.players)
        ? a.players
            .filter(Boolean)
            .map(p => (typeof p === 'object'
              ? { rosterId: p.rosterId || null, name: p.name || p.legacyName || '' }
              : { rosterId: null, name: String(p) }))
        : [];
      // Pad to at least 3 slots so admin can fill empty ones
      while (docPlayers.length < 3) docPlayers.push(null);

      const sniperRosterId = a.sniperRosterId || (a.sniper && a.sniper.rosterId) || null;
      const sniperName = (a.sniper && a.sniper.name) || a.assignedToName || a.sniperName || '';
      const sniperCode = a.assignedTo || '';

      let slot = {
        time: a.time || '08:00',
        sniperCode,
        sniper: sniperRosterId ? { rosterId: sniperRosterId, name: sniperName } : null,
        players: docPlayers,
        notes: a.notes || '',
      };
      let restored = false;
      // E.4.5: restore in-progress edit if present (after token-expiry re-auth, etc.)
      try {
        const cached = sessionStorage.getItem(editSessionKey);
        if (cached) {
          const parsed = JSON.parse(cached);
          if (parsed && parsed.time) { slot = parsed; restored = true; }
        }
      } catch (e) { /* ignore */ }

      const set = new Set();
      if (slot.sniper && slot.sniper.rosterId) set.add(slot.sniper.rosterId);
      (slot.players || []).forEach(p => { if (p && p.rosterId) set.add(p.rosterId); });
      return { slots: [slot], date: a.date || '', inProgress: set, restored };
    }

    try {
      const slotsJson = sessionStorage.getItem('gbSlots');
      const date = sessionStorage.getItem('gbDate');
      if (slotsJson && date) {
        const slots = JSON.parse(slotsJson);
        if (Array.isArray(slots) && slots.length > 0) {
          const set = new Set();
          slots.forEach(slot => {
            if (slot && slot.sniper && slot.sniper.rosterId) set.add(slot.sniper.rosterId);
            (slot && slot.players || []).forEach(p => { if (p && p.rosterId) set.add(p.rosterId); });
          });
          return { slots, date, inProgress: set, restored: true };
        }
      }
    } catch (e) { /* ignore */ }
    return {
      slots: [{ time: '08:00', sniperCode: '', sniper: null, players: [null, null, null] }],
      date: '',
      inProgress: new Set(),
      restored: false
    };
  }, []);

  const gbInProgressRosterIdsRef = useRef(initialState.inProgress);
  const [gbInProgressVersion, setGbInProgressVersion] = useState(0);
  const [gbSlots, setGbSlots] = useState(initialState.slots);
  const [gbCurrentDate, setGbCurrentDate] = useState(initialState.date);
  const [gbRoster, setGbRoster] = useState([]);
  const [gbRosterError, setGbRosterError] = useState(null);
  const [conflictBanner, setConflictBanner] = useState({ visible: false, message: '', conflicts: [] });
  const [restoredVisible, setRestoredVisible] = useState(initialState.restored);
  const [autoArm, setAutoArm] = useState(true);
  const [users, setUsers] = useState([]);
  const [openPicker, setOpenPicker] = useState({ slot: -1, player: -1 });
  const [searchText, setSearchText] = useState('');
  const [dispatching, setDispatching] = useState(false);
  const [staleModal, setStaleModal] = useState(null);
  // E.6.5 [Override and dispatch] retry: re-invokes dispatchAll/saveEdit with
  // { allowSlotCollision: true }. The backend independently enforces person-
  // overlap conflicts (allowSlotCollision only bypasses SLOT_TARGET_COLLISION),
  // so re-iterating gbSlots is safe; payload stashing is not required.

  // Load active users (snipers list) on mount
  useOnMount(() => {
    if (window.adminApi && window.adminApi.getUsers) {
      window.adminApi.getUsers().then(u => {
        const list = Array.isArray(u) ? u : (u?.users || []);
        setUsers(list.filter(x => x.status !== 'revoked'));
      }).catch(() => {});
    }
  });

  // Load persons for restored date on mount
  useOnMount(() => {
    if (initialState.date) loadPersonsForDate(initialState.date);
  });

  // Persist helpers
  function persistSession(slots, date) {
    if (isEdit) {
      // E.4.5: edit mode uses per-assignment key, single slot
      try {
        if (slots && slots[0]) sessionStorage.setItem(editSessionKey, JSON.stringify(slots[0]));
      } catch (e) { /* ignore */ }
      return;
    }
    try {
      if (slots && slots.length > 0) {
        sessionStorage.setItem('gbSlots', JSON.stringify(slots));
        sessionStorage.setItem('gbDate', date || '');
      }
    } catch (e) { /* ignore */ }
  }
  function clearSession() {
    if (isEdit) {
      try { sessionStorage.removeItem(editSessionKey); } catch (e) { /* ignore */ }
      return;
    }
    try {
      sessionStorage.removeItem('gbSlots');
      sessionStorage.removeItem('gbDate');
    } catch (e) { /* ignore */ }
  }

  async function loadPersonsForDate(date) {
    if (!date) { setGbRoster([]); setGbRosterError(null); return; }
    setGbRosterError(null);
    try {
      const res = await window.adminApi.apiRosterForDate(date);
      setGbRoster((res && res.roster) || []);
    } catch (e) {
      if (e && e.errorCode === 'MIGRATION_REQUIRED') {
        setGbRoster([]);
        setGbRosterError('Person migration must run before assignments can be made. Contact admin.');
      } else if (e && e.status === 404) {
        // endpoint not deployed — fallback to empty
        setGbRoster([]);
        setGbRosterError('Picker unavailable: endpoint not deployed.');
      } else {
        setGbRoster([]);
        setGbRosterError('Could not load member availability: ' + (e && e.message || 'unknown'));
      }
    }
  }

  // visibilitychange — refetch persons when tab becomes visible (avoids stale data)
  useEffect(() => {
    function onVisible() {
      if (document.visibilityState === 'visible' && gbCurrentDate) {
        loadPersonsForDate(gbCurrentDate);
      }
    }
    document.addEventListener('visibilitychange', onVisible);
    return () => document.removeEventListener('visibilitychange', onVisible);
  }, [gbCurrentDate]);

  // Date change handler
  async function onDateChange(newDate) {
    setGbCurrentDate(newDate);
    persistSession(gbSlots, newDate);
    if (newDate) await loadPersonsForDate(newDate);
  }

  // Slot helpers
  function nextTime() {
    if (gbSlots.length === 0) return '08:00';
    const last = gbSlots[gbSlots.length - 1].time || '08:00';
    const [h, m] = last.split(':').map(Number);
    const total = h * 60 + m + 8;
    return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
  }

  function addSlot() {
    setGbSlots(prev => {
      const next = [...prev, { time: nextTime(), sniperCode: '', sniper: null, players: [null, null, null] }];
      persistSession(next, gbCurrentDate);
      return next;
    });
  }

  function removeSlot(idx) {
    setGbSlots(prev => {
      const slot = prev[idx];
      const inProgress = gbInProgressRosterIdsRef.current;
      if (slot) {
        if (slot.sniper && slot.sniper.rosterId) inProgress.delete(slot.sniper.rosterId);
        (slot.players || []).forEach(p => { if (p && p.rosterId) inProgress.delete(p.rosterId); });
      }
      setGbInProgressVersion(v => v + 1);
      const next = prev.filter((_, i) => i !== idx);
      if (next.length === 0) clearSession();
      else persistSession(next, gbCurrentDate);
      return next;
    });
  }

  function updateTime(idx, val) {
    setGbSlots(prev => {
      const next = prev.map((s, i) => i === idx ? { ...s, time: val } : s);
      persistSession(next, gbCurrentDate);
      return next;
    });
  }

  function clearAll() {
    const inProgress = gbInProgressRosterIdsRef.current;
    inProgress.clear();
    setGbInProgressVersion(v => v + 1);
    clearSession();
    setGbSlots([{ time: '08:00', sniperCode: '', sniper: null, players: [null, null, null] }]);
  }

  function selectPlayer(slotIdx, playerIdx, person) {
    if (!person || !person.name) return;
    // Phase 2: defensive — UI already blocks click, but reject if person is on a live assignment elsewhere.
    const editingId = isEdit ? initialAssignment.id : null;
    if (person.unavailableBecause && person.unavailableBecause.assignmentId !== editingId) {
      const ub = person.unavailableBecause;
      const msg = `${person.name} is in ${ub.otherName || 'another group'}'s group at ${_time12h(ub.time)}`;
      toast(msg, { icon: 'warning' });
      return;
    }
    const inProgress = gbInProgressRosterIdsRef.current;
    if (person.rosterId && inProgress.has(person.rosterId)) {
      toast(`${person.name} is already in another slot`, { icon: 'warning' });
      return;
    }
    setGbSlots(prev => {
      const copy = prev.map(s => ({ ...s, players: [...(s.players || [])] }));
      const slot = copy[slotIdx];
      const players = slot.players;
      const prevPlayer = players[playerIdx];
      if (prevPlayer && prevPlayer.rosterId) inProgress.delete(prevPlayer.rosterId);
      players[playerIdx] = { rosterId: person.rosterId || null, name: person.name };
      if (person.rosterId) inProgress.add(person.rosterId);
      persistSession(copy, gbCurrentDate);
      return copy;
    });
    setGbInProgressVersion(v => v + 1);
    setOpenPicker({ slot: -1, player: -1 });
    setSearchText('');
  }

  function removePlayer(slotIdx, playerIdx) {
    setGbSlots(prev => {
      const copy = prev.map(s => ({ ...s, players: [...(s.players || [])] }));
      const slot = copy[slotIdx];
      const inProgress = gbInProgressRosterIdsRef.current;
      const p = slot.players[playerIdx];
      if (p && p.rosterId) inProgress.delete(p.rosterId);
      slot.players[playerIdx] = null;
      persistSession(copy, gbCurrentDate);
      return copy;
    });
    setGbInProgressVersion(v => v + 1);
  }

  function selectSniper(slotIdx, userId) {
    if (!userId) {
      // Clear sniper
      setGbSlots(prev => {
        const inProgress = gbInProgressRosterIdsRef.current;
        const copy = prev.map((s, i) => i === slotIdx ? { ...s } : s);
        const slot = copy[slotIdx];
        if (slot.sniper && slot.sniper.rosterId) inProgress.delete(slot.sniper.rosterId);
        slot.sniper = null;
        slot.sniperCode = '';
        persistSession(copy, gbCurrentDate);
        return copy;
      });
      setGbInProgressVersion(v => v + 1);
      return;
    }
    const sniperUser = users.find(u => u.id === userId);
    if (!sniperUser) return;
    const matched = (gbRoster || []).find(p =>
      p.email && sniperUser.email && p.email.toLowerCase() === sniperUser.email.toLowerCase()
    );
    if (!matched && !sniperUser.rosterId) {
      window.alert(`${sniperUser.name || sniperUser.id} has no roster entry. Run migration or add via Roster tab first.`);
      return;
    }
    const rosterId = matched ? matched.rosterId : sniperUser.rosterId;
    const name = matched ? (matched.name || sniperUser.name) : (sniperUser.name || sniperUser.id);
    // Phase 2: defensive — refuse if matched roster entry is on a live assignment (not the one being edited).
    const editingId = isEdit ? initialAssignment.id : null;
    if (matched && matched.unavailableBecause && matched.unavailableBecause.assignmentId !== editingId) {
      const ub = matched.unavailableBecause;
      window.alert(`${name} is in ${ub.otherName || 'another group'}'s group at ${_time12h(ub.time)} (${ub.role}).`);
      return;
    }
    const inProgress = gbInProgressRosterIdsRef.current;
    if (inProgress.has(rosterId)) {
      window.alert(`${name} is already assigned in another slot in this group.`);
      return;
    }
    setGbSlots(prev => {
      const copy = prev.map((s, i) => i === slotIdx ? { ...s } : s);
      const slot = copy[slotIdx];
      if (slot.sniper && slot.sniper.rosterId) inProgress.delete(slot.sniper.rosterId);
      inProgress.add(rosterId);
      slot.sniper = { rosterId, name };
      slot.sniperCode = sniperUser.id;
      persistSession(copy, gbCurrentDate);
      return copy;
    });
    setGbInProgressVersion(v => v + 1);
  }

  function dismissRestored() {
    setRestoredVisible(false);
  }

  function clearRestored() {
    clearAll();
    setRestoredVisible(false);
  }

  async function saveEdit(opts = {}) {
    if (!isEdit) return;
    setDispatching(true);
    setConflictBanner({ visible: false, message: '', conflicts: [] });
    const slot = gbSlots[0];
    const playerList = (slot && slot.players || []).filter(Boolean);
    const playerCount = playerList.length;
    if (!slot || !slot.sniper || playerCount === 0 || playerCount > 3 || !slot.time) {
      toast('Need sniper, valid player count (1-3), and time', { icon: 'warning' });
      setDispatching(false);
      return;
    }
    const payload = {
      assignmentId: initialAssignment.id,
      sniper: slot.sniper,
      players: playerList.map(p => ({ rosterId: p.rosterId || null, name: p.name })),
      time: slot.time,
      autoArm: !!autoArm,
      notes: slot.notes || '',
      expectedUpdatedAt: initialAssignment.updatedAt || initialAssignment.dispatchedAt || null,
    };
    if (opts.allowSlotCollision) payload.allowSlotCollision = true;
    try {
      const res = await window.adminApi.updateAssignment(payload);
      if (res && res.noChange) {
        toast('No changes to save.', { icon: 'info' });
      } else {
        toast(
          `Assignment updated.${res && res.sniperReassignmentEmailSent ? ' Previous sniper notified.' : ''}`,
          { icon: 'check' }
        );
      }
      try {
        store.logConfig && store.logConfig(
          'assignment_updated',
          initialAssignment.id,
          JSON.stringify({ playerCount: payload.players.length })
        );
      } catch (_) {}
      clearSession();
      onUpdated && onUpdated();
    } catch (e) {
      if (e && e.errorCode === 'ASSIGNMENT_STALE') {
        setStaleModal({ currentUpdatedAt: e.currentUpdatedAt || null });
      } else if (e && e.errorCode === 'ASSIGNMENT_CONFLICT') {
        setConflictBanner({
          visible: true,
          message: e.message || 'Scheduling conflict.',
          conflicts: Array.isArray(e.conflicts) ? e.conflicts : []
        });
      } else if (e && e.errorCode === 'ASSIGNMENT_NOT_LIVE') {
        toast(`This assignment is no longer editable: ${e.message || ''}`, { icon: 'warning' });
        onUpdated && onUpdated();
      } else if (e && e.errorCode === 'ASSIGNMENT_BOOKING_WINDOW_PASSED') {
        toast('Booking window has passed — cannot edit.', { icon: 'warning' });
      } else if (e && e.errorCode === 'SNIPER_NO_EMAIL') {
        toast('Sniper has no email on file. Add an email to the roster entry first.', { icon: 'warning' });
      } else if (e && e.errorCode === 'PLAYERS_TOO_MANY') {
        toast(`Too many players: ${e.message || 'cap is 3'}`, { icon: 'warning' });
      } else if (e && e.errorCode === 'ASSIGNMENT_NEEDS_PLAYERS') {
        toast('At least one player is required.', { icon: 'warning' });
      } else if (e && e.errorCode === 'INVALID_TIME_FORMAT') {
        toast(`Invalid time: ${e.message || ''}`, { icon: 'warning' });
      } else if (e && e.errorCode === 'TEE_TIME_IN_PAST') {
        toast('Tee time is in the past — cannot save.', { icon: 'warning' });
      } else {
        toast(`Save failed: ${(e && e.message) || 'unknown'}`, { icon: 'warning' });
      }
    } finally {
      setDispatching(false);
    }
  }

  async function dispatchAll(opts = {}) {
    if (!gbCurrentDate) {
      toast('Select a date first', { icon: 'warning' });
      return;
    }
    setDispatching(true);
    setConflictBanner({ visible: false, message: '', conflicts: [] });
    const successfulIndices = [];
    const failedIndices = [];
    let conflicts = [];
    let ok = 0, fail = 0, emailWarnings = 0;
    let migrationBlocked = false;
    // Track payloads we actually submit, so [Override and dispatch] can retry
    // the SAME set of slots that hit SLOT_TARGET_COLLISION.
    const attemptedPayloads = []; // { slotIndex, payload }

    for (let i = 0; i < gbSlots.length; i++) {
      const slot = gbSlots[i];
      const playerCount = (slot.players || []).filter(Boolean).length;
      if (!slot.sniper || playerCount === 0 || !slot.time) {
        failedIndices.push(i);
        continue;
      }
      const payload = {
        sniper: slot.sniper,
        players: (slot.players || []).filter(Boolean).map(p => ({ rosterId: p.rosterId || null, name: p.name })),
        date: gbCurrentDate,
        time: slot.time,
        autoArm: !!autoArm,
        notes: slot.notes || ''
      };
      if (opts.allowSlotCollision) payload.allowSlotCollision = true;
      attemptedPayloads.push({ slotIndex: i, payload });
      try {
        const res = await window.adminApi.dispatch(payload);
        successfulIndices.push(i);
        ok++;
        if (res && res.emailSent === false) emailWarnings++;
      } catch (e) {
        if (e && e.errorCode === 'MIGRATION_REQUIRED') {
          toast('Person migration must complete before new assignments can be dispatched. Contact admin.', { icon: 'error' });
          migrationBlocked = true;
          break;
        }
        if (e && e.errorCode === 'ASSIGNMENT_CONFLICT') {
          failedIndices.push(i);
          if (Array.isArray(e.conflicts)) conflicts = conflicts.concat(e.conflicts);
          fail++;
          continue;
        }
        failedIndices.push(i);
        fail++;
      }
    }

    // Remove successful slots, release their rosterIds
    if (successfulIndices.length > 0) {
      setGbSlots(prev => {
        const inProgress = gbInProgressRosterIdsRef.current;
        successfulIndices.forEach(idx => {
          const slot = prev[idx];
          if (!slot) return;
          if (slot.sniper && slot.sniper.rosterId) inProgress.delete(slot.sniper.rosterId);
          (slot.players || []).forEach(p => { if (p && p.rosterId) inProgress.delete(p.rosterId); });
        });
        const remaining = prev.filter((_, i) => !successfulIndices.includes(i));
        if (remaining.length === 0) clearSession();
        else persistSession(remaining, gbCurrentDate);
        return remaining;
      });
      setGbInProgressVersion(v => v + 1);
      refreshAssignments();
      onDispatched && onDispatched();
    }

    if (conflicts.length > 0) {
      const hasSlotCollision = conflicts.some(c => c.conflictType === 'SLOT_TARGET_COLLISION');
      const hasPersonOverlap = conflicts.some(c => c.conflictType !== 'SLOT_TARGET_COLLISION');
      const msg = hasSlotCollision && !hasPersonOverlap
        ? 'Another group already targets this tee time.'
        : `${conflicts.length} conflict(s) — review below.`;
      setConflictBanner({
        visible: true,
        message: msg,
        conflicts
      });
    }

    let statusMsg = '';
    if (ok > 0) statusMsg += `${ok} dispatched`;
    if (fail > 0) statusMsg += (statusMsg ? ', ' : '') + `${fail} failed`;
    if (emailWarnings > 0) statusMsg += `, ${emailWarnings} email warning(s)`;
    if (migrationBlocked) statusMsg += ' (migration blocked)';
    if (statusMsg) toast(statusMsg, { icon: ok > 0 ? 'check' : 'error' });
    setDispatching(false);
  }

  const Icon = window.Icon, Toolbar = window.Toolbar;
  const ConflictBanner = window.ConflictBanner;
  const RestoredSessionNotice = window.RestoredSessionNotice;

  // -------- Conflict-banner action handlers (Phase 3 E.6.1 – E.6.5) -------- //
  const adminStore = React.useContext(window.AdminStoreContext) || {};
  function lookupAssignment(id) {
    const list = (adminStore && adminStore.assignments) || [];
    return list.find(a => a.id === id) || null;
  }

  // E.6.2 — preserve current build before navigating to Edit. dispatch mode
  // already persists to sessionStorage on every state change; we just need to
  // make sure the latest state is flushed before unmount. (Edit mode uses its
  // own per-assignment session key — the conflicting-assignment Edit modal is
  // a DIFFERENT id, so it won't collide.)
  function onEditConflicting(priorAssignmentId) {
    if (!priorAssignmentId) return;
    if (!isEdit) {
      // Flush current build snapshot to sessionStorage.
      try { persistSession(gbSlots, gbCurrentDate); } catch (_) {}
    }
    if (typeof onRequestEdit === 'function') {
      onRequestEdit(priorAssignmentId);
    } else {
      toast('Cannot open Edit from here — re-open from Assignments list.', { icon: 'warning' });
    }
  }

  // E.6.1 — self-cancel warning.
  async function onCancelConflicting(priorAssignmentId, priorSniperName, priorTime, isOwn) {
    if (!priorAssignmentId) return;
    const msg = isOwn
      ? 'This is YOUR OWN assignment. Cancelling will delete it. Continue?'
      : `Cancel ${priorSniperName || 'that'}’s group${priorTime ? ' at ' + priorTime : ''}? They’ll be notified.`;
    if (!window.confirm(msg)) return;
    try {
      await window.adminApi.cancelAssignment({ id: priorAssignmentId, reason: 'admin-resolved-conflict' });
      toast('Conflicting assignment cancelled', { icon: 'check' });
      // Drop just that prior-assignment's conflicts from the banner.
      setConflictBanner(prev => {
        const remaining = (prev.conflicts || []).filter(c => c.priorAssignmentId !== priorAssignmentId);
        return remaining.length > 0
          ? { ...prev, conflicts: remaining }
          : { visible: false, message: '', conflicts: [] };
      });
      refreshAssignments();
    } catch (e) {
      if (e && e.errorCode === 'ASSIGNMENT_ALREADY_CANCELLED') {
        toast('Already cancelled', { icon: 'info' });
        refreshAssignments();
      } else {
        toast('Cancel failed: ' + ((e && e.message) || 'unknown'), { icon: 'error' });
      }
    }
  }

  // E.6.4 — remove the conflicting rosterId from every slot + the in-progress ref.
  function onPickDifferent(rosterId) {
    if (!rosterId) return;
    setGbSlots(prev => {
      const inProgress = gbInProgressRosterIdsRef.current;
      const next = prev.map(slot => {
        const s = { ...slot, players: (slot.players || []).slice() };
        if (s.sniper && s.sniper.rosterId === rosterId) {
          s.sniper = null;
          s.sniperCode = '';
        }
        for (let i = 0; i < s.players.length; i++) {
          if (s.players[i] && s.players[i].rosterId === rosterId) {
            s.players[i] = null;
          }
        }
        return s;
      });
      inProgress.delete(rosterId);
      persistSession(next, gbCurrentDate);
      return next;
    });
    setGbInProgressVersion(v => v + 1);
    // Drop any banner rows that named this person.
    setConflictBanner(prev => {
      const remaining = (prev.conflicts || []).filter(c => c.rosterId !== rosterId);
      return remaining.length > 0
        ? { ...prev, conflicts: remaining }
        : { visible: false, message: '', conflicts: [] };
    });
  }

  // E.6.5 — re-submit with allowSlotCollision: true.
  async function onOverrideSlot(priorSniperName) {
    const who = priorSniperName || 'another sniper';
    if (!window.confirm(
      `You're about to dispatch a group that targets the same tee-time as ${who}'s. ` +
      `Only one will actually win the booking. Continue?`
    )) return;

    setConflictBanner({ visible: false, message: '', conflicts: [] });
    if (isEdit) {
      // Re-run saveEdit with the flag set.
      await saveEdit({ allowSlotCollision: true });
    } else {
      // Dispatch mode: re-run dispatchAll for the still-unfired slots.
      // dispatchAll already iterates current gbSlots (successful ones were
      // removed on prior pass), so re-invoking with the flag re-attempts them.
      await dispatchAll({ allowSlotCollision: true });
    }
  }

  const readyCount = gbSlots.filter(s => s.sniper && (s.players || []).filter(Boolean).length >= 1).length;
  const incompleteCount = gbSlots.length - readyCount;
  // Group size counts the sniper (Player 1) plus the players array, so a sniper
  // with 2 players reads "3". Snipers are counted per group that has one assigned.
  const totalPlayers = gbSlots.reduce((n, s) => n + (s.sniper ? 1 : 0) + (s.players || []).filter(Boolean).length, 0);

  return (
    <div className="card card-elevated" style={{ marginBottom: 24, background: 'var(--md-sys-color-surface-container)' }}>
      <div className="card-header" style={{ background: 'var(--md-sys-color-secondary-container)' }}>
        <Icon name="group_add" />
        <h3 style={{ color: 'var(--md-sys-color-on-secondary-container)', margin: 0 }}>Tee Time Group Builder</h3>
        <div style={{ flex: 1 }}></div>
        <button className="btn btn-icon" onClick={onClose}><Icon name="close" /></button>
      </div>
      <div className="card-body">
        {restoredVisible && isEdit && (
          <div style={{
            padding: 10, marginBottom: 12,
            background: 'var(--md-sys-color-tertiary-container)',
            color: 'var(--md-sys-color-on-tertiary-container)',
            borderRadius: 8, fontSize: 13,
            display: 'flex', alignItems: 'center', gap: 8
          }}>
            <Icon name="restore" />
            <span style={{ flex: 1 }}>Restored your unsaved edit from this session.</span>
            <button className="btn btn-text btn-sm" onClick={dismissRestored}>Keep</button>
            <button className="btn btn-text btn-sm" onClick={() => {
              clearSession();
              setRestoredVisible(false);
              onUpdated && onUpdated();
            }}>Discard</button>
          </div>
        )}
        {restoredVisible && !isEdit && RestoredSessionNotice && (
          <RestoredSessionNotice
            slotCount={gbSlots.length}
            date={gbCurrentDate}
            onContinue={dismissRestored}
            onClear={clearRestored}
          />
        )}

        {gbRosterError && !conflictBanner.visible && (
          <div style={{ padding: 12, marginBottom: 12, background: 'var(--md-sys-color-error-container)', color: 'var(--md-sys-color-on-error-container)', borderRadius: 8, fontSize: 13 }}>
            {gbRosterError}
          </div>
        )}

        {conflictBanner.visible && ConflictBanner && (
          <ConflictBanner
            visible={conflictBanner.visible}
            message={conflictBanner.message}
            conflicts={conflictBanner.conflicts}
            currentAdminEmail={currentAdminEmail}
            lookupAssignment={lookupAssignment}
            onEditConflicting={onEditConflicting}
            onCancelConflicting={onCancelConflicting}
            onPickDifferent={onPickDifferent}
            onOverrideSlot={onOverrideSlot}
            onRefresh={async () => {
              if (gbCurrentDate) await loadPersonsForDate(gbCurrentDate);
              setConflictBanner({ visible: false, message: '', conflicts: [] });
            }}
            onDismiss={() => setConflictBanner({ visible: false, message: '', conflicts: [] })}
          />
        )}

        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, marginBottom: 20 }}>
          <div className="field">
            <label>Date</label>
            <input className="input" type="date" value={gbCurrentDate}
              onChange={e => onDateChange(e.target.value)}
              disabled={isEdit}
              title={isEdit ? 'Date changes require cancel + re-dispatch — edit time, sniper, players, or notes only.' : ''}
            />
          </div>
          <div className="field">
            <label>Auto-arm</label>
            <label style={{ display: 'flex', alignItems: 'center', gap: 8, height: 40, cursor: 'pointer' }}>
              <input type="checkbox" checked={autoArm} onChange={e => setAutoArm(e.target.checked)} />
              Yes, arm all snipers
            </label>
          </div>
          <div className="field">
            <label>&nbsp;</label>
            <button className="btn btn-text" onClick={async () => {
              if (gbCurrentDate) await loadPersonsForDate(gbCurrentDate);
            }}><Icon name="refresh" /> Refresh picker</button>
          </div>
        </div>

        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          {gbSlots.map((slot, si) => {
            const playerCount = (slot.players || []).filter(Boolean).length;
            const statusDot = slot.sniperCode && playerCount >= 1 ? '🟢' : '🟡';
            return (
              <div key={si} style={{ background: 'var(--md-sys-color-surface-container-low)', borderRadius: 12, padding: 16 }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
                  <span style={{ fontSize: 16 }}>{statusDot}</span>
                  <div className="field" style={{ width: 110 }}>
                    <label style={{ fontSize: 10 }}>Time</label>
                    <input className="input compact" type="time" step={600} value={slot.time || ''}
                      onChange={e => updateTime(si, e.target.value)} />
                  </div>
                  <div className="field" style={{ flex: 1 }}>
                    <label style={{ fontSize: 10 }}>Sniper</label>
                    <select className="input compact" value={slot.sniperCode || ''}
                      onChange={e => selectSniper(si, e.target.value)}>
                      <option value="">— Pick —</option>
                      {users.map(u => {
                        // Phase 2: gray out users whose roster entry is on a live assignment for this date.
                        const rosterMatch = (gbRoster || []).find(r =>
                          r.email && u.email && r.email.toLowerCase() === u.email.toLowerCase()
                        );
                        const ub = rosterMatch && rosterMatch.unavailableBecause;
                        const editingId = isEdit ? initialAssignment.id : null;
                        const selfExcluded = !!(ub && editingId && ub.assignmentId === editingId);
                        const isCurrentSlotSniper = slot.sniperCode === u.id;
                        const unavailable = ub && !selfExcluded && !isCurrentSlotSniper ? ub : null;
                        const label = (u.name || u.id) + (unavailable ? ` — booked ${_time12h(unavailable.time)}` : '');
                        return (
                          <option key={u.id} value={u.id} disabled={!!unavailable}>{label}</option>
                        );
                      })}
                    </select>
                  </div>
                  {!isEdit && (
                    <button className="btn btn-icon btn-sm danger" onClick={() => removeSlot(si)} title="Remove slot" aria-label={`Remove slot ${si + 1}`}>
                      <Icon name="delete" />
                    </button>
                  )}
                </div>
                {/* Sniper auto-assigned as locked Player 1 (E.5.1 / sniper-as-Player-1 model) */}
                <div style={{
                  display: 'flex', alignItems: 'center', gap: 8,
                  padding: '6px 10px', marginBottom: 8,
                  background: 'var(--md-sys-color-surface)',
                  border: '1px dashed var(--md-sys-color-outline-variant)',
                  borderRadius: 6
                }} title="The sniper is the lead member and is automatically Player 1. Pick up to 3 additional players.">
                  <span style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)' }}>1.</span>
                  <span style={{ fontSize: 13, flex: 1 }}>
                    {slot.sniper && slot.sniper.name
                      ? <>{slot.sniper.name} <span aria-hidden="true">🔒</span> <span className="muted text-xs">(sniper — auto-assigned as Player 1)</span></>
                      : <span className="muted">Pick a sniper above to auto-fill Player 1</span>}
                  </span>
                </div>
                {(() => {
                  const playerSlots = slot.players || [];
                  const legacyCount = Math.max(0, playerSlots.length - 3); // > 0 means N>3 (legacy)
                  const cols = Math.min(4, Math.max(3, playerSlots.length));
                  return (
                    <div style={{ display: 'grid', gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: 8 }}>
                      {playerSlots.map((p, pi) => {
                        const playerOrdinal = pi + 2; // Player 2..N
                        const isLegacy = pi >= 3;
                        const isOpen = openPicker.slot === si && openPicker.player === pi;
                        if (p && p.name) {
                          return (
                            <div key={pi} style={{
                              display: 'flex', alignItems: 'center', gap: 6, padding: '6px 8px',
                              background: isLegacy ? 'var(--md-sys-color-error-container)' : 'var(--md-sys-color-surface)',
                              borderRadius: 6
                            }} title={isLegacy ? 'Legacy player — drop to save' : ''}>
                              <span style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)' }}>{playerOrdinal}.</span>
                              <span style={{ fontSize: 13, flex: 1 }}>
                                {p.name}
                                {isLegacy && <span className="muted text-xs"> (legacy — drop to save)</span>}
                              </span>
                              <button className="btn btn-icon btn-sm" onClick={() => removePlayer(si, pi)} aria-label={`Remove player ${playerOrdinal}`}><Icon name="close" /></button>
                            </div>
                          );
                        }
                        return (
                          <div key={pi} style={{ position: 'relative' }}>
                            <input
                              className="input compact"
                              type="text"
                              placeholder={`Player ${playerOrdinal} — search…`}
                              aria-label={`Player ${playerOrdinal} search`}
                              value={isOpen ? searchText : ''}
                              onFocus={() => { setOpenPicker({ slot: si, player: pi }); setSearchText(''); }}
                              onChange={e => setSearchText(e.target.value)}
                              onBlur={() => setTimeout(() => {
                                setOpenPicker(cur => (cur.slot === si && cur.player === pi) ? { slot: -1, player: -1 } : cur);
                              }, 200)}
                            />
                            {isOpen && (
                              <PickerDropdown
                                roster={gbRoster}
                                search={searchText}
                                inProgress={gbInProgressRosterIdsRef.current}
                                onSelect={(person) => selectPlayer(si, pi, person)}
                                editingAssignmentId={isEdit ? initialAssignment.id : null}
                              />
                            )}
                          </div>
                        );
                      })}
                    </div>
                  );
                })()}
              </div>
            );
          })}
        </div>

        {!isEdit && (
          <div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
            <button className="btn btn-text" onClick={addSlot}><Icon name="add" /> Add Slot</button>
            <button className="btn btn-text" onClick={clearAll}><Icon name="restart_alt" /> Clear All</button>
          </div>
        )}

        <div style={{ marginTop: 20, padding: 16, background: 'var(--md-sys-color-surface-container-high)', borderRadius: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
          <div className="text-sm">
            {isEdit ? (() => {
              const slot = gbSlots[0] || {};
              const pCount = (slot.players || []).filter(Boolean).length;
              const groupSize = pCount + 1; // sniper (Player 1) + players array
              return (
                <>
                  <strong>Edit:</strong> {gbCurrentDate ? _shortDate(gbCurrentDate) : '—'} @ {slot.time ? _time12h(slot.time) : '—'}
                  {' · '}{groupSize} player{groupSize !== 1 ? 's' : ''}
                  {pCount > 3 && <> · <span style={{ color: 'var(--md-sys-color-error)' }}>{pCount - 3} over cap</span></>}
                  {gbInProgressVersion >= 0 ? '' : ''}
                </>
              );
            })() : (
              <>
                <strong style={{ fontVariantNumeric: 'tabular-nums' }}>{readyCount}</strong> ready
                {incompleteCount > 0 && <> · <span style={{ color: 'var(--md-sys-color-error)' }}>{incompleteCount} incomplete</span></>}
                {' · '}{totalPlayers} player{totalPlayers !== 1 ? 's' : ''}
                {gbCurrentDate && <> · {_shortDate(gbCurrentDate)}</>}
                {gbInProgressVersion >= 0 ? '' : ''}
              </>
            )}
          </div>
          <div style={{ display: 'flex', gap: 8 }}>
            <button className="btn btn-text" onClick={onClose}>Cancel</button>
            {isEdit ? (() => {
              const slot = gbSlots[0] || {};
              const pCount = (slot.players || []).filter(Boolean).length;
              const tooMany = pCount > 3;
              const tooFew = pCount === 0;
              const saveDisabled = dispatching || !slot.sniper || !slot.time || tooMany || tooFew;
              const tip = tooMany
                ? `Remove ${pCount - 3} player(s) to save`
                : tooFew ? 'Add at least one player to save' : '';
              return (
                <button
                  className="btn btn-filled"
                  disabled={saveDisabled}
                  onClick={saveEdit}
                  title={tip}
                >
                  <Icon name="save" /> {dispatching ? 'Saving…' : 'Save changes'}
                </button>
              );
            })() : (
              <button className="btn btn-filled" disabled={dispatching || readyCount === 0} onClick={dispatchAll}>
                <Icon name="send" /> {dispatching ? 'Dispatching…' : 'Dispatch All'}
              </button>
            )}
          </div>
        </div>
      </div>
      {isEdit && staleModal && window.Modal && (
        <window.Modal open={!!staleModal} onClose={() => setStaleModal(null)} title="Someone else edited this">
          <p>This assignment was changed since you opened the editor. Reload to see the latest, then make your changes.</p>
          <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12 }}>
            <button className="btn btn-text" onClick={() => setStaleModal(null)}>Cancel</button>
            <button className="btn btn-filled" onClick={() => { setStaleModal(null); clearSession(); onUpdated && onUpdated(); }}>Reload</button>
          </div>
        </window.Modal>
      )}
    </div>
  );
}

// Picker dropdown (H-7 email column + taken states)
function PickerDropdown({ roster, search, inProgress, onSelect, editingAssignmentId }) {
  const q = (search || '').trim().toLowerCase();
  const filtered = (roster || [])
    .filter(p => !q || (p.name || '').toLowerCase().includes(q) || (p.email || '').toLowerCase().includes(q))
    .slice(0, 25);

  return (
    <div className="picker-dropdown" style={{
      position: 'absolute',
      top: '100%', left: 0,
      zIndex: 100,
      background: 'var(--md-sys-color-surface)',
      border: '1px solid var(--md-sys-color-outline-variant)',
      borderRadius: 6,
      maxHeight: 240, overflowY: 'auto',
      width: 280,
      boxShadow: '0 4px 12px rgba(0,0,0,.12)',
      marginTop: 2
    }}>
      {filtered.length === 0 && (
        <div style={{ padding: '8px 12px', fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)' }}>
          {q ? 'No matches' : 'Type to search…'}
        </div>
      )}
      {filtered.map(p => {
        // Phase 2: per-date availability from backend. Treat undefined as null (backend not yet deployed).
        // Self-exclusion: in edit mode, ignore the unavailable mark if it points to the very assignment
        // being edited — those players need to remain selectable.
        const ub = p.unavailableBecause || null;
        const selfExcluded = !!(ub && editingAssignmentId && ub.assignmentId === editingAssignmentId);
        const unavailable = ub && !selfExcluded ? ub : null;
        // Legacy fallback to assignedOnDate flag from earlier backend shape.
        const legacyTaken = !unavailable && !!p.assignedOnDate;
        const inSession = p.rosterId && inProgress.has(p.rosterId);
        const disabled = !!unavailable || legacyTaken || inSession;
        const tooltip = unavailable
          ? `In ${unavailable.otherName || 'another group'}'s group at ${_time12h(unavailable.time)} (${unavailable.role})`
          : legacyTaken
            ? `Already assigned at ${p.assignedTime || '?'}`
            : inSession
              ? 'Already selected in another slot of this group'
              : '';
        return (
          <div
            key={p.rosterId || p.name}
            onMouseDown={!disabled ? (e) => { e.preventDefault(); onSelect(p); } : undefined}
            title={tooltip}
            aria-disabled={disabled || undefined}
            style={{
              padding: '8px 10px',
              cursor: disabled ? 'not-allowed' : 'pointer',
              opacity: disabled ? 0.4 : 1,
              pointerEvents: disabled ? 'none' : undefined,
              borderBottom: '1px solid var(--md-sys-color-outline-variant)'
            }}
          >
            <div style={{ fontWeight: 600, fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>
              <span style={{ flex: 1 }}>{p.name}</span>
              {unavailable && (
                <span style={{
                  fontSize: 10,
                  padding: '1px 6px',
                  borderRadius: 8,
                  background: 'var(--md-sys-color-error-container)',
                  color: 'var(--md-sys-color-on-surface-variant)',
                  whiteSpace: 'nowrap'
                }}>📌 booked {unavailable.time}</span>
              )}
            </div>
            <div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 2 }}>
              {p.email ? p.email : <em>(no email)</em>}
              {legacyTaken && ` — already assigned ${p.assignedTime || ''}${p.assignedAs ? ' as ' + p.assignedAs : ''}`}
              {!unavailable && !legacyTaken && inSession && ' — already in another slot'}
            </div>
          </div>
        );
      })}
    </div>
  );
}

// ============ REQUESTS PAGE ============
function RequestsPage() {
  const store = React.useContext(window.AdminStoreContext) || {};
  const { requests = [], refreshRequests = () => {} } = store;
  const toast = window.useToast ? window.useToast() : (() => {});

  const [filter, setFilter] = useState('pending');
  const [approveModal, setApproveModal] = useState({ open: false, request: null, code: '' });

  const filtered = requests.filter(r => filter === 'all' || r.status === filter);
  const counts = {
    pending: requests.filter(r => r.status === 'pending').length,
    approved: requests.filter(r => r.status === 'approved').length,
    dismissed: requests.filter(r => r.status === 'dismissed').length
  };

  async function approve(req) {
    try {
      const res = await adminApi.invite({
        name: req.name,
        email: req.email,
        role: 'sniper'
      });
      const code = (res && res.code) || '';
      // Mark request as approved via Firestore — store.updateRequest is provided by AdminStoreContext.
      await store.updateRequest({ id: req.id, status: 'approved', inviteCode: code });
      try { store.logConfig && store.logConfig('request_approved', req.email || req.id, JSON.stringify({ name: req.name, code })); } catch (_) {}
      try { store.logConfig && store.logConfig('invite_sent', code, JSON.stringify({ name: req.name, email: req.email, role: 'sniper' })); } catch (_) {}
      toast(`Approved — invite code ${code}`, { icon: 'check' });
      setApproveModal({ open: true, request: req, code });
      refreshRequests();
    } catch (e) {
      toast('Approve failed: ' + (e.message || 'unknown'), { icon: 'error' });
    }
  }

  async function dismiss(req) {
    if (!window.confirm(`Dismiss ${req.name || req.email || 'this'}'s request?`)) return;
    try {
      await store.updateRequest({ id: req.id, status: 'dismissed' });
      try { store.logConfig && store.logConfig('request_dismissed', req.email || req.id, JSON.stringify({ name: req.name })); } catch (_) {}
      toast('Dismissed', { icon: 'block' });
      refreshRequests();
    } catch (e) {
      toast('Dismiss failed: ' + (e.message || 'unknown'), { icon: 'error' });
    }
  }

  const Icon = window.Icon, Modal = window.Modal, Toolbar = window.Toolbar;
  const Chip = window.Chip, Badge = window.Badge, PageHeader = window.PageHeader;
  const EmptyState = window.EmptyState;

  return (
    <div className="page">
      <PageHeader title="Access Requests" subtitle="People who submitted the public access form. Approve to auto-generate an invite code, or dismiss." />

      <Toolbar>
        <div className="filter-chips">
          <Chip active={filter === 'pending'} onClick={() => setFilter('pending')}>Pending <span className="muted text-xs" style={{ marginLeft: 4 }}>{counts.pending}</span></Chip>
          <Chip active={filter === 'approved'} onClick={() => setFilter('approved')}>Approved <span className="muted text-xs" style={{ marginLeft: 4 }}>{counts.approved}</span></Chip>
          <Chip active={filter === 'dismissed'} onClick={() => setFilter('dismissed')}>Dismissed <span className="muted text-xs" style={{ marginLeft: 4 }}>{counts.dismissed}</span></Chip>
          <Chip active={filter === 'all'} onClick={() => setFilter('all')}>All</Chip>
        </div>
        <div className="toolbar-spacer"></div>
        <button className="btn btn-icon btn-sm" onClick={refreshRequests} aria-label="Refresh requests"><Icon name="refresh" /></button>
      </Toolbar>

      <div className="table-wrap">
        <table className="table">
          <thead><tr><th>Name</th><th>Email</th><th>Member #</th><th>Message</th><th>Requested</th><th>Status</th><th></th></tr></thead>
          <tbody>
            {filtered.length === 0 && (
              <tr><td colSpan="7">{EmptyState ? <EmptyState icon="inbox" title="No requests" /> : <div style={{ padding: 24, textAlign: 'center' }}>No requests.</div>}</td></tr>
            )}
            {filtered.map(r => (
              <tr key={r.id}>
                <td><strong>{r.name}</strong></td>
                <td className="muted">{r.email}</td>
                <td className="mono text-sm">#{r.memberNum || '—'}</td>
                <td className="muted text-sm" style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                  {r.message || <span style={{ opacity: 0.5 }}>—</span>}
                </td>
                <td className="muted text-sm">{formatRelTime(r.requestedAt || r.createdAt)}</td>
                <td>
                  {r.status === 'pending' ? <Badge tone="warning">Pending</Badge>
                    : r.status === 'approved' ? <Badge tone="success">Approved</Badge>
                    : <Badge tone="neutral">Dismissed</Badge>}
                </td>
                <td>
                  {r.status === 'pending' && (
                    <>
                      <button className="btn btn-text btn-sm" onClick={() => approve(r)}>Approve</button>
                      <button className="btn btn-text btn-sm danger" onClick={() => dismiss(r)}>Dismiss</button>
                    </>
                  )}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <Modal open={approveModal.open} onClose={() => setApproveModal({ open: false, request: null, code: '' })} title="Invite Sent">
        <div style={{ padding: 8 }}>
          <p>Invite sent to <strong>{approveModal.request && approveModal.request.email}</strong>.</p>
          <p>Invite code: <code className="mono" style={{ fontSize: 16, padding: '4px 8px', background: 'var(--md-sys-color-surface-container-high)', borderRadius: 4 }}>{approveModal.code}</code></p>
          <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
            <button className="btn btn-filled" onClick={() => setApproveModal({ open: false, request: null, code: '' })}>OK</button>
          </div>
        </div>
      </Modal>
    </div>
  );
}

// ============ READINESS PAGE ============
function ReadinessPage() {
  const store = React.useContext(window.AdminStoreContext) || {};
  const { assignments = [] } = store;
  const [users, setUsers] = useState([]);

  useOnMount(() => {
    if (window.adminApi && window.adminApi.getUsers) {
      window.adminApi.getUsers().then(u => setUsers(Array.isArray(u) ? u : (u?.users || []))).catch(() => {});
    }
  });

  const stats = useMemo(() => {
    const active = users.filter(u => u.status === 'active').length;
    const pending = users.filter(u => u.status === 'pending').length;
    const armed = assignments.filter(a => ['dispatched', 'acknowledged'].includes(a.status)).length;
    const sevenDays = Date.now() + 7 * 24 * 60 * 60 * 1000;
    const primed = assignments.filter(a => {
      if (a.status !== 'acknowledged') return false;
      if (!a.date) return false;
      const t = new Date(a.date).getTime();
      return !isNaN(t) && t <= sevenDays;
    }).length;
    return { active, pending, armed, primed };
  }, [users, assignments]);

  const Icon = window.Icon, Badge = window.Badge, Stat = window.Stat, PageHeader = window.PageHeader;

  function userArmed(u) {
    return assignments.find(a =>
      a.sniperRosterId === u.rosterId &&
      ['dispatched', 'acknowledged'].includes(a.status)
    );
  }

  return (
    <div className="page">
      <PageHeader title="Readiness" subtitle="Pre-flight check. Are all snipers synced, primed, and ready for the next booking window?" />
      <div className="stat-grid">
        <Stat label="Active" value={stats.active} icon="groups" tone="accent" />
        <Stat label="Pending" value={stats.pending} icon="schedule" />
        <Stat label="Armed" value={stats.armed} icon="bolt" tone="warning" />
        <Stat label="Primed (≤7d)" value={stats.primed} icon="cloud_done" tone="success" />
      </div>
      <div className="table-wrap">
        <table className="table">
          <thead><tr><th>Sniper</th><th>Status</th><th>Last seen</th><th>Active assignment</th><th>Ready?</th></tr></thead>
          <tbody>
            {users.map(u => {
              const armed = userArmed(u);
              const isPrimed = armed && armed.status === 'acknowledged';
              return (
                <tr key={u.id}>
                  <td><strong>{u.name || u.id}</strong> <span className="muted text-sm">{u.email}</span></td>
                  <td><Badge tone={u.status === 'active' ? 'success' : u.status === 'revoked' ? 'danger' : 'warning'}>{u.status || 'pending'}</Badge></td>
                  <td className="muted text-sm">{u.lastSeen ? formatRelTime(u.lastSeen) : '—'}</td>
                  <td className="text-sm">
                    {armed ? <span><span className="mono">{_shortDate(armed.date)}</span> @ <span className="mono">{armed.time}</span></span> : <span className="muted">—</span>}
                  </td>
                  <td>
                    {isPrimed ? <Badge tone="success">Primed</Badge>
                     : armed ? <Badge tone="info">Armed</Badge>
                     : u.status === 'active' ? <Badge tone="info">Active</Badge>
                     : <Badge tone="warning">Idle</Badge>}
                  </td>
                </tr>
              );
            })}
            {users.length === 0 && (
              <tr><td colSpan="5" style={{ padding: 24, textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)' }}>No users.</td></tr>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}

// ============ CALENDAR VIEW ============
// Wires AdminOverhaul's CalendarView to real assignments.
function CalendarView({ assignments = [] }) {
  const Icon = window.Icon;
  const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
  const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());

  const monthName = new Date(viewYear, viewMonth, 1).toLocaleString(undefined, { month: 'long', year: 'numeric' });
  const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
  const firstDay = new Date(viewYear, viewMonth, 1).getDay();
  const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
  const today = new Date();
  const todayDay = (today.getFullYear() === viewYear && today.getMonth() === viewMonth) ? today.getDate() : -1;

  const cells = Array.from({ length: 42 }, (_, i) => {
    const day = i - firstDay + 1;
    if (day < 1 || day > daysInMonth) return { day: null, date: null, ev: [] };
    const dateStr = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
    const ev = assignments.filter(a => a.date === dateStr);
    return { day, date: dateStr, ev };
  });

  function prev() {
    if (viewMonth === 0) { setViewYear(viewYear - 1); setViewMonth(11); }
    else setViewMonth(viewMonth - 1);
  }
  function next() {
    if (viewMonth === 11) { setViewYear(viewYear + 1); setViewMonth(0); }
    else setViewMonth(viewMonth + 1);
  }
  function jumpToday() {
    setViewYear(new Date().getFullYear());
    setViewMonth(new Date().getMonth());
  }

  return (
    <div className="card-outlined" style={{ borderRadius: 12, padding: 16 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
        <h3 style={{ font: '500 18px var(--md-sys-typescale-brand)', margin: 0 }}>{monthName}</h3>
        <div style={{ display: 'flex', gap: 4 }}>
          <button className="btn btn-icon btn-sm" onClick={prev} aria-label="Previous month"><Icon name="chevron_left" /></button>
          <button className="btn btn-text btn-sm" onClick={jumpToday}>Today</button>
          <button className="btn btn-icon btn-sm" onClick={next} aria-label="Next month"><Icon name="chevron_right" /></button>
        </div>
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 1, background: 'var(--md-sys-color-outline-variant)', border: '1px solid var(--md-sys-color-outline-variant)' }}>
        {days.map(d => (
          <div key={d} style={{ background: 'var(--md-sys-color-surface-container)', padding: '8px 12px', fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--md-sys-color-on-surface-variant)' }}>{d}</div>
        ))}
        {cells.map((c, i) => {
          const isToday = c.day === todayDay;
          const cellLabel = c.day
            ? `${new Date(viewYear, viewMonth, c.day).toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}${isToday ? ', today' : ''}${c.ev.length ? `, ${c.ev.length} assignment${c.ev.length === 1 ? '' : 's'}` : ', no assignments'}`
            : undefined;
          return (
          <div key={i} role={c.day ? 'gridcell' : undefined} aria-label={cellLabel} style={{ background: 'var(--md-sys-color-surface)', minHeight: 90, padding: 8, opacity: c.day ? 1 : 0.4 }}>
            <div style={{ fontSize: 13, fontWeight: isToday ? 600 : 400, color: isToday ? 'var(--md-sys-color-primary)' : 'inherit', marginBottom: 4 }}>
              {c.day || ''}
            </div>
            {c.ev.slice(0, 2).map(a => {
              const isArmed = a.status === 'armed' || a.status === 'dispatched';
              const name = a.assignedToName || a.sniperName || a.assignedTo || '';
              return (
                <div key={a.id} style={{
                  background: isArmed ? 'var(--md-sys-color-tertiary-container)' : 'var(--md-sys-color-secondary-container)',
                  color: isArmed ? 'var(--md-sys-color-on-tertiary-container)' : 'var(--md-sys-color-on-secondary-container)',
                  fontSize: 11, padding: '2px 6px', borderRadius: 4, marginBottom: 2,
                  whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis'
                }}>
                  {a.time} {name.split(' ')[0]}
                </div>
              );
            })}
            {c.ev.length > 2 && <div className="muted text-xs">+{c.ev.length - 2} more</div>}
          </div>
          );
        })}
      </div>
    </div>
  );
}

// ============ EXPORT ============
Object.assign(window, {
  SnipersPage,
  AssignmentsPage,
  RequestsPage,
  ReadinessPage,
  GroupBuilder,
  CalendarView,
  PickerDropdown,
  useOnMount
});
