/*
 * Copyright 2015 CANAL+ Group
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import log from "../../log";
import globalScope from "../global_scope";
import isNullOrUndefined from "../is_null_or_undefined";
import getMonotonicTimeStamp from "../monotonic_timestamp";
import type { CancellationError, CancellationSignal } from "../task_canceller";
import RequestError, { RequestErrorTypes } from "./request_error";

/** Object returned by `fetchRequest` after the fetch operation succeeded. */
export interface IFetchedStreamComplete {
  /** Duration of the whole request, in milliseconds. */
  requestDuration: number;
  /** Monotonically-raising timestamp at the time the request was received. */
  receivedTime: number;
  /** Monotonically-raising timestamp at the time the request was started. */
  sendingTime: number;
  /** Size of the entire emitted data, in bytes. */
  size: number;
  /** HTTP status of the request performed. */
  status: number;
  /** URL of the recuperated data (post-redirection if one). */
  url: string;
}

/** Object emitted by `fetchRequest` when a new chunk of the data is available. */
export interface IFetchedDataObject {
  /** Monotonically-raising timestamp at the time this data was recuperated. */
  currentTime: number;
  /** Duration of the request until `currentTime`, in milliseconds. */
  duration: number;
  /** Size in bytes of the data emitted as `chunk`. */
  chunkSize: number;
  /** Cumulated size of the received data by the request until now. */
  size: number;
  /** Monotonically-raising timestamp at the time the request began. */
  sendingTime: number;
  /** URL of the recuperated data (post-redirection if one). */
  url: string;
  /**
   * Value of the "Content-Length" header, which should (yet also might not be)
   * the size of the complete data that will be fetched.
   */
  totalSize: number | undefined;
  /**
   * Current available chunk, which might only be a sub-part of the whole
   * data.
   * To retrieve the whole data, all `chunk` received from `fetchRequest` can be
   * concatenated.
   */
  chunk: ArrayBuffer;
}

/** Options for the `fetchRequest` utils function. */
export interface IFetchOptions {
  /** URL you want to perform the HTTP GET request on. */
  url: string;
  /**
   * Callback called as new data is available.
   * This callback might be called multiple times with chunks of the complete
   * data until the fetch operation is finished.
   */
  onData: (data: IFetchedDataObject) => void;
  /**
   * Signal allowing to cancel the fetch operation.
   * If cancellation happens while the request is pending, `fetchRequest` will
   * reject with the corresponding `CancellationError`.
   */
  cancelSignal: CancellationSignal;
  /** Dictionary of headers you want to set. `null` or `undefined` for no header. */
  headers?: Record<string, string> | null | undefined;
  /**
   * Optional timeout for the HTTP GET request perfomed by `fetchRequest`.
   * This timeout is just enabled until the HTTP response from the server, even
   * if not all data has been received yet.
   */
  timeout?: number | undefined;
  /**
   * Optional connection timeout, in milliseconds, after which the request is canceled
   * if the responses headers has not being received.
   * Do not set or set to "undefined" to disable it.
   */
  connectionTimeout?: number | undefined;
}

type IHeadersConstructor = new () => Headers;
type IAbortControllerConstructor = new () => AbortController;

const _Headers: IHeadersConstructor | null =
  typeof Headers === "function" ? Headers : null;
const _AbortController: IAbortControllerConstructor | null =
  typeof AbortController === "function" ? AbortController : null;

