/* global React, Icon, money, PageSkeleton, DemoChip, Spinner, ConfirmDialog,
   useGolfers, useStaff, GOLFER_SORTS, sortGolfers, golfersCsv, ACTIVITY_DAYS,
   useManagerIds, useCourses, addCourseManager, removeCourseManager,
   CreateManagerForm, MigrationNeeded, userName, tierLabel, TIERS, normalTier,
   MembershipCard, AccessCard, GuestPassesCard, DangerZone,
   useMatchHistory, useMatchTrend, sendPasswordReset, setUserPassword, useUserEmail, MatchEditor, deleteMatches, CourseCard, useSbxCourses,
   TabBar, TrendChart */
// People — the golfer database, and who has keys to which course.
//
// The detail panel is the existing UserDetail mounted with `embedded`. Every
// control it carries — membership tier, admin toggle, guest passes and their
// allowance maths, course access, the delete path — is behaviour that already
// works and has nothing to gain from being retyped. This file replaces the
// list around it, not the thing itself.

// Two tiers now. Legacy values in the database still normalise to one of
// these, so a row saved before sql/people-admin.sql still lands in a bucket.
const MEMBER_TIER_KEYS = ['free', 'plus'];
const LEGACY = { walkup: 'free', stats: 'free', league: 'plus', plus: 'plus', free: 'free' };
const tierKey = (t) => LEGACY[t] || 'free';

function initialsOf(name) {
  return String(name || '').split(' ').filter(Boolean).slice(0, 2)
    .map(s => s[0].toUpperCase()).join('') || '·';
}

function relativeDay(iso) {
  if (!iso) return 'Never';
  const days = Math.floor((Date.now() - new Date(iso).getTime()) / 864e5);
  if (days <= 0) return 'Today';
  if (days === 1) return 'Yesterday';
  if (days < 30) return `${days}d ago`;
  if (days < 365) return `${Math.round(days / 30)}mo ago`;
  return `${Math.floor(days / 365)}y ago`;
}


// ─── PersonAvatar ───────────────────────────────────────────────────────────
// The picture when there is one, initials when there isn't. onError matters:
// an avatar_url that 404s would otherwise leave a broken-image glyph in
// every row, which looks worse than never having tried.
function PersonAvatar({ src, name, size = 28 }) {
  const [failed, setFailed] = React.useState(false);
  const show = src && !failed;
  return (
    <span aria-hidden="true" style={{
      width: size, height: size, borderRadius: 999, flexShrink: 0, overflow: 'hidden',
      background: 'rgba(234,226,206,0.12)', color: 'var(--cream)',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      fontSize: Math.round(size * 0.37), fontWeight: 800,
    }}>
      {show
        ? <img src={src} alt="" width={size} height={size} onError={() => setFailed(true)}
            style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}/>
        : initialsOf(name)}
    </span>
  );
}

// ─── RolePill ─────────────────────────────────────────────────────────
// Filled for admin, outlined for manager, nothing at all for a regular
// golfer — most rows are regular, and a badge on every one is noise.
function RolePill({ role }) {
  if (role === 'regular') return null;
  const s = role === 'admin'
    ? { background: 'var(--cream)', color: 'var(--forest)', border: '1px solid var(--cream)' }
    : { background: 'transparent', color: 'var(--ink)', border: '1px solid var(--ink-faint)' };
  return <span className="pill-mono" style={{ ...s, fontSize: 8.5, padding: '2px 6px' }}>{role}</span>;
}

// ─── Filter chips ─────────────────────────────────────────────────────
function Chips({ options, value, onChange }) {
  return (
    <div className="no-scrollbar" style={{ display: 'flex', gap: 6, overflowX: 'auto' }}>
      {options.map(o => {
        const on = o.key === value;
        return (
          <button key={o.key} onClick={() => onChange(o.key)}
            style={{
              padding: '6px 12px', borderRadius: 999, cursor: 'pointer', whiteSpace: 'nowrap',
              font: 'inherit', fontSize: 12, fontWeight: on ? 700 : 600,
              background: on ? 'var(--cream)' : 'transparent',
              color: on ? 'var(--forest)' : 'var(--ink-muted)',
              border: `1px solid ${on ? 'var(--cream)' : 'var(--line-strong)'}`,
            }}>
            {o.label}
            {o.count != null && <span style={{ fontFamily: 'var(--font-mono)', opacity: 0.65, marginLeft: 5 }}>{o.count}</span>}
          </button>
        );
      })}
    </div>
  );
}

