// api.js — every backend call lives here.
//
// All write requests pass {credentials: 'include'} implicitly because
// the dev proxy and the production deploy serve the API from the same
// origin as the frontend. If you split origins later, change here.

async function jsonOrThrow(res) {
  if (!res.ok) {
    let detail = ''
    try {
      const body = await res.json()
      detail = body.detail || JSON.stringify(body)
    } catch {
      detail = await res.text()
    }
    const error = new Error(detail || `HTTP ${res.status}`)
    error.status = res.status
    throw error
  }
  return res.json()
}

export async function getMe() {
  const res = await fetch('/api/auth/me')
  return jsonOrThrow(res)
}

// ── v0.23.0: sign-in state resume (§6.2, roadmap item #29) ───────────────
//
// The route-change hook (useLastState) debounce-posts the user's current
// route + a small bag of *light* view state here for authenticated users.
// The next sign-in reads `last_route` off `/api/auth/me` and redirects.
// Privacy: `state` carries ephemeral view state ONLY — never draft-buffer
// contents (see SPEC §6.2). Best-effort: callers ignore failures (an
// offline/401 POST must never disrupt navigation).
export async function putLastState(route, state) {
  const body = { route }
  if (state != null) body.state = state
  const res = await fetch('/api/me/last-state', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  return jsonOrThrow(res)
}

// ── v0.7.0: email + one-time-code sign-in (§6.2) ─────────────────────────
//
// The legacy /auth/login → /auth/callback OAuth flow remains during the
// migration — the new UI just no longer points at it primarily. These
// two helpers drive the Login.jsx surface.

export async function requestOtc(email, { turnstileToken } = {}) {
  // v0.12.0 / roadmap item #10: when the Turnstile widget has produced
  // a token, send it alongside the email so the backend can siteverify
  // before the OTC dispatch. The backend treats a missing token as
  // either soft-fail (no secret wired AND TURNSTILE_REQUIRED=false)
  // or hard-fail (verification required) — the frontend stays
  // uninvolved in the policy.
  const body = { email }
  if (turnstileToken) body.turnstile_token = turnstileToken
  const res = await fetch('/auth/otc/request', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  return jsonOrThrow(res)
}

export async function verifyOtc(email, code, { trustDevice = false } = {}) {
  // v0.11.0 — `trustDevice` is the "trust this device for 30 days"
  // checkbox on the Login.jsx OTC step. When true, the server mints
  // a fresh device-trust row and sets the long-lived cookie; on
  // subsequent visits, the cookie skips the OTC roundtrip via
  // `startDeviceTrust()`.
  const res = await fetch('/auth/otc/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, code, trust_device: !!trustDevice }),
  })
  return jsonOrThrow(res)
}

// ── v0.8.0: open beta-access request flow (§6.1 / §14.1) ─────────────────
//
// On the first OTC sign-in, the user lands in `permission_state='pending'`
// and `/api/auth/me` reports `needs_profile=true`. The Login.jsx surface
// then prompts for first/last/why and POSTs them here. After this lands,
// the user sees the /beta-pending page until an admin grants access.

export async function submitBetaRequest({ first_name, last_name, beta_request_reason }) {
  const res = await fetch('/api/auth/me/beta-request', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ first_name, last_name, beta_request_reason }),
  })
  return jsonOrThrow(res)
}

// ── v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8) ─────────
//
// After a successful OTC sign-in, a contributor may set a passcode and
// use email + passcode for subsequent sign-ins. OTC remains the
// forgot-passcode fallback — 5 consecutive verify failures locks the
// passcode path for 15 minutes (HTTP 423); the OTC path is unaffected.

export async function checkPasscode(email) {
  // Anonymous endpoint. Returns `{has_passcode: boolean}` so the
  // Login.jsx flow can decide whether to render a passcode input or
  // fall back to OTC. We URL-encode the email so addresses with '+'
  // round-trip cleanly.
  const params = new URLSearchParams({ email })
  const res = await fetch(`/auth/passcode/check?${params}`)
  return jsonOrThrow(res)
}

export async function verifyPasscode(email, passcode, { trustDevice = false } = {}) {
  // v0.11.0 — same trust-device opt-in as `verifyOtc`.
  const res = await fetch('/auth/passcode/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, passcode, trust_device: !!trustDevice }),
  })
  return jsonOrThrow(res)
}

// ── v0.11.0: trust device for 30 days (§6.2, roadmap item #9) ─────────────
//
// On a returning visit with a valid device-trust cookie, `startDeviceTrust`
// re-establishes the session without an OTC / passcode roundtrip. The
// cookie is HttpOnly so the client cannot read it; the call is a pure POST
// that the browser attaches the cookie to automatically.
//
// `listMyDevices`, `revokeMyDevice`, and `revokeAllMyDevices` drive the
// /settings/devices revoke-device UI. The signed-in user is the implicit
// subject; the cookie carries the session.

