/* eslint-disable max-lines */
import type {
  RequestSendOptionsType,
  ParamsType,
  RequestSendType,
  PayloadType,
  RequestJSON,
  RequestOptionsType,
  RequestConfigurationType,
  PayloadMapperType,
  RequestInstance,
  RequestMapper,
  ResponseMapper,
  ExtractUrlParams,
  RetryOnErrorCallbackType,
  OptimisticCallback,
} from "./request.types";
import type { RequestHooks } from "./request.hooks";
import { createRequestHooks } from "./request.hooks";
import { mapResponseForSend, sendRequest, scopeKey } from "./request.utils";
import type { ClientInstance } from "client";
import type { ResponseErrorType, ResponseSuccessType, ResponseType } from "adapter";
import type {
  ExtractAdapterType,
  ExtractClientAdapterType,
  ExtractClientGlobalError,
  ExtractEndpointType,
  ExtractParamsType,
  ExtractPayloadType,
  ExtractQueryParamsType,
  EmptyTypes,
  ExtractAdapterMethodType,
  ExtractAdapterOptionsType,
  HydrateDataType,
  SyncOrAsync,
} from "types";
import { Time } from "constants/time.constants";
import type { MockerConfigType, MockResponseType } from "mocker";

type ClientAdapterOptions<C extends ClientInstance> = ExtractAdapterOptionsType<ExtractClientAdapterType<C>>;
type ClientAdapterMethod<C extends ClientInstance> = ExtractAdapterMethodType<ExtractClientAdapterType<C>>;
type ClientRequestOptions<E, C extends ClientInstance> = RequestOptionsType<
  E,
  ClientAdapterOptions<C>,
  ClientAdapterMethod<C>
>;

/**
 * Request is a class that represents a request sent to the server. It contains all the necessary information to make a request, like endpoint, method, headers, data, and much more.
 * It is executed at any time via methods like `send` or `exec`.
 *
 * We can set it up with options like endpoint, method, headers and more.
 * We can choose some of advanced settings like cache, invalidation patterns, concurrency, retries and much, much more.
 *
 * @info We should not use this class directly in the standard development flow.
 * We can initialize it using the `createRequest` method on the **Client** class.
 *
 * @attention The most important thing about the request is that it keeps data in the format that can be dumped.
 * This is necessary for the persistence and different dispatcher storage types.
 * This class doesn't have any callback methods by design and communicate with dispatcher and cache by events.
 *
 * It should be serializable to JSON and deserializable back to the class.
 * Serialization should not affect the result of the request, so it's methods and functional part should be only syntax sugar for given runtime.
 */

export class Request<
  Response,
  Payload,
  QueryParams,
  LocalError,
  Endpoint extends string,
  Client extends ClientInstance,
  HasPayload extends true | false = false,
  HasParams extends true | false = false,
  HasQuery extends true | false = false,
  MutationContext = undefined,
