// /admin — the admin home base.
//
// Topics 12 and 13 both expanded the admin's repertoire without giving
// it a centralized home. Slice 7 consolidates them: role management,
// the §6.2 app-wide write-mute, the audit-log viewer, the
// graduation-readiness queue, and a read of the permission-events log
// — five thin sub-surfaces behind a left-rail menu.
//
// The page is admin-only; the App.jsx route mounts it only when the
// viewer's role is owner or admin, and every /api/admin/* endpoint
// guards independently.

import { useEffect, useMemo, useState } from 'react'
import { Routes, Route, NavLink, Link } from 'react-router-dom'
import {
  listAdminUsers,
  setUserRole,
  setUserMute,
  setUserPermission,
  listAuditLog,
  listPermissionEvents,
  listGraduationQueue,
  listAllowlist,
  addAllowlistEmail,
  removeAllowlistEmail,
  createUserInvite,
  listRetiredRFCs,
  unretireRFC,
} from '../api.js'
import { EVENTS, track } from '../lib/analytics.js'
import { entryPath, useProjectId } from '../lib/entryPaths'
import NotFound from './NotFound.jsx'

// v0.17.0 — roadmap item #16. The max length the backend enforces
// (Pydantic body bound + `invites.CUSTOM_MESSAGE_MAX_LENGTH`); kept
// here so the modal's "remaining chars" counter stays in lockstep
// with the server-side bound.
const CUSTOM_MESSAGE_MAX_LENGTH = 500

const TABS = [
  { path: 'users', label: 'Users' },
  { path: 'allowlist', label: 'Allowlist' },
  { path: 'graduation', label: 'Graduation queue' },
  { path: 'audit', label: 'Audit log' },
  { path: 'permissions', label: 'Permission events' },
]

export default function Admin({ viewer }) {
  // §13.7: the "Retired" surface (un-retire) is site-owner-only. The
  // backend gates /api/admin/retired-rfcs and /unretire on the owner role
  // regardless; we only show the link to owners so admins aren't offered
  // a tab that would 403.
  const isSiteOwner = viewer.role === 'owner'
  const tabs = isSiteOwner ? [...TABS, { path: 'retired', label: 'Retired' }] : TABS
  return (
    <div className="admin-page">
      <nav className="admin-rail">
        <h2>Admin</h2>
        <ul>
          {tabs.map(t => (
            <li key={t.path}>
              <NavLink
                to={`/admin/${t.path}`}
                end
                className={({ isActive }) => `admin-rail-link ${isActive ? 'active' : ''}`}
              >
                {t.label}
              </NavLink>
            </li>
          ))}
        </ul>
        <p className="admin-rail-note">
          Signed in as <strong>{viewer.display_name}</strong> ({viewer.role}).
          You can <Link to="/">return to the catalog</Link> at any time.
        </p>
      </nav>
      <div className="admin-content">
        <Routes>
          <Route index element={<UsersTab />} />
          <Route path="users" element={<UsersTab />} />
          <Route path="allowlist" element={<AllowlistTab />} />
          <Route path="graduation" element={<GraduationTab />} />
          {isSiteOwner && <Route path="retired" element={<RetiredTab />} />}
          <Route path="audit" element={<AuditTab />} />
          <Route path="permissions" element={<PermissionsTab />} />
          <Route path="*" element={<NotFound message="That admin page doesn't exist." />} />
        </Routes>
      </div>
    </div>
  )
}

// ── Users + role + write-mute + permission grant/revoke (§6.1 / §6.2) ──────
//
// v0.9.0 (roadmap item #7) lands the user-management surface. The table
// shows every user with their permission_state, sign-up reason (when
// pending), role, write-mute, and Grant / Revoke controls. State filter
// chips above the table narrow to one bucket — the "Pending" chip is the
// admin's daily inbox shape.

const STATE_CHIPS = [
  { value: 'all', label: 'All' },
  { value: 'pending', label: 'Pending' },
  { value: 'granted', label: 'Granted' },
  { value: 'revoked', label: 'Revoked' },
]

