/*
 This file is part of GNU Taler
 (C) 2023-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 { CancellationToken } from "@gnu-taler/taler-util";
import { Codec } from "./codec.js";
import { j2s } from "./helpers.js";
import {
  TalerError,
  base64FromArrayBuffer,
  makeErrorDetail,
  stringToBytes,
} from "./index.js";
import { Logger } from "./logging.js";
import { TalerErrorCode } from "./taler-error-codes.js";
import { AbsoluteTime, Duration } from "./time.js";
import { TalerErrorDetail } from "./types-taler-wallet.js";

const textEncoder = new TextEncoder();

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

/**
 * An HTTP response that is returned by all request methods of this library.
 */
export interface HttpResponse {
  requestUrl: string;
  requestMethod: string;
  status: number;
  headers: Headers;
  json(): Promise<any>;
  text(): Promise<string>;
  bytes(): Promise<ArrayBuffer>;
}

export const DEFAULT_REQUEST_TIMEOUT_MS = 60000;

export interface HttpRequestOptions {
  method?: "POST" | "PATCH" | "PUT" | "GET" | "DELETE";
  headers?: { [name: string]: string | undefined };

  /**
   * Timeout after which the request should be aborted.
   */
  timeout?: Duration;

  /**
   * Cancellation token that should abort the request when
   * cancelled.
   */
  cancellationToken?: CancellationToken;

  body?: string | ArrayBuffer | object;

  /**
   * How to handle redirects.
   * Same semantics as WHATWG fetch.
   */
  redirect?: "follow" | "error" | "manual";

  /**
   * How to compress the payload
   */
  compress?: "gzip" | "deflate";
}

/**
 * Headers, roughly modeled after the fetch API's headers object.
 */
export interface Headers {
  get(name: string): string | null;
  set(name: string, value: string): void;
  toJSON(): any;
}

export class HeadersImpl {
  private headerMap = new Map<string, string>();

  get(name: string): string | null {
    const r = this.headerMap.get(name.toLowerCase());
    if (r) {
      return r;
    }
    return null;
  }

  set(name: string, value: string): void {
    const normalizedName = name.toLowerCase();
    const existing = this.headerMap.get(normalizedName);
    if (existing !== undefined) {
      this.headerMap.set(normalizedName, existing + "," + value);
    } else {
      this.headerMap.set(normalizedName, value);
    }
  }

  toJSON(): any {
    const m: Record<string, string> = {};
    this.headerMap.forEach((v, k) => (m[k] = v));
    return m;
  }
}

/**
 * Interface for the HTTP request library used by the wallet.
 *
 * The request library is bundled into an interface to make mocking and
 * request tunneling easy.
 */
export interface HttpRequestLibrary {
  /**
   * Make an HTTP POST request with a JSON body.
   */
  fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse>;
}

type TalerErrorResponse = {
  code: number;
} & unknown;

type ResponseOrError<T> =
  | { isError: false; response: T }
  | { isError: true; talerErrorResponse: TalerErrorResponse };

/**
 * Read Taler error details from an HTTP response.
 */
export async function readTalerErrorResponse(
  httpResponse: HttpResponse,
): Promise<TalerErrorDetail> {
  const contentType = httpResponse.headers.get("content-type");
  if (contentType !== "application/json") {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: httpResponse.requestUrl,
        requestMethod: httpResponse.requestMethod,
        httpStatusCode: httpResponse.status,
        response: await httpResponse.text(),
        contentType: contentType || "<null>",
      },
      "Error response did not even contain JSON. The request URL might be wrong or the service might be unavailable.",
    );
  }
  let errJson;
  try {
    errJson = await httpResponse.json();
  } catch (e) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: httpResponse.requestUrl,
        requestMethod: httpResponse.requestMethod,
        httpStatusCode: httpResponse.status,
        response: await httpResponse.text(),
        validationError: e instanceof Error ? e.message : String(e),
      },
      "Couldn't parse JSON format from error response",
    );
  }

  const talerErrorCode = errJson.code;
  if (typeof talerErrorCode !== "number") {
    logger.warn(
      `malformed error response (status ${httpResponse.status}): ${j2s(
        errJson,
      )}`,
    );
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: httpResponse.requestUrl,
        requestMethod: httpResponse.requestMethod,
        httpStatusCode: httpResponse.status,
        response: await httpResponse.text(),
      },
      "Error response did not contain error code",
    );
  }
  return errJson;
}

