/*
 This file is part of GNU Taler
 (C) 2022 Taler Systems S.A.

 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.

 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

 You should have received a copy of the GNU General Public License along with
 GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

/**
 * Imports.
 */
import {
  Duration,
  RequestThrottler,
  TalerError,
  TalerErrorCode,
} from "@gnu-taler/taler-util";

import {
  DEFAULT_REQUEST_TIMEOUT_MS,
  HeadersImpl,
  HttpLibArgs,
  HttpRequestLibrary,
  HttpRequestOptions,
  encodeBody,
  getDefaultHeaders,
} from "@gnu-taler/taler-util/http";
import { compress } from "./http-impl.browser.js";

/**
 * An implementation of the [[HttpRequestLibrary]] using the
 * browser's XMLHttpRequest.
 */
export class BrowserFetchHttpLib implements HttpRequestLibrary {
  private throttle = new RequestThrottler();
  private throttlingEnabled = true;
  private requireTls = false;

  public constructor(args?: HttpLibArgs) {
    this.throttlingEnabled = args?.enableThrottling ?? true;
    this.requireTls = args?.requireTls ?? false;
  }

  async fetch(requestUrl: string, options?: HttpRequestOptions) {
    const requestMethod = options?.method ?? "GET";
    const requestBody = options?.body;
    const requestHeader = options?.headers;
    const requestTimeout =
      options?.timeout ?? Duration.fromMilliseconds(DEFAULT_REQUEST_TIMEOUT_MS);
    const requestCancel = options?.cancellationToken;
    const requestRedirect = options?.redirect;

    const parsedUrl = new URL(requestUrl);
    if (this.throttlingEnabled && this.throttle.applyThrottle(requestUrl)) {
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED,
        {
          requestMethod,
          requestUrl,
          throttleStats: this.throttle.getThrottleStats(requestUrl),
        },
        `request to origin ${parsedUrl.origin} was throttled`,
      );
    }
    if (this.requireTls && parsedUrl.protocol !== "https:") {
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_NETWORK_ERROR,
        {
          requestMethod: requestMethod,
          requestUrl: requestUrl,
        },
        `request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`,
      );
    }

    const encodedBody: ArrayBuffer | undefined =
      requestMethod === "POST" ||
      requestMethod === "PUT" ||
      requestMethod === "PATCH"
        ? encodeBody(requestBody)
        : undefined;

    const myBody =
      !options?.compress || !encodedBody
        ? encodedBody
        : await compress(options.compress, encodedBody);

    const requestHeadersMap = getDefaultHeaders(requestMethod);
    if (requestHeader) {
      Object.entries(requestHeader).forEach(([key, value]) => {
        if (value === undefined) return;
        requestHeadersMap[key] = value;
      });
    }

    if (options?.compress) {
      requestHeadersMap["Content-Encoding"] = options.compress;
    }
    /**
     * default header assume everything is json
     * in case of formData the content-type will be
     * auto generated
     */
    if (requestBody instanceof FormData) {
      delete requestHeadersMap["Content-Type"];
    } else if (requestBody instanceof URLSearchParams) {
      requestHeadersMap["Content-Type"] = "application/x-www-form-urlencoded";
    }

    const controller = new AbortController();
    let timeoutId: ReturnType<typeof setTimeout> | undefined;
    if (requestTimeout.d_ms !== "forever") {
      timeoutId = setTimeout(() => {
        controller.abort(TalerErrorCode.GENERIC_TIMEOUT);
      }, requestTimeout.d_ms);
    }
    if (requestCancel) {
      requestCancel.onCancelled(() => {
        controller.abort(TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR);
      });
    }

    try {
      const response = await fetch(requestUrl, {
        headers: requestHeadersMap,
        body: myBody,
        method: requestMethod,
        signal: controller.signal,
        redirect: requestRedirect,
      });

      if (timeoutId) {
        clearTimeout(timeoutId);
      }

      const headerMap = new HeadersImpl();
      response.headers.forEach((value, key) => {
        headerMap.set(key, value);
      });
      const text = makeTextHandler(response, requestUrl, requestMethod);
      const json = makeJsonHandler(response, requestUrl, requestMethod, text);
      return {
        headers: headerMap,
        status: response.status,
        requestMethod,
        requestUrl,
        json,
        text,
        bytes: async () => (await response.blob()).arrayBuffer(),
      };
    } catch (e) {
      if (controller.signal) {
        throw TalerError.fromDetail(
          controller.signal.reason,
          {
            requestUrl,
            requestMethod,
            timeoutMs:
              requestTimeout.d_ms === "forever" ? 0 : requestTimeout.d_ms,
          },
          `HTTP request failed.`,
        );
      }
      throw e;
    }
  }
}

function makeTextHandler(
  response: Response,
  requestUrl: string,
  requestMethod: string,
) {
  let firstTime = true;
  let respText: string;
  let error: TalerError | undefined;
  return async function getTextFromResponse(): Promise<string> {
    if (firstTime) {
      firstTime = false;
      try {
        respText = await response.text();
      } catch (e) {
        error = TalerError.fromDetail(
          TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
          {
            requestUrl,
            requestMethod,
            httpStatusCode: response.status,
            validationError: e instanceof Error ? e.message : String(e),
          },
          "Invalid text from HTTP response",
        );
      }
    }
    if (error !== undefined) {
      throw error;
    }
    return respText;
  };
}

function makeJsonHandler(
  response: Response,
  requestUrl: string,
  requestMethod: string,
  readTextHandler: () => Promise<string>,
) {
  let firstTime = true;
  let responseJson: string | undefined = undefined;
  let error: TalerError | undefined;
  return async function getJsonFromResponse(): Promise<any> {
    if (firstTime) {
      let responseText: string;
      try {
        responseText = await readTextHandler();
      } catch (e) {
        const message =
          e instanceof Error
            ? `Couldn't read HTTP response: ${e.message}`
            : "Couldn't read HTTP response";
        error = TalerError.fromDetail(
          TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
          {
            requestUrl,
            requestMethod,
            httpStatusCode: response.status,
            validationError: e instanceof Error ? e.message : String(e),
          },
          message,
        );
      }
      if (!error) {
        try {
          // @ts-expect-error no error then text is initialized
          responseJson = JSON.parse(responseText);
        } catch (e) {
          const message =
            e instanceof Error
              ? `Invalid JSON from HTTP response: ${e.message}`
              : "Invalid JSON from HTTP response";
          error = TalerError.fromDetail(
            TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
            {
              requestUrl,
              requestMethod,
              // @ts-expect-error no error then text is initialized
              response: responseText,
              httpStatusCode: response.status,
              validationError: e instanceof Error ? e.message : String(e),
            },
            message,
          );
        }
        if (responseJson === null || typeof responseJson !== "object") {
          error = TalerError.fromDetail(
            TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
            {
              requestUrl,
              requestMethod,
              response: JSON.stringify(responseJson),
              httpStatusCode: response.status,
            },
            "Invalid JSON from HTTP response: null or not object",
          );
        }
      }
    }
    if (error !== undefined) {
      throw error;
    }
    return responseJson;
  };
}