export async function startDeviceTrust() {
  const res = await fetch('/auth/device-trust/start', { method: 'POST' })
  return jsonOrThrow(res)
}

export async function listMyDevices() {
  return jsonOrThrow(await fetch('/api/auth/me/devices'))
}

export async function revokeMyDevice(deviceId) {
  return jsonOrThrow(await fetch(`/api/auth/me/devices/${deviceId}`, { method: 'DELETE' }))
}

export async function revokeAllMyDevices() {
  return jsonOrThrow(await fetch('/api/auth/me/devices', { method: 'DELETE' }))
}

export async function setPasscode(passcode) {
  // Requires an active session — the server returns 401 if not signed
  // in. The signed-in user is the implicit subject; the body carries
  // only the new passcode.
  const res = await fetch('/auth/passcode/set', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ passcode }),
  })
  return jsonOrThrow(res)
}

export async function clearPasscode() {
  const res = await fetch('/auth/passcode', { method: 'DELETE' })
  return jsonOrThrow(res)
}

// ── §22.9/§22.10 (M3): runtime deployment + per-project config ────────────
//
// Replaces the build-time VITE_APP_NAME. `getDeployment` is fetched once on
// boot (DeploymentProvider): { name, tagline, default_project_id, projects[] }.
// `getProject` drives ProjectLayout's per-project chrome + theme.
export async function getDeployment() {
  return jsonOrThrow(await fetch('/api/deployment'))
}

export async function getProject(projectId) {
  return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
}

// §22 S5: create-project (global Owner). The backend provisions a Gitea content
// repo, commits the project to projects.yaml, re-mirrors the registry, and
// returns the new project { id, name, type, visibility }.
export async function createProject({ projectId, name, type, visibility, contentRepo }) {
  const res = await fetch('/api/projects', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      project_id: projectId,
      name,
      type,
      visibility: visibility || null,
      content_repo: contentRepo || null,
    }),
  })
  return jsonOrThrow(res)
}

// §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a
// collectionId, read the collection-scoped routes; with only a projectId, the
// project default-collection compat path; with neither, the unscoped path.
// §22.4a SLICE-3: faceted catalog. `opts.selections` is { field: string[] }
// (each non-empty array becomes repeated query params, OR within the field);
// `opts.malformed` toggles the malformed-only filter. Returns the full payload
// { items, facets } — callers read both. With no selections it behaves as the
// pre-SLICE-3 list (and a no-fields collection returns facets: {}). Facets are
// served only on the project/collection-scoped paths, not the unscoped one.
export async function listRFCs(projectId, collectionId, opts = {}) {
  const { selections = {}, malformed = false } = opts
  const qs = new URLSearchParams()
  for (const [field, values] of Object.entries(selections)) {
    for (const v of values) qs.append(field, v)
  }
  if (malformed) qs.set('malformed', 'true')
  const query = qs.toString()
  let base
  if (projectId && collectionId) {
    base = `/api/projects/${projectId}/collections/${collectionId}/rfcs`
  } else {
    base = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
  }
  const url = query ? `${base}?${query}` : base
  return jsonOrThrow(await fetch(url))
}

export async function getRFC(projectId, slug, collectionId) {
  // Back-compat: getRFC(slug) (one arg) still hits the unscoped default path.
  if (slug === undefined) {
    return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`))
  }
  if (collectionId) {
    return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}`))
  }
  return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
}

// §22 S2: the collections of a project (for the /p/<project>/ directory). The
// response also carries a `viewer` block (§22 S4 capability flags:
// can_create_collection, can_invite, role) driving the role-aware directory.
export async function listCollections(projectId) {
  return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections`))
}

// §22 S4: one collection's settings + the viewer's collection-level
// capabilities (viewer.can_contribute / can_invite / role) — drives the
// propose-first empty state and the collection invite control.
export async function getCollection(projectId, collectionId) {
  return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}`))
}

// §22 S4 (C.2): the scope-role membership surface. An Owner grants
// {owner, contributor} at the project, or at one collection (collectionId set),
// to an existing account by email; the backend writes the membership row and
// §15-notifies the grantee.
export async function listScopeMembers(projectId) {
  return jsonOrThrow(await fetch(`/api/projects/${projectId}/members`))
}

export async function grantScopeMember(projectId, { email, role, collectionId }) {
  const res = await fetch(`/api/projects/${projectId}/members`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, role, collection_id: collectionId || null }),
  })
  return jsonOrThrow(res)
}

