// RFCView.jsx — the §8 active-RFC view and (per §17's routing-collapse
// rule + §9.4) the §9.4 super-draft view.
//
// Three-column shape per §8.1. Opens on main in discuss mode per §8.2
// for active RFCs, on the canonical body in discuss mode per §9.4 for
// super-drafts. The discuss-vs-contribute flip on non-main / non-edit
// branches per §8.3 carries over unchanged.
//
// The active-RFC and super-draft surfaces share their editor, chat,
// change-card, preview-pane tracked-change overlay (§8.10), selection-
// tooltip, and PR-modal machinery; the only branchings are
// "Start Contributing" (which
// dispatches to promote-to-branch for active main and start-edit-branch
// for a super-draft's canonical body per §9.5) and the metadata pane
// (super-draft-only per §9.5).

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import {
  acceptChange as apiAccept,
  createThread,
  declineChange as apiDecline,
  editMetadata,
  getBranch,
  getRFC,
  getRFCMain,
  getThreadMessages,
  listModels,
  manualFlush,
  promoteToBranch,
  reaskChange,
  resolveThread,
  setBranchVisibility,
  startEditBranch,
  streamChatTurn,
} from '../api'
import MarkdownSourceEditor from './MarkdownSourceEditor.jsx'
import MarkdownPreview from './MarkdownPreview.jsx'
import SelectionTooltip from './SelectionTooltip.jsx'
import PromptBar from './PromptBar.jsx'
import ChatPanel from './ChatPanel.jsx'
import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
import ChangePanel, { diffWords } from './ChangePanel.jsx'
import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx'
import InvitationsModal from './InvitationsModal.jsx'
import { claimOwnership, retireRFC, unretireRFC } from '../api'
import { EVENTS, track } from '../lib/analytics'
import { entryPath, entryPrPath, useProjectId, useCollectionId } from '../lib/entryPaths'
import { getCollection } from '../api'
import MetadataFieldsPanel from './MetadataFieldsPanel.jsx'

const MANUAL_IDLE_MS = 5 * 60 * 1000   // §8.6 idle window; exact value is impl detail.
const MANUAL_DEBOUNCE_MS = 800

function debounce(fn, ms) {
  let t
  return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms) }
}

// Split markdown source into paragraph-ish blocks on blank lines for the
// §8.11 manual-edit count. Headings and lists each count as one block.
function splitSourceParagraphs(md) {
  return (md || '')
    .split(/\n\s*\n/)
    .map(p => p.trim())
    .filter(p => p.length > 0)
}