function UsersTab() {
  const [users, setUsers] = useState(null)
  const [busy, setBusy] = useState({})
  const [error, setError] = useState(null)
  const [stateFilter, setStateFilter] = useState('all')
  // v0.17.0 — roadmap item #16. The "Create user + invite" modal's
  // open/closed state. The modal is local to UsersTab (it only opens
  // from the header button) and refreshes the listing on success.
  const [inviteModalOpen, setInviteModalOpen] = useState(false)

  async function refresh() {
    setError(null)
    try {
      const r = await listAdminUsers()
      setUsers(r.items || [])
    } catch (e) {
      setError(e.message)
    }
  }

  useEffect(() => { refresh() }, [])

  async function changeRole(userId, role) {
    setBusy(b => ({ ...b, [userId]: true }))
    setError(null)
    try {
      await setUserRole(userId, role)
      setUsers(prev => prev.map(u => u.id === userId ? { ...u, role } : u))
    } catch (e) {
      setError(e.message)
    } finally {
      setBusy(b => ({ ...b, [userId]: false }))
    }
  }

  async function toggleMute(userId, muted) {
    setBusy(b => ({ ...b, [userId]: true }))
    setError(null)
    try {
      await setUserMute(userId, muted)
      setUsers(prev => prev.map(u => u.id === userId ? { ...u, muted } : u))
    } catch (e) {
      setError(e.message)
    } finally {
      setBusy(b => ({ ...b, [userId]: false }))
    }
  }

  async function flipPermission(userId, state) {
    setBusy(b => ({ ...b, [userId]: true }))
    setError(null)
    try {
      await setUserPermission(userId, state)
      // v0.15.0 — analytics: fire on a successful §6.1 grant/revoke.
      // action collapses the {pending → granted, revoked → granted}
      // edges onto `grant`, and `granted → revoked` onto `revoke`,
      // matching the roadmap's two-arm taxonomy.
      const action = state === 'granted' ? 'grant' : 'revoke'
      track(EVENTS.ADMIN_PERMISSION_DECISION, { action, target_user_id: String(userId) })
      // Refresh the full row so permission_decided_{at,by_*} update too.
      await refresh()
    } catch (e) {
      setError(e.message)
    } finally {
      setBusy(b => ({ ...b, [userId]: false }))
    }
  }

  const counts = useMemo(() => {
    const c = { all: 0, pending: 0, granted: 0, revoked: 0 }
    if (users) {
      c.all = users.length
      for (const u of users) {
        const s = u.permission_state || 'granted'
        if (s in c) c[s] += 1
      }
    }
    return c
  }, [users])

  if (users == null) return <p className="muted">Loading users…</p>

  const filtered = stateFilter === 'all'
    ? users
    : users.filter(u => (u.permission_state || 'granted') === stateFilter)

  return (
    <div className="admin-tab">
      <header className="admin-tab-header">
        <div className="admin-tab-heading">
          <h2>Users</h2>
          {/* v0.17.0 — roadmap item #16. The "Create user + invite"
              affordance opens a modal that provisions a fresh users row
              with the chosen role and sends an invite email with a
              single-use claim link. */}
          <div className="admin-tab-actions">
            <button
              type="button"
              className="btn-primary"
              onClick={() => setInviteModalOpen(true)}
            >Create user + invite</button>
          </div>
        </div>
        <p className="muted">
          The pending bucket is the beta-access review queue (§6.1 /
          v0.8.0). Grant or revoke writes to <code>permission_events</code>
          and stamps <code>permission_decided_by</code> +{' '}
          <code>permission_decided_at</code>. Role and write-mute controls
          retain their v0.7.0 semantics — promote to admin to remove a
          user's ability to write without silencing them.
        </p>
      </header>
      {error && <p className="settings-note warning">{error}</p>}
      {inviteModalOpen && (
        <CreateUserInviteModal
          onClose={() => setInviteModalOpen(false)}
          onSuccess={async () => {
            setInviteModalOpen(false)
            await refresh()
          }}
        />
      )}

      <div className="admin-filter-chips">
        {STATE_CHIPS.map(chip => (
          <button
            key={chip.value}
            type="button"
            className={`admin-chip${stateFilter === chip.value ? ' active' : ''}`}
            onClick={() => setStateFilter(chip.value)}
          >
            {chip.label} <span className="admin-chip-count">{counts[chip.value] ?? 0}</span>
          </button>
        ))}
      </div>

      {filtered.length === 0 ? (
        <p className="muted">No users in this bucket.</p>
      ) : (
        <table className="admin-table admin-users-table">
          <thead>
            <tr>
              <th>User</th>
              <th>State</th>
              <th>Role</th>
              <th>Write-muted</th>
              <th>Signed up</th>
              <th>Last seen</th>
            </tr>
          </thead>
          <tbody>
            {filtered.map(u => (
              <UserRow
                key={u.id}
                user={u}
                busy={!!busy[u.id]}
                onChangeRole={role => changeRole(u.id, role)}
                onToggleMute={muted => toggleMute(u.id, muted)}
                onFlipPermission={state => flipPermission(u.id, state)}
              />
            ))}
          </tbody>
        </table>
      )}
    </div>
  )
}