export async function revokeScopeMember(projectId, userId, collectionId) {
  const qs = collectionId ? `?collection_id=${encodeURIComponent(collectionId)}` : ''
  const res = await fetch(`/api/projects/${projectId}/members/${userId}${qs}`, {
    method: 'DELETE',
  })
  return jsonOrThrow(res)
}

// §22 S2: create-collection (deployment owner/admin). The backend commits a
// .collection.yaml and re-mirrors the registry, returning the new collection.
export async function createCollection(projectId, { collectionId, type, name, visibility, initialState }) {
  const res = await fetch(`/api/projects/${projectId}/collections`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      collection_id: collectionId,
      type,
      name: name || null,
      visibility: visibility || null,
      initial_state: initialState || null,
    }),
  })
  return jsonOrThrow(res)
}

export async function listProposals(projectId) {
  const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals'
  return jsonOrThrow(await fetch(url))
}

export async function getProposal(prNumber) {
  return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
}

// §22.4 (Plan B write): propose into a specific project when projectId is
// given; else the default-project compat path.
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase, collectionId }) {
  const url = (projectId && collectionId)
    ? `/api/projects/${projectId}/collections/${collectionId}/rfcs/propose`
    : (projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose')
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    // #26: proposed_use_case is optional; send null when blank so the
    // backend treats it as "left blank".
    body: JSON.stringify({
      title,
      slug,
      pitch,
      tags: tags || [],
      proposed_use_case: proposedUseCase || null,
    }),
  })
  return jsonOrThrow(res)
}

// Roadmap #27: Claude Haiku tag suggestions for the propose-RFC modal.
// Returns a (possibly empty) array of { tag, confidence }. Deliberately
// forgiving — any non-OK response (rate limit, transient error, no key
// configured server-side) resolves to [] so the modal just shows nothing
// rather than surfacing an error for what is a best-effort assist.
export async function suggestTags({ title, pitch, useCase }) {
  try {
    const res = await fetch('/api/rfcs/suggest-tags', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: title || '',
        pitch: pitch || '',
        use_case: useCase || '',
      }),
    })
    if (!res.ok) return []
    const data = await res.json()
    return Array.isArray(data.suggestions) ? data.suggestions : []
  } catch {
    return []
  }
}

// Roadmap #28 Part 3: offer-to-contribute-to-a-pending-RFC.
// `contributionTarget` feeds the contribute form (RFC title, owner
// display, the viewer's eligibility); `requestContribution` submits the
// ask; accept/decline are the owner's inbox actions.
export async function contributionTarget(slug) {
  return jsonOrThrow(await fetch(`/api/rfcs/${slug}/contribution-target`))
}

export async function requestContribution(slug, { matchedTerm, whoIAm, why, useCase }) {
  const res = await fetch(`/api/rfcs/${slug}/contribution-requests`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      matched_term: matchedTerm,
      who_i_am: whoIAm,
      why,
      use_case: useCase || null,
    }),
  })
  return jsonOrThrow(res)
}

export async function acceptContributionRequest(slug, requestId) {
  return jsonOrThrow(await fetch(
    `/api/rfcs/${slug}/contribution-requests/${requestId}/accept`, { method: 'POST' },
  ))
}

export async function declineContributionRequest(slug, requestId) {
  return jsonOrThrow(await fetch(
    `/api/rfcs/${slug}/contribution-requests/${requestId}/decline`, { method: 'POST' },
  ))
}

// §22.8: request-to-join a scope + the cross-collection inbox.
// `joinTarget` feeds the request modal (scope name, the viewer's eligibility);
// `requestJoin` submits the ask (desired role + optional message); accept/decline
// are the scope Owner's inbox actions (accept writes the membership row).
export async function joinTarget(scopeType, scopeId) {
  return jsonOrThrow(await fetch(
    `/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-target`,
  ))
}

