import Ajv from 'ajv/dist/2020.js'
import fastify from 'fastify'
import { v4 as uuidv4 } from 'uuid'

import ComponentRegistrar from './component-registrar.js'
import { makePinoLoggerOptions } from './make-logger.js'
import compressPlugin from './plugins/compress.js'
import contentParserPlugin from './plugins/content-parser.js'
import cookiePlugin from './plugins/cookie.js'
import errorHandlerPlugin from './plugins/error-handler.js'
import healthCheckPlugin from './plugins/health-check.js'
import latencyTrackerPlugin from './plugins/latency-tracker.js'
import swaggerPlugin from './plugins/swagger.js'

/**
 * @typedef {object} CookieConfig
 * @property {boolean} [disabled=false] Adds fastify-cookie plugin
 * @property {string} [secret] A secret to use to secure cookie
 */
const COOKIE_CONFIG = {
  disabled: false,
  secret: null
}

/**
 * @typedef {object} LoggingConfig
 * @property {boolean} [reportAllErrors=false] Whether include all API
 *   validation errors in error logging. Recommend to keep it off for production,
 *   on for testing.
 * @property {boolean} [reportErrorDetail=false] Whether include all details
 *   of an error. Recommend to keep it off for remote testing, on for local
 *   testing.
 * @property {String} [sentryDSN] The Sentry DSN to report errors to.
 *   Deliberate client errors (a RequestError subclass with status < 500)
 *   are skipped unless flagged via `RequestError.forceSentry()`; crashes
 *   and errors not thrown through the exception classes are always
 *   reported.
 * @property {Object} [sentryRateLimiter] Optional rate limiter instance (from
 *   {@link createSentryRateLimiter}) to control Sentry reporting for errors
 *   flagged via `RequestError.rateLimitSentry()`. Defaults to a fresh limiter
 *   using the real clock; tests inject one with a controllable clock.
 * @property {function} [customizePinoOpts] An optional function customize the
 *   arguments to pass to Pino. Takes as input the options that will be sent
 *   to be Pino by default (can be modified). Return the new options.
 */
const LOGGING_CONFIG = {
  customizePinoOpts: null,
  reportAllErrors: false,
  reportErrorDetail: false,
  sentryDSN: null,
  sentryRateLimiter: null
}

/**
 * @typedef {object} HealthCheckConfig
 * @property {boolean} [disabled=false] Whether to add a health check endpoint
 *   that simply returns 200.
 * @property {string} [path='/'] The path to the health check endpoint.
 */
const HEALTH_CHECK_CONFIG = {
  disabled: false,
  path: '/'
}

/**
 * @typedef {object} SwaggerConfig
 * @property {boolean} [disabled=false] Whether to disable
 * @property {Array<string>} [servers=[]] The host endpoint (scheme + domain) to
 *   send requests to
 * @property {Array<string>} [authHeaders=[]] Authentication headers
 * @property {string} [routePrefix='/docs'] Authentication headers
 */
const SWAGGER_CONFIG = {
  disabled: false,
  servers: [],
  authHeaders: [],
  routePrefix: '/docs'
}

/**
 * @typedef {object} LatencyTrackerConfig
 * @property {boolean} [disabled=false] Whether to record how long from when we
 *   start to process a request to when we start to send the response
 * @property {string} [header='x-latency-ms'] response header to send the
 *   latency info back in
 */
const LATENCY_TRACKER_CONFIG = {
  disabled: false,
  header: 'x-latency-ms'
}

const PARAMS_CONFIG = {
  service: undefined,
  components: undefined,
  RegistrarCls: ComponentRegistrar,
  cookie: {},
  healthCheck: {},
  latencyTracker: {},
  logging: {},
  swagger: {}
}

function loadConfigDefault (config, defaultConfig) {
  for (const key of Object.keys(config)) {
    if (!Object.prototype.hasOwnProperty.call(defaultConfig, key)) {
      throw new Error(`Unknown config ${key}`)
    }
  }
  if (!config.disabled) {
    for (const [key, defaultValue] of Object.entries(defaultConfig)) {
      if (defaultValue === undefined && !config[key]) {
        throw new Error(`Missing required value for ${key}`)
      }
    }
  }
  Object.assign(config, { ...defaultConfig, ...config })
}

