import { randomUUID } from 'node:crypto'
import qs from 'node:querystring'
import { URL } from 'node:url'
import RedisStore from 'connect-redis'
import type { NextFunction, Request, Response } from 'express'
import express from 'express'
import type { SessionOptions } from 'express-session'
import session from 'express-session'
import helmet from 'helmet'
import morgan from 'morgan'
import * as companion from '../companion.js'
import type { CompanionInitOptions } from '../schemas/index.js'
import { isRecord, toError } from '../server/helpers/type-guards.js'
import logger from '../server/logger.js'
import * as redis from '../server/redis.js'
import {
  buildHelpfulStartupMessage,
  generateSecret,
  getCompanionOptions,
} from './helper.js'

export default function server(inputCompanionOptions?: CompanionInitOptions) {
  const companionOptions = getCompanionOptions(inputCompanionOptions)

  companion.setLoggerProcessName(companionOptions)

  if (!companionOptions.secret)
    companionOptions.secret = generateSecret('secret')
  if (!companionOptions.preAuthSecret)
    companionOptions.preAuthSecret = generateSecret('preAuthSecret')

  const app = express()

  const router = express.Router()

  const serverPath = companionOptions.server.path
  if (serverPath != null) {
    app.use(serverPath, router)
  } else {
    app.use(router)
  }

  // Query string keys whose values should not end up in logging output.
  const sensitiveKeys = new Set(['access_token', 'uppyAuthToken'])

  /**
   * Obscure the contents of query string keys listed in `sensitiveKeys`.
   *
   * Returns a copy of the object with unknown types removed and sensitive values replaced by ***.
   *
   * The input type is more broad that it needs to be, this way typescript can help us guarantee that we're dealing with all
   * possible inputs :)
   *
   * @param rawQuery
   */
  function censorQuery(rawQuery: Record<string, unknown>): {
    query: Record<string, string>
    censored: boolean
  } {
    const query: Record<string, string> = {}
    let censored = false
    Object.keys(rawQuery).forEach((key) => {
      const value = rawQuery[key]
      if (typeof value !== 'string') {
        return
      }
      if (sensitiveKeys.has(key)) {
        // replace logged access token
        query[key] = '********'
        censored = true
      } else {
        query[key] = value
      }
    })
    return { query, censored }
  }

  router.use((request, response, next) => {
    const headerName = 'X-Request-Id'
    const oldValue = request.get(headerName)
    response.set(headerName, oldValue ?? randomUUID())

    next()
  })
  // log server requests.
  router.use(morgan('combined'))
  morgan.token<Request>('url', (req) => {
    const { query, censored } = censorQuery(req.query)
    return censored
      ? `${req.path}?${qs.stringify(query)}`
      : req.originalUrl || req.url || '-'
  })

  morgan.token('referrer', (req) => {
    const ref = req.headers['referer'] || req.headers['referrer']
    if (typeof ref === 'string') {
      let parsed: URL
      try {
        parsed = new URL(ref)
      } catch {
        return ref
      }
      const rawQuery = qs.parse(parsed.search.replace('?', ''))
      const { query, censored } = censorQuery(rawQuery)
      return censored
        ? `${parsed.href.split('?')[0]}?${qs.stringify(query)}`
        : parsed.href
    }
    return '-'
  })

  // Use helmet to secure Express headers
  router.use(helmet.frameguard())
  router.use(helmet.xssFilter())
  router.use(helmet.noSniff())
  router.use(helmet.ieNoOpen())

  app.disable('x-powered-by')

  const sessionOptions: SessionOptions = {
    secret: companionOptions.secret,
    resave: true,
    saveUninitialized: true,
  }

  const redisClient = redis.client({
    redisUrl: companionOptions.redisUrl,
    redisOptions: companionOptions.redisOptions,
  })
  if (redisClient) {
    sessionOptions.store = new RedisStore({
      client: redisClient,
      prefix:
        process.env['COMPANION_REDIS_EXPRESS_SESSION_PREFIX'] ||
        'companion-session:',
    })
  }

  const cookieOptions: SessionOptions['cookie'] = {
    // AFAIK sessions are only used for the initial oauth2 dance, and not after that.
    // Therefore 10 minutes should be plenty of time for the user to complete the oauth2 dance.
    // we might want to see if we can make grant work without sessions, so we can remove sessions:
    // https://github.com/transloadit/uppy/issues/4394
    // https://github.com/transloadit/api2/pull/8345#pullrequestreview-4552806932
    maxAge: 10 * 60 * 1000,
  }

  if (process.env['COMPANION_COOKIE_DOMAIN']) {
    cookieOptions.domain = process.env['COMPANION_COOKIE_DOMAIN']
  }

  sessionOptions.cookie = cookieOptions

  // Session is used for grant redirects, so that we don't need to expose secret tokens in URLs
  // See https://github.com/transloadit/uppy/pull/1668
  // https://github.com/transloadit/uppy/issues/3538#issuecomment-1069232909
  // https://github.com/simov/grant#callback-session
  router.use(session(sessionOptions))

  // Routes
  if (process.env['COMPANION_HIDE_WELCOME'] !== 'true') {
    router.get('/', (req, res) => {
      res.setHeader('Content-Type', 'text/plain')
      res.send(buildHelpfulStartupMessage(companionOptions))
    })
  }

  // initialize companion
  const { app: companionApp, emitter } = companion.app(companionOptions)

  // add companion to server middleware
  router.use(companionApp)

  // WARNING: This route is added in order to validate your app with OneDrive.
  // Only set COMPANION_ONEDRIVE_DOMAIN_VALIDATION if you are sure that you are setting the
  // correct value for COMPANION_ONEDRIVE_KEY (i.e application ID). If there's a slightest possiblilty
  // that you might have mixed the values for COMPANION_ONEDRIVE_KEY and COMPANION_ONEDRIVE_SECRET,
  // please DO NOT set any value for COMPANION_ONEDRIVE_DOMAIN_VALIDATION
  if (
    process.env['COMPANION_ONEDRIVE_DOMAIN_VALIDATION'] === 'true' &&
    process.env['COMPANION_ONEDRIVE_KEY']
  ) {
    router.get(
      '/.well-known/microsoft-identity-association.json',
      (req, res) => {
        const content = JSON.stringify({
          associatedApplications: [
            { applicationId: process.env['COMPANION_ONEDRIVE_KEY'] },
          ],
        })
        res.header('Content-Length', `${Buffer.byteLength(content, 'utf8')}`)
        // use writeHead to prevent 'charset' from being appended
        // https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-configure-publisher-domain#to-select-a-verified-domain
        res.writeHead(200, { 'Content-Type': 'application/json' })
        res.write(content)
        res.end()
      },
    )
  }

  app.use((req, res) => {
    return res.status(404).json({ message: 'Not Found' })
  })

  app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
    const error = toError(err)
    const status = isRecord(err) ? err['status'] : undefined
    if (app.get('env') === 'production') {
      // if the error is a URIError from the requested URL we only log the error message
      // to avoid uneccessary error alerts
      if (status === 400 && error.name === 'URIError') {
        logger.error(error.message, 'root.error', req.id)
      } else {
        logger.error(err, 'root.error', req.id)
      }
      res
        .status(500)
        .json({ message: 'Something went wrong', requestId: req.id })
    } else {
      logger.error(err, 'root.error', req.id)
      res
        .status(500)
        .json({ message: error.message, error: err, requestId: req.id })
    }
  })

  return { app, companionOptions, emitter }
}