function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }) {
  const state = u.permission_state || 'granted'
  const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
  const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
  // v0.17.0 — roadmap item #16. The user's row may also be the
  // "(pending invite)" shape: admin-created via POST /api/admin/users,
  // not yet claimed via /api/invites/claim. The backend's user-listing
  // surfaces this via `pending_invite` (object with invite_id +
  // expires_at) or null. The badge sits inline next to the handle so
  // the admin sees at a glance which rows are real users vs. unclaimed
  // invites.
  const pendingInvite = u.pending_invite
  // When there's no gitea_login the handle already IS the email, so the
  // subline would otherwise repeat it. Only append the email when it adds
  // something the handle doesn't already show.
  const showEmail = u.email && u.email !== handle
  return (
    <>
      <tr>
        <td>
          <div className="user-cell">
            <div className="user-cell-handle">
              <span className="user-handle">{handle}</span>
              {pendingInvite && (
                <span
                  className="invite-badge"
                  title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
                >pending invite</span>
              )}
            </div>
            <span className="muted">
              {fullName || u.display_name}
              {showEmail ? ` · ${u.email}` : ''}
            </span>
          </div>
        </td>
        <td>
          <PermissionCell user={u} busy={busy} onFlipPermission={onFlipPermission} />
        </td>
        <td>
          <select
            value={u.role}
            onChange={e => onChangeRole(e.target.value)}
            disabled={busy}
          >
            <option value="contributor">Contributor</option>
            <option value="admin">Admin</option>
            <option value="owner">Owner</option>
          </select>
        </td>
        <td>
          {u.role === 'contributor' ? (
            <label className="mute-toggle">
              <input
                type="checkbox"
                checked={!!u.muted}
                onChange={e => onToggleMute(e.target.checked)}
                disabled={busy}
              />
              {u.muted ? 'Muted' : 'Active'}
            </label>
          ) : (
            <span className="muted">N/A</span>
          )}
        </td>
        <TimeCell value={u.created_at} />
        {/* An unclaimed admin invite has provably never authenticated, so
            last_seen_at is just the row-creation default (it equals
            created_at). Render the truth — "Never" — rather than a
            timestamp that reads like a real visit. */}
        <TimeCell
          value={pendingInvite ? null : u.last_seen_at}
          emptyLabel={pendingInvite ? 'Never' : '—'}
        />
      </tr>
      {state === 'pending' && u.beta_request_reason ? (
        <tr className="user-row-reason">
          <td colSpan={6}>
            <div className="user-reason-block">
              <strong>Why they want access:</strong>
              <p>{u.beta_request_reason}</p>
            </div>
          </td>
        </tr>
      ) : null}
    </>
  )
}

// Render a "YYYY-MM-DD HH:MM:SS" timestamp as an intentional date-over-time
// stack (date prominent, time quiet below) rather than letting a narrow
// column wrap the value mid-string. Falls back to an em-dash when absent.
function TimeCell({ value, emptyLabel = '—' }) {
  if (!value) return <td className="muted">{emptyLabel}</td>
  const [date, ...rest] = String(value).split(' ')
  const time = rest.join(' ')
  return (
    <td className="user-when">
      <span className="user-when-date">{date}</span>
      {time && <span className="user-when-time muted">{time}</span>}
    </td>
  )
}

function PermissionCell({ user: u, busy, onFlipPermission }) {
  const state = u.permission_state || 'granted'
  const decidedSuffix = u.permission_decided_at
    ? ` · by ${u.permission_decided_by_login ? '@' + u.permission_decided_by_login : '—'} at ${u.permission_decided_at}`
    : ''
  return (
    <div className="permission-cell">
      <span className={`permission-badge permission-badge-${state}`}>{state}</span>
      <div className="permission-actions">
        {state !== 'granted' && (
          <button
            type="button"
            className="btn-link-quiet"
            disabled={busy}
            onClick={() => onFlipPermission('granted')}
          >Grant</button>
        )}
        {state === 'granted' && (
          <button
            type="button"
            className="btn-link-quiet"
            disabled={busy}
            onClick={() => {
              if (confirm(`Revoke access for ${u.display_name || u.email}?`)) {
                onFlipPermission('revoked')
              }
            }}
          >Revoke</button>
        )}
      </div>
      {decidedSuffix && (
        <div className="permission-decided muted">{decidedSuffix.replace(/^ · /, '')}</div>
      )}
    </div>
  )
}

