import bearerAuthPlugin from "@fastify/bearer-auth";
import {fastifyCors} from "@fastify/cors";
import {FastifyError, FastifyInstance, FastifyRequest, errorCodes, fastify} from "fastify";
import {parse as parseQueryString} from "qs";
import {addSszContentTypeParser} from "@lodestar/api/server";
import {ErrorAborted, Gauge, Histogram, Logger} from "@lodestar/utils";
import {isLocalhostIP} from "../../util/ip.js";
import {ApiError, FailureList, IndexedError, NodeIsSyncing} from "../impl/errors.js";
import {HttpActiveSocketsTracker, SocketMetrics} from "./activeSockets.js";

export type RestApiServerOpts = {
  port: number;
  cors?: string;
  address?: string;
  bearerToken?: string;
  headerLimit?: number;
  bodyLimit?: number;
  stacktraces?: boolean;
  swaggerUI?: boolean;
};

export type RestApiServerModules = {
  logger: Logger;
  metrics: RestApiServerMetrics | null;
};

export type RestApiServerMetrics = SocketMetrics & {
  requests: Gauge<{operationId: string}>;
  responseTime: Histogram<{operationId: string}>;
  errors: Gauge<{operationId: string}>;
};

/**
 * Error response body format as defined in beacon-api spec
 *
 * See https://github.com/ethereum/beacon-APIs/blob/v3.1.0/types/http.yaml
 */
type ErrorResponse = {
  code: number;
  message: string;
  stacktraces?: string[];
};

type IndexedErrorResponse = ErrorResponse & {
  failures?: FailureList;
};

/**
 * Error code used by Fastify if media type is not supported (415)
 */
const INVALID_MEDIA_TYPE_CODE = errorCodes.FST_ERR_CTP_INVALID_MEDIA_TYPE().code;

/**
 * Error code used by Fastify if JSON schema validation failed
 */
const SCHEMA_VALIDATION_ERROR_CODE = errorCodes.FST_ERR_VALIDATION().code;

/**
 * REST API powered by `fastify` server.
 */
export class RestApiServer {
  protected readonly server: FastifyInstance;
  protected readonly logger: Logger;
  private readonly activeSockets: HttpActiveSocketsTracker;

