import assert from 'node:assert'
import querystring from 'node:querystring'

import S from '@pbvision/schema'

import fetchWrapper from '../fetch-wrapper.js'

import {
  BadRequestException,
  InvalidInputException,
  InternalFailureException,
  RedirectException,
  RequestDone,
  RequestError
} from './exception.js'
import RESPONSES from './response.js'

/**
 * Given an object, return a new object with undefined keys removed.
 * @param {*} obj Input object, with potentially undefined keys
 * @returns new obj with no undefined keys
 * @private
 */
function pruneUndefined (obj) {
  return Object.entries(obj).reduce((all, [key, val]) => {
    if (val !== undefined) {
      all[key] = val
    }
    return all
  }, {})
}

/**
 * Public APIs (accessible without any user credentials) should be defined as
 * a subclass of @this.
 *
 * Override METHOD, PATH, QS, etc. to define the API. Swagger documentation
 * will be automatically generated for your API.
 *
 * Don't instantiate API instances yourself. Define the subclass, and pass it
 * to the service creation function from make-app.js and it will take care of
 * setting up routing for your API, etc.
 *
 * @public
 * @class
 */
class API {
  /**
   * The API's human-readable name.
   *
   * By default, this is computed by de-camel-casing the class name.
   * @public
   */
  static get NAME () {
    // the name is the de-camel-cased class name
    const clsName = this.name
    const ret = []
    let cur = []

    function wordDone () {
      if (cur.length) {
        ret.push(cur.join(''))
      }
    }

    let prevWasUpper = false
    let isAcronym = false
    const len = clsName.length - (clsName.endsWith('API') ? 3 : 0)
    for (let i = 0; i < len; i++) {
      const ch = clsName[i]
      const code = ch.charCodeAt(0)
      if (code >= 65 && code <= 90) {
        if (prevWasUpper) {
          // multiple uppercase in a row is an acronym; keep it together
          isAcronym = true
          cur.push(ch)
        } else {
          // previous word done
          wordDone()
          cur = [ch]
        }
        prevWasUpper = true
      } else {
        if (isAcronym) {
          // the last uppercase letter is part of the next word (not part of
          // the acronym)
          const lastUpper = cur.splice(cur.length - 1, 1)[0]
          wordDone()
          cur = [lastUpper]
          isAcronym = false
        }
        cur.push(ch)
        prevWasUpper = false
      }
    }
    wordDone()
    return ret.join(' ')
  }

  /* istanbul ignore next */
  /**
   * The HTTP method used to request this API (e.g., GET, POST).
   * @public
   */
  static METHOD = 'POST'

  /* istanbul ignore next */
  /**
   * The HTTP path suffix used to request this API.
   * @public
   * @abstract
   */
  static get PATH () {
    assert.fail('PATH must be overridden in ' + this.name)
    return undefined
  }

  /* istanbul ignore next */
  /**
   * A human-readable description of what this API does. It is only used when
   * automatically generating the Swagger documentation for the API.
   *
   * When used with the Swagger docs, newlines will be replaced with a single
   * space character. May use Markdown formatting.
   * @public
   * @abstract
   */
  static get DESC () {
    assert.fail('DESC must be overridden')
    return undefined
  }

  /**
   * Returns DESC as a string ready for output as Markdown.
   * @private
   */
  static getDescMarkdownString () {
    return this.DESC.trim().replace(/\n/g, ' ')
  }

  /**
   * Tag used to group APIs in the generated Swagger documentation. Set to null
   * to exclude it from the generated Swagger documentation.
   * @returns {String} a string tag
   * @public
   */
  static TAG = undefined

  /**
   * The Schema describing the HTTP headers this API handles, if any.
   * @public
   */
  static HEADERS = undefined

  /**
   * The Schema describing the path params this API handles, if any.
   * @public
   */
  static PATH_PARAMS = undefined

  /**
   * The Schema describing the query string this API handles, if any.
   * @public
   */
  static QS = undefined

