import { HttpHeaders, HttpHeadersSchema, HttpSchema, HttpSchemaMethod, HttpSchemaPath } from '@zimic/http';

import { FetchRequest, FetchRequestObject, FetchResponse, FetchResponseObject } from '../types/requests';

/**
 * Options for converting a {@link FetchResponseError `FetchResponseError`} into a plain object.
 *
 * @see {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#fetchresponseerrortoobject `FetchResponseError#toObject` API reference}
 */
export interface FetchResponseErrorObjectOptions {
  /**
   * Whether to include the body of the request in the plain object.
   *
   * @default false
   */
  includeRequestBody?: boolean;

  /**
   * Whether to include the body of the response in the plain object.
   *
   * @default false
   */
  includeResponseBody?: boolean;
}

export namespace FetchResponseErrorObjectOptions {
  /**
   * Options for converting a {@link FetchResponseError `FetchResponseError`} into a plain object, including the body of
   * the request and/or response.
   */
  export type WithBody = FetchResponseErrorObjectOptions &
    ({ includeRequestBody: true } | { includeResponseBody: true });

  /**
   * Options for converting a {@link FetchResponseError `FetchResponseError`} into a plain object, excluding the body of
   * the request and/or response.
   */
  export type WithoutBody = FetchResponseErrorObjectOptions &
    ({ includeRequestBody?: false } | { includeResponseBody?: false });
}

/**
 * A plain object representation of a {@link FetchResponseError `FetchResponseError`}, compatible with JSON. It is useful
 * for serialization, debugging, and logging purposes.
 *
 * @see {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#fetchresponseerrortoobject `FetchResponseError#toObject` API reference}
 */
export interface FetchResponseErrorObject {
  name: string;
  message: string;
  request: FetchRequestObject;
  response: FetchResponseObject;
}

/**
 * An error representing a response with a failure status code (4XX or 5XX).
 *
 * @example
 *   import { type HttpSchema } from '@zimic/http';
 *   import { createFetch } from '@zimic/fetch';
 *
 *   interface User {
 *     id: string;
 *     username: string;
 *   }
 *
 *   type Schema = HttpSchema<{
 *     '/users/:userId': {
 *       GET: {
 *         response: {
 *           200: { body: User };
 *           404: { body: { message: string } };
 *         };
 *       };
 *     };
 *   }>;
 *
 *   const fetch = createFetch<Schema>({
 *     baseURL: 'http://localhost:3000',
 *   });
 *
 *   const response = await fetch(`/users/${userId}`, {
 *     method: 'GET',
 *   });
 *
 *   if (!response.ok) {
 *     console.log(response.status); // 404
 *
 *     console.log(response.error); // FetchResponseError<Schema, 'GET', '/users'>
 *     console.log(response.error.request); // FetchRequest<Schema, 'GET', '/users'>
 *     console.log(response.error.response); // FetchResponse<Schema, 'GET', '/users'>
 *
 *     const plainError = response.error.toObject();
 *     console.log(JSON.stringify(plainError));
 *     // {"name":"FetchResponseError","message":"...","request":{...},"response":{...}}
 *   }
 *
 * @see {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#fetchresponseerror `FetchResponseError` API reference}
 */
class FetchResponseError<
  Schema extends HttpSchema,
  Method extends HttpSchemaMethod<Schema>,
  Path extends HttpSchemaPath.Literal<Schema, Method>,
