// ── OpenAPI spec generator (swagger-autogen) ───────────────────────────────
//
// Static-analyzes the Express routers and emits `swagger-output.json`, which is
// served by swagger-ui-express at /api/docs (see src/app.js). Per-endpoint
// details — tags, summaries, parameters, request bodies, security and response
// codes — live as `#swagger.*` comments next to each route in
// src/router/**. This file supplies everything shared: API metadata, servers,
// tag descriptions, the two auth schemes (session cookie + mobile bearer), and
// the reusable component schemas the annotations reference.
//
// Regenerate with:  npm run swagger   (from the server/ directory)
// The generated JSON is committed so the docs work without a build step.

const fs = require('fs')

const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
const pkg = require('../package.json')
const brand = require('../src/config/brand')

// Cookie name is env-configurable (COOKIE_NAME); the spec documents whatever this
// build targets. This is a build-time artifact — regenerate with `npm run swagger`.
const COOKIE_NAME = process.env.COOKIE_NAME || 'rg_token'

const outputFile = './swagger/swagger-output.json'

// Entry point of the routing graph. swagger-autogen follows the `app.use(...)`
// mount chain from here (/api → /v1 → auth|public|admin), so generated paths are
// fully-qualified (e.g. /api/v1/auth/login).
const routes = ['./src/app.js']