export async function requestJoin(scopeType, scopeId, { role, message }) {
  const res = await fetch(
    `/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ role, message: message || null }),
    },
  )
  return jsonOrThrow(res)
}

export async function acceptJoinRequest(scopeType, scopeId, requestId, role) {
  return jsonOrThrow(await fetch(
    `/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests/${requestId}/accept`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ role: role || null }),
    },
  ))
}

export async function declineJoinRequest(scopeType, scopeId, requestId) {
  return jsonOrThrow(await fetch(
    `/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests/${requestId}/decline`,
    { method: 'POST' },
  ))
}

export async function mergeProposal(prNumber) {
  const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
  return jsonOrThrow(res)
}

export async function declineProposal(prNumber, comment) {
  const res = await fetch(`/api/proposals/${prNumber}/decline`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ comment }),
  })
  return jsonOrThrow(res)
}

export async function withdrawProposal(prNumber) {
  const res = await fetch(`/api/proposals/${prNumber}/withdraw`, { method: 'POST' })
  return jsonOrThrow(res)
}

// ── Slice 2: active-RFC view (§8) ─────────────────────────────────────────

export async function listModels(slug) {
  return jsonOrThrow(await fetch(`/api/rfcs/${slug}/models`))
}

export async function getRFCMain(slug, projectId, collectionId) {
  // §22/G-15: when the caller knows the entry's project + collection, read the
  // collection-scoped route so a slug that exists in two collections resolves
  // unambiguously and the entry's own content repo / subfolder is used. The
  // bare-slug form stays for back-compat (default-collection callers).
  if (projectId && collectionId) {
    return jsonOrThrow(await fetch(
      `/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}/main`
    ))
  }
  return jsonOrThrow(await fetch(`/api/rfcs/${slug}/main`))
}

export async function getBranch(slug, branch, projectId, collectionId) {
  // §22/G-15: collection-scoped branch-body read (the canonical-body GET); see
  // getRFCMain for the rationale. Falls back to the slug-only route.
  if (projectId && collectionId) {
    return jsonOrThrow(await fetch(
      `/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}/branches/${encodeURIComponent(branch)}`
    ))
  }
  return jsonOrThrow(await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}`
  ))
}

export async function promoteToBranch(slug, body = {}) {
  const res = await fetch(`/api/rfcs/${slug}/branches/main/promote-to-branch`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  return jsonOrThrow(res)
}

export async function acceptChange(slug, branch, changeId, { proposed, wasEdited, forceApplyStale }) {
  const res = await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/accept`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        proposed,
        was_edited_before_accept: !!wasEdited,
        force_apply_stale: !!forceApplyStale,
      }),
    },
  )
  return jsonOrThrow(res)
}

export async function declineChange(slug, branch, changeId) {
  const res = await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/decline`,
    { method: 'POST' },
  )
  return jsonOrThrow(res)
}

export async function reaskChange(slug, branch, changeId) {
  const res = await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/reask`,
    { method: 'POST' },
  )
  return jsonOrThrow(res)
}

export async function manualFlush(slug, branch, { newContent, paragraphCount }) {
  const res = await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/manual-flush`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ new_content: newContent, paragraph_count: paragraphCount }),
    },
  )
  return jsonOrThrow(res)
}

export async function setBranchVisibility(slug, branch, { readPublic, contributeMode }) {
  const res = await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/visibility`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        read_public: readPublic,
        contribute_mode: contributeMode,
      }),
    },
  )
  return jsonOrThrow(res)
}

export async function createThread(slug, branch, body) {
  const res = await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    },
  )
  return jsonOrThrow(res)
}

export async function listThreads(slug, branch) {
  return jsonOrThrow(await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`,
  ))
}

export async function getThreadMessages(slug, branch, threadId) {
  return jsonOrThrow(await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`,
  ))
}

export async function postThreadMessage(slug, branch, threadId, { text, quote }) {
  const res = await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text, quote }),
    },
  )
  return jsonOrThrow(res)
}

export async function resolveThread(slug, branch, threadId) {
  const res = await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/resolve`,
    { method: 'POST' },
  )
  return jsonOrThrow(res)
}

// ── v0.16.0: owner-only invite for per-RFC PR or PR-less discussion ──────
//
// roadmap item #12 / §6 / §10. The RFC's owner invites specific emails
// to one of two per-RFC roles ('contributor' or 'discussant'); the
// invitee accepts via the email-encoded token after signing in. The
// platform-level grant remains the admin's decision (per item #6 /
// v0.8.0) — these endpoints control per-RFC membership only.

export async function listRFCInvitations(slug) {
  return jsonOrThrow(await fetch(`/api/rfcs/${slug}/invitations`))
}

export async function createRFCInvitation(slug, { inviteeEmail, roleInRFC }) {
  const res = await fetch(`/api/rfcs/${slug}/invitations`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ invitee_email: inviteeEmail, role_in_rfc: roleInRFC }),
  })
  return jsonOrThrow(res)
}

export async function revokeRFCInvitation(slug, invitationId) {
  const res = await fetch(`/api/rfcs/${slug}/invitations/${invitationId}/revoke`, {
    method: 'POST',
  })
  return jsonOrThrow(res)
}

export async function previewInvitation(token) {
  const params = new URLSearchParams({ token })
  return jsonOrThrow(await fetch(`/api/invitations/accept?${params}`))
}

export async function acceptInvitation(token) {
  const res = await fetch('/api/invitations/accept', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token }),
  })
  return jsonOrThrow(res)
}

// ── v0.5.0: PR-less per-RFC discussion (§5 / §10) ────────────────────────
//
// The substrate is `threads.branch_name IS NULL` — the same threads
// table the branch chat uses, with a null branch the schema already
// supported. Contribution still requires a PR (api_prs / openPR), so
// these endpoints are read+write for discussion only.

