'use strict'

const fp = require('fastify-plugin')
const { createSigner, createDecoder, createVerifier, TokenError } = require('fast-jwt')
const assert = require('node:assert')
const steed = require('steed')
const { parse } = require('@lukeed/ms')
const createError = require('@fastify/error')

const messages = {
  badRequestErrorMessage: 'Format is Authorization: Bearer [token]',
  badCookieRequestErrorMessage: 'Cookie could not be parsed in request',
  noAuthorizationInHeaderMessage: 'No Authorization was found in request.headers',
  noAuthorizationInCookieMessage: 'No Authorization was found in request.cookies',
  authorizationTokenExpiredMessage: 'Authorization token expired',
  authorizationTokenInvalid: (err) => `Authorization token is invalid: ${err.message}`,
  authorizationTokenUntrusted: 'Untrusted authorization token',
  authorizationTokenUnsigned: 'Unsigned authorization token'
}

function isString (x) {
  return Object.prototype.toString.call(x) === '[object String]'
}

function wrapStaticSecretInCallback (secret) {
  return function (_request, _payload, cb) {
    return cb(null, secret)
  }
}

function convertToMs (time) {
  // by default if time is number we assume that they are seconds - see README.md
  if (typeof time === 'number') {
    return time * 1000
  }
  return parse(time)
}

function convertTemporalProps (options, isVerifyOptions) {
  if (!options || typeof options === 'function') {
    return options
  }

  const formatedOptions = Object.assign({}, options)

  if (isVerifyOptions && formatedOptions.maxAge) {
    formatedOptions.maxAge = convertToMs(formatedOptions.maxAge)
  } else if (formatedOptions.expiresIn || formatedOptions.notBefore) {
    if (formatedOptions.expiresIn) {
      formatedOptions.expiresIn = convertToMs(formatedOptions.expiresIn)
    }

    if (formatedOptions.notBefore) {
      formatedOptions.notBefore = convertToMs(formatedOptions.notBefore)
    }
  }

  return formatedOptions
}

function validateOptions (options) {
  assert(options.secret, 'missing secret')
  assert(!options.options, 'options prefix is deprecated')

  assert(!options.jwtVerify || isString(options.jwtVerify), 'Invalid options.jwtVerify')
  assert(!options.jwtDecode || isString(options.jwtDecode), 'Invalid options.jwtDecode')
  assert(!options.jwtSign || isString(options.jwtSign), 'Invalid options.jwtSign')

  if (
    options.sign?.algorithm?.includes('RS') &&
    (typeof options.secret === 'string' ||
      options.secret instanceof Buffer)
  ) {
    throw new Error('RSA Signatures set as Algorithm in the options require a private and public key to be set as the secret')
  }
  if (
    options.sign?.algorithm?.includes('ES') &&
    (typeof options.secret === 'string' ||
      options.secret instanceof Buffer)
  ) {
    throw new Error('ECDSA Signatures set as Algorithm in the options require a private and public key to be set as the secret')
  }
}