// ── Create user + invite modal (v0.17.0 / roadmap item #16) ────────────────
//
// The "Create user + invite" affordance on the Users tab opens this
// modal. Admin types email, first name, last name, role, and (optionally)
// a custom message to embed in the invite email. On submit, calls
// `POST /api/admin/users` which provisions the row + sends the email.
// The 409 path (duplicate email) and 422 path (self-invite, owner-
// grant-by-non-owner, malformed input) surface the server's message
// inline; the success path closes the modal and refreshes the listing.
//
// The modal lives in this file rather than a separate component
// because it has one caller (UsersTab), reuses the existing modal
// stylesheet from /admin's chrome, and shares the
// CUSTOM_MESSAGE_MAX_LENGTH constant defined at the top of the file.

function CreateUserInviteModal({ onClose, onSuccess }) {
  const [email, setEmail] = useState('')
  const [firstName, setFirstName] = useState('')
  const [lastName, setLastName] = useState('')
  const [role, setRole] = useState('contributor')
  const [customMessage, setCustomMessage] = useState('')
  const [busy, setBusy] = useState(false)
  const [error, setError] = useState(null)
  const [success, setSuccess] = useState(null)

  const remaining = CUSTOM_MESSAGE_MAX_LENGTH - customMessage.length

  async function handleSubmit(event) {
    event.preventDefault()
    const trimmedEmail = email.trim()
    if (!trimmedEmail) {
      setError('Email is required')
      return
    }
    setBusy(true)
    setError(null)
    setSuccess(null)
    try {
      const result = await createUserInvite({
        email: trimmedEmail,
        first_name: firstName.trim(),
        last_name: lastName.trim(),
        role,
        custom_message: customMessage,
      })
      // v0.17.0 + #21 Part C — Amplitude wiring. target_user_id is
      // the OHM user id the invite-create gesture provisioned;
      // initial_role is what the invitee inherits on claim.
      // custom_message_chars is a coarse signal of admin effort
      // (0 = template-only, 1+ = personalized). No PII.
      track(EVENTS.USER_INVITED, {
        target_user_id: result.invited_user_id != null
          ? String(result.invited_user_id) : null,
        initial_role: result.role,
        custom_message_chars: (customMessage || '').length,
      })
      setSuccess(`Invite sent to ${result.email} (${result.role}).`)
      // Brief delay so the admin sees the success state, then close
      // and let the parent refresh the listing.
      setTimeout(() => { onSuccess?.() }, 600)
    } catch (e) {
      setError(e.message || 'Unable to send invite')
    } finally {
      setBusy(false)
    }
  }

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal-panel" onClick={e => e.stopPropagation()}>
        <header className="modal-header">
          <h3>Create user + invite</h3>
          <button
            type="button"
            className="btn-link-quiet"
            onClick={onClose}
            disabled={busy}
            aria-label="Close"
          >×</button>
        </header>
        <p className="muted">
          Provisions a fresh user row with the chosen role and sends an
          invite email carrying a single-use claim link. The link
          expires in 7 days. The invitee clicks through to claim
          their account — no OTC roundtrip is required on first sign-in.
        </p>
        <form onSubmit={handleSubmit} className="create-user-invite-form">
          <label>
            <span>Email</span>
            <input
              type="email"
              value={email}
              onChange={e => setEmail(e.target.value)}
              required
              disabled={busy}
              autoFocus
              maxLength={320}
            />
          </label>
          <div className="form-row">
            <label>
              <span>First name</span>
              <input
                type="text"
                value={firstName}
                onChange={e => setFirstName(e.target.value)}
                disabled={busy}
                maxLength={120}
              />
            </label>
            <label>
              <span>Last name</span>
              <input
                type="text"
                value={lastName}
                onChange={e => setLastName(e.target.value)}
                disabled={busy}
                maxLength={120}
              />
            </label>
          </div>
          <label>
            <span>Role</span>
            <select
              value={role}
              onChange={e => setRole(e.target.value)}
              disabled={busy}
            >
              <option value="contributor">Contributor</option>
              <option value="admin">Admin</option>
              <option value="owner">Owner (owner-only)</option>
            </select>
          </label>
          <label>
            <span>
              Custom message (optional){' '}
              <span className={`muted${remaining < 0 ? ' warning' : ''}`}>
                {remaining} chars left
              </span>
            </span>
            <textarea
              value={customMessage}
              onChange={e => setCustomMessage(e.target.value)}
              disabled={busy}
              rows={4}
              maxLength={CUSTOM_MESSAGE_MAX_LENGTH}
              placeholder="Optional — embedded in the invite email."
            />
          </label>
          {error && <p className="settings-note warning">{error}</p>}
          {success && <p className="settings-note success">{success}</p>}
          <div className="modal-actions">
            <button type="button" onClick={onClose} disabled={busy}>Cancel</button>
            <button
              type="submit"
              className="btn-primary"
              disabled={busy || !email.trim() || remaining < 0}
            >
              {busy ? 'Sending…' : 'Send invite'}
            </button>
          </div>
        </form>
      </div>
    </div>
  )
}