export async function readUnexpectedResponseDetails(
  httpResponse: HttpResponse,
): Promise<TalerErrorDetail> {
  let errJson;
  try {
    errJson = await httpResponse.json();
  } catch (e) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: httpResponse.requestUrl,
        requestMethod: httpResponse.requestMethod,
        httpStatusCode: httpResponse.status,
        response: await httpResponse.text(),
        validationError: e instanceof Error ? e.message : String(e),
      },
      "Couldn't parse JSON format from error response",
    );
  }
  const talerErrorCode = errJson.code;
  if (typeof talerErrorCode !== "number") {
    return makeErrorDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: httpResponse.requestUrl,
        requestMethod: httpResponse.requestMethod,
        httpStatusCode: httpResponse.status,
        response: await httpResponse.text(),
      },
      "Error response did not contain error code",
    );
  }
  return makeErrorDetail(
    TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
    {
      requestUrl: httpResponse.requestUrl,
      requestMethod: httpResponse.requestMethod,
      httpStatusCode: httpResponse.status,
      errorResponse: errJson,
    },
    `Unexpected HTTP status (${httpResponse.status}) in response`,
  );
}

export async function readSuccessResponseJsonOrErrorCode<T>(
  httpResponse: HttpResponse,
  codec: Codec<T>,
): Promise<ResponseOrError<T>> {
  if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
    return {
      isError: true,
      talerErrorResponse: await readTalerErrorResponse(httpResponse),
    };
  }
  let respJson;
  try {
    respJson = await httpResponse.json();
  } catch (e) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: httpResponse.requestUrl,
        requestMethod: httpResponse.requestMethod,
        httpStatusCode: httpResponse.status,
        response: await httpResponse.text(),
        validationError: e instanceof Error ? e.message : String(e),
      },
      "Couldn't parse JSON format from response",
    );
  }
  let parsedResponse: T;
  try {
    parsedResponse = codec.decode(respJson);
  } catch (e) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: httpResponse.requestUrl,
        requestMethod: httpResponse.requestMethod,
        httpStatusCode: httpResponse.status,
        response: await httpResponse.text(),
        validationError: e instanceof Error ? e.message : String(e),
      },
      "Response invalid",
    );
  }
  return {
    isError: false,
    response: parsedResponse,
  };
}

export async function readResponseJsonOrThrow<T>(
  httpResponse: HttpResponse,
  codec: Codec<T>,
): Promise<T> {
  let respJson;
  try {
    respJson = await httpResponse.json();
  } catch (e) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: httpResponse.requestUrl,
        requestMethod: httpResponse.requestMethod,
        httpStatusCode: httpResponse.status,
        response: await httpResponse.text(),
        validationError: e instanceof Error ? e.message : String(e),
      },
      "Couldn't parse JSON format from response",
    );
  }
  let parsedResponse: T;
  try {
    parsedResponse = codec.decode(respJson);
  } catch (e) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: httpResponse.requestUrl,
        requestMethod: httpResponse.requestMethod,
        httpStatusCode: httpResponse.status,
        response: await httpResponse.text(),
        validationError: e instanceof Error ? e.message : String(e),
      },
      "Response invalid",
    );
  }
  return parsedResponse;
}

type HttpErrorDetails = {
  requestUrl: string;
  requestMethod: string;
  httpStatusCode: number;
};

export function getHttpResponseErrorDetails(
  httpResponse: HttpResponse,
): HttpErrorDetails {
  return {
    requestUrl: httpResponse.requestUrl,
    requestMethod: httpResponse.requestMethod,
    httpStatusCode: httpResponse.status,
  };
}

export function throwUnexpectedRequestError(
  httpResponse: HttpResponse,
  talerErrorResponse: TalerErrorResponse,
): never {
  const errorDetails = {
    requestUrl: httpResponse.requestUrl,
    requestMethod: httpResponse.requestMethod,
    httpStatusCode: httpResponse.status,
    errorResponse: talerErrorResponse,
  };
  logger.trace(`unexpected request error: ${j2s(errorDetails)}`);
  throw TalerError.fromDetail(
    TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
    errorDetails,
    `Unexpected HTTP status ${httpResponse.status} in response`,
  );
}

export async function readSuccessResponseJsonOrThrow<T>(
  httpResponse: HttpResponse,
  codec: Codec<T>,
): Promise<T> {
  const r = await readSuccessResponseJsonOrErrorCode(httpResponse, codec);
  if (!r.isError) {
    return r.response;
  }
  throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
}

export async function expectSuccessResponseOrThrow<T>(
  httpResponse: HttpResponse,
): Promise<void> {
  if (httpResponse.status >= 200 && httpResponse.status <= 299) {
    return;
  }
  const errResp = await readTalerErrorResponse(httpResponse);
  throwUnexpectedRequestError(httpResponse, errResp);
}