  constructor(
    protected readonly opts: RestApiServerOpts,
    modules: RestApiServerModules
  ) {
    // Apply opts defaults
    const {logger, metrics} = modules;

    const server = fastify({
      logger: false,
      ajv: {customOptions: {coerceTypes: "array"}},
      routerOptions: {
        querystringParser: (str) =>
          parseQueryString(str, {
            // Array as comma-separated values must be supported to be OpenAPI spec compliant
            comma: true,
            // Drop support for array query strings like `id[0]=1&id[1]=2&id[2]=3` as those are not required to
            // be OpenAPI spec compliant and results are inconsistent, see https://github.com/ljharb/qs/issues/331.
            // The schema validation will catch this and throw an error as parsed query string results in an object.
            parseArrays: false,
          }),
      },
      bodyLimit: opts.bodyLimit,
      http: {maxHeaderSize: opts.headerLimit},
    });

    addSszContentTypeParser(server);

    this.activeSockets = new HttpActiveSocketsTracker(server.server, metrics);

    // To parse our ApiError -> statusCode
    server.setErrorHandler<FastifyError | Error>((err, _req, res) => {
      const stacktraces = opts.stacktraces ? err.stack?.split("\n") : undefined;
      if ("validation" in err && err.validation) {
        const {instancePath = "unknown", message} = err.validation?.[0] ?? {};
        const payload: ErrorResponse = {
          code: 400,
          message: `${instancePath.substring(instancePath.lastIndexOf("/") + 1)} ${message}`,
          stacktraces,
        };
        void res.status(400).send(payload);
      } else if (err instanceof IndexedError) {
        const payload: IndexedErrorResponse = {
          code: err.statusCode,
          message: err.message,
          failures: err.failures,
          stacktraces,
        };
        void res.status(err.statusCode).send(payload);
      } else {
        // Convert our custom ApiError into status code
        const statusCode = err instanceof ApiError ? err.statusCode : 500;
        const payload: ErrorResponse = {code: statusCode, message: err.message, stacktraces};
        void res.status(statusCode).send(payload);
      }
    });

    server.setNotFoundHandler((req, res) => {
      const message = `Route ${req.raw.method}:${req.raw.url} not found`;
      this.logger.warn(message);
      const payload: ErrorResponse = {code: 404, message};
      void res.code(404).send(payload);
    });

    if (opts.cors) {
      void server.register(fastifyCors, {origin: opts.cors});
    }

    if (opts.bearerToken) {
      void server.register(bearerAuthPlugin, {keys: new Set([opts.bearerToken])});
    }

    // Log all incoming request to debug (before parsing). TODO: Should we hook latter in the lifecycle? https://www.fastify.io/docs/latest/Lifecycle/
    // Note: Must be an async method so fastify can continue the release lifecycle. Otherwise we must call done() or the request stalls
    server.addHook("onRequest", async (req, _res) => {
      const operationId = getOperationId(req);
      this.logger.debug(`Req ${req.id} ${req.ip} ${operationId}`);
      metrics?.requests.inc({operationId});
    });

    server.addHook("preHandler", async (req, _res) => {
      const operationId = getOperationId(req);
      this.logger.debug(`Exec ${req.id} ${req.ip} ${operationId}`);
    });

    // Log after response
    server.addHook("onResponse", async (req, res) => {
      const operationId = getOperationId(req);
      this.logger.debug(`Res ${req.id} ${operationId} - ${res.raw.statusCode}`);
      metrics?.responseTime.observe({operationId}, res.elapsedTime / 1000);
    });

    server.addHook("onError", async (req, _res, err) => {
      // Don't log ErrorAborted errors, they happen on node shutdown and are not useful
      // Don't log NodeISSyncing errors, they happen very frequently while syncing and the validator polls duties
      if (err instanceof ErrorAborted || err instanceof NodeIsSyncing) return;

      const operationId = getOperationId(req);

      if (err instanceof ApiError || [INVALID_MEDIA_TYPE_CODE, SCHEMA_VALIDATION_ERROR_CODE].includes(err.code)) {
        this.logger.warn(`Req ${req.id} ${operationId} failed`, {reason: err.message});
      } else {
        this.logger.error(`Req ${req.id} ${operationId} error`, {}, err);
      }
      metrics?.errors.inc({operationId});
    });

    this.server = server;
    this.logger = logger;
  }

  /**
   * Start the REST API server.
   */
  async listen(): Promise<void> {
    try {
      const host = this.opts.address;
      await this.server.listen({port: this.opts.port, host});
      const {address, port} = this.server.addresses()[0];
      this.logger.info("Started REST API server", {address: `http://${address}:${port}`});
      if (!host || !isLocalhostIP(host)) {
        this.logger.warn("REST API server is exposed, ensure untrusted traffic cannot reach this API");
      }
    } catch (e) {
      this.logger.error("Error starting REST api server", this.opts, e as Error);
      throw e;
    }
  }

  /**
   * Close the server instance and terminate all existing connections.
   */
  async close(): Promise<void> {
    // In NodeJS land calling close() only causes new connections to be rejected.
    // Existing connections can prevent .close() from resolving for potentially forever.
    // In Lodestar case when the BeaconNode wants to close we will attempt to gracefully
    // close all existing connections but forcefully terminate after timeout for a fast shutdown.
    // Inspired by https://github.com/gajus/http-terminator/
    await this.activeSockets.terminate();

    await this.server.close();

    this.logger.debug("REST API server closed");
  }

  /** For child classes to override */
  protected shouldIgnoreError(_err: Error): boolean {
    return false;
  }
}

function getOperationId(req: FastifyRequest): string {
  return req.routeOptions.schema?.operationId ?? "unknown";
}
