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

import type { FollowOptions, RedirectableRequest } from "follow-redirects";
import followRedirects from "follow-redirects";
import type { ClientRequest, IncomingMessage } from "node:http";
import { RequestOptions } from "node:http";
import * as net from "node:net";
import { deflateSync } from "node:zlib";
import { TalerError } from "./errors.js";
import { HttpLibArgs, encodeBody, getDefaultHeaders } from "./http-common.js";
import {
  DEFAULT_REQUEST_TIMEOUT_MS,
  HeadersImpl,
  HttpRequestLibrary,
  HttpRequestOptions,
  HttpResponse,
} from "./http.js";
import {
  Logger,
  RequestThrottler,
  TalerErrorCode,
  URL,
  j2s,
  typedArrayConcat,
} from "./index.js";

const http = followRedirects.http;
const https = followRedirects.https;

// 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
// The issues is not fixed on 22 and 24.
// More info https://bugs.gnunet.org/view.php?id=9942
if (
  // check for `node` in case we want to use this in "exotic" JS envs
  process.versions.node &&
  process.versions.node.match(/(20|22|24)\./)
) {
  //@ts-ignore
  net.setDefaultAutoSelectFamily(false);
}

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

const textDecoder = new TextDecoder();
let SHOW_CURL_HTTP_REQUEST = false;
export function setPrintHttpRequestAsCurl(b: boolean) {
  SHOW_CURL_HTTP_REQUEST = b;
}

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

  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?.toUpperCase() ?? "GET";
    const rid = this.idCounter++;

    if (logger.shouldLogTrace()) {
      if (opt?.body != null) {
        logger.trace(`request ${rid} ${method} ${url}: ${opt.body}`);
      } else {
        logger.trace(`request ${rid} ${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 ${rid} to ${parsedUrl.origin} was throttled`,
      );
    }
    if (this.requireTls && parsedUrl.protocol !== "https:") {
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_NETWORK_ERROR,
        {
          requestMethod: method,
          requestUrl: url,
        },
        `request ${rid} 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);
    if (opt?.headers) {
      Object.entries(opt?.headers).forEach(([key, value]) => {
        if (value === undefined) return;
        requestHeadersMap[key] = value;
      });
    }
    logger.trace(`request ${rid} timeout ${timeoutMs} ms`);

    let reqBody: ArrayBuffer | undefined;

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

    if (reqBody && opt?.compress === "deflate") {
      reqBody = deflateSync(reqBody);
      requestHeadersMap["Content-Encoding"] = "deflate";
    }

    if (opt?.body instanceof URLSearchParams) {
      requestHeadersMap["Content-Type"] = "application/x-www-form-urlencoded";
    }

    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 & FollowOptions<RequestOptions> = {
      protocol,
      port: parsedUrl.port,
      host: parsedUrl.hostname,
      method: method,
      path,
      headers: requestHeadersMap,
      timeout: timeoutMs,
      followRedirects: opt?.redirect !== "manual",
    };

    const chunks: Uint8Array[] = [];

    if (SHOW_CURL_HTTP_REQUEST) {
      const payload =
        !reqBody || reqBody.byteLength === 0
          ? undefined
          : textDecoder.decode(reqBody);
      const headers = Object.entries(requestHeadersMap).reduce(
        (prev, [key, value]) => {
          return `${prev} -H "${key}: ${value}"`;
        },
        "",
      );
      function ifUndefined<T>(arg: string, v: undefined | T): string {
        if (v === undefined) return "";
        return arg + " '" + String(v) + "'";
      }
      console.log(
        `TALER_API_DEBUG: curl -X ${options.method} "${
          parsedUrl.href
        }" ${headers} ${ifUndefined("-d", payload)}`,
      );
    }

    let timeoutHandle: NodeJS.Timeout | undefined = undefined;
    let cancelCancelledHandler: (() => void) | undefined = undefined;

    const doCleanup = () => {
      if (timeoutHandle != null) {
        clearTimeout(timeoutHandle);
      }
      if (cancelCancelledHandler) {
        cancelCancelledHandler();
      }
    };

    return new Promise((resolve, reject) => {
      const handler = (res: IncomingMessage) => {
        res.on("data", (d) => {
          chunks.push(d);
        });
        res.on("end", () => {
          const headers: HeadersImpl = new HeadersImpl();
          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);
              const json = JSON.parse(text);
              if (logger.shouldLogTrace()) {
                logger.trace(`request ${rid} JSON: ${j2s(json)}`);
              }
              return json;
            },
            async text() {
              const text = textDecoder.decode(data);
              if (logger.shouldLogTrace()) {
                logger.trace(`request ${rid} TEXT: ${text}`);
              }
              return text;
            },
          };
          logger.trace(`request ${rid} status code ${resp.status}`);
          doCleanup();
          if (SHOW_CURL_HTTP_REQUEST) {
            console.log(
              `TALER_API_DEBUG: ${res.statusCode} ${textDecoder.decode(data)}`,
            );
          }
          resolve(resp);
        });
        res.on("error", (e) => {
          const code = "code" in e ? e.code : "unknown";
          const err = TalerError.fromDetail(
            TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
            {
              requestUrl: url,
              requestMethod: method,
              httpStatusCode: 0,
            },
            `Error in HTTP response handler: ${code}`,
          );
          doCleanup();
          reject(err);
        });
      };

      let req: RedirectableRequest<ClientRequest, IncomingMessage>;
      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}`);
      }

      if (timeoutMs != null) {
        timeoutHandle = setTimeout(() => {
          logger.info(`request to ${url} timed out`);
          const err = TalerError.fromDetail(
            TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
            {
              requestUrl: url,
              requestMethod: method,
              httpStatusCode: 0,
            },
            `Request timed out after ${timeoutMs} ms`,
          );
          timeoutHandle = undefined;
          req.destroy();
          doCleanup();
          reject(err);
          req.destroy();
        }, timeoutMs);
      }

      if (opt?.cancellationToken) {
        cancelCancelledHandler = opt.cancellationToken.onCancelled(() => {
          const err = TalerError.fromDetail(
            TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
            {
              requestUrl: url,
              requestMethod: method,
              httpStatusCode: 0,
            },
            `Request cancelled`,
          );
          req.destroy();
          doCleanup();
          reject(err);
        });
      }

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

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