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

/**
 * @fileoverview
 * Construction and parsing of taler:// URIs.
 * Specification: https://lsd.gnunet.org/lsd0006/
 */

/**
 * Imports.
 */
import { Codec, Context, DecodingError, renderContext } from "./codec.js";
import { canonicalizeBaseUrl } from "./helpers.js";
import { opFixedSuccess, opKnownTalerFailure } from "./operation.js";
import { TalerErrorCode } from "./taler-error-codes.js";
import { AmountString } from "./types-taler-common.js";
import { URL, URLSearchParams } from "./url.js";

/**
 * A parsed taler URI.
 */
export type TalerUri =
  | PayUriResult
  | PayTemplateUriResult
  | DevExperimentUri
  | PayPullUriResult
  | PayPushUriResult
  | BackupRestoreUri
  | RefundUriResult
  | WithdrawUriResult
  | WithdrawExchangeUri
  | AddExchangeUri;

declare const __action_str: unique symbol;
export type TalerUriString = string & { [__action_str]: true };

export function codecForTalerUriString(): Codec<TalerUriString> {
  return {
    decode(x: any, c?: Context): TalerUriString {
      if (typeof x !== "string") {
        throw new DecodingError(
          `expected string at ${renderContext(c)} but got ${typeof x}`,
        );
      }
      if (parseTalerUri(x) === undefined) {
        throw new DecodingError(
          `invalid taler URI at ${renderContext(c)} but got "${x}"`,
        );
      }
      return x as TalerUriString;
    },
  };
}

export interface PayUriResult {
  type: TalerUriAction.Pay;
  merchantBaseUrl: string;
  orderId: string;
  sessionId: string;
  claimToken?: string;
  noncePriv?: string;
}

export type TemplateParams = {
  amount?: string;
  summary?: string;
};

export interface PayTemplateUriResult {
  type: TalerUriAction.PayTemplate;
  merchantBaseUrl: string;
  templateId: string;
}

export interface WithdrawUriResult {
  type: TalerUriAction.Withdraw;
  bankIntegrationApiBaseUrl: string;
  withdrawalOperationId: string;
  externalConfirmation?: boolean;
}

export interface RefundUriResult {
  type: TalerUriAction.Refund;
  merchantBaseUrl: string;
  orderId: string;
}

export interface PayPushUriResult {
  type: TalerUriAction.PayPush;
  exchangeBaseUrl: string;
  contractPriv: string;
}

export interface PayPullUriResult {
  type: TalerUriAction.PayPull;
  exchangeBaseUrl: string;
  contractPriv: string;
}

export interface DevExperimentUri {
  type: TalerUriAction.DevExperiment;
  devExperimentId: string;
}

export interface BackupRestoreUri {
  type: TalerUriAction.Restore;
  walletRootPriv: string;
  providers: Array<string>;
}

export interface WithdrawExchangeUri {
  type: TalerUriAction.WithdrawExchange;
  exchangeBaseUrl: string;
  amount?: AmountString;
}

export interface AddExchangeUri {
  type: TalerUriAction.AddExchange;
  exchangeBaseUrl: string;
}

/**
 * Parse a taler[+http]://withdraw URI.
 * Return undefined if not passed a valid URI.
 */
export function parseWithdrawUriWithError(s: string) {
  const pi = parseProtoInfoWithError(s, "withdraw");
  if (pi.type === "fail") {
    return pi;
  }

  const c = pi.body.rest.split("?", 2);
  const path = c[0];
  const q = new URLSearchParams(c[1] ?? "");

  const parts = path.split("/");

  if (parts.length < 2) {
    return opKnownTalerFailure(TalerErrorCode.WALLET_TALER_URI_MALFORMED, {
      code: TalerErrorCode.WALLET_TALER_URI_MALFORMED,
    });
  }

  const host = parts[0].toLowerCase();
  const pathSegments = parts.slice(1, parts.length - 1);
  /**
   * The statement below does not tolerate a slash-ended URI.
   * This results in (1) the withdrawalId being passed as the
   * empty string, and (2) the bankIntegrationApi ending with the
   * actual withdrawal operation ID.  That can be fixed by
   * trimming the parts-list.  FIXME
   */
  const withdrawId = parts[parts.length - 1];
  const p = [host, ...pathSegments].join("/");

  const result: WithdrawUriResult = {
    type: TalerUriAction.Withdraw,
    bankIntegrationApiBaseUrl: canonicalizeBaseUrl(
      `${pi.body.innerProto}://${p}/`,
    ),
    withdrawalOperationId: withdrawId,
    externalConfirmation: q.get("external-confirmation") == "1",
  };
  return opFixedSuccess(result);
}