export default function RFCView({ viewer }) {
  const { slug } = useParams()
  const [searchParams, setSearchParams] = useSearchParams()
  const navigate = useNavigate()
  const pid = useProjectId()
  const cid = useCollectionId()

  const branchParam = searchParams.get('branch') || 'main'

  const [entry, setEntry] = useState(null)
  // §22.4a SLICE-4: the collection's metadata field schema (null when the
  // collection declares none — the panel then renders nothing, INV-5).
  const [collectionFields, setCollectionFields] = useState(null)
  const [mainView, setMainView] = useState(null)
  const [branchView, setBranchView] = useState(null)
  const [error, setError] = useState(null)
  const [models, setModels] = useState([])
  const [selectedModel, setSelectedModel] = useState('')

  // Editor state — owned here so accept/decline can mutate it.
  // editorRef points at the MarkdownSourceEditor handle ({view, getDoc,
  // setDoc}) when Contribute mode is mounted; otherwise null. The
  // read-only render is now MarkdownPreview, which has no imperative
  // surface to expose.
  const editorRef = useRef(null)
  const originalSourceLinesRef = useRef([])
  // §8.12 Option B — asking-while-reading on `main`. When Ask is invoked from
  // the canonical view we transparently cut an edit branch, navigate to it, and
  // run the question there once its view (and main_thread_id) loads. The pending
  // question is stashed here and fired by the pending-ask effect below — we
  // can't push optimistic chat messages before branchView.main_thread_id exists.
  const pendingAskRef = useRef(null)   // { text, quote, branch } | null
  // Live ref to submitChatTurn so the (intentionally narrow-dep) message-load
  // effect can fire the stashed §8.12 main-view ask with the latest closure.
  const submitChatTurnRef = useRef(null)
  const [editorContent, setEditorContent] = useState('')
  // Mirror of the live CM6 doc for the Contribute-mode preview pane,
  // debounced so the preview doesn't re-render on every keystroke.
  const [previewContent, setPreviewContent] = useState('')

  // Selection + tooltip per §8.12. With Tiptap retired from this view,
  // selection is sourced from window.getSelection() inside the
  // MarkdownPreview surface — see MarkdownPreview's onSelectionChange.
  const [selection, setSelection] = useState(null)
  // Phase 3 — Discuss-mode opt-in for the §8.10 tracked-change overlay
  // in the single-pane preview. Contribute mode is default-on (the
  // pane exists for editorial review of your own work); Discuss mode
  // keeps clean prose by default and exposes a toggle.
  const [discussShowChanges, setDiscussShowChanges] = useState(false)

  // Mode: discuss vs contribute (§8.3). Always discuss on main.
  const [mode, setMode] = useState('discuss')

  // Chat + changes (loaded with the branch).
  const [messages, setMessages] = useState([])
  const [changes, setChanges] = useState([])
  const [pendingDiscussChanges, setPendingDiscussChanges] = useState([])
  const [isStreaming, setIsStreaming] = useState(false)
  const [focusedChangeId, setFocusedChangeId] = useState(null)
  const [showVisibility, setShowVisibility] = useState(false)
  const [showPRModal, setShowPRModal] = useState(false)

  // Manual-edit buffer state per §8.11.
  const [manualPending, setManualPending] = useState(null)
  // {paragraphCount, deadline} — null when buffer empty
  const [manualCountdown, setManualCountdown] = useState(null)

  // Phase 6 — narrow-viewport (<1280px) right-column collapse. The drawer
  // chrome is rendered unconditionally; CSS hides it above 1280px where
  // the right panel is always inline. Per-session, default closed.
  const [drawerOpen, setDrawerOpen] = useState(false)

  useEffect(() => {
    // §22.4a: an entry in a NAMED collection must be fetched collection-
    // scoped. Omitting `cid` falls back to the project's DEFAULT-collection
    // route (/api/projects/<pid>/rfcs/<slug>), which 404s for an entry that
    // lives only in a named collection — surfacing as "Error: Not found" and
    // a missing metadata panel. (Tier-1 masked this when the same slug was
    // also reachable via the default collection; PPE's isolated content
    // exposed it.)
    getRFC(pid, slug, cid).then(entry => {
      setEntry(entry)
      // v0.15.0 — analytics: fire RFC Viewed once per slug load.
      // We key on the slug param rather than the loaded entry so a
      // re-render doesn't double-fire; the slug is the stable
      // identifier. id is included for join-friendliness in the
      // Amplitude dashboard.
      track(EVENTS.RFC_VIEWED, { rfc_slug: slug, rfc_id: entry?.id })
    }).catch(err => setError(err.message))
    listModels(slug)
      .then(({ models, default: def }) => {
        setModels(models || [])
        setSelectedModel(def || models?.[0]?.id || '')
      })
      .catch(() => {})
  }, [slug, pid, cid])

  // §22.4a SLICE-4: load the collection's metadata field schema for the
  // detail panel. Independent of the entry load; the panel reads the entry's
  // own `meta` values + `can_edit_meta` capability.
  useEffect(() => {
    getCollection(pid, cid)
      .then(c => setCollectionFields(c?.fields || null))
      .catch(() => setCollectionFields(null))
  }, [pid, cid])

  // Per §9.4 / §17's routing-collapse rule, super-drafts render through
  // the same surface as active RFCs — the bot, the chat, the change
  // panel, and the PR flow all dispatch on the entry's state internally.
  // The view-level differences are: the breadcrumb's state label, the
  // metadata pane, and the start-contributing dispatch target.
  const isSuperDraft = entry?.state === 'super-draft'
  const [showMetadataPane, setShowMetadataPane] = useState(false)
  const [showGraduateDialog, setShowGraduateDialog] = useState(false)
  const [claimError, setClaimError] = useState(null)
  // v0.16.0 (item #12): the per-RFC invitations modal. Visible only to
  // RFC owners (frontmatter) and platform admin/owner — the backend
  // gates the underlying endpoints regardless, so a leaked toggle
  // can't actually leak anything.
  const [showInvitationsModal, setShowInvitationsModal] = useState(false)
  // §13.7 retire / un-retire. RFC owners + site owners may retire; only
  // site owners may un-retire. The backend gates both regardless.
  const [actionError, setActionError] = useState(null)
  const canRetire = !!viewer && (entry?.state === 'super-draft' || entry?.state === 'active') && (
    viewer.role === 'owner' || (entry?.owners || []).includes(viewer.gitea_login)
  )
  const handleRetire = useCallback(async () => {
    if (!window.confirm(
      `Retire “${entry?.title || slug}”? It will be soft-deleted — removed `
      + `from the catalog and every view. A site owner can un-retire it later.`
    )) return
    setActionError(null)
    try {
      await retireRFC(slug)
      navigate('/')  // the entry is now hidden; return to the catalog
    } catch (err) {
      setActionError(err.message)
    }
  }, [slug, entry, navigate])
  const handleUnretire = useCallback(async () => {
    setActionError(null)
    try {
      const res = await unretireRFC(slug)
      getRFC(pid, slug, cid).then(setEntry).catch(() => {})
      if (res?.state) navigate(entryPath(pid, slug))
    } catch (err) {
      setActionError(err.message)
    }
  }, [slug, navigate, pid, cid])

  // Load main view + branch view whenever slug/branch changes.
  useEffect(() => {
    if (!entry || (entry.state !== 'active' && entry.state !== 'super-draft')) return
    setError(null)
    setEditorContent('')
    setMessages([])
    setChanges([])
    setPendingDiscussChanges([])
    setManualPending(null)
    setDiscussShowChanges(false)
    setSelection(null)
    setMode('discuss')

    getRFCMain(slug, pid, cid).then(setMainView).catch(err => setError(err.message))
    getBranch(slug, branchParam, pid, cid)
      .then(view => {
        setBranchView(view)
        setEditorContent(view.body || '')
        setPreviewContent(view.body || '')
        originalSourceLinesRef.current = splitSourceParagraphs(view.body || '')
        setChanges(view.changes || [])
      })
      .catch(err => setError(err.message))
    // §22/G-15: pid+cid scope the body reads; re-run if the collection changes.
  }, [slug, branchParam, entry, pid, cid])

  // Load chat messages whenever the branch's main thread id resolves.
  useEffect(() => {
    if (!branchView?.main_thread_id) return
    loadAllMessages(slug, branchParam, branchView.threads).then(loaded => {
      setMessages(loaded)
      // §8.12 Option B — run the stashed main-view question now that the freshly
      // cut branch's messages have loaded. Firing here (rather than in a parallel
      // effect) appends the turn AFTER the loaded set, so this late message load
      // can't clobber the optimistic chat turn. Land in contribute mode to
      // surface any edits the turn proposes (AI chat is an editing activity).
      const pending = pendingAskRef.current
      if (pending && pending.branch === branchView.branch_name && pending.branch === branchParam) {
        pendingAskRef.current = null
        setMode('contribute')
        submitChatTurnRef.current?.(pending.text, pending.quote)
      }
    })
  }, [branchView?.main_thread_id, branchView?.branch_name, slug, branchParam])

  // Selection wiring (§8.12). MarkdownPreview reports {text, coords}
  // sourced from window.getSelection(); the SelectionTooltip
  // positions itself from coords. No PM/Tiptap surface is involved.
  const handleSelectionChange = useCallback((sel) => {
    setSelection(sel)
  }, [])

  // Manual-edit debounced upsert per §8.11 — produces a pending manual
  // card with a live countdown and an explicit Save now. With the CM6
  // markdown source editor (Phase 1), the doc IS the markdown — no more
  // HTML→getText round-trip, so §19.2 lossiness is gone.
  const flushManualBuffer = useCallback(async () => {
    const editor = editorRef.current
    if (!editor || !branchView || mode !== 'contribute') return
    if (typeof editor.getDoc !== 'function') return
    const raw = editor.getDoc()
    const newContent = raw.endsWith('\n') ? raw : raw + '\n'
    if (newContent.trim() === (branchView.body || '').trim()) {
      setManualPending(null)
      return
    }
    try {
      const res = await manualFlush(slug, branchParam, {
        newContent,
        paragraphCount: manualPending?.paragraphCount || 1,
      })
      if (!res.noop) {
        const fresh = await getBranch(slug, branchParam, pid, cid)
        setBranchView(fresh)
        setChanges(fresh.changes || [])
        setPreviewContent(fresh.body || '')
        originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
        setManualPending(null)
        loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
      }
    } catch (err) {
      setError(err.message)
    }
  }, [slug, branchParam, branchView, mode, manualPending?.paragraphCount])

  const handleEditorUpdate = useMemo(() => debounce((doc) => {
    if (mode !== 'contribute' || !branchView) return
    const current = splitSourceParagraphs(doc)
    const baseline = originalSourceLinesRef.current || []
    const len = Math.max(current.length, baseline.length)
    // §8.11 Phase 5: per-paragraph word-diff for the pending manual card.
    // Naive index-aligned compare against the same baseline Phase 4's
    // gutter uses; tolerates the insert-in-middle cascade as the "you've
    // touched stuff below this point" cue. An LCS-anchored pass could
    // refine this later.
    const diffs = []
    for (let i = 0; i < len; i++) {
      const a = baseline[i] ?? ''
      const b = current[i] ?? ''
      if (a === b) continue
      diffs.push({
        baselineIndex: i,
        baselineText: a,
        currentText: b,
        tokens: diffWords(a, b),
      })
    }
    if (diffs.length > 0) {
      setManualPending({ paragraphCount: diffs.length, diffs })
      setManualCountdown({ deadline: Date.now() + MANUAL_IDLE_MS })
    } else {
      setManualPending(null)
      setManualCountdown(null)
    }
  }, MANUAL_DEBOUNCE_MS), [mode, branchView])

  // Faster debounce for the live preview pane — manual-edit tracking
  // can stay at MANUAL_DEBOUNCE_MS, but the rendered preview should feel
  // responsive while typing. ~150ms keeps marked + mermaid re-renders off
  // the keystroke hot path without feeling stale.
  const handleEditorUpdatePreview = useMemo(() => debounce((doc) => {
    setPreviewContent(doc)
  }, 150), [])

  const handleEditorDocUpdate = useCallback((doc) => {
    handleEditorUpdate(doc)
    handleEditorUpdatePreview(doc)
  }, [handleEditorUpdate, handleEditorUpdatePreview])

  // Idle flush — auto-save when countdown elapses.
  useEffect(() => {
    if (!manualCountdown) return
    const delay = Math.max(0, manualCountdown.deadline - Date.now())
    const t = setTimeout(() => {
      flushManualBuffer()
    }, delay)
    return () => clearTimeout(t)
  }, [manualCountdown, flushManualBuffer])

  // ── Start contributing ─────────────────────────────────────────────────
  // Returns the branch the viewer should now be on (the freshly cut branch on
  // main, or the current branch on a mode-flip), or null if no branch was cut
  // (signed-out → login redirect, or the server rejected the cut). §8.12's
  // main-view Ask reuses this dispatch and consumes the returned branch name.
  const handleStartContributing = useCallback(async () => {
    if (!viewer) { window.location.href = '/auth/login'; return null }
    if (branchParam === 'main') {
      try {
        // §9.5 dispatch: super-drafts cut a meta-repo edit branch via
        // start-edit-branch; active RFCs run §8.14's promote-to-branch.
        const { branch_name } = isSuperDraft
          ? await startEditBranch(slug)
          : await promoteToBranch(slug)
        setSearchParams({ branch: branch_name })
        return branch_name
      } catch (err) {
        setError(err.message)
        return null
      }
    }
    // Non-main: pure mode flip per §8.14.
    if (pendingDiscussChanges.length > 0) {
      setPendingDiscussChanges([])
    }
    setMode('contribute')
    return branchParam
  }, [viewer, slug, branchParam, pendingDiscussChanges, setSearchParams, isSuperDraft])

  // ── §8.12 Option B — Ask invoked from the canonical `main` view ─────────
  // AI chat is an editing activity and only runs on an edit branch, so asking
  // while reading transparently cuts one (same dispatch as Start Contributing),
  // navigates to it, and stashes the question; the pending-ask effect fires it
  // once the new branch's view (and its main_thread_id) has resolved. Degrades
  // gracefully: signed-out → login; a viewer who can't contribute has the cut
  // rejected server-side → the error surfaces and we neither navigate nor chat.
  const handleMainAsk = useCallback(async (text, quote) => {
    if (!viewer) { window.location.href = '/auth/login'; return }
    const branch = await handleStartContributing()
    if (!branch || branch === 'main') return   // cut failed → setError already ran
    pendingAskRef.current = { text, quote: quote || null, branch }
  }, [viewer, handleStartContributing])

  // ── Submit a chat turn (prompt bar or selection tooltip) ───────────────
  const submitChatTurn = useCallback(async (text, quote) => {
    if (!branchView?.main_thread_id || isStreaming) return
    if (!viewer) { window.location.href = '/auth/login'; return }
    setIsStreaming(true)
    // Optimistic insert of the user message and an assistant placeholder.
    const tempUserId = `tmp-user-${Date.now()}`
    const tempAssistId = `tmp-assist-${Date.now()}`
    setMessages(prev => [
      ...prev,
      { id: tempUserId, role: 'user', author_login: viewer.gitea_login, text, quote, created_at: new Date().toISOString() },
      { id: tempAssistId, role: 'assistant', text: '', model_id: selectedModel, streaming: true, created_at: new Date().toISOString() },
    ])
    try {
      const { assistantId, userMsgId } = await streamChatTurn(
        slug,
        branchParam,
        branchView.main_thread_id,
        { text, quote, model: selectedModel },
        {
          onChunk: chunk => {
            setMessages(prev => prev.map(m =>
              m.id === tempAssistId ? { ...m, text: (m.text || '') + chunk } : m
            ))
          },
          onChanges: payload => {
            // payload: { message_id, change_ids, count }
            // The page-level state holds onto the assistant id so we
            // can correlate change.source_message_id when the branch
            // re-loads below.
            if (payload?.message_id) {
              setMessages(prev => prev.map(m =>
                m.id === tempAssistId ? { ...m, id: payload.message_id, streaming: false } : m
              ))
            }
          },
          onDone: () => { /* terminal */ },
        },
      )
      // Rebind to the real ids returned via response headers in case
      // the X-Assistant-Message-Id header arrived before the changes event.
      if (assistantId) {
        setMessages(prev => prev.map(m =>
          m.id === tempAssistId ? { ...m, id: Number(assistantId), streaming: false } : m
        ))
      }
      if (userMsgId) {
        setMessages(prev => prev.map(m =>
          m.id === tempUserId ? { ...m, id: Number(userMsgId) } : m
        ))
      }
      // Re-pull authoritative state: changes have been materialized server-side.
      const fresh = await getBranch(slug, branchParam, pid, cid)
      setChanges(fresh.changes || [])
      // If we're in discuss mode and the new turn produced pending AI changes,
      // surface them as discuss-mode buffered count.
      if (mode === 'discuss') {
        const newPending = (fresh.changes || []).filter(c => c.state === 'pending' && c.kind === 'ai')
        setPendingDiscussChanges(newPending)
      }
    } catch (err) {
      setError(err.message)
      setMessages(prev => prev.map(m =>
        m.id === tempAssistId ? { ...m, text: `[Error: ${err.message}]`, streaming: false } : m
      ))
    } finally {
      setIsStreaming(false)
    }
  }, [slug, branchParam, branchView?.main_thread_id, isStreaming, viewer, selectedModel, mode])

  // Keep submitChatTurnRef pointed at the latest submitChatTurn so the
  // message-load effect's §8.12 main-view ask uses the current branch closure.
  useEffect(() => { submitChatTurnRef.current = submitChatTurn }, [submitChatTurn])

  const handlePrompt = useCallback((text, sel) => {
    const quote = sel?.text || null
    // On the canonical view there is no chat surface; Ask cuts an edit branch
    // and runs the question there (§8.12 Option B). On a branch, ask directly.
    if (branchParam === 'main') { handleMainAsk(text, quote); return }
    submitChatTurn(text, quote)
  }, [branchParam, handleMainAsk, submitChatTurn])

  const handleTooltipAsk = useCallback(async (textOrNull, quote) => {
    if (textOrNull === null) { setSelection(null); return }
    setSelection(null)
    if (branchParam === 'main') { await handleMainAsk(textOrNull, quote); return }
    await submitChatTurn(textOrNull, quote)
  }, [branchParam, handleMainAsk, submitChatTurn])

  const handleTooltipFlag = useCallback(async (label, quote) => {
    if (!viewer) { window.location.href = '/auth/login'; return }
    setSelection(null)
    try {
      await createThread(slug, branchParam, {
        thread_kind: 'flag',
        anchor_kind: 'range',
        anchor_payload: { quote },
        label,
      })
      const fresh = await getBranch(slug, branchParam, pid, cid)
      setBranchView(fresh)
      loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
    } catch (err) {
      setError(err.message)
    }
  }, [slug, branchParam, viewer])

  // ── Accept / decline / reask ──────────────────────────────────────────
  const handleAccept = useCallback(async ({ change, proposed, wasEdited }) => {
    try {
      await apiAccept(slug, branchParam, change.id, { proposed, wasEdited })
      // Pull authoritative branch state and reset the editor to the new
      // body. The proper tracked-changes overlay lives in Phase 3's
      // preview pane; with CM6 as the source editor there is no HTML
      // surface to inject `<span class="tracked-*">` into.
      const fresh = await getBranch(slug, branchParam, pid, cid)
      setBranchView(fresh)
      setChanges(fresh.changes || [])
      setEditorContent(fresh.body || '')
      setPreviewContent(fresh.body || '')
      originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
    } catch (err) {
      setError(err.message)
    }
  }, [slug, branchParam])

  const handleDecline = useCallback(async (changeId) => {
    try {
      await apiDecline(slug, branchParam, changeId)
      const fresh = await getBranch(slug, branchParam, pid, cid)
      setChanges(fresh.changes || [])
    } catch (err) {
      setError(err.message)
    }
  }, [slug, branchParam])

  const handleReask = useCallback(async (changeId) => {
    try {
      await reaskChange(slug, branchParam, changeId)
      const fresh = await getBranch(slug, branchParam, pid, cid)
      setBranchView(fresh)
      setChanges(fresh.changes || [])
      loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
    } catch (err) {
      setError(err.message)
    }
  }, [slug, branchParam])

  const handleResolveThread = useCallback(async (threadId) => {
    try {
      await resolveThread(slug, branchParam, threadId)
      const fresh = await getBranch(slug, branchParam, pid, cid)
      setBranchView(fresh)
      loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
    } catch (err) {
      setError(err.message)
    }
  }, [slug, branchParam])

  const handleSaveNow = useCallback(() => {
    flushManualBuffer()
  }, [flushManualBuffer])

  // Phase 6 — Escape closes the drawer when open. Cheap and gated on
  // drawerOpen so the listener attaches only while needed. The CSS hides
  // the drawer chrome above 1280px regardless, so this is a no-op there
  // because drawerOpen has no visible effect either way.
  useEffect(() => {
    if (!drawerOpen) return
    const onKey = (e) => { if (e.key === 'Escape') setDrawerOpen(false) }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }, [drawerOpen])

  // ── Editor-click → focus matching change card ──────────────────────────
  const handleEditorClick = useCallback((e) => {
    const span = e.target.closest('[data-change-id]')
    if (span) {
      const id = span.getAttribute('data-change-id')
      setFocusedChangeId(Number(id))
      setTimeout(() => setFocusedChangeId(null), 1800)
    }
  }, [])

  // ── Branch dropdown navigation ─────────────────────────────────────────
  const onPickBranch = useCallback((name) => {
    if (name === branchParam) return
    if (name === 'main') {
      setSearchParams({})
    } else {
      setSearchParams({ branch: name })
    }
  }, [branchParam, setSearchParams])

  // Render early-out states.
  if (error) return <article className="entry-view"><p>Error: {error}</p></article>
  if (!entry) return <article className="entry-view">Loading…</article>
  if (entry.state !== 'active' && entry.state !== 'super-draft') {
    // §13.7: a retired entry is only reachable here by a site owner (the
    // backend 404s everyone else), so this doubles as the direct-nav
    // un-retire surface alongside the admin "Retired" list.
    const canUnretire = entry.state === 'retired' && viewer?.role === 'owner'
    return (
      <article className="entry-view">
        <p>This RFC is {entry.state}.</p>
        {canUnretire && (
          <>
            <p className="muted">
              Retired entries are hidden from the catalog and every view.
              Un-retiring restores it to its prior state.
            </p>
            <button type="button" className="btn-primary" onClick={handleUnretire}>
              Un-retire
            </button>
          </>
        )}
        {actionError && <p className="rfc-error-banner">{actionError}</p>}
      </article>
    )
  }
  if (!branchView) return <article className="entry-view">Loading branch…</article>

  const canContribute = branchView.capabilities?.can_contribute && branchParam !== 'main'
  const canChangeSettings = branchView.capabilities?.can_change_branch_settings
  const editorEditable = mode === 'contribute' && canContribute
  const showPromptBar = !!viewer

  const inDiscuss = mode === 'discuss'
  const pendingCount = changes.filter(c => c.state === 'pending').length
  // Phase 6 — count surfaced on the narrow-viewport drawer toggle. Mirrors
  // the change-panel-header badge: pending AI changes + the manual card
  // when one's buffered.
  const drawerBadgeCount = pendingCount + (manualPending ? 1 : 0)
  const showContributeToolbar = mode === 'contribute' && canContribute
  const showDiscussToolbar = inDiscuss && branchParam !== 'main'
    && changes.some(c => c.state === 'accepted')

  // §10.1: the Open PR affordance only surfaces on a non-main branch
  // that has commits ahead of main and no open PR already.
  const openPRForBranch = mainView?.open_prs?.find(p => p.head_branch === branchParam) || null
  const branchHasCommitsAhead = branchView && mainView && branchView.body !== (mainView.body || '')
  const canOpenPR = (
    branchParam !== 'main'
    && canContribute
    && !openPRForBranch
    && branchHasCommitsAhead
  )

  return (
    <div className="rfc-view">
      {/* Breadcrumb */}
      <div className="rfc-breadcrumb">
        <span className="breadcrumb-label">
          {isSuperDraft ? 'super-draft' : (entry.id || 'active')}
        </span>
        <span className="breadcrumb-sep">›</span>
        <strong>{entry.title}</strong>
        <span className="breadcrumb-sep">›</span>
        <BranchDropdown
          current={branchParam}
          mainView={mainView}
          isSuperDraft={isSuperDraft}
          onPick={onPickBranch}
          viewer={viewer}
        />
        <span className="breadcrumb-sep">·</span>
        <span className="breadcrumb-meta">
          {mainView ? `${mainView.branches.length} ${isSuperDraft ? 'edit branch' : 'branch'}${mainView.branches.length === 1 ? '' : 'es'}` : '…'}
          {mainView && mainView.open_prs.length > 0 && ` · ${mainView.open_prs.length} PR${mainView.open_prs.length === 1 ? '' : 's'}`}
        </span>
        <div className="breadcrumb-actions">
          {branchParam !== 'main' && canContribute && (
            <button
              type="button"
              className={`btn-mode-toggle ${mode}`}
              onClick={() => setMode(mode === 'discuss' ? 'contribute' : 'discuss')}
              title={mode === 'discuss' ? 'Flip into edit mode (Beta)' : 'Flip back to read-only discuss (Beta)'}
            >
              {mode === 'discuss' ? 'Contribute' : 'Discuss'}
              <span className="beta-chip">Beta</span>
            </button>
          )}
          {(branchParam === 'main' || !canContribute) && viewer && (
            <button
              type="button"
              className="btn-start-contribution-header"
              onClick={handleStartContributing}
            >
              Start Contributing
              <span className="beta-chip">Beta</span>
            </button>
          )}
          {!viewer && (
            <a className="btn-link" href="/auth/login" title="Private beta — only invited emails can sign in">
              Sign in <span className="beta-chip">Beta</span>
            </a>
          )}
          {canOpenPR && (
            <button
              type="button"
              className="btn-primary btn-open-pr"
              onClick={() => setShowPRModal(true)}
              title="§10.1 — open a PR from this branch against main"
            >
              Open PR
            </button>
          )}
          {openPRForBranch && (
            <a
              className="btn-link"
              href={entryPrPath(pid, slug, openPRForBranch.pr_number)}
              title="View the open PR for this branch"
            >
              PR #{openPRForBranch.pr_number} →
            </a>
          )}
          {canChangeSettings && branchParam !== 'main' && (
            <button
              type="button"
              className="btn-link"
              onClick={() => setShowVisibility(true)}
            >Branch settings</button>
          )}
          {isSuperDraft && branchView?.capabilities?.can_edit_metadata && (
            <button
              type="button"
              className="btn-link"
              onClick={() => setShowMetadataPane(true)}
              title="§9.5 — open a small meta-repo PR to edit title or tags"
            >
              Metadata
            </button>
          )}
          {isSuperDraft && viewer && entry?.owners && !entry.owners.includes(viewer.gitea_login) && (
            <button
              type="button"
              className="btn-link"
              onClick={async () => {
                setClaimError(null)
                try {
                  const result = await claimOwnership(slug)
                  if (result?.noop) return
                  if (result?.pr_number) {
                    navigate(entryPrPath(pid, slug, result.pr_number))
                  }
                } catch (err) {
                  setClaimError(err.message)
                }
              }}
              title="§13.1 — open a claim PR adding you to the entry's owners"
            >
              Claim ownership
            </button>
          )}
          {isSuperDraft && viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
            <button
              type="button"
              className="btn-primary btn-graduate"
              onClick={() => setShowGraduateDialog(true)}
              title="§13 — graduate this super-draft to a per-RFC repo"
            >
              Graduate to RFC repo
            </button>
          )}
          {/* v0.16.0 (item #12): owner-only invitations affordance.
              Shown when the viewer is named in the RFC's frontmatter
              `owners` list or holds a platform admin/owner role.
              Available on both super-drafts and active RFCs. */}
          {viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
            <button
              type="button"
              className="btn-link"
              onClick={() => setShowInvitationsModal(true)}
              title="Invite collaborators to this RFC"
            >
              Invitations
            </button>
          )}
          {/* §13.7: Retire (soft delete). RFC owners + site owners only. */}
          {canRetire && (
            <button
              type="button"
              className="btn-link btn-retire"
              onClick={handleRetire}
              title="§13.7 — soft-delete this RFC (removes it from every view; a site owner can un-retire it)"
            >
              Retire
            </button>
          )}
        </div>
      </div>
      {actionError && (
        <div className="rfc-error-banner">Retire failed: {actionError}</div>
      )}
      {claimError && (
        <div className="rfc-error-banner">Claim failed: {claimError}</div>
      )}

      {/* Two columns: editor + chat */}
      <div className={`rfc-body${drawerOpen ? ' drawer-open' : ''}`}>
        <div className="editor-area" onClick={handleEditorClick}>
          {branchParam === 'main' && (
            <div className="discuss-mode-banner">
              {isSuperDraft
                ? 'Canonical body is read-only — PRs against the meta repo are the only path to change it. Use Start Contributing to cut an edit branch.'
                : 'main is read-only — PRs are the only path to change it. Open a branch to propose edits.'}
            </div>
          )}
          {/* #26: the optional ground-truth use case captured at propose
              time. Shown on the canonical (main) view; muted "left blank"
              treatment when the proposer didn't supply one. */}
          {branchParam === 'main' && (
            <div className="rfc-use-case" style={{ margin: '8px 0 16px', padding: '10px 14px', borderLeft: '3px solid #e0e0e0', background: '#fafafa' }}>
              <div style={{ fontSize: 11, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>
                Intended use case
              </div>
              {entry.proposed_use_case
                ? <div style={{ whiteSpace: 'pre-wrap' }}>{entry.proposed_use_case}</div>
                : <span style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</span>}
            </div>
          )}
          {/* §22.4a SLICE-4 (PUC-1, UX §5.2): schema-driven metadata panel on
              the canonical (main) view. Renders nothing when the collection
              declares no `fields` (INV-5). */}
          {branchParam === 'main' && collectionFields && (
            <MetadataFieldsPanel
              projectId={pid}
              collectionId={cid}
              slug={slug}
              fields={collectionFields}
              meta={entry.meta || {}}
              canEdit={!!entry.can_edit_meta}
            />
          )}
          {inDiscuss && branchParam !== 'main' && (
            <div className="discuss-mode-banner">
              Discuss mode on <strong>{branchParam}</strong> — chat freely;
              flip to Contribute to edit the document and apply AI changes.
            </div>
          )}
          {!canContribute && branchParam !== 'main' && viewer && (
            <div className="discuss-mode-banner muted">
              You don't have contribute access to this branch. The branch
              creator or an arbiter can grant access.
            </div>
          )}
          {showContributeToolbar && (
            <div className="editor-toolbar">
              <span className="editor-toolbar-hint">
                {changes.filter(c => c.state === 'accepted').length} accepted ·{' '}
                {pendingCount} pending
              </span>
              <ChatDrawerToggleButton
                open={drawerOpen}
                count={drawerBadgeCount}
                onToggle={() => setDrawerOpen(v => !v)}
              />
            </div>
          )}
          {showDiscussToolbar && (
            <div className="editor-toolbar">
              <button
                type="button"
                className={`btn-review-toggle${discussShowChanges ? ' active' : ''}`}
                onClick={() => setDiscussShowChanges(v => !v)}
                title="§8.10 — surface accepted tracked changes inline in the preview"
              >
                {discussShowChanges ? 'Hide tracked changes' : 'Show tracked changes'}
              </button>
              <span className="editor-toolbar-hint">
                {changes.filter(c => c.state === 'accepted').length} accepted on this branch
              </span>
              <ChatDrawerToggleButton
                open={drawerOpen}
                count={drawerBadgeCount}
                onToggle={() => setDrawerOpen(v => !v)}
              />
            </div>
          )}
          {!showContributeToolbar && !showDiscussToolbar && (
            // Phase 6 — when neither editing toolbar would render, surface
            // a narrow-only toolbar carrying just the drawer toggle. CSS
            // hides it above 1280px where the right panel is always inline.
            <div className="editor-toolbar editor-toolbar-narrow-only">
              <ChatDrawerToggleButton
                open={drawerOpen}
                count={drawerBadgeCount}
                onToggle={() => setDrawerOpen(v => !v)}
              />
            </div>
          )}
          {editorEditable ? (
            <div className="editor-split">
              <div className="editor-split-pane editor-split-raw">
                <MarkdownSourceEditor
                  initialDoc={editorContent}
                  editorRef={editorRef}
                  onUpdate={handleEditorDocUpdate}
                />
              </div>
              <div className="editor-split-pane editor-split-preview">
                <MarkdownPreview
                  content={previewContent}
                  onSelectionChange={handleSelectionChange}
                  changes={changes}
                  messages={messages}
                  showTrackedChanges
                />
              </div>
            </div>
          ) : (
            <MarkdownPreview
              content={editorContent}
              onSelectionChange={handleSelectionChange}
              className="markdown-preview-solo"
              changes={changes}
              messages={messages}
              showTrackedChanges={inDiscuss && discussShowChanges}
            />
          )}
          <SelectionTooltip
            selection={selection}
            onAsk={handleTooltipAsk}
            onFlag={handleTooltipFlag}
            disabled={isStreaming || !viewer}
            models={models}
            selectedModel={selectedModel}
            onModelChange={setSelectedModel}
          />
          {showPromptBar ? (
            <PromptBar
              selection={selection?.text || null}
              onSubmit={handlePrompt}
              disabled={isStreaming}
              models={models}
              selectedModel={selectedModel}
              onModelChange={setSelectedModel}
              discussMode={inDiscuss}
            />
          ) : (
            <div className="readonly-bar">
              Read-only view. Discussion is in private <strong>Beta</strong> —{' '}
              <a href="/auth/login">sign in</a> if your email has been invited.
            </div>
          )}
        </div>

        <div
          className="right-panel-backdrop"
          onClick={() => setDrawerOpen(false)}
          aria-hidden="true"
          data-open={drawerOpen ? 'true' : 'false'}
        />
        <div className={`right-panel${drawerOpen ? ' drawer-open' : ''}`} role="complementary">
          {/* v0.5.0 — on main, the right panel is the PR-less discussion
            * surface (threads.branch_name IS NULL). Branches keep their
            * existing branch-chat panel; contribution still requires
            * opening a PR from a branch via the Open PR affordance above. */}
          {branchParam === 'main' ? (
            <RFCDiscussionPanel slug={slug} viewer={viewer} />
          ) : (
            <ChatPanel
              messages={messages}
              threads={branchView.threads || []}
              changes={changes}
              branchName={branchParam}
              isStreaming={isStreaming}
              contributionMode={mode === 'contribute'}
              onStartContribution={handleStartContributing}
              onScrollToChange={setFocusedChangeId}
              onResolveThread={handleResolveThread}
            />
          )}
          {mode === 'contribute' && (changes.length > 0 || manualPending) && (
            <ChangePanel
              changes={changes}
              onAccept={handleAccept}
              onDecline={handleDecline}
              onReask={handleReask}
              onScrollToMessage={focusMessage}
              focusedChangeId={focusedChangeId}
              manualPendingStatus={manualPending ? {
                paragraphCount: manualPending.paragraphCount,
                diffs: manualPending.diffs,
                savingIn: manualCountdownLabel(manualCountdown),
                onSaveNow: handleSaveNow,
              } : null}
            />
          )}
          {inDiscuss && pendingDiscussChanges.length > 0 && (
            <div className="contribution-cta">
              <div className="contribution-cta-count">
                {pendingDiscussChanges.length} change{pendingDiscussChanges.length === 1 ? '' : 's'} proposed
              </div>
              <p className="contribution-cta-desc">
                Flip into Contribute to act on them.
              </p>
              <button
                type="button"
                className="btn-start-contribution"
                onClick={handleStartContributing}
              >
                {branchParam === 'main' ? 'Start Contributing →' : 'Contribute on this branch →'}
              </button>
            </div>
          )}
        </div>
      </div>

      {showPRModal && (
        <PRModal
          slug={slug}
          branch={branchParam}
          branchIsPrivate={!branchView.visibility?.read_public}
          onClose={() => setShowPRModal(false)}
          onOpened={(prNumber) => {
            setShowPRModal(false)
            navigate(entryPrPath(pid, slug, prNumber))
          }}
        />
      )}

      {showVisibility && (
        <BranchVisibilityModal
          slug={slug}
          branch={branchParam}
          current={branchView.visibility}
          onClose={() => setShowVisibility(false)}
          onSaved={async () => {
            const fresh = await getBranch(slug, branchParam, pid, cid)
            setBranchView(fresh)
            setShowVisibility(false)
          }}
        />
      )}

      {showGraduateDialog && (
        <GraduateDialog
          slug={slug}
          entry={entry}
          onClose={() => setShowGraduateDialog(false)}
          onCompleted={() => {
            setShowGraduateDialog(false)
            // The catalog row and the RFC view now reflect `active`.
            getRFC(pid, slug, cid).then(setEntry).catch(() => {})
            getRFCMain(slug, pid, cid).then(setMainView).catch(() => {})
          }}
        />
      )}

      {showInvitationsModal && (
        <InvitationsModal
          slug={slug}
          rfcTitle={entry?.title}
          onClose={() => setShowInvitationsModal(false)}
        />
      )}

      {showMetadataPane && (
        <MetadataPaneModal
          slug={slug}
          currentTitle={entry.title}
          currentTags={entry.tags || []}
          onClose={() => setShowMetadataPane(false)}
          onOpened={(prNumber) => {
            setShowMetadataPane(false)
            // Refresh main view so the new open PR surfaces in the
            // breadcrumb meta count immediately.
            getRFCMain(slug, pid, cid).then(setMainView).catch(() => {})
            navigate(entryPrPath(pid, slug, prNumber))
          }}
        />
      )}
    </div>
  )
}