  /**
   * The Schema describing the request body this API handles. Note that
   * GET requests should not have request bodies. Use HTTP POST requests if
   * request data size is too big to fit in the query string.
   * @public
   */
  static BODY = undefined

  /**
   * The schema describes the response body. This is used to verify that the
   * implementation produces a correctly shaped output. It also speeds up JSON
   * output serialization by 10-20%.
   *
   * This can return one of two things:
   *   1) {@see ResponseSchema} - responses with HTTP 200 status codes
   *      will be validated against this schema. Non-200 responses can be
   *      anything. This is the typical return value.
   *   2) A subclass of {@see RequestDone}.
   *
   * The default is to not allow any output on HTTP 200 responses.
   * @public
   */
  static RESPONSE = RESPONSES.NO_OUTPUT

  /**
   * Errors that may be returned from the API. Errors are subclasses
   * of RequestError.
   * @public
   */
  static ERRORS = {}

  /**
   * Whether to log request body to Sentry when error.
   * For API with sensitive user data, this shouldn't set to true.
   * @public
   */
  static LOG_REQUEST_BODY_ON_ERROR = false

  constructor (fastify, req, reply) {
    this.fastify = fastify
    this.log = fastify.log
    this.redis = fastify.redis
    this.req = req
    this.__reply = reply
    if (this.constructor.CORS_ORIGIN) {
      this.__setCORSHeaders(
        this.constructor.getCORSOrigin(), this.constructor.CORS_HEADERS)
    }
    reply.logRequestBodyOnError = this.constructor.LOG_REQUEST_BODY_ON_ERROR
    reply.apiName = this.constructor.name
    this.req.__sentry = { context: {}, tags: {} }
    this.__trackInputsWithSentry()
  }

  /**
   * The hostname from which this API can be called in a browser. By default
   * this API cannot be called from a browser due to CORS policy (combined with
   * the fact that no web application runs on our API subdomain).
   * @public
   */
  static CORS_ORIGIN = undefined

  /**
   * Returns the CORS origin to use. On localhost, a non-wildcard CORS_ORIGIN
   * is returned as the localhost web app domain instead.
   * @private
   */
  static getCORSOrigin () {
    const origin = this.CORS_ORIGIN
    if (origin && origin !== '*' && origin !== 'null') {
      /* istanbul ignore else */
      if (process.env.NODE_ENV === 'localhost') {
        return 'http://localhost:3000'
      }
    }
    return origin
  }

  /**
   * The header(s) which are allowed in CORS requests.
   * @public
   */
  static CORS_HEADERS = ['Content-Type']

  /**
   * Set headers to allow this API to be used in a browser from another origin.
   *
   * @param {string} origin the hostname from which this API can be called via
   *   CORS. On localhost, the origin is ignored and localhost is used instead.
   * @param {Array<string>} headers an optional list of headers to allow when
   *   this API is requested via CORS
   * @private
   */
  __setCORSHeaders (origin, headers) {
    this.constructor.setCORSHeadersOnReply(this.__reply, origin, headers)
  }

  static setCORSHeadersOnReply (reply, origin, headers) {
    reply.header('Access-Control-Allow-Origin', origin)
    if (headers && headers.length) {
      reply.header('Access-Control-Allow-Headers', headers.join(', '))
    }
  }

  /**
   * Calls computeResponse() inside _callAndHandleRequestDone().
   * @returns {Object|String} the HTTP response body
   * @private
   */
  async _computeResponse () {
    return this._callAndHandleRequestDone(this.computeResponse, this.req)
  }

  /**
   * Runs func(...args) and catches and handles RequestDone, if it is thrown.
   * @param {Function} func the function to run
   * @param  {...any} args the arguments to call func with
   * @returns the response data
   * @private
   */
  async _callAndHandleRequestDone (func, ...args) {
    return this.constructor._callAndHandleRequestDone(
      this.__reply, async () => func.call(this, ...args))
  }