/**
 *
 * @deprecated use parseWithdrawUriWithError
 */
export function parseWithdrawUri(s: string): WithdrawUriResult | undefined {
  const r = parseWithdrawUriWithError(s);
  if (r.type === "fail") return undefined;
  return r.body;
}

/**
 * Parse a taler[+http]://withdraw URI.
 * Return undefined if not passed a valid URI.
 */
export function parseAddExchangeUriWithError(s: string) {
  const pi = parseProtoInfoWithError(s, "add-exchange");
  if (pi.type === "fail") {
    return pi;
  }
  const parts = pi.body.rest.split("/");

  if (parts.length < 2) {
    return opKnownTalerFailure(TalerErrorCode.WALLET_TALER_URI_MALFORMED, {
      code: TalerErrorCode.WALLET_TALER_URI_MALFORMED,
    });
  }

  const host = parts[0].toLowerCase();
  const pathSegments = parts.slice(1, parts.length - 1);
  /**
   * The statement below does not tolerate a slash-ended URI.
   * This results in (1) the withdrawalId being passed as the
   * empty string, and (2) the bankIntegrationApi ending with the
   * actual withdrawal operation ID.  That can be fixed by
   * trimming the parts-list.  FIXME
   */
  const p = [host, ...pathSegments].join("/");

  const result: AddExchangeUri = {
    type: TalerUriAction.AddExchange,
    exchangeBaseUrl: canonicalizeBaseUrl(`${pi.body.innerProto}://${p}/`),
  };
  return opFixedSuccess(result);
}

/**
 *
 * @deprecated use parseWithdrawUriWithError
 */
export function parseAddExchangeUri(s: string): AddExchangeUri | undefined {
  const r = parseAddExchangeUriWithError(s);
  if (r.type === "fail") return undefined;
  return r.body;
}

/**
 * @deprecated use TalerUriAction
 */
export enum TalerUriType {
  TalerPay = "taler-pay",
  TalerTemplate = "taler-template",
  TalerPayTemplate = "taler-pay-template",
  TalerWithdraw = "taler-withdraw",
  TalerTip = "taler-tip",
  TalerRefund = "taler-refund",
  TalerPayPush = "taler-pay-push",
  TalerPayPull = "taler-pay-pull",
  TalerRecovery = "taler-recovery",
  TalerDevExperiment = "taler-dev-experiment",
  Unknown = "unknown",
}

export enum TalerUriAction {
  Pay = "pay",
  Withdraw = "withdraw",
  Refund = "refund",
  PayPull = "pay-pull",
  PayPush = "pay-push",
  PayTemplate = "pay-template",
  Restore = "restore",
  DevExperiment = "dev-experiment",
  WithdrawExchange = "withdraw-exchange",
  AddExchange = "add-exchange",
}

interface TalerUriProtoInfo {
  innerProto: "http" | "https";
  rest: string;
}

function parseProtoInfo(
  s: string,
  action: string,
): TalerUriProtoInfo | undefined {
  const pfxPlain = `taler://${action}/`;
  const pfxHttp = `taler+http://${action}/`;
  if (s.toLowerCase().startsWith(pfxPlain)) {
    return {
      innerProto: "https",
      rest: s.substring(pfxPlain.length),
    };
  } else if (s.toLowerCase().startsWith(pfxHttp)) {
    return {
      innerProto: "http",
      rest: s.substring(pfxHttp.length),
    };
  } else {
    return undefined;
  }
}

function parseProtoInfoWithError(s: string, action: string) {
  if (
    !s.toLowerCase().startsWith("taler://") &&
    !s.toLowerCase().startsWith("taler+http://")
  ) {
    return opKnownTalerFailure(TalerErrorCode.WALLET_TALER_URI_MALFORMED, {
      code: TalerErrorCode.WALLET_TALER_URI_MALFORMED,
    });
  }
  const pfxPlain = `taler://${action}/`;
  const pfxHttp = `taler+http://${action}/`;
  if (s.toLowerCase().startsWith(pfxPlain)) {
    return opFixedSuccess({
      innerProto: "https",
      rest: s.substring(pfxPlain.length),
    });
  } else if (s.toLowerCase().startsWith(pfxHttp)) {
    return opFixedSuccess({
      innerProto: "http",
      rest: s.substring(pfxHttp.length),
    });
  } else {
    return opKnownTalerFailure(TalerErrorCode.WALLET_TALER_URI_MALFORMED, {
      code: TalerErrorCode.WALLET_TALER_URI_MALFORMED,
    });
  }
}