export async function listDiscussionThreads(slug) {
  return jsonOrThrow(await fetch(`/api/rfcs/${slug}/discussion/threads`))
}

export async function createDiscussionThread(slug, { label = null, message = null } = {}) {
  const res = await fetch(`/api/rfcs/${slug}/discussion/threads`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ label, message }),
  })
  return jsonOrThrow(res)
}

export async function getDiscussionThreadMessages(slug, threadId) {
  return jsonOrThrow(await fetch(
    `/api/rfcs/${slug}/discussion/threads/${threadId}/messages`,
  ))
}

export async function postDiscussionMessage(slug, threadId, { text, quote = null }) {
  const res = await fetch(
    `/api/rfcs/${slug}/discussion/threads/${threadId}/messages`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text, quote }),
    },
  )
  return jsonOrThrow(res)
}

export async function resolveDiscussionThread(slug, threadId) {
  const res = await fetch(
    `/api/rfcs/${slug}/discussion/threads/${threadId}/resolve`,
    { method: 'POST' },
  )
  return jsonOrThrow(res)
}

// ── Slice 4: super-draft body editing (§9.5) ─────────────────────────────

export async function startEditBranch(slug, body = {}) {
  const res = await fetch(`/api/rfcs/${slug}/start-edit-branch`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  return jsonOrThrow(res)
}

export async function editMetadata(slug, { title, tags, prDescription }) {
  const res = await fetch(`/api/rfcs/${slug}/metadata`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      title: title ?? null,
      tags: tags ?? null,
      pr_description: prDescription ?? null,
    }),
  })
  return jsonOrThrow(res)
}

// §22.4a SLICE-4: edit one entry's schema-defined metadata — a direct commit
// to its `<slug>.meta.yaml` sidecar (contributor+ gated server-side).
export async function saveEntryMeta(projectId, collectionId, slug, values) {
  const res = await fetch(
    `/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}/meta`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ values }),
    },
  )
  return jsonOrThrow(res)
}

// §22.4a SLICE-5 (PUC-2): apply one field op (set | add | remove) to many
// entries at once — one commit server-side; returns { applied, rejected }.
export async function bulkEntryMeta(projectId, collectionId, { slugs, op, field, value }) {
  const res = await fetch(
    `/api/projects/${projectId}/collections/${collectionId}/meta/bulk`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ slugs, op, field, value }),
    },
  )
  return jsonOrThrow(res)
}

// ── Slice 5: §13 graduation + §13.1 claim ────────────────────────────────

export async function claimOwnership(slug) {
  const res = await fetch(`/api/rfcs/${slug}/claim`, { method: 'POST' })
  return jsonOrThrow(res)
}

export async function listBlockingPRs(slug) {
  return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`))
}

// §13.7 retire (soft delete). RFC owners + site owners may retire; only
// site owners may un-retire. The backend gates both regardless of UI.
export async function retireRFC(slug) {
  return jsonOrThrow(await fetch(`/api/rfcs/${slug}/retire`, { method: 'POST' }))
}

export async function unretireRFC(slug) {
  return jsonOrThrow(await fetch(`/api/rfcs/${slug}/unretire`, { method: 'POST' }))
}

// §13.7: the site-owner-only list of retired entries (for the admin
// "Retired" surface, where un-retire lives).
export async function listRetiredRFCs() {
  return jsonOrThrow(await fetch('/api/admin/retired-rfcs'))
}

export async function graduateCheck(slug, { id }) {
  // Meta-only topology (§13.2): two fields — integer id + owners. No
  // repo name to validate.
  const params = new URLSearchParams()
  if (id != null) params.set('id', id)
  return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`))
}

export async function startGraduation(slug, { rfcId, owners }) {
  const res = await fetch(`/api/rfcs/${slug}/graduate`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ rfc_id: rfcId, owners }),
  })
  return jsonOrThrow(res)
}

// Open an EventSource on the §13.3 progress stream. Returns the
// EventSource so the caller can close() on dialog dismiss. Calls
// onUpdate with the parsed state payload for every event.
export function openGraduationProgress(slug, { onUpdate, onDone, onError }) {
  const es = new EventSource(`/api/rfcs/${slug}/graduate/progress`)
  const handle = (e) => {
    try {
      const payload = JSON.parse(e.data)
      onUpdate?.(payload, e.type)
      if (e.type === 'done' && payload?.finished) onDone?.(payload)
    } catch (err) {
      onError?.(err)
    }
  }
  for (const name of ['snapshot', 'step', 'rollback_step', 'completed', 'rolled_back', 'done']) {
    es.addEventListener(name, handle)
  }
  es.onerror = (e) => { onError?.(e); es.close() }
  return es
}