  /**
   * Runs func and catches and handles RequestDone, if it is thrown.
   * @param {fastify-reply} reply the reply object
   * @param {Function} func the function to run
   * @returns the response data
   * @private
   */
  static async _callAndHandleRequestDone (reply, func) {
    try {
      return await func()
    } catch (e) {
      if (e instanceof RequestDone) {
        reply.code(e.httpCode)
        return e.respData
      } else {
        if (e.statusCode) {
          delete e.statusCode
          e.httpCode = 500
        }
        throw e
      }
    }
  }

  /* istanbul ignore next */
  /**
   * The API logic. This method is called after all inputs are validated
   * according to the schemas specified by the API definition.
   * @arg {Request} req the fastify request object being handled
   * @returns {Object|String} optional JSON-able object or string to send back
   *   as the HTTP response body
   * @abstract
   * @protected
   */
  async computeResponse (req) {
    assert.fail('API not implemented')
  }

  /**
   * Call an API.
   *
   * Any headers to forward (that were sent to the request which is calling
   * this function) will be added to the headers specified.
   *
   * @typedef {Object} callAPIOptions
   * @param {String} method The HTTP method to use
   * @param {Map<String, String>} headers HTTP headers to send
   * @param {String} url the URL to request
   * @param {String} body the body to send (if it's a string, it is sent as-is;
   *   otherwise, it is assumed to be JSON and encoded as such and the
   *   appropriate Content-Type header is set)
   * @param {Map<String, String>} qsParams query string parameters
   *
   * @param {callAPIOptions} options describe the HTTP request to make
   * @returns {Object|string} the HTTP response body, parsed if it was JSON
   * @protected
   */
  async callAPI ({
    method = 'POST', headers = {}, url, body, qsParams, ignoreRespBody = false
  }) {
    headers = {
      ...headers,
      ...this.getHeadersToForward()
    }
    const request = {
      headers,
      method,
      url,
      qsParams
    }
    // istanbul ignore if
    if (body) {
      if (typeof body === 'string') {
        request.body = body
      } else {
        request.json = body
      }
    }
    const resp = await fetchWrapper(request)
    const ret = {
      code: resp.status,
      isOk: resp.status >= 200 && resp.status <= 299
    }
    if (!ignoreRespBody) {
      const respBody = await resp.text()
      if (respBody) {
        if (resp.headers.get('content-type')?.startsWith('application/json')) {
          try {
            ret.data = JSON.parse(respBody)
          } catch (e) {
            // istanbul ignore next
            throw new Error(`JSON.parse failed on ${respBody} with reason ${e}.`)
          }
        } else {
          ret.data = respBody
        }
      }
    }
    return ret
  }

  /**
   * Gets headers from this request that should be forwarded.
   *
   * By default, this is all headers defined in HEADERS.
   *
   * @protected
   */
  getHeadersToForward () {
    // forward permission headers, if present
    const ret = {}
    const headersToForward = this.constructor._HEADERS_TO_FORWARD
    for (let i = 0; i < headersToForward.length; i++) {
      const header = headersToForward[i]
      const headerValue = this.req.headers[header]
      if (headerValue !== undefined) {
        ret[header] = headerValue
      }
    }
    return ret
  }

  /**
   * Adds inputs from the query string, path params and body to the Sentry
   * context. This provides helpful debugging information but should only be
   * used if these fields don't include sensitive information.
   */
  __trackInputsWithSentry () {
    const inputs = this.constructor.getInputsToTrackWithSentry(this.req)
    // istanbul ignore else
    if (inputs) {
      const inputsSerialized = {}
      for (const [k, v] of Object.entries(inputs)) {
        if (typeof v === 'object') {
          inputsSerialized[k] = JSON.stringify(v)
        } else {
          inputsSerialized[k] = v
        }
      }
      this.setSentryContext('inputs', inputs)
    }
  }

  /**
   * Returns a map of inputs to track in Sentry's context.
   *
   * By default, includes the query string, path params and body. This provides
   * helpful debugging information but should only be
   * used if these fields don't include sensitive information.
   */
  static getInputsToTrackWithSentry (req) {
    return {
      ...req.query,
      ...req.params,
      ...req.body
    }
  }