function ChatDrawerToggleButton({ open, count, onToggle }) {
  // Phase 6 — toolbar button that opens/closes the right-panel drawer
  // at narrow viewports. The badge mirrors the change-panel-header's
  // .badge styling; an aria-label gives screen readers the count. CSS
  // hides the button above 1280px where the right panel is inline.
  return (
    <button
      type="button"
      className={`btn-chat-drawer-toggle${open ? ' active' : ''}`}
      onClick={onToggle}
      aria-pressed={open}
      aria-label={`${open ? 'Close' : 'Open'} chat panel${count > 0 ? ` (${count} pending)` : ''}`}
      title={open ? 'Close chat panel' : 'Open chat panel'}
    >
      Chat
      {count > 0 && <span className="badge">{count}</span>}
    </button>
  )
}

function focusMessage(messageId) {
  const el = document.querySelector(`[data-message-id="${messageId}"]`)
  if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}

function manualCountdownLabel(c) {
  if (!c) return ''
  const remaining = Math.max(0, c.deadline - Date.now())
  const totalSec = Math.ceil(remaining / 1000)
  const m = Math.floor(totalSec / 60)
  const s = totalSec % 60
  return `${m}:${String(s).padStart(2, '0')}`
}

async function loadAllMessages(slug, branch, threads) {
  // For Slice 2 we pull each thread's messages and stitch them in
  // chronological order. The branch chat is the unified feed of
  // every message across every thread per §8.12.
  if (!threads || threads.length === 0) return []
  const all = []
  for (const t of threads) {
    if (t.state !== 'open' && t.thread_kind !== 'flag') continue
    try {
      const { messages } = await getThreadMessages(slug, branch, t.id)
      for (const m of messages) {
        all.push({
          ...m,
          thread_id: t.id,
          thread_kind: t.thread_kind,
          anchor_kind: t.anchor_kind,
          anchor_preview: t.anchor_payload?.quote || null,
          flag_label: t.thread_kind === 'flag' ? t.label : null,
        })
      }
      if (t.thread_kind === 'flag' && (!messages || messages.length === 0)) {
        all.push({
          id: `flag-${t.id}`,
          role: 'system',
          text: t.label || '',
          thread_id: t.id,
          thread_kind: 'flag',
          anchor_kind: t.anchor_kind,
          anchor_preview: t.anchor_payload?.quote || null,
          flag_label: t.label,
          created_at: t.created_at,
          author_login: null,
        })
      }
    } catch {
      // Tolerate per-thread fetch failures; the surface for triage
      // belongs to a future error overlay.
    }
  }
  all.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''))
  return all
}