// Sortable column header. The arrow shows both which column is sorted and
// which way, so the table never leaves you guessing why the order changed.
function Th({ id, label, sort, onSort, align = 'left' }) {
  const on = sort.key === id;
  return (
    <th style={{ textAlign: align }}>
      <button onClick={() => onSort(id)}
        style={{
          background: 'none', border: 'none', padding: 0, font: 'inherit', cursor: 'pointer',
          color: on ? 'var(--paper)' : 'inherit', fontWeight: 700,
          display: 'inline-flex', alignItems: 'center', gap: 4,
          flexDirection: align === 'right' ? 'row-reverse' : 'row',
        }}
        aria-sort={on ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'}>
        {label}
        <span aria-hidden="true" style={{ opacity: on ? 0.9 : 0.25, fontSize: 9 }}>
          {on ? (sort.dir === 'asc' ? '▲' : '▼') : '▾'}
        </span>
      </button>
    </th>
  );
}

// ─── GolfersBoard ─────────────────────────────────────────────────────
function GolfersBoard({ adminId }) {
  const [query, setQuery] = React.useState('');
  const [debounced, setDebounced] = React.useState('');
  const [role, setRole] = React.useState('all');
  const [tier, setTier] = React.useState('all');
  const [sort, setSort] = React.useState({ key: 'joined', dir: 'desc' });
  const [open, setOpen] = React.useState(null);
  const [creating, setCreating] = React.useState(false);

  // Typing shouldn't fire a query per keystroke against the whole table.
  React.useEffect(() => {
    const t = setTimeout(() => setDebounced(query), 280);
    return () => clearTimeout(t);
  }, [query]);

  const [state, reload] = useGolfers(debounced);
  const managerIds = useManagerIds();

  const roleFor = React.useCallback((g) => {
    if (g.is_admin) return 'admin';
    if (managerIds && managerIds.has(g.id)) return 'manager';
    return 'regular';
  }, [managerIds]);

  if (state && state.error === 'MIGRATION') return <MigrationNeeded/>;

  if (creating) {
    return (
      <div style={{ maxWidth: 900, margin: '0 auto' }}>
        <button className="btn btn-ghost" onClick={() => setCreating(false)} style={{ marginBottom: 16, padding: '6px 12px' }}>← Golfers</button>
        <CreateManagerForm embedded adminId={adminId}
          onClose={() => setCreating(false)}
          onCreated={() => { setCreating(false); reload(); }}/>
      </div>
    );
  }

  if (open) {
    const name = userName(open);
    return (
      <div style={{ maxWidth: 900, margin: '0 auto' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 18 }}>
          <button className="btn btn-ghost" onClick={() => { setOpen(null); reload(); }} style={{ padding: '6px 12px' }}>← Golfers</button>
          <PersonAvatar src={open.avatar_url} name={name} size={44}/>
          <div style={{ flex: 1, minWidth: 160 }}>
            <div style={{ fontFamily: 'var(--font-display)', fontSize: 22, color: 'var(--paper)', lineHeight: 1.1 }}>{name}</div>
            <div style={{ fontSize: 12.5, color: 'var(--ink-muted)', marginTop: 2, fontFamily: 'var(--font-mono)' }}>
              {open.handle ? `@${String(open.handle).replace(/^@/, '')}` : '—'} · SBX {open.sbx != null ? Number(open.sbx).toFixed(3) : '—'}
              {' · '}{open.act.rounds} round{open.act.rounds === 1 ? '' : 's'} · {money(open.act.spend)} in {ACTIVITY_DAYS === 365 ? '12mo' : `${ACTIVITY_DAYS}d`}
            </div>
          </div>
          <RolePill role={roleFor(open)}/>
        </div>
        <GolferCards user={open} adminId={adminId} onGone={() => { setOpen(null); reload(); }}/>
      </div>
    );
  }

  if (!state) return <PageSkeleton/>;

  const counts = { all: state.rows.length, admin: 0, manager: 0, regular: 0 };
  state.rows.forEach(g => { counts[roleFor(g)] += 1; });
  const tierCounts = { all: state.rows.length };
  MEMBER_TIER_KEYS.forEach(k => { tierCounts[k] = state.rows.filter(g => tierKey(g.tier) === k).length; });

  const filtered = state.rows.filter(g =>
    (role === 'all' || roleFor(g) === role)
    && (tier === 'all' || tierKey(g.tier) === tier));
  const shown = sortGolfers(filtered, sort.key, sort.dir);

  const onSort = (key) => setSort(s => (
    s.key === key
      ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' }
      // Numbers and dates are far more useful biggest-first on the first
      // click; names are not.
      : { key, dir: GOLFER_SORTS[key].numeric || key === 'joined' || key === 'played' ? 'desc' : 'asc' }
  ));

  function exportCsv() {
    const csv = golfersCsv(shown, roleFor);
    const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
    const a = document.createElement('a');
    a.href = url;
    a.download = `sandbox-golfers-${new Date().toISOString().slice(0, 10)}.csv`;
    document.body.appendChild(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  }

  const totals = shown.reduce((n, g) => ({
    rounds: n.rounds + g.act.rounds,
    spend: n.spend + g.act.spend,
    active: n.active + (g.act.rounds > 0 ? 1 : 0),
  }), { rounds: 0, spend: 0, active: 0 });

  return (
    <div style={{ maxWidth: 1240, margin: '0 auto' }}>
      {state.demo && <div style={{ marginBottom: 12 }}><DemoChip corner={false}/></div>}

      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(168px, 1fr))', gap: 12, marginBottom: 16 }}>
        {[
          { label: 'Golfers', value: state.rows.length.toLocaleString(), sub: `${counts.admin} admin · ${counts.manager} manager` },
          { label: 'Played In 12mo', value: totals.active.toLocaleString(), sub: state.rows.length ? `${Math.round((totals.active / state.rows.length) * 100)}% of the base` : '—' },
          { label: 'Rounds · 12mo', value: totals.rounds.toLocaleString(), sub: 'In the current view' },
          { label: 'Spend · 12mo', value: money(totals.spend), sub: 'Booking value, in view' },
        ].map(k => (
          <div key={k.label} className="card" style={{ padding: '15px 16px' }}>
            <div className="eyebrow">{k.label}</div>
            <div style={{ fontFamily: 'var(--font-display)', fontSize: 25, color: 'var(--paper)', marginTop: 3, fontVariantNumeric: 'tabular-nums' }}>{k.value}</div>
            <div style={{ fontSize: 11.5, color: 'var(--ink-muted)', marginTop: 2 }}>{k.sub}</div>
          </div>
        ))}
      </div>

      {/* Controls */}
      <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginBottom: 12 }}>
        <div style={{ position: 'relative', flex: '1 1 220px', minWidth: 190 }}>
          <input className="input" value={query} onChange={e => setQuery(e.target.value)}
            placeholder="Search name or @handle" aria-label="Search golfers"
            style={{ paddingLeft: 32 }}/>
          <Icon name="users" size={14} style={{ position: 'absolute', left: 11, top: '50%', transform: 'translateY(-50%)', opacity: 0.45, pointerEvents: 'none' }}/>
        </div>
        <Chips value={role} onChange={setRole} options={[
          { key: 'all', label: 'All', count: counts.all },
          { key: 'admin', label: 'Admins', count: counts.admin },
          { key: 'manager', label: 'Managers', count: counts.manager },
          { key: 'regular', label: 'Golfers', count: counts.regular },
        ]}/>
        <Chips value={tier} onChange={setTier} options={[
          { key: 'all', label: 'Any tier' },
          ...MEMBER_TIER_KEYS.map(k => ({ key: k, label: tierLabel(k), count: tierCounts[k] })),
        ]}/>
        <div style={{ flex: 1 }}/>
        <button className="btn btn-ghost" onClick={exportCsv} disabled={!shown.length}
          title="Export exactly what's on screen">Export CSV</button>
        <button className="btn btn-forest" onClick={() => setCreating(true)}>+ Manager account</button>
      </div>

      <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
        <div style={{ overflowX: 'auto' }}>
          <table className="table" style={{ minWidth: 900 }}>
            <thead>
              <tr>
                <Th id="name" label="Golfer" sort={sort} onSort={onSort}/>
                <th>Tier</th>
                <Th id="sbx" label="SBX" sort={sort} onSort={onSort} align="right"/>
                <Th id="rounds" label="Rounds" sort={sort} onSort={onSort} align="right"/>
                <Th id="spend" label="Spend" sort={sort} onSort={onSort} align="right"/>
                <Th id="played" label="Last played" sort={sort} onSort={onSort}/>
                <Th id="joined" label="Joined" sort={sort} onSort={onSort}/>
              </tr>
            </thead>
            <tbody>
              {shown.map(g => {
                const name = userName(g);
                const num = { textAlign: 'right', fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' };
                return (
                  <tr key={g.id} onClick={() => setOpen(g)} style={{ cursor: 'pointer' }}>
                    <td>
                      <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
                        <PersonAvatar src={g.avatar_url} name={name} size={30}/>
                        <span style={{ minWidth: 0 }}>
                          <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                            <span style={{ fontWeight: 700, color: 'var(--paper)' }}>{name}</span>
                            <RolePill role={roleFor(g)}/>
                          </span>
                          <span style={{ display: 'block', fontSize: 11.5, color: 'var(--ink-muted)', fontFamily: 'var(--font-mono)' }}>
                            {g.handle ? `@${String(g.handle).replace(/^@/, '')}` : '—'}
                          </span>
                        </span>
                      </div>
                    </td>
                    <td><span className="pill-mono" style={{ fontSize: 9, padding: '3px 8px', border: '1px solid var(--line-strong)', color: 'var(--ink-soft)' }}>{tierLabel(g.tier)}</span></td>
                    <td style={num}>{g.sbx != null ? Number(g.sbx).toFixed(3) : '—'}</td>
                    <td style={num}>{g.act.rounds || '—'}</td>
                    <td style={{ ...num, color: g.act.spend ? 'var(--paper)' : 'var(--ink-faint)' }}>{g.act.spend ? money(g.act.spend) : '—'}</td>
                    <td style={{ fontSize: 12.5, color: 'var(--ink-muted)', whiteSpace: 'nowrap' }}>{relativeDay(g.act.lastPlayed)}</td>
                    <td style={{ fontSize: 12.5, color: 'var(--ink-muted)', fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}>
                      {g.created_at ? String(g.created_at).slice(0, 10) : '—'}
                    </td>
                  </tr>
                );
              })}
              {!shown.length && (
                <tr><td colSpan={7} style={{ padding: 30, textAlign: 'center', color: 'var(--ink-muted)' }}>
                  {state.error || (debounced ? `Nobody matches “${debounced}”.` : 'No golfers match these filters.')}
                </td></tr>
              )}
            </tbody>
          </table>
        </div>
      </div>

      <div style={{ fontSize: 11.5, color: 'var(--ink-faint)', marginTop: 10 }}>
        Rounds and spend cover the last 12 months and exclude cancellations and no-shows.
      </div>
    </div>
  );
}


// ─── MatchHistory ─────────────────────────────────────────────────────
// Result is shape as well as fill, matching how the app renders match play
// everywhere else: W filled, L outlined, H a dashed outline.
function ResultBadge({ outcome }) {
  const map = {
    W: { background: 'var(--cream)', color: 'var(--forest)', border: '1px solid var(--cream)', label: 'W' },
    L: { background: 'transparent', color: 'var(--ink-soft)', border: '1px solid var(--ink-faint)', label: 'L' },
    H: { background: 'transparent', color: 'var(--ink-muted)', border: '1px dashed var(--ink-faint)', label: 'H' },
  }[outcome];
  if (!map) return <span className="pill-mono" style={{ fontSize: 9, padding: '3px 8px', border: '1px dashed var(--ink-faint)', color: 'var(--ink-muted)' }}>—</span>;
  return (
    <span className="pill-mono" style={{
      background: map.background, color: map.color, border: map.border,
      fontSize: 11, padding: '4px 10px', minWidth: 30, justifyContent: 'center',
    }}>{map.label}</span>
  );
}

function MatchHistory({ user }) {
  const [pages, setPages] = React.useState(1);
  const hist = useMatchHistory(user.id, pages);
  // Which match is open for editing. MatchEditor + its data layer come from
  // the monorepo (components/matches-data.jsx); this screen is the only
  // place the admin dashboard can reach them, so the rows are the way in.
  const [editing, setEditing] = React.useState(null);

  // Bulk cleanup — the reason this exists is test/junk matches skewing a
  // real golfer's SBX rating, and those rarely come one at a time.
  const [selectMode, setSelectMode] = React.useState(false);
  const [selected, setSelected] = React.useState(() => new Set());
  const [confirmBulk, setConfirmBulk] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState('');

  // GolferCards does not remount this component when the admin switches to
  // a different golfer's page (no `key` on the tree above it) — so without
  // this, a page number or a stale selection from the last golfer viewed
  // would carry over onto the next one.
  React.useEffect(() => {
    setPages(1); setSelectMode(false); setSelected(new Set()); setErr('');
  }, [user.id]);

  if (editing) {
    return (
      <div className="card" style={{ padding: 22 }}>
        <MatchEditor
          matchId={editing}
          onBack={() => setEditing(null)}
          onDeleted={() => setEditing(null)}
        />
      </div>
    );
  }

  if (!hist) {
    return <div className="card" style={{ padding: 22 }}><div className="eyebrow">Matches</div><div style={{ marginTop: 12 }}><Spinner/></div></div>;
  }

  const played = hist.matches.filter(m => m.status === 'completed');
  const w = played.filter(m => m.outcome === 'W').length;
  const l = played.filter(m => m.outcome === 'L').length;
  const h = played.filter(m => m.outcome === 'H').length;

  function toggle(id) {
    setSelected(s => {
      const next = new Set(s);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
  }
  function selectAllLoaded() { setSelected(new Set(hist.matches.map(m => m.id))); }
  function clearSelection() { setSelected(new Set()); }
  function exitSelectMode() { setSelectMode(false); clearSelection(); }

  async function runBulkDelete() {
    setBusy(true); setErr('');
    try {
      await deleteMatches([...selected]);
      exitSelectMode();
      setPages(1); // reload from the top rather than trust stale pagination
    } catch (e) { setErr(e.message || 'Could not delete those matches.'); }
    setBusy(false); setConfirmBulk(false);
  }

  const selectedCourses = [...new Set(
    hist.matches.filter(m => selected.has(m.id)).map(m => m.course || 'Course not recorded'),
  )];

  return (
    <div className="card" style={{ padding: 22 }}>
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
        <div className="eyebrow">Matches</div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
          {!!played.length && (
            <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11.5, color: 'var(--ink-muted)' }}>
              {w}W · {l}L{h ? ` · ${h}H` : ''} · {hist.matches.length} loaded{hist.hasMore ? '+' : ''}
            </div>
          )}
          {!hist.blocked && !!hist.matches.length && (
            selectMode ? (
              <button className="btn btn-ghost" style={{ padding: '4px 11px', fontSize: 11.5 }} onClick={exitSelectMode} disabled={busy}>
                Cancel
              </button>
            ) : (
              <button className="btn btn-ghost" style={{ padding: '4px 11px', fontSize: 11.5 }} onClick={() => setSelectMode(true)}>
                Select
              </button>
            )
          )}
        </div>
      </div>

      {hist.blocked ? (
        <div style={{ fontSize: 13, color: 'var(--ink-soft)', marginTop: 12, lineHeight: 1.55 }}>
          <strong style={{ color: 'var(--paper)' }}>Match history isn&rsquo;t visible to admins yet.</strong>{' '}
          The golfer app&rsquo;s row-level security lets a player read only their own matches. Run{' '}
          <code style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>sql/people-admin.sql</code> in Supabase to
          add read-only admin access.
        </div>
      ) : !hist.matches.length ? (
        <div style={{ fontSize: 13, color: 'var(--ink-muted)', marginTop: 12 }}>
          No matches on record for this golfer.
        </div>
      ) : (
        <>
          {selectMode && (
            <div style={{
              display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
              marginTop: 12, padding: '9px 12px', borderRadius: 'var(--r-xs)',
              background: 'rgba(234,226,206,0.06)', border: '1px solid var(--line)',
            }}>
              <span style={{ fontSize: 12.5, color: 'var(--paper)', fontWeight: 700 }}>
                {selected.size} selected
              </span>
              <button onClick={selectAllLoaded}
                style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', cursor: 'pointer', fontSize: 12, color: 'var(--ink-muted)', textDecoration: 'underline' }}>
                Select all {hist.matches.length} loaded
              </button>
              {selected.size > 0 && (
                <button onClick={clearSelection}
                  style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', cursor: 'pointer', fontSize: 12, color: 'var(--ink-muted)', textDecoration: 'underline' }}>
                  Clear
                </button>
              )}
              <div style={{ flex: 1 }}/>
              <button className="btn btn-danger" style={{ padding: '5px 12px', fontSize: 12 }}
                disabled={!selected.size || busy} onClick={() => setConfirmBulk(true)}>
                Delete {selected.size || ''} selected
              </button>
            </div>
          )}

          {err && <div className="form-error" role="alert" style={{ marginTop: 12, marginBottom: 0 }}>{err}</div>}

          <div style={{ marginTop: 12 }}>
            {hist.matches.map((m, i) => {
              const isSelected = selected.has(m.id);
              const row = (
                <>
                  {selectMode && (
                    <input type="checkbox" checked={isSelected} onChange={() => toggle(m.id)}
                      onClick={e => e.stopPropagation()}
                      aria-label={`Select match ${m.opponent ? `vs ${userName(m.opponent)}` : ''} on ${relativeDay(m.playedAt)}`}
                      style={{ width: 16, height: 16, flexShrink: 0, accentColor: 'var(--loss)', cursor: 'pointer' }}/>
                  )}
                  <ResultBadge outcome={m.outcome}/>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: 13.5, color: 'var(--paper)', fontWeight: 600 }}>
                      {m.solo
                        ? 'Solo round'
                        : <>vs <strong>{m.opponent ? userName(m.opponent) : 'Unknown player'}</strong></>}
                      {m.margin ? <span style={{ color: 'var(--ink-muted)', fontWeight: 400 }}> · {m.margin}</span> : null}
                    </div>
                    <div style={{ fontSize: 11.5, color: 'var(--ink-muted)', fontFamily: 'var(--font-mono)', marginTop: 2 }}>
                      {[m.course || 'Course not recorded', m.holes ? `${m.holes} holes` : null, relativeDay(m.playedAt)]
                        .filter(Boolean).join(' · ')}
                    </div>
                  </div>
                  {m.status !== 'completed' && (
                    <span className="pill-mono" style={{ fontSize: 9, padding: '2px 7px', border: '1px dashed var(--ink-faint)', color: 'var(--ink-muted)' }}>
                      {m.status}
                    </span>
                  )}
                </>
              );
              const rowStyle = {
                display: 'flex', alignItems: 'center', gap: 12, padding: '11px 0',
                borderTop: i ? '1px solid var(--line)' : 'none',
                width: '100%', textAlign: 'left', font: 'inherit',
                background: isSelected ? 'rgba(234,226,206,0.05)' : 'transparent',
                border: 'none', borderRadius: 0, cursor: 'pointer',
              };
              // Reapply the top hairline over the selection tint, since the
              // background override above would otherwise paint over it.
              if (i) rowStyle.borderTop = '1px solid var(--line)';
              return (
                <button key={m.id} onClick={() => (selectMode ? toggle(m.id) : setEditing(m.id))}
                  title={selectMode ? 'Toggle selection' : 'Open this match to correct or delete it'}
                  style={rowStyle}>
                  {row}
                </button>
              );
            })}
          </div>

          {hist.hasMore && !selectMode && (
            <div style={{ textAlign: 'center', marginTop: 14 }}>
              <button className="btn btn-ghost" style={{ padding: '6px 14px', fontSize: 12.5 }} onClick={() => setPages(p => p + 1)}>
                Load older matches
              </button>
            </div>
          )}
        </>
      )}

      {confirmBulk && (
        <ConfirmDialog
          open danger
          title={`Delete ${selected.size} match${selected.size === 1 ? '' : 'es'}?`}
          body={
            <>
              This permanently deletes {selected.size} match{selected.size === 1 ? '' : 'es'} and their
              hole-by-hole scores{selectedCourses.length
                ? <> — {selectedCourses.slice(0, 3).join(', ')}{selectedCourses.length > 3 ? `, +${selectedCourses.length - 3} more` : ''}</>
                : null}. There is no undo.
              <div style={{ marginTop: 8 }}>
                SBX ratings are recomputed on a schedule, not instantly — the golfer&rsquo;s rating will
                catch up to this once that job next runs, not the moment you click delete.
              </div>
            </>
          }
          confirmLabel={busy ? 'Deleting…' : `Delete ${selected.size}`}
          busy={busy}
          onConfirm={runBulkDelete}
          onCancel={() => setConfirmBulk(false)}/>
      )}
    </div>
  );
}