  /** Sets context to be transmitted to Sentry if this request fails. */
  setSentryContext (key, contextObject) {
    this.req.__sentry.context[key] = contextObject
  }

  /** Sets a tag to be transmitted to Sentry if this request fails. */
  setSentryTag (tag, tagValue) {
    this.req.__sentry.tags[tag] = tagValue
  }

  /** Sets user info to be transmitted to Sentry if this request fails. */
  setSentryUserInfo (userInfo) {
    this.req.__sentry.userInfo = userInfo
  }

  /**
   * Redirects to a URL optionally with query string and cookie.
   *
   * Any headers to forward (that were sent to the request which is calling
   * this function) will be added to the existing cookie's JSON data, if any.
   *
   * @param {String} [schemeAndHost] e.g., https://my-webapp.example.com
   * @param {String} [path] the version of the web application to launch;
   *   can be overridden by query parameter "version" but otherwise defaults to
   *   the version of the service which served the web app
   * @param {Object} [qsParams] the query string parameters to launch with
   * @param {Object} [cookie] cookie data to send (good for sensitive values
   *   which should not be passed in the query string)
   * @protected
   */
  redirectToWebApp ({
    schemeAndHost,
    path = '/',
    qsParams = undefined,
    cookie
  }) {
    const qStr = qsParams
      ? '?' + querystring.stringify(qsParams)
      : ''

    const isLocalhost = process.env.NODE_ENV === 'localhost'
    /* istanbul ignore else */
    if (isLocalhost) {
      schemeAndHost = 'http://localhost:3000'
    }
    if (cookie) {
      const { values, domain, name } = cookie
      // istanbul ignore next
      const cookieDomain = isLocalhost ? '' : domain
      // any headers which should be forwarded are added to the cookie (these
      // overwrite existing values with those names, if any)
      const newCookieData = JSON.stringify({
        ...(values ?? {}),
        ...this.getHeadersToForward()
      })
      this.__reply.setCookie(name, newCookieData, {
        domain: cookieDomain,
        maxAge: 604800, // one week
        path: '/',
        secure: !isLocalhost,
        signed: true
      })
    }
    this.__reply.redirect(schemeAndHost + path + qStr)
  }

  /** @package */
  static async register (registrar) {
    await registrar.registerAPI(this)
  }

  /**
   * Registers this API with the fastify app. This is called by internal
   * implementation details in make-app.js. In rare occasions, subclasses
   * may override this functionality to register the API with special
   * middleware or other custom options.
   *
   * If the API allows CORS requests, then an OPTIONS API with the same path
   * will also be registered to support browsers' CORS preflight requests.
   *
   * @param {*} app The fastify app to service this API from.
   * @param {String} service The service this API belongs to.
   * @package
   */
  static registerAPI (registrar) {
    const { app } = registrar
    if (this.setup) {
      app.register(this.setup)
    }
    const cls = this
    app.register(async (fastify) => {
      try {
        await cls.registerAPIWithFastify(fastify, cls.PATH)
      } catch (e) {
        if (e instanceof assert.AssertionError) {
          e.message = `${cls.name}: ${e.message}`
        }
        console.error(`failed to register API: ${cls.name}`)
        throw e
      }
    })

    if (this.CORS_ORIGIN) {
      // create an OPTIONS method API to support CORS preflight requests from
      // browsers
      const path = cls.PATH
      const { params, querystring } = cls.swaggerSchema
      const schema = { hide: true, params, querystring }
      app.options(path, { schema: pruneUndefined(schema) },
        async (req, reply) => {
          reply.header('Access-Control-Allow-Origin', this.getCORSOrigin())
          if (this.CORS_HEADERS) {
            reply.header('Access-Control-Allow-Headers',
              this.CORS_HEADERS.join(', '))
          }
          await reply.send()
        })
    }
  }

