/*
 * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
 */

export type Fetcher = (
  input: RequestInfo | URL,
  init?: RequestInit,
) => Promise<Response>;

export type Awaitable<T> = T | Promise<T>;

const DEFAULT_FETCHER: Fetcher = (input, init) => {
  // If input is a Request and init is undefined, Bun will discard the method,
  // headers, body and other options that were set on the request object.
  // Node.js and browers would ignore an undefined init value. This check is
  // therefore needed for interop with Bun.
  if (init == null) {
    return fetch(input);
  } else {
    return fetch(input, init);
  }
};

export type RequestInput = {
  /**
   * The URL the request will use.
   */
  url: URL;
  /**
   * Options used to create a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request).
   */
  options?: RequestInit | undefined;
};

export interface HTTPClientOptions {
  fetcher?: Fetcher;
}

export type BeforeRequestHook = (req: Request) => Awaitable<Request | void>;
export type RequestErrorHook = (err: unknown, req: Request) => Awaitable<void>;
export type ResponseHook = (res: Response, req: Request) => Awaitable<void>;

export class HTTPClient {
  private fetcher: Fetcher;
  private requestHooks: BeforeRequestHook[] = [];
  private requestErrorHooks: RequestErrorHook[] = [];
  private responseHooks: ResponseHook[] = [];

  constructor(private options: HTTPClientOptions = {}) {
    this.fetcher = options.fetcher || DEFAULT_FETCHER;
  }

  async request(request: Request): Promise<Response> {
    let req = request;
    for (const hook of this.requestHooks) {
      const nextRequest = await hook(req);
      if (nextRequest) {
        req = nextRequest;
      }
    }

    try {
      const res = await this.fetcher(req);

      for (const hook of this.responseHooks) {
        await hook(res, req);
      }

      return res;
    } catch (err) {
      for (const hook of this.requestErrorHooks) {
        await hook(err, req);
      }

      throw err;
    }
  }

  /**
   * Registers a hook that is called before a request is made. The hook function
   * can mutate the request or return a new request. This may be useful to add
   * additional information to request such as request IDs and tracing headers.
   */
  addHook(hook: "beforeRequest", fn: BeforeRequestHook): this;
  /**
   * Registers a hook that is called when a request cannot be made due to a
   * network error.
   */
  addHook(hook: "requestError", fn: RequestErrorHook): this;
  /**
   * Registers a hook that is called when a response has been received from the
   * server.
   */
  addHook(hook: "response", fn: ResponseHook): this;
  addHook(
    ...args:
      | [hook: "beforeRequest", fn: BeforeRequestHook]
      | [hook: "requestError", fn: RequestErrorHook]
      | [hook: "response", fn: ResponseHook]
  ) {
    if (args[0] === "beforeRequest") {
      this.requestHooks.push(args[1]);
    } else if (args[0] === "requestError") {
      this.requestErrorHooks.push(args[1]);
    } else if (args[0] === "response") {
      this.responseHooks.push(args[1]);
    } else {
      throw new Error(`Invalid hook type: ${args[0]}`);
    }
    return this;
  }

  /** Removes a hook that was previously registered with `addHook`. */
  removeHook(hook: "beforeRequest", fn: BeforeRequestHook): this;
  /** Removes a hook that was previously registered with `addHook`. */
  removeHook(hook: "requestError", fn: RequestErrorHook): this;
  /** Removes a hook that was previously registered with `addHook`. */
  removeHook(hook: "response", fn: ResponseHook): this;
  removeHook(
    ...args:
      | [hook: "beforeRequest", fn: BeforeRequestHook]
      | [hook: "requestError", fn: RequestErrorHook]
      | [hook: "response", fn: ResponseHook]
  ): this {
    let target: unknown[];
    if (args[0] === "beforeRequest") {
      target = this.requestHooks;
    } else if (args[0] === "requestError") {
      target = this.requestErrorHooks;
    } else if (args[0] === "response") {
      target = this.responseHooks;
    } else {
      throw new Error(`Invalid hook type: ${args[0]}`);
    }

    const index = target.findIndex((v) => v === args[1]);
    if (index >= 0) {
      target.splice(index, 1);
    }

    return this;
  }

  clone(): HTTPClient {
    const child = new HTTPClient(this.options);
    child.requestHooks = this.requestHooks.slice();
    child.requestErrorHooks = this.requestErrorHooks.slice();
    child.responseHooks = this.responseHooks.slice();

    return child;
  }
}

