/*
 This file is part of GNU Taler
 (C) 2019 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/>

 SPDX-License-Identifier: AGPL3.0-or-later
*/

/**
 * Imports.
 */
import { j2s, Logger, openPromise } from "@gnu-taler/taler-util";
import { TalerError } from "./errors.js";
import { encodeBody, getDefaultHeaders, HttpLibArgs } from "./http-common.js";
import {
  HeadersImpl,
  HttpRequestLibrary,
  HttpRequestOptions,
  HttpResponse,
} from "./http.js";
import { RequestThrottler, TalerErrorCode, URL } from "./index.js";
import { QjsHttpResp, qjsOs } from "./qtart.js";

const logger = new Logger("http-impl.qtart.ts");

const textDecoder = new TextDecoder();

export class RequestTimeoutError extends Error {
  public constructor() {
    super("Request timed out");
    Object.setPrototypeOf(this, RequestTimeoutError.prototype);
  }
}

export class RequestCancelledError extends Error {
  public constructor() {
    super("Request cancelled");
    Object.setPrototypeOf(this, RequestCancelledError.prototype);
  }
}

/**
 * Implementation of the HTTP request library interface for node.
 */
export class HttpLibImpl implements HttpRequestLibrary {
  private throttle = new RequestThrottler();
  private throttlingEnabled = true;
  private requireTls = false;

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

  /**
   * Set whether requests should be throttled.
   */
  setThrottling(enabled: boolean): void {
    this.throttlingEnabled = enabled;
  }

  async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
    const method = (opt?.method ?? "GET").toUpperCase();

    logger.trace(`Requesting ${method} ${url}`);

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

    let data: ArrayBuffer | undefined = undefined;
    const requestHeadersMap = getDefaultHeaders(method);
    if (opt?.headers) {
      Object.entries(opt?.headers).forEach(([key, value]) => {
        if (value === undefined) return;
        requestHeadersMap[key] = value;
      });
    }
    let headersList: string[] = [];
    for (let headerName of Object.keys(requestHeadersMap)) {
      headersList.push(`${headerName}: ${requestHeadersMap[headerName]}`);
    }
    if (method === "POST") {
      data = encodeBody(opt?.body);
    }

    const cancelPromCap = openPromise<QjsHttpResp>();

    logger.trace(`calling qtart fetchHttp`);

    // Just like WHATWG fetch(), the qjs http client doesn't
    // really support cancellation, so cancellation here just
    // means that the result is ignored!
    const { promise: fetchProm, cancelFn } = qjsOs.fetchHttp(url, {
      method,
      data,
      headers: headersList,
    });

    let timeoutHandle: any = undefined;
    let cancelCancelledHandler: (() => void) | undefined = undefined;

    if (opt?.timeout && opt.timeout.d_ms !== "forever") {
      timeoutHandle = setTimeout(() => {
        cancelPromCap.reject(new RequestTimeoutError());
      }, opt.timeout.d_ms);
    }

    if (opt?.cancellationToken) {
      cancelCancelledHandler = opt.cancellationToken.onCancelled(() => {
        logger.info(`cancelling native networking request`);
        const cancelRes = cancelFn();
        logger.info(
          `cancelled native networking request (result ${cancelRes})`,
        );
        cancelPromCap.reject(new RequestCancelledError());
      });
    }

    let res: QjsHttpResp;
    try {
      res = await Promise.race([fetchProm, cancelPromCap.promise]);
    } catch (e) {
      logger.trace(`got exception while waiting for qtart http response`);
      if (e instanceof RequestCancelledError) {
        throw TalerError.fromDetail(
          TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
          {
            requestUrl: url,
            requestMethod: method,
            httpStatusCode: 0,
          },
          `Request cancelled`,
        );
      }
      if (e instanceof RequestTimeoutError) {
        throw TalerError.fromDetail(
          TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
          {
            requestUrl: url,
            requestMethod: method,
            httpStatusCode: 0,
          },
          `Request timed out (timeout ${opt?.timeout?.d_ms ?? "none"})`,
        );
      }
      throw e;
    }

    if (logger.shouldLogTrace()) {
      logger.trace(`got qtart http response, status ${res.status}`);
      logger.trace(`response headers: ${j2s(res.headers)}`);
    }

    if (timeoutHandle != null) {
      clearTimeout(timeoutHandle);
    }

    if (cancelCancelledHandler != null) {
      cancelCancelledHandler();
    }

    const headers: HeadersImpl = new HeadersImpl();

    if (res.headers) {
      for (const headerStr of res.headers) {
        const splitPos = headerStr.indexOf(":");
        if (splitPos < 0) {
          continue;
        }
        const headerName = headerStr.slice(0, splitPos).trim().toLowerCase();
        let headerValue = headerStr.slice(splitPos + 1).trim();
        // FIXME: This is a hotfix for the broken native networking implementation on Android
        // that sends the content type header value in square brackets
        if (
          headerName === "content-type" &&
          headerValue.startsWith("[") &&
          headerValue.endsWith("]")
        ) {
          headerValue = headerValue.substring(1, headerValue.length - 2);
        }
        headers.set(headerName, headerValue);
      }
    }

    return {
      requestMethod: method,
      headers,
      async bytes() {
        return res.data;
      },
      json() {
        const text = textDecoder.decode(res.data);
        return JSON.parse(text);
      },
      async text() {
        const text = textDecoder.decode(res.data);
        return text;
      },
      requestUrl: url,
      status: res.status,
    };
  }
}