  /**
   * Removes the required marker from any top-level properties which have a
   * default value.
   * @param {Schema} schema a Schema object
   * @private
   */
  static __makeParamsWithDefaultValuesOptional (schema) {
    schema = schema.jsonSchema()
    assert.ok(schema.type === 'object', 'param schemas must be an S.obj')
    const requiredKeys = schema.required || []
    const requiredKeysSet = new Set(requiredKeys)
    assert.ok(requiredKeysSet.size === requiredKeys.length,
      `${this.name} required has a dupe; is description() in the wrong place?`)
    for (let i = requiredKeys.length - 1; i >= 0; i--) {
      const requiredKey = requiredKeys[i]
      const prop = schema.properties[requiredKey]
      if (Object.hasOwnProperty.call(prop, 'default')) {
        // a param with a default value is NOT required
        requiredKeys.splice(i, 1)
      }
    }
    return schema
  }

  /**
   * Returns the headers schema.
   * @private
  */
  static _getHeaders () {
    let headers = this.HEADERS
    if (headers) {
      headers = headers.isSchema
        ? headers
        : S.obj(headers)
    }
    this._HEADERS_TO_FORWARD = (
      headers
        ? Object.keys(headers.objectSchemas)
        : [])
    return headers
  }

  /**
   * Returns the body schema.
   * @private
  */
  static _getBody () {
    return this.BODY
  }

  /**
   * Return a response wrapped in a subclass of RequestDone.
   * @private
   */
  static _getResponse () {
    const response = this.RESPONSE
    let ret
    if (response === RESPONSES.UNVALIDATED) {
      ret = undefined
    } else if (response.prototype instanceof RequestDone) {
      ret = response
    } else {
      ret = class extends RequestDone {
        static STATUS = 200
        static SCHEMA = response
      }
    }
    return ret
  }

  /**
   * Return all errors an API may return. Common base classes may use this
   * method to add common errors, so users of the common base class can specify
   * ERRORS without special considerations.
   * @private
   */
  static _getErrors () {
    return {
      InternalFailureException,
      BadRequestException,
      InvalidInputException,
      ...this.ERRORS
    }
  }

  /**
   * Return a mapping from status code to Schemas.
   * @private
   */
  static _getResponseSchemas () {
    const schemas = {}

    const response = this._getResponse()
    if (response) {
      schemas[response.STATUS] = response.schema
    }

    for (const error of Object.values(this._getErrors())) {
      schemas[error.STATUS] = error.respSchema
    }
    return schemas
  }

  /**
   * Return a default empty response matching the type specified in success
   * response schema.
   * @private
   */
  static _getEmptySuccessResponse () {
    const response = this._getResponse()
    if (!response) {
      return ''
    }
    const jsonSchema = response.schema.jsonSchema()
    return {
      string: '',
      object: {},
      array: []
    }[jsonSchema.type]
  }

  /**
   * Handle an error thrown from API. Raise exceptions when errors that were
   * not explicitly listed in ERRORS is thrown in test environment. Re-throw
   * exceptions as 400 and 500 errors in production environment.
   * @param {Error} err A subclass of Error.
   * @param {Object} reply Reply object
   * @private
   */
  static async _handleError (err, reply) {
    const errorName = err.constructor.name
    const isUntrackedError = err instanceof RequestError &&
      errorName !== RequestError.name &&
      !this._getErrors()[errorName]
    // istanbul ignore if
    if (isUntrackedError) {
      const errorMessage = `API ${this.name} emitted untracked error ` +
        `${err.constructor.name}`
      if (process.env.NODE_ENV !== 'prod') {
        throw new Error(errorMessage)
      }
      console.error(errorMessage)
      if (err.httpCode >= 500) {
        throw new InternalFailureException(err.message, err.data)
      } else if (err.httpCode >= 400) {
        throw new BadRequestException(err.message, err.data)
      }
    }
    if (err instanceof RedirectException) {
      reply.code(err.httpCode).redirect(err.url)
    } else {
      throw err
    }
  }