function fastifyJwt (fastify, options, next) {
  try {
    validateOptions(options)
  } catch (e) {
    return next(e)
  }

  const {
    cookie,
    decode: decodeOptions = {},
    formatUser,
    jwtDecode,
    jwtSign,
    jwtVerify,
    secret,
    sign: initialSignOptions = {},
    trusted,
    decoratorName = 'user',
    // TODO: disable on next major
    // enable errorCacheTTL to prevent breaking change
    verify: initialVerifyOptions = { errorCacheTTL: 600000 },
    ...pluginOptions
  } = options

  const validatorCache = new Map()

  let secretOrPrivateKey
  let secretOrPublicKey

  if (typeof secret === 'object' && !Buffer.isBuffer(secret)) {
    if (!secret.public) {
      return next(new Error('missing public key'))
    }
    secretOrPrivateKey = secret.private
    secretOrPublicKey = secret.public
  } else {
    secretOrPrivateKey = secretOrPublicKey = secret
  }

  let hasStaticPublicKey = false
  let secretCallbackSign = secretOrPrivateKey
  let secretCallbackVerify = secretOrPublicKey
  if (typeof secretCallbackSign !== 'function') {
    secretCallbackSign = wrapStaticSecretInCallback(secretCallbackSign)
  }
  if (typeof secretCallbackVerify !== 'function') {
    secretCallbackVerify = wrapStaticSecretInCallback(secretCallbackVerify)
    hasStaticPublicKey = true
  }

  const signOptions = convertTemporalProps(initialSignOptions)
  const verifyOptions = convertTemporalProps(initialVerifyOptions, true)
  const messagesOptions = Object.assign({}, messages, pluginOptions.messages)
  const namespace = typeof pluginOptions.namespace === 'string' ? pluginOptions.namespace : undefined

  const NoAuthorizationInCookieError = createError('FST_JWT_NO_AUTHORIZATION_IN_COOKIE', messagesOptions.noAuthorizationInCookieMessage, 401)
  const AuthorizationTokenExpiredError = createError('FST_JWT_AUTHORIZATION_TOKEN_EXPIRED', messagesOptions.authorizationTokenExpiredMessage, 401)
  const AuthorizationTokenUntrustedError = createError('FST_JWT_AUTHORIZATION_TOKEN_UNTRUSTED', messagesOptions.authorizationTokenUntrusted, 401)
  const AuthorizationTokenUnsignedError = createError('FAST_JWT_MISSING_SIGNATURE', messagesOptions.authorizationTokenUnsigned, 401)
  const NoAuthorizationInHeaderError = createError('FST_JWT_NO_AUTHORIZATION_IN_HEADER', messagesOptions.noAuthorizationInHeaderMessage, 401)
  const AuthorizationTokenInvalidError = createError('FST_JWT_AUTHORIZATION_TOKEN_INVALID', typeof messagesOptions.authorizationTokenInvalid === 'function'
    ? messagesOptions.authorizationTokenInvalid({ message: '%s' })
    : messagesOptions.authorizationTokenInvalid
  , 401)
  const BadRequestError = createError('FST_JWT_BAD_REQUEST', messagesOptions.badRequestErrorMessage, 400)
  const BadCookieRequestError = createError('FST_JWT_BAD_COOKIE_REQUEST', messagesOptions.badCookieRequestErrorMessage, 400)

  const jwtDecorator = {
    decode,
    options: {
      decode: decodeOptions,
      sign: initialSignOptions,
      verify: initialVerifyOptions,
      messages: messagesOptions,
      decoratorName
    },
    cookie,
    sign,
    verify,
    lookupToken
  }

  let jwtDecodeName = 'jwtDecode'
  let jwtVerifyName = 'jwtVerify'
  let jwtSignName = 'jwtSign'

  if (namespace) {
    if (!fastify.jwt) {
      fastify.decorateRequest(decoratorName, null)
      fastify.decorate('jwt', Object.create(null))
    }

    if (fastify.jwt[namespace]) {
      return next(new Error(`JWT namespace already used "${namespace}"`))
    }
    fastify.jwt[namespace] = jwtDecorator

    jwtDecodeName = jwtDecode || `${namespace}JwtDecode`
    jwtVerifyName = jwtVerify || `${namespace}JwtVerify`
    jwtSignName = jwtSign || `${namespace}JwtSign`
  } else {
    fastify.decorateRequest(decoratorName, null)
    fastify.decorate('jwt', jwtDecorator)
  }

  fastify.decorateRequest(jwtDecodeName, requestDecode)
  fastify.decorateRequest(jwtVerifyName, requestVerify)
  fastify.decorateReply(jwtSignName, replySign)

  const signerConfig = checkAndMergeSignOptions()
  // no signer when configured in verify-mode
  const signer = signerConfig.options.key
    ? createSigner(signerConfig.options)
    : null
  const decoder = createDecoder(decodeOptions)
  const verifierConfig = checkAndMergeVerifyOptions()
  const verifier = createVerifier(verifierConfig.options)

  next()

  function getVerifier (options, globalOptions) {
    const useGlobalOptions = globalOptions ?? options === verifierConfig.options
    // Use global verifier if using global options with static key
    if (useGlobalOptions && hasStaticPublicKey) return verifier
    // Only cache verifier when using default options (except for key)
    if (useGlobalOptions && options.key && typeof options.key === 'string') {
      let verifier = validatorCache.get(options.key)
      if (!verifier) {
        verifier = createVerifier(options)
        validatorCache.set(options.key, verifier)
      }
      return verifier
    }
    return createVerifier(options)
  }

  function decode (token, options) {
    assert(token, 'missing token')

    let selectedDecoder = decoder

    if (options && options !== decodeOptions && typeof options !== 'function') {
      selectedDecoder = createDecoder(options)
    }

    try {
      return selectedDecoder(token)
    } catch (error) {
      // Ignoring the else branch because it's not possible to test it,
      // it's just a safeguard for future changes in the fast-jwt library
      if (error.code === TokenError.codes.malformed) {
        throw new AuthorizationTokenInvalidError(error.message)
      } else if (error.code === TokenError.codes.invalidType) {
        throw new AuthorizationTokenInvalidError(error.message)
      } /* c8 ignore start */ else {
        throw error
      } /* c8 ignore stop */
    }
  }

  function lookupToken (request, options) {
    assert(request, 'missing request')

    options = Object.assign({}, verifyOptions, options)

    let token
    const extractToken = options.extractToken
    const onlyCookie = options.onlyCookie
    if (extractToken) {
      token = extractToken(request)
      if (!token) {
        throw new BadRequestError()
      }
    } else if (request.headers.authorization && !onlyCookie && /^Bearer\s/i.test(request.headers.authorization)) {
      const parts = request.headers.authorization.split(' ')
      if (parts.length === 2) {
        token = parts[1]
      } else {
        throw new BadRequestError()
      }
    } else if (cookie) {
      if (request.cookies) {
        if (request.cookies[cookie.cookieName]) {
          const tokenValue = request.cookies[cookie.cookieName]

          token = cookie.signed ? request.unsignCookie(tokenValue).value : tokenValue
        } else {
          throw new NoAuthorizationInCookieError()
        }
      } else {
        throw new BadCookieRequestError()
      }
    } else {
      throw new NoAuthorizationInHeaderError()
    }

    return token
  }

  function mergeOptionsWithKey (options, useProvidedPrivateKey) {
    if (useProvidedPrivateKey && (typeof useProvidedPrivateKey !== 'boolean')) {
      return Object.assign({}, options, { key: useProvidedPrivateKey })
    } else {
      const key = useProvidedPrivateKey ? secretOrPrivateKey : secretOrPublicKey
      return Object.assign(!options.key ? { key } : {}, options)
    }
  }

  function checkAndMergeOptions (options, defaultOptions, usePrivateKey, callback) {
    if (typeof options === 'function') {
      return { options: mergeOptionsWithKey(defaultOptions, usePrivateKey), callback: options }
    }

    return { options: mergeOptionsWithKey(options || defaultOptions, usePrivateKey), callback }
  }

  function checkAndMergeSignOptions (options, callback) {
    return checkAndMergeOptions(options, signOptions, true, callback)
  }

  function checkAndMergeVerifyOptions (options, callback) {
    return checkAndMergeOptions(options, verifyOptions, false, callback)
  }

  function sign (payload, options, callback) {
    assert(payload, 'missing payload')
    // if a global signer was not created, sign mode is not supported
    assert(signer, 'unable to sign: secret is configured in verify mode')

    let localSigner = signer

    const localOptions = convertTemporalProps(options)
    const signerConfig = checkAndMergeSignOptions(localOptions, callback)

    if (options && typeof options !== 'function') {
      localSigner = createSigner(signerConfig.options)
    }

    if (typeof signerConfig.callback === 'function') {
      const token = localSigner(payload)
      signerConfig.callback(null, token)
    } else {
      return localSigner(payload)
    }
  }

  function verify (token, options, callback) {
    assert(token, 'missing token')
    assert(secretOrPublicKey, 'missing secret')

    let localVerifier = verifier

    const localOptions = convertTemporalProps(options, true)
    const verifierConfig = checkAndMergeVerifyOptions(localOptions, callback)

    if (options && typeof options !== 'function') {
      localVerifier = getVerifier(verifierConfig.options)
    }

    if (typeof verifierConfig.callback === 'function') {
      const result = localVerifier(token)
      verifierConfig.callback(null, result)
    } else {
      return localVerifier(token)
    }
  }

  function replySign (payload, options, next) {
    // if a global signer was not created, sign mode is not supported
    assert(signer, 'unable to sign: secret is configured in verify mode')

    let useLocalSigner = true
    if (typeof options === 'function') {
      next = options
      options = {}
      useLocalSigner = false
    } // support no options

    if (!options) {
      options = {}
      useLocalSigner = false
    }

    const reply = this

    if (next === undefined) {
      return new Promise(function (resolve, reject) {
        reply[jwtSignName](payload, options, function (err, val) {
          err ? reject(err) : resolve(val)
        })
      })
    }

    if (options.sign) {
      const localSignOptions = convertTemporalProps(options.sign)
      // New supported contract, options supports sign and can expand
      options = {
        sign: mergeOptionsWithKey(Object.assign({}, signOptions, localSignOptions), true)
      }
    } else {
      const localOptions = convertTemporalProps(options)
      // Original contract, options supports only sign
      options = mergeOptionsWithKey(Object.assign({}, signOptions, localOptions), true)
    }

    if (!payload) {
      return next(new Error('jwtSign requires a payload'))
    }

    steed.waterfall([
      function getSecret (callback) {
        const signResult = secretCallbackSign(reply.request, payload, callback)

        if (signResult && typeof signResult.then === 'function') {
          signResult.then(result => callback(null, result), callback)
        }
      },
      function sign (secretOrPrivateKey, callback) {
        if (useLocalSigner) {
          const signerOptions = mergeOptionsWithKey(options.sign || options, secretOrPrivateKey)
          const localSigner = createSigner(signerOptions)
          const token = localSigner(payload)
          callback(null, token)
        } else {
          const token = signer(payload)
          callback(null, token)
        }
      }
    ], next)
  }

  function requestDecode (options, next) {
    if (typeof options === 'function' && !next) {
      next = options
      options = {}
    } // support no options

    if (!options) {
      options = {}
    }

    options = {
      decode: Object.assign({}, decodeOptions, options.decode),
      verify: Object.assign({}, verifyOptions, options.verify)
    }

    const request = this

    if (next === undefined) {
      return new Promise(function (resolve, reject) {
        request[jwtDecodeName](options, function (err, val) {
          err ? reject(err) : resolve(val)
        })
      })
    }

    try {
      const token = lookupToken(request, options.verify)
      const decodedToken = decode(token, options.decode)
      return next(null, decodedToken)
    } catch (err) {
      return next(err)
    }
  }

  function requestVerify (options, next) {
    const request = this

    if (next === undefined) {
      return new Promise(function (resolve, reject) {
        request[jwtVerifyName](options, function (err, val) {
          err ? reject(err) : resolve(val)
        })
      })
    }

    const useGlobalOptions = !options

    if (typeof options === 'function') {
      next = options
      options = {}
    } // support no options

    if (!options) {
      options = {}
    }

    if (options.decode || options.verify) {
      const localVerifyOptions = convertTemporalProps(options.verify, true)
      // New supported contract, options supports both decode and verify
      options = {
        decode: Object.assign({}, decodeOptions, options.decode),
        verify: Object.assign({}, verifyOptions, localVerifyOptions)
      }
    } else {
      const localOptions = convertTemporalProps(options, true)
      // Original contract, options supports only verify
      options = Object.assign({}, verifyOptions, localOptions)
    }

    let token
    try {
      token = lookupToken(request, options.verify || options)
    } catch (err) {
      return next(err)
    }

    const decodedToken = decode(token, options.decode || decodeOptions)

    steed.waterfall([
      function getSecret (callback) {
        const verifyResult = secretCallbackVerify(request, decodedToken, callback)
        if (verifyResult && typeof verifyResult.then === 'function') {
          verifyResult.then(result => callback(null, result), callback)
        }
      },
      function verify (secretOrPublicKey, callback) {
        try {
          const verifierOptions = mergeOptionsWithKey(options.verify || options, secretOrPublicKey)
          const localVerifier = getVerifier(verifierOptions, useGlobalOptions)
          const verifyResult = localVerifier(token)
          if (verifyResult && typeof verifyResult.then === 'function') {
            verifyResult.then(result => callback(null, result), error => wrapError(error, callback))
          } else {
            callback(null, verifyResult)
          }
        } catch (error) {
          return wrapError(error, callback)
        }
      },
      function checkIfIsTrusted (result, callback) {
        if (!trusted) {
          callback(null, result)
        } else {
          const maybePromise = trusted(request, result)

          if (maybePromise?.then) {
            maybePromise
              .then(trusted => trusted ? callback(null, result) : callback(new AuthorizationTokenUntrustedError()))
          } else if (maybePromise) {
            callback(null, result)
          } else {
            callback(new AuthorizationTokenUntrustedError())
          }
        }
      }
    ], function (err, result) {
      if (err) {
        next(err)
      } else {
        const user = formatUser ? formatUser(result) : result
        request[decoratorName] = user
        next(null, user)
      }
    })
  }

  function wrapError (error, callback) {
    if (error.code === TokenError.codes.expired) {
      return callback(new AuthorizationTokenExpiredError())
    }

    if (error.code === TokenError.codes.invalidKey ||
        error.code === TokenError.codes.invalidSignature ||
        error.code === TokenError.codes.invalidClaimValue ||
        error.code === TokenError.codes.missingRequiredClaim
    ) {
      return callback(typeof messagesOptions.authorizationTokenInvalid === 'function'
        ? new AuthorizationTokenInvalidError(error.message)
        : new AuthorizationTokenInvalidError())
    }

    if (error.code === TokenError.codes.missingSignature) {
      return callback(new AuthorizationTokenUnsignedError())
    }

    return callback(error)
  }
}

module.exports = fp(fastifyJwt, {
  fastify: '5.x',
  name: '@fastify/jwt'
})
module.exports.default = fastifyJwt
module.exports.fastifyJwt = fastifyJwt
