import type { JsonRpcRequestWrapperFunction } from "./network-manager.js";
import type {
  JsonRpcRequest,
  JsonRpcResponse,
  RequestArguments,
  SuccessfulJsonRpcResponse,
} from "../../../types/providers.js";
import type {
  Dispatcher,
  HttpResponse,
  RequestOptions,
} from "@nomicfoundation/hardhat-utils/request";

import { HardhatError } from "@nomicfoundation/hardhat-errors";
import { ensureError } from "@nomicfoundation/hardhat-utils/error";
import { sleep, isObject } from "@nomicfoundation/hardhat-utils/lang";
import {
  getDispatcher,
  getProxyUrl,
  isValidUrl,
  postJsonRequest,
  shouldUseProxy,
  ConnectionRefusedError,
  RequestTimeoutError,
  ResponseStatusCodeError,
} from "@nomicfoundation/hardhat-utils/request";

import { EDR_NETWORK_REVERT_SNAPSHOT_EVENT } from "../../constants.js";
import { getHardhatVersion } from "../../utils/package.js";

import { BaseProvider } from "./base-provider.js";
import {
  getJsonRpcRequest,
  isFailedJsonRpcResponse,
  parseJsonRpcResponse,
} from "./json-rpc.js";
import {
  ProviderError,
  LimitExceededError,
  UnknownError,
} from "./provider-errors.js";

const TOO_MANY_REQUEST_STATUS = 429;
const MAX_RETRIES = 6;
const MAX_RETRY_WAIT_TIME_SECONDS = 5;

interface HttpProviderConfig {
  url: string;
  networkName: string;
  extraHeaders?: Record<string, string>;
  timeout: number;
  jsonRpcRequestWrapper?: JsonRpcRequestWrapperFunction;
  testDispatcher?: Dispatcher;
}

export class HttpProvider extends BaseProvider {
  readonly #url: string;
  readonly #networkName: string;
  readonly #extraHeaders: Readonly<Record<string, string>>;
  readonly #jsonRpcRequestWrapper?: JsonRpcRequestWrapperFunction;

  #dispatcher: Dispatcher | undefined;
  #nextRequestId = 1;

  /**
   * Creates a new instance of `HttpProvider`.
   */
  public static async create({
    url,
    networkName,
    extraHeaders = {},
    timeout,
    jsonRpcRequestWrapper,
    testDispatcher,
  }: HttpProviderConfig): Promise<HttpProvider> {
    if (!isValidUrl(url)) {
      throw new HardhatError(HardhatError.ERRORS.CORE.NETWORK.INVALID_URL, {
        value: url,
      });
    }

    const dispatcher =
      testDispatcher ?? (await getHttpDispatcher(url, timeout));

    const httpProvider = new HttpProvider(
      url,
      networkName,
      extraHeaders,
      dispatcher,
      jsonRpcRequestWrapper,
    );

    return httpProvider;
  }

  /**
   * @private
   *
   * This constructor is intended for internal use only.
   * Use the static method {@link HttpProvider.create} to create an instance of
   * `HttpProvider`.
   */
  private constructor(
    url: string,
    networkName: string,
    extraHeaders: Record<string, string>,
    dispatcher: Dispatcher,
    jsonRpcRequestWrapper?: JsonRpcRequestWrapperFunction,
  ) {
    super();

    this.#url = url;
    this.#networkName = networkName;
    this.#extraHeaders = extraHeaders;
    this.#dispatcher = dispatcher;
    this.#jsonRpcRequestWrapper = jsonRpcRequestWrapper;
  }

  public async request(
    requestArguments: RequestArguments,
  ): Promise<SuccessfulJsonRpcResponse["result"]> {
    if (this.#dispatcher === undefined) {
      throw new HardhatError(HardhatError.ERRORS.CORE.NETWORK.PROVIDER_CLOSED);
    }

    const { method, params } = requestArguments;

    const jsonRpcRequest = getJsonRpcRequest(
      this.#nextRequestId++,
      method,
      params,
    );

    let jsonRpcResponse: JsonRpcResponse;

    if (this.#jsonRpcRequestWrapper !== undefined) {
      jsonRpcResponse = await this.#jsonRpcRequestWrapper(
        jsonRpcRequest,
        (request) => this.#fetchJsonRpcResponse(request),
      );
    } else {
      jsonRpcResponse = await this.#fetchJsonRpcResponse(jsonRpcRequest);
    }