type Parser = (s: string) => TalerUri | undefined;
const parsers: { [A in TalerUriAction]: Parser } = {
  [TalerUriAction.Pay]: parsePayUri,
  [TalerUriAction.PayPull]: parsePayPullUri,
  [TalerUriAction.PayPush]: parsePayPushUri,
  [TalerUriAction.PayTemplate]: parsePayTemplateUri,
  [TalerUriAction.Restore]: parseRestoreUri,
  [TalerUriAction.Refund]: parseRefundUri,
  [TalerUriAction.Withdraw]: parseWithdrawUri,
  [TalerUriAction.DevExperiment]: parseDevExperimentUri,
  [TalerUriAction.WithdrawExchange]: parseWithdrawExchangeUri,
  [TalerUriAction.AddExchange]: parseAddExchangeUri,
};

export function parseTalerUri(string: string): TalerUri | undefined {
  const https = string.startsWith("taler://");
  const http = string.startsWith("taler+http://");
  if (!https && !http) return undefined;
  const actionStart = https ? 8 : 13;
  const actionEnd = string.indexOf("/", actionStart + 1);
  const action = string.substring(actionStart, actionEnd);
  const found = Object.values(TalerUriAction).find((x) => x === action);
  if (!found) return undefined;
  return parsers[found](string);
}

export function stringifyTalerUri(uri: TalerUri): string {
  switch (uri.type) {
    case TalerUriAction.DevExperiment: {
      return stringifyDevExperimentUri(uri);
    }
    case TalerUriAction.Pay: {
      return stringifyPayUri(uri);
    }
    case TalerUriAction.PayPull: {
      return stringifyPayPullUri(uri);
    }
    case TalerUriAction.PayPush: {
      return stringifyPayPushUri(uri);
    }
    case TalerUriAction.PayTemplate: {
      return stringifyPayTemplateUri(uri);
    }
    case TalerUriAction.Restore: {
      return stringifyRestoreUri(uri);
    }
    case TalerUriAction.Refund: {
      return stringifyRefundUri(uri);
    }
    case TalerUriAction.Withdraw: {
      return stringifyWithdrawUri(uri);
    }
    case TalerUriAction.WithdrawExchange: {
      return stringifyWithdrawExchange(uri);
    }
    case TalerUriAction.AddExchange: {
      return stringifyAddExchange(uri);
    }
  }
}

/**
 * Parse a taler[+http]://pay URI.
 * Return undefined if not passed a valid URI.
 */
export function parsePayUri(s: string): PayUriResult | undefined {
  const pi = parseProtoInfo(s, "pay");
  if (!pi) {
    return undefined;
  }
  const c = pi?.rest.split("?");
  const q = new URLSearchParams(c[1] ?? "");
  const claimToken = q.get("c") ?? undefined;
  const noncePriv = q.get("n") ?? undefined;
  const parts = c[0].split("/");
  if (parts.length < 3) {
    return undefined;
  }
  const host = parts[0].toLowerCase();
  const sessionId = parts[parts.length - 1];
  const orderId = parts[parts.length - 2];
  const pathSegments = parts.slice(1, parts.length - 2);
  const p = [host, ...pathSegments].join("/");
  const merchantBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);

  return {
    type: TalerUriAction.Pay,
    merchantBaseUrl,
    orderId,
    sessionId,
    claimToken,
    noncePriv,
  };
}

export function parsePayTemplateUri(
  uriString: string,
): PayTemplateUriResult | undefined {
  const pi = parseProtoInfo(uriString, TalerUriAction.PayTemplate);
  if (!pi) {
    return undefined;
  }
  const c = pi.rest.split("?");

  const parts = c[0].split("/");
  if (parts.length < 2) {
    return undefined;
  }

  const q = new URLSearchParams(c[1] ?? "");
  const params: Record<string, string> = {};
  q.forEach((v, k) => {
    params[k] = v;
  });

  const host = parts[0].toLowerCase();
  const templateId = parts[parts.length - 1];
  const pathSegments = parts.slice(1, parts.length - 1);
  const hostAndSegments = [host, ...pathSegments].join("/");
  const merchantBaseUrl = canonicalizeBaseUrl(
    `${pi.innerProto}://${hostAndSegments}/`,
  );

  return {
    type: TalerUriAction.PayTemplate,
    merchantBaseUrl,
    templateId,
  };
}

