import { Config, EnqueuedTaskObject } from './types';
import { PACKAGE_VERSION } from './package-version';

import {
  MeiliSearchError,
  httpResponseErrorHandler,
  httpErrorHandler,
} from './errors';

import { addTrailingSlash, addProtocolIfNotPresent } from './utils';

type queryParams<T> = { [key in keyof T]: string };

function toQueryParams<T extends object>(parameters: T): queryParams<T> {
  const params = Object.keys(parameters) as Array<keyof T>;

  const queryParams = params.reduce<queryParams<T>>((acc, key) => {
    const value = parameters[key];
    if (value === undefined) {
      return acc;
    } else if (Array.isArray(value)) {
      return { ...acc, [key]: value.join(',') };
    } else if (value instanceof Date) {
      return { ...acc, [key]: value.toISOString() };
    }
    return { ...acc, [key]: value };
  }, {} as queryParams<T>);
  return queryParams;
}

function constructHostURL(host: string): string {
  try {
    host = addProtocolIfNotPresent(host);
    host = addTrailingSlash(host);
    return host;
  } catch (e) {
    throw new MeiliSearchError('The provided host is not valid.');
  }
}

function cloneAndParseHeaders(headers: HeadersInit): Record<string, string> {
  if (Array.isArray(headers)) {
    return headers.reduce(
      (acc, headerPair) => {
        acc[headerPair[0]] = headerPair[1];
        return acc;
      },
      {} as Record<string, string>,
    );
  } else if ('has' in headers) {
    const clonedHeaders: Record<string, string> = {};
    (headers as Headers).forEach((value, key) => (clonedHeaders[key] = value));
    return clonedHeaders;
  } else {
    return Object.assign({}, headers);
  }
}

function createHeaders(config: Config): Record<string, any> {
  const agentHeader = 'X-Meilisearch-Client';
  const packageAgent = `Meilisearch JavaScript (v${PACKAGE_VERSION})`;
  const contentType = 'Content-Type';
  const authorization = 'Authorization';
  const headers = cloneAndParseHeaders(config.requestConfig?.headers ?? {});

  // do not override if user provided the header
  if (config.apiKey && !headers[authorization]) {
    headers[authorization] = `Bearer ${config.apiKey}`;
  }

  if (!headers[contentType]) {
    headers['Content-Type'] = 'application/json';
  }

  // Creates the custom user agent with information on the package used.
  if (config.clientAgents && Array.isArray(config.clientAgents)) {
    const clients = config.clientAgents.concat(packageAgent);

    headers[agentHeader] = clients.join(' ; ');
  } else if (config.clientAgents && !Array.isArray(config.clientAgents)) {
    // If the header is defined but not an array
    throw new MeiliSearchError(
      `Meilisearch: The header "${agentHeader}" should be an array of string(s).\n`,
    );
  } else {
    headers[agentHeader] = packageAgent;
  }

  return headers;
}

class HttpRequests {
  headers: Record<string, any>;
  url: URL;
  requestConfig?: Config['requestConfig'];
  httpClient?: Required<Config>['httpClient'];
  requestTimeout?: number;

  constructor(config: Config) {
    this.headers = createHeaders(config);
    this.requestConfig = config.requestConfig;
    this.httpClient = config.httpClient;
    this.requestTimeout = config.timeout;

    try {
      const host = constructHostURL(config.host);
      this.url = new URL(host);
    } catch (e) {
      throw new MeiliSearchError('The provided host is not valid.');
    }
  }