export default function fetchRequest(
  options: IFetchOptions,
): Promise<IFetchedStreamComplete> {
  let headers: Headers | Record<string, string> | undefined;
  if (!isNullOrUndefined(options.headers)) {
    if (isNullOrUndefined(_Headers)) {
      headers = options.headers;
    } else {
      headers = new _Headers();
      const headerNames = Object.keys(options.headers);
      for (let i = 0; i < headerNames.length; i++) {
        const headerName = headerNames[i];
        headers.append(headerName, options.headers[headerName]);
      }
    }
  }

  log.debug("utils", "Fetch", { url: options.url });
  let cancellation: CancellationError | null = null;
  let isTimedOut = false;
  let isConnectionTimedOut = false;
  const sendingTime = getMonotonicTimeStamp();
  const abortController: AbortController | null = !isNullOrUndefined(_AbortController)
    ? new _AbortController()
    : null;

  /**
   * Abort current fetchRequest by triggering AbortController signal.
   * @returns {void}
   */
  function abortFetch(): void {
    if (isNullOrUndefined(abortController)) {
      log.warn("utils", "Fetch: AbortController API not available.");
      return;
    }
    abortController.abort();
  }

  let timeoutId: ReturnType<typeof setTimeout> | undefined;
  if (options.timeout !== undefined) {
    timeoutId = setTimeout(() => {
      isTimedOut = true;
      if (connectionTimeoutId !== undefined) {
        clearTimeout(connectionTimeoutId);
      }
      abortFetch();
    }, options.timeout);
  }

  let connectionTimeoutId: ReturnType<typeof setTimeout> | undefined;
  if (options.connectionTimeout !== undefined) {
    connectionTimeoutId = setTimeout(() => {
      isConnectionTimedOut = true;
      if (timeoutId !== undefined) {
        clearTimeout(timeoutId);
      }
      abortFetch();
    }, options.connectionTimeout);
  }

  const deregisterCancelLstnr = options.cancelSignal.register(function abortRequest(
    err: CancellationError,
  ) {
    cancellation = err;
    abortFetch();
  });

  const fetchOpts: RequestInit = { method: "GET" };
  if (headers !== undefined) {
    fetchOpts.headers = headers;
  }
  fetchOpts.signal = !isNullOrUndefined(abortController) ? abortController.signal : null;

  if (log.hasLevel("DEBUG")) {
    let logLine = "fetch GET " + options.url;
    if (options.timeout !== undefined) {
      logLine += " to=" + String(options.timeout / 1000);
    }
    if (options.connectionTimeout !== undefined) {
      logLine += " cto=" + String(options.connectionTimeout / 1000);
    }
    if (options.headers?.Range !== undefined) {
      logLine += " Range=" + options.headers?.Range;
    }
    log.debug("utils", logLine);
  }
  return fetch(options.url, fetchOpts)
    .then((response: Response): Promise<IFetchedStreamComplete> => {
      if (connectionTimeoutId !== undefined) {
        clearTimeout(connectionTimeoutId);
      }
      if (response.status >= 300) {
        log.warn("utils", "Fetch: Request HTTP Error", {
          status: response.status,
          responseUrl: response.url,
        });
        throw new RequestError(
          response.url,
          response.status,
          RequestErrorTypes.ERROR_HTTP_CODE,
        );
      }

      if (isNullOrUndefined(response.body)) {
        throw new RequestError(
          response.url,
          response.status,
          RequestErrorTypes.PARSE_ERROR,
        );
      }

      const contentLengthHeader = response.headers.get("Content-Length");
      const contentLength =
        !isNullOrUndefined(contentLengthHeader) && !isNaN(+contentLengthHeader)
          ? +contentLengthHeader
          : undefined;
      const reader = response.body.getReader();
      let size = 0;

      return readBufferAndSendEvents();

      async function readBufferAndSendEvents(): Promise<IFetchedStreamComplete> {
        const data = await reader.read();

        if (!data.done && !isNullOrUndefined(data.value)) {
          size += data.value.byteLength;
          const currentTime = getMonotonicTimeStamp();
          const dataInfo = {
            url: response.url,
            currentTime,
            duration: currentTime - sendingTime,
            sendingTime,
            chunkSize: data.value.byteLength,
            chunk: data.value.buffer,
            size,
            totalSize: contentLength,
          };
          options.onData(dataInfo);
          return readBufferAndSendEvents();
        } else if (data.done) {
          if (timeoutId !== undefined) {
            clearTimeout(timeoutId);
          }
          deregisterCancelLstnr();
          const receivedTime = getMonotonicTimeStamp();
          const requestDuration = receivedTime - sendingTime;
          return {
            requestDuration,
            receivedTime,
            sendingTime,
            size,
            status: response.status,
            url: response.url,
          };
        }
        return readBufferAndSendEvents();
      }
    })
    .catch((err: unknown) => {
      if (cancellation !== null) {
        throw cancellation;
      }
      deregisterCancelLstnr();
      if (isTimedOut) {
        log.warn("utils", "Fetch: Request timed out.", {
          url: options.url,
          timeout: options.timeout,
        });
        throw new RequestError(options.url, 0, RequestErrorTypes.TIMEOUT);
      } else if (isConnectionTimedOut) {
        log.warn("utils", "Fetch: Request connection timed out.", {
          url: options.url,
          connectionTimeout: options.connectionTimeout,
        });
        throw new RequestError(options.url, 0, RequestErrorTypes.TIMEOUT);
      } else if (err instanceof RequestError) {
        throw err;
      }
      log.warn("utils", "Fetch: Request Error", {
        error: err instanceof Error ? err.toString() : "Unkwown Error",
      });
      throw new RequestError(options.url, 0, RequestErrorTypes.ERROR_EVENT);
    });
}

/**
 * Returns true if fetch should be supported in the current browser.
 * @return {boolean}
 */
export function fetchIsSupported(): boolean {
  // Match [native code] and variants with different white space.
  const nativeCodeRegex = /\[\s*native\s+code\s*\]/;
  return (
    typeof globalScope.fetch === "function" &&
    /**
     * Detect if AbortController function has been rewritten.
     * Polyfills can rewrite those function without a proper implementation
     * leading to issues.
     * In this case it's preferable to use XHR over fetch, because fetch uses
     * AbortSignal to cancel requests.
     * @see https://github.com/TanStack/query/discussions/9049
     */
    !isNullOrUndefined(_AbortController) &&
    nativeCodeRegex.test(_AbortController.toString()) &&
    !isNullOrUndefined(_Headers)
  );
}
