/*
 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 * as http from "node:http";
import * as https from "node:https";
import * as net from "node:net";
import { RequestOptions } from "node:http";
import { TalerError } from "./errors.js";
import { encodeBody, getDefaultHeaders, HttpLibArgs } from "./http-common.js";
import {
  DEFAULT_REQUEST_TIMEOUT_MS,
  Headers,
  HttpRequestLibrary,
  HttpRequestOptions,
  HttpResponse,
} from "./http.js";
import {
  Logger,
  RequestThrottler,
  TalerErrorCode,
  typedArrayConcat,
  URL,
} from "./index.js";

// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed
// in v20.3.0.
// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
// Safe to remove once support for Node v20 is dropped.
if (
  // check for `node` in case we want to use this in "exotic" JS envs
  process.versions.node &&
  process.versions.node.match(/20\.[0-2]\.0/)
) {
  //@ts-ignore
  net.setDefaultAutoSelectFamily(false);
}

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

const textDecoder = new TextDecoder();

/**
 * 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 ?? false;
    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?.toUpperCase() ?? "GET";

    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 timeoutMs: number | undefined;
    if (typeof opt?.timeout?.d_ms === "number") {
      timeoutMs = opt.timeout.d_ms;
    } else {
      timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
    }

    const requestHeadersMap = { ...getDefaultHeaders(method), ...opt?.headers };

    let reqBody: ArrayBuffer | undefined;

    if (opt?.method == "POST") {
      reqBody = encodeBody(opt.body);
    }

    let path = parsedUrl.pathname;
    if (parsedUrl.search != null) {
      path += parsedUrl.search;
    }

    let protocol: string;
    if (parsedUrl.protocol === "https:") {
      protocol = "https:";
    } else if (parsedUrl.protocol === "http:") {
      protocol = "http:";
    } else {
      throw Error(`unsupported protocol (${parsedUrl.protocol})`);
    }

    const options: RequestOptions = {
      protocol,
      port: parsedUrl.port,
      host: parsedUrl.hostname,
      method: method,
      path,
      headers: requestHeadersMap,
      timeout: timeoutMs,
    };

    const chunks: Uint8Array[] = [];

    return new Promise((resolve, reject) => {
      const handler = (res: http.IncomingMessage) => {
        res.on("data", (d) => {
          chunks.push(d);
        });
        res.on("end", () => {
          const headers: Headers = new Headers();
          for (const [k, v] of Object.entries(res.headers)) {
            if (!v) {
              continue;
            }
            if (typeof v === "string") {
              headers.set(k, v);
            } else {
              headers.set(k, v.join(", "));
            }
          }
          const data = typedArrayConcat(chunks);
          const resp: HttpResponse = {
            requestMethod: method,
            requestUrl: parsedUrl.href,
            status: res.statusCode || 0,
            headers,
            async bytes() {
              return data;
            },
            json() {
              const text = textDecoder.decode(data);
              return JSON.parse(text);
            },
            async text() {
              const text = textDecoder.decode(data);
              return text;
            },
          };
          resolve(resp);
        });
        res.on("error", (e) => {
          const err = TalerError.fromDetail(
            TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
            {
              requestUrl: url,
              requestMethod: method,
              httpStatusCode: 0,
            },
            `Error in HTTP response handler: ${e.message}`,
          );
          reject(err);
        });
      };

      let req: http.ClientRequest;
      if (options.protocol === "http:") {
        req = http.request(options, handler);
      } else if (options.protocol === "https:") {
        req = https.request(options, handler);
      } else {
        throw new Error(`unsupported protocol ${options.protocol}`);
      }

      req.on("error", (e: Error) => {
        const err = TalerError.fromDetail(
          TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
          {
            requestUrl: url,
            requestMethod: method,
            httpStatusCode: 0,
          },
          `Error in HTTP request: ${e.message}`,
        );
        reject(err);
      });

      if (reqBody) {
        req.write(new Uint8Array(reqBody));
      }
      req.end();
    });
  }

  async get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
    return this.fetch(url, {
      method: "GET",
      ...opt,
    });
  }

  async postJson(
    url: string,
    body: any,
    opt?: HttpRequestOptions,
  ): Promise<HttpResponse> {
    return this.fetch(url, {
      method: "POST",
      body,
      ...opt,
    });
  }
}