const { Request, Response } = require('express')
const passport = require('passport')
const OpenIDClient = require('openid-client')
const axios = require('axios')
const Database = require('../Database')
const Logger = require('../Logger')
const { getRequestOrigin } = require('../utils/requestUtils')

/**
 * OpenID Connect authentication strategy
 */
class OidcAuthStrategy {
  constructor() {
    this.name = 'openid-client'
    this.strategy = null
    this.client = null
    // Map of openId sessions indexed by oauth2 state-variable
    this.openIdAuthSession = new Map()
  }

  /**
   * Get the passport strategy instance
   * @returns {OpenIDClient.Strategy}
   */
  getStrategy() {
    if (!this.strategy) {
      this.strategy = new OpenIDClient.Strategy(
        {
          client: this.getClient(),
          params: {
            redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
            scope: this.getScope()
          }
        },
        this.verifyCallback.bind(this)
      )
    }
    return this.strategy
  }

  /**
   * Get the OpenID Connect client
   * @returns {OpenIDClient.Client}
   */
  getClient() {
    if (!this.client) {
      if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
        throw new Error('OpenID Connect settings are not valid')
      }

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

      this.client = new openIdIssuerClient({
        client_id: global.ServerSettings.authOpenIDClientID,
        client_secret: global.ServerSettings.authOpenIDClientSecret,
        id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm
      })
    }
    return this.client
  }

  /**
   * Get the scope string for the OpenID Connect request
   * @returns {string}
   */
  getScope() {
    let scope = 'openid profile email'
    if (global.ServerSettings.authOpenIDGroupClaim) {
      scope += ' ' + global.ServerSettings.authOpenIDGroupClaim
    }
    if (global.ServerSettings.authOpenIDAdvancedPermsClaim) {
      scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim
    }
    return scope
  }

  /**
   * Initialize the strategy with passport
   */
  init() {
    if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
      Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`)
      return
    }
    passport.use(this.name, this.getStrategy())
  }

  /**
   * Remove the strategy from passport
   */
  unuse() {
    passport.unuse(this.name)
    this.strategy = null
    this.client = null
  }

  /**
   * Verify callback for OpenID Connect authentication
   * @param {Object} tokenset
   * @param {Object} userinfo
   * @param {Function} done - Passport callback
   */
  async verifyCallback(tokenset, userinfo, done) {
    let isNewUser = false
    let user = null
    try {
      Logger.debug(`[OidcAuth] 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`)
      }

      user = await Database.userModel.findUserFromOpenIdUserInfo(userinfo)

      if (user?.error) {
        throw new Error('Invalid userinfo or already linked')
      }

      if (!user) {
        // If no existing user was matched, auto-register if configured
        if (global.ServerSettings.authOpenIDAutoRegister) {
          Logger.info(`[User] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo)
          user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo)
          isNewUser = true
        } else {
          Logger.warn(`[User] openid: User not found and auto-register is disabled`)
        }
      }

      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(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`)
      // Remove new user if an error occurs
      if (isNewUser && user) {
        await user.destroy()
      }
      return done(null, null, 'Unauthorized')
    }
  }

  /**
   * Validates the presence and content of the group claim in userinfo.
   * @param {Object} userinfo
   * @returns {boolean}
   */
  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('../models/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(`[OidcAuth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`)
        user.type = userType
        await user.save()
      }
    } 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('../models/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 (await user.updatePermissionsFromExternalJSON(absPermissions)) {
      Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
    }
  }

  /**
   * Generate PKCE parameters for the authorization request
   * @param {Request} req
   * @param {boolean} isMobileFlow
   * @returns {Object|{error: string}}
   */
  generatePkce(req, isMobileFlow) {
    if (isMobileFlow) {
      if (!req.query.code_challenge) {
        return {
          error: 'code_challenge required for mobile flow (PKCE)'
        }
      }
      if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') {
        return {
          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 }
    }
  }

  /**
   * Check if a redirect URI is valid
   * @param {string} uri
   * @returns {boolean}
   */
  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] === '*')
  }

  /**
   * Get the authorization URL for OpenID Connect
   * Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow
   * @param {Request} req
   * @returns {{ authorizationUrl: string }|{status: number, error: string}}
   */
  getAuthorizationUrl(req) {
    const client = this.getClient()
    const strategy = this.getStrategy()
    const sessionKey = strategy._key

    try {
      const { origin } = getRequestOrigin(req)
      const hostUrl = new URL(origin)
      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(`[OidcAuth] OIDC Invalid response_type=${req.query.response_type}`)
        return {
          status: 400,
          error: '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 || !this.isValidRedirectUri(req.query.redirect_uri)) {
          Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`)
          return {
            status: 400,
            error: '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(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
      } else {
        redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()

        if (req.query.state) {
          Logger.debug(`[OidcAuth] Invalid state - not allowed on web openid flow`)
          return {
            status: 400,
            error: 'Invalid state, not allowed on web flow'
          }
        }
      }

      // Update the strategy's redirect_uri for this request
      strategy._params.redirect_uri = redirectUri
      Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`)

      const pkceData = this.generatePkce(req, isMobileFlow)
      if (pkceData.error) {
        return {
          status: 400,
          error: pkceData.error
        }
      }

      req.session[sessionKey] = {
        ...req.session[sessionKey],
        state: state,
        max_age: strategy._params.max_age,
        response_type: 'code',
        code_verifier: pkceData.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: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback
      }

      const authorizationUrl = client.authorizationUrl({
        ...strategy._params,
        redirect_uri: redirectUri,
        state: state,
        response_type: 'code',
        scope: this.getScope(),
        code_challenge: pkceData.code_challenge,
        code_challenge_method: pkceData.code_challenge_method
      })

      return {
        authorizationUrl,
        isMobileFlow
      }
    } catch (error) {
      Logger.error(`[OidcAuth] Error generating authorization URL: ${error}\n${error?.stack}`)
      return {
        status: 500,
        error: error.message || 'Unknown error'
      }
    }
  }

  /**
   * Get the end session URL for logout
   * @param {Request} req
   * @param {string} idToken
   * @param {string} authMethod
   * @returns {string|null}
   */
  getEndSessionUrl(req, idToken, authMethod) {
    const client = this.getClient()

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

      if (authMethod === 'openid') {
        const { origin } = getRequestOrigin(req)
        // 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 = `${origin}${global.RouterBasePath}/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)

      return client.endSessionUrl({
        id_token_hint: idToken,
        post_logout_redirect_uri: postLogoutRedirectUri
      })
    }

    return null
  }

  /**
   * @typedef {Object} OpenIdIssuerConfig
   * @property {string} issuer
   * @property {string} authorization_endpoint
   * @property {string} token_endpoint
   * @property {string} userinfo_endpoint
   * @property {string} end_session_endpoint
   * @property {string} jwks_uri
   * @property {string} id_token_signing_alg_values_supported
   *
   * Get OpenID Connect configuration from an issuer URL
   * @param {string} issuerUrl
   * @returns {Promise<OpenIdIssuerConfig|{status: number, error: string}>}
   */
  async getIssuerConfig(issuerUrl) {
    // Strip trailing slash
    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(`[OidcAuth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
      return {
        status: 400,
        error: "Invalid request. Query param 'issuer' is invalid"
      }
    }

    try {
      const { data } = await axios.get(configUrl.toString())
      return {
        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(`[OidcAuth] Failed to get openid configuration at "${configUrl}"`, error)
      return {
        status: 400,
        error: 'Failed to get openid configuration'
      }
    }
  }

  /**
   * Handle mobile redirect for OAuth2 callback
   * @param {Request} req
   * @param {Response} res
   */
  handleMobileRedirect(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('[OidcAuth] /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('[OidcAuth] 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(`[OidcAuth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`)
      res.status(500).send('Internal Server Error')
    }
  }

  /**
   * Validates if a callback URL is safe for redirect (same-origin only)
   * @param {string} callbackUrl - The callback URL to validate
   * @param {Request} req - Express request object to get current host
   * @returns {boolean} - True if the URL is safe (same-origin), false otherwise
   */
  isValidWebCallbackUrl(callbackUrl, req) {
    if (!callbackUrl) return false

    try {
      // Reject protocol-relative (//host) and backslash-prefixed (/\host) values,
      // which browsers resolve to a cross-origin absolute URL.
      if (callbackUrl.startsWith('//') || callbackUrl.startsWith('/\\')) {
        Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL: ${callbackUrl}`)
        return false
      }

      const { origin: serverOrigin } = getRequestOrigin(req)
      const resolvedUrl = callbackUrl.startsWith('/') ? new URL(callbackUrl, serverOrigin) : new URL(callbackUrl)

      if (resolvedUrl.origin !== serverOrigin) {
        Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${serverOrigin})`)
        return false
      }

      const pathname = decodeURIComponent(resolvedUrl.pathname)
      if (pathname.startsWith('//') || pathname.startsWith('/\\')) {
        Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL path: ${callbackUrl}`)
        return false
      }

      if (!resolvedUrl.pathname.startsWith(global.RouterBasePath + '/')) {
        Logger.warn(`[OidcAuth] Rejected callback URL outside router base path: ${callbackUrl}`)
        return false
      }

      return true
    } catch (error) {
      Logger.error(`[OidcAuth] Invalid callback URL format: ${callbackUrl}`, error)
      return false
    }
  }
}

module.exports = OidcAuthStrategy
