const axios = require('axios')
const passport = require('passport')
const bcrypt = require('./libs/bcryptjs')
const jwt = require('./libs/jsonwebtoken')
const LocalStrategy = require('./libs/passportLocal')
const JwtStrategy = require('passport-jwt').Strategy
const ExtractJwt = require('passport-jwt').ExtractJwt
const OpenIDClient = require('openid-client')
const Database = require('./Database')
const Logger = require('./Logger')

/**
 * @class Class for handling all the authentication related functionality.
 */
class Auth {

  constructor() {
    // Map of openId sessions indexed by oauth2 state-variable
    this.openIdAuthSession = new Map()
  }

  /**
   * Inializes all passportjs strategies and other passportjs ralated initialization.
   */
  async initPassportJs() {
    // Check if we should load the local strategy (username + password login)
    if (global.ServerSettings.authActiveAuthMethods.includes("local")) {
      this.initAuthStrategyPassword()
    }

    // Check if we should load the openid strategy
    if (global.ServerSettings.authActiveAuthMethods.includes("openid")) {
      this.initAuthStrategyOpenID()
    }

    // Load the JwtStrategy (always) -> for bearer token auth 
    passport.use(new JwtStrategy({
      jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
      secretOrKey: Database.serverSettings.tokenSecret
    }, this.jwtAuthCheck.bind(this)))

    // define how to seralize a user (to be put into the session)
    passport.serializeUser(function (user, cb) {
      process.nextTick(function () {
        // only store id to session
        return cb(null, JSON.stringify({
          id: user.id,
        }))
      })
    })

    // define how to deseralize a user (use the ID to get it from the database)
    passport.deserializeUser((function (user, cb) {
      process.nextTick((async function () {
        const parsedUserInfo = JSON.parse(user)
        // load the user by ID that is stored in the session
        const dbUser = await Database.userModel.getUserById(parsedUserInfo.id)
        return cb(null, dbUser)
      }).bind(this))
    }).bind(this))
  }

