import corsImport from 'cors'
import type { NextFunction, Request, RequestHandler, Response } from 'express'
import promBundle from 'express-prom-bundle'

import packageJson from '../../package.json' with { type: 'json' }
import type { CompanionRuntimeOptions } from '../types/companion-options.js'
import type { ProviderUserSession } from '../types/express.js'
import * as tokenService from './helpers/jwt.js'
import { getCookieName } from './helpers/jwt.js'
import { getURLBuilder } from './helpers/utils.js'
import * as logger from './logger.js'
import { isOAuthProvider } from './provider/Provider.js'
import getS3Client from './s3-client.js'

export const hasSessionAndProvider: RequestHandler = (req, res, next) => {
  if (!req.session) {
    logger.debug(
      'No session attached to req object. Exiting dispatcher.',
      undefined,
      req.id,
    )
    return res.sendStatus(400)
  }

  if (!req.companion.provider) {
    logger.debug(
      'No provider/provider-handler found. Exiting dispatcher.',
      undefined,
      req.id,
    )
    return res.sendStatus(400)
  }

  return next()
}

const isOAuthProviderReq = (req: Request) =>
  isOAuthProvider(req.companion.providerClass?.oauthProvider)
const isSimpleAuthProviderReq = (req: Request) =>
  !!req.companion.providerClass?.hasSimpleAuth

/**
 * Middleware can be used to verify that the current request is to an OAuth provider
 * This is because not all requests are supported by non-oauth providers (formerly known as SearchProviders)
 */
export const hasOAuthProvider: RequestHandler = (req, res, next) => {
  if (!isOAuthProviderReq(req)) {
    logger.debug('Provider does not support OAuth.', undefined, req.id)
    return res.sendStatus(400)
  }

  return next()
}

export const hasSimpleAuthProvider: RequestHandler = (req, res, next) => {
  if (!isSimpleAuthProviderReq(req)) {
    logger.debug('Provider does not support simple auth.', undefined, req.id)
    return res.sendStatus(400)
  }

  return next()
}

export const hasBody: RequestHandler = (req, res, next) => {
  if (!req.body) {
    logger.debug(
      'No body attached to req object. Exiting dispatcher.',
      undefined,
      req.id,
    )
    return res.sendStatus(400)
  }

  return next()
}

export const hasSearchQuery: RequestHandler = (req, res, next) => {
  if (typeof req.query['q'] !== 'string') {
    logger.debug(
      'search request has no search query',
      'search.query.check',
      req.id,
    )
    return res.sendStatus(400)
  }

  return next()
}

export const verifyToken: RequestHandler = (req, res, next) => {
  if (isOAuthProviderReq(req) || isSimpleAuthProviderReq(req)) {
    // For OAuth / simple auth provider, we find the encrypted auth token from the header:
    const token = req.companion.authToken
    if (token == null) {
      logger.info('cannot auth token', 'token.verify.unset', req.id)
      res.sendStatus(401)
      return
    }
    const { secret } = req.companion.options
    const providerName = req.params['providerName']
    if (typeof providerName !== 'string' || providerName.length === 0) {
      res.sendStatus(400)
      return
    }
    try {
      const payload = tokenService.verifyEncryptedAuthToken<
        Record<string, ProviderUserSession>
      >(token, secret, providerName)
      req.companion.providerUserSession = payload[providerName]
    } catch (err) {
      logger.error(
        err instanceof Error ? err.message : String(err),
        'token.verify.error',
        req.id,
      )
      res.sendStatus(401)
      return
    }
    next()
    return
  }

  // for non auth providers, we just load the static key from options
  if (!isOAuthProviderReq(req)) {
    const { providerOptions } = req.companion.options
    const providerName = req.params['providerName']
    const providerOption =
      typeof providerName === 'string'
        ? providerOptions?.[providerName]
        : undefined
    const key = providerOption?.key
    if (!key) {
      logger.info(
        `unconfigured credentials for ${providerName}`,
        'non.oauth.token.load.unset',
        req.id,
      )
      res.sendStatus(501)
      return
    }

    req.companion.providerUserSession = {
      accessToken: key,
    }
    next()
  }
}

// does not fail if token is invalid
export const gentleVerifyToken: RequestHandler = (req, res, next) => {
  const providerName = req.params['providerName']
  if (typeof providerName !== 'string' || providerName.length === 0) {
    next()
    return
  }
  const { secret } = req.companion.options
  if (req.companion.authToken != null) {
    try {
      const payload = tokenService.verifyEncryptedAuthToken<
        Record<string, ProviderUserSession>
      >(req.companion.authToken, secret, providerName)
      req.companion.providerUserSession = payload[providerName]
    } catch (err) {
      logger.error(
        err instanceof Error ? err.message : String(err),
        'token.gentle.verify.error',
        req.id,
      )
    }
  }
  next()
}