> {
  endpoint: Endpoint;
  headers?: HeadersInit;
  auth: boolean;
  method: ClientAdapterMethod<Client>;
  params: ExtractUrlParams<Endpoint> | EmptyTypes;
  payload: PayloadType<Payload>;
  queryParams: QueryParams | EmptyTypes;
  options?: ClientAdapterOptions<Client> | undefined;
  cancelable: boolean;
  retry: number;
  retryTime: number;
  cacheTime: number;
  cache: boolean;
  staleTime: number;
  queued: boolean;
  offline: boolean;
  abortKey: string;
  cacheKey: string;
  queryKey: string;
  used: boolean;
  deduplicate: boolean;
  deduplicateTime: number | null;
  scope: string | null;

  /**
   * Instance-level lifecycle hooks. These callbacks fire for every `send()` / `exec()` call
   * made on this request instance (and its clones), without needing to pass them to `send()` each time.
   * Useful for cross-cutting concerns like logging, analytics, or toast notifications.
   *
   * Each method registers a callback and returns an unsubscribe function.
   * Multiple listeners per hook are supported.
   */
  $hooks: RequestHooks<RequestInstance> = createRequestHooks();

  isMockerEnabled = false;

  unstable_mock?: {
    fn: (options: {
      request: RequestInstance;
      requestId: string;
    }) => MockResponseType<Response, LocalError | ExtractClientGlobalError<Client>, ExtractClientAdapterType<Client>>;
    config: MockerConfigType;
  };
  /** @internal */
  unstable_payloadMapper?: PayloadMapperType<Payload>;
  /** @internal */
  unstable_requestMapper?: RequestMapper<any, any>;
  /** @internal */
  unstable_responseMapper?: ResponseMapper<this, ResponseSuccessType<any, any> | ResponseErrorType<any, any>>;
  /** @internal */
  retryOnError?: RetryOnErrorCallbackType<RequestInstance>;
  /** @internal */
  optimistic?: OptimisticCallback<RequestInstance, any>;

  unstable_hasParams: HasParams = false as HasParams;
  unstable_hasPayload: HasPayload = false as HasPayload;
  unstable_hasQuery: HasQuery = false as HasQuery;
  unstable_hasMutationContext: MutationContext = undefined as MutationContext;

  private updatedAbortKey: boolean;
  private updatedCacheKey: boolean;
  private updatedQueryKey: boolean;

  constructor(
    readonly client: Client,
    readonly requestOptions: ClientRequestOptions<Endpoint, Client>,
    readonly initialRequestConfiguration?:
      | RequestConfigurationType<
          Payload,
          Endpoint extends string ? ExtractUrlParams<Endpoint> : never,
          QueryParams,
          Endpoint,
          ClientAdapterOptions<Client>,
          ClientAdapterMethod<Client>
        >
      | undefined,
  ) {
    const configuration: ClientRequestOptions<Endpoint, Client> = {
      ...(this.client.adapter.unstable_getRequestDefaults?.(requestOptions) as ClientRequestOptions<Endpoint, Client>),
      ...requestOptions,
    };
    const {
      endpoint,
      headers,
      auth = true,
      method = client.adapter.defaultMethod,
      options,
      cancelable = false,
      retry = 0,
      retryTime = 500,
      cacheTime = Time.MIN * 5,
      cache = true,
      staleTime = Time.MIN * 5,
      queued = false,
      offline = true,
      abortKey,
      cacheKey,
      queryKey,
      deduplicate = false,
      deduplicateTime = null,
    } = configuration;
    this.endpoint = initialRequestConfiguration?.endpoint ?? endpoint;
    this.headers = initialRequestConfiguration?.headers ?? headers;
    this.auth = initialRequestConfiguration?.auth ?? auth;
    this.method = method as ExtractAdapterMethodType<ExtractClientAdapterType<Client>>;
    this.params = initialRequestConfiguration?.params;
    this.payload = initialRequestConfiguration?.payload;
    this.queryParams = initialRequestConfiguration?.queryParams;
    this.options = initialRequestConfiguration?.options ?? options;
    this.cancelable = initialRequestConfiguration?.cancelable ?? cancelable;
    this.retry = initialRequestConfiguration?.retry ?? retry;
    this.retryTime = initialRequestConfiguration?.retryTime ?? retryTime;
    this.cacheTime = initialRequestConfiguration?.cacheTime ?? cacheTime;
    this.cache = initialRequestConfiguration?.cache ?? cache;
    this.staleTime = initialRequestConfiguration?.staleTime ?? staleTime;
    this.queued = initialRequestConfiguration?.queued ?? queued;
    this.offline = initialRequestConfiguration?.offline ?? offline;
    this.abortKey = initialRequestConfiguration?.abortKey ?? abortKey ?? this.client.unstable_abortKeyMapper(this);
    this.cacheKey = initialRequestConfiguration?.cacheKey ?? cacheKey ?? this.client.unstable_cacheKeyMapper(this);
    this.queryKey = initialRequestConfiguration?.queryKey ?? queryKey ?? this.client.unstable_queryKeyMapper(this);
    this.used = initialRequestConfiguration?.used ?? false;
    this.deduplicate = initialRequestConfiguration?.deduplicate ?? deduplicate;
    this.deduplicateTime = initialRequestConfiguration?.deduplicateTime ?? deduplicateTime;
    this.scope = initialRequestConfiguration?.scope ?? null;
    this.updatedAbortKey = initialRequestConfiguration?.updatedAbortKey ?? false;
    this.updatedCacheKey = initialRequestConfiguration?.updatedCacheKey ?? false;
    this.updatedQueryKey = initialRequestConfiguration?.updatedQueryKey ?? false;
  }

  /** Set the request headers. Returns a cloned request with the new headers applied. */
  public setHeaders = (headers: HeadersInit) => {
    return this.clone({ headers });
  };

  /** Set whether authentication interceptors should run for this request. */
  public setAuth = (auth: boolean) => {
    return this.clone({ auth });
  };

  /** Set the URL path parameters (e.g., `:userId`). */
  public setParams = <P extends ExtractParamsType<this>>(params: P) => {
    return this.clone<HasPayload, P extends null ? false : true, HasQuery>({ params });
  };

  /** Set the request body payload. */
  public setPayload = <P extends Payload>(payload: P) => {
    return this.clone<P extends null ? false : true, HasParams, HasQuery>({
      payload,
    });
  };

  /** Set the query parameters appended to the URL. */
  public setQueryParams = (queryParams: QueryParams) => {
    return this.clone<HasPayload, HasParams, true>({ queryParams });
  };

  /** Set adapter-specific options (e.g., axios config, fetch init). */
  public setOptions = (options: ClientAdapterOptions<Client>) => {
    return this.clone<HasPayload, HasParams, true>({ options });
  };

  /**
   * Set a scope identifier for this request.
   * All keys (cache, queue, abort) are prefixed with this scope, isolating
   * the request from other scopes. In "server" client mode, setting a scope
   * also enables caching (which is otherwise disabled to prevent cross-request leaks).
   */
  public setScope = (scopeId: string) => {
    const cloned = this.clone<HasPayload, HasParams, HasQuery>();
    cloned.scope = scopeId;
    return cloned;
  };

  /** Set whether in-flight requests with the same abort key are cancelled when a new one is dispatched. */
  public setCancelable = (cancelable: boolean) => {
    return this.clone({ cancelable });
  };

  /** Set the number of retry attempts on failure. */
  public setRetry = (retry: ClientRequestOptions<Endpoint, Client>["retry"]) => {
    return this.clone({ retry });
  };

  /** Set the delay in milliseconds between retry attempts. */
  public setRetryTime = (retryTime: ClientRequestOptions<Endpoint, Client>["retryTime"]) => {
    return this.clone({ retryTime });
  };

  /**
   * Set a callback that controls whether a failed request should be retried.
   * Called on each failed attempt before scheduling the next retry.
   * Return `true` to allow the retry, `false` to stop retrying immediately.
   */
  public setRetryOnError = (
    callback: RetryOnErrorCallbackType<
      Request<Response, Payload, QueryParams, LocalError, Endpoint, Client, HasPayload, HasParams, HasQuery>
    >,
  ) => {
    const cloned = this.clone<HasPayload, HasParams, HasQuery>();
    cloned.retryOnError = callback as RetryOnErrorCallbackType<RequestInstance>;
    return cloned;
  };

  /**
   * Configure optimistic update behavior for this request.
   * The callback runs before the request is sent (in React's `useSubmit`) and receives
   * the request, client, and payload. Return `context` (available in submit callbacks),
   * `rollback` (called automatically on failure/abort), and `invalidate` (cache keys
   * invalidated on success).
   */
  public setOptimistic = <Ctx>(callback: OptimisticCallback<this, Ctx>) => {
    const cloned = this.clone<HasPayload, HasParams, HasQuery>();
    cloned.optimistic = callback as OptimisticCallback<RequestInstance, any>;
    return cloned as unknown as Request<
      Response,
      Payload,
      QueryParams,
      LocalError,
      Endpoint,
      Client,
      HasPayload,
      HasParams,
      HasQuery,
      Ctx
    >;
  };

  /** Set how long (in ms) the response remains in cache before being garbage collected. */
  public setCacheTime = (cacheTime: ClientRequestOptions<Endpoint, Client>["cacheTime"]) => {
    return this.clone({ cacheTime });
  };

  /** Set whether this request should use the cache. */
  public setCache = (cache: ClientRequestOptions<Endpoint, Client>["cache"]) => {
    return this.clone({ cache });
  };

  /** Set how long (in ms) cached data is considered fresh before triggering a revalidation. */
  public setStaleTime = (staleTime: ClientRequestOptions<Endpoint, Client>["staleTime"]) => {
    return this.clone({ staleTime });
  };

  /** Set whether the request should be queued and executed sequentially rather than immediately. */
  public setQueued = (queued: boolean) => {
    return this.clone({ queued });
  };

  /** Override the abort key used to cancel this request. */
  public setAbortKey = (abortKey: string) => {
    this.updatedAbortKey = true;
    return this.clone({ abortKey });
  };

  /** Override the cache key used to store/retrieve the response. */
  public setCacheKey = (cacheKey: string) => {
    this.updatedCacheKey = true;
    return this.clone({ cacheKey });
  };

  /** Override the query key that identifies this request in the dispatcher queue. */
  public setQueryKey = (queryKey: string) => {
    this.updatedQueryKey = true;
    return this.clone({ queryKey });
  };

  /** Enable or disable request deduplication. When enabled, concurrent identical requests share a single response. */
  public setDeduplicate = (deduplicate: boolean) => {
    return this.clone({ deduplicate });
  };

  /** Set the time window (in ms) during which identical requests are deduplicated. */
  public setDeduplicateTime = (deduplicateTime: number) => {
    return this.clone({ deduplicateTime });
  };

  /** Mark the request as used (prevents automatic refetching in hooks). */
  public setUsed = (used: boolean) => {
    return this.clone({ used });
  };

  /** Set whether the request should be stored in the offline queue when there is no network connection. */
  public setOffline = (offline: boolean) => {
    return this.clone({ offline });
  };

  /** Attach a mock handler to this request. When mocking is enabled, the handler is called instead of the real adapter. */
  public setMock = (
    fn: (options: {
      request: Request<Response, Payload, QueryParams, LocalError, Endpoint, Client, HasPayload, HasParams, HasQuery>;
      requestId: string;
    }) => SyncOrAsync<
      MockResponseType<Response, LocalError | ExtractClientGlobalError<Client>, ExtractClientAdapterType<Client>>
    >,
    config: MockerConfigType = {},
  ) => {
    this.unstable_mock = { fn, config } as typeof this.unstable_mock;
    this.isMockerEnabled = true;
    return this;
  };

  /** Remove any attached mock handler and disable mocking for this request. */
  public clearMock = () => {
    this.unstable_mock = undefined;
    this.isMockerEnabled = false;
    return this;
  };

  /** Enable or disable the mocker for this request without removing the mock handler. */
  public setMockingEnabled = (isMockerEnabled: boolean) => {
    this.isMockerEnabled = isMockerEnabled;
    return this;
  };

  /**
   * Map data before it gets send to the server
   * @param payloadMapper
   * @returns
   */
  public setPayloadMapper = <MappedPayload extends any | Promise<any>>(
    payloadMapper: (data: Payload) => MappedPayload,
  ) => {
    const cloned = this.clone<HasPayload, HasParams, HasQuery>(undefined);

    cloned.unstable_payloadMapper = payloadMapper as typeof this.unstable_payloadMapper;

    return cloned;
  };

  /**
   * Map request before it gets send to the server
   * @param requestMapper mapper of the request
   * @returns new request
   */
  public setRequestMapper = <NewRequest extends RequestInstance>(requestMapper: RequestMapper<this, NewRequest>) => {
    const cloned = this.clone<HasPayload, HasParams, HasQuery>(undefined);

    cloned.unstable_requestMapper = requestMapper;

    return cloned;
  };

  /**
   * Map the response to the new interface
   * @param responseMapper our mapping callback
   * @returns new response
   */
  public setResponseMapper = <MappedResponse extends ResponseSuccessType<any, any> | ResponseErrorType<any, any>>(
    responseMapper?: ResponseMapper<this, MappedResponse>,
  ) => {
    const cloned = this.clone<HasPayload, HasParams, HasQuery>();

    cloned.unstable_responseMapper = responseMapper as typeof cloned.unstable_responseMapper;

    return cloned as unknown as Request<
      MappedResponse extends ResponseType<infer R, any, any> ? R : Response,
      Payload,
      QueryParams,
      MappedResponse extends ResponseType<any, infer E, any> ? E : LocalError,
      Endpoint,
      Client,
      HasPayload,
      HasParams,
      HasQuery,
      MutationContext
    >;
  };

  private paramsMapper = (params: ParamsType | null | undefined): Endpoint => {
    const { endpoint } = this.requestOptions;

    let stringEndpoint = String(endpoint);
    if (params) {
      Object.entries(params).forEach(([key, value]) => {
        stringEndpoint = stringEndpoint.replace(new RegExp(`:${key}`, "g"), String(value));
      });
    }

    return stringEndpoint as Endpoint;
  };

  /** Serialize the request configuration into a plain JSON object for persistence or transfer. */
  public toJSON(): RequestJSON<this> {
    return {
      requestOptions: this.requestOptions as unknown as RequestOptionsType<
        ExtractEndpointType<this>,
        ExtractAdapterOptionsType<ExtractAdapterType<this>>,
        ExtractAdapterMethodType<ExtractAdapterType<this>>
      >,
      endpoint: this.endpoint as ExtractEndpointType<this>,
      headers: this.headers,
      auth: this.auth,
      // TODO: fix this type
      method: this.method as any,
      params: this.params as ExtractParamsType<this>,
      payload: this.payload as ExtractPayloadType<this>,
      queryParams: this.queryParams as ExtractQueryParamsType<this>,
      options: this.options,
      cancelable: this.cancelable,
      retry: this.retry,
      retryTime: this.retryTime,
      cacheTime: this.cacheTime,
      cache: this.cache,
      staleTime: this.staleTime,
      queued: this.queued,
      offline: this.offline,
      abortKey: this.abortKey,
      cacheKey: this.cacheKey,
      queryKey: this.queryKey,
      used: this.used,
      disableResponseInterceptors: this.requestOptions.disableResponseInterceptors,
      disableRequestInterceptors: this.requestOptions.disableRequestInterceptors,
      updatedAbortKey: this.updatedAbortKey,
      updatedCacheKey: this.updatedCacheKey,
      updatedQueryKey: this.updatedQueryKey,
      deduplicate: this.deduplicate,
      deduplicateTime: this.deduplicateTime,
      scope: this.scope,
      isMockerEnabled: this.isMockerEnabled,
      hasMock: !!this.unstable_mock,
    };
  }

  /** Create a new request instance with optional configuration overrides. Used internally by all setter methods. */
  public clone<
    NewData extends true | false = HasPayload,
    NewParams extends true | false = HasParams,
    NewQueryParams extends true | false = HasQuery,
  >(
    configuration?: RequestConfigurationType<
      Payload,
      (typeof this)["params"],
      QueryParams,
      Endpoint,
      ClientAdapterOptions<Client>,
      ClientAdapterMethod<Client>
    >,
  ) {
    const json = this.toJSON();
    const initialRequestConfiguration: RequestConfigurationType<
      Payload,
      Endpoint extends string ? ExtractUrlParams<Endpoint> : never,
      QueryParams,
      Endpoint,
      ClientAdapterOptions<Client>,
      ClientAdapterMethod<Client>
    > = {
      ...json,
      ...configuration,
      options: configuration?.options || this.options,
      abortKey: this.updatedAbortKey ? configuration?.abortKey || this.abortKey : undefined,
      cacheKey: this.updatedCacheKey ? configuration?.cacheKey || this.cacheKey : undefined,
      queryKey: this.updatedQueryKey ? configuration?.queryKey || this.queryKey : undefined,
      endpoint: this.paramsMapper(configuration?.params || this.params),
      queryParams: configuration?.queryParams || this.queryParams,
      payload: configuration?.payload || this.payload,
      params: (configuration?.params || this.params) as
        | EmptyTypes
        | (Endpoint extends string ? ExtractUrlParams<Endpoint> : never),
    };

    const cloned = new Request<
      Response,
      Payload,
      QueryParams,
      LocalError,
      Endpoint,
      Client,
      NewData,
      NewParams,
      NewQueryParams,
      MutationContext
    >(this.client, this.requestOptions, initialRequestConfiguration);

    // Inherit methods
    cloned.unstable_payloadMapper = this.unstable_payloadMapper;
    cloned.unstable_responseMapper = this.unstable_responseMapper as typeof cloned.unstable_responseMapper;
    cloned.unstable_requestMapper = this.unstable_requestMapper;
    cloned.retryOnError = this.retryOnError;

    cloned.unstable_mock = this.unstable_mock;
    cloned.isMockerEnabled = this.isMockerEnabled;
    cloned.optimistic = this.optimistic;
    cloned.$hooks = createRequestHooks(this.$hooks.__snapshot());

    return cloned;
  }

  /** Abort all in-flight requests sharing this request's abort key. */
  public abort = () => {
    const { requestManager } = this.client;
    requestManager.abortByKey(scopeKey(this.abortKey, this.scope));

    return this.clone();
  };

  /** Extract the current cache data for this request into a portable hydration payload (useful for SSR). */
  public dehydrate = (config?: {
    /** in case of using adapter without cache we can provide response to dehydrate */
    response?: ResponseType<Response, LocalError | ExtractClientGlobalError<Client>, ExtractClientAdapterType<Client>>;
    /** override cache data */
    override?: boolean;
  }):
    | HydrateDataType<Response, LocalError | ExtractClientGlobalError<Client>, ExtractClientAdapterType<Client>>
    | undefined => {
    const { response, override = true } = config || {};

    if (response) {
      return {
        override,
        cacheTime: this.cacheTime,
        staleTime: this.staleTime,
        cacheKey: this.cacheKey,
        scope: this.scope,
        timestamp: +new Date(),
        hydrated: true,
        cache: true,
        response,
      };
    }

    const cacheData = this.client.cache.get<Response, LocalError | ExtractClientGlobalError<Client>>(
      scopeKey(this.cacheKey, this.scope),
    );

    if (!cacheData) {
      return undefined;
    }

    return {
      override,
      cacheTime: this.cacheTime,
      staleTime: this.staleTime,
      cacheKey: this.cacheKey,
      scope: this.scope,
      timestamp: +new Date(),
      hydrated: true,
      cache: true,
      response: {
        data: cacheData.data,
        error: cacheData.error,
        status: cacheData.status,
        success: cacheData.success,
        extra: cacheData.extra,
        requestTimestamp: cacheData.requestTimestamp,
        responseTimestamp: cacheData.responseTimestamp,
      },
    };
  };

  /**
   * Read the response from cache data
   *
   * If it returns error and data at the same time, it means that latest request was failed
   * and we show previous data from cache together with error received from actual request
   */
  public read():
    | ResponseType<Response, LocalError | ExtractClientGlobalError<Client>, ExtractClientAdapterType<Client>>
    | undefined {
    const cacheData = this.client.cache.get<Response, LocalError | ExtractClientGlobalError<Client>>(
      scopeKey(this.cacheKey, this.scope),
    );

    if (cacheData) {
      return {
        data: cacheData.data,
        error: cacheData.error,
        status: cacheData.status,
        success: cacheData.success,
        extra: cacheData.extra,
        requestTimestamp: cacheData.requestTimestamp,
        responseTimestamp: cacheData.responseTimestamp,
      };
    }
    return undefined;
  }

  /**
   * Method to use the request WITHOUT adding it to cache and queues. This mean it will make simple request without queue side effects.
   * @param options
   * @disableReturns
   * @returns
   * ```tsx
   * Promise<[Data | null, Error | null, HttpStatus]>
   * ```
   */
  public exec: RequestSendType<this> = async (options?: RequestSendOptionsType<this>) => {
    const { adapter, requestManager } = this.client;
    const request = this.clone(options);

    const requestId = this.client.unstable_requestIdMapper(this);

    const scopedAbortKey = scopeKey(this.abortKey, this.scope);

    // Listen for aborting
    requestManager.addAbortController(scopedAbortKey, requestId);

    const response = await adapter.fetch(request, requestId);

    // Stop listening for aborting
    requestManager.removeAbortController(scopedAbortKey, requestId);

    if (request.unstable_responseMapper) {
      return request.unstable_responseMapper(response);
    }

    return response;
  };

  /**
   * Method used to perform requests with usage of cache and queues
   * @param options
   * @param requestCallback
   * @disableReturns
   * @returns
   * ```tsx
   * Promise<{ data: Data | null, error: Error | null, status: HttpStatus, ... }>
   * ```
   */
  public send: RequestSendType<this> = async (options?: RequestSendOptionsType<this>) => {
    const { dispatcherType, cachePolicy = "network-only", ...configuration } = options || {};

    const request = this.clone(configuration) as unknown as this;

    const sendRequestOptions = options === undefined ? undefined : { ...options, cachePolicy: undefined };

    if (cachePolicy === "network-only") {
      return sendRequest(request, sendRequestOptions);
    }

    const cached = request.read();

    if (cachePolicy === "cache-first") {
      if (cached) {
        return mapResponseForSend(request, cached);
      }
      return sendRequest(request, sendRequestOptions);
    }

    // revalidate
    if (cached) {
      const resolved = await mapResponseForSend(request, cached);
      // Background revalidation; promise from send() already resolved with cache snapshot
      // eslint-disable-next-line @typescript-eslint/no-floating-promises
      sendRequest(request, sendRequestOptions);
      return resolved;
    }

    return sendRequest(request, sendRequestOptions);
  };

  /** Reconstruct a Request instance from a previously serialized JSON representation. */
  static fromJSON = <
    NewResponse,
    NewPayload,
    NewQueryParams,
    NewLocalError,
    NewEndpoint extends string,
    NewClient extends ClientInstance,
    NewHasPayload extends true | false = false,
    NewHasParams extends true | false = false,
    NewHasQuery extends true | false = false,
  >(
    client: NewClient,
    json: RequestJSON<
      Request<
        NewResponse,
        NewPayload,
        NewQueryParams,
        NewLocalError,
        NewEndpoint,
        NewClient,
        NewHasPayload,
        NewHasParams,
        NewHasQuery
      >
    >,
  ) => {
    return new Request<
      NewResponse,
      NewPayload,
      NewQueryParams,
      NewLocalError,
      NewEndpoint,
      NewClient,
      NewHasPayload,
      NewHasParams,
      NewHasQuery
    >(client, json.requestOptions, json);
  };
}