> extends Error {
  constructor(
    public request: FetchRequest<Schema, Method, Path>,
    public response: FetchResponse<Schema, Method, Path, true, 'manual'>,
  ) {
    super(`${request.method} ${request.url} failed with status ${response.status}: ${response.statusText}`);
    this.name = 'FetchResponseError';
  }

  /**
   * Converts this error into a plain object. This method is useful for serialization, debugging, and logging purposes.
   *
   * @example
   *   const fetch = createFetch<Schema>({
   *     baseURL: 'http://localhost:3000',
   *   });
   *
   *   const response = await fetch(`/users/${userId}`, {
   *     method: 'GET',
   *   });
   *
   *   if (!response.ok) {
   *     const plainError = response.error.toObject();
   *     console.log(JSON.stringify(plainError));
   *     // {"name":"FetchResponseError","message":"...","request":{...},"response":{...}}
   *   }
   *
   * @param options Options for converting this error into a plain object. By default, the body of the request and
   *   response will not be included.
   * @returns A plain object representing this error. If `options.includeRequestBody` or `options.includeResponseBody`
   *   is `true`, the body of the request and response will be included, respectively, and the return is a `Promise`.
   *   Otherwise, the return is the plain object itself without the bodies.
   * @see {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#fetchresponseerrortoobject `FetchResponseError#toObject` API reference}
   */
  toObject(options: FetchResponseErrorObjectOptions.WithBody): Promise<FetchResponseErrorObject>;
  toObject(options: FetchResponseErrorObjectOptions.WithoutBody): FetchResponseErrorObject;
  toObject(options?: FetchResponseErrorObjectOptions): Promise<FetchResponseErrorObject> | FetchResponseErrorObject;
  toObject(options?: FetchResponseErrorObjectOptions): Promise<FetchResponseErrorObject> | FetchResponseErrorObject {
    const includeRequestBody = options?.includeRequestBody ?? false;
    const includeResponseBody = options?.includeResponseBody ?? false;

    const partialObject = {
      name: this.name,
      message: this.message,
    } satisfies Partial<FetchResponseErrorObject>;

    if (!includeRequestBody && !includeResponseBody) {
      const request = this.convertRequestToObject({ includeBody: false });
      const response = this.convertResponseToObject({ includeBody: false });
      return { ...partialObject, request, response };
    }

    return Promise.all([
      this.convertRequestToObject({ includeBody: includeRequestBody }),
      this.convertResponseToObject({ includeBody: includeResponseBody }),
    ]).then(([request, response]) => ({ ...partialObject, request, response }));
  }

  private convertRequestToObject(options: { includeBody: true }): Promise<FetchRequestObject>;
  private convertRequestToObject(options: { includeBody: false }): FetchRequestObject;
  private convertRequestToObject(options: { includeBody: boolean }): Promise<FetchRequestObject> | FetchRequestObject;
  private convertRequestToObject(options: { includeBody: boolean }): Promise<FetchRequestObject> | FetchRequestObject {
    const requestObject: FetchRequestObject = {
      url: this.request.url,
      path: this.request.path,
      method: this.request.method,
      headers: HttpHeaders.prototype.toObject.call(this.request.headers) as HttpHeadersSchema,
      cache: this.request.cache,
      destination: this.request.destination,
      credentials: this.request.credentials,
      integrity: this.request.integrity,
      keepalive: this.request.keepalive,
      mode: this.request.mode,
      redirect: this.request.redirect,
      referrer: this.request.referrer,
      referrerPolicy: this.request.referrerPolicy,
    };

    if (!options.includeBody) {
      return requestObject;
    }

    // Optimize type checking by narrowing the type of the body
    const bodyAsTextPromise = this.request.text() as Promise<string>;

    return bodyAsTextPromise.then((bodyAsText) => {
      requestObject.body = bodyAsText.length > 0 ? bodyAsText : null;
      return requestObject;
    });
  }

  private convertResponseToObject(options: { includeBody: true }): Promise<FetchResponseObject>;
  private convertResponseToObject(options: { includeBody: false }): FetchResponseObject;
  private convertResponseToObject(options: {
    includeBody: boolean;
  }): Promise<FetchResponseObject> | FetchResponseObject;
  private convertResponseToObject(options: {
    includeBody: boolean;
  }): Promise<FetchResponseObject> | FetchResponseObject {
    const responseObject: FetchResponseObject = {
      url: this.response.url,
      type: this.response.type,
      status: this.response.status,
      statusText: this.response.statusText,
      ok: this.response.ok,
      headers: HttpHeaders.prototype.toObject.call(this.response.headers) as HttpHeadersSchema,
      redirected: this.response.redirected,
    };

    if (!options.includeBody) {
      return responseObject;
    }

    // Optimize type checking by narrowing the type of the body
    const bodyAsTextPromise = this.response.text() as Promise<string>;

    return bodyAsTextPromise.then((bodyAsText) => {
      responseObject.body = bodyAsText.length > 0 ? bodyAsText : null;
      return responseObject;
    });
  }
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyFetchRequestError = FetchResponseError<any, any, any>;

export default FetchResponseError;