  /**
   * Passport use LocalStrategy
   */
  initAuthStrategyPassword() {
    passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this)))
  }

  /**
   * Passport use OpenIDClient.Strategy
   */
  initAuthStrategyOpenID() {
    if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
      Logger.error(`[Auth] Cannot init openid auth strategy - invalid settings`)
      return
    }

    // Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing
    OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 })

    const openIdIssuerClient = new OpenIDClient.Issuer({
      issuer: global.ServerSettings.authOpenIDIssuerURL,
      authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL,
      token_endpoint: global.ServerSettings.authOpenIDTokenURL,
      userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL,
      jwks_uri: global.ServerSettings.authOpenIDJwksURL,
      end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL
    }).Client
    const openIdClient = new openIdIssuerClient({
      client_id: global.ServerSettings.authOpenIDClientID,
      client_secret: global.ServerSettings.authOpenIDClientSecret,
      id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm
    })
    passport.use('openid-client', new OpenIDClient.Strategy({
      client: openIdClient,
      params: {
        redirect_uri: '/auth/openid/callback',
        scope: 'openid profile email'
      }
    }, async (tokenset, userinfo, done) => {
      try {
        Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))

        if (!userinfo.sub) {
          throw new Error('Invalid userinfo, no sub')
        }

        if (!this.validateGroupClaim(userinfo)) {
          throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
        }

        let user = await this.findOrCreateUser(userinfo)

        if (!user?.isActive) {
          throw new Error('User not active or not found')
        }

        await this.setUserGroup(user, userinfo)
        await this.updateUserPermissions(user, userinfo)

        // We also have to save the id_token for later (used for logout) because we cannot set cookies here
        user.openid_id_token = tokenset.id_token

        return done(null, user)
      } catch (error) {
        Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`)

        return done(null, null, 'Unauthorized')
      }
    }))
  }

  /**
   * Finds an existing user by OpenID subject identifier, or by email/username based on server settings,
   * or creates a new user if configured to do so.
   */
  async findOrCreateUser(userinfo) {
    let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub)

    // Matched by sub
    if (user) {
      Logger.debug(`[Auth] openid: User found by sub`)
      return user
    }

    // Match existing user by email
    if (Database.serverSettings.authOpenIDMatchExistingBy === 'email') {
      if (userinfo.email) {
        // Only disallow when email_verified explicitly set to false (allow both if not set or true)
        if (userinfo.email_verified === false) {
          Logger.warn(`[Auth] openid: User not found and email "${userinfo.email}" is not verified`)
          return null
        } else {
          Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`)
          user = await Database.userModel.getUserByEmail(userinfo.email)

          if (user?.authOpenIDSub) {
            Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`)
            return null // User is linked to a different OpenID subject; do not proceed.
          }
        }
      } else {
        Logger.warn(`[Auth] openid: User not found and no email in userinfo`)
        // We deny login, because if the admin whishes to match email, it makes sense to require it
        return null
      }
    }
    // Match existing user by username
    else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username') {
      let username

      if (userinfo.preferred_username) {
        Logger.info(`[Auth] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`)
        username = userinfo.preferred_username
      } else if (userinfo.username) {
        Logger.info(`[Auth] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`)
        username = userinfo.username
      } else {
        Logger.warn(`[Auth] openid: User not found and neither preferred_username nor username in userinfo`)
        return null
      }


      user = await Database.userModel.getUserByUsername(username)

      if (user?.authOpenIDSub) {
        Logger.warn(`[Auth] openid: User found with username "${username}" but is already matched with sub "${user.authOpenIDSub}"`)
        return null // User is linked to a different OpenID subject; do not proceed.
      }
    }

    // Found existing user via email or username
    if (user) {
      if (!user.isActive) {
        Logger.warn(`[Auth] openid: User found but is not active`)
        return null
      }

      user.authOpenIDSub = userinfo.sub
      await Database.userModel.updateFromOld(user)

      Logger.debug(`[Auth] openid: User found by email/username`)
      return user
    }

    // If no existing user was matched, auto-register if configured
    if (Database.serverSettings.authOpenIDAutoRegister) {
      Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo)
      user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this)
      return user
    }

    Logger.warn(`[Auth] openid: User not found and auto-register is disabled`)
    return null
  }

  /**
   * Validates the presence and content of the group claim in userinfo.
   */
  validateGroupClaim(userinfo) {
    const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
    if (!groupClaimName) // Allow no group claim when configured like this
      return true

    // If configured it must exist in userinfo
    if (!userinfo[groupClaimName]) {
      return false
    }
    return true
  }

  /**
   * Sets the user group based on group claim in userinfo.
   * 
   * @param {import('./objects/user/User')} user
   * @param {Object} userinfo
   */
  async setUserGroup(user, userinfo) {
    const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
    if (!groupClaimName) // No group claim configured, don't set anything
      return

    if (!userinfo[groupClaimName])
      throw new Error(`Group claim ${groupClaimName} not found in userinfo`)

    const groupsList = userinfo[groupClaimName].map(group => group.toLowerCase())
    const rolesInOrderOfPriority = ['admin', 'user', 'guest']

    let userType = rolesInOrderOfPriority.find(role => groupsList.includes(role))
    if (userType) {
      if (user.type === 'root') {
        // Check OpenID Group
        if (userType !== 'admin') {
          throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`)
        } else {
          // If root user is logging in via OpenID, we will not change the type
          return
        }
      }

      if (user.type !== userType) {
        Logger.info(`[Auth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`)
        user.type = userType
        await Database.userModel.updateFromOld(user)
      }
    } else {
      throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`)
    }
  }

  /**
   * Updates user permissions based on the advanced permissions claim.
   * 
   * @param {import('./objects/user/User')} user
   * @param {Object} userinfo
   */
  async updateUserPermissions(user, userinfo) {
    const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim
    if (!absPermissionsClaim) // No advanced permissions claim configured, don't set anything
      return

    if (user.type === 'admin' || user.type === 'root')
      return

    const absPermissions = userinfo[absPermissionsClaim]
    if (!absPermissions)
      throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)

    if (user.updatePermissionsFromExternalJSON(absPermissions)) {
      Logger.info(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
      await Database.userModel.updateFromOld(user)
    }
  }

  /**
   * Unuse strategy
   * 
   * @param {string} name 
   */
  unuseAuthStrategy(name) {
    passport.unuse(name)
  }

  /**
   * Use strategy
   * 
   * @param {string} name 
   */
  useAuthStrategy(name) {
    if (name === 'openid') {
      this.initAuthStrategyOpenID()
    } else if (name === 'local') {
      this.initAuthStrategyPassword()
    } else {
      Logger.error('[Auth] Invalid auth strategy ' + name)
    }
  }

  /**
   * Returns if the given auth method is API based.
   *
   * @param {string} authMethod
   * @returns {boolean}
   */
  isAuthMethodAPIBased(authMethod) {
    return ['api', 'openid-mobile'].includes(authMethod)
  }

  /**
   * Stores the client's choice of login callback method in temporary cookies.
   *
   * The `authMethod` parameter specifies the authentication strategy and can have the following values:
   * - 'local': Standard authentication,
   * - 'api': Authentication for API use
   * - 'openid': OpenID authentication directly over web
   * - 'openid-mobile': OpenID authentication, but done via an mobile device
   * 
   * @param {import('express').Request} req
   * @param {import('express').Response} res
   * @param {string} authMethod - The authentication method, default is 'local'.
   */
  paramsToCookies(req, res, authMethod = 'local') {
    const TWO_MINUTES = 120000 // 2 minutes in milliseconds
    const callback = req.query.redirect_uri || req.query.callback

    // Additional handling for non-API based authMethod
    if (!this.isAuthMethodAPIBased(authMethod)) {
      // Store 'auth_state' if present in the request
      if (req.query.state) {
        res.cookie('auth_state', req.query.state, { maxAge: TWO_MINUTES, httpOnly: true })
      }

      // Validate and store the callback URL
      if (!callback) {
        return res.status(400).send({ message: 'No callback parameter' })
      }
      res.cookie('auth_cb', callback, { maxAge: TWO_MINUTES, httpOnly: true })
    }

    // Store the authentication method for long
    res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true })
  }

  /**
   * Informs the client in the right mode about a successfull login and the token
   * (clients choise is restored from cookies).
   * 
   * @param {import('express').Request} req
   * @param {import('express').Response} res
   */
  async handleLoginSuccessBasedOnCookie(req, res) {
    // get userLogin json (information about the user, server and the session)
    const data_json = await this.getUserLoginResponsePayload(req.user)

    if (this.isAuthMethodAPIBased(req.cookies.auth_method)) {
      // REST request - send data
      res.json(data_json)
    } else {
      // UI request -> check if we have a callback url
      // TODO: do we want to somehow limit the values for auth_cb?
      if (req.cookies.auth_cb) {
        let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : ''
        // UI request -> redirect to auth_cb url and send the jwt token as parameter
        res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}${stateQuery}`)
      } else {
        res.status(400).send('No callback or already expired')
      }
    }
  }

  /**
   * Creates all (express) routes required for authentication.
   * 
   * @param {import('express').Router} router 
   */
  async initAuthRoutes(router) {
    // Local strategy login route (takes username and password)
    router.post('/login', passport.authenticate('local'), async (req, res) => {
      // return the user login response json if the login was successfull
      res.json(await this.getUserLoginResponsePayload(req.user))
    })

    // openid strategy login route (this redirects to the configured openid login provider)
    router.get('/auth/openid', (req, res, next) => {
      // Get the OIDC client from the strategy
      // We need to call the client manually, because the strategy does not support forwarding the code challenge
      //    for API or mobile clients
      const oidcStrategy = passport._strategy('openid-client')
      const client = oidcStrategy._client
      const sessionKey = oidcStrategy._key

      try {
        const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
        const hostUrl = new URL(`${protocol}://${req.get('host')}`)
        const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge

        // Only allow code flow (for mobile clients)
        if (req.query.response_type && req.query.response_type !== 'code') {
          Logger.debug(`[Auth] OIDC Invalid response_type=${req.query.response_type}`)
          return res.status(400).send('Invalid response_type, only code supported')
        }

        // Generate a state on web flow or if no state supplied
        const state = (!isMobileFlow || !req.query.state) ? OpenIDClient.generators.random() : req.query.state

        // Redirect URL for the SSO provider
        let redirectUri
        if (isMobileFlow) {
          // Mobile required redirect uri
          // If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect
          //    where we will handle the redirect to it
          if (!req.query.redirect_uri || !isValidRedirectUri(req.query.redirect_uri)) {
            Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri}`)
            return res.status(400).send('Invalid redirect_uri')
          }
          // We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API
          //   for the request to mobile-redirect and as such the session is not shared
          this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })

          redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString()
        } else {
          redirectUri = new URL('/auth/openid/callback', hostUrl).toString()

          if (req.query.state) {
            Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
            return res.status(400).send('Invalid state, not allowed on web flow')
          }
        }
        oidcStrategy._params.redirect_uri = redirectUri
        Logger.debug(`[Auth] OIDC redirect_uri=${redirectUri}`)

        let { code_challenge, code_challenge_method, code_verifier } = generatePkce(req, isMobileFlow)

        req.session[sessionKey] = {
          ...req.session[sessionKey],
          state: state,
          max_age: oidcStrategy._params.max_age,
          response_type: 'code',
          code_verifier: code_verifier, // not null if web flow
          mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
          sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback
        }

        var scope = 'openid profile email'
        if (global.ServerSettings.authOpenIDGroupClaim) {
          scope += ' ' + global.ServerSettings.authOpenIDGroupClaim
        }
        if (global.ServerSettings.authOpenIDAdvancedPermsClaim) {
          scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim
        }

        const authorizationUrl = client.authorizationUrl({
          ...oidcStrategy._params,
          state: state,
          response_type: 'code',
          scope: scope,
          code_challenge,
          code_challenge_method
        })

        this.paramsToCookies(req, res, isMobileFlow ? 'openid-mobile' : 'openid')

        res.redirect(authorizationUrl)
      } catch (error) {
        Logger.error(`[Auth] Error in /auth/openid route: ${error}\n${error?.stack}`)
        res.status(500).send('Internal Server Error')
      }

      function generatePkce(req, isMobileFlow) {
        if (isMobileFlow) {
          if (!req.query.code_challenge) {
            throw new Error('code_challenge required for mobile flow (PKCE)')
          }
          if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') {
            throw new Error('Only S256 code_challenge_method method supported')
          }
          return {
            code_challenge: req.query.code_challenge,
            code_challenge_method: req.query.code_challenge_method || 'S256'
          }
        } else {
          const code_verifier = OpenIDClient.generators.codeVerifier()
          const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier)
          return { code_challenge, code_challenge_method: 'S256', code_verifier }
        }
      }

      function isValidRedirectUri(uri) {
        // Check if the redirect_uri is in the whitelist
        return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) ||
          (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
      }
    })

    // This will be the oauth2 callback route for mobile clients
    // It will redirect to an app-link like audiobookshelf://oauth
    router.get('/auth/openid/mobile-redirect', (req, res) => {
      try {
        // Extract the state parameter from the request
        const { state, code } = req.query

        // Check if the state provided is in our list
        if (!state || !this.openIdAuthSession.has(state)) {
          Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch')
          return res.status(400).send('State parameter mismatch')
        }

        let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri

        if (!mobile_redirect_uri) {
          Logger.error('[Auth] No redirect URI')
          return res.status(400).send('No redirect URI')
        }

        this.openIdAuthSession.delete(state)

        const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
        // Redirect to the overwrite URI saved in the map
        res.redirect(redirectUri)
      } catch (error) {
        Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`)
        res.status(500).send('Internal Server Error')
      }
    })

    // openid strategy callback route (this receives the token from the configured openid login provider)
    router.get('/auth/openid/callback', (req, res, next) => {
      const oidcStrategy = passport._strategy('openid-client')
      const sessionKey = oidcStrategy._key

      if (!req.session[sessionKey]) {
        return res.status(400).send('No session')
      }

      // If the client sends us a code_verifier, we will tell passport to use this to send this in the token request
      // The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request
      // Crucial for API/Mobile clients
      if (req.query.code_verifier) {
        req.session[sessionKey].code_verifier = req.query.code_verifier
      }

      function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
        Logger.error(JSON.stringify(logMessage, null, 2))
        if (response) {
          // Depending on the error, it can also have a body
          // We also log the request header the passport plugin sents for the URL
          const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
          Logger.debug(header + '\n' + JSON.stringify(response.body, null, 2))
        }

        if (isMobile) {
          return res.status(errorCode).send(errorMessage)
        } else {
          return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`)
        }
      }

      function passportCallback(req, res, next) {
        return (err, user, info) => {
          const isMobile = req.session[sessionKey]?.mobile === true
          if (err) {
            return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response)
          }

          if (!user) {
            // Info usually contains the error message from the SSO provider
            return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response)
          }

          req.logIn(user, (loginError) => {
            if (loginError) {
              return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)
            }

            // The id_token does not provide access to the user, but is used to identify the user to the SSO provider
            //   instead it containts a JWT with userinfo like user email, username, etc.
            //   the client will get to know it anyway in the logout url according to the oauth2 spec
            //   so it is safe to send it to the client, but we use strict settings
            res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' })
            next()
          })
        }
      }


      // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request
      // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided
      // We set it here again because the passport param can change between requests
      return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next)
    },
      // on a successfull login: read the cookies and react like the client requested (callback or json)
      this.handleLoginSuccessBasedOnCookie.bind(this))

    /**
     * Helper route used to auto-populate the openid URLs in config/authentication
     * Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration"
     * 
     * @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/
     */
    router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => {
      if (!req.user.isAdminOrUp) {
        Logger.error(`[Auth] Non-admin user "${req.user.username}" attempted to get issuer config`)
        return res.sendStatus(403)
      }

      if (!req.query.issuer) {
        return res.status(400).send('Invalid request. Query param \'issuer\' is required')
      }

      // Strip trailing slash
      let issuerUrl = req.query.issuer
      if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)

      // Append config pathname and validate URL
      let configUrl = null
      try {
        configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`)
        if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) {
          throw new Error('Invalid pathname')
        }
      } catch (error) {
        Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
        return res.status(400).send('Invalid request. Query param \'issuer\' is invalid')
      }

      axios.get(configUrl.toString()).then(({ data }) => {
        res.json({
          issuer: data.issuer,
          authorization_endpoint: data.authorization_endpoint,
          token_endpoint: data.token_endpoint,
          userinfo_endpoint: data.userinfo_endpoint,
          end_session_endpoint: data.end_session_endpoint,
          jwks_uri: data.jwks_uri,
          id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported
        })
      }).catch((error) => {
        Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error)
        res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`)
      })
    })

    // Logout route
    router.post('/logout', (req, res) => {
      // TODO: invalidate possible JWTs
      req.logout((err) => {
        if (err) {
          res.sendStatus(500)
        } else {
          const authMethod = req.cookies.auth_method

          res.clearCookie('auth_method')

          let logoutUrl = null

          if (authMethod === 'openid' || authMethod === 'openid-mobile') {
            // If we are using openid, we need to redirect to the logout endpoint
            // node-openid-client does not support doing it over passport
            const oidcStrategy = passport._strategy('openid-client')
            const client = oidcStrategy._client

            if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) {
              let postLogoutRedirectUri = null

              if (authMethod === 'openid') {
                const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http'
                const host = req.get('host')
                // TODO: ABS does currently not support subfolders for installation
                // If we want to support it we need to include a config for the serverurl
                postLogoutRedirectUri = `${protocol}://${host}/login`
              }
              // else for openid-mobile we keep postLogoutRedirectUri on null
              //  nice would be to redirect to the app here, but for example Authentik does not implement
              //  the post_logout_redirect_uri parameter at all and for other providers
              //  we would also need again to implement (and even before get to know somehow for 3rd party apps)
              //  the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect).
              //   Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like
              //  &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution
              //   (The URL needs to be whitelisted in the config of the SSO/ID provider)

              logoutUrl = client.endSessionUrl({
                id_token_hint: req.cookies.openid_id_token,
                post_logout_redirect_uri: postLogoutRedirectUri
              })
            }

            res.clearCookie('openid_id_token')
          }

          // Tell the user agent (browser) to redirect to the authentification provider's logout URL
          // (or redirect_url: null if we don't have one)
          res.send({ redirect_url: logoutUrl })
        }
      })
    })
  }

  /**
   * middleware to use in express to only allow authenticated users.
   * @param {import('express').Request} req 
   * @param {import('express').Response} res 
   * @param {import('express').NextFunction} next  
   */
  isAuthenticated(req, res, next) {
    // check if session cookie says that we are authenticated
    if (req.isAuthenticated()) {
      next()
    } else {
      // try JWT to authenticate
      passport.authenticate("jwt")(req, res, next)
    }
  }

  /**
   * Function to generate a jwt token for a given user
   * 
   * @param {{ id:string, username:string }} user 
   * @returns {string} token
   */
  generateAccessToken(user) {
    return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret)
  }

  /**
   * Function to validate a jwt token for a given user
   * 
   * @param {string} token 
   * @returns {Object} tokens data
   */
  static validateAccessToken(token) {
    try {
      return jwt.verify(token, global.ServerSettings.tokenSecret)
    }
    catch (err) {
      return null
    }
  }

  /**
   * Generate a token which is used to encrpt/protect the jwts.
   */
  async initTokenSecret() {
    if (process.env.TOKEN_SECRET) { // User can supply their own token secret
      Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET
    } else {
      Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
    }
    await Database.updateServerSettings()

    // New token secret creation added in v2.1.0 so generate new API tokens for each user
    const users = await Database.userModel.getOldUsers()
    if (users.length) {
      for (const user of users) {
        user.token = await this.generateAccessToken(user)
      }
      await Database.updateBulkUsers(users)
    }
  }

  /**
   * Checks if the user in the validated jwt_payload really exists and is active.
   * @param {Object} jwt_payload 
   * @param {function} done 
   */
  async jwtAuthCheck(jwt_payload, done) {
    // load user by id from the jwt token
    const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)

    if (!user?.isActive) {
      // deny login
      done(null, null)
      return
    }
    // approve login
    done(null, user)
    return
  }

  /**
   * Checks if a username and password tuple is valid and the user active.
   * @param {string} username 
   * @param {string} password 
   * @param {Promise<function>} done 
   */
  async localAuthCheckUserPw(username, password, done) {
    // Load the user given it's username
    const user = await Database.userModel.getUserByUsername(username.toLowerCase())

    if (!user?.isActive) {
      done(null, null)
      return
    }

    // Check passwordless root user
    if (user.type === 'root' && !user.pash) {
      if (password) {
        // deny login
        done(null, null)
        return
      }
      // approve login
      done(null, user)
      return
    } else if (!user.pash) {
      Logger.error(`[Auth] User "${user.username}"/"${user.type}" attempted to login without a password set`)
      done(null, null)
      return
    }

    // Check password match
    const compare = await bcrypt.compare(password, user.pash)
    if (compare) {
      // approve login
      done(null, user)
      return
    }
    // deny login
    done(null, null)
    return
  }

  /**
   * Hashes a password with bcrypt.
   * @param {string} password 
   * @returns {Promise<string>} hash 
   */
  hashPass(password) {
    return new Promise((resolve) => {
      bcrypt.hash(password, 8, (err, hash) => {
        if (err) {
          resolve(null)
        } else {
          resolve(hash)
        }
      })
    })
  }

  /**
   * Return the login info payload for a user
   * 
   * @param {Object} user 
   * @returns {Promise<Object>} jsonPayload
   */
  async getUserLoginResponsePayload(user) {
    const libraryIds = await Database.libraryModel.getAllLibraryIds()
    return {
      user: user.toJSONForBrowser(),
      userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
      serverSettings: Database.serverSettings.toJSONForBrowser(),
      ereaderDevices: Database.emailSettings.getEReaderDevices(user),
      Source: global.Source
    }
  }

  /**
   * 
   * @param {string} password 
   * @param {import('./models/User')} user 
   * @returns {Promise<boolean>}
   */
  comparePassword(password, user) {
    if (user.type === 'root' && !password && !user.pash) return true
    if (!password || !user.pash) return false
    return bcrypt.compare(password, user.pash)
  }

  /**
   * User changes their password from request
   * 
   * @param {import('express').Request} req 
   * @param {import('express').Response} res 
   */
  async userChangePassword(req, res) {
    let { password, newPassword } = req.body
    newPassword = newPassword || ''
    const matchingUser = req.user

    // Only root can have an empty password
    if (matchingUser.type !== 'root' && !newPassword) {
      return res.json({
        error: 'Invalid new password - Only root can have an empty password'
      })
    }

    // Check password match
    const compare = await this.comparePassword(password, matchingUser)
    if (!compare) {
      return res.json({
        error: 'Invalid password'
      })
    }

    let pw = ''
    if (newPassword) {
      pw = await this.hashPass(newPassword)
      if (!pw) {
        return res.json({
          error: 'Hash failed'
        })
      }
    }

    matchingUser.pash = pw

    const success = await Database.updateUser(matchingUser)
    if (success) {
      Logger.info(`[Auth] User "${matchingUser.username}" changed password`)
      res.json({
        success: true
      })
    } else {
      res.json({
        error: 'Unknown error'
      })
    }
  }
}

module.exports = Auth