import { CustomError, rethrowAsBadRequest } from '../helpers/common.ts'
import { detectImageMime, imageMimeTypes } from '../helpers/images.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { UserPatch, UserProfilePatch } from '../models/users.ts'

interface UserUpdateBody {
  name?: string
  email?: string
  isActive?: boolean
  isVerified?: boolean
  meta?: Record<string, any>
  prefs?: Record<string, any>
  groups?: string[]
  auth?: Record<string, any>
}

/** How large an avatar upload may be, before any resizing. */
const avatarUploadLimit = 2 * 1024 * 1024

/**
 * The user the session belongs to, or null when the request is not from a logged in user.
 *
 * The `/profile` routes are session-authenticated rather than permission-gated: every logged in user
 * may read and change its own profile, and no permission expresses that.
 */
function sessionUserId(req: FastifyRequest): string | null {
  return req.session?.authenticated && req.session.user?.id ? req.session.user.id : null
}

/**
 * Who is asking, as the interface needs to know it: the account on the session and the group-wide
 * permissions it holds, or nothing at all for a guest.
 *
 * Exported because `bootstrap` answers the same question as part of the one call an app load makes,
 * and two versions of "who is this" would be one too many.
 */
export function whoAmI(req: FastifyRequest): Record<string, any> {
  if (!req.session?.authenticated) {
    return { authenticated: false }
  }
  return {
    authenticated: true,
    ...req.session.user,
    /*
      The same list the route permission hook checks against — written onto the session at login from
      the groups the user belongs to. Nothing is added for the interface's benefit: a control it shows
      on a permission the session does not hold leads to a button that gets a 403 from the endpoint
      behind it.
    */
    permissions: req.session.permissions ?? []
  }
}

/**
 * Refuse a `manage:users` holder any change to a user who is protected by `manage:system`.
 *
 * `manage:users` is deliberately short of the root: an administrator who can rename, re-group, reset
 * the password of, or delete a `manage:system` account can take the instance over through it. Only
 * somebody who already holds `manage:system` may touch one.
 *
 * @returns The refusal to throw, or null when the caller may proceed
 */
async function systemUserGuard(req: FastifyRequest, userId: string): Promise<CustomError | null> {
  if (WIKI.models.groups.holdsSystemPermission(req)) {
    return null
  }
  if (!(await WIKI.models.groups.userHoldsSystemPermission(userId))) {
    return null
  }
  return new CustomError(
    'userSystemProtected',
    'This user belongs to a group with the manage:system permission. Only a user who holds manage:system can modify them.',
    403
  )
}

/**
 * Whether self-service profile editing is enabled on the site being browsed.
 *
 * It is a per-site feature: an instance whose user data comes from an external identity provider turns
 * it off. The site is resolved from the request hostname, which is how the admin flag is scoped; an
 * unresolvable hostname leaves the feature at its default.
 */
async function isProfileEditable(req: FastifyRequest): Promise<boolean> {
  const site = req.hostname
    ? await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
    : null
  return !site || site.config?.features?.profile !== false
}

/**
 * Users API Routes
 */
