// Thin fetch wrapper. Always sends cookies (httpOnly JWT) and talks to the
// same-origin API (/api/v1) — proxied to the Express server in dev.
const BASE = '/api/v1'

// Prefix a non-empty query string with "?" (and nothing when it is empty), so
// callers can append it to a path without a dangling "?".
const withQs = (s) => (s ? `?${s}` : '')

class ApiError extends Error {
  constructor(status, message, body) {
    super(message)
    this.status = status
    this.body = body
  }
}

async function req(path, { method = 'GET', body, headers, raw } = {}) {
  const opts = { method, credentials: 'include', headers: { ...headers } }
  if (body !== undefined) {
    if (raw) {
      opts.body = body // FormData — let the browser set the content-type
    } else {
      opts.headers['Content-Type'] = 'application/json'
      opts.body = JSON.stringify(body)
    }
  }
  const res = await fetch(BASE + path, opts)
  const text = await res.text()
  const data = text ? safeParse(text) : null
  if (!res.ok) {
    const message = (data && data.message) || res.statusText || 'Request failed'
    throw new ApiError(res.status, message, data)
  }
  return data
}

function safeParse(text) {
  try {
    return JSON.parse(text)
  } catch {
    return text
  }
}

// The request PRIMITIVE, exported for installed modules and handed to them on
// `window.__rg.api` (docs/website/MODULE_API.md §3.5). Core owns the fetch
// semantics — same-origin /api/v1, cookies included, JSON in and out, ApiError
// on a non-2xx — and nothing above them: a module owns the paths it calls,
// because it owns the routes at the other end.
//
// The `api` object below is core's own binding surface and nothing else: every
// namespace in it belongs to a route core still serves. A module binds its own
// paths in its own chunk, against this primitive.
// `BASE` goes with it: a module that needs an EventSource URL cannot go through
// `req` (fetch-only) and must not hardcode `/api/v1`, which is core's choice of
// mount point and not a promise it has made.
export { req as request, BASE }