function BranchDropdown({ current, mainView, isSuperDraft, onPick }) {
  const [open, setOpen] = useState(false)
  // For super-drafts the first dropdown position is "canonical body"
  // per §9.4 — the entry as it appears on meta-repo main. For active
  // RFCs it is literally `main` per §8.1.
  const mainLabel = isSuperDraft ? 'canonical body' : 'main'
  const items = [{ name: 'main', label: mainLabel }, ...(mainView?.branches || [])]
  const preGrad = mainView?.pre_graduation_history || []
  const currentLabel = current === 'main' ? mainLabel : current
  return (
    <div className="branch-dropdown">
      <button
        type="button"
        className="branch-dropdown-trigger"
        onClick={() => setOpen(o => !o)}
      >
        {currentLabel} ▾
      </button>
      {open && (
        <div className="branch-dropdown-menu" onMouseLeave={() => setOpen(false)}>
          {items.map(b => (
            <button
              key={b.name}
              type="button"
              className={`branch-dropdown-item ${b.name === current ? 'active' : ''}`}
              onClick={() => { setOpen(false); onPick(b.name) }}
            >
              <span className="branch-name">{b.label || b.name}</span>
              {b.visibility && b.name !== 'main' && !b.visibility.read_public && (
                <span className="branch-private-icon" title="Private">🔒</span>
              )}
              {b.creator && (
                <span className="branch-creator">@{b.creator}</span>
              )}
            </button>
          ))}
          {preGrad.length > 0 && (
            <>
              <div className="branch-dropdown-section">
                Pre-graduation history ({preGrad.length})
              </div>
              {preGrad.map(b => (
                <button
                  key={b.branch_name}
                  type="button"
                  className={`branch-dropdown-item pre-graduation ${b.branch_name === current ? 'active' : ''}`}
                  onClick={() => { setOpen(false); onPick(b.branch_name) }}
                  title="§9.8: meta-repo edit branch from before graduation — read-only"
                >
                  <span className="branch-name">{b.branch_name}</span>
                  <span className="branch-meta">
                    {b.thread_count} thread{b.thread_count === 1 ? '' : 's'}
                    {b.change_count ? ` · ${b.change_count} change${b.change_count === 1 ? '' : 's'}` : ''}
                  </span>
                </button>
              ))}
            </>
          )}
        </div>
      )}
    </div>
  )
}