const doc = {
  info: {
    title: `${brand.name} API`,
    version: pkg.version,
    description:
      `REST API for the ${brand.name} website, wiki and admin panel.\n\n` +
      'This document is core. Installed modules add their own paths, tags and ' +
      'schemas to it at request time from the fragment each one ships, so ' +
      '`/api/docs.json` on a running instance describes more than `npm run swagger` ' +
      'generates here (docs/website/MODULE_API.md §6.1a).\n\n' +
      '### Authentication\n' +
      `- **Web / admin panel** uses an httpOnly session cookie (\`${COOKIE_NAME}\`) issued by ` +
      '`POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n' +
      '- **Native / mobile clients** use bearer access tokens from ' +
      '`POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\n' +
      'Endpoints under `/api/v1/admin/**` require a valid session; some are further ' +
      'restricted to the `admin` role (editors are limited to content).',
  },
  servers: [
    { url: '/', description: 'Same-origin (current host)' },
    { url: 'http://localhost:3000', description: 'Local development' },
  ],
  // Core's tags only. A module contributes its own in its fragment, and they are
  // merged in beside these — the four game-specific ones that used to sit here
  // (`Public · Shard`, `Public · Atlas`, `Player · Shard`, `Admin · Shard`) went
  // with the routes they group, and arrive back from module-uo on any instance
  // that has it installed.
  tags: [
    { name: 'Health', description: 'Liveness probe' },
    { name: 'Auth', description: 'Web session login/logout (cookie + TOTP)' },
    { name: 'Auth · Me', description: 'The signed-in account: profile, notification streams and devices' },
    { name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' },
    { name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
    { name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' },
    { name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' },
    { name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' },
    { name: 'Player · Appeals', description: 'Player-submitted moderation appeals' },
    { name: 'Settings', description: 'Site-wide settings any authenticated account may read (nav overrides)' },
    { name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
    { name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
    { name: 'Admin · Pages', description: 'Editable static site pages' },
    { name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
    { name: 'Admin · Settings', description: 'Site settings (admin only)' },
    { name: 'Admin · Email', description: 'Outbound email configuration and delivery test (admin only)' },
    { name: 'Admin · Invites', description: 'Registration invites — issue, list and revoke' },
    { name: 'Admin · Moderation', description: 'Player reports, appeals and moderator actions' },
    { name: 'Admin · Activity', description: 'Admin activity log' },
    { name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' },
    { name: 'Admin · Discord Bot', description: 'Discord bot token/config and live status (admin only)' },
    { name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' },
    { name: 'Admin · Users', description: 'User management (admin only)' },
  ],
  components: {
    securitySchemes: {
      // Web/admin session — httpOnly cookie set by the login endpoints.
      cookieAuth: {
        type: 'apiKey',
        in: 'cookie',
        name: COOKIE_NAME,
        description: 'Session JWT set as an httpOnly cookie by POST /api/v1/auth/login.',
      },
      // Native/mobile clients — Authorization: Bearer <accessToken>.
      bearerAuth: {
        type: 'http',
        scheme: 'bearer',
        bearerFormat: 'JWT',
        description: 'Access token from POST /api/v1/auth/mobile/login (or /refresh).',
      },
    },
    schemas: {
      Error: {
        type: 'object',
        properties: { message: { type: 'string', example: 'Not found' } },
      },
      ValidationError: {
        type: 'object',
        properties: {
          errors: {
            type: 'array',
            items: {
              type: 'object',
              properties: {
                type: { type: 'string', example: 'field' },
                msg: { type: 'string', example: 'Invalid value' },
                path: { type: 'string', example: 'username' },
                location: { type: 'string', example: 'body' },
              },
            },
          },
        },
      },
      SafeUser: {
        type: 'object',
        properties: {
          id: { type: 'integer', example: 1 },
          username: { type: 'string', example: 'admin' },
          role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' },
        },
      },
      LoginRequest: {
        type: 'object',
        required: ['username', 'password'],
        properties: {
          username: { type: 'string', example: 'admin' },
          password: { type: 'string', format: 'password', example: 'super-secret' },
          company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' },
        },
      },
      RegisterRequest: {
        type: 'object',
        required: ['username', 'password'],
        properties: {
          username: { type: 'string', minLength: 3, maxLength: 32, example: 'newplayer' },
          password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
          email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' },
          company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' },
        },
      },
      LoginResponse: {
        type: 'object',
        description:
          'Either a session (user) or, for 2FA accounts, a TOTP challenge to complete at /login/totp.',
        properties: {
          user: { $ref: '#/components/schemas/SafeUser' },
          totpRequired: { type: 'boolean', example: true },
          challenge: { type: 'string', description: 'Signed challenge token for the TOTP step.' },
        },
      },
      TotpLoginRequest: {
        type: 'object',
        required: ['challenge'],
        description: 'Second step for 2FA login. Supply either code OR recoveryCode.',
        properties: {
          challenge: { type: 'string', description: 'Token returned by /login when totpRequired.' },
          code: { type: 'string', description: 'Current authenticator code.', example: '123456' },
          recoveryCode: { type: 'string', description: 'A single-use recovery code (alternative to code).', example: 'abcde-12345' },
          trustDevice: { type: 'boolean', description: 'Remember this browser so future logins skip the TOTP step (30 days).', example: false },
          deviceName: { type: 'string', description: 'Optional friendly label for the Trusted Devices list.', example: 'My Laptop' },
        },
      },
      MobileLoginRequest: {
        type: 'object',
        required: ['username', 'password'],
        properties: {
          username: { type: 'string', example: 'admin' },
          password: { type: 'string', format: 'password', example: 'super-secret' },
          code: { type: 'string', description: 'TOTP code (only when 2FA is enabled).', example: '123456' },
          recoveryCode: { type: 'string', description: 'Single-use recovery code (alternative to code).', example: 'abcde-12345' },
          trustDevice: { type: 'boolean', description: 'Remember this device so future logins skip the TOTP step; the response then carries trustToken.', example: false },
          device_name: { type: 'string', description: 'Optional friendly device label for Active/Trusted Devices.', example: 'Pixel 8' },
        },
      },
      MobileTokenResponse: {
        type: 'object',
        properties: {
          accessToken: { type: 'string', description: 'Short-lived bearer JWT.' },
          refreshToken: { type: 'string', description: 'Long-lived, revocable refresh token.' },
          expiresIn: {
            type: 'string',
            description: 'Access token lifetime as a duration string (zeit/ms format, e.g. "15m").',
            example: '15m',
          },
          user: { $ref: '#/components/schemas/SafeUser' },
          trustToken: { type: 'string', nullable: true, description: 'Present only when trustDevice was requested and accepted — store securely and send as X-Trust-Token on future logins to skip TOTP.' },
          trustLimitReached: { type: 'boolean', nullable: true, description: 'Present (true) when trustDevice was requested but the device cap is reached; see devices.' },
          devices: { type: 'array', nullable: true, items: { $ref: '#/components/schemas/TrustedDevice' }, description: 'The existing trusted devices, when trustLimitReached is set.' },
        },
      },
      MobileRefreshRequest: {
        type: 'object',
        required: ['refreshToken'],
        properties: { refreshToken: { type: 'string' } },
      },
      MobileLogoutRequest: {
        type: 'object',
        properties: {
          refreshToken: { type: 'string', description: 'Revoke a single session.' },
          all: { type: 'boolean', description: 'Revoke every session for the user.', example: false },
        },
      },
      MobileSsoExchangeRequest: {
        type: 'object',
        required: ['code', 'code_verifier'],
        properties: {
          code: {
            type: 'string',
            description: 'The single-use authorization code returned to the app callback.',
          },
          code_verifier: {
            type: 'string',
            description: 'The PKCE verifier for the challenge sent to /auth/mobile/sso/start.',
          },
          device_name: { type: 'string', description: 'Optional friendly device label for Active Devices.', example: 'Pixel 8' },
        },
      },
      DeviceSession: {
        type: 'object',
        properties: {
          id: { type: 'integer', description: 'Session row id (pass to DELETE /auth/me/sessions/:id).' },
          deviceName: { type: 'string', nullable: true, example: 'Pixel 8' },
          userAgent: { type: 'string', nullable: true },
          createdAt: { type: 'string', format: 'date-time' },
          lastUsedAt: { type: 'string', format: 'date-time' },
          expiresAt: { type: 'string', format: 'date-time' },
        },
      },
      // A device allowed to skip the TOTP step at login (MFA "Trust this device").
      // Distinct from DeviceSession (a live mobile login session). Never exposes the
      // trust token/hash.
      TrustedDevice: {
        type: 'object',
        properties: {
          id: { type: 'integer', description: 'Trusted-device id (pass to DELETE …/trusted-devices/:id).' },
          platform: { type: 'string', enum: ['web', 'mobile'], example: 'web' },
          deviceName: { type: 'string', nullable: true, example: 'My Laptop' },
          userAgent: { type: 'string', nullable: true },
          createdAt: { type: 'string', format: 'date-time' },
          lastUsedAt: { type: 'string', format: 'date-time' },
          expiresAt: { type: 'string', format: 'date-time' },
        },
      },
      // Result of POST /auth/me/trusted-devices. Web receives an httpOnly cookie and
      // { trusted:true }; native (bearer) sessions additionally get { trustToken }.
      TrustDeviceResult: {
        type: 'object',
        properties: {
          trusted: { type: 'boolean', example: true },
          trustToken: { type: 'string', nullable: true, description: 'Native clients only — store securely and send as X-Trust-Token.' },
        },
      },
      // 409 body when the trusted-device cap is reached: the caller must revoke one
      // of the listed devices before retrying.
      TrustedDeviceLimit: {
        type: 'object',
        properties: {
          error: { type: 'string', example: 'trusted_device_limit' },
          devices: { type: 'array', items: { $ref: '#/components/schemas/TrustedDevice' } },
        },
      },
      // One-time recovery (backup) codes. Returned ONLY at generation; never re-shown.
      RecoveryCodes: {
        type: 'object',
        properties: {
          recoveryCodes: { type: 'array', items: { type: 'string', example: 'abcde-12345' } },
        },
      },
      Message: {
        type: 'object',
        properties: { message: { type: 'string', example: 'Logged out.' } },
      },
      ContactRequest: {
        type: 'object',
        required: ['message'],
        properties: {
          message: { type: 'string', maxLength: 5000, example: 'When does the shard launch?' },
          email: { type: 'string', format: 'email', example: 'player@example.com' },
          name: { type: 'string', maxLength: 100, example: 'Lord British' },
        },
      },
      // Public discovery shape (GET /auth/providers) — enough for the login page
      // to render a button and start the flow. Never exposes secrets or endpoints.
      Provider: {
        type: 'object',
        properties: {
          id: { type: 'string', example: 'google' },
          name: { type: 'string', example: 'Google' },
          icon: {
            type: 'string',
            description: "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2').",
            example: 'google',
          },
          loginUrl: {
            type: 'string',
            description: 'Relative URL to begin the redirect flow.',
            example: '/api/v1/auth/sso/google/start',
          },
          priority: { type: 'integer', description: 'Sort order (ascending).', example: 1 },
        },
      },
      // Admin-facing provider config (GET/POST/PUT /admin/auth/providers). The
      // client secret is write-only and NEVER returned — `hasSecret` reports
      // whether one is stored. `builtin` marks google/discord (fixed kind/name),
      // and `health` is the config-completeness check used to gate visibility.
      ProviderConfig: {
        type: 'object',
        properties: {
          id: { type: 'string', example: 'okta' },
          kind: { type: 'string', enum: ['google', 'discord', 'oidc', 'oauth2'], example: 'oidc' },
          name: { type: 'string', example: 'Okta' },
          enabled: { type: 'boolean', example: true },
          clientId: { type: 'string' },
          hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned).', example: true },
          authorizeUrl: { type: 'string', format: 'uri' },
          tokenUrl: { type: 'string', format: 'uri' },
          userinfoUrl: { type: 'string', format: 'uri' },
          scopes: { type: 'string', example: 'openid email profile' },
          priority: { type: 'integer', example: 10 },
          builtin: { type: 'boolean', description: 'True for the fixed google/discord providers.', example: false },
          health: { $ref: '#/components/schemas/ProviderHealth' },
        },
      },
      ProviderHealth: {
        type: 'object',
        description: 'Config-completeness check that gates whether a provider is offered to end users.',
        properties: {
          valid: { type: 'boolean', example: true },
          missing: {
            type: 'array',
            description: 'Names of required config fields that are still missing.',
            items: { type: 'string' },
            example: [],
          },
        },
      },
      ProviderCreateRequest: {
        type: 'object',
        required: ['id', 'kind', 'name'],
        properties: {
          id: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'okta' },
          kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' },
          name: { type: 'string', maxLength: 80, example: 'Okta' },
          enabled: { type: 'boolean', example: true },
          clientId: { type: 'string' },
          secret: { type: 'string', format: 'password' },
          authorizeUrl: { type: 'string', format: 'uri' },
          tokenUrl: { type: 'string', format: 'uri' },
          userinfoUrl: { type: 'string', format: 'uri' },
          scopes: { type: 'string', maxLength: 500, example: 'openid email profile' },
          priority: { type: 'integer', example: 10 },
        },
      },
      Post: {
        type: 'object',
        properties: {
          id: { type: 'integer', example: 12 },
          category: { type: 'string', example: 'news' },
          title: { type: 'string', example: 'Server maintenance this weekend' },
          slug: { type: 'string', example: 'server-maintenance-this-weekend' },
          excerpt: { type: 'string', nullable: true },
          body: { type: 'string', nullable: true },
          image_url: { type: 'string', nullable: true, example: '/uploads/1700000000-abcd.png' },
          published: { type: 'boolean', example: true },
          author_id: { type: 'integer', nullable: true, example: 1 },
          created_at: { type: 'string', format: 'date-time' },
          updated_at: { type: 'string', format: 'date-time' },
          published_at: { type: 'string', format: 'date-time', nullable: true },
        },
      },
      PostCreateRequest: {
        type: 'object',
        required: ['category', 'title'],
        properties: {
          category: { type: 'string', example: 'news' },
          title: { type: 'string', maxLength: 200, example: 'Server maintenance this weekend' },
          body: { type: 'string' },
          image_url: { type: 'string', description: 'Required for the screenshots category.' },
          published: { type: 'boolean', example: false },
        },
      },
      PublishRequest: {
        type: 'object',
        required: ['published'],
        properties: { published: { type: 'boolean', example: true } },
      },
      UploadResponse: {
        type: 'object',
        properties: { url: { type: 'string', example: '/uploads/1700000000-abcd.png' } },
      },
      WikiPage: {
        type: 'object',
        properties: {
          id: { type: 'integer', example: 3 },
          slug: { type: 'string', example: 'getting-started' },
          title: { type: 'string', example: 'Getting Started' },
          excerpt: { type: 'string' },
          body: { type: 'string' },
          category_id: { type: 'integer', nullable: true, example: 2 },
          published: { type: 'boolean', example: true },
          tags: { type: 'array', items: { type: 'string' }, example: ['newbie', 'guide'] },
          created_at: { type: 'string', format: 'date-time' },
          updated_at: { type: 'string', format: 'date-time' },
        },
      },
      WikiPageCreateRequest: {
        type: 'object',
        required: ['slug', 'title'],
        properties: {
          slug: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'getting-started' },
          title: { type: 'string', maxLength: 200, example: 'Getting Started' },
          excerpt: { type: 'string', maxLength: 400 },
          body: { type: 'string' },
          category_id: { type: 'integer', nullable: true },
          published: { type: 'boolean', example: false },
          tags: { type: 'array', items: { type: 'string' } },
        },
      },
      WikiCategory: {
        type: 'object',
        properties: {
          id: { type: 'integer', example: 2 },
          slug: { type: 'string', example: 'guides' },
          title: { type: 'string', example: 'Guides' },
          description: { type: 'string' },
          sort_order: { type: 'integer', example: 0 },
        },
      },
      WikiCategoryCreateRequest: {
        type: 'object',
        required: ['slug', 'title'],
        properties: {
          slug: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'guides' },
          title: { type: 'string', maxLength: 200, example: 'Guides' },
          description: { type: 'string', maxLength: 400 },
          sort_order: { type: 'integer', example: 0 },
        },
      },
      User: {
        type: 'object',
        properties: {
          id: { type: 'integer', example: 1 },
          username: { type: 'string', example: 'admin' },
          role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'admin' },
          status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
          email: { type: 'string', format: 'email', nullable: true },
          email_verified: { type: 'boolean', example: false },
          totp_enabled: { type: 'boolean', example: true },
          last_login_at: { type: 'string', format: 'date-time', nullable: true },
          created_at: { type: 'string', format: 'date-time' },
        },
      },
      UserCreateRequest: {
        type: 'object',
        required: ['username', 'password'],
        properties: {
          username: { type: 'string', minLength: 3, maxLength: 32, example: 'editor1' },
          password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
          role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'editor' },
          status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
          email: { type: 'string', format: 'email', nullable: true },
        },
      },
      // Player self-service credential changes (/api/v1/player/account/*).
      ChangeUsernameRequest: {
        type: 'object',
        required: ['username'],
        properties: {
          username: { type: 'string', minLength: 3, maxLength: 32, example: 'newname' },
        },
      },
      ChangePasswordRequest: {
        type: 'object',
        required: ['newPassword'],
        properties: {
          newPassword: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
          currentPassword: {
            type: 'string',
            format: 'password',
            description:
              'Required when the account already has a password. Omit only for an SSO-provisioned account setting its first password.',
          },
        },
      },
      PlayerAccount: {
        type: 'object',
        description: 'Self-service player account (GET /player/account).',
        properties: {
          id: { type: 'integer', example: 42 },
          username: { type: 'string', example: 'newplayer' },
          role: { type: 'string', enum: ['player'], example: 'player' },
          email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' },
          status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
          totp_enabled: { type: 'boolean', example: false },
          has_password: {
            type: 'boolean',
            description: 'False for an SSO-provisioned account that has not set a password yet.',
            example: true,
          },
        },
      },
      OkFlag: {
        type: 'object',
        properties: { ok: { type: 'boolean', example: true } },
      },
      // ── Push notifications (M7) ─────────────────────────────────────────────
      RegisterDeviceRequest: {
        type: 'object',
        required: ['endpoint'],
        properties: {
          endpoint: {
            type: 'string',
            format: 'uri',
            description: 'The UnifiedPush/ntfy endpoint URL the distributor handed the app (or an FCM token). Must be an allowed HTTPS relay origin — private/loopback hosts are rejected.',
            example: 'https://ntfy.example.com/UP0a1b2c3d4e5f',
          },
          transport: { type: 'string', enum: ['unifiedpush', 'fcm'], default: 'unifiedpush', example: 'unifiedpush' },
          platform: { type: 'string', nullable: true, maxLength: 40, example: 'android' },
        },
      },
      PushDevice: {
        type: 'object',
        properties: {
          id: { type: 'integer', example: 7 },
          transport: { type: 'string', enum: ['unifiedpush', 'fcm'], example: 'unifiedpush' },
          endpoint: { type: 'string', example: 'https://ntfy.example.com/UP0a1b2c3d4e5f' },
          platform: { type: 'string', nullable: true, example: 'android' },
          createdAt: { type: 'string', format: 'date-time' },
          lastSeenAt: { type: 'string', format: 'date-time' },
        },
      },
      NotificationStream: {
        type: 'object',
        description: 'One subscribable push stream from the catalog.',
        properties: {
          id: { type: 'string', example: 'idoc.warning' },
          label: { type: 'string', example: 'IDOC warnings' },
          description: { type: 'string', example: 'A house falls into its final (IDOC) decay stage.' },
          personal: {
            type: 'boolean',
            description: 'Owner-keyed — delivered only to the owning user, never fanned out publicly.',
            example: false,
          },
          requiresLinkedAccount: {
            type: 'boolean',
            description: 'The stream needs a linked game account (personal streams).',
            example: false,
          },
        },
      },
      NotificationStreams: {
        type: 'object',
        properties: {
          streams: { type: 'array', items: { $ref: '#/components/schemas/NotificationStream' } },
        },
      },
      NotificationSubscriptions: {
        type: 'object',
        description: 'The set of stream ids the user has opted into (used for both GET and PUT).',
        properties: {
          streams: {
            type: 'array',
            items: { type: 'string' },
            example: ['news.post', 'idoc.warning', 'vendor.sale'],
          },
        },
      },
      // ── Moderation appeals (Phase 6c/6d) ────────────────────────────────────
      Appeal: {
        type: 'object',
        description: 'A player-submitted moderation appeal (as returned to the player and in the staff queue).',
        properties: {
          id: { type: 'integer', example: 12 },
          mod_action_id: { type: 'integer', example: 340 },
          discord_user_id: { type: 'string', example: '216734083584917504' },
          action_type: { type: 'string', enum: ['ban', 'mute'], example: 'ban' },
          user_id: { type: 'integer', nullable: true, example: 42 },
          status: {
            type: 'string',
            enum: ['pending', 'under_review', 'approved', 'denied', 'withdrawn'],
            example: 'pending',
          },
          submitted_text: { type: 'string', example: 'I was banned by mistake — please review.' },
          staff_response: { type: 'string', nullable: true, example: null },
          handled_by_user_id: { type: 'integer', nullable: true, example: null },
          handled_by_tag: { type: 'string', nullable: true, example: null },
          reversal_status: {
            type: 'string',
            enum: ['none', 'done', 'failed'],
            description: 'Discord-reversal outcome. done/failed only after an approval; none otherwise.',
            example: 'none',
          },
          submitted_at: { type: 'string', format: 'date-time' },
          resolved_at: { type: 'string', format: 'date-time', nullable: true, example: null },
          action_target_tag: { type: 'string', nullable: true, example: 'Rogue#1234', description: 'Snapshot of the original action target tag (from mod_actions).' },
          action_reason: { type: 'string', nullable: true, example: 'Spam' },
          action_created_at: { type: 'string', format: 'date-time', nullable: true },
          action_duration_seconds: { type: 'integer', nullable: true, example: 86400 },
          submitter_username: { type: 'string', nullable: true, example: 'newplayer' },
        },
      },
      AppealQueueItem: {
        allOf: [{ $ref: '#/components/schemas/Appeal' }],
        description: 'A staff-queue appeal row — identical shape to Appeal, with the joined action/submitter columns populated.',
      },
      AppealResolveResult: {
        allOf: [
          { $ref: '#/components/schemas/Appeal' },
          {
            type: 'object',
            properties: {
              reversal: {
                type: 'object',
                description: 'What the approval attempted against Discord.',
                properties: {
                  attempted: { type: 'boolean', example: true },
                  ok: { type: 'boolean', example: true },
                  reversal_status: { type: 'string', enum: ['none', 'done', 'failed'], example: 'done' },
                  bot_status: { type: 'integer', nullable: true, example: 200, description: 'HTTP status from the bot internal call, or null when no call was made.' },
                  error: { type: 'string', nullable: true, example: null },
                },
              },
            },
          },
        ],
      },
      AppealEligibleAction: {
        type: 'object',
        description: 'A ban/mute mod_action the caller may appeal (no active appeal outstanding).',
        properties: {
          id: { type: 'integer', example: 340, description: 'mod_action id — pass as mod_action_id when submitting.' },
          action_type: { type: 'string', enum: ['ban', 'mute'], example: 'ban' },
          target_tag: { type: 'string', nullable: true, example: 'Rogue#1234' },
          reason: { type: 'string', nullable: true, example: 'Spam' },
          duration_seconds: { type: 'integer', nullable: true, example: 86400 },
          created_at: { type: 'string', format: 'date-time' },
        },
      },
      CreateAppealRequest: {
        type: 'object',
        required: ['mod_action_id', 'submitted_text'],
        properties: {
          mod_action_id: { type: 'integer', example: 340, description: 'The ban/mute mod_action to appeal (must belong to the caller).' },
          submitted_text: { type: 'string', minLength: 1, maxLength: 4000, example: 'I was banned by mistake — please review.' },
        },
      },
      ResolveAppealRequest: {
        type: 'object',
        required: ['status'],
        properties: {
          status: { type: 'string', enum: ['approved', 'denied'], example: 'approved' },
          staff_response: { type: 'string', maxLength: 4000, nullable: true, example: 'Reviewed — reversing the ban.' },
        },
      },
      TotpCodeRequest: {
        type: 'object',
        required: ['code'],
        properties: { code: { type: 'string', example: '123456' } },
      },
      SiteModeRequest: {
        type: 'object',
        required: ['mode'],
        properties: { mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' } },
      },
      UnbanRequest: {
        type: 'object',
        required: ['ip'],
        properties: { ip: { type: 'string', example: '203.0.113.5' } },
      },
      // ── Actual mutation-response shapes ─────────────────────────────────────
      // These endpoints do NOT return the generic { message } envelope; they echo
      // the affected resource id/slug or a boolean flag. Documented here as-is so
      // the spec matches the controllers. (The shapes are intentionally recorded
      // rather than normalized — see the audit note if standardizing later.)
      AccountStatus: {
        type: 'object',
        description: 'Self-service account security status (GET /admin/account).',
        properties: {
          id: { type: 'integer', example: 1 },
          username: { type: 'string', example: 'admin' },
          role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' },
          totp_enabled: { type: 'boolean', example: true },
        },
      },
      TotpSetup: {
        type: 'object',
        description: 'Enrollment material returned by POST /account/totp/setup.',
        properties: {
          otpauthUrl: { type: 'string', example: `otpauth://totp/${brand.name}:admin?secret=...` },
          qr: { type: 'string', description: 'QR code as a data: URL.', example: 'data:image/png;base64,iVBORw0KGgo...' },
        },
      },
      TotpState: {
        type: 'object',
        description: 'Result of enabling/disabling 2FA. Enabling also returns the one-time recovery codes.',
        properties: {
          totp_enabled: { type: 'boolean', example: true },
          recoveryCodes: {
            type: 'array',
            nullable: true,
            description: 'Single-use recovery codes, shown ONCE on enable.',
            items: { type: 'string', example: 'abcde-12345' },
          },
        },
      },
      LinkedIdentity: {
        type: 'object',
        properties: {
          provider: { type: 'string', example: 'google' },
          email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' },
          linked_at: { type: 'string', format: 'date-time' },
        },
      },
      SiteModeState: {
        type: 'object',
        description: 'Result of PUT /admin/site-mode.',
        properties: {
          site_mode: { type: 'string', enum: ['live', 'maintenance'], example: 'maintenance' },
          changed_at: { type: 'string', format: 'date-time' },
          changed_by: { type: 'string', example: 'admin' },
        },
      },
      PublicStatus: {
        type: 'object',
        description: 'Public site status (GET /public/status).',
        properties: {
          mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' },
          status_message: { type: 'string', example: '' },
          version: { $ref: '#/components/schemas/PublicVersion' },
        },
      },
      PublicVersion: {
        type: 'object',
        description: 'Backend identity + version (GET /public/version; also embedded in /public/status).',
        properties: {
          service: { type: 'string', example: 'runic-gateway', description: 'Stable backend identifier for first-run recognition.' },
          api: { type: 'string', example: 'v1', description: 'API contract version (matches the /api/v1 mount).' },
          server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' },
        },
      },
      PublicModules: {
        type: 'object',
        description:
          'Installed modules currently SERVING (GET /public/modules). A disabled or failed module is absent, not listed with a state — its routes and nav are absent too. Database-free and not site-mode gated.',
        properties: {
          modules: {
            type: 'array',
            items: { $ref: '#/components/schemas/PublicModule' },
          },
        },
      },
      PublicModule: {
        type: 'object',
        description: 'One started module, as published to anonymous clients.',
        properties: {
          id: { type: 'string', example: 'uo', description: 'Module id — also the URL segment its routes live under (/api/v1/public/<id-owned prefixes>).' },
          name: { type: 'string', example: 'Ultima Online', description: 'Human label.' },
          version: { type: 'string', example: '1.0.0', description: 'The module\'s own version (semver). Unrelated to the API version.' },
          capabilities: {
            type: 'array',
            description: 'Opaque strings the module declares. Feature-detect against them; treat an unknown one as absent.',
            items: { type: 'string', example: 'shard' },
          },
        },
      },
      Brand: {
        type: 'object',
        description:
          'Per-shard branding (BRAND_* env, with admin overrides for name/contactEmail). A client themes itself from this — one instance runs as any shard. Asset fields (logo/hero/favicon) may be site-relative paths; resolve them against the site base URL.',
        properties: {
          name: { type: 'string', example: 'Runic Gateway' },
          shortName: { type: 'string', example: 'Runic Gateway' },
          tagline: { type: 'string', example: 'an independent private Ultima Online shard' },
          description: { type: 'string' },
          contactEmail: { type: 'string', example: '' },
          url: { type: 'string', example: '' },
          accent: {
            type: 'string',
            example: '#7f99bd',
            description:
              'Seed/accent color (hex) for theming. **Effective** value: the admin theme (theme_visual) wins over BRAND_ACCENT_COLOR, so a client that themes from this tracks admin theming with no change.',
          },
          logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo. An uploaded brand_assets.logo overrides BRAND_LOGO.' },
          hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path. An uploaded brand_assets.hero overrides BRAND_HERO.' },
          favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path. An uploaded brand_assets.favicon overrides BRAND_FAVICON.' },
        },
      },
      PublicSettings: {
        type: 'object',
        description:
          'Public site settings + branding (GET /public/settings). Whitelisted string settings, plus derived availability flags and the brand block a client themes from. Additional whitelisted keys may appear.',
        properties: {
          site_title: { type: 'string', example: 'Runic Gateway' },
          status_message: { type: 'string', example: '' },
          maintenance_message: { type: 'string', example: '' },
          registration: {
            type: 'object',
            properties: { password: { type: 'boolean' }, sso: { type: 'boolean' } },
          },
          brand: { $ref: '#/components/schemas/Brand' },
          theme: {
            type: 'object',
            nullable: true,
            description:
              'The effective CSS custom properties for the admin theme, resolved server-side (:root ← preset ← custom). **Absent** when the admin never set a theme, which is what makes an untouched instance render from the shipped stylesheet unchanged. Keys are CSS variable names; every value comes from a closed set (hex color, curated font stack, bounded px length, listed shadow).',
            additionalProperties: { type: 'string' },
            example: { '--accent': '#c9973f', '--bg': '#1a120b', '--radius-card': '2px' },
          },
          push: {
            type: 'object',
            description:
              'Push-notification relay config (M7). `ntfyUrl` is the client-facing ntfy base URL the app registers its device topic against (from NTFY_PUBLIC_URL / NTFY_ALLOWED_ORIGINS); null when push is not configured for this shard.',
            properties: {
              ntfyUrl: { type: 'string', nullable: true, example: 'https://ntfy.example.com' },
            },
          },
        },
        additionalProperties: true,
      },
      NavSettings: {
        type: 'object',
        description:
          'Nav overrides for the two authenticated layouts (GET /settings/nav). Each value is the stored JSON **string** — settings.value is TEXT — or null when that nav was never overridden. Parse fail-safe: treat malformed as absent and fall back to the hardcoded nav.',
        properties: {
          nav_admin: {
            type: 'string',
            nullable: true,
            example: '{"/admin/posts":{"label":"Blog Posts","order":10}}',
          },
          nav_player: { type: 'string', nullable: true, example: null },
        },
      },
      ThemeOptions: {
        type: 'object',
        description:
          'The closed sets an admin may choose from when theming the site (GET /settings/theme-options). Served so the admin form cannot offer a value PUT /admin/settings would reject. Static — derived from the server theme config, not the database.',
        properties: {
          presets: {
            type: 'array',
            description:
              'Selectable presets and their full token maps, so a form can show what an unset field currently resolves to. `custom` has null tokens and means "no preset base — the shipped theme plus whatever custom fields are set".',
            items: {
              type: 'object',
              properties: {
                id: { type: 'string', example: 'fantasy' },
                label: { type: 'string', example: 'Fantasy' },
                tokens: {
                  type: 'object',
                  nullable: true,
                  additionalProperties: { type: 'string' },
                  example: { '--bg': '#1a120b', '--accent': '#c9973f' },
                },
              },
            },
          },
          colorFields: {
            type: 'array',
            description: 'Editable color fields, each paired with the CSS variable it drives.',
            items: {
              type: 'object',
              properties: { name: { type: 'string', example: 'accent' }, token: { type: 'string', example: '--accent' } },
            },
          },
          radiusFields: {
            type: 'array',
            items: {
              type: 'object',
              properties: { name: { type: 'string', example: 'radiusCard' }, token: { type: 'string', example: '--radius-card' } },
            },
          },
          shippedTokens: {
            type: 'object',
            description: 'What the stylesheet declares by default — the values an unset field resolves to when no preset is selected.',
            additionalProperties: { type: 'string' },
          },
          fonts: {
            type: 'object',
            description: 'Curated Google Fonts shortlist per role. Each option\'s `value` is the full CSS font-family stack exactly as it will be applied — the stored value, so no stack is ever built from admin input.',
            additionalProperties: {
              type: 'array',
              items: {
                type: 'object',
                properties: { value: { type: 'string' }, label: { type: 'string' } },
              },
            },
          },
          shadows: {
            type: 'array',
            items: {
              type: 'object',
              properties: { value: { type: 'string' }, label: { type: 'string' } },
            },
          },
          radiusMaxPx: { type: 'integer', example: 999 },
        },
      },
      // Delete/mutation acknowledgements — each echoes the affected resource key
      // or a boolean flag rather than a { message } string.
      DeletedId: {
        type: 'object',
        properties: { id: { type: 'integer', example: 12 } },
      },
      DeletedSlug: {
        type: 'object',
        properties: { slug: { type: 'string', example: 'getting-started' } },
      },
      DeletedFlag: {
        type: 'object',
        properties: { deleted: { type: 'boolean', example: true } },
      },
      UnlinkedFlag: {
        type: 'object',
        properties: { unlinked: { type: 'boolean', example: true } },
      },
      UnbanResult: {
        type: 'object',
        properties: {
          ip: { type: 'string', example: '203.0.113.5' },
          removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true },
        },
      },
    },
  },
}

/**
 * Normalize `/a/b/` → `/a/b` in the generated path keys.
 *
 * swagger-autogen builds a path by string-concatenating the mount prefix with the
 * route argument, so a capability router mounted at `/users` that declares its
 * collection route as `router.get('/')` documents as `/api/v1/admin/users/`.
 * Express itself does not care (non-strict routing treats the two as one route,
 * and server/routes.manifest.json records the canonical slash-less form), but the
 * *spec* would advertise a URL no client uses and stop documenting the one they
 * all call. The domain split (docs/website/API_V2_PLAN.md § Phase 2) creates one
 * of these per capability router, so it is fixed here once rather than by
 * contorting the route declarations in every router file.
 *
 * The path keys are also **sorted**. swagger-autogen emits them in router-traversal
 * order, so moving a route between files rewrites most of this 5k-line committed
 * artifact even when the API is provably unchanged — burying the one line a
 * reviewer needs to see. OpenAPI attaches no meaning to path order, and
 * scripts/routeManifest.js already sorts for the same reason.
 */
function normalizePaths(spec) {
  const paths = {}
  for (const [p, item] of Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
    const key = p.length > 1 ? p.replace(/\/+$/, '') : p
    if (paths[key]) {
      // Two different declarations collapsed onto one path — merging would hide
      // whichever lost. Nothing in the tree does this today; fail loudly if it starts.
      throw new Error(
        `swagger: "${p}" and "${key}" collide after trailing-slash normalization. ` +
          'Two routes are documenting the same URL — reconcile them in the router.',
      )
    }
    paths[key] = item
  }
  spec.paths = paths
  return spec
}

// Static analysis cannot follow a route into an extension slot, so the slot
// routers contribute a generated fragment afterwards — see swagger/slotSpecs.js
// for what goes wrong without it. Merged BEFORE normalizePaths, so the merged-in
// paths are sorted and trailing-slash-checked with everything else.
//
// The pool is pointed at a closed port here for the same reason
// scripts/routeManifest.js does it: the merge step requires src/app.js to find
// where each slot router is mounted, and requiring app.js builds the models. No
// query is ever run.
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
process.env.DB_PORT = process.env.DB_PORT || '59999'

// ── An annotation swagger-autogen cannot parse is DROPPED, not failed ──────
//
// It `console.error`s "Syntax error" or "out of structure", skips that one
// annotation, and prints `Success` in green. Nothing was listening, so the tree
// had been carrying a broken one — `POST /api/v1/admin/invites` documented with an
// EMPTY request body — for as long as it had existed. The same class turned up
// four more times in module-uo, whose annotations came from here.
//
// Two ways one breaks, both of them invisible in review: an object literal a
// brace short, and a `"` or a backtick inside a single-quoted description
// (swagger-autogen re-quotes both to `'` before evaluating, which ends the string
// early). Capturing the diagnostics is the only way to be told.
const swaggerComplaints = []
const realConsoleError = console.error
console.error = (...args) => {
  const line = args.map(String).join(' ')
  if (/syntax error|out of structure/i.test(line)) swaggerComplaints.push(line.trim())
  else realConsoleError(...args)
}

/* eslint-disable global-require */
swaggerAutogen(outputFile, routes, doc)
  .then(() => {
    console.error = realConsoleError
    if (swaggerComplaints.length > 0) {
      throw new Error(
        `swagger: ${swaggerComplaints.length} annotation(s) could not be parsed and were DROPPED ` +
          `— the spec would be missing what they described:\n  ${swaggerComplaints.join('\n  ')}`,
      )
    }
  })
  .then(() => require('./slotSpecs').mergeSlotSpecs(outputFile))
  .then(() => {
    const written = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
    fs.writeFileSync(outputFile, `${JSON.stringify(normalizePaths(written), null, 2)}\n`)
    // eslint-disable-next-line no-console
    console.log('swagger-output.json generated.')
    // The mariadb pool keeps the loop alive even pointed at a dead port.
    return require('../src/utils/db').close()
  })
  .catch((err) => {
    console.error = realConsoleError
    process.stderr.write(`${err.stack || err.message}\n`)
    process.exit(1)
  })
/* eslint-enable global-require */