// ─── CopyField ────────────────────────────────────────────────────────
// A value you are meant to take away with you, with a copy button that
// confirms it worked. navigator.clipboard only exists in a secure context,
// so there is a fallback for plain http.
function CopyField({ value, label }) {
  const [copied, setCopied] = React.useState(false);

  async function copy() {
    let ok = false;
    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(value);
        ok = true;
      }
    } catch (_) { ok = false; }
    if (!ok) {
      // Fallback: a hidden textarea and the old execCommand path.
      try {
        const ta = document.createElement('textarea');
        ta.value = value;
        ta.setAttribute('readonly', '');
        ta.style.cssText = 'position:fixed;top:-1000px;opacity:0';
        document.body.appendChild(ta);
        ta.select();
        ok = document.execCommand('copy');
        ta.remove();
      } catch (_) { ok = false; }
    }
    if (ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); }
  }

  return (
    <div>
      {label && <div className="eyebrow" style={{ fontSize: 9, marginBottom: 5 }}>{label}</div>}
      <div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
        <code style={{
          flex: 1, minWidth: 0, fontFamily: 'var(--font-mono)', fontSize: 15, letterSpacing: '0.02em',
          background: 'var(--surface-sunken)', border: '1px solid var(--line-strong)',
          borderRadius: 'var(--r-sm)', padding: '10px 12px', color: 'var(--paper)',
          overflowX: 'auto', whiteSpace: 'nowrap',
        }}>{value}</code>
        <button className="btn btn-forest" onClick={copy} style={{ whiteSpace: 'nowrap' }}>
          {copied ? 'Copied ✓' : 'Copy'}
        </button>
      </div>
    </div>
  );
}