  async request({
    method,
    url,
    params,
    body,
    config = {},
  }: {
    method: string;
    url: string;
    params?: { [key: string]: any };
    body?: any;
    config?: Record<string, any>;
  }) {
    if (typeof fetch === 'undefined') {
      require('cross-fetch/polyfill');
    }

    const constructURL = new URL(url, this.url);
    if (params) {
      const queryParams = new URLSearchParams();
      Object.keys(params)
        .filter((x: string) => params[x] !== null)
        .map((x: string) => queryParams.set(x, params[x]));
      constructURL.search = queryParams.toString();
    }

    // in case a custom content-type is provided
    // do not stringify body
    if (!config.headers?.['Content-Type']) {
      body = JSON.stringify(body);
    }

    const headers = { ...this.headers, ...config.headers };

    try {
      const result = this.fetchWithTimeout(
        constructURL.toString(),
        {
          ...config,
          ...this.requestConfig,
          method,
          body,
          headers,
        },
        this.requestTimeout,
      );

      // When using a custom HTTP client, the response is returned to allow the user to parse/handle it as they see fit
      if (this.httpClient) {
        return await result;
      }

      const response = await result.then((res: any) =>
        httpResponseErrorHandler(res),
      );
      const parsedBody = await response.json().catch(() => undefined);

      return parsedBody;
    } catch (e: any) {
      const stack = e.stack;
      httpErrorHandler(e, stack, constructURL.toString());
    }
  }

  async fetchWithTimeout(
    url: string,
    options: Record<string, any> | RequestInit | undefined,
    timeout: HttpRequests['requestTimeout'],
  ): Promise<Response> {
    return new Promise((resolve, reject) => {
      const fetchFn = this.httpClient ? this.httpClient : fetch;

      const fetchPromise = fetchFn(url, options);

      const promises: Array<Promise<any>> = [fetchPromise];

      // TimeoutPromise will not run if undefined or zero
      let timeoutId: ReturnType<typeof setTimeout>;
      if (timeout) {
        const timeoutPromise = new Promise((_, reject) => {
          timeoutId = setTimeout(() => {
            reject(new Error('Error: Request Timed Out'));
          }, timeout);
        });

        promises.push(timeoutPromise);
      }

      Promise.race(promises)
        .then(resolve)
        .catch(reject)
        .finally(() => {
          clearTimeout(timeoutId);
        });
    });
  }

  async get(
    url: string,
    params?: { [key: string]: any },
    config?: Record<string, any>,
  ): Promise<void>;

  async get<T = any>(
    url: string,
    params?: { [key: string]: any },
    config?: Record<string, any>,
  ): Promise<T>;

  async get(
    url: string,
    params?: { [key: string]: any },
    config?: Record<string, any>,
  ): Promise<any> {
    return await this.request({
      method: 'GET',
      url,
      params,
      config,
    });
  }

  async post<T = any, R = EnqueuedTaskObject>(
    url: string,
    data?: T,
    params?: { [key: string]: any },
    config?: Record<string, any>,
  ): Promise<R>;

  async post(
    url: string,
    data?: any,
    params?: { [key: string]: any },
    config?: Record<string, any>,
  ): Promise<any> {
    return await this.request({
      method: 'POST',
      url,
      body: data,
      params,
      config,
    });
  }

  async put<T = any, R = EnqueuedTaskObject>(
    url: string,
    data?: T,
    params?: { [key: string]: any },
    config?: Record<string, any>,
  ): Promise<R>;

  async put(
    url: string,
    data?: any,
    params?: { [key: string]: any },
    config?: Record<string, any>,
  ): Promise<any> {
    return await this.request({
      method: 'PUT',
      url,
      body: data,
      params,
      config,
    });
  }

  async patch(
    url: string,
    data?: any,
    params?: { [key: string]: any },
    config?: Record<string, any>,
  ): Promise<any> {
    return await this.request({
      method: 'PATCH',
      url,
      body: data,
      params,
      config,
    });
  }

  async delete(
    url: string,
    data?: any,
    params?: { [key: string]: any },
    config?: Record<string, any>,
  ): Promise<EnqueuedTaskObject>;
  async delete<T>(
    url: string,
    data?: any,
    params?: { [key: string]: any },
    config?: Record<string, any>,
  ): Promise<T>;
  async delete(
    url: string,
    data?: any,
    params?: { [key: string]: any },
    config?: Record<string, any>,
  ): Promise<any> {
    return await this.request({
      method: 'DELETE',
      url,
      body: data,
      params,
      config,
    });
  }
}

export { HttpRequests, toQueryParams };