export const api = {
  // ----- auth -----
  me: () => req('/auth/me'),
  // `extra` carries the honeypot field (and any future login fields).
  login: (username, password, extra = {}) =>
    req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
  // Public self-registration (player accounts). `extra` carries the honeypot +
  // optional email. Returns { user } and sets the session cookie on success.
  register: (username, password, extra = {}) =>
    req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
  // Email invites (public, token-gated accept).
  getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
  acceptInvite: (token, username, password, extra = {}) =>
    req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
  // Second factor for web login. `extra` carries the optional recoveryCode (an
  // alternative to code) and the trustDevice/deviceName opt-in. On success the
  // response may include { trustLimitReached, devices } when trust was requested
  // but the device cap is reached.
  loginTotp: (challenge, code, extra = {}) =>
    req('/auth/login/totp', { method: 'POST', body: { challenge, code, ...extra } }),
  // Self-service password reset (public, token-gated). forgot always resolves the
  // same way whether or not the email exists (no enumeration); getPasswordReset
  // validates a link (200 → { username }, 404 → invalid/expired); resetPassword
  // sets the new password and revokes all sessions (the user then signs in fresh).
  forgotPassword: (email) => req('/auth/password/forgot', { method: 'POST', body: { email } }),
  getPasswordReset: (token) => req(`/auth/password/reset/${encodeURIComponent(token)}`),
  resetPassword: (token, password) =>
    req(`/auth/password/reset/${encodeURIComponent(token)}`, { method: 'POST', body: { password } }),
  // Second factor for an SSO login (challenge is held in an httpOnly cookie set by
  // the callback, so only the code is sent). `extra` carries the trustDevice/
  // deviceName opt-in, same as the password path. Returns { user, returnTo } — plus
  // { trustLimitReached, devices } when trust was asked for but the cap is reached.
  ssoLoginTotp: (code, extra = {}) => req('/auth/sso/totp', { method: 'POST', body: { code, ...extra } }),
  logout: () => req('/auth/logout', { method: 'POST' }),
  // Public SSO provider discovery — drives the login-page provider buttons.
  authProviders: () => req('/auth/providers'),
  // Active mobile device sessions (role-agnostic self-service under /auth/me).
  // List the active ones and revoke a single device by its session id.
  mySessions: () => req('/auth/me/sessions'),
  revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
  // Trusted devices (MFA "Trust this device"), role-agnostic under /auth/me. These
  // are the browsers/apps allowed to skip the TOTP step at login (distinct from
  // mySessions, which are live mobile login sessions).
  myTrustedDevices: () => req('/auth/me/trusted-devices'),
  trustThisDevice: (deviceName) =>
    req('/auth/me/trusted-devices', { method: 'POST', body: { deviceName } }),
  revokeTrustedDevice: (id) =>
    req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
  revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
  // Recovery (backup) codes. status → remaining count; generate → a fresh set,
  // returned ONCE (password step-up for accounts that have a password).
  recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
  generateRecoveryCodes: (currentPassword) =>
    req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),

  // ----- settings (any authenticated account) -----
  // Nav overrides for the layouts the caller's own role renders, and the theme
  // catalog the appearance form is built from. A fifth group, not part of
  // /admin, because AdminLayout renders for editors and moderators too — see
  // docs/website/THEMING_AND_NAV.md §4.2.
  navSettings: () => req('/settings/nav'),
  themeOptions: () => req('/settings/theme/options'),

  // ----- public -----
  publicSettings: () => req('/public/settings'),
  status: () => req('/public/status'),
  posts: (category) => req(`/public/posts/${category}`),
  post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`),
  wiki: (opts = {}) => {
    const qs = new URLSearchParams()
    if (opts.category) qs.set('category', opts.category)
    if (opts.tag) qs.set('tag', opts.tag)
    if (opts.q) qs.set('q', opts.q)
    const s = qs.toString()
    return req(`/public/wiki${withQs(s)}`)
  },
  wikiCategories: () => req('/public/wiki/categories'),
  wikiTags: () => req('/public/wiki/tags'),
  wikiPage: (slug) => req(`/public/wiki/${slug}`),
  // CMS pages (block-based). Published-only for the public; a draft-preview link
  // is fetched by id + token.
  page: (slug) => req(`/public/pages/${slug}`),
  pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
  contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),

  // ----- admin -----
  admin: {
    dashboard: () => req('/admin/dashboard'),
    setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }),
    listPosts: (category) => {
      const q = category ? `category=${category}` : ''
      return req(`/admin/posts${withQs(q)}`)
    },
    getPost: (id) => req(`/admin/posts/${id}`),
    createPost: (data) => req('/admin/posts', { method: 'POST', body: data }),
    updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }),
    deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
    publishPost: (id, published) =>
      req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
    // News announcement pipeline (town crier + Discord) status + per-leg retry.
    getAnnounce: (id) => req(`/admin/posts/${id}/announce`),
    retryAnnounceLeg: (id, leg) =>
      req(`/admin/posts/${id}/announce/retry`, { method: 'POST', body: { leg } }),
    uploadImage: (file) => {
      const fd = new FormData()
      fd.append('image', file)
      return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true })
    },
    // Generalized upload for rich-text editors → { url }.
    upload: (file) => {
      const fd = new FormData()
      fd.append('image', file)
      return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
    },
    // ----- CMS pages (block-based page builder) -----
    listPages: () => req('/admin/pages'),
    getPage: (id) => req(`/admin/pages/${id}`),
    createPage: (data) => req('/admin/pages', { method: 'POST', body: data }),
    updatePage: (id, data) => req(`/admin/pages/${id}`, { method: 'PATCH', body: data }),
    deletePage: (id) => req(`/admin/pages/${id}`, { method: 'DELETE' }),
    unprotectPage: (id, password) =>
      req(`/admin/pages/${id}/unprotect`, { method: 'POST', body: { password } }),
    createPagePreview: (id) => req(`/admin/pages/${id}/preview`, { method: 'POST' }),
    listWiki: (params = '') => req(`/admin/wiki${params}`),
    getWiki: (slug) => req(`/admin/wiki/${slug}`),
    createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
    updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', body: data }),
    publishWiki: (slug, published) =>
      req(`/admin/wiki/${slug}/publish`, { method: 'PATCH', body: { published } }),
    deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }),
    listWikiRevisions: (slug) => req(`/admin/wiki/${slug}/revisions`),
    getWikiRevision: (slug, id) => req(`/admin/wiki/${slug}/revisions/${id}`),
    restoreWikiRevision: (slug, id) =>
      req(`/admin/wiki/${slug}/revisions/${id}/restore`, { method: 'POST' }),
    listWikiTags: () => req('/admin/wiki/tags'),
    listWikiCategories: () => req('/admin/wiki/categories'),
    createWikiCategory: (data) => req('/admin/wiki/categories', { method: 'POST', body: data }),
    updateWikiCategory: (id, data) =>
      req(`/admin/wiki/categories/${id}`, { method: 'PUT', body: data }),
    deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
    getSettings: () => req('/admin/settings'),
    updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
    // Reset one setting to its default by deleting the row — the theming/nav
    // keys and the hero draft only (the server holds the allowlist). Idempotent,
    // so the caller need not know whether a row exists.
    resetSetting: (key) => req(`/admin/settings/${encodeURIComponent(key)}`, { method: 'DELETE' }),
    // Upload one brand asset (logo | hero | favicon) and set it as the override
    // in the same call → { url, brand_assets }. A separate endpoint from the
    // generic upload above because the server applies per-slot rules (favicons
    // are PNG-only and capped small) and writes the settings row itself, so an
    // upload never leaves a file nothing points at.
    uploadBrandAsset: (slot, file) => {
      const fd = new FormData()
      fd.append('image', file)
      return req(`/admin/settings/brand-asset/${encodeURIComponent(slot)}`, { method: 'POST', body: fd, raw: true })
    },
    activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
    botActivity: () => req('/admin/bot-activity'),
    unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
    listUsers: () => req('/admin/users'),
    getUser: (id) => req(`/admin/users/${id}`),
    createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
    updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
    deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
    // A user's trusted devices + MFA reset (admin only).
    userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
    revokeUserTrustedDevice: (id, deviceId) =>
      req(`/admin/users/${id}/trusted-devices/${deviceId}`, { method: 'DELETE' }),
    revokeAllUserTrustedDevices: (id) =>
      req(`/admin/users/${id}/trusted-devices`, { method: 'DELETE' }),
    resetUserMfa: (id) => req(`/admin/users/${id}/mfa/reset`, { method: 'POST' }),
    // Email invites.
    listInvites: () => req('/admin/invites'),
    createInvite: (email, role, sendEmail = true) =>
      req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
    revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),

    // Installed modules (MODULE_SYSTEM.md §2.7.2). `uninstallModule`'s purge flag
    // is a query parameter rather than a body because it hangs off a DELETE, and
    // it is spelled out at the call site rather than defaulted, so the
    // destructive branch is never the one you get by forgetting an argument.
    listModules: () => req('/admin/modules'),
    installModule: (url) => req('/admin/modules', { method: 'POST', body: { url } }),
    enableModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
    disableModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
    uninstallModule: (id, { purge } = {}) =>
      req(`/admin/modules/${encodeURIComponent(id)}${purge ? '?purge=true' : ''}`, { method: 'DELETE' }),
    purgeModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/purge`, { method: 'POST' }),
    setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
    restartServer: () => req('/admin/modules/restart', { method: 'POST' }),

    // ----- moderation dashboard (admin + moderator) -----
    modSummary: () => req('/admin/moderation/stats/summary'),
    modRecent: (params = {}) => {
      const qs = new URLSearchParams()
      if (params.type) qs.set('type', params.type)
      if (params.limit) qs.set('limit', params.limit)
      if (params.offset) qs.set('offset', params.offset)
      const s = qs.toString()
      return req(`/admin/moderation/recent${withQs(s)}`)
    },
    modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
    modMembers: (params = {}) => {
      const qs = new URLSearchParams()
      if (params.type) qs.set('type', params.type)
      if (params.limit) qs.set('limit', params.limit)
      if (params.offset) qs.set('offset', params.offset)
      const s = qs.toString()
      return req(`/admin/moderation/members${withQs(s)}`)
    },
    modFilterHits: (params = {}) => {
      const qs = new URLSearchParams()
      if (params.limit) qs.set('limit', params.limit)
      if (params.offset) qs.set('offset', params.offset)
      const s = qs.toString()
      return req(`/admin/moderation/filter-hits${withQs(s)}`)
    },
    modSpamHits: (params = {}) => {
      const qs = new URLSearchParams()
      if (params.limit) qs.set('limit', params.limit)
      if (params.offset) qs.set('offset', params.offset)
      const s = qs.toString()
      return req(`/admin/moderation/spam-hits${withQs(s)}`)
    },
    modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
    modUserActions: (discordId, params = {}) => {
      const qs = new URLSearchParams()
      if (params.type) qs.set('type', params.type)
      if (params.limit) qs.set('limit', params.limit)
      if (params.offset) qs.set('offset', params.offset)
      const s = qs.toString()
      return req(`/admin/moderation/user/${discordId}/actions${withQs(s)}`)
    },
    modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
    addModNote: (discordId, data) =>
      req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),

    // ----- moderation appeals (admin + moderator) -----
    getAppeals: (params = {}) => {
      const qs = new URLSearchParams()
      if (params.status) qs.set('status', params.status)
      if (params.limit) qs.set('limit', params.limit)
      if (params.offset) qs.set('offset', params.offset)
      const s = qs.toString()
      return req(`/admin/moderation/appeals${withQs(s)}`)
    },
    getAppeal: (id) => req(`/admin/moderation/appeals/${id}`),
    claimAppeal: (id) => req(`/admin/moderation/appeals/${id}/claim`, { method: 'POST' }),
    resolveAppeal: (id, data) =>
      req(`/admin/moderation/appeals/${id}/resolve`, { method: 'POST', body: data }),
    getUserAppeals: (discordId) => req(`/admin/moderation/user/${discordId}/appeals`),

    // ----- account security (self-service 2FA) -----
    getAccount: () => req('/admin/account'),
    totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
    totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
    totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),

    // ----- linked SSO identities (self-service) -----
    linkedIdentities: () => req('/admin/account/identities'),
    unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),

    // ----- auth providers / SSO config (admin only) -----
    listAuthProviders: () => req('/admin/auth/providers'),
    createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
    updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
    deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }),

    // ----- Discord bot control (admin only) -----
    getDiscordBotConfig: () => req('/admin/discord-bot/config'),
    saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),

    // ----- Email delivery / Gmail OAuth2 (admin only) -----
    getEmailConfig: () => req('/admin/email/config'),
    saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
    emailConnectUrl: () => req('/admin/email/connect/start'),
    testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
    disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
  },

  // ----- player self-service (role: 'player') -----
  // Mirrors the admin account methods but self-scoped under /player. The change
  // endpoints re-issue the session cookie server-side, so the caller stays signed in.
  player: {
    getAccount: () => req('/player/account'),
    changeUsername: (username) =>
      req('/player/account/username', { method: 'PATCH', body: { username } }),
    changePassword: (newPassword, currentPassword) =>
      req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
    totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
    totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
    totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
    linkedIdentities: () => req('/player/account/identities'),
    unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),

    // ----- moderation appeals (self-service) -----
    getMyAppeals: () => req('/player/appeals'),
    getEligibleAppeals: () => req('/player/appeals/eligible'),
    submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
    withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
  },
}

export { ApiError }