// ─── PasswordPanel ────────────────────────────────────────────────────
// A reset link and a direct set are genuinely different operations here,
// and the panel says which is which rather than presenting two buttons that
// look equivalent.
function PasswordPanel({ user }) {
  const known = useUserEmail(user.id);      // undefined while looking, null if unavailable
  const [email, setEmail] = React.useState('');
  const [touched, setTouched] = React.useState(false);
  // Fill it in once the lookup lands, unless the admin has already typed.
  React.useEffect(() => { if (known && !touched) setEmail(known); }, [known, touched]);
  const [pw, setPw] = React.useState('');
  const [busy, setBusy] = React.useState('');
  const [err, setErr] = React.useState('');
  const [note, setNote] = React.useState('');
  const [confirm, setConfirm] = React.useState(false);
  const [done, setDone] = React.useState(null);   // { password, email, emailConfirmed }

  function generate() {
    // Readable and shareable — three short chunks. It is a temporary
    // password the account holder is expected to change, not a secret we
    // are protecting, so Math.random is adequate here.
    const chunk = () => Math.random().toString(36).slice(2, 6);
    setPw(`${chunk()}-${chunk()}-${chunk()}`);
    setErr(''); setNote('');
  }

  async function reset() {
    setBusy('reset'); setErr(''); setNote('');
    try {
      const addr = await sendPasswordReset(email);
      setNote(`Reset link sent to ${addr}. It expires, so tell them to look now.`);
      setEmail('');
    } catch (e) { setErr(e.message); }
    setBusy('');
  }

  async function apply() {
    setBusy('set'); setErr(''); setNote('');
    try {
      const res = await setUserPassword(user.id, pw);
      // The password stays on screen until it is dismissed. Clearing it on
      // success was the original behaviour and it was a trap: a generated
      // password nobody had copied yet vanished the moment it was set.
      setDone({ password: pw, email: res.email, emailConfirmed: res.emailConfirmed !== false });
    } catch (e) { setErr(e.message); }
    setBusy(''); setConfirm(false);
  }

  if (done) {
    return (
      <div className="card" style={{ padding: 22 }}>
        <div className="eyebrow">Password</div>
        <div style={{ fontSize: 14, color: 'var(--paper)', fontWeight: 700, marginTop: 10 }}>
          Password set{done.email ? <> for <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 400 }}>{done.email}</span></> : null}.
        </div>
        <div style={{ fontSize: 12.5, color: 'var(--ink-muted)', marginTop: 4, lineHeight: 1.5 }}>
          Copy it now — it is not stored anywhere and this is the only time it is shown.
          Send it over something private, not email.
        </div>

        <div style={{ marginTop: 14 }}>
          <CopyField label="New password" value={done.password}/>
        </div>

        {!done.emailConfirmed && (
          <div className="form-error" role="alert" style={{ marginTop: 14, marginBottom: 0 }}>
            This account&rsquo;s email has never been confirmed, so Supabase will refuse an
            email-and-password sign-in even with the right password. Confirm it in
            Authentication → Users, or turn off &ldquo;Confirm email&rdquo; for the project.
          </div>
        )}

        <div style={{ display: 'flex', gap: 10, marginTop: 16, flexWrap: 'wrap' }}>
          <button className="btn btn-ghost" onClick={() => { setDone(null); setPw(''); }}>Done</button>
        </div>
      </div>
    );
  }

  return (
    <div className="card" style={{ padding: 22 }}>
      <div className="eyebrow">Password</div>

      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 18, marginTop: 14 }}>
        <div>
          <div style={{ fontSize: 13.5, fontWeight: 700, color: 'var(--paper)' }}>Send a reset link</div>
          <div style={{ fontSize: 12, color: 'var(--ink-muted)', marginTop: 4, lineHeight: 1.5 }}>
            They set their own password and you never see it.
            {known === undefined
              ? ' Fetching the address on file…'
              : known
                ? ' Their address is filled in below; change it if you need to send it somewhere else.'
                : ' Their address needs typing in — run sql/people-admin.sql and it will fill itself in.'}
          </div>
          <div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
            <input className="input" type="email" value={email}
              onChange={e => { setEmail(e.target.value); setTouched(true); }}
              placeholder={known === undefined ? 'Looking up their email…' : 'their@email.com'}
              aria-label="Email for the reset link"
              style={{ flex: '1 1 160px', minWidth: 150 }}/>
            <button className="btn btn-ghost" onClick={reset} disabled={!!busy || !email.trim()}>
              {busy === 'reset' ? 'Sending…' : 'Send link'}
            </button>
          </div>
        </div>

        <div>
          <div style={{ fontSize: 13.5, fontWeight: 700, color: 'var(--paper)' }}>Set one directly</div>
          <div style={{ fontSize: 12, color: 'var(--ink-muted)', marginTop: 4, lineHeight: 1.5 }}>
            For a course account you&rsquo;re handing over in person. Goes through an Edge Function,
            because changing someone else&rsquo;s password needs a key that must never be in a browser.
          </div>
          <div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
            <input className="input" value={pw} onChange={e => setPw(e.target.value)}
              placeholder="at least 8 characters" aria-label="New password"
              style={{ flex: '1 1 160px', minWidth: 150, fontFamily: 'var(--font-mono)' }}/>
            <button className="btn btn-ghost" onClick={generate} disabled={!!busy}>Generate</button>
            <button className="btn btn-forest" onClick={() => setConfirm(true)} disabled={!!busy || pw.length < 8}>
              {busy === 'set' ? 'Setting…' : 'Set'}
            </button>
          </div>
        </div>
      </div>

      {(err || note) && (
        <div style={{
          marginTop: 14, padding: '11px 14px', borderRadius: 'var(--r-sm)',
          background: 'var(--surface-sunken)', borderLeft: `3px solid ${err ? 'var(--loss)' : 'var(--cream)'}`,
          fontSize: 12.5, color: 'var(--ink-soft)', lineHeight: 1.5,
        }}>{err || note}</div>
      )}

      {confirm && (
        <ConfirmDialog
          open danger
          title={`Set a new password for ${userName(user)}?`}
          body="They will be signed out of nothing automatically, but their old password stops working immediately. Make sure you can actually get the new one to them."
          confirmLabel="Set password"
          onConfirm={apply}
          onCancel={() => setConfirm(false)}/>
      )}
    </div>
  );
}