/**
 * @param {Object} params
 * @param {string} params.service Name of the service, for example, iam,
 *   user-id, leaderboard. This affects API's prefixes.
 * @param {Array<API|Model|component>} params.components A list of
 *   components.
 * @param {object} [params.RegistrarCls=ComponentRegistrar] A subclass of
 *   ComponentRegistrar
 * @param {CookieConfig} [params.cookie] Configures fastify-cookie.
 * @param {HealthCheckConfig} [params.healthCheck] Configures health check endpoint.
 * @param {LatencyTrackerConfig} [params.latencyTracker]
 * @param {LoggingConfig} [params.logging] Configures logging.
 * @param {SwaggerConfig} [params.swagger] Configures swagger.
 * @returns {Promise<server>} fastify app with configured plugins
 */
export default async function makeService (params = {}) {
  const configs = [
    [() => params, PARAMS_CONFIG],
    [() => params.cookie, COOKIE_CONFIG],
    [() => params.healthCheck, HEALTH_CHECK_CONFIG],
    [() => params.latencyTracker, LATENCY_TRACKER_CONFIG],
    [() => params.logging, LOGGING_CONFIG],
    [() => params.swagger, SWAGGER_CONFIG]
  ]
  for (const [getter, defaultConfig] of configs) {
    loadConfigDefault(getter(), defaultConfig)
  }
  const {
    service,
    components,
    RegistrarCls,
    cookie,
    healthCheck,
    latencyTracker,
    logging,
    swagger
  } = params
  const fastifyServerId = uuidv4()
  let requestCount = 0
  const ajvParams = {
    removeAdditional: false,
    allErrors: logging.reportAllErrors,
    useDefaults: true,
    strictSchema: false,
    strictRequired: true
  }
  const ajvObjs = {
    coerceTypes: new Ajv({ ...ajvParams, coerceTypes: true }),
    noCoerceTypes: new Ajv({ ...ajvParams, coerceTypes: false })
  }
  const logger = makePinoLoggerOptions(logging.customizePinoOpts)
  const app = fastify({
    disableRequestLogging: true,
    logger,
    genReqId: () => `${fastifyServerId}-${++requestCount}`
  })
    .setValidatorCompiler(({ httpPart, schema }) => {
      // header, route, and query string always come as strings; coerce them
      // to the correct types if possible; body should come exactly in the
      // right type so don't allow type coercion there
      const useCoerce = httpPart !== 'body'
      const ajv = useCoerce ? ajvObjs.coerceTypes : ajvObjs.noCoerceTypes
      const validate = ajv.compile(schema)
      return (value) => !validate(value)
        ? ({ value, error: validate.errors })
        : ({ value })
    })
    .addHook('onResponse', (req, reply, done) => {
      let objToLog = {}
      try {
        objToLog = logger.serializers.res(reply.raw)
        Object.assign(objToLog, logger.serializers.req(req))
        // istanbul ignore else
        if (latencyTracker.header) {
          // latency in milliseconds
          objToLog.latency = reply.getHeader(latencyTracker.header)
        }
      } catch (err) /* istanbul ignore next */ {
        // crashes in onResponse() aren't reported or caught unless we do this
        if (typeof objToLog !== 'object') {
          objToLog = {}
        }
        objToLog.failedToLog = JSON.stringify(err)
      }
      req.log.info(objToLog)
      done()
    })

  const registrar = new RegistrarCls(app, service)

  app.register(cookiePlugin, { cookie })
    .register(compressPlugin)
    .register(contentParserPlugin)
    .register(latencyTrackerPlugin, { latencyTracker })
    .register(errorHandlerPlugin, {
      errorHandler: {
        returnErrorDetail: logging.reportErrorDetail,
        sentryDSN: logging.sentryDSN,
        sentryRateLimiter: logging.sentryRateLimiter,
        serverName: fastifyServerId,
        service
      }
    })
    .register(healthCheckPlugin, { healthCheck })
    .register(swaggerPlugin, { swagger: { service, ...swagger } })

  await registrar.registerComponents(components)
  return app
}