// ── Private-beta allowlist (`migrations/011_allowlist.sql`) ────────────────

function AllowlistTab() {
  const [data, setData] = useState(null)
  const [error, setError] = useState(null)
  const [draftEmail, setDraftEmail] = useState('')
  const [draftNote, setDraftNote] = useState('')
  const [busy, setBusy] = useState(false)

  async function refresh() {
    setError(null)
    try {
      setData(await listAllowlist())
    } catch (e) {
      setError(e.message)
    }
  }

  useEffect(() => { refresh() }, [])

  async function handleAdd(event) {
    event.preventDefault()
    const email = draftEmail.trim()
    if (!email) return
    setBusy(true); setError(null)
    try {
      await addAllowlistEmail(email, draftNote.trim() || null)
      setDraftEmail(''); setDraftNote('')
      await refresh()
    } catch (e) {
      setError(e.message)
    } finally {
      setBusy(false)
    }
  }

  async function handleRemove(email) {
    if (!confirm(`Remove ${email} from the allowlist?`)) return
    setBusy(true); setError(null)
    try {
      await removeAllowlistEmail(email)
      await refresh()
    } catch (e) {
      setError(e.message)
    } finally {
      setBusy(false)
    }
  }

  if (data == null && !error) return <p className="muted">Loading allowlist…</p>

  return (
    <div className="admin-tab">
      <header className="admin-tab-header">
        <h2>Allowlist</h2>
        <p className="muted">
          When this list has any rows, OAuth sign-in is restricted: only emails
          here (case-insensitive) may sign in. Already-provisioned users are
          grandfathered by their Gitea ID and never re-checked. An empty list
          turns the gate off entirely.
        </p>
        <p className="muted">
          Status:{' '}
          <strong>{data?.active ? 'Private beta — gate active' : 'Open — anyone can sign in'}</strong>
        </p>
      </header>
      {error && <p className="settings-note warning">{error}</p>}

      <form className="allowlist-add" onSubmit={handleAdd}>
        <input
          type="email"
          placeholder="email@example.com"
          value={draftEmail}
          onChange={e => setDraftEmail(e.target.value)}
          required
          disabled={busy}
        />
        <input
          type="text"
          placeholder="Note (optional)"
          value={draftNote}
          onChange={e => setDraftNote(e.target.value)}
          maxLength={200}
          disabled={busy}
        />
        <button type="submit" className="btn-primary" disabled={busy || !draftEmail.trim()}>
          Add to allowlist
        </button>
      </form>

      {data?.items?.length > 0 ? (
        <table className="admin-table">
          <thead>
            <tr>
              <th>Email</th>
              <th>Note</th>
              <th>Added by</th>
              <th>Added at</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            {data.items.map(r => (
              <tr key={r.email}>
                <td><code>{r.email}</code></td>
                <td>{r.note || <span className="muted">—</span>}</td>
                <td>
                  {r.added_by_login
                    ? <span>@{r.added_by_login}</span>
                    : <span className="muted">—</span>}
                </td>
                <td className="muted">{r.created_at}</td>
                <td>
                  <button
                    type="button"
                    className="btn-link-quiet"
                    onClick={() => handleRemove(r.email)}
                    disabled={busy}
                  >Remove</button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      ) : (
        <p className="muted">
          No allow-listed emails yet. Add the first one to put the deployment
          into private-beta mode.
        </p>
      )}
    </div>
  )
}

// ── Graduation-readiness queue (§13.2) ─────────────────────────────────────

function GraduationTab() {
  const [data, setData] = useState(null)
  const [error, setError] = useState(null)
  const pid = useProjectId()

  useEffect(() => {
    listGraduationQueue()
      .then(setData)
      .catch(e => setError(e.message))
  }, [])

  if (error) return <p className="settings-note warning">{error}</p>
  if (data == null) return <p className="muted">Loading queue…</p>

  return (
    <div className="admin-tab">
      <header className="admin-tab-header">
        <h2>Graduation queue</h2>
        <p className="muted">
          Super-drafts with owners claimed and zero blocking body-edit PRs.
          Open one to run the §13.3 graduation sequence.
        </p>
      </header>

      <h3 className="admin-section-h">Ready ({data.ready.length})</h3>
      {data.ready.length === 0 && (
        <p className="muted">No super-drafts ready right now.</p>
      )}
      <ul className="grad-queue">
        {data.ready.map(item => (
          <li key={item.slug}>
            <Link to={entryPath(pid, item.slug)} className="grad-queue-link">
              <strong>{item.title}</strong>
              <span className="muted"> — owners: {item.owners.join(', ')}</span>
            </Link>
          </li>
        ))}
      </ul>

      <h3 className="admin-section-h">Blocked ({data.blocked.length})</h3>
      {data.blocked.length === 0 && (
        <p className="muted">No blocked super-drafts.</p>
      )}
      <ul className="grad-queue">
        {data.blocked.map(item => (
          <li key={item.slug}>
            <Link to={entryPath(pid, item.slug)} className="grad-queue-link">
              <strong>{item.title}</strong>
              <span className="muted">
                {' — '}
                {!item.owners_set && 'no owners yet'}
                {!item.owners_set && item.blocking_prs > 0 && '; '}
                {item.blocking_prs > 0 && `${item.blocking_prs} open body-edit PR${item.blocking_prs === 1 ? '' : 's'}`}
              </span>
            </Link>
          </li>
        ))}
      </ul>
    </div>
  )
}

// ── Retired (soft-deleted) entries — site-owner-only un-retire (§13.7) ─────

function RetiredTab() {
  const [data, setData] = useState(null)
  const [error, setError] = useState(null)
  const [busy, setBusy] = useState({})

  const load = () => {
    listRetiredRFCs()
      .then(setData)
      .catch(e => setError(e.message))
  }
  useEffect(load, [])

  const onUnretire = async (slug) => {
    setBusy(b => ({ ...b, [slug]: true }))
    setError(null)
    try {
      await unretireRFC(slug)
      load()
    } catch (e) {
      setError(e.message)
    } finally {
      setBusy(b => ({ ...b, [slug]: false }))
    }
  }

  if (error) return <p className="settings-note warning">{error}</p>
  if (data == null) return <p className="muted">Loading retired entries…</p>

  return (
    <div className="admin-tab">
      <header className="admin-tab-header">
        <h2>Retired</h2>
        <p className="muted">
          Soft-deleted RFCs (§13.7). They are hidden from the catalog and
          every view; the entry stays in the meta repo. Un-retiring restores
          an entry to the state it held before. Site owners only.
        </p>
      </header>

      <h3 className="admin-section-h">Retired ({data.items.length})</h3>
      {data.items.length === 0 && (
        <p className="muted">No retired entries.</p>
      )}
      <ul className="grad-queue">
        {data.items.map(item => (
          <li key={item.slug}>
            <span className="grad-queue-link">
              <strong>{item.title}</strong>
              <span className="muted">
                {' — '}{item.id || item.slug}
                {`; restores to ${item.restores_to}`}
              </span>
            </span>
            <button
              type="button"
              className="btn-secondary"
              disabled={!!busy[item.slug]}
              onClick={() => onUnretire(item.slug)}
            >
              {busy[item.slug] ? 'Un-retiring…' : 'Un-retire'}
            </button>
          </li>
        ))}
      </ul>
    </div>
  )
}

// ── Audit log (`actions`) — filter chips + paging ──────────────────────────

function AuditTab() {
  const [data, setData] = useState(null)
  const [filters, setFilters] = useState({ actionKind: '', actorUserId: '', rfcSlug: '' })
  const [error, setError] = useState(null)

  async function load(beforeId = null) {
    setError(null)
    try {
      const r = await listAuditLog({
        actionKind: filters.actionKind || undefined,
        actorUserId: filters.actorUserId ? Number(filters.actorUserId) : undefined,
        rfcSlug: filters.rfcSlug || undefined,
        beforeId,
        limit: 100,
      })
      setData(r)
    } catch (e) {
      setError(e.message)
    }
  }

  useEffect(() => { load() }, [filters])

  const kinds = useMemo(() => data?.action_kinds || [], [data])

  return (
    <div className="admin-tab">
      <header className="admin-tab-header">
        <h2>Audit log</h2>
        <p className="muted">
          Every bot-mediated write lands here. The most recent rows show first;
          filter to narrow to one kind, one actor, or one RFC.
        </p>
      </header>

      <div className="audit-filters">
        <select
          value={filters.actionKind}
          onChange={e => setFilters(f => ({ ...f, actionKind: e.target.value }))}
        >
          <option value="">All action kinds</option>
          {kinds.map(k => <option key={k} value={k}>{k}</option>)}
        </select>
        <input
          type="text"
          placeholder="RFC slug…"
          value={filters.rfcSlug}
          onChange={e => setFilters(f => ({ ...f, rfcSlug: e.target.value }))}
        />
        <input
          type="number"
          placeholder="Actor user_id…"
          value={filters.actorUserId}
          onChange={e => setFilters(f => ({ ...f, actorUserId: e.target.value }))}
        />
      </div>

      {error && <p className="settings-note warning">{error}</p>}
      {data == null && <p className="muted">Loading…</p>}
      {data?.items?.length === 0 && (
        <p className="muted">No rows match this filter.</p>
      )}
      {data?.items?.length > 0 && (
        <table className="admin-table audit-table">
          <thead>
            <tr>
              <th>When</th>
              <th>Action</th>
              <th>Actor</th>
              <th>On behalf of</th>
              <th>RFC</th>
              <th>PR / branch</th>
            </tr>
          </thead>
          <tbody>
            {data.items.map(row => (
              <tr key={row.id}>
                <td className="muted">{row.created_at}</td>
                <td><code>{row.action_kind}</code></td>
                <td>{row.actor_display || row.actor_login || '—'}</td>
                <td>{row.on_behalf_of}</td>
                <td>{row.rfc_slug || '—'}</td>
                <td>
                  {row.pr_number != null && <span>#{row.pr_number} </span>}
                  {row.branch_name && <code>{row.branch_name}</code>}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
      {data?.has_more && (
        <button
          className="btn-link-muted"
          onClick={() => load(data.items[data.items.length - 1].id)}
        >
          Load older →
        </button>
      )}
    </div>
  )
}

// ── Permission events (`permission_events`) ────────────────────────────────

function PermissionsTab() {
  const [data, setData] = useState(null)
  const [error, setError] = useState(null)

  useEffect(() => {
    listPermissionEvents({ limit: 100 })
      .then(setData)
      .catch(e => setError(e.message))
  }, [])

  if (error) return <p className="settings-note warning">{error}</p>
  if (data == null) return <p className="muted">Loading…</p>

  return (
    <div className="admin-tab">
      <header className="admin-tab-header">
        <h2>Permission events</h2>
        <p className="muted">
          Every role change and write-mute toggle. The companion to the
          audit log, scoped to authorization changes.
        </p>
      </header>
      {data.items.length === 0 && (
        <p className="muted">No permission events yet.</p>
      )}
      {data.items.length > 0 && (
        <table className="admin-table">
          <thead>
            <tr>
              <th>When</th>
              <th>Event</th>
              <th>Actor</th>
              <th>Subject</th>
              <th>Details</th>
            </tr>
          </thead>
          <tbody>
            {data.items.map(r => (
              <tr key={r.id}>
                <td className="muted">{r.created_at}</td>
                <td><code>{r.event_kind}</code></td>
                <td>{r.actor_display || r.actor_login || '—'}</td>
                <td>{r.subject_display || r.subject_login || '—'}</td>
                <td className="muted">
                  {r.details ? formatDetails(r.details) : ''}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </div>
  )
}

function formatDetails(details) {
  if (!details || typeof details !== 'object') return String(details ?? '')
  if (details.before != null && details.after != null) {
    return `${String(details.before)} → ${String(details.after)}`
  }
  return JSON.stringify(details)
}