function MetadataPaneModal({ slug, currentTitle, currentTags, onClose, onOpened }) {
  // §9.5: a small meta-repo PR that touches only frontmatter. Slug
  // renames are deferred per §9.5 and the §19.2 candidate — the slug
  // field is rendered read-only as a deliberate signal.
  const [title, setTitle] = useState(currentTitle || '')
  const [tagsRaw, setTagsRaw] = useState((currentTags || []).join(', '))
  const [prDescription, setPrDescription] = useState('')
  const [saving, setSaving] = useState(false)
  const [err, setErr] = useState(null)

  const onSave = async () => {
    setSaving(true); setErr(null)
    try {
      const tags = tagsRaw
        .split(',')
        .map(t => t.trim())
        .filter(Boolean)
      const result = await editMetadata(slug, {
        title: title.trim(),
        tags,
        prDescription: prDescription.trim() || null,
      })
      if (result?.noop) {
        setErr('No changes — title and tags match the current entry.')
        setSaving(false)
        return
      }
      onOpened(result.pr_number)
    } catch (e) {
      setErr(e.message)
      setSaving(false)
    }
  }

  return (
    <div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
      <div className="modal">
        <div className="modal-header">
          <h2>Edit metadata</h2>
          <button className="modal-close" onClick={onClose}>×</button>
        </div>
        <div className="modal-body">
          <label>Title</label>
          <input
            type="text"
            value={title}
            onChange={e => setTitle(e.target.value)}
            disabled={saving}
          />
          <label style={{ marginTop: 12 }}>Tags (comma-separated)</label>
          <input
            type="text"
            value={tagsRaw}
            onChange={e => setTagsRaw(e.target.value)}
            disabled={saving}
            placeholder="identity, schema"
          />
          <label style={{ marginTop: 12 }}>PR description (optional)</label>
          <textarea
            rows={3}
            value={prDescription}
            onChange={e => setPrDescription(e.target.value)}
            disabled={saving}
            placeholder="What changed and why."
          />
          <p className="field-help" style={{ marginTop: 12 }}>
            §9.5: title and tag edits open a small meta-repo PR via the bot.
            Slug renames are not supported in v1.
          </p>
          {err && <p className="field-error">{err}</p>}
        </div>
        <div className="modal-actions">
          <button className="btn-secondary" onClick={onClose} disabled={saving}>Cancel</button>
          <button className="btn-primary" onClick={onSave} disabled={saving}>
            {saving ? 'Opening PR…' : 'Open metadata PR'}
          </button>
        </div>
      </div>
    </div>
  )
}

