import fs from "fs";
import http from "http";
import path from "path";
import formidable from "formidable";
import Koa from "koa";
import escape from "lodash/escape";
import isNil from "lodash/isNil";
import snakeCase from "lodash/snakeCase";
import {
  ValidationError as SequelizeValidationError,
  EmptyResultError as SequelizeEmptyResultError,
} from "sequelize";
import env from "@server/env";
import {
  AuthorizationError,
  ClientClosedRequestError,
  InternalError,
  NotFoundError,
  ValidationError,
} from "@server/errors";
import { requestErrorHandler } from "@server/logging/sentry";

let errorHtmlCache: Buffer | undefined;

export default function onerror(app: Koa) {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  app.context.onerror = function (err: any) {
    // Don't do anything if there is no error, this allows you to pass `this.onerror` to node-style callbacks.
    if (isNil(err)) {
      return;
    }

    err = wrapInNativeError(err);

    if (err instanceof SequelizeValidationError) {
      if (err.errors && err.errors[0]) {
        err = ValidationError(
          `${err.errors[0].message} (${err.errors[0].path})`
        );
      } else {
        err = ValidationError();
      }
    }

    // Client aborted errors are a 500 by default, but 499 is more appropriate
    if (err instanceof formidable.errors.FormidableError) {
      if (err.internalCode === 1002) {
        err = ClientClosedRequestError();
      }
    }

    if (
      err.code === "ENOENT" ||
      err instanceof SequelizeEmptyResultError ||
      /Not found/i.test(err.message)
    ) {
      err = NotFoundError();
    }

    if (
      !(err instanceof AuthorizationError) &&
      /Authorization error/i.test(err.message)
    ) {
      err = AuthorizationError();
    }

    // Push only unknown and 500 status errors to sentry
    if (
      typeof err.status !== "number" ||
      !http.STATUS_CODES[err.status] ||
      err.status === 500
    ) {
      requestErrorHandler(err, this);

      if (!(err instanceof InternalError)) {
        if (env.ENVIRONMENT === "test") {
          // eslint-disable-next-line no-console
          console.error(err);
        }
        err = InternalError();
      }
    }

    const headerSent = this.headerSent || !this.writable;
    if (headerSent) {
      err.headerSent = true;
    }

    // Nothing we can do here other than delegate to the app-level handler and log.
    if (headerSent) {
      return;
    }

    this.set(err.headers);
    this.status = err.status;
    this.type = this.accepts("json", "html") || "json";

    if (this.type === "text/html") {
      this.body = readErrorFile()
        .toString()
        .replace(/\/\/inject-message\/\//g, escape(err.message))
        .replace(/\/\/inject-status\/\//g, escape(err.status))
        .replace(/\/\/inject-stack\/\//g, escape(err.stack));
    } else {
      this.body = JSON.stringify({
        ok: false,
        error: snakeCase(err.id),
        status: Number(err.status),
        message: String(err.message || err.name),
        data: err.errorData ?? undefined,
      });
    }

    this.res.end(this.body);
  };

  return app;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function wrapInNativeError(err: any): Error {
  // When dealing with cross-globals a normal `instanceof` check doesn't work properly.
  // See https://github.com/koajs/koa/issues/1466
  // We can probably remove it once jest fixes https://github.com/facebook/jest/issues/2549.
  const isNativeError =
    Object.prototype.toString.call(err) === "[object Error]" ||
    err instanceof Error;

  if (isNativeError) {
    return err as Error;
  }

  let errMsg = err;
  if (typeof err === "object") {
    try {
      errMsg = JSON.stringify(err);
      // eslint-disable-next-line no-empty
    } catch (e) {}
  }
  const newError = InternalError(`Non-error thrown: ${errMsg}`);
  // err maybe an object, try to copy the name, message and stack to the new error instance
  if (err) {
    if (err.name) {
      newError.name = err.name;
    }
    if (err.message) {
      newError.message = err.message;
    }
    if (err.stack) {
      newError.stack = err.stack;
    }
    if (err.status) {
      newError.status = err.status;
    }
    if (err.headers) {
      newError.headers = err.headers;
    }
  }

  return newError;
}

function readErrorFile(): Buffer {
  if (env.isDevelopment) {
    return fs.readFileSync(path.join(__dirname, "error.dev.html"));
  }

  if (env.isProduction) {
    return (
      errorHtmlCache ??
      (errorHtmlCache = fs.readFileSync(
        path.join(__dirname, "error.prod.html")
      ))
    );
  }

  return (
    errorHtmlCache ??
    (errorHtmlCache = fs.readFileSync(
      path.join(__dirname, "static/error.dev.html")
    ))
  );
}