    if (isFailedJsonRpcResponse(jsonRpcResponse)) {
      const error = new ProviderError(
        jsonRpcResponse.error.message,
        jsonRpcResponse.error.code,
      );
      error.data = jsonRpcResponse.error.data;

      // eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError
      throw error;
    }

    if (jsonRpcRequest.method === "evm_revert") {
      this.emit(EDR_NETWORK_REVERT_SNAPSHOT_EVENT);
    }

    return jsonRpcResponse.result;
  }

  public async close(): Promise<void> {
    this.removeAllListeners();
    if (this.#dispatcher !== undefined) {
      // See https://github.com/nodejs/undici/discussions/3522#discussioncomment-10498734
      await this.#dispatcher.close();
      this.#dispatcher = undefined;
    }
  }

  async #fetchJsonRpcResponse(
    jsonRpcRequest: JsonRpcRequest,
    retryCount = 0,
  ): Promise<JsonRpcResponse> {
    const requestOptions: RequestOptions = {
      extraHeaders: {
        "User-Agent": `Hardhat ${await getHardhatVersion()}`,
        ...this.#extraHeaders,
      },
    };

    let response: HttpResponse;
    try {
      response = await postJsonRequest(
        this.#url,
        jsonRpcRequest,
        requestOptions,
        this.#dispatcher,
      );
    } catch (e) {
      ensureError(e);

      if (e instanceof ConnectionRefusedError) {
        throw new HardhatError(
          HardhatError.ERRORS.CORE.NETWORK.CONNECTION_REFUSED,
          { network: this.#networkName },
          e,
        );
      }

      if (e instanceof RequestTimeoutError) {
        throw new HardhatError(
          HardhatError.ERRORS.CORE.NETWORK.NETWORK_TIMEOUT,
          e,
        );
      }

      /**
       * Nodes can have a rate limit mechanism to avoid abuse. This logic checks
       * if the response indicates a rate limit has been reached and retries the
       * request after the specified time.
       */
      if (
        e instanceof ResponseStatusCodeError &&
        e.statusCode === TOO_MANY_REQUEST_STATUS
      ) {
        const retryAfterHeader =
          isObject(e.headers) && typeof e.headers["retry-after"] === "string"
            ? e.headers["retry-after"]
            : undefined;
        const retryAfterSeconds = this.#getRetryAfterSeconds(
          retryAfterHeader,
          retryCount,
        );
        if (this.#shouldRetryRequest(retryAfterSeconds, retryCount)) {
          return this.#retry(jsonRpcRequest, retryAfterSeconds, retryCount);
        }

        // eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError
        throw new LimitExceededError(undefined, e);
      }

      // eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError
      throw new UnknownError(e.message, e);
    }

    return parseJsonRpcResponse(await response.body.text());
  }

  #getRetryAfterSeconds(
    retryAfterHeader: string | undefined,
    retryCount: number,
  ) {
    const parsedRetryAfter = parseInt(`${retryAfterHeader}`, 10);
    if (isNaN(parsedRetryAfter)) {
      // use an exponential backoff if the retry-after header can't be parsed
      return Math.min(2 ** retryCount, MAX_RETRY_WAIT_TIME_SECONDS);
    }

    return parsedRetryAfter;
  }

  #shouldRetryRequest(retryAfterSeconds: number, retryCount: number) {
    if (retryCount > MAX_RETRIES) {
      return false;
    }

    if (retryAfterSeconds > MAX_RETRY_WAIT_TIME_SECONDS) {
      return false;
    }

    return true;
  }

  async #retry(
    request: JsonRpcRequest,
    retryAfterSeconds: number,
    retryCount: number,
  ) {
    await sleep(retryAfterSeconds);
    return this.#fetchJsonRpcResponse(request, retryCount + 1);
  }
}

/**
 * Gets either a pool or proxy dispatcher depending on the URL and the
 * proxy configuration. This function is used internally by
 * `HttpProvider.create` and should not be used directly.
 */
export async function getHttpDispatcher(
  url: string,
  timeout?: number,
): Promise<Dispatcher> {
  let dispatcher: Dispatcher;

  const proxyUrl = shouldUseProxy(url) ? getProxyUrl(url) : undefined;

  if (proxyUrl !== undefined) {
    dispatcher = await getDispatcher(url, {
      proxy: proxyUrl,
      timeout,
    });
  } else {
    dispatcher = await getDispatcher(url, { pool: true, timeout });
  }

  return dispatcher;
}