// ─── Golfer detail tabs ───────────────────────────────────────────────
// Three tabs, split by what you came to do rather than by which table the
// data lives in:
//   Overview        — who is this person (read-only), plus the password
//                     controls, which are the one thing you reach for
//                     without wanting to change anything about the account
//   Player history  — what they have actually done: form over time, and
//                     every match
//   Settings        — everything that CHANGES the account, including the
//                     irreversible bit, behind one deliberate click
//
// Membership appears in both, deliberately and differently: a read-only
// card on Overview because "what tier are they" is a question you ask far
// more often than you change it, and the editable one in Settings. Reading
// it should not put a control that alters billing under the cursor.
const GOLFER_TABS = [
  { id: 'overview', label: 'Overview',       hint: 'Who this golfer is' },
  { id: 'history',  label: 'Player History', hint: 'Form over time, and every match' },
  { id: 'settings', label: 'Settings',       hint: 'Membership, passes, access, deletion' },
];

// A labelled value, or a muted em-dash. Rows whose value is missing are
// dropped by the caller rather than rendered blank, so the card never shows
// a column of dashes.
function FactRow({ label, children }) {
  return (
    <>
      <dt style={{ color: 'var(--ink-muted)', fontSize: 12.5 }}>{label}</dt>
      <dd style={{ margin: 0, fontSize: 13.5, color: 'var(--paper)', minWidth: 0, wordBreak: 'break-word' }}>{children}</dd>
    </>
  );
}

// Membership, stated not offered. The editable control is in Settings.
function TierReadout({ tier }) {
  const key = normalTier(tier);
  const plus = key === 'plus';
  return (
    <div className="card" style={{ padding: 20 }}>
      <div className="eyebrow">Membership</div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 12, flexWrap: 'wrap' }}>
        <span className="pill-mono" style={{
          fontSize: 11, padding: '5px 12px', fontWeight: 700,
          background: plus ? 'var(--cream)' : 'transparent',
          color: plus ? 'var(--forest)' : 'var(--ink)',
          border: plus ? 'none' : '1px solid var(--line-strong)',
        }}>{tierLabel(key)}</span>
        <span style={{ fontSize: 12.5, color: 'var(--ink-muted)' }}>
          {plus ? 'Priority seeding in the sift, and guest passes.' : 'Pays per round, no priority seeding.'}
        </span>
      </div>
      <div style={{ fontSize: 11.5, color: 'var(--ink-faint)', marginTop: 12, lineHeight: 1.5 }}>
        Change this under <strong style={{ color: 'var(--ink-muted)' }}>Settings</strong>.
      </div>
    </div>
  );
}

function OverviewTabPanel({ user }) {
  const email = useUserEmail(user.id);   // undefined while looking, null if unavailable
  const display = user.handle ? `@${String(user.handle).replace(/^@/, '')}` : null;
  const real = [user.first_name, user.last_name].filter(Boolean).join(' ').trim();
  const joined = user.created_at
    ? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
    : null;

  // gender / birthday are rendered only if the row actually carries them.
  // `profiles` has neither column today and fetchGolfers selects an explicit
  // list, so these never appear — but naming a column that doesn't exist in
  // that select would fail the whole golfer query (which is exactly how the
  // `tier` migration broke this screen once). So: no schema guesswork, and
  // the rows light up on their own if the columns are ever added.
  const facts = [
    display && ['Display name', display],
    real && ['Real name', real],
    user.gender && ['Gender', String(user.gender)],
    user.birthday && ['Birthday', new Date(user.birthday).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })],
    ['SBX rating', user.sbx != null ? Number(user.sbx).toFixed(3) : 'Not rated yet'],
    joined && ['Joined', joined],
    ['Email', email === undefined ? 'Looking it up…' : (email || 'Not available — run sql/people-admin.sql')],
  ].filter(Boolean);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 16 }}>
        <div className="card" style={{ padding: 20 }}>
          <div className="eyebrow">Identity</div>
          <dl style={{
            margin: '14px 0 0', display: 'grid', gridTemplateColumns: 'auto 1fr',
            gap: '10px 18px', alignItems: 'baseline',
          }}>
            {facts.map(([k, v]) => <FactRow key={k} label={k}>{v}</FactRow>)}
          </dl>
        </div>

        <TierReadout tier={user.tier}/>
      </div>

      <PasswordPanel user={user}/>
    </div>
  );
}