// ── Slice 3: the §10 PR flow ─────────────────────────────────────────────

export async function draftPRText(slug, branch) {
  const res = await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/pr-draft`,
    { method: 'POST' },
  )
  return jsonOrThrow(res)
}

export async function openPR(slug, branch, { title, description, proposedUseCase }) {
  const res = await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      // #26: proposed_use_case is optional; null when blank.
      body: JSON.stringify({ title, description, proposed_use_case: proposedUseCase || null }),
    },
  )
  return jsonOrThrow(res)
}

export async function getPR(slug, prNumber) {
  return jsonOrThrow(await fetch(`/api/rfcs/${slug}/prs/${prNumber}`))
}

export async function advancePRSeen(slug, prNumber, { lastSeenCommitSha, lastSeenMessageId }) {
  const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/seen`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      last_seen_commit_sha: lastSeenCommitSha,
      last_seen_message_id: lastSeenMessageId,
    }),
  })
  return jsonOrThrow(res)
}

export async function postPRReview(slug, prNumber, { text, anchorPayload, quote }) {
  const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/review`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text, anchor_payload: anchorPayload || {}, quote: quote || null }),
  })
  return jsonOrThrow(res)
}

export async function mergePR(slug, prNumber) {
  const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/merge`, { method: 'POST' })
  return jsonOrThrow(res)
}

export async function withdrawPR(slug, prNumber) {
  const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/withdraw`, { method: 'POST' })
  return jsonOrThrow(res)
}

export async function editPRDescription(slug, prNumber, { title, description }) {
  const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/description`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, description }),
  })
  return jsonOrThrow(res)
}

