// 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
  }
}

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 }),

  // ----- shard live data (uo-link) -----
  // Token-free, same-origin reads backed by the ingested feed + a cached live
  // character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed.
  shard: {
    status: () => req('/public/shard/status'),
    feed: (opts = {}) => {
      const qs = new URLSearchParams()
      if (opts.kind) qs.set('kind', opts.kind)
      if (opts.limit) qs.set('limit', opts.limit)
      const s = qs.toString()
      return req(`/public/shard/feed${withQs(s)}`)
    },
    economy: (limit) => {
      const q = limit ? `limit=${limit}` : ''
      return req(`/public/shard/economy${withQs(q)}`)
    },
    online: () => req('/public/shard/online'),
    idoc: () => req('/public/shard/idoc'),
    champs: () => req('/public/shard/champs'),
    // Protocol 2.0 boards.
    guilds: () => req('/public/shard/guilds'),
    governors: () => req('/public/shard/governors'),
    governorHistory: (city, limit) => {
      const q = limit ? `limit=${limit}` : ''
      return req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(q)}`)
    },
    presence: () => req('/public/shard/presence'),
    houses: () => req('/public/shard/houses'),
    // Protocol 3.0: the shard's published ruleset. Resolves to null when the
    // shard has never published one — a real answer, not an error.
    ruleset: () => req('/public/shard/ruleset'),
    // Protocol 3.0: points/loyalty leaderboards, one board per point system.
    // `board` 404s for a system the shard has never published.
    points: () => req('/public/shard/points'),
    pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
    // Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
    // the page debounces its search box rather than firing per keystroke.
    market: (opts = {}) => {
      const qs = new URLSearchParams()
      if (opts.q) qs.set('q', opts.q)
      if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
      if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
      if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
      if (opts.map) qs.set('map', opts.map)
      if (opts.region) qs.set('region', opts.region)
      if (opts.sort) qs.set('sort', opts.sort)
      if (opts.limit) qs.set('limit', opts.limit)
      if (opts.offset) qs.set('offset', opts.offset)
      return req(`/public/shard/market${withQs(qs.toString())}`)
    },
    marketMeta: () => req('/public/shard/market/meta'),
    marketVendor: (serial, opts = {}) => {
      const qs = new URLSearchParams()
      if (opts.limit) qs.set('limit', opts.limit)
      if (opts.offset) qs.set('offset', opts.offset)
      return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
    },
    // Which shard surfaces this caller may reach, plus the audience rung they
    // resolved to. Drives nav so we never render a link that would 403.
    features: () => req('/public/shard/features'),
  },

  // ----- spawn atlas (Protocol 3.0 Part C) -----
  // Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
  // not under /shard, because nothing here depends on the sidecar and the pages
  // stay populated while the shard is offline.
  atlas: {
    creatures: (opts = {}) => {
      const qs = new URLSearchParams()
      if (opts.q) qs.set('q', opts.q)
      if (opts.facet) qs.set('facet', opts.facet)
      if (opts.limit) qs.set('limit', opts.limit)
      if (opts.offset) qs.set('offset', opts.offset)
      return req(`/public/atlas/creatures${withQs(qs.toString())}`)
    },
    creature: (slug, opts = {}) => {
      const qs = new URLSearchParams()
      if (opts.facet) qs.set('facet', opts.facet)
      if (opts.points) qs.set('points', opts.points)
      return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
    },
    regions: (opts = {}) => {
      const qs = new URLSearchParams()
      if (opts.facet) qs.set('facet', opts.facet)
      if (opts.q) qs.set('q', opts.q)
      return req(`/public/atlas/regions${withQs(qs.toString())}`)
    },
    landmarks: (opts = {}) => {
      const qs = new URLSearchParams()
      if (opts.facet) qs.set('facet', opts.facet)
      if (opts.q) qs.set('q', opts.q)
      return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
    },
    // The CONFIGURED altar roster, not the live board — see shard.champs() for
    // "which spawn is on level 3 right now".
    champions: (facet) => req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
    meta: () => req('/public/atlas/meta'),
  },
  // Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
  // fetch-only, so SSE subscribers build the URL from here. The admin stream
  // carries every kind (incl. audit/cheat) and needs the staff session cookie.
  shardStreamUrl: `${BASE}/public/shard/stream`,
  adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,

  // ----- 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' }),
    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' }),
    // A single user's shard (uo-link) footprint, scoped to their linked accounts.
    // accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
    // reuse the admin-bypass /admin/shard/* endpoints (which already read any
    // account) so the shared GameAccounts component works unchanged.
    userShard: (id) => ({
      accounts: () => req(`/admin/users/${id}/shard/accounts`),
      roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
      vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
      char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
      sales: () => req(`/admin/users/${id}/shard/sales`),
      houses: () => req(`/admin/users/${id}/shard/houses`),
      online: () => req(`/admin/users/${id}/shard/online`),
      standing: () => req(`/admin/users/${id}/shard/standing`),
      unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
    }),

    // ----- 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' }),

    // ----- game account linking (self-service, staff) -----
    shard: {
      link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
      accounts: () => req('/admin/shard/accounts'),
      roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
      vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
      char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
      sales: () => req('/admin/shard/sales'),
      houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
      createAccount: (account, password) =>
        req('/admin/shard/account', { method: 'POST', body: { account, password } }),
    },

    // ----- 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 }),

    // ----- uo-link sidecar control (admin only) -----
    getUoLinkConfig: () => req('/admin/uo-link/config'),
    saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
    postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
    deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
    // Per-feature shard visibility: who may see which shard surface, and which
    // sensitive fields within it. Admin only — it decides what ANONYMOUS
    // visitors get. acct/webId are admin-only always and the API rejects any
    // attempt to configure them.
    getShardVisibility: () => req('/admin/shard/visibility'),
    saveShardVisibility: (features) =>
      req('/admin/shard/visibility', { method: 'PUT', body: { features } }),

    // ----- spawn atlas operation (admin only) -----
    // The atlas re-derives itself from the ServUO tree on every boot; these are
    // for applying a map change without a restart, and for the approve/reject
    // decision on a refresh that would remove a facet.
    atlas: {
      status: () => req('/admin/shard/atlas'),
      import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
      approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
      reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
      setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
    },

    // ----- in-game staff operations: write plane + support queue (admin/moderator) -----
    // `actor` is stamped server-side from the session — never sent from here.
    shardOps: {
      kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
      ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
      unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
      broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
      pages: () => req('/admin/shard/pages'),
      respondPage: (id, data) =>
        req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
      closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
      audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
    },

    // ----- 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' }),

    // ----- game account linking (uo-link) -----
    shard: {
      link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
      accounts: () => req('/player/shard/accounts'),
      roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
      vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
      char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
      sales: () => req('/player/shard/sales'),
      houses: () => req('/player/shard/houses'), // the caller's own houses
      createAccount: (account, password) =>
        req('/player/shard/account', { method: 'POST', body: { account, password } }),
    },

    // ----- 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 }
