import { nanoid } from 'nanoid'
import { maskSensitiveProps } from '../helpers/common.ts'
import { limitAuthAttempts } from '../helpers/rateLimit.ts'
import type { AuthStrategy } from '../models/authentication.ts'
import type { FastifyInstance, FastifyRequest } from 'fastify'

/**
 * How long a redirect login may take before its callback is refused.
 *
 * Long enough for somebody to be asked for a password and a second factor at the provider, short
 * enough that a `state` left lying around in a URL somewhere is no longer worth anything.
 */
const AUTH_FLOW_MINUTES = 15

/**
 * Where a provider sends the browser back, as an absolute URL.
 *
 * Built from the request rather than stored, so an instance reachable on more than one hostname keeps
 * working — but it has to match what the administrator registered with the provider, which is why the
 * admin area shows this exact shape on the strategy's page.
 */
function callbackUrl(req: FastifyRequest, strategyId: string): string {
  return `${req.protocol}://${req.host}/_api/auth/${strategyId}/callback`
}

/**
 * The login screen, carrying what went wrong.
 *
 * A redirect login fails at the provider or on the way back, where there is no request left to answer
 * with an error — so the browser is sent to the login screen with a code it can put in front of the
 * user, and `redirect` is preserved so that a successful second attempt still lands where the first
 * one was going.
 */
function loginErrorUrl(redirect: string, code: string): string {
  const params = new URLSearchParams({ error: code })
  if (redirect && redirect !== '/') {
    params.set('redirect', redirect)
  }
  return `/login?${params.toString()}`
}

/**
 * A strategy as it may be sent to a client: everything about it, minus the secrets.
 *
 * A prop the module declares `sensitive` — an OAuth client secret, an LDAP bind password — is
 * write-only, and reads as a mask standing in for whatever is stored. The admin area sends the whole
 * configuration back when it saves, and `buildConfig` understands the mask as "leave this alone".
 *
 * Done here rather than in the model because the model's strategies are the ones a login runs on: the
 * secret has to stay in the object the module authenticates with, so this is the last point at which
 * it can be taken out. `manage:system` on the route is not a reason to skip it — the secret would
 * still end up in a browser's memory, its cache and whatever is on the administrator's screen.
 */
function withoutSecrets(strategy: AuthStrategy): AuthStrategy {
  const props = WIKI.models.authentication.getModule(strategy.module)?.props ?? {}
  return { ...strategy, config: maskSensitiveProps(props, strategy.config) }
}

/**
 * Authentication API Routes
 */