export function parsePayPushUri(s: string): PayPushUriResult | undefined {
  const pi = parseProtoInfo(s, TalerUriAction.PayPush);
  if (!pi) {
    return undefined;
  }
  const c = pi?.rest.split("?");
  const parts = c[0].split("/");
  if (parts.length < 2) {
    return undefined;
  }
  const host = parts[0].toLowerCase();
  const contractPriv = parts[parts.length - 1];
  const pathSegments = parts.slice(1, parts.length - 1);
  const hostAndSegments = [host, ...pathSegments].join("/");
  const exchangeBaseUrl = canonicalizeBaseUrl(
    `${pi.innerProto}://${hostAndSegments}/`,
  );

  return {
    type: TalerUriAction.PayPush,
    exchangeBaseUrl,
    contractPriv,
  };
}

export function parsePayPullUri(s: string): PayPullUriResult | undefined {
  const pi = parseProtoInfo(s, TalerUriAction.PayPull);
  if (!pi) {
    return undefined;
  }
  const c = pi?.rest.split("?");
  const parts = c[0].split("/");
  if (parts.length < 2) {
    return undefined;
  }
  const host = parts[0].toLowerCase();
  const contractPriv = parts[parts.length - 1];
  const pathSegments = parts.slice(1, parts.length - 1);
  const hostAndSegments = [host, ...pathSegments].join("/");
  const exchangeBaseUrl = canonicalizeBaseUrl(
    `${pi.innerProto}://${hostAndSegments}/`,
  );

  return {
    type: TalerUriAction.PayPull,
    exchangeBaseUrl,
    contractPriv,
  };
}

export function parseWithdrawExchangeUri(
  s: string,
): WithdrawExchangeUri | undefined {
  const pi = parseProtoInfo(s, "withdraw-exchange");
  if (!pi) {
    return undefined;
  }
  const c = pi?.rest.split("?");
  const parts = c[0].split("/");
  if (parts.length < 1) {
    return undefined;
  }
  const host = parts[0].toLowerCase();
  // Used to be the reserve public key, now it's empty!
  const lastPathComponent =
    parts.length > 1 ? parts[parts.length - 1] : undefined;

  if (lastPathComponent) {
    // invalid taler://withdraw-exchange URI, must end with a slash
    return undefined;
  }
  const pathSegments = parts.slice(1, parts.length - 1);
  const hostAndSegments = [host, ...pathSegments].join("/");
  const exchangeBaseUrl = canonicalizeBaseUrl(
    `${pi.innerProto}://${hostAndSegments}/`,
  );
  const q = new URLSearchParams(c[1] ?? "");
  const amount = (q.get("a") ?? undefined) as AmountString | undefined;

  return {
    type: TalerUriAction.WithdrawExchange,
    exchangeBaseUrl,
    amount,
  };
}

/**
 * Parse a taler[+http]://refund URI.
 * Return undefined if not passed a valid URI.
 */
export function parseRefundUri(s: string): RefundUriResult | undefined {
  const pi = parseProtoInfo(s, "refund");
  if (!pi) {
    return undefined;
  }
  const c = pi?.rest.split("?");
  const parts = c[0].split("/");
  if (parts.length < 3) {
    return undefined;
  }
  const host = parts[0].toLowerCase();
  const sessionId = parts[parts.length - 1];
  const orderId = parts[parts.length - 2];
  const pathSegments = parts.slice(1, parts.length - 2);
  const hostAndSegments = [host, ...pathSegments].join("/");
  const merchantBaseUrl = canonicalizeBaseUrl(
    `${pi.innerProto}://${hostAndSegments}/`,
  );

  return {
    type: TalerUriAction.Refund,
    merchantBaseUrl,
    orderId,
  };
}

export function parseDevExperimentUri(s: string): DevExperimentUri | undefined {
  const pi = parseProtoInfo(s, "dev-experiment");
  const c = pi?.rest.split("?");
  if (!c) {
    return undefined;
  }
  const parts = c[0].split("/");
  return {
    type: TalerUriAction.DevExperiment,
    devExperimentId: parts[0],
  };
}