  /** @protected */
  static get swaggerSecurityConfig () {
    return []
  }

  /** @private */
  static get swaggerSchema () {
    const wrapInSchema = (x) => {
      return x.isSchema ? x : S.obj(x)
    }

    // istanbul ignore next
    const tags = [this.TAG || 'default']
    const schema = {
      summary: this.NAME,
      description: this.getDescMarkdownString(),
      tags,
      response: {}
    }
    let headers = this._getHeaders()
    if (headers) {
      // Make sure extra header fields are passed along
      headers = wrapInSchema(headers).jsonSchema()
      headers.additionalProperties = true // Hack to enable additional props
      schema.headers = headers
    }
    if (this.TAG === null) {
      schema.hide = true
    }

    const pathParams = this.PATH_PARAMS
    if (pathParams) {
      schema.params = this.__makeParamsWithDefaultValuesOptional(
        wrapInSchema(pathParams))
    }
    const qs = this.QS
    if (qs) {
      schema.querystring = this.__makeParamsWithDefaultValuesOptional(
        wrapInSchema(qs))
    }
    const body = this._getBody()
    if (body) {
      schema.body = this.__makeParamsWithDefaultValuesOptional(
        wrapInSchema(body))
    }

    const respSchemas = this._getResponseSchemas()
    for (const statusCode in respSchemas) {
      const schemaForStatusCode = respSchemas[statusCode]
      const compiledSchema = schemaForStatusCode.getValidatorAndJSONSchema(
        `${schema.summary} HTTP ${statusCode} Response`)
      schema.response[statusCode] = compiledSchema.jsonSchema
    }

    schema.security = this.swaggerSecurityConfig
    return pruneUndefined(schema)
  }

  /**
   * Registers the API with fastify's router.
   * @param {*} fastify The fastify library object from app.register().
   * @private
   */
  static async registerAPIWithFastify (fastify, fullPath) {
    assert.ok(this.DESC, 'DESC is missing')
    assert.ok(this.PATH && this.PATH.startsWith('/'),
      'API path must start with a "/"')

    // convert our proprietary schema format to the fast-json-stringify format
    const respSchemas = this._getResponseSchemas()
    const responseValidators = {}
    for (const statusCode in respSchemas) {
      const schemaForStatusCode = respSchemas[statusCode]
      const compiledSchema = schemaForStatusCode.getValidatorAndJSONSchema(
        `${this.NAME} HTTP ${statusCode} Response`)
      responseValidators[statusCode] = compiledSchema.assertValid
    }

    const method = this.METHOD.toLowerCase()
    fastify[method](fullPath, {
      schema: this.swaggerSchema,
      attachValidation: true
    },
    async (req, reply) => {
      let ret
      try {
        if (req.validationError) {
          req.__sentry = { context: {} }
          const errCopy = { ...req.validationError }
          errCopy.validation = JSON.stringify(errCopy.validation, null, 4)
          req.__sentry.context.validationError = errCopy
          try {
            req.__sentry.context.inputs = this.getInputsToTrackWithSentry(req)
          } catch (err) {
            // istanbul ignore next
            console.warn('trying to get inputs to track failed', err)
          }
          if (this.CORS_ORIGIN) {
            this.setCORSHeadersOnReply(
              reply, this.getCORSOrigin(), this.CORS_HEADERS)
          }
          throw new InvalidInputException(req.validationError)
        }
        ret = await this._callAndHandleRequestDone(reply, async () => {
          const handler = new this(fastify, req, reply)
          return handler._computeResponse()
        })
      } catch (err) {
        await this._handleError(err, reply)
      }
      if (!reply.sent) {
        // convert an undefined return to an empty output (or fastify will hang
        // and wait for output forever)
        if (ret === undefined) {
          ret = this._getEmptySuccessResponse()
        }
        // verify the output we received is consistent with the schema
        const assertValidResponse = responseValidators[reply.statusCode]
        if (assertValidResponse) {
          assertValidResponse(ret)
        }
        return ret
      }
    })
  }
}

export default API