const assert = require('assert')

const S = require('@pocketgems/schema')

const RESPONSES = require('./response')

/**
 * @namespace Exceptions
 * @public
 */
/**
 * Thrown to shortcut request handling.
 *
 * {@link API} will catch this exception and send the HTTP response code
 * and (optional) data. This is useful to immediately stop request processing,
 * especially from deeply nested locations in the call stack where it would
 * be cumbersome to pass return information (especially errors) all the way
 * back up the call stack.
 *
 * Typically, users should throw {@link RequestError} for error responses or
 * @see RequestOkay for non-error responses (not @this).
 *
 * @arg {Number} httpCode The HTTP status code to respond with.
 * @arg {String|Object} [respData=''] The object (to JSON.stringify()) or
 *   string to send in the HTTP response body.
 * @package
 * @memberof Exceptions
 */
class __RequestDone extends Error {
  static STATUS = undefined
  static SCHEMA = RESPONSES.NO_OUTPUT

  static get schemaValidator () {
    const cacheKey = '_CACHED_SCHEMA_VALIDATOR'
    if (!Object.prototype.hasOwnProperty.call(this, cacheKey)) {
      this[cacheKey] = this.schema.compile(`data input to ${this.name}`)
    }
    return this[cacheKey]
  }

  /**
   * Gets a Todea Schema object.
   */
  static get schema () {
    let schema = this.SCHEMA
    if (!schema.isTodeaSchema) {
      schema = S.obj(schema)
    }
    return schema
  }

  /**
   * Create an exception.
   * @param {String} message An error message
   * @param {Object} data A JSON object containing additional data
   * @param {Integer} code An optional code to override exception's default
   *   STATUS
   */
  constructor (message, data = {}, code = undefined) {
    super(message)
    this.httpCode = code ?? this.constructor.STATUS
    assert(this.httpCode !== undefined, 'Status must be defined')
    assert(this.constructor.SCHEMA !== undefined, 'Schema must be defined')
    this.constructor.schemaValidator(data)
    this.data = data
  }
}

/**
 * Concrete class to indicate request is completed ok.
 */
class RequestDone extends __RequestDone {
  static STATUS = 200
  static SCHEMA = S.obj().optional()

  /**
   * Return data to return to caller.
   */
  get respData () {
    return this.data
  }

  /**
   * Create a success response. Status must be smaller than 400.
   * @param {Object} data Data to return to caller
   * @param {Integer} code A status to override default status.
   */
  constructor (data, code = undefined) {
    super(undefined, data, code)
    assert(this.httpCode < 300, 'Status code must be less than 300')
  }
}

/**
 * Thrown to shortcut request handling and return an HTTP success code (200).
 *
 * @arg {String|Object} [respData=''] The object (to JSON.stringify()) or
 *   string to send in the HTTP response body.
 * @public
 * @memberof Exceptions
 * @see RequestDone
 */
class RequestOkay extends RequestDone {
  static STATUS = 200
}

/**
 * Thrown to shortcut request handling and return an HTTP error.
 *
 * @arg {String} message The human-readable error message to send back in the
 *   JSON response data (in the "error" key).
 * @arg {Object} [data={}] Optional additional JSON data to send back
 *   in the error response body.
 * @arg {Number} [code=400] The HTTP status code to respond with; defaults
 *   to STATUS
 * @public
 * @memberof Exceptions
 * @see RequestDone
 */
class RequestError extends __RequestDone {
  static SCHEMA = S.obj()

  constructor (message, data = {}, code = undefined) {
    super(message, data, code)
    assert(this.httpCode >= 300, 'Status code must be at least 300')
  }

  /**
   * Schema to use on returned data
   */
  static get respSchema () {
    return S.obj({
      code: S.str,
      message: S.str.optional(),
      data: S.obj() // Data is validated in constructor, don't validate again.
    })
  }

  /**
   * Schema to use in SDKs
   */
  static get c2jSchema () {
    return S.obj({
      message: S.str.optional().title('ErrorMessage')
    })
  }

  /**
   * Data to return to the caller
   */
  get respData () {
    return {
      code: this.constructor.name,
      message: this.message,
      data: this.data
    }
  }
}

/**
 * Thrown when a redirect is required. RedirectException is a subclass of
 * RequestError, because AWS SDKs don't support more than one success
 * responses, and they are using exceptions to handle redirects already.
 */
class RedirectException extends RequestError {
  static STATUS = 302
  static SCHEMA = S.str.max(0)

  constructor (url, code) {
    super('', '', code)
    assert(this.httpCode < 400, 'Status code must be less than 400')
    assert(typeof url === 'string' && url.length)
    this.url = url
  }
}

/**
 * Thrown when a error is induced by client.
 */
class BadRequestException extends RequestError {
  static STATUS = 400
}

/**
 * Wraps Request Validation failures into
 * a RequestError object for processing.
 */
class InvalidInputException extends BadRequestException {
  static STATUS = 400
  static __ERROR_PREFIX = {
    headers: 'Header Validation Failure',
    body: 'Body Validation Failure',
    querystring: 'Query Validation Failure',
    params: 'Path Validation Failure'
  }

  constructor (schemaError) {
    const prefixMap = InvalidInputException.__ERROR_PREFIX
    const prefix = prefixMap[schemaError.validationContext] ??
      'Unknown Validation Failure'
    super(`${prefix}: ${schemaError.message}`)
    this.name = this.constructor.name
    // istanbul ignore else
    if (process.env.NODE_ENV !== 'prod' && schemaError.validation) {
      for (const err of schemaError.validation) {
        console.log(JSON.stringify(err))
      }
    }
  }
}

/**
 * Thrown when a client is not authorized to access the requested resource. For
 * example, user is trying to log in using invalid credentials.
 */
class UnauthorizedException extends RequestError {
  static STATUS = 401

  constructor (message = 'access denied') {
    super(message)
  }
}

/**
 * Thrown when an authenticated client does not have enough privilege to access
 * the requested resource. For example, a user tries to change other user's
 * data.
 */
class ForbiddenException extends RequestError {
  static STATUS = 403

  constructor (message = 'access denied') {
    super(message)
  }
}

/**
 * Thrown when the requested resource is not found, or should be hidden.
 */
class NotFoundException extends RequestError {
  static STATUS = 404

  constructor () {
    super('Not found')
  }
}

/**
 * Thrown when an error is induced by a server bug.
 */
class InternalFailureException extends RequestError {
  static STATUS = 500
}

/**
 * Thrown when server is temporarily unable to serve the request, due to
 * internal timeouts or service outage.
 */
class ServiceUnavailableException extends RequestError {
  static STATUS = 503
}

module.exports = {
  // Base exceptions
  RequestError,
  RequestDone,

  // Success
  RequestOkay,

  // Redirect
  RedirectException,

  // Error
  BadRequestException,
  InvalidInputException,
  ForbiddenException,
  InternalFailureException,
  NotFoundException,
  ServiceUnavailableException,
  UnauthorizedException
}