// ─── Player history ───────────────────────────────────────────────────
const TREND_WINDOWS = [
  { key: '7d',  label: '1W',  days: 7 },
  { key: '30d', label: '1M',  days: 30 },
  { key: '6m',  label: '6M',  days: 182 },
  { key: '1y',  label: '1Y',  days: 365 },
  { key: 'all', label: 'All', days: null },
];

function PlayerHistoryPanel({ user }) {
  const trend = useMatchTrend(user.id);
  const sbxHist = useSbxHistory(user.id, user.sbx);
  const [win, setWin] = React.useState('6m');
  // SBX is the headline; Form is the fallback that works from day one,
  // since SBX history only starts accumulating from the day the table
  // went in and a brand-new trail is a single dot.
  const [mode, setMode] = React.useState('sbx');
  // A different golfer starts from the same defaults rather than inheriting
  // whatever was open on the last one.
  React.useEffect(() => { setWin('6m'); setMode('sbx'); }, [user.id]);

  const chosen = TREND_WINDOWS.find(w => w.key === win) || TREND_WINDOWS[2];
  const since = chosen.days == null ? null : Date.now() - chosen.days * 864e5;

  // Cumulative win-loss differential, running from the golfer's very first
  // match — then sliced to the window. Cumulative rather than per-match so
  // the line reads as form (climbing = winning more than losing), and built
  // from the full series rather than the window so the curve entering the
  // window starts where the golfer actually was, not at zero.
  const series = React.useMemo(() => {
    if (!trend || !trend.points.length) return [];
    let run = 0;
    return trend.points.map(p => {
      if (p.outcome === 'W') run += 1;
      else if (p.outcome === 'L') run -= 1;
      return { at: p.at, y: run, outcome: p.outcome };
    });
  }, [trend]);

  const inWindow = React.useMemo(
    () => (since == null ? series : series.filter(p => p.at >= since)),
    [series, since]
  );

  const sbxSeries = (sbxHist && sbxHist.points) || [];
  const sbxInWindow = React.useMemo(
    () => (since == null ? sbxSeries : sbxSeries.filter(p => p.at >= since)),
    [sbxSeries, since]
  );

  if (!trend || !sbxHist) {
    return <div className="card" style={{ padding: 22 }}><Spinner/></div>;
  }

  // Window delta = where the value ended minus where it stood going in. The
  // point immediately BEFORE the window is the true baseline; with none, the
  // series starts inside the window, so its own first point is the baseline
  // (for form that's a differential of zero — genuinely where they started;
  // for a rating it's simply the earliest reading we hold).
  function deltaOf(all, windowed, zeroBase) {
    if (!windowed.length) return 0;
    const firstIdx = since == null ? 0 : all.findIndex(p => p.at >= since);
    const base = (since == null || firstIdx <= 0)
      ? (zeroBase ? 0 : windowed[0].y)
      : all[firstIdx - 1].y;
    return windowed[windowed.length - 1].y - base;
  }

  const isSbx = mode === 'sbx';
  const sbxDelta = deltaOf(sbxSeries, sbxInWindow, false);
  const formDelta = deltaOf(series, inWindow, true);
  const delta = isSbx ? sbxDelta : formDelta;
  const deltaText = isSbx
    ? `${sbxDelta > 0 ? '+' : sbxDelta < 0 ? '−' : ''}${Math.abs(sbxDelta).toFixed(3)}`
    : (formDelta > 0 ? `+${formDelta}` : String(formDelta));

  const played = inWindow.length;
  const w = inWindow.filter(p => p.outcome === 'W').length;
  const l = inWindow.filter(p => p.outcome === 'L').length;
  const h = inWindow.filter(p => p.outcome === 'H').length;
  const decided = w + l;
  const winRate = decided ? Math.round((w / decided) * 100) : null;

  const deltaColor = delta > 0 ? 'var(--cream)' : delta < 0 ? 'var(--loss-soft, #E7B8A7)' : 'var(--ink-muted)';
  const stat = { fontFamily: 'var(--font-display)', fontSize: 25, color: 'var(--paper)', marginTop: 3, lineHeight: 1.1 };
  const modeBtn = (on) => ({
    padding: '5px 12px', font: 'inherit', fontSize: 11, fontWeight: 700,
    fontFamily: 'var(--font-mono)', cursor: 'pointer', border: 'none',
    background: on ? 'var(--cream)' : 'transparent',
    color: on ? 'var(--forest)' : 'var(--ink-muted)',
  });

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
      <div className="card" style={{ padding: 22 }}>
        <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
          <div>
            <div className="eyebrow">{isSbx ? 'SBX Rating Over Time' : 'Form Over Time'}</div>
            <div style={{ fontSize: 12.5, color: 'var(--ink-muted)', marginTop: 4, lineHeight: 1.5, maxWidth: 460 }}>
              {isSbx
                ? <>Sandbox Rating™ as recorded, one reading per day it changed. Currently{' '}
                    <strong style={{ color: 'var(--paper)', fontFamily: 'var(--font-mono)' }}>
                      {user.sbx != null ? Number(user.sbx).toFixed(3) : '—'}
                    </strong>.</>
                : <>Running win-loss differential — every win takes it up one, every loss down one.
                    Halved matches hold it level.</>}
            </div>
          </div>
          <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
            <div role="group" aria-label="Chart"
              style={{ display: 'flex', border: '1px solid var(--line-strong)', borderRadius: 999, overflow: 'hidden' }}>
              <button onClick={() => setMode('sbx')} aria-pressed={isSbx} style={modeBtn(isSbx)}>SBX</button>
              <button onClick={() => setMode('form')} aria-pressed={!isSbx} style={modeBtn(!isSbx)}>Form</button>
            </div>
            <div role="group" aria-label="Time window"
              style={{ display: 'flex', border: '1px solid var(--line-strong)', borderRadius: 999, overflow: 'hidden' }}>
              {TREND_WINDOWS.map(t => (
                <button key={t.key} onClick={() => setWin(t.key)} aria-pressed={t.key === win} style={modeBtn(t.key === win)}>
                  {t.label}
                </button>
              ))}
            </div>
          </div>
        </div>

        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: 12, margin: '18px 0 4px' }}>
          <div>
            <div className="eyebrow">Change</div>
            <div style={{ ...stat, color: deltaColor, fontFamily: isSbx ? 'var(--font-mono)' : 'var(--font-display)', fontSize: isSbx ? 21 : 25 }}>
              {deltaText}
            </div>
            <div style={{ fontSize: 11.5, color: 'var(--ink-muted)' }}>
              {isSbx ? 'SBX over the window' : 'Net over the window'}
            </div>
          </div>
          <div>
            <div className="eyebrow">Matches</div>
            <div style={stat}>{played}</div>
            <div style={{ fontSize: 11.5, color: 'var(--ink-muted)' }}>{chosen.days == null ? 'All time' : `Last ${chosen.label}`}</div>
          </div>
          <div>
            <div className="eyebrow">Record</div>
            <div style={{ ...stat, fontFamily: 'var(--font-mono)', fontSize: 20 }}>{w}–{l}{h ? `–${h}` : ''}</div>
            <div style={{ fontSize: 11.5, color: 'var(--ink-muted)' }}>W–L{h ? '–H' : ''}</div>
          </div>
          <div>
            <div className="eyebrow">Win Rate</div>
            <div style={stat}>{winRate == null ? '—' : `${winRate}%`}</div>
            <div style={{ fontSize: 11.5, color: 'var(--ink-muted)' }}>{decided ? `Of ${decided} decided` : 'Nothing decided yet'}</div>
          </div>
        </div>

        <div style={{ marginTop: 10, color: 'var(--ink-soft)' }}>
          {isSbx ? (
            sbxHist.missing ? (
              <div style={{ padding: '22px 4px', textAlign: 'center', fontSize: 13, color: 'var(--ink-muted)', lineHeight: 1.6 }}>
                Rating history isn&rsquo;t being recorded yet.<br/>
                <span style={{ fontSize: 12, color: 'var(--ink-faint)' }}>
                  Run <code style={{ fontFamily: 'var(--font-mono)' }}>sql/sbx-history.sql</code> — it seeds every
                  golfer at today&rsquo;s rating and records each change from then on.
                </span>
              </div>
            ) : sbxInWindow.length > 1 ? (
              <TrendChart points={sbxInWindow} label="SBX" format={(v) => Number(v).toFixed(3)} color="var(--cream)"/>
            ) : (
              <div style={{ padding: '22px 4px', textAlign: 'center', fontSize: 13, color: 'var(--ink-muted)', lineHeight: 1.6 }}>
                {sbxSeries.length
                  ? <>Only one reading so far{sbxInWindow.length ? '' : ` — none inside the last ${chosen.label}`}.
                      A line needs a second one, which lands the next time their rating moves.</>
                  : <>No readings yet for this golfer.</>}
              </div>
            )
          ) : trend.blocked ? (
            <div className="form-error" role="alert" style={{ marginBottom: 0 }}>{trend.message || 'Matches could not be read.'}</div>
          ) : inWindow.length ? (
            <TrendChart points={inWindow} zeroLine label="Differential"
              format={(v) => (v > 0 ? `+${v}` : String(v))} color="var(--cream)"/>
          ) : (
            <div style={{ padding: '28px 0', textAlign: 'center', fontSize: 13, color: 'var(--ink-muted)' }}>
              {series.length
                ? `No completed matches in the last ${chosen.label}.`
                : 'No completed matches yet — the curve starts at their first result.'}
            </div>
          )}
        </div>

        {/* Both worth saying out loud rather than letting someone infer them
            from numbers that look more complete than they are. */}
        <div style={{ fontSize: 11.5, color: 'var(--ink-faint)', marginTop: 14, lineHeight: 1.55 }}>
          {isSbx
            ? <>Rating history starts the day it was switched on — there is no record of where
                anyone sat before that, and nothing here back-fills one. Record, matches and win
                rate above always describe match results, not the rating.</>
            : <>This is match record, not SBX — switch to SBX for the rating itself.</>}
          {' '}Matches where this golfer sat in a 2v2&rsquo;s second seat aren&rsquo;t counted here,
          matching the list below.
        </div>
      </div>

      <MatchHistory user={user}/>
    </div>
  );
}