async function routes(app: FastifyInstance) {
  // -> An avatar upload is the raw image rather than a multipart form: one file, no fields, and no
  //    dependency to add. Registered inside this plugin, so every other route keeps rejecting an
  //    image body outright.
  app.addContentTypeParser(
    [...imageMimeTypes],
    { parseAs: 'buffer', bodyLimit: avatarUploadLimit },
    (req, body, done) => {
      done(null, body)
    }
  )

  app.get<{
    Querystring: { page?: number; limit?: number; filter?: string; assignableToGroupId?: string }
  }>(
    '/',
    {
      config: {
        permissions: ['read:users', 'manage:users']
      },
      schema: {
        summary: 'List all users',
        tags: ['Users'],
        querystring: {
          type: 'object',
          properties: {
            filter: {
              type: 'string',
              description: 'Matched against the user name and email, case-insensitively.',
              maxLength: 255
            },
            assignableToGroupId: {
              type: 'string',
              format: 'uuid',
              description:
                'Keep only the users that may be assigned to this group, i.e. omit its current members and any system user. Intended for pickers offering users to assign.'
            },
            page: { type: 'integer', minimum: 1, default: 1 },
            limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }
          }
        },
        response: {
          200: {
            description: 'List of Users',
            type: 'object',
            properties: {
              page: { type: 'integer' },
              limit: { type: 'integer' },
              total: { type: 'integer' },
              users: {
                type: 'array',
                items: { $ref: 'UserCore#' }
              }
            }
          }
        }
      }
    },
    async (req) => {
      const page = req.query.page ?? 1
      const limit = req.query.limit ?? 20
      const { total, users } = await WIKI.models.users.getUsers({
        filter: req.query.filter ?? '',
        assignableToGroupId: req.query.assignableToGroupId ?? '',
        page,
        limit
      })
      return { page, limit, total, users }
    }
  )

  /**
   * RECENT LOGINS
   */
  app.get<{ Querystring: { limit?: number } }>(
    '/recent-logins',
    {
      config: {
        // -> `access:admin`, not `read:users`: this answers a panel on the admin dashboard, which
        //    everyone who can open the admin area sees, and it is the same permission `system/info`
        //    fills the rest of that dashboard with. It is why the answer is identity plus a timestamp
        //    and nothing else -- the user list, and every account flag on it, still needs `read:users`.
        permissions: ['access:admin']
      },
      schema: {
        summary: 'List the most recent logins',
        description:
          'Who signed in last, most recent first. Accounts that have never logged in are left out rather than trailing the list, as are system accounts — nothing signs in as the guest.',
        tags: ['Users'],
        querystring: {
          type: 'object',
          properties: {
            limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 }
          }
        },
        response: {
          200: {
            description: 'The most recent logins, newest first',
            type: 'array',
            items: {
              type: 'object',
              properties: {
                id: { type: 'string', format: 'uuid' },
                name: { type: 'string' },
                email: { type: 'string' },
                lastLoginAt: {
                  type: 'string',
                  format: 'date-time',
                  description: 'RFC 3339 Date Time'
                }
              }
            }
          }
        }
      }
    },
    async (req) => {
      return WIKI.models.users.getRecentLogins({ limit: req.query.limit ?? 10 })
    }
  )

  app.get(
    '/whoami',
    {
      schema: {
        summary: 'Get currently logged in user info',
        description:
          'Includes the group-wide permissions of the session, which is what the interface hides its own controls by. Permissions ON A PAGE are a different question, answered by `pages/userPermissions`.\n\nThe app itself gets this from `bootstrap` on load, together with the site and the flags; this endpoint is what asks again once a login or a logout has changed the answer.',
        tags: ['Users']
      }
    },
    async (req, reply) => {
      reply.preventCache()
      return whoAmI(req)
    }
  )

  /**
   * GET OWN PROFILE
   */
  app.get(
    '/profile',
    {
      schema: {
        summary: "Get the logged in user's own profile",
        description:
          'Returns the profile of the user the session belongs to, with the `meta` / `prefs` blobs flattened into plain fields.',
        tags: ['Users'],
        response: {
          200: {
            description: 'User profile',
            type: 'object',
            $ref: 'UserProfile#'
          }
        }
      }
    },
    async (req, reply) => {
      reply.preventCache()
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }
      const profile = await WIKI.models.users.getProfile(userId)
      if (!profile) {
        // -> The session outlived the user it points at
        return reply.unauthorized()
      }
      return profile
    }
  )

  /**
   * UPDATE OWN PROFILE
   */
  app.put<{ Body: UserProfilePatch }>(
    '/profile',
    {
      schema: {
        summary: "Update the logged in user's own profile",
        description:
          'Updates any subset of the profile fields; omitted ones are left unchanged. Requires the current site to have the `profile` feature enabled. The email cannot be changed here, and neither can any field an administrator owns.',
        tags: ['Users'],
        body: {
          $ref: 'UserProfileUpdate#'
        },
        response: {
          200: {
            description: 'Profile updated successfully',
            type: 'object',
            properties: {
              ok: {
                type: 'boolean'
              },
              message: {
                type: 'string'
              },
              profile: {
                $ref: 'UserProfile#'
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }
      if (!(await isProfileEditable(req))) {
        return reply.forbidden('Profile editing is disabled on this site.')
      }

      // -> A bad time zone would break every date the user sees, and the list of valid zones is only
      //    known at runtime, so it cannot be expressed as a schema enum
      if (req.body.timezone !== undefined && req.body.timezone !== '') {
        if (!Intl.supportedValuesOf('timeZone').includes(req.body.timezone)) {
          throw new CustomError(
            'userProfileInvalidTimezone',
            `Not a recognized IANA time zone: ${req.body.timezone}`
          )
        }
      }

      const patch: UserProfilePatch = {}
      for (const key of [
        'name',
        'location',
        'jobTitle',
        'pronouns',
        'timezone',
        'dateFormat',
        'timeFormat',
        'appearance',
        'cvd'
      ] as const) {
        if (req.body[key] !== undefined) {
          patch[key] = req.body[key]
        }
      }
      if (Object.keys(patch).length < 1) {
        throw new CustomError('userProfileEmpty', 'No profile fields provided to update.')
      }
      if (patch.name !== undefined && !/^[^<>"]+$/.test(patch.name)) {
        throw new CustomError('userProfileInvalidName', 'Invalid User Name')
      }

      const profile = await WIKI.models.users.updateProfile(userId, patch)
      if (!profile) {
        return reply.unauthorized()
      }

      // -> The session carries a copy of the name and the preferences, which `/whoami` serves on
      //    every page load. Left alone, it would hand back the pre-save values.
      req.session.user = {
        ...req.session.user!,
        name: profile.name,
        timezone: profile.timezone,
        dateFormat: profile.dateFormat,
        timeFormat: profile.timeFormat,
        appearance: profile.appearance,
        cvd: profile.cvd
      }

      return {
        ok: true,
        message: 'Profile updated successfully.',
        profile
      }
    }
  )

  /**
   * UPLOAD OWN AVATAR
   */
  app.put(
    '/profile/avatar',
    {
      schema: {
        summary: "Replace the logged in user's own avatar",
        description: `The body is the raw image, not a multipart form — send the file itself with its \`Content-Type\`. At most ${avatarUploadLimit / 1024 / 1024} MB, and it must really be one of the accepted formats: the bytes are checked, not the declared type. Resized to a 180x180 JPEG when the Sharp extension is installed, otherwise stored as uploaded. Requires the current site to have the \`profile\` feature enabled.`,
        tags: ['Users'],
        consumes: [...imageMimeTypes],
        response: {
          200: {
            description: 'Avatar uploaded successfully',
            type: 'object',
            properties: {
              ok: {
                type: 'boolean'
              },
              message: {
                type: 'string'
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }
      if (!(await isProfileEditable(req))) {
        return reply.forbidden('Profile editing is disabled on this site.')
      }

      const data = req.body
      if (!Buffer.isBuffer(data) || data.length < 1) {
        throw new CustomError('userAvatarEmpty', 'No image was sent.')
      }
      // -> The declared content type got the request this far; what the bytes actually are is what
      //    decides, since they are what gets stored and served back
      if (!detectImageMime(data)) {
        throw new CustomError(
          'userAvatarInvalidImage',
          'Not a PNG, JPEG, WebP or GIF image, whatever the request said it was.'
        )
      }

      await WIKI.models.users.setAvatar(userId, data)
      // -> The account menu reads `hasAvatar` off the session on every page load
      req.session.user = { ...req.session.user!, hasAvatar: true }

      return {
        ok: true,
        message: 'Avatar uploaded successfully.'
      }
    }
  )

  /**
   * CLEAR OWN AVATAR
   */
  app.delete(
    '/profile/avatar',
    {
      schema: {
        summary: "Remove the logged in user's own avatar",
        description:
          'Leaves the user to be rendered as a placeholder again. Succeeds even if there was no avatar to remove. Requires the current site to have the `profile` feature enabled.',
        tags: ['Users'],
        response: {
          200: {
            description: 'Avatar cleared successfully',
            type: 'object',
            properties: {
              ok: {
                type: 'boolean'
              },
              message: {
                type: 'string'
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }
      if (!(await isProfileEditable(req))) {
        return reply.forbidden('Profile editing is disabled on this site.')
      }

      await WIKI.models.users.clearAvatar(userId)
      req.session.user = { ...req.session.user!, hasAvatar: false }

      return {
        ok: true,
        message: 'Avatar cleared successfully.'
      }
    }
  )

  /**
   * GET OWN GROUPS
   *
   * A user may see which groups it belongs to without holding `read:groups`, which would expose every
   * group on the instance.
   */
  app.get(
    '/profile/groups',
    {
      schema: {
        summary: 'Get the groups the logged in user belongs to',
        description:
          'Only the identity of each group. Reading what a group grants requires `read:groups`.',
        tags: ['Users'],
        response: {
          200: {
            description: 'Groups the user belongs to',
            type: 'array',
            items: {
              type: 'object',
              properties: {
                id: {
                  type: 'string',
                  format: 'uuid'
                },
                name: {
                  type: 'string'
                }
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      reply.preventCache()
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }
      return WIKI.models.users.getUserGroups(userId)
    }
  )

  /**
   * GET OWN EDITOR SETTINGS
   *
   * Per-user and per-editor, e.g. whether the markdown editor opens with its preview pane showing.
   * Session-scoped like the rest of `/profile`, so it needs no permission of its own: a user can
   * only ever read its own.
   */
  app.get<{ Params: { editor: string } }>(
    '/profile/editor-settings/:editor',
    {
      schema: {
        summary: "Get the logged in user's settings for one editor",
        tags: ['Users'],
        params: {
          type: 'object',
          properties: {
            editor: { type: 'string', description: 'Editor key, e.g. `markdown`' }
          },
          required: ['editor']
        },
        response: {
          200: {
            description: 'Editor settings. An object whose shape belongs to the editor.',
            type: 'object',
            additionalProperties: true
          }
        }
      }
    },
    async (req, reply) => {
      reply.preventCache()
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }
      return WIKI.models.users.getEditorSettings(userId, req.params.editor)
    }
  )

  /**
   * UPDATE OWN EDITOR SETTINGS
   */
  app.put<{ Params: { editor: string }; Body: Record<string, any> }>(
    '/profile/editor-settings/:editor',
    {
      schema: {
        summary: "Update the logged in user's settings for one editor",
        description:
          "Replaces the settings for this editor. Other editors' settings, and every other preference, are left alone.",
        tags: ['Users'],
        params: {
          type: 'object',
          properties: {
            editor: { type: 'string', description: 'Editor key, e.g. `markdown`' }
          },
          required: ['editor']
        },
        body: {
          type: 'object',
          additionalProperties: true
        },
        response: {
          200: {
            description: 'Editor settings updated successfully',
            type: 'object',
            properties: {
              ok: { type: 'boolean' },
              config: { type: 'object', additionalProperties: true }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }
      const config = await WIKI.models.users.setEditorSettings(userId, req.params.editor, req.body)
      if (config === null) {
        // -> The session outlived the user it points at
        return reply.unauthorized()
      }
      return { ok: true, config }
    }
  )

  /**
   * GET OWN AUTHENTICATION METHODS
   *
   * What the profile's authentication page is built from: the providers linked to the account and the
   * passkeys registered against it. Session-scoped like the rest of `/profile` — a user can only ever
   * see its own, and no permission expresses that.
   */
  app.get(
    '/profile/auth',
    {
      schema: {
        summary: "Get the logged in user's authentication methods",
        description:
          'The providers the account can be signed in with, plus its registered passkeys. Secrets are never included: each provider reports only whether a password is set, whether 2FA is active, and whether the user is allowed to turn it off.',
        tags: ['Users'],
        response: {
          200: {
            description: 'Authentication methods',
            type: 'object',
            properties: {
              authMethods: {
                type: 'array',
                items: {
                  type: 'object',
                  properties: {
                    authId: { type: 'string', format: 'uuid' },
                    authName: { type: 'string' },
                    strategyKey: { type: 'string' },
                    strategyIcon: { type: 'string' },
                    config: {
                      type: 'object',
                      properties: {
                        isPasswordSet: { type: 'boolean' },
                        isTfaSetup: { type: 'boolean' },
                        isTfaRequired: {
                          type: 'boolean',
                          description:
                            'Either this user is flagged for 2FA or the strategy enforces it. Turning 2FA off is refused while this holds.'
                        },
                        isPasswordLoginEnabled: {
                          type: 'boolean',
                          description:
                            'False once password login has been turned off, by the user or by an administrator.'
                        },
                        canDisablePasswordLogin: {
                          type: 'boolean',
                          description:
                            'Whether the account has another way in — a passkey or another linked provider — and may therefore turn password login off.'
                        }
                      }
                    }
                  }
                }
              },
              passkeys: {
                type: 'array',
                items: { $ref: 'Passkey#' }
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      reply.preventCache()
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }
      return {
        authMethods: await WIKI.models.users.getProfileAuthMethods(userId),
        passkeys: await WIKI.models.passkeys.list(userId)
      }
    }
  )

  /**
   * CHANGE OWN PASSWORD
   */
  app.put<{ Body: { strategyId: string; currentPassword: string; newPassword: string } }>(
    '/profile/password',
    {
      schema: {
        summary: "Change the logged in user's own password",
        description:
          'The current password has to be given, and is what authorizes the change. Only a provider that stores the password on this instance can be changed here. Also clears any pending forced password change.',
        tags: ['Users'],
        body: {
          type: 'object',
          required: ['strategyId', 'currentPassword', 'newPassword'],
          properties: {
            strategyId: {
              type: 'string',
              format: 'uuid',
              description: 'The provider whose password is being changed.'
            },
            currentPassword: { type: 'string', minLength: 1, maxLength: 255 },
            newPassword: { type: 'string', minLength: 8, maxLength: 255 }
          }
        },
        response: {
          200: {
            description: 'Password changed successfully',
            type: 'object',
            properties: {
              ok: { type: 'boolean' },
              message: { type: 'string' }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }

      try {
        await WIKI.models.users.changeOwnPassword({
          userId,
          strategyId: req.body.strategyId,
          currentPassword: req.body.currentPassword,
          newPassword: req.body.newPassword
        })
      } catch (err: any) {
        rethrowAsBadRequest(err)
      }

      return {
        ok: true,
        message: 'Password changed successfully.'
      }
    }
  )

  /**
   * TURN OWN PASSWORD LOGIN ON OR OFF
   */
  app.put<{ Body: { strategyId: string; isEnabled: boolean } }>(
    '/profile/password-login',
    {
      schema: {
        summary: "Turn password login on or off for the logged in user's own account",
        description:
          'The same restriction an administrator can apply from the admin area. Turning it off is refused unless the account has another way in — a registered passkey or another linked provider — so that a user cannot lock themselves out. The password itself is kept, so turning it back on restores it.',
        tags: ['Users'],
        body: {
          type: 'object',
          required: ['strategyId', 'isEnabled'],
          properties: {
            strategyId: {
              type: 'string',
              format: 'uuid',
              description:
                'The provider to change, which has to be one that stores a password here.'
            },
            isEnabled: { type: 'boolean' }
          }
        },
        response: {
          200: {
            description: 'Password login setting updated successfully',
            type: 'object',
            properties: {
              ok: { type: 'boolean' },
              message: { type: 'string' }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }

      try {
        await WIKI.models.users.setPasswordLoginEnabled({
          userId,
          strategyId: req.body.strategyId,
          isEnabled: req.body.isEnabled
        })
      } catch (err: any) {
        rethrowAsBadRequest(err)
      }

      return {
        ok: true,
        message: req.body.isEnabled ? 'Password login enabled.' : 'Password login disabled.'
      }
    }
  )

  /**
   * START OWN 2FA SETUP
   *
   * Two steps, because the server cannot know the secret reached the user's authenticator until the
   * user proves it did: this hands out a QR code and a continuation token, and `PUT` activates the
   * secret once a code generated from it comes back.
   */
  app.post<{ Body: { strategyId: string } }>(
    '/profile/tfa',
    {
      schema: {
        summary: "Start setting up 2FA on the logged in user's account",
        description:
          'Generates a secret and returns the QR code to scan. The secret does nothing until a code produced by it is submitted to `PUT /users/profile/tfa` with the continuation token returned here. Starting again replaces a secret that was never activated.',
        tags: ['Users'],
        body: {
          type: 'object',
          required: ['strategyId'],
          properties: {
            strategyId: { type: 'string', format: 'uuid' }
          }
        },
        response: {
          200: {
            description: '2FA setup started',
            type: 'object',
            properties: {
              ok: { type: 'boolean' },
              continuationToken: { type: 'string' },
              tfaQRImage: {
                type: 'string',
                description: 'The `otpauth://` URI as an SVG QR code, to be rendered as-is.'
              },
              tfaSecret: {
                type: 'string',
                description:
                  'The base32 secret the QR code encodes, for a user who would rather type it into an authenticator app than scan it. Only ever returned here, to the user setting 2FA up on their own account.'
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }

      // -> The site names the entry in the user's authenticator app, and is the one being browsed
      //    rather than one the client names: nothing else about this request is client-chosen either
      const site = req.hostname
        ? await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
        : null

      try {
        const { continuationToken, tfaQRImage, tfaSecret } =
          await WIKI.models.users.startProfileTfaSetup({
            userId,
            strategyId: req.body.strategyId,
            siteId: site?.id
          })
        return {
          ok: true,
          continuationToken,
          tfaQRImage,
          tfaSecret
        }
      } catch (err: any) {
        rethrowAsBadRequest(err)
      }
    }
  )

  /**
   * FINISH OWN 2FA SETUP
   */
  app.put<{ Body: { strategyId: string; continuationToken: string; securityCode: string } }>(
    '/profile/tfa',
    {
      schema: {
        summary: 'Activate the 2FA secret the logged in user just set up',
        description:
          'Checks a code from the user’s authenticator against the secret generated by `POST /users/profile/tfa`, and activates it. A wrong code can be retried a handful of times before the continuation token is discarded and the setup has to be started again.',
        tags: ['Users'],
        body: {
          type: 'object',
          required: ['strategyId', 'continuationToken', 'securityCode'],
          properties: {
            strategyId: { type: 'string', format: 'uuid' },
            continuationToken: { type: 'string', minLength: 1, maxLength: 255 },
            securityCode: {
              type: 'string',
              pattern: '^[0-9]{6}$',
              description: 'The six digits shown by the authenticator app.'
            }
          }
        },
        response: {
          200: {
            description: '2FA activated successfully',
            type: 'object',
            properties: {
              ok: { type: 'boolean' },
              message: { type: 'string' }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }

      try {
        await WIKI.models.users.confirmTfaSetup({
          userId,
          strategyId: req.body.strategyId,
          continuationToken: req.body.continuationToken,
          securityCode: req.body.securityCode
        })
      } catch (err: any) {
        rethrowAsBadRequest(err)
      }

      return {
        ok: true,
        message: '2FA enabled successfully.'
      }
    }
  )

  /**
   * TURN OWN 2FA OFF
   */
  app.delete<{ Params: { strategyId: string } }>(
    '/profile/tfa/:strategyId',
    {
      schema: {
        summary: "Turn 2FA off on the logged in user's account",
        description:
          'Forgets the secret, so setting 2FA up again starts from a new one. Refused when the account is flagged for 2FA or the strategy enforces it — the next login would only ask for it again.',
        tags: ['Users'],
        params: {
          type: 'object',
          properties: {
            strategyId: { type: 'string', format: 'uuid' }
          },
          required: ['strategyId']
        },
        response: {
          204: {
            description: '2FA turned off successfully'
          }
        }
      }
    },
    async (req, reply) => {
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }

      try {
        await WIKI.models.users.disableTfa(userId, req.params.strategyId)
      } catch (err: any) {
        rethrowAsBadRequest(err)
      }

      return reply.code(204).send()
    }
  )

  /**
   * START REGISTERING A PASSKEY
   */
  app.post(
    '/profile/passkeys/challenge',
    {
      schema: {
        summary: 'Get the options for registering a new passkey',
        description:
          "Pass the result to the browser's WebAuthn API, then send what the authenticator produces to `POST /users/profile/passkeys`. The credential is bound to the hostname of this request, so a passkey registered on one site of a multi-site instance does not work on another.",
        tags: ['Users'],
        response: {
          200: {
            description: 'Registration options',
            type: 'object',
            properties: {
              ok: { type: 'boolean' },
              registrationOptions: {
                type: 'object',
                additionalProperties: true,
                description: 'A WebAuthn `PublicKeyCredentialCreationOptions`, JSON-encoded.'
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }

      try {
        const { registrationOptions, pending } = await WIKI.models.passkeys.startRegistration({
          userId,
          hostname: req.hostname,
          origin: req.headers.origin
        })
        // -> Kept out of the client's hands: what the authenticator signs is only worth anything if the
        //    challenge it answers is one this server remembers issuing
        req.session.passkeyRegistration = pending
        return {
          ok: true,
          registrationOptions
        }
      } catch (err: any) {
        rethrowAsBadRequest(err)
      }
    }
  )

  /**
   * FINISH REGISTERING A PASSKEY
   */
  app.post<{ Body: { name: string; registrationResponse: Record<string, any> } }>(
    '/profile/passkeys',
    {
      schema: {
        summary: 'Register the passkey an authenticator just created',
        tags: ['Users'],
        body: {
          type: 'object',
          required: ['name', 'registrationResponse'],
          properties: {
            name: {
              type: 'string',
              minLength: 1,
              maxLength: 255,
              description: 'What to call it in the list, e.g. the device it lives on.'
            },
            registrationResponse: {
              type: 'object',
              additionalProperties: true,
              description: "The browser's WebAuthn registration response, JSON-encoded."
            }
          }
        },
        response: {
          200: {
            description: 'Passkey registered successfully',
            type: 'object',
            properties: {
              ok: { type: 'boolean' },
              passkey: { $ref: 'Passkey#' }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }

      try {
        const passkey = await WIKI.models.passkeys.finalizeRegistration({
          userId,
          name: req.body.name,
          registrationResponse: req.body.registrationResponse as any,
          pending: req.session.passkeyRegistration
        })
        return {
          ok: true,
          passkey
        }
      } catch (err: any) {
        rethrowAsBadRequest(err)
      } finally {
        // -> Spent either way: a rejected response does not get a second go at the same challenge
        req.session.passkeyRegistration = undefined
      }
    }
  )

  /**
   * REMOVE A PASSKEY
   */
  app.delete<{ Params: { passkeyId: string } }>(
    '/profile/passkeys/:passkeyId',
    {
      schema: {
        summary: 'Remove one of the logged in user’s passkeys',
        description:
          'Only this instance forgets it — the credential itself lives on the user’s device and has to be deleted there too.',
        tags: ['Users'],
        params: {
          type: 'object',
          properties: {
            passkeyId: {
              type: 'string',
              description: 'The credential ID, as listed by `GET /users/profile/auth`.'
            }
          },
          required: ['passkeyId']
        },
        response: {
          204: {
            description: 'Passkey removed successfully'
          }
        }
      }
    },
    async (req, reply) => {
      const userId = sessionUserId(req)
      if (!userId) {
        return reply.unauthorized()
      }
      if (!(await WIKI.models.passkeys.remove(userId, req.params.passkeyId))) {
        return reply.notFound('You have no passkey with this ID.')
      }
      return reply.code(204).send()
    }
  )

  /**
   * GET USER DEFAULTS
   *
   * Instance-wide, not per-site: stored as the `userDefaults` key of the settings table.
   */
  app.get(
    '/defaults',
    {
      config: {
        permissions: ['read:users', 'manage:users']
      },
      schema: {
        summary: 'Get the defaults applied to new users',
        tags: ['Users'],
        response: {
          200: {
            description: 'User defaults',
            type: 'object',
            $ref: 'UserDefaults#'
          }
        }
      }
    },
    async () => {
      return WIKI.config.userDefaults
    }
  )

  /**
   * UPDATE USER DEFAULTS
   */
  app.put<{ Body: { timezone?: string; dateFormat?: string; timeFormat?: string } }>(
    '/defaults',
    {
      config: {
        permissions: ['manage:users']
      },
      schema: {
        summary: 'Update the defaults applied to new users',
        description:
          'These are instance-wide, not per-site. Existing users keep their own preferences.',
        tags: ['Users'],
        body: {
          $ref: 'UserDefaults#'
        },
        response: {
          200: {
            description: 'User defaults updated successfully',
            type: 'object',
            properties: {
              ok: {
                type: 'boolean'
              },
              message: {
                type: 'string'
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      // -> A bad time zone would break every date the affected users see, and the list of valid
      //    zones is only known at runtime, so it cannot be expressed as a schema enum
      if (req.body.timezone !== undefined) {
        if (!Intl.supportedValuesOf('timeZone').includes(req.body.timezone)) {
          throw new CustomError(
            'userDefaultsInvalidTimezone',
            `Not a recognized IANA time zone: ${req.body.timezone}`
          )
        }
      }

      const patch: Record<string, any> = {}
      for (const key of ['timezone', 'dateFormat', 'timeFormat'] as const) {
        if (req.body[key] !== undefined) {
          patch[key] = req.body[key]
        }
      }
      if (Object.keys(patch).length < 1) {
        throw new CustomError('userDefaultsEmpty', 'No user defaults provided to update.')
      }

      const previousDefaults = WIKI.config.userDefaults
      WIKI.config.userDefaults = { ...previousDefaults, ...patch }

      if (!(await WIKI.configSvc.saveToDb(['userDefaults']))) {
        WIKI.config.userDefaults = previousDefaults
        return reply.internalServerError('Failed to save user defaults.')
      }

      return {
        ok: true,
        message: 'User defaults updated successfully.'
      }
    }
  )

  app.get<{ Params: { userId: string } }>(
    '/:userId',
    {
      config: {
        permissions: ['read:users', 'manage:users']
      },
      schema: {
        summary: 'Get user info',
        description:
          'Returns the user with its group membership and linked authentication providers.',
        tags: ['Users'],
        params: {
          type: 'object',
          properties: {
            userId: {
              type: 'string',
              format: 'uuid'
            }
          },
          required: ['userId']
        },
        response: {
          200: {
            description: 'User info',
            type: 'object',
            $ref: 'User#'
          }
        }
      }
    },
    async (req, reply) => {
      const user = await WIKI.models.users.getUserDetail(req.params.userId)
      if (!user) {
        return reply.notFound('User does not exist.')
      }
      return user
    }
  )

  /**
   * CREATE USER
   */
  app.post<{
    Body: {
      name: string
      email: string
      password: string
      groups?: string[]
      mustChangePassword?: boolean
      sendWelcomeEmail?: boolean
      sendWelcomeEmailFromSiteId?: string
    }
  }>(
    '/',
    {
      config: {
        permissions: ['create:users', 'manage:users']
      },
      schema: {
        summary: 'Create a new user',
        description:
          'Creates a user authenticated against the local strategy. `sendWelcomeEmail` is accepted but not yet supported, as the server has no mail transport.',
        tags: ['Users'],
        body: {
          type: 'object',
          required: ['name', 'email', 'password'],
          properties: {
            name: {
              type: 'string',
              minLength: 1,
              maxLength: 255
            },
            email: {
              type: 'string',
              format: 'email',
              maxLength: 255
            },
            password: {
              type: 'string',
              minLength: 8,
              maxLength: 255
            },
            groups: {
              type: 'array',
              items: {
                type: 'string',
                format: 'uuid'
              }
            },
            mustChangePassword: {
              type: 'boolean',
              default: false
            },
            sendWelcomeEmail: {
              type: 'boolean',
              default: false
            },
            sendWelcomeEmailFromSiteId: {
              type: 'string',
              format: 'uuid'
            }
          },
          examples: [
            {
              name: 'Jane Doe',
              email: 'jane@example.com',
              password: 'a-long-password',
              groups: []
            }
          ]
        },
        response: {
          200: {
            description: 'User created successfully',
            type: 'object',
            properties: {
              ok: {
                type: 'boolean'
              },
              message: {
                type: 'string'
              },
              id: {
                type: 'string',
                format: 'uuid'
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      if (!/^[^<>"]+$/.test(req.body.name)) {
        throw new CustomError('userCreateInvalidName', 'Invalid User Name')
      }
      if (await WIKI.models.users.getByEmail(req.body.email.toLowerCase())) {
        throw new CustomError('userCreateDuplicateEmail', 'A user with this email already exists.')
      }
      // -> There is no mail transport yet, so accepting this flag would silently drop the request
      if (req.body.sendWelcomeEmail) {
        throw new CustomError(
          'userCreateWelcomeEmailUnavailable',
          'Sending a welcome email is not supported yet, as mail delivery is not implemented.'
        )
      }

      try {
        const id = await WIKI.models.users.createUser({
          name: req.body.name,
          email: req.body.email,
          password: req.body.password,
          groups: req.body.groups ?? [],
          mustChangePassword: req.body.mustChangePassword ?? false
        })
        return {
          ok: true,
          message: 'User created successfully.',
          id
        }
      } catch (err: any) {
        WIKI.logger.warn(err)
        return reply.internalServerError()
      }
    }
  )

  /**
   * UPDATE USER
   */
  app.put<{ Params: { userId: string }; Body: UserUpdateBody }>(
    '/:userId',
    {
      config: {
        permissions: ['manage:users']
      },
      schema: {
        summary: 'Update a user',
        description:
          'Updates any subset of the user fields. Omitted fields are left unchanged. Passing `groups` replaces the group membership entirely — except for system users (the guest account), whose membership is fixed.',
        tags: ['Users'],
        params: {
          type: 'object',
          properties: {
            userId: {
              type: 'string',
              format: 'uuid'
            }
          },
          required: ['userId']
        },
        body: {
          type: 'object',
          properties: {
            name: {
              type: 'string',
              minLength: 1,
              maxLength: 255
            },
            email: {
              type: 'string',
              format: 'email',
              maxLength: 255
            },
            isActive: {
              type: 'boolean'
            },
            isVerified: {
              type: 'boolean'
            },
            meta: {
              type: 'object',
              additionalProperties: true
            },
            prefs: {
              type: 'object',
              additionalProperties: true
            },
            groups: {
              type: 'array',
              items: {
                type: 'string',
                format: 'uuid'
              }
            },
            auth: {
              type: 'object',
              description:
                'Local-strategy flags: `mustChangePwd`, `restrictLogin`, `tfaRequired`. Secrets cannot be set here — use the password endpoint.',
              properties: {
                mustChangePwd: {
                  type: 'boolean'
                },
                restrictLogin: {
                  type: 'boolean'
                },
                tfaRequired: {
                  type: 'boolean'
                }
              }
            }
          }
        },
        response: {
          200: {
            description: 'User updated successfully',
            type: 'object',
            properties: {
              ok: {
                type: 'boolean'
              },
              message: {
                type: 'string'
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const user = await WIKI.models.users.getById(req.params.userId)
      if (!user) {
        return reply.notFound('User does not exist.')
      }

      const systemUserRefusal = await systemUserGuard(req, user.id)
      if (systemUserRefusal) {
        throw systemUserRefusal
      }

      // -> Collect only the fields actually provided
      const patch: UserPatch = {}
      for (const key of ['name', 'email', 'isActive', 'isVerified', 'meta', 'prefs'] as const) {
        if (req.body[key] !== undefined) {
          ;(patch as Record<string, any>)[key] = req.body[key]
        }
      }

      if (
        Object.keys(patch).length < 1 &&
        req.body.groups === undefined &&
        req.body.auth === undefined
      ) {
        throw new CustomError('userUpdateEmpty', 'No user fields provided to update.')
      }

      // -> Email is unique, so a clash needs a clearer answer than a constraint violation
      if (patch.email && patch.email.toLowerCase() !== user.email.toLowerCase()) {
        if (await WIKI.models.users.getByEmail(patch.email.toLowerCase())) {
          throw new CustomError(
            'userUpdateDuplicateEmail',
            'A user with this email already exists.'
          )
        }
      }

      // -> Group membership is replaced wholesale here, which would otherwise be a way around the
      //    guards on the groups endpoint.
      if (req.body.groups !== undefined) {
        // -> The guest account must stay in the guests group and nowhere else. Resending the
        //    membership unchanged is allowed, so that saving another field is not blocked.
        if (user.isSystem) {
          const current = await WIKI.models.users.getUserGroupIds(req.params.userId)
          const requested = req.body.groups
          const unchanged =
            current.length === requested.length && current.every((id) => requested.includes(id))
          if (!unchanged) {
            return reply.conflict('Cannot change the group membership of a system user.')
          }
        }

        /*
          Handing somebody `manage:system` by putting them in a group that carries it. Only ADDING is
          checked: a user already in such a group is protected by `systemUserGuard` above, which has
          refused this request before it gets here.
        */
        if (!WIKI.models.groups.holdsSystemPermission(req)) {
          const current = await WIKI.models.users.getUserGroupIds(req.params.userId)
          const systemGroupIds = await WIKI.models.groups.systemGroupIds()
          const added = req.body.groups.filter((id) => !current.includes(id))
          if (added.some((id) => systemGroupIds.includes(id))) {
            throw new CustomError(
              'groupMembershipSystemProtected',
              'Only a user who holds the manage:system permission can add a user to a group that has it.',
              403
            )
          }
        }

        const rootAdminGroupId = WIKI.config.auth.rootAdminGroupId
        const wasRootAdmin = await WIKI.models.groups.isUserInGroup(
          rootAdminGroupId,
          req.params.userId
        )
        if (wasRootAdmin && !req.body.groups.includes(rootAdminGroupId)) {
          if ((await WIKI.models.groups.countUsersInGroup(rootAdminGroupId)) <= 1) {
            return reply.conflict('Cannot remove the last user from the root administrators group.')
          }
        }
      }

      try {
        if (Object.keys(patch).length > 0) {
          await WIKI.models.users.updateUser(req.params.userId, patch)
        }
        if (req.body.groups !== undefined) {
          await WIKI.models.users.setUserGroups(req.params.userId, req.body.groups)
        }
        if (req.body.auth !== undefined) {
          await WIKI.models.users.setUserAuthFlags(req.params.userId, req.body.auth)
        }
        return {
          ok: true,
          message: 'User updated successfully.'
        }
      } catch (err: any) {
        WIKI.logger.warn(err)
        return reply.internalServerError()
      }
    }
  )

  /**
   * SET USER PASSWORD
   */
  app.put<{
    Params: { userId: string }
    Body: { newPassword: string; mustChangePassword?: boolean }
  }>(
    '/:userId/password',
    {
      config: {
        permissions: ['manage:users']
      },
      schema: {
        summary: "Set a user's password",
        description: 'Replaces the local-strategy password. Other linked providers are untouched.',
        tags: ['Users'],
        params: {
          type: 'object',
          properties: {
            userId: {
              type: 'string',
              format: 'uuid'
            }
          },
          required: ['userId']
        },
        body: {
          type: 'object',
          required: ['newPassword'],
          properties: {
            newPassword: {
              type: 'string',
              minLength: 8,
              maxLength: 255
            },
            mustChangePassword: {
              type: 'boolean',
              default: false
            }
          }
        },
        response: {
          200: {
            description: 'Password updated successfully',
            type: 'object',
            properties: {
              ok: {
                type: 'boolean'
              },
              message: {
                type: 'string'
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const systemUserRefusal = await systemUserGuard(req, req.params.userId)
      if (systemUserRefusal) {
        throw systemUserRefusal
      }

      const updated = await WIKI.models.users.setUserPassword({
        id: req.params.userId,
        newPassword: req.body.newPassword,
        mustChangePassword: req.body.mustChangePassword ?? false
      })
      if (!updated) {
        return reply.notFound('User does not exist.')
      }
      return {
        ok: true,
        message: 'User password updated successfully.'
      }
    }
  )

  app.delete<{ Params: { userId: string } }>(
    '/:userId',
    {
      config: {
        permissions: ['manage:users']
      },
      schema: {
        summary: 'Delete a user',
        description:
          'System users cannot be deleted, nor the account the caller is signed in as, nor the last user of the root administrators group. A user who has authored pages or assets cannot be deleted either — deactivate them, or reassign what they own.',
        tags: ['Users'],
        params: {
          type: 'object',
          properties: {
            userId: {
              type: 'string',
              format: 'uuid'
            }
          },
          required: ['userId']
        },
        response: {
          204: {
            description: 'User deleted successfully'
          }
        }
      }
    },
    async (req, reply) => {
      const user = await WIKI.models.users.getById(req.params.userId)
      if (!user) {
        return reply.notFound('User does not exist.')
      }

      const systemUserRefusal = await systemUserGuard(req, user.id)
      if (systemUserRefusal) {
        throw systemUserRefusal
      }

      // -> The guest account is the only system user, and anonymous access is resolved through it
      if (user.isSystem) {
        return reply.conflict('Cannot delete a system user.')
      }

      /*
        Not your own account, whatever permissions you hold: the request would end the session making
        it, and an administrator who did it by accident has nothing left to undo it with. Another
        administrator can — which is also the answer to an account that has to go and cannot ask.
      */
      if (user.id === sessionUserId(req)) {
        return reply.conflict('You cannot delete your own account. Another administrator can.')
      }

      // -> Emptying the root administrators group would lock everyone out of system management
      const rootAdminGroupId = WIKI.config.auth.rootAdminGroupId
      if (await WIKI.models.groups.isUserInGroup(rootAdminGroupId, user.id)) {
        if ((await WIKI.models.groups.countUsersInGroup(rootAdminGroupId)) <= 1) {
          return reply.conflict('Cannot delete the last user of the root administrators group.')
        }
      }

      try {
        await WIKI.models.users.deleteUser(user.id)
        return reply.code(204).send()
      } catch (err: any) {
        // -> Pages and assets reference users without a cascade, so a user who authored content
        //    cannot be removed. That is a conflict to report, not a server fault.
        if (err.cause?.code === '23503' || err.code === '23503') {
          return reply.conflict(
            'Cannot delete a user who still owns pages or assets. Reassign them first.'
          )
        }
        WIKI.logger.warn(err)
        return reply.internalServerError()
      }
    }
  )
}

export default routes