export type StatusCodePredicate = number | string | (number | string)[];

// A semicolon surrounded by optional whitespace characters is used to separate
// segments in a media type string.
const mediaParamSeparator = /\s*;\s*/g;

export function matchContentType(response: Response, pattern: string): boolean {
  // `*` is a special case which means anything is acceptable.
  if (pattern === "*") {
    return true;
  }

  let contentType =
    response.headers.get("content-type")?.trim() || "application/octet-stream";
  contentType = contentType.toLowerCase();

  const wantParts = pattern.toLowerCase().trim().split(mediaParamSeparator);
  const [wantType = "", ...wantParams] = wantParts;

  if (wantType.split("/").length !== 2) {
    return false;
  }

  const gotParts = contentType.split(mediaParamSeparator);
  const [gotType = "", ...gotParams] = gotParts;

  const [type = "", subtype = ""] = gotType.split("/");
  if (!type || !subtype) {
    return false;
  }

  if (
    wantType !== "*/*" &&
    gotType !== wantType &&
    `${type}/*` !== wantType &&
    `*/${subtype}` !== wantType
  ) {
    return false;
  }

  if (gotParams.length < wantParams.length) {
    return false;
  }

  const params = new Set(gotParams);
  for (const wantParam of wantParams) {
    if (!params.has(wantParam)) {
      return false;
    }
  }

  return true;
}

const codeRangeRE = new RegExp("^[0-9]xx$", "i");

export function matchStatusCode(
  response: Response,
  codes: StatusCodePredicate,
): boolean {
  const actual = `${response.status}`;
  const expectedCodes = Array.isArray(codes) ? codes : [codes];
  if (!expectedCodes.length) {
    return false;
  }

  return expectedCodes.some((ec) => {
    const code = `${ec}`;

    if (code === "default") {
      return true;
    }

    if (!codeRangeRE.test(`${code}`)) {
      return code === actual;
    }

    const expectFamily = code.charAt(0);
    if (!expectFamily) {
      throw new Error("Invalid status code range");
    }

    const actualFamily = actual.charAt(0);
    if (!actualFamily) {
      throw new Error(`Invalid response status code: ${actual}`);
    }

    return actualFamily === expectFamily;
  });
}

export function matchResponse(
  response: Response,
  code: StatusCodePredicate,
  contentTypePattern: string,
): boolean {
  return (
    matchStatusCode(response, code) &&
    matchContentType(response, contentTypePattern)
  );
}

/**
 * Uses various heurisitics to determine if an error is a connection error.
 */
export function isConnectionError(err: unknown): boolean {
  if (typeof err !== "object" || err == null) {
    return false;
  }

  // Covers fetch in Deno as well
  const isBrowserErr =
    err instanceof TypeError &&
    err.message.toLowerCase().startsWith("failed to fetch");

  const isNodeErr =
    err instanceof TypeError &&
    err.message.toLowerCase().startsWith("fetch failed");

  const isBunErr = "name" in err && err.name === "ConnectionError";

  const isGenericErr =
    "code" in err &&
    typeof err.code === "string" &&
    err.code.toLowerCase() === "econnreset";

  return isBrowserErr || isNodeErr || isGenericErr || isBunErr;
}

/**
 * Uses various heurisitics to determine if an error is a timeout error.
 */
export function isTimeoutError(err: unknown): boolean {
  if (typeof err !== "object" || err == null) {
    return false;
  }

  // Fetch in browser, Node.js, Bun, Deno
  const isNative = "name" in err && err.name === "TimeoutError";
  const isLegacyNative = "code" in err && err.code === 23;

  // Node.js HTTP client and Axios
  const isGenericErr =
    "code" in err &&
    typeof err.code === "string" &&
    err.code.toLowerCase() === "econnaborted";

  return isNative || isLegacyNative || isGenericErr;
}

/**
 * Uses various heurisitics to determine if an error is a abort error.
 */
export function isAbortError(err: unknown): boolean {
  if (typeof err !== "object" || err == null) {
    return false;
  }

  // Fetch in browser, Node.js, Bun, Deno
  const isNative = "name" in err && err.name === "AbortError";
  const isLegacyNative = "code" in err && err.code === 20;

  // Node.js HTTP client and Axios
  const isGenericErr =
    "code" in err &&
    typeof err.code === "string" &&
    err.code.toLowerCase() === "econnaborted";

  return isNative || isLegacyNative || isGenericErr;
}