function SettingsPanel({ user, adminId, onGone }) {
  const [tier, setTier] = React.useState(normalTier(user.tier));
  React.useEffect(() => { setTier(normalTier(user.tier)); }, [user.id, user.tier]);
  const isSelf = adminId === user.id;
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
      <MembershipCard user={user} tier={tier} onTier={setTier}/>
      <GuestPassesCard user={user} adminId={adminId} tier={tier}/>
      <AccessCard user={user} adminId={adminId}/>
      {isSelf ? (
        <div className="card" style={{ padding: 20 }}>
          <div className="eyebrow">Danger Zone</div>
          <div style={{ fontSize: 13, color: 'var(--ink-muted)', marginTop: 10, lineHeight: 1.5 }}>
            This is your own account — the delete control is hidden here on purpose.
          </div>
        </div>
      ) : <DangerZone user={user} onDeleted={onGone}/>}
    </div>
  );
}

function GolferCards({ user, adminId, onGone }) {
  const [tab, setTab] = React.useState('overview');
  // Switching golfers returns to Overview rather than dropping the admin
  // into the previous golfer's Settings tab — this component is reused, not
  // remounted, when the open golfer changes.
  React.useEffect(() => { setTab('overview'); }, [user.id]);

  return (
    <div>
      <TabBar tabs={GOLFER_TABS} value={tab} onChange={setTab}/>
      <div key={tab} className="rise-in" style={{ marginTop: 18 }}>
        {tab === 'overview' && <OverviewTabPanel user={user}/>}
        {tab === 'history'  && <PlayerHistoryPanel user={user}/>}
        {tab === 'settings' && <SettingsPanel user={user} adminId={adminId} onGone={onGone}/>}
      </div>
    </div>
  );
}