export async function readSuccessResponseTextOrErrorCode<T>(
  httpResponse: HttpResponse,
): Promise<ResponseOrError<string>> {
  if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
    let errJson;
    try {
      errJson = await httpResponse.json();
    } catch (e) {
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
        {
          requestUrl: httpResponse.requestUrl,
          requestMethod: httpResponse.requestMethod,
          httpStatusCode: httpResponse.status,
          response: await httpResponse.text(),
          validationError: e instanceof Error ? e.message : String(e),
        },
        "Couldn't parse JSON format from error response",
      );
    }

    const talerErrorCode = errJson.code;
    if (typeof talerErrorCode !== "number") {
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
        {
          httpStatusCode: httpResponse.status,
          requestUrl: httpResponse.requestUrl,
          response: await httpResponse.text(),
          requestMethod: httpResponse.requestMethod,
        },
        "Error response did not contain error code",
      );
    }
    return {
      isError: true,
      talerErrorResponse: errJson,
    };
  }
  const respJson = await httpResponse.text();
  return {
    isError: false,
    response: respJson,
  };
}

export async function checkSuccessResponseOrThrow(
  httpResponse: HttpResponse,
): Promise<void> {
  if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
    let errJson;
    try {
      errJson = await httpResponse.json();
    } catch (e) {
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
        {
          requestUrl: httpResponse.requestUrl,
          requestMethod: httpResponse.requestMethod,
          httpStatusCode: httpResponse.status,
          response: await httpResponse.text(),
          validationError: e instanceof Error ? e.message : String(e),
        },
        "Couldn't parse JSON format from error response",
      );
    }

    const talerErrorCode = errJson.code;
    if (typeof talerErrorCode !== "number") {
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
        {
          httpStatusCode: httpResponse.status,
          requestUrl: httpResponse.requestUrl,
          response: await httpResponse.text(),
          requestMethod: httpResponse.requestMethod,
        },
        "Error response did not contain error code",
      );
    }
    throwUnexpectedRequestError(httpResponse, errJson);
  }
}

export async function readSuccessResponseTextOrThrow<T>(
  httpResponse: HttpResponse,
): Promise<string> {
  const r = await readSuccessResponseTextOrErrorCode(httpResponse);
  if (!r.isError) {
    return r.response;
  }
  throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
}

/**
 * Get the timestamp at which the response's content is considered expired.
 */
export function getExpiry(
  httpResponse: HttpResponse,
  opt: { minDuration?: Duration },
): AbsoluteTime {
  const expiryDateMs = new Date(
    httpResponse.headers.get("expiry") ?? "",
  ).getTime();
  let t: AbsoluteTime;
  if (Number.isNaN(expiryDateMs)) {
    t = AbsoluteTime.now();
  } else {
    t = AbsoluteTime.fromMilliseconds(expiryDateMs);
  }
  if (opt.minDuration) {
    const t2 = AbsoluteTime.addDuration(AbsoluteTime.now(), opt.minDuration);
    return AbsoluteTime.max(t, t2);
  }
  return t;
}

export interface HttpLibArgs {
  enableThrottling?: boolean;
  /**
   * Only allow HTTPS connections, not plain http.
   */
  requireTls?: boolean;
  printAsCurl?: boolean;
}

export function encodeBody(body: unknown): ArrayBuffer {
  if (body == null) {
    return new ArrayBuffer(0);
  }
  if (typeof body === "string") {
    return textEncoder.encode(body).buffer;
  } else if (ArrayBuffer.isView(body)) {
    return body.buffer;
  } else if (body instanceof ArrayBuffer) {
    return body;
  } else if (body instanceof URLSearchParams) {
    return textEncoder.encode(body.toString()).buffer;
  } else if (typeof body === "object" && body.constructor.name === "FormData") {
    return body as ArrayBuffer;
  } else if (typeof body === "object") {
    return textEncoder.encode(JSON.stringify(body)).buffer;
  }
  throw new TypeError("unsupported request body type");
}

export function getDefaultHeaders(method: string): Record<string, string> {
  const headers: Record<string, string> = {};

  if (method === "POST" || method === "PUT" || method === "PATCH") {
    // Default to JSON if we have a body
    headers["Content-Type"] = "application/json";
  }

  headers["Accept"] = "application/json";

  return headers;
}

/**
 * Helper function to generate the "Authorization" HTTP header.
 */
export function makeBasicAuthHeader(
  username: string,
  password: string,
): string {
  const auth = `${username}:${password}`;
  const authEncoded: string = base64FromArrayBuffer(stringToBytes(auth));
  return `Basic ${authEncoded}`;
}