export const cookieAuthToken: RequestHandler = (req, res, next) => {
  const oauthProvider = req.companion.providerClass?.oauthProvider
  if (oauthProvider == null || oauthProvider.length === 0) {
    return next()
  }
  req.companion.authToken = req.cookies[getCookieName(oauthProvider)]
  return next()
}

export const cors =
  (options: {
    corsOrigins?: CompanionRuntimeOptions['corsOrigins']
    sendSelfEndpoint?: CompanionRuntimeOptions['sendSelfEndpoint']
  }): RequestHandler =>
  (req, res, next) => {
    // HTTP headers are not case sensitive, and express always handles them in lower case, so that's why we lower case them.
    // I believe that HTTP verbs are case sensitive, and should be uppercase.

    const existingExposeHeaders = res.get('Access-Control-Expose-Headers')
    const exposeHeadersSet = new Set(
      existingExposeHeaders
        ?.split(',')
        ?.map((method) => method.trim().toLowerCase()),
    )

    const sendSelfEndpoint = options['sendSelfEndpoint']
    if (sendSelfEndpoint != null) {
      exposeHeadersSet.add('i-am')
    }

    // Needed for basic operation: https://github.com/transloadit/uppy/issues/3021
    const allowedHeaders = [
      'uppy-auth-token',
      'uppy-credentials-params',
      'authorization',
      'origin',
      'content-type',
      'accept',
    ]
    const existingAllowHeaders = res.get('Access-Control-Allow-Headers')
    const allowHeadersSet = new Set(
      existingAllowHeaders
        ? existingAllowHeaders
            .split(',')
            .map((method) => method.trim().toLowerCase())
            .concat(allowedHeaders)
        : allowedHeaders,
    )

    const existingAllowMethods = res.get('Access-Control-Allow-Methods')
    const allowMethodsSet = new Set(
      existingAllowMethods
        ?.split(',')
        ?.map((method) => method.trim().toUpperCase()),
    )
    // Needed for basic operation:
    allowMethodsSet.add('GET').add('POST').add('OPTIONS').add('DELETE')

    // If endpoint urls are specified, then we only allow those endpoints.
    // Otherwise, we allow any client url to access companion.
    // Must be set to at least true (origin "*" with "credentials: true" will cause error in many browsers)
    // https://github.com/expressjs/cors/issues/119
    // allowedOrigins can also be any type supported by https://github.com/expressjs/cors#configuration-options
    const { corsOrigins: origin = true } = options

    // Because we need to merge with existing headers, we need to call cors inside our own middleware
    return corsImport({
      credentials: true,
      methods: Array.from(allowMethodsSet),
      allowedHeaders: Array.from(allowHeadersSet).join(','),
      exposedHeaders: Array.from(exposeHeadersSet).join(','),
      ...(origin !== undefined && { origin }),
    })(req, res, next)
  }

function hasPromClient(mw: unknown): mw is RequestHandler & {
  promClient: {
    collectDefaultMetrics: (opts: unknown) => void
    Gauge: new (opts: unknown) => { set: (n: number) => void }
    register: unknown
  }
} {
  return !!mw && typeof mw === 'function' && 'promClient' in mw
}

export const metrics = ({
  path = undefined,
}: {
  path?: string
} = {}): RequestHandler => {
  const metricsMiddleware = promBundle({
    includeMethod: true,
    ...(path && { metricsPath: `${path}/metrics` }),
  })
  if (!hasPromClient(metricsMiddleware)) return metricsMiddleware
  const { promClient } = metricsMiddleware
  const { collectDefaultMetrics } = promClient
  collectDefaultMetrics({ register: promClient.register })

  // Add version as a prometheus gauge
  const versionGauge = new promClient.Gauge({
    name: 'companion_version',
    help: 'npm version as an integer',
  })
  const numberVersion = Number(packageJson.version.replace(/\D/g, ''))
  versionGauge.set(numberVersion)
  return metricsMiddleware
}

export const getCompanionMiddleware = (
  options: CompanionRuntimeOptions,
): RequestHandler => {
  const middleware = (req: Request, _res: Response, next: NextFunction) => {
    const s3Client = getS3Client(options, false)
    const s3ClientCreatePresignedPost = getS3Client(options, true)
    const authToken =
      req.header('uppy-auth-token') || req.query['uppyAuthToken']

    req.companion = {
      options,
      buildURL: getURLBuilder(options),
      ...(s3Client && { s3Client }),
      ...(s3ClientCreatePresignedPost && { s3ClientCreatePresignedPost }),
      ...(typeof authToken === 'string' && { authToken }),
    }
    next()
  }

  return middleware
}