export function parseRestoreUri(uri: string): BackupRestoreUri | undefined {
  const pi = parseProtoInfo(uri, "restore");
  if (!pi) {
    return undefined;
  }
  const c = pi.rest.split("?");
  const parts = c[0].split("/");
  if (parts.length < 2) {
    return undefined;
  }

  const walletRootPriv = parts[0];
  if (!walletRootPriv) return undefined;
  const providers = new Array<string>();
  parts[1].split(",").map((name) => {
    const url = canonicalizeBaseUrl(
      `${pi.innerProto}://${decodeURIComponent(name)}/`,
    );
    providers.push(url);
  });
  return {
    type: TalerUriAction.Restore,
    walletRootPriv,
    providers,
  };
}

// ================================================
//  To string functions
// ================================================

export function stringifyPayUri({
  merchantBaseUrl,
  orderId,
  sessionId,
  claimToken,
  noncePriv,
}: Omit<PayUriResult, "type">): string {
  const { proto, path, query } = getUrlInfo(merchantBaseUrl, {
    c: claimToken,
    n: noncePriv,
  });
  return `${proto}://pay/${path}${orderId}/${sessionId}${query}`;
}

export function stringifyPayPullUri({
  contractPriv,
  exchangeBaseUrl,
}: Omit<PayPullUriResult, "type">): string {
  const { proto, path } = getUrlInfo(exchangeBaseUrl);
  return `${proto}://pay-pull/${path}${contractPriv}`;
}

export function stringifyPayPushUri({
  contractPriv,
  exchangeBaseUrl,
}: Omit<PayPushUriResult, "type">): string {
  const { proto, path } = getUrlInfo(exchangeBaseUrl);

  return `${proto}://pay-push/${path}${contractPriv}`;
}

export function stringifyRestoreUri({
  providers,
  walletRootPriv,
}: Omit<BackupRestoreUri, "type">): string {
  const list = providers
    .map((url) => `${encodeURIComponent(new URL(url).href)}`)
    .join(",");
  return `taler://restore/${walletRootPriv}/${list}`;
}

export function stringifyWithdrawExchange({
  exchangeBaseUrl,
  amount,
}: Omit<WithdrawExchangeUri, "type">): string {
  const { proto, path, query } = getUrlInfo(exchangeBaseUrl, {
    a: amount,
  });
  return `${proto}://withdraw-exchange/${path}${query}`;
}

export function stringifyAddExchange({
  exchangeBaseUrl,
}: Omit<AddExchangeUri, "type">): string {
  const { proto, path } = getUrlInfo(exchangeBaseUrl);
  return `${proto}://add-exchange/${path}`;
}

export function stringifyDevExperimentUri({
  devExperimentId,
}: Omit<DevExperimentUri, "type">): string {
  return `taler://dev-experiment/${devExperimentId}`;
}

export function stringifyPayTemplateUri({
  merchantBaseUrl,
  templateId,
}: Omit<PayTemplateUriResult, "type">): string {
  const { proto, path, query } = getUrlInfo(merchantBaseUrl);
  return `${proto}://pay-template/${path}${templateId}${query}`;
}

export function stringifyRefundUri({
  merchantBaseUrl,
  orderId,
}: Omit<RefundUriResult, "type">): string {
  const { proto, path } = getUrlInfo(merchantBaseUrl);
  return `${proto}://refund/${path}${orderId}/`;
}

export function stringifyWithdrawUri({
  bankIntegrationApiBaseUrl,
  withdrawalOperationId,
}: Omit<WithdrawUriResult, "type">): string {
  const { proto, path } = getUrlInfo(bankIntegrationApiBaseUrl);
  return `${proto}://withdraw/${path}${withdrawalOperationId}`;
}

/**
 * Use baseUrl to defined http or https
 * create path using host+port+pathname
 * use params to create a query parameter string or empty
 */
function getUrlInfo(
  baseUrl: string,
  params: Record<string, string | undefined> = {},
): { proto: string; path: string; query: string } {
  const url = new URL(baseUrl);
  let proto: string;
  if (url.protocol === "https:") {
    proto = "taler";
  } else if (url.protocol === "http:") {
    proto = "taler+http";
  } else {
    throw Error(`Unsupported URL protocol in ${baseUrl}`);
  }
  let path = url.hostname;
  if (url.port) {
    path = path + ":" + url.port;
  }
  if (url.pathname) {
    path = path + url.pathname;
  }
  if (!path.endsWith("/")) {
    path = path + "/";
  }

  const qp = new URLSearchParams();
  let withParams = false;
  Object.entries(params).forEach(([name, value]) => {
    if (value !== undefined) {
      withParams = true;
      qp.append(name, value);
    }
  });
  const query = withParams ? "?" + qp.toString() : "";

  return { proto, path, query };
}
