import { initializer, location } from 'knifecycle';
import {
  DEFAULT_DEBUG_NODE_ENVS,
  DEFAULT_STRINGIFIERS,
  type WhookStringifiers,
} from '../libs/constants.js';
// @ts-expect-error no types for this module
import miniquery from 'miniquery';
import { printStackTrace, YError } from 'yerror';
import { type YHTTPError } from 'yhttperror';
import { type AppEnvVars } from 'application-services';
import { type WhookResponseBodySpec } from '../libs/body.js';
import { type JSONSchema } from 'ya-json-schema-types';
import { JsonValue } from 'type-fest';

/* Architecture Note #2.13: Error handler

Whook provides a default error handler that allows
 you to use the [`yerror`](https://github.com/nfroidure/yerror)
 and [`yhttperror`](https://github.com/nfroidure/yhttperror)
 modules to throw errors with some parameters allowing you
 to give some debugging context with raised errors.

Depending on the `NODE_ENV` it adds extra information to
 HTTP response, beware to avoid activating it in production.
*/

/* Architecture Note #2.13.1: Errors descriptors

Errors descriptors allows you to change the error
 handler behavior selectively for each error :
- change error codes to proper error messages templated
 with params,
- add URIs to help for debugging API errors
- override the error status with the `status` property.
- decide to pass some error parameters through with the
 `transmittedParams` so that your end users can have some
 more context on the error root cause.
*/
export interface WhookErrorDescriptor {
  code: string;
  description?: string;
  uri?: string;
  help?: string;
  status?: number;
  transmittedParams?: number[];
}
export type WhookErrorsDescriptors = Record<string, WhookErrorDescriptor>;

export const DEFAULT_ERROR_URI =
  'https://stackoverflow.com/search?q=%5Bwhook%5D+$code';
export const DEFAULT_HELP_URI =
  'https://stackoverflow.com/questions/ask?tags=whook&title=How+to+debug+$code+whook+error+code';
export const DEFAULT_DEFAULT_ERROR_CODE = 'E_UNEXPECTED';
export const DEFAULT_ERRORS_DESCRIPTORS = {
  [DEFAULT_DEFAULT_ERROR_CODE]: {
    code: 'error',
    description: 'Got an unexpected error',
    status: 500,
    help: DEFAULT_HELP_URI,
  },
  E_TRANSACTION_TIMEOUT: {
    code: 'error',
    description: 'The request processing timed out (timeout: "$0")',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_STRINGIFYER_LACK: {
    code: 'router_misconfiguration',
    description:
      'The router has no stringifyer declared for the response content type "$0" with status "$1.status"',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_UNDECLARED_PATH_PARAMETER: {
    code: 'router_misconfiguration',
    description:
      'The no path parameter declared for the node "$0" on the path "$1"',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_BAD_PATH: {
    code: 'router_misconfiguration',
    description: 'The path "$0" must start with a slash',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_NO_OPERATION_ID: {
    code: 'router_misconfiguration',
    description: 'No operation id for the path "$0" and the "$1"',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_NO_HANDLER: {
    code: 'router_misconfiguration',
    description: 'No handler declared for the operation id "$0"',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_BAD_OPEN_API: {
    code: 'router_misconfiguration',
    description: 'The Open API provided to the router seems invalid',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_UNDECLARED_SECURITY_SCHEME: {
    code: 'router_misconfiguration',
    description:
      'An operation ("$1") mentions an undeclared security scheme ("$0")',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_BAD_BODY_SCHEMA: {
    code: 'router_misconfiguration',
    description: 'An operation ("$0") has an invalid body schema ("$1")',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_UNSUPPORTED_HTTP_SCHEME: {
    code: 'router_misconfiguration',
    description: 'This router do not support this security schema yet ("$0")',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_UNSUPPORTED_API_KEY_SOURCE: {
    code: 'router_misconfiguration',
    description: 'This router do not support this API key source yet ("$0")',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_BAD_PARAMETER_NAME: {
    code: 'router_misconfiguration',
    description:
      'Bad name for the parameter at index "$1" for the operation "$0"',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_UNSUPPORTED_PARAMETER_DEFINITION: {
    code: 'router_misconfiguration',
    description:
      'This router does not support the parameter "$1" defined in "$0"',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_BAD_PARAMETER_SCHEMA: {
    code: 'router_misconfiguration',
    description:
      'The router  where unable to compile the parameter "$1" defined in "$0"',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_NO_RESPONSE_PROMISE: {
    code: 'bad_handler',
    description:
      'The handler for the operation id "$0" seems to not return a promise',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_NO_RESPONSE: {
    code: 'bad_handler',
    description:
      'The handler for the operation id "$0" seems to not return any response',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_NO_RESPONSE_STATUS: {
    code: 'bad_handler',
    description:
      'The handler for the operation id "$0" seems to not return any status',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_NON_NUMERIC_STATUS: {
    code: 'bad_handler',
    description:
      'The handler for the operation id "$0" seems to not be a number',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_NOT_FOUND: {
    code: 'bad_handler',
    description: 'No endpoint found at that path ("$0", "$2")',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_REQUEST_CONTENT_TOO_LARGE: {
    code: 'bad_request',
    description:
      'The request content length ("$0") exceeded the maximum allowed ("$1")',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_BAD_BODY: {
    code: 'bad_request',
    description: 'The request body could not be parsed',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
    transmittedParams: [0],
  },
  E_BAD_BODY_LENGTH: {
    code: 'bad_request',
    description:
      'The request body length ("$0") is different than the length defined in the headers ("$1")',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_UNSUPPORTED_CHARSET: {
    code: 'bad_request',
    description:
      'The body charset "$0" is not supported (supported char sets "$1")',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_BAD_CONTENT_TYPE: {
    code: 'bad_request',
    description: 'The content type provided is weird "$0"',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_REQUEST_FAILURE: {
    code: 'bad_request',
    description: 'Failed to read the request body input stream',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_UNSUPPORTED_MEDIA_TYPE: {
    code: 'bad_request',
    description:
      'The body media type "$0" is not supported (supported media types "$1")',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_UNACCEPTABLE_CHARSET: {
    code: 'bad_request',
    description:
      'The asked response charset "$0" is not satisfiable (allowed char sets "$2")',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_UNACCEPTABLE_MEDIA_TYPE: {
    code: 'bad_request',
    description:
      'The asked response media type "$0" is not satisfiable (allowed media types "$1")',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_REQUIRED_REQUEST_BODY: {
    code: 'bad_request',
    description: 'This endpoint requires a request body to be sent',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_BAD_REQUEST_BODY: {
    code: 'bad_request',
    description: 'The request body does not match the schema',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
    transmittedParams: [2, 3],
  },
  E_NO_REQUEST_BODY: {
    code: 'bad_request',
    description:
      'This endpoint do not declare a request body but one were sent',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_REQUIRED_PARAMETER: {
    code: 'bad_request',
    description: 'This endpoint requires a value for the parameter "$0"',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_BAD_PARAMETER: {
    code: 'bad_request',
    description: 'The value provided for the parameter "$0" is not valid',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
    transmittedParams: [2, 3],
  },
  E_NON_REENTRANT_NUMBER: {
    code: 'bad_request',
    description: 'The value provided for the parameter "$0" is not reentrant',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
    transmittedParams: [2, 3],
  },
  E_BAD_BOOLEAN: {
    code: 'bad_request',
    description: 'The value provided ("$0") is not a boolean',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
  E_TRANSACTION_ID_NOT_UNIQUE: {
    code: 'bad_request',
    description:
      'The transaction id of the request "$0" is colliding with another one.',
    uri: DEFAULT_ERROR_URI,
    help: DEFAULT_HELP_URI,
  },
} satisfies WhookErrorsDescriptors;

export interface WhookErrorHandlerConfig {
  DEBUG_NODE_ENVS: string[];
  ERRORS_DESCRIPTORS: WhookErrorsDescriptors;
  DEFAULT_ERROR_CODE?: string;
}
export type WhookErrorHandlerDependencies = WhookErrorHandlerConfig & {
  ENV: AppEnvVars;
  STRINGIFIERS?: WhookStringifiers;
};

export type WhookErrorHandler = (
  transactionId: string,
  responseSpec: WhookResponseBodySpec,
  err: Error,
) => Promise<WhookErrorResponse>;

export interface WhookErrorResponse {
  status: number;
  headers: {
    'cache-control': 'private';
    'content-type': string;
    [name: string]: string | string[];
  };
  body: {
    error: string;
    error_description?: string;
    error_uri?: string;
    error_help_uri?: string;
    error_params?: JsonValue[];
    error_debug_data: {
      guruMeditation: string;
      code?: string;
      stack?: string;
      params?: JsonValue[];
    };
  };
}

export const whookErrorSchema: JSONSchema = {
  type: 'object',
  additionalProperties: false,
  properties: {
    error: { type: 'string' },
    error_description: { type: 'string' },
    error_uri: { type: 'string' },
    error_debug_data: {
      type: 'object',
      additionalProperties: true,
      properties: {
        code: { type: 'string' },
        stack: { type: 'string' },
        // TODO: find a way to specify params or
        // maybe standardize it (only one object in params?)
      },
    },
  },
};

export default location(
  initializer(
    {
      name: 'errorHandler',
      type: 'service',
      inject: [
        'ENV',
        '?DEBUG_NODE_ENVS',
        '?STRINGIFIERS',
        '?ERRORS_DESCRIPTORS',
        '?DEFAULT_ERROR_CODE',
      ],
    },
    initErrorHandler,
  ),
  import.meta.url,
);

/**
 * Initialize an error handler for the
 * HTTP router
 * @param  {Object}   services
 * The service dependencies
 * @param  {Object}   services.ENV
 * The app ENV
 * @param  {Array}   [services.DEBUG_NODE_ENVS]
 * The environnement that activate debugging
 *  (prints stack trace in HTTP errors responses)
 * @param  {Object} [services.STRINGIFIERS]
 * The synchronous body stringifiers
 * @param  {Object} [services.ERRORS_DESCRIPTORS]
 * An hash of the various error descriptors
 * @param  {Object} [services.DEFAULT_ERROR_CODE]
 * A string giving the default error code
 * @return {Promise}
 * A promise of a function to handle errors
 */
async function initErrorHandler({
  ENV,
  DEBUG_NODE_ENVS = DEFAULT_DEBUG_NODE_ENVS,
  STRINGIFIERS = DEFAULT_STRINGIFIERS,
  ERRORS_DESCRIPTORS = DEFAULT_ERRORS_DESCRIPTORS,
  DEFAULT_ERROR_CODE = DEFAULT_DEFAULT_ERROR_CODE,
}: WhookErrorHandlerDependencies): Promise<WhookErrorHandler> {
  // Ensure we have required error descriptors
  ERRORS_DESCRIPTORS = {
    ...DEFAULT_ERRORS_DESCRIPTORS,
    ...ERRORS_DESCRIPTORS,
  };

  return errorHandler;

  /**
   * Handle an HTTP transaction error and
   * map it to a serializable response
   * @param  {String}  transactionId
   * A raw NodeJS HTTP incoming message
   * @param  {Object} responseSpec
   * The response specification
   * @param  {YHTTPError} err
   * The encountered error
   * @return {Promise}
   * A promise resolving when the operation
   *  completes
   */
  async function errorHandler(
    transactionId: string,
    responseSpec: WhookResponseBodySpec,
    err: Error | YError | YHTTPError,
  ) {
    const errorCode = (err as YError).code || DEFAULT_ERROR_CODE;
    const errorDescriptor =
      ERRORS_DESCRIPTORS[errorCode] ||
      ERRORS_DESCRIPTORS[DEFAULT_DEFAULT_ERROR_CODE];
    const response: WhookErrorResponse = {
      status:
        (errorDescriptor !== ERRORS_DESCRIPTORS[DEFAULT_DEFAULT_ERROR_CODE] &&
        errorDescriptor.status
          ? errorDescriptor.status
          : undefined) ||
        (err as YHTTPError).httpCode ||
        ERRORS_DESCRIPTORS[DEFAULT_DEFAULT_ERROR_CODE].status ||
        500,
      headers: Object.assign({}, (err as YHTTPError).headers || {}, {
        // Avoid caching errors
        'cache-control': 'private' as const,
        // Fallback to the default stringifyer to always be
        // able to display errors
        'content-type':
          responseSpec &&
          responseSpec.contentTypes[0] &&
          STRINGIFIERS[responseSpec.contentTypes[0]]
            ? responseSpec.contentTypes[0]
            : Object.keys(STRINGIFIERS)[0],
      }),
      // Here we do not respect the camelCase convention to
      // have OAuth2 compatible errors 🤷
      body: {
        error: errorDescriptor.code,
        error_description: replaceTemplatedValues(
          err as YError,
          errorDescriptor.description as string,
        ),
        error_uri: replaceTemplatedValues(
          err as YError,
          errorDescriptor.uri as string,
        ),
        error_help_uri: replaceTemplatedValues(
          err as YError,
          errorDescriptor.help as string,
        ),
        error_params: errorDescriptor.transmittedParams
          ? errorDescriptor.transmittedParams.map(
              (paramIndex) =>
                (((err as YError).debug as JsonValue[]) || [])[paramIndex],
            )
          : undefined,
        error_debug_data: {
          // Enjoy nerdy stuff:
          // https://en.wikipedia.org/wiki/Guru_Meditation
          guruMeditation: transactionId,
        },
      },
    };

    if (DEBUG_NODE_ENVS.includes(ENV.NODE_ENV)) {
      response.body.error_debug_data.code = errorCode;
      response.body.error_debug_data.stack = printStackTrace(err);
      response.body.error_debug_data.params = (err as YError)
        .debug as JsonValue[];
    }

    return response;
  }
}

function replaceTemplatedValues(err: YError, str: string): string | undefined {
  return str
    ? str
        .replace(/\$([0-9](:?\.[a-zA-Z0-9#@*]+)*)/g, (_, path) =>
          miniquery(path, err.debug ? [err.debug] : []).join(', '),
        )
        .replace(/\$code/, (err as YError).code)
    : undefined;
}