function BranchVisibilityModal({ slug, branch, current, onClose, onSaved }) {
  const [readPublic, setReadPublic] = useState(!!current?.read_public)
  const [contributeMode, setContributeMode] = useState(current?.contribute_mode || 'just-me')
  const [saving, setSaving] = useState(false)
  const [err, setErr] = useState(null)

  const onSave = async () => {
    setSaving(true); setErr(null)
    try {
      await setBranchVisibility(slug, branch, { readPublic, contributeMode })
      onSaved()
    } catch (e) { setErr(e.message); setSaving(false) }
  }

  return (
    <div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
      <div className="modal">
        <div className="modal-header">
          <h2>Branch settings — {branch}</h2>
          <button className="modal-close" onClick={onClose}>×</button>
        </div>
        <div className="modal-body">
          <label>
            <input type="checkbox" checked={readPublic} onChange={e => setReadPublic(e.target.checked)} />
            {' '}Public read access
          </label>
          <p className="field-help">
            §11.1: a public branch can be read by anyone, including anonymous viewers.
          </p>
          <label style={{ marginTop: 12 }}>Contribute mode</label>
          <select value={contributeMode} onChange={e => setContributeMode(e.target.value)}>
            <option value="just-me">Just me</option>
            <option value="specific">Specific contributors</option>
            <option value="any-contributor">Any contributor</option>
          </select>
          {err && <p className="field-error">{err}</p>}
        </div>
        <div className="modal-actions">
          <button className="btn-secondary" onClick={onClose} disabled={saving}>Cancel</button>
          <button className="btn-primary" onClick={onSave} disabled={saving}>
            {saving ? 'Saving…' : 'Save'}
          </button>
        </div>
      </div>
    </div>
  )
}