async function routes(app: FastifyInstance) {
  /**
   * GET SITE AUTHENTICATION STRATEGIES
   */
  app.get<{ Params: { siteId: string }; Querystring: { visibleOnly?: boolean } }>(
    '/sites/:siteId/auth/strategies',
    {
      config: {
        publicAccess: true
      },
      schema: {
        summary: 'List all site authentication strategies',
        description:
          'Ordered by the position configured for the site. `activeStrategy` holds the per-instance settings, nested under it `strategy` holds the module definition.',
        tags: ['Authentication'],
        params: {
          type: 'object',
          properties: {
            siteId: {
              type: 'string',
              format: 'uuid'
            }
          },
          required: ['siteId']
        },
        querystring: {
          type: 'object',
          properties: {
            visibleOnly: {
              type: 'boolean',
              default: false
            }
          }
        },
        response: {
          200: {
            description: 'List of site authentication strategies',
            type: 'array',
            items: {
              type: 'object',
              properties: {
                id: {
                  type: 'string',
                  format: 'uuid'
                },
                order: {
                  type: 'integer'
                },
                isVisible: {
                  type: 'boolean'
                },
                activeStrategy: {
                  type: 'object',
                  properties: {
                    displayName: {
                      type: 'string'
                    },
                    registration: {
                      type: 'boolean'
                    },
                    allowForgotPassword: {
                      type: 'boolean',
                      description:
                        'Whether this strategy offers a password reset from the login screen. False for a strategy whose module has no such setting.'
                    },
                    strategy: {
                      type: 'object',
                      properties: {
                        key: {
                          type: 'string'
                        },
                        title: {
                          type: 'string'
                        },
                        icon: {
                          type: 'string'
                        },
                        color: {
                          type: 'string'
                        },
                        useForm: {
                          type: 'boolean'
                        },
                        usernameType: {
                          type: 'string'
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
      if (!site) {
        return reply.badRequest('Invalid Site ID')
      }
      /*
        `getActiveStrategies` rather than the raw rows: it completes each config from the module's
        declared defaults, so a prop added to a module after a strategy was configured reads as its
        default here instead of as a missing key.
      */
      const activeStrategies = (await WIKI.models.authentication.getActiveStrategies()).filter(
        (str: any) => str.isEnabled
      )
      // -> A site created before it had strategies configured has no list at all
      const configuredStrategies = site.config.authStrategies ?? []
      const siteStrategies = activeStrategies
        .map((str: any) => {
          const authModule = WIKI.data.authentication.find((m: any) => m.key === str.module)
          const siteStr = configuredStrategies.find((s: any) => s.id === str.id) || {}
          return {
            id: str.id,
            order: siteStr.order ?? 0,
            isVisible: siteStr.isVisible ?? false,
            activeStrategy: {
              displayName: str.displayName,
              registration: str.registration,
              /*
                Named explicitly, like every other field here: this endpoint is public and a strategy's
                config is where an OAuth client secret lives, so nothing may reach it by spreading.

                A module that declares no such prop reads as false, which is correct rather than a
                default -- a strategy with no password of its own has no password to reset.
              */
              allowForgotPassword: str.config?.allowForgotPassword === true,
              strategy: {
                key: authModule?.key ?? str.module,
                title: authModule?.title ?? str.module,
                icon: authModule?.icon ?? '',
                color: authModule?.color ?? 'primary',
                useForm: authModule?.useForm ?? false,
                usernameType: authModule?.usernameType ?? 'email'
              }
            }
          }
        })
        .sort((a: any, b: any) => a.order - b.order)
      return req.query.visibleOnly ? siteStrategies.filter((s: any) => s.isVisible) : siteStrategies
    }
  )

  /**
   * LOGIN USING USER/PASS
   */
  app.put<{
    Params: { siteId: string }
    Body: { strategyId: string; username?: string; password?: string }
  }>(
    '/sites/:siteId/auth/login',
    {
      config: {
        publicAccess: true
      },
      // -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
      onRequest: limitAuthAttempts,
      schema: {
        summary: 'Login',
        tags: ['Authentication'],
        params: {
          type: 'object',
          properties: {
            siteId: {
              type: 'string',
              format: 'uuid'
            }
          }
        },
        body: {
          type: 'object',
          required: ['strategyId'],
          properties: {
            strategyId: {
              type: 'string',
              format: 'uuid'
            },
            username: {
              type: 'string',
              minLength: 1,
              maxLength: 255
            },
            password: {
              type: 'string',
              minLength: 1,
              maxLength: 255
            }
          }
        },
        response: {
          200: { $ref: 'AuthLoginResult#' }
        }
      }
    },
    async (req, reply) => {
      try {
        const result = await WIKI.models.users.login(
          {
            siteId: req.params.siteId,
            strategyId: req.body.strategyId,
            username: req.body.username,
            password: req.body.password,
            ip: req.ip
          },
          req
        )
        if (!result) {
          throw new Error('Unexpected empty login response.')
        }
        return {
          ok: true,
          ...result
        }
      } catch (err: any) {
        if (err.message.startsWith('ERR_')) {
          return reply.badRequest(err.message)
        } else {
          // -> An unexpected failure, reported to the client as a generic one. The detail is behind
          //    the authDebug flag rather than logged on every failed login.
          WIKI.logger.debug(err)
          WIKI.models.flags.authDebug(`Login failed unexpectedly: ${err.message}`)
          return reply.badRequest('ERR_LOGIN_FAILED')
        }
      }
    }
  )

  /**
   * CHANGE PASSWORD
   */
  app.put<{
    Params: { siteId: string }
    Body: { strategyId: string; continuationToken: string; newPassword: string }
  }>(
    '/sites/:siteId/auth/changePassword',
    {
      config: {
        publicAccess: true
      },
      // -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
      onRequest: limitAuthAttempts,
      schema: {
        summary: 'Change Password From Login',
        tags: ['Authentication'],
        params: {
          type: 'object',
          properties: {
            siteId: {
              type: 'string',
              format: 'uuid'
            }
          }
        },
        body: {
          type: 'object',
          required: ['strategyId', 'continuationToken', 'newPassword'],
          properties: {
            strategyId: {
              type: 'string',
              format: 'uuid'
            },
            continuationToken: {
              type: 'string',
              minLength: 1,
              maxLength: 255
            },
            newPassword: {
              type: 'string',
              minLength: 1,
              maxLength: 255
            }
          }
        },
        response: {
          200: { $ref: 'AuthLoginResult#' }
        }
      }
    },
    async (req, reply) => {
      try {
        const result = await WIKI.models.users.loginChangePassword(
          {
            siteId: req.params.siteId,
            strategyId: req.body.strategyId,
            continuationToken: req.body.continuationToken,
            newPassword: req.body.newPassword,
            ip: req.ip
          },
          req
        )
        if (!result) {
          throw new Error('Unexpected empty change password response.')
        }
        if (result?.authenticated) {
          req.session.authenticated = true
        }
        return {
          ok: true,
          ...result
        }
      } catch (err: any) {
        if (err.message.startsWith('ERR_')) {
          WIKI.models.flags.authDebug(`Password change from login rejected: ${err.message}`)
          return reply.badRequest(err.message)
        } else {
          WIKI.logger.debug(err)
          WIKI.models.flags.authDebug(`Password change from login failed: ${err.message}`)
          return reply.badRequest('ERR_CHANGE_PASSWORD_FAILED')
        }
      }
    }
  )

  /**
   * SUBMIT A 2FA CODE
   *
   * The other half of a login that answered `provideTfa` or `setupTfa`: the continuation token stands
   * for the login that got that far, and the code proves the second factor. With `setup`, a correct
   * code also activates the secret the login generated, which is how an account that is required to
   * use 2FA gets it configured.
   */
  app.put<{
    Params: { siteId: string }
    Body: {
      strategyId: string
      continuationToken: string
      securityCode: string
      setup?: boolean
    }
  }>(
    '/sites/:siteId/auth/tfa',
    {
      config: {
        publicAccess: true
      },
      // -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
      onRequest: limitAuthAttempts,
      schema: {
        summary: 'Submit a 2FA Security Code From Login',
        description:
          'Answers like the login route does, since the same checks continue afterwards: a user who also owes a password change is asked for one next. A wrong code can be retried a few times before the continuation token is discarded and the login has to be started again.',
        tags: ['Authentication'],
        params: {
          type: 'object',
          properties: {
            siteId: {
              type: 'string',
              format: 'uuid'
            }
          },
          required: ['siteId']
        },
        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.'
            },
            setup: {
              type: 'boolean',
              default: false,
              description:
                'True when answering a `setupTfa` login, i.e. the code confirms a secret that was just generated.'
            }
          }
        },
        response: {
          200: { $ref: 'AuthLoginResult#' }
        }
      }
    },
    async (req, reply) => {
      try {
        const result = await WIKI.models.users.loginTFA(
          {
            siteId: req.params.siteId,
            strategyId: req.body.strategyId,
            continuationToken: req.body.continuationToken,
            securityCode: req.body.securityCode,
            setup: req.body.setup ?? false,
            ip: req.ip
          },
          req
        )
        return {
          ok: true,
          ...result
        }
      } catch (err: any) {
        if (err.message.startsWith('ERR_')) {
          WIKI.models.flags.authDebug(`2FA verification rejected: ${err.message}`)
          return reply.badRequest(err.message)
        } else {
          WIKI.logger.debug(err)
          WIKI.models.flags.authDebug(`2FA verification failed unexpectedly: ${err.message}`)
          return reply.badRequest('ERR_TFA_FAILED')
        }
      }
    }
  )

  /**
   * REQUEST A PASSKEY CHALLENGE
   *
   * Takes no identity: a passkey says which account it belongs to, so there is nobody to name until the
   * assertion comes back. The challenge is remembered on the session.
   */
  app.post<{ Params: { siteId: string } }>(
    '/sites/:siteId/auth/passkey/challenge',
    {
      config: {
        publicAccess: true
      },
      // -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
      onRequest: limitAuthAttempts,
      schema: {
        summary: 'Get the options for logging in with a passkey',
        description:
          "Pass the result to the browser's WebAuthn API, then send what the authenticator produces to `PUT /sites/:siteId/auth/passkey/login`. No credential list is sent and no user is named: passkeys are registered as discoverable credentials, so the authenticator offers whichever ones it holds for this hostname and the assertion identifies the account.",
        tags: ['Authentication'],
        params: {
          type: 'object',
          properties: {
            siteId: {
              type: 'string',
              format: 'uuid'
            }
          },
          required: ['siteId']
        },
        response: {
          200: {
            description: 'Passkey challenge generated',
            type: 'object',
            properties: {
              ok: { type: 'boolean' },
              authOptions: {
                type: 'object',
                additionalProperties: true,
                description: 'A WebAuthn `PublicKeyCredentialRequestOptions`, JSON-encoded.'
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      try {
        const { authOptions, pending } = await WIKI.models.passkeys.startLogin({
          hostname: req.hostname,
          origin: req.headers.origin
        })
        req.session.passkeyLogin = pending
        return {
          ok: true,
          authOptions
        }
      } catch (err: any) {
        if (err.message.startsWith('ERR_')) {
          return reply.badRequest(err.message)
        } else {
          WIKI.logger.debug(err)
          return reply.badRequest('ERR_LOGIN_FAILED')
        }
      }
    }
  )

  /**
   * LOGIN USING A PASSKEY
   */
  app.put<{ Params: { siteId: string }; Body: { authResponse: Record<string, any> } }>(
    '/sites/:siteId/auth/passkey/login',
    {
      config: {
        publicAccess: true
      },
      // -> Guessing is what this endpoint is attacked with; see `helpers/rateLimit.ts`
      onRequest: limitAuthAttempts,
      schema: {
        summary: 'Login With a Passkey',
        description:
          'Verifies what the authenticator signed and, if it holds up, logs the user in. A passkey establishes both identity and presence, so no password or 2FA code is asked for on top of it.',
        tags: ['Authentication'],
        params: {
          type: 'object',
          properties: {
            siteId: {
              type: 'string',
              format: 'uuid'
            }
          },
          required: ['siteId']
        },
        body: {
          type: 'object',
          required: ['authResponse'],
          properties: {
            authResponse: {
              type: 'object',
              additionalProperties: true,
              description: "The browser's WebAuthn authentication response, JSON-encoded."
            }
          }
        },
        response: {
          200: { $ref: 'AuthLoginResult#' }
        }
      }
    },
    async (req, reply) => {
      try {
        const result = await WIKI.models.passkeys.verifyLogin(
          {
            authResponse: req.body.authResponse as any,
            pending: req.session.passkeyLogin,
            ip: req.ip
          },
          req
        )
        return {
          ok: true,
          ...result
        }
      } catch (err: any) {
        if (err.message.startsWith('ERR_')) {
          return reply.badRequest(err.message)
        } else {
          WIKI.logger.debug(err)
          WIKI.models.flags.authDebug(`Passkey login failed unexpectedly: ${err.message}`)
          return reply.badRequest('ERR_LOGIN_FAILED')
        }
      } finally {
        // -> Spent either way: a rejected assertion does not get a second go at the same challenge
        req.session.passkeyLogin = undefined
      }
    }
  )

  /**
   * START A REDIRECT LOGIN
   */
  app.get<{
    Params: { strategyId: string }
    Querystring: { siteId?: string; redirect?: string }
  }>(
    '/auth/:strategyId/authorize',
    {
      config: {
        publicAccess: true
      },
      schema: {
        summary: 'Start a login at an identity provider',
        description:
          'Answers with a redirect to the provider, for a strategy whose module signs users in there rather than through a form — OpenID Connect, Google, GitHub. The `state`, `nonce` and PKCE verifier that tie the answer back to this browser are generated here and kept on the session; the browser is never trusted with any of them.\n\nOpened by following the link, not by fetching it: what comes back is a page at the provider.',
        tags: ['Authentication'],
        params: {
          type: 'object',
          properties: {
            strategyId: { type: 'string', format: 'uuid' }
          },
          required: ['strategyId']
        },
        querystring: {
          type: 'object',
          properties: {
            siteId: { type: 'string', format: 'uuid' },
            redirect: {
              type: 'string',
              maxLength: 255,
              description:
                'Where to send the user once they are logged in. A path on this wiki; anything else is ignored.'
            }
          }
        },
        response: {
          302: { description: 'Redirect to the identity provider', type: 'null' }
        }
      }
    },
    async (req, reply) => {
      const strategy = await WIKI.models.authentication.getStrategyById(req.params.strategyId)
      const instance = WIKI.auth.strategies[req.params.strategyId] as any
      if (!strategy?.isEnabled || typeof instance?.authorizationUrl !== 'function') {
        return reply.notFound('There is no such login provider.')
      }

      const siteId = req.query.siteId ?? WIKI.sitesMappings[req.hostname] ?? ''
      const flow = {
        strategyId: strategy.id,
        siteId,
        state: nanoid(32),
        nonce: nanoid(32),
        codeVerifier: nanoid(64),
        // -> Only a path on this wiki: an open redirect is how a login page is turned into a lure
        redirect: (req.query.redirect ?? '').startsWith('/') ? req.query.redirect! : '/',
        startedAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' })
      }
      req.session.authFlow = flow

      try {
        const url = await instance.authorizationUrl({
          redirectUri: callbackUrl(req, strategy.id),
          state: flow.state,
          nonce: flow.nonce,
          codeVerifier: flow.codeVerifier
        })
        WIKI.models.flags.authDebug(
          `Redirecting to ${strategy.module} provider for strategy ${strategy.id} from ${req.ip}`
        )
        return reply.redirect(url)
      } catch (err: any) {
        WIKI.logger.warn(`Could not start a login at ${strategy.module}: ${err.message}`)
        return reply.redirect(loginErrorUrl(flow.redirect, err.message))
      }
    }
  )

  /**
   * FINISH A REDIRECT LOGIN
   */
  app.get<{
    Params: { strategyId: string }
    Querystring: { code?: string; state?: string; error?: string; error_description?: string }
  }>(
    '/auth/:strategyId/callback',
    {
      config: {
        publicAccess: true
      },
      // -> A callback is a password check by another name: whatever it carries decides who is logged in
      onRequest: limitAuthAttempts,
      schema: {
        summary: 'Finish a login at an identity provider',
        description:
          "Where the provider sends the browser back. The answer is only accepted if it matches the flow this session started — same strategy, same `state`, and within the time a login takes — after which the module turns the code into an account and the session is established. Ends in a redirect either way: to where the login was heading, or to the login screen carrying an error code.\n\nThis is the URL an administrator registers with the provider; it is shown on the strategy's own page in the admin area.",
        tags: ['Authentication'],
        params: {
          type: 'object',
          properties: {
            strategyId: { type: 'string', format: 'uuid' }
          },
          required: ['strategyId']
        },
        response: {
          302: { description: 'Redirect back into the wiki', type: 'null' }
        }
      }
    },
    async (req, reply) => {
      const flow = req.session.authFlow
      const redirect = flow?.redirect ?? '/'
      /*
        Everything about the answer is checked against the flow this session started. A callback that
        arrives with no flow behind it, for another strategy, with a different `state`, or long after
        the login began is not this session's login — and is refused without the code being spent.
      */
      if (
        !flow ||
        flow.strategyId !== req.params.strategyId ||
        !req.query.state ||
        req.query.state !== flow.state ||
        Temporal.Instant.compare(
          Temporal.Instant.from(flow.startedAt).add({ minutes: AUTH_FLOW_MINUTES }),
          Temporal.Now.instant()
        ) < 0
      ) {
        WIKI.models.flags.authDebug(
          `Callback for strategy ${req.params.strategyId} from ${req.ip} did not match this session's login`
        )
        req.session.authFlow = undefined
        return reply.redirect(loginErrorUrl(redirect, 'ERR_LOGIN_EXPIRED'))
      }
      // -> Spent, whatever happens next: one callback per login
      req.session.authFlow = undefined

      if (req.query.error) {
        WIKI.models.flags.authDebug(
          `Provider refused the login for strategy ${flow.strategyId}: ${req.query.error} ${req.query.error_description ?? ''}`
        )
        return reply.redirect(loginErrorUrl(redirect, 'ERR_LOGIN_FAILED'))
      }

      const strategy = await WIKI.models.authentication.getStrategyById(flow.strategyId)
      const instance = WIKI.auth.strategies[flow.strategyId] as any
      if (!strategy?.isEnabled || typeof instance?.profile !== 'function') {
        return reply.redirect(loginErrorUrl(redirect, 'ERR_LOGIN_FAILED'))
      }

      try {
        const profile = await instance.profile({
          redirectUri: callbackUrl(req, strategy.id),
          state: flow.state,
          nonce: flow.nonce,
          codeVerifier: flow.codeVerifier,
          currentUrl: `${callbackUrl(req, strategy.id)}?${new URLSearchParams(req.query as Record<string, string>).toString()}`,
          code: req.query.code
        })
        const result = await WIKI.models.users.loginWithProvider(
          { siteId: flow.siteId, strategy, profile, ip: req.ip },
          req
        )
        return reply.redirect(result.redirect || redirect)
      } catch (err: any) {
        WIKI.models.flags.authDebug(
          `Login through ${strategy.module} strategy ${strategy.id} failed: ${err.message}`
        )
        return reply.redirect(loginErrorUrl(redirect, err.message))
      }
    }
  )

  /**
   * LOGOUT
   */
  app.post<{ Params: { siteId: string } }>(
    '/sites/:siteId/auth/logout',
    {
      config: {
        publicAccess: true
      },
      schema: {
        summary: 'Logout',
        description:
          "Destroys the current session and answers with where to send the user next: the first of the user's groups that sets a logout redirect, otherwise the site's own setting, otherwise the site root. A request that was not logged in gets the same answer rather than an error, so that a client acting on a session the server has already forgotten still ends up somewhere sensible.",
        tags: ['Authentication'],
        params: {
          type: 'object',
          properties: {
            siteId: {
              type: 'string',
              format: 'uuid'
            }
          },
          required: ['siteId']
        },
        response: {
          200: {
            description: 'Logged out successfully',
            type: 'object',
            properties: {
              ok: {
                type: 'boolean'
              },
              redirect: {
                type: 'string',
                description: 'A path within this wiki, or an absolute URL if one is configured.'
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const user = req.session?.authenticated ? req.session.user : null

      // -> Resolved before the session goes away, since it depends on who was logged in
      const redirect = await WIKI.models.users.getLogoutRedirect(
        user?.id ?? null,
        req.params.siteId
      )

      if (req.session) {
        // -> Drops the stored session, so the cookie the browser still holds refers to nothing
        await req.session.destroy()
      }
      // -> And clear that cookie too: `destroy()` detaches the session, which leaves the plugin's own
      //    save hook with nothing to do. Name and options match the registration in `index.ts`.
      reply.clearCookie('wikiSession')

      if (user) {
        WIKI.models.flags.authDebug(
          `User ${user.id} <${user.email}> logged out, redirecting to ${redirect}`
        )
        await WIKI.models.hooks.emit('user:logout', {
          userId: user.id,
          ip: req.ip,
          metadata: {
            name: user.name,
            email: user.email
          }
        })
      }

      return {
        ok: true,
        redirect
      }
    }
  )

  /**
   * LIST AUTHENTICATION MODULES
   */
  app.get(
    '/authentication/modules',
    {
      config: {
        permissions: ['manage:system']
      },
      schema: {
        summary: 'List the authentication modules available on this server',
        description:
          'Read from `modules/authentication` at startup, so installing a module means dropping it on disk and restarting. Modules that declare themselves unavailable are not listed.',
        tags: ['Authentication'],
        response: {
          200: {
            description: 'List of authentication modules',
            type: 'array',
            items: { $ref: 'AuthModule#' }
          }
        }
      }
    },
    async () => {
      return WIKI.models.authentication.getModules()
    }
  )

  /**
   * LIST CONFIGURED STRATEGIES
   */
  app.get(
    '/authentication/strategies',
    {
      config: {
        permissions: ['manage:system']
      },
      schema: {
        summary: 'List the configured authentication strategies',
        description:
          'Instance-wide, i.e. every strategy regardless of which sites offer it. Which of them a given site shows on its login screen, and in what order, is part of that site’s configuration. A configuration value belonging to a prop marked `sensitive` is write-only and comes back masked, never as the stored secret.',
        tags: ['Authentication'],
        response: {
          200: {
            description: 'List of configured strategies',
            type: 'array',
            items: { $ref: 'AuthStrategy#' }
          }
        }
      }
    },
    async () => {
      return (await WIKI.models.authentication.getActiveStrategies()).map(withoutSecrets)
    }
  )

  /**
   * GET CONFIGURED STRATEGY
   */
  app.get<{ Params: { strategyId: string } }>(
    '/authentication/strategies/:strategyId',
    {
      config: {
        permissions: ['manage:system']
      },
      schema: {
        summary: 'Get a single configured authentication strategy',
        tags: ['Authentication'],
        params: {
          type: 'object',
          properties: {
            strategyId: {
              type: 'string',
              format: 'uuid'
            }
          },
          required: ['strategyId']
        },
        response: {
          200: { $ref: 'AuthStrategy#' }
        }
      }
    },
    async (req, reply) => {
      const strategy = await WIKI.models.authentication.getStrategyById(req.params.strategyId)
      if (!strategy) {
        return reply.notFound('Authentication strategy does not exist.')
      }
      return withoutSecrets(strategy)
    }
  )

  /**
   * CREATE STRATEGY
   */
  app.post<{ Body: Record<string, any> }>(
    '/authentication/strategies',
    {
      config: {
        permissions: ['manage:system']
      },
      schema: {
        summary: 'Configure a new authentication strategy',
        description:
          'A module can be configured more than once, so that two instances of the same provider can coexist. A new strategy is not offered by any site until that site adds it to its login screen.',
        tags: ['Authentication'],
        body: {
          allOf: [{ $ref: 'AuthStrategyInput#' }, { type: 'object', required: ['module'] }]
        },
        response: {
          200: {
            description: 'Strategy created successfully',
            type: 'object',
            properties: {
              ok: {
                type: 'boolean'
              },
              message: {
                type: 'string'
              },
              id: {
                type: 'string',
                format: 'uuid'
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const mod = WIKI.models.authentication.getModule(req.body.module)
      if (!mod) {
        return reply.badRequest(`There is no authentication module named "${req.body.module}".`)
      }

      const invalid =
        (await WIKI.models.authentication.validateStrategy({
          module: req.body.module,
          displayName: req.body.displayName,
          isEnabled: req.body.isEnabled,
          allowedEmailRegex: req.body.allowedEmailRegex,
          autoEnrollGroups: req.body.autoEnrollGroups
        })) ?? WIKI.models.authentication.validateConfig(req.body.module, req.body.config)
      if (invalid) {
        return reply.badRequest(invalid)
      }

      const id = await WIKI.models.authentication.createStrategy(req.body as any)

      return {
        ok: true,
        message: 'Authentication strategy created successfully.',
        id
      }
    }
  )

  /**
   * UPDATE STRATEGY
   */
  app.put<{ Params: { strategyId: string }; Body: Record<string, any> }>(
    '/authentication/strategies/:strategyId',
    {
      config: {
        permissions: ['manage:system']
      },
      schema: {
        summary: 'Update an authentication strategy',
        description:
          'Accepts any subset of the fields, except `module`, which is fixed once a strategy exists. The strategies are reloaded on success, so a configuration change applies to the next login rather than after a restart.',
        tags: ['Authentication'],
        params: {
          type: 'object',
          properties: {
            strategyId: {
              type: 'string',
              format: 'uuid'
            }
          },
          required: ['strategyId']
        },
        body: { $ref: 'AuthStrategyInput#' },
        response: {
          200: {
            description: 'Strategy updated successfully',
            type: 'object',
            properties: {
              ok: {
                type: 'boolean'
              },
              message: {
                type: 'string'
              }
            }
          }
        }
      }
    },
    async (req, reply) => {
      const current = await WIKI.models.authentication.getStrategyById(req.params.strategyId)
      if (!current) {
        return reply.notFound('Authentication strategy does not exist.')
      }
      if (req.body.module !== undefined && req.body.module !== current.module) {
        return reply.badRequest('The module of an existing strategy cannot be changed.')
      }

      const patch: Record<string, any> = {}
      for (const field of [
        'displayName',
        'isEnabled',
        'registration',
        'allowedEmailRegex',
        'autoEnrollGroups',
        'config'
      ] as const) {
        if (req.body[field] !== undefined) {
          patch[field] = req.body[field]
        }
      }
      if (Object.keys(patch).length < 1) {
        return reply.badRequest('No strategy fields provided to update.')
      }

      const invalid =
        (await WIKI.models.authentication.validateStrategy({
          id: current.id,
          module: current.module,
          ...patch
        })) ?? WIKI.models.authentication.validateConfig(current.module, patch.config)
      if (invalid) {
        return reply.badRequest(invalid)
      }

      if (!(await WIKI.models.authentication.updateStrategy(req.params.strategyId, patch))) {
        return reply.internalServerError('Failed to update the authentication strategy.')
      }

      return {
        ok: true,
        message: 'Authentication strategy updated successfully.'
      }
    }
  )

  /**
   * DELETE STRATEGY
   */
  app.delete<{ Params: { strategyId: string } }>(
    '/authentication/strategies/:strategyId',
    {
      config: {
        permissions: ['manage:system']
      },
      schema: {
        summary: 'Delete an authentication strategy',
        description:
          'Also removes it from every site’s login screen. The built-in local strategy cannot be deleted: every account stores its password under that strategy ID, so removing it would leave no way in.',
        tags: ['Authentication'],
        params: {
          type: 'object',
          properties: {
            strategyId: {
              type: 'string',
              format: 'uuid'
            }
          },
          required: ['strategyId']
        },
        response: {
          204: {
            description: 'Strategy deleted successfully'
          }
        }
      }
    },
    async (req, reply) => {
      const strategy = await WIKI.models.authentication.getStrategyById(req.params.strategyId)
      if (!strategy) {
        return reply.notFound('Authentication strategy does not exist.')
      }
      if (strategy.id === WIKI.data.systemIds.localAuthId) {
        return reply.conflict('The built-in local strategy cannot be deleted.')
      }

      await WIKI.models.authentication.deleteStrategy(req.params.strategyId)
      return reply.code(204).send()
    }
  )
}

export default routes
