/*
 This file is part of GNU Taler
 (C) 2023 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 
 SPDX-License-Identifier: AGPL3.0-or-later
*/
import { CancellationToken } from "./CancellationToken.js";
import { Codec } from "./codec.js";
import { j2s } from "./helpers.js";
import { TalerError, makeErrorDetail } from "./index.js";
import { Logger } from "./logging.js";
import { TalerErrorCode } from "./taler-error-codes.js";
import { Duration, AbsoluteTime } from "./time.js";
import { TalerErrorDetail } from "./wallet-types.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;
  text(): Promise;
  bytes(): Promise;
}
export const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
export interface HttpRequestOptions {
  method?: "POST" | "PUT" | "GET";
  headers?: { [name: string]: string };
  /**
   * 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;
}
/**
 * Headers, roughly modeled after the fetch API's headers object.
 */
export class Headers {
  private headerMap = new Map();
  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 = {};
    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 GET request.
   *
   * FIXME: Get rid of this, we want the API surface to be minimal.
   *
   * @deprecated use fetch instead
   */
  get(url: string, opt?: HttpRequestOptions): Promise;
  /**
   * Make an HTTP POST request with a JSON body.
   *
   * FIXME: Get rid of this, we want the API surface to be minimal.
   *
   * @deprecated use fetch instead
   */
  postJson(
    url: string,
    body: any,
    opt?: HttpRequestOptions,
  ): Promise;
  /**
   * Make an HTTP POST request with a JSON body.
   */
  fetch(url: string, opt?: HttpRequestOptions): Promise;
}
type TalerErrorResponse = {
  code: number;
} & unknown;
type ResponseOrError =
  | { isError: false; response: T }
  | { isError: true; talerErrorResponse: TalerErrorResponse };
export async function readTalerErrorResponse(
  httpResponse: HttpResponse,
): Promise {
  let errJson;
  try {
    errJson = await httpResponse.json();
  } catch (e: any) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: httpResponse.requestUrl,
        requestMethod: httpResponse.requestMethod,
        httpStatusCode: httpResponse.status,
        validationError: e.toString(),
      },
      "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,
      },
      "Error response did not contain error code",
    );
  }
  return errJson;
}
export async function readUnexpectedResponseDetails(
  httpResponse: HttpResponse,
): Promise {
  let errJson;
  try {
    errJson = await httpResponse.json();
  } catch (e: any) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: httpResponse.requestUrl,
        requestMethod: httpResponse.requestMethod,
        httpStatusCode: httpResponse.status,
        validationError: e.toString(),
      },
      "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,
      },
      "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(
  httpResponse: HttpResponse,
  codec: Codec,
): Promise> {
  if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
    return {
      isError: true,
      talerErrorResponse: await readTalerErrorResponse(httpResponse),
    };
  }
  let respJson;
  try {
    respJson = await httpResponse.json();
  } catch (e: any) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: httpResponse.requestUrl,
        requestMethod: httpResponse.requestMethod,
        httpStatusCode: httpResponse.status,
        validationError: e.toString(),
      },
      "Couldn't parse JSON format from response",
    );
  }
  let parsedResponse: T;
  try {
    parsedResponse = codec.decode(respJson);
  } catch (e: any) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
      {
        requestUrl: httpResponse.requestUrl,
        requestMethod: httpResponse.requestMethod,
        httpStatusCode: httpResponse.status,
        validationError: e.toString(),
      },
      "Response invalid",
    );
  }
  return {
    isError: false,
    response: 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 {
  throw TalerError.fromDetail(
    TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
    {
      requestUrl: httpResponse.requestUrl,
      requestMethod: httpResponse.requestMethod,
      httpStatusCode: httpResponse.status,
      errorResponse: talerErrorResponse,
    },
    `Unexpected HTTP status ${httpResponse.status} in response`,
  );
}
export async function readSuccessResponseJsonOrThrow(
  httpResponse: HttpResponse,
  codec: Codec,
): Promise {
  const r = await readSuccessResponseJsonOrErrorCode(httpResponse, codec);
  if (!r.isError) {
    return r.response;
  }
  throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
}
export async function readSuccessResponseTextOrErrorCode(
  httpResponse: HttpResponse,
): Promise> {
  if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
    let errJson;
    try {
      errJson = await httpResponse.json();
    } catch (e: any) {
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
        {
          requestUrl: httpResponse.requestUrl,
          requestMethod: httpResponse.requestMethod,
          httpStatusCode: httpResponse.status,
          validationError: e.toString(),
        },
        "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,
          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 {
  if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
    let errJson;
    try {
      errJson = await httpResponse.json();
    } catch (e: any) {
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
        {
          requestUrl: httpResponse.requestUrl,
          requestMethod: httpResponse.requestMethod,
          httpStatusCode: httpResponse.status,
          validationError: e.toString(),
        },
        "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,
          requestMethod: httpResponse.requestMethod,
        },
        "Error response did not contain error code",
      );
    }
    throwUnexpectedRequestError(httpResponse, errJson);
  }
}
export async function readSuccessResponseTextOrThrow(
  httpResponse: HttpResponse,
): Promise {
  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 = {
      t_ms: expiryDateMs,
    };
  }
  if (opt.minDuration) {
    const t2 = AbsoluteTime.addDuration(AbsoluteTime.now(), opt.minDuration);
    return AbsoluteTime.max(t, t2);
  }
  return t;
}
export interface HttpLibArgs {
  enableThrottling?: boolean;
  allowHttp?: boolean;
}
export function encodeBody(body: any): 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 (typeof body === "object") {
    return textEncoder.encode(JSON.stringify(body)).buffer;
  }
  throw new TypeError("unsupported request body type");
}
export function getDefaultHeaders(method: string): Record {
  const headers: Record = {};
  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;
}