export async function startResolutionBranch(slug, prNumber) {
  const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/resolution-branch`, {
    method: 'POST',
  })
  return jsonOrThrow(res)
}

// Stream a chat turn into a per-branch thread. Calls onChunk for each
// text fragment, onChanges when the trailing `changes` event arrives,
// and onDone at the terminal DONE marker. Returns the response headers
// (so the caller can pull X-Assistant-Message-Id without re-streaming).
export async function streamChatTurn(slug, branch, threadId, { text, quote, model }, { onChunk, onChanges, onDone }) {
  const res = await fetch(
    `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/chat`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text, quote, model }),
    },
  )
  if (!res.ok) {
    const detail = await res.text()
    throw new Error(`Chat failed: ${detail || res.status}`)
  }
  const assistantId = res.headers.get('X-Assistant-Message-Id')
  const userMsgId = res.headers.get('X-User-Message-Id')
  const reader = res.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''
  let currentEvent = null
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    buffer += decoder.decode(value, { stream: true })
    const parts = buffer.split('\n\n')
    buffer = parts.pop()
    for (const part of parts) {
      const lines = part.split('\n')
      let dataLine = null
      let event = null
      for (const line of lines) {
        if (line.startsWith('event: ')) event = line.slice(7).trim()
        if (line.startsWith('data: ')) dataLine = line.slice(6).trim()
      }
      if (dataLine === null) continue
      if (event === 'changes') {
        try { onChanges?.(JSON.parse(dataLine)) } catch {}
        continue
      }
      if (dataLine === 'DONE') { onDone?.(); break }
      try {
        const text = new TextDecoder().decode(
          Uint8Array.from(atob(dataLine), c => c.charCodeAt(0))
        )
        onChunk?.(text)
      } catch {
        // partial chunk
      }
    }
  }
  onDone?.()
  return { assistantId, userMsgId }
}

// ---------------------------------------------------------------------------
// §15 / Slice 6: notifications surface
// ---------------------------------------------------------------------------

export async function listNotifications({ unread, rfcSlug, category, actorUserId, bundled } = {}) {
  const params = new URLSearchParams()
  if (unread) params.set('unread', '1')
  if (rfcSlug) params.set('rfc_slug', rfcSlug)
  if (category) params.set('category', category)
  if (actorUserId) params.set('actor_user_id', actorUserId)
  if (bundled) params.set('bundled', '1')
  const qs = params.toString()
  return jsonOrThrow(await fetch(`/api/notifications${qs ? `?${qs}` : ''}`))
}

export async function markNotificationRead(id) {
  return jsonOrThrow(await fetch(`/api/notifications/${id}/read`, { method: 'POST' }))
}

export async function markNotificationsReadByFilter(filter) {
  return jsonOrThrow(await fetch('/api/notifications/read', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(filter || {}),
  }))
}

export async function listWatches() {
  return jsonOrThrow(await fetch('/api/watches'))
}

export async function setWatch(slug, state) {
  return jsonOrThrow(await fetch(`/api/rfcs/${slug}/watch`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ state }),
  }))
}

export async function getNotificationPreferences() {
  return jsonOrThrow(await fetch('/api/users/me/notification-preferences'))
}

export async function setNotificationPreferences(prefs) {
  return jsonOrThrow(await fetch('/api/users/me/notification-preferences', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(prefs),
  }))
}

export async function getQuietHours() {
  return jsonOrThrow(await fetch('/api/users/me/quiet-hours'))
}

export async function setQuietHours({ start, end, timezone } = {}) {
  return jsonOrThrow(await fetch('/api/users/me/quiet-hours', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ start: start || null, end: end || null, timezone: timezone || null }),
  }))
}

// v0.13.0 / roadmap item #11: cookie consent (SPEC §14.5).
export async function getCookieConsent() {
  return jsonOrThrow(await fetch('/api/users/me/cookie-consent'))
}

export async function setCookieConsent({ analytics, other } = {}) {
  return jsonOrThrow(await fetch('/api/users/me/cookie-consent', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      essential: true,
      analytics: !!analytics,
      other: !!other,
    }),
  }))
}

export async function muteUser(userId) {
  return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
}

export async function unmuteUser(userId) {
  return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'DELETE' }))
}

export async function advanceChatSeen(slug, branch, lastSeenMessageId) {
  return jsonOrThrow(await fetch(`/api/rfcs/${slug}/branches/${branch}/chat-seen`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ last_seen_message_id: lastSeenMessageId || null }),
  }))
}

// ---------------------------------------------------------------------------
// §14.2 / Slice 7: PHILOSOPHY.md surface
// ---------------------------------------------------------------------------

export async function getPhilosophy() {
  return jsonOrThrow(await fetch('/api/philosophy'))
}

export async function getDocs() {
  return jsonOrThrow(await fetch('/api/docs'))
}

// ---------------------------------------------------------------------------
// v0.19.0 / roadmap item #30 — /api/docs/sessions/* surface
// ---------------------------------------------------------------------------
//
// The framework mediates reads against the public
// `wiggleverse/ohm-session-history` gitea repo so the rendered
// `/docs/sessions/*` surface inherits the same chrome as
// `/docs/user-guide`. Three text-bearing endpoints return markdown
// (Content-Type: text/markdown) and the manifest returns JSON. We
// wrap each into a small helper.
//
// 404 from `getSessionAbout` / `getSessionTranscript` / `getSessionIndex`
// throws an Error with `.status === 404` so the UI can render its own
// empty-state. 502 (gitea unreachable) throws `.status === 502` so
// the UI can offer a retry button.

export async function getSessionsManifest() {
  // Manifest 404 is mapped server-side to HTTP 200 + `{}` so this
  // helper never throws on the empty-state path.
  return jsonOrThrow(await fetch('/api/docs/sessions/manifest'))
}

async function _textOrThrow(res) {
  if (!res.ok) {
    let detail = ''
    try {
      const body = await res.json()
      detail = body.detail || JSON.stringify(body)
    } catch {
      detail = await res.text()
    }
    const error = new Error(detail || `HTTP ${res.status}`)
    error.status = res.status
    throw error
  }
  return res.text()
}

export async function getSessionsAbout() {
  return _textOrThrow(await fetch('/api/docs/sessions/about'))
}

export async function getSessionTranscript(nnnn, filename) {
  return _textOrThrow(await fetch(
    `/api/docs/sessions/${encodeURIComponent(nnnn)}/${encodeURIComponent(filename)}`
  ))
}

export async function getSessionIndex(nnnn) {
  return jsonOrThrow(await fetch(
    `/api/docs/sessions/${encodeURIComponent(nnnn)}/index`
  ))
}

// ---------------------------------------------------------------------------
// v0.20.0 — /api/docs/specs/* surface
// ---------------------------------------------------------------------------
//
// Sibling of the docs-sessions helpers above. The framework mediates
// reads against the configured spec URLs (default: rfc-app's own
// SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the
// `/docs/specs/*` route inherits the same chrome as `/docs/user-guide`
// and `/docs/sessions/*`. The manifest endpoint always returns 200 +
// {specs: [...]} — a malformed `OHM_DOCS_SPECS` env var falls back to
// the framework default at parse time on the backend.
//
// 404 from `getSpec` throws `.status === 404`; 502 throws `.status === 502`,
// matching the docs-sessions helper convention.

export async function getSpecsManifest() {
  return jsonOrThrow(await fetch('/api/docs/specs/manifest'))
}

export async function getSpec(name) {
  return _textOrThrow(await fetch(
    `/api/docs/specs/${encodeURIComponent(name)}`
  ))
}

// ---------------------------------------------------------------------------
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
// typeahead).
// ---------------------------------------------------------------------------

export async function listAdminUsers() {
  return jsonOrThrow(await fetch('/api/admin/users'))
}

export async function setUserRole(userId, role) {
  return jsonOrThrow(await fetch(`/api/admin/users/${userId}/role`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ role }),
  }))
}

export async function setUserMute(userId, muted) {
  return jsonOrThrow(await fetch(`/api/admin/users/${userId}/mute`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ muted }),
  }))
}

// v0.9.0 — roadmap item #7. Flip a user's permission_state between
// 'pending', 'granted', and 'revoked'. The Users tab on the admin
// page wires Grant / Revoke buttons against this endpoint; the
// returned `changed` flag is false when the requested state already
// matched the row.
export async function setUserPermission(userId, state) {
  return jsonOrThrow(await fetch(`/api/admin/users/${userId}/permission`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ state }),
  }))
}

export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
  const params = new URLSearchParams()
  if (actionKind) params.set('action_kind', actionKind)
  if (actorUserId != null) params.set('actor_user_id', actorUserId)
  if (rfcSlug) params.set('rfc_slug', rfcSlug)
  if (beforeId != null) params.set('before_id', beforeId)
  if (limit != null) params.set('limit', limit)
  const qs = params.toString()
  return jsonOrThrow(await fetch(`/api/admin/audit${qs ? `?${qs}` : ''}`))
}

export async function listPermissionEvents({ beforeId, limit } = {}) {
  const params = new URLSearchParams()
  if (beforeId != null) params.set('before_id', beforeId)
  if (limit != null) params.set('limit', limit)
  const qs = params.toString()
  return jsonOrThrow(await fetch(`/api/admin/permission-events${qs ? `?${qs}` : ''}`))
}

export async function listGraduationQueue() {
  return jsonOrThrow(await fetch('/api/admin/graduation-queue'))
}

export async function listAllowlist() {
  return jsonOrThrow(await fetch('/api/admin/allowlist'))
}

export async function addAllowlistEmail(email, note) {
  return jsonOrThrow(await fetch('/api/admin/allowlist', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, note: note || null }),
  }))
}

export async function removeAllowlistEmail(email) {
  return jsonOrThrow(await fetch(`/api/admin/allowlist/${encodeURIComponent(email)}`, {
    method: 'DELETE',
  }))
}

// v0.17.0 — roadmap item #16. Admin-create user + invite email with
// optional custom message. The frontend modal on /admin/users wires
// these two helpers; the claim helper drives the /invites/claim page
// that the invitee lands on when they click the email link.
//
// `createUserInvite` returns `{ ok, invite_id, invited_user_id, email,
// role }`. The 409 path (duplicate email) and 422 path (self-invite,
// owner-grant-by-non-owner, malformed input) surface as thrown errors
// via `jsonOrThrow` so the modal can render the server's message.
//
// `listUserInvites` returns the active-invites list for the admin's
// "I sent these but they haven't been claimed yet" view. Active means
// not claimed and not expired; once the invitee clicks through, the
// row clears here and the user-listing's `pending_invite` badge
// vanishes alongside.

export async function createUserInvite({ email, first_name, last_name, role, custom_message }) {
  return jsonOrThrow(await fetch('/api/admin/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email,
      first_name: first_name || '',
      last_name: last_name || '',
      role,
      custom_message: custom_message || '',
    }),
  }))
}

export async function listUserInvites() {
  return jsonOrThrow(await fetch('/api/admin/users/invites'))
}

// Claim an admin-issued invite token. Anonymous endpoint — the invitee
// is not yet signed in; this call establishes the session on success.
// `trustDevice` mirrors the v0.11.0 OTC/passcode opt-in: when true,
// the server mints a fresh device-trust row + sets the long-lived
// cookie so the invitee skips OTC on their next visit.
export async function claimInvite(token, { trustDevice = false } = {}) {
  return jsonOrThrow(await fetch('/api/invites/claim', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token, trust_device: !!trustDevice }),
  }))
}

export async function searchUsers(q) {
  const params = new URLSearchParams()
  if (q) params.set('q', q)
  return jsonOrThrow(await fetch(`/api/users/search?${params}`))
}

// SSE subscription helper. Returns a close() function. The handler
// surface mirrors §15.3: a snapshot event on open, then per-notification
// `notification` events, plus `read` events when another tab marks a row.
export function subscribeToNotifications({ onSnapshot, onNotification, onRead, onError } = {}) {
  const source = new EventSource('/api/notifications/stream')
  source.addEventListener('snapshot', e => {
    try { onSnapshot?.(JSON.parse(e.data)) } catch {}
  })
  source.addEventListener('notification', e => {
    try { onNotification?.(JSON.parse(e.data)) } catch {}
  })
  source.addEventListener('read', e => {
    try { onRead?.(JSON.parse(e.data)) } catch {}
  })
  source.onerror = err => { onError?.(err) }
  return () => source.close()
}