// ─── StaffBoard ───────────────────────────────────────────────────────
// Access read by course rather than by person, because "who can get into
// Melreese" is the question you ask when somebody leaves.
// ─── StaffBoard ───────────────────────────────────────────────────────
// Courses as tiles with their own photograph, because "which course is this"
// is answered by a picture far faster than by a name in a list — and the
// picture is the one the golfers already see, so the two halves of the
// product look like the same company.
//
// The card carries the operational answer too: who holds the keys, or a
// blunt "No manager assigned" when nobody does. A course with no manager
// cannot publish tee times, so that is the state worth spotting from across
// the room rather than reading for.
function StaffBoard({ adminId }) {
  const [staff, reload] = useStaff();
  const [courses] = useCourses();
  const [golfers] = useGolfers('');
  // Which courses actually have a Sandbox 9 on them. The card draws the same
  // distinction the map pins do, and a badge that appears on one surface and
  // not the other is worse than no badge at all.
  const sbxIds = useSbxCourses();
  const [query, setQuery] = React.useState('');
  const [openId, setOpenId] = React.useState(null);

  if (!staff || !courses) return <PageSkeleton/>;

  const byCourse = staff.byCourse;

  // The managers of one course, in the shape CourseCard wants.
  const managersOf = (courseId) => {
    const entry = byCourse[courseId];
    return (entry ? entry.managers : []).map(m => ({
      id: m.id,
      userId: m.user_id,
      link: m,
      name: m.user ? userName(m.user) : 'Unknown account',
      handle: m.user && m.user.handle ? `@${String(m.user.handle).replace(/^@/, '')}` : null,
      initials: initialsOf(m.user ? userName(m.user) : '?'),
      avatar_url: m.user ? m.user.avatar_url : null,
      since: m.created_at ? String(m.created_at).slice(0, 10) : null,
    }));
  };

  // undefined until the lookup lands, so the card says nothing rather than
  // wrongly claiming a course has no Sandbox setup.
  const withSbx = (c) => (sbxIds ? { ...c, hasSbx: sbxIds.has(c.id) } : c);

  const open = openId ? courses.find(c => c.id === openId) : null;
  if (open) {
    return <CourseStaffPanel course={withSbx(open)} managers={managersOf(open.id)} adminId={adminId}
      people={(golfers && golfers.rows) || []} loadingPeople={!golfers}
      onBack={() => { setOpenId(null); reload(); }} onChanged={reload}/>;
  }

  // One search box over both halves of the question — "who runs Melreese"
  // and "what does Marco run" are the same lookup from the user's side, so
  // they should not be two different controls.
  const term = query.trim().toLowerCase();
  const shown = courses.filter(c => {
    if (!term) return true;
    const mgrs = managersOf(c.id);
    return [c.name, c.short_name, c.city, c.state].some(v => String(v || '').toLowerCase().includes(term))
      || mgrs.some(m => `${m.name} ${m.handle || ''}`.toLowerCase().includes(term));
  });

  const unstaffed = courses.filter(c => !managersOf(c.id).length);

  return (
    <div style={{ maxWidth: 1240, margin: '0 auto' }}>
      <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
        <div style={{ position: 'relative', flex: '1 1 280px', minWidth: 220 }}>
          <input className="input" value={query} onChange={e => setQuery(e.target.value)}
            placeholder="Search by course or manager" aria-label="Search courses and managers"
            style={{ paddingLeft: 32 }}/>
          <Icon name="users" size={14} style={{ position: 'absolute', left: 11, top: '50%', transform: 'translateY(-50%)', opacity: 0.45, pointerEvents: 'none' }}/>
        </div>
        <div style={{ fontSize: 12, color: 'var(--ink-muted)', fontFamily: 'var(--font-mono)' }}>
          {shown.length} of {courses.length} course{courses.length === 1 ? '' : 's'}
        </div>
      </div>

      {!!unstaffed.length && !term && (
        <div className="card" style={{ padding: '14px 18px', marginBottom: 16, borderLeft: '3px solid var(--loss)' }}>
          <div style={{ fontSize: 13, color: 'var(--ink-soft)', lineHeight: 1.5 }}>
            <strong style={{ color: 'var(--paper)' }}>
              {unstaffed.length} course{unstaffed.length === 1 ? ' has' : 's have'} nobody with portal access
            </strong>{' '}
            — {unstaffed.map(c => c.short_name || c.name).join(', ')}. Nobody there can publish tee times or set yardages.
          </div>
        </div>
      )}

      {shown.length ? (
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(248px, 1fr))', gap: 16 }}>
          {shown.map(c => (
            <CourseCard key={c.id} course={withSbx(c)} managers={managersOf(c.id)} onClick={() => setOpenId(c.id)}/>
          ))}
        </div>
      ) : (
        <div className="card" style={{ padding: 34, textAlign: 'center' }}>
          <div style={{ fontSize: 14, color: 'var(--paper)', fontWeight: 700 }}>Nothing matches &ldquo;{query}&rdquo;.</div>
          <div style={{ fontSize: 12.5, color: 'var(--ink-muted)', marginTop: 6 }}>
            Search runs over course names, cities and the people who manage them.
          </div>
        </div>
      )}
    </div>
  );
}

// ─── One course's keys ────────────────────────────────────────────────
// The grant/revoke behaviour is unchanged; it just has a page of its own now
// instead of being stacked inside every row of a list.
function CourseStaffPanel({ course, managers, adminId, people, loadingPeople, onBack, onChanged }) {
  const [pick, setPick] = React.useState('');
  const [busy, setBusy] = React.useState('');
  const [err, setErr] = React.useState('');
  const [confirm, setConfirm] = React.useState(null);

  const already = new Set(managers.map(m => m.userId));
  const candidates = people.filter(p => !already.has(p.id));

  async function grant() {
    if (!pick) return;
    setBusy('grant'); setErr('');
    try { await addCourseManager({ userId: pick, courseId: course.id, createdBy: adminId }); setPick(''); onChanged(); }
    catch (e) { setErr(e.message); }
    setBusy('');
  }
  async function revoke(m) {
    setBusy(m.id); setErr('');
    try { await removeCourseManager(m.id); onChanged(); }
    catch (e) { setErr(e.message); }
    setBusy(''); setConfirm(null);
  }

  return (
    <div style={{ maxWidth: 900, margin: '0 auto' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 18 }}>
        <button className="btn btn-ghost" onClick={onBack} style={{ padding: '6px 12px' }}>← Course staff</button>
        <div style={{ flex: 1, minWidth: 160 }}>
          <div style={{ fontFamily: 'var(--font-display)', fontSize: 22, color: 'var(--paper)', lineHeight: 1.1 }}>
            {course.short_name || course.name}
          </div>
          <div style={{ fontSize: 12.5, color: 'var(--ink-muted)', marginTop: 2, fontFamily: 'var(--font-mono)' }}>
            {[course.city, course.state].filter(Boolean).join(', ') || 'Location not set'}
          </div>
        </div>
      </div>

      <div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 300px) 1fr', gap: 18, alignItems: 'start' }}>
        <CourseCard course={course} managers={managers}/>

        <div className="card" style={{ padding: 22 }}>
          <div className="eyebrow">Portal access</div>

          {managers.length ? (
            <div style={{ marginTop: 12 }}>
              {managers.map((m, i) => (
                <div key={m.id} style={{
                  display: 'flex', alignItems: 'center', gap: 10, padding: '10px 0',
                  borderTop: '1px solid var(--line)',
                }}>
                  <PersonAvatar src={m.avatar_url} name={m.name} size={28}/>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: 13.5, fontWeight: 700, color: 'var(--paper)' }}>{m.name}</div>
                    <div style={{ fontSize: 11.5, color: 'var(--ink-muted)', fontFamily: 'var(--font-mono)' }}>
                      {m.handle || '—'}{m.since ? ` · since ${m.since}` : ''}
                    </div>
                  </div>
                  <button className="btn btn-danger" style={{ padding: '5px 11px', fontSize: 12 }}
                    disabled={busy === m.id} onClick={() => setConfirm(m)}>Revoke</button>
                </div>
              ))}
            </div>
          ) : (
            <div style={{ fontSize: 13, color: 'var(--ink-soft)', marginTop: 10, lineHeight: 1.55 }}>
              Nobody can sign in to the partner portal for this course, so no tee times or yardages
              can be published here.
            </div>
          )}

          {err && <div className="form-error" role="alert" style={{ marginTop: 14, marginBottom: 0 }}>{err}</div>}

          <div style={{ display: 'flex', gap: 8, marginTop: 16, flexWrap: 'wrap' }}>
            <select className="select" style={{ flex: '1 1 220px', minWidth: 180 }}
              aria-label={`Grant access to ${course.short_name || course.name}`}
              value={pick} onChange={e => setPick(e.target.value)}>
              <option value="">{loadingPeople ? 'Loading people…' : 'Grant access to…'}</option>
              {candidates.map(p => (
                <option key={p.id} value={p.id} style={{ color: '#111' }}>
                  {userName(p)}{p.handle ? ` (@${String(p.handle).replace(/^@/, '')})` : ''}
                </option>
              ))}
            </select>
            <button className="btn btn-forest" onClick={grant} disabled={!pick || busy === 'grant'}>
              {busy === 'grant' ? 'Granting…' : 'Grant'}
            </button>
          </div>
        </div>
      </div>

      {confirm && (
        <ConfirmDialog
          open danger
          title={`Revoke access to ${course.short_name || course.name}?`}
          body={`${confirm.name} will no longer be able to sign in to the partner portal for this course. The account itself stays.`}
          confirmLabel="Revoke access"
          onConfirm={() => revoke(confirm)}
          onCancel={() => setConfirm(null)}/>
      )}
    </div>
  );
}

Object.assign(window, {
  GolfersBoard, StaffBoard, RolePill, relativeDay, initialsOf, Chips,
  PersonAvatar, MatchHistory, PasswordPanel, ResultBadge, GolferCards, CopyField,
});
