/*
 This file is part of GNU Taler
 (C) 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 
 */
/**
 * Imports.
 */
import {
  AbsoluteTime,
  base64FromArrayBuffer,
  ConfirmPayResultType,
  Duration,
  IntegrationTestV2Args,
  j2s,
  Logger,
  NotificationType,
  RegisterAccountRequest,
  stringToBytes,
  TalerCorebankApiClient,
  TestPayResult,
  TransactionMajorState,
  TransactionMinorState,
  TransactionState,
  TransactionType,
  WithdrawTestBalanceRequest,
} from "@gnu-taler/taler-util";
import {
  HttpRequestLibrary,
  readSuccessResponseJsonOrThrow,
  checkSuccessResponseOrThrow,
} from "@gnu-taler/taler-util/http";
import {
  AmountString,
  codecForAny,
  CheckPaymentResponse,
  codecForCheckPaymentResponse,
  IntegrationTestArgs,
  Amounts,
  TestPayArgs,
  URL,
  PreparePayResultType,
} from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js";
import {
  confirmPay,
  preparePayForUri,
  startRefundQueryForUri,
} from "./pay-merchant.js";
import { getBalances } from "./balance.js";
import { checkLogicInvariant } from "../util/invariants.js";
import { acceptWithdrawalFromUri } from "./withdraw.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import { initiatePeerPullPayment } from "./pay-peer-pull-credit.js";
import {
  preparePeerPullDebit,
  confirmPeerPullDebit,
} from "./pay-peer-pull-debit.js";
import {
  preparePeerPushCredit,
  confirmPeerPushCredit,
} from "./pay-peer-push-credit.js";
import { initiatePeerPushDebit } from "./pay-peer-push-debit.js";
import { OpenedPromise, openPromise } from "../index.js";
import { getTransactionById, getTransactions } from "./transactions.js";
const logger = new Logger("operations/testing.ts");
interface MerchantBackendInfo {
  baseUrl: string;
  authToken?: string;
}
/**
 * Generate a random alphanumeric ID.  Does *not* use cryptographically
 * secure randomness.
 */
function makeId(length: number): string {
  let result = "";
  const characters =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  for (let i = 0; i < length; i++) {
    result += characters.charAt(Math.floor(Math.random() * characters.length));
  }
  return result;
}
export async function withdrawTestBalance(
  ws: InternalWalletState,
  req: WithdrawTestBalanceRequest,
): Promise {
  const amount = req.amount;
  const exchangeBaseUrl = req.exchangeBaseUrl;
  const corebankApiBaseUrl = req.corebankApiBaseUrl;
  logger.trace(
    `Registering bank user, bank access base url ${corebankApiBaseUrl}`,
  );
  const corebankClient = new TalerCorebankApiClient(corebankApiBaseUrl);
  const bankUser = await corebankClient.createRandomBankUser();
  logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`);
  corebankClient.setAuth(bankUser);
  const wresp = await corebankClient.createWithdrawalOperation(
    bankUser.username,
    amount,
  );
  await acceptWithdrawalFromUri(ws, {
    talerWithdrawUri: wresp.taler_withdraw_uri,
    selectedExchange: exchangeBaseUrl,
    forcedDenomSel: req.forcedDenomSel,
  });
  await corebankClient.confirmWithdrawalOperation(bankUser.username, {
    withdrawalOperationId: wresp.withdrawal_id,
  });
}
/**
 * FIXME: User MerchantApiClient instead.
 */
function getMerchantAuthHeader(m: MerchantBackendInfo): Record {
  if (m.authToken) {
    return {
      Authorization: `Bearer ${m.authToken}`,
    };
  }
  return {};
}
/**
 * FIXME: User MerchantApiClient instead.
 */
async function refund(
  http: HttpRequestLibrary,
  merchantBackend: MerchantBackendInfo,
  orderId: string,
  reason: string,
  refundAmount: string,
): Promise {
  const reqUrl = new URL(
    `private/orders/${orderId}/refund`,
    merchantBackend.baseUrl,
  );
  const refundReq = {
    order_id: orderId,
    reason,
    refund: refundAmount,
  };
  const resp = await http.postJson(reqUrl.href, refundReq, {
    headers: getMerchantAuthHeader(merchantBackend),
  });
  const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
  const refundUri = r.taler_refund_uri;
  if (!refundUri) {
    throw Error("no refund URI in response");
  }
  return refundUri;
}
/**
 * FIXME: User MerchantApiClient instead.
 */
async function createOrder(
  http: HttpRequestLibrary,
  merchantBackend: MerchantBackendInfo,
  amount: string,
  summary: string,
  fulfillmentUrl: string,
): Promise<{ orderId: string }> {
  const t = Math.floor(new Date().getTime() / 1000) + 15 * 60;
  const reqUrl = new URL("private/orders", merchantBackend.baseUrl).href;
  const orderReq = {
    order: {
      amount,
      summary,
      fulfillment_url: fulfillmentUrl,
      refund_deadline: { t_s: t },
      wire_transfer_deadline: { t_s: t },
    },
  };
  const resp = await http.postJson(reqUrl, orderReq, {
    headers: getMerchantAuthHeader(merchantBackend),
  });
  const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
  const orderId = r.order_id;
  if (!orderId) {
    throw Error("no order id in response");
  }
  return { orderId };
}
/**
 * FIXME: User MerchantApiClient instead.
 */
async function checkPayment(
  http: HttpRequestLibrary,
  merchantBackend: MerchantBackendInfo,
  orderId: string,
): Promise {
  const reqUrl = new URL(`private/orders/${orderId}`, merchantBackend.baseUrl);
  reqUrl.searchParams.set("order_id", orderId);
  const resp = await http.fetch(reqUrl.href, {
    headers: getMerchantAuthHeader(merchantBackend),
  });
  return readSuccessResponseJsonOrThrow(resp, codecForCheckPaymentResponse());
}
async function makePayment(
  ws: InternalWalletState,
  merchant: MerchantBackendInfo,
  amount: string,
  summary: string,
): Promise<{ orderId: string }> {
  const orderResp = await createOrder(
    ws.http,
    merchant,
    amount,
    summary,
    "taler://fulfillment-success/thx",
  );
  logger.trace("created order with orderId", orderResp.orderId);
  let paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
  logger.trace("payment status", paymentStatus);
  const talerPayUri = paymentStatus.taler_pay_uri;
  if (!talerPayUri) {
    throw Error("no taler://pay/ URI in payment response");
  }
  const preparePayResult = await preparePayForUri(ws, talerPayUri);
  logger.trace("prepare pay result", preparePayResult);
  if (preparePayResult.status != "payment-possible") {
    throw Error("payment not possible");
  }
  const confirmPayResult = await confirmPay(
    ws,
    preparePayResult.proposalId,
    undefined,
  );
  logger.trace("confirmPayResult", confirmPayResult);
  paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
  logger.trace("payment status after wallet payment:", paymentStatus);
  if (paymentStatus.order_status !== "paid") {
    throw Error("payment did not succeed");
  }
  return {
    orderId: orderResp.orderId,
  };
}
export async function runIntegrationTest(
  ws: InternalWalletState,
  args: IntegrationTestArgs,
): Promise {
  logger.info("running test with arguments", args);
  const parsedSpendAmount = Amounts.parseOrThrow(args.amountToSpend);
  const currency = parsedSpendAmount.currency;
  logger.info("withdrawing test balance");
  await withdrawTestBalance(ws, {
    amount: args.amountToWithdraw,
    corebankApiBaseUrl: args.corebankApiBaseUrl,
    exchangeBaseUrl: args.exchangeBaseUrl,
  });
  await waitUntilDone(ws);
  logger.info("done withdrawing test balance");
  const balance = await getBalances(ws);
  logger.trace(JSON.stringify(balance, null, 2));
  const myMerchant: MerchantBackendInfo = {
    baseUrl: args.merchantBaseUrl,
    authToken: args.merchantAuthToken,
  };
  await makePayment(ws, myMerchant, args.amountToSpend, "hello world");
  // Wait until the refresh is done
  await waitUntilDone(ws);
  logger.trace("withdrawing test balance for refund");
  const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
  const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
  const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
  const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
  await withdrawTestBalance(ws, {
    amount: Amounts.stringify(withdrawAmountTwo),
    corebankApiBaseUrl: args.corebankApiBaseUrl,
    exchangeBaseUrl: args.exchangeBaseUrl,
  });
  // Wait until the withdraw is done
  await waitUntilDone(ws);
  const { orderId: refundOrderId } = await makePayment(
    ws,
    myMerchant,
    Amounts.stringify(spendAmountTwo),
    "order that will be refunded",
  );
  const refundUri = await refund(
    ws.http,
    myMerchant,
    refundOrderId,
    "test refund",
    Amounts.stringify(refundAmount),
  );
  logger.trace("refund URI", refundUri);
  await startRefundQueryForUri(ws, refundUri);
  logger.trace("integration test: applied refund");
  // Wait until the refund is done
  await waitUntilDone(ws);
  logger.trace("integration test: making payment after refund");
  await makePayment(
    ws,
    myMerchant,
    Amounts.stringify(spendAmountThree),
    "payment after refund",
  );
  logger.trace("integration test: make payment done");
  await waitUntilDone(ws);
  logger.trace("integration test: all done!");
}
export async function waitUntilDone(ws: InternalWalletState): Promise {
  logger.info("waiting until all transactions are in a final state");
  ws.ensureTaskLoopRunning();
  let p: OpenedPromise | undefined = undefined;
  const cancelNotifs = ws.addNotificationListener((notif) => {
    if (!p) {
      return;
    }
    if (notif.type === NotificationType.TransactionStateTransition) {
      switch (notif.newTxState.major) {
        case TransactionMajorState.Pending:
        case TransactionMajorState.Aborting:
          break;
        default:
          p.resolve();
      }
    }
  });
  while (1) {
    p = openPromise();
    const txs = await getTransactions(ws, {
      includeRefreshes: true,
      filterByState: "nonfinal",
    });
    let finished = true;
    for (const tx of txs.transactions) {
      switch (tx.txState.major) {
        case TransactionMajorState.Pending:
        case TransactionMajorState.Aborting:
        case TransactionMajorState.Suspended:
        case TransactionMajorState.SuspendedAborting:
          finished = false;
          logger.info(
            `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
          );
          break;
      }
    }
    if (finished) {
      break;
    }
    // Wait until transaction state changed
    await p.promise;
  }
  cancelNotifs();
  logger.info("done waiting until all transactions are in a final state");
}
export async function waitUntilRefreshesDone(
  ws: InternalWalletState,
): Promise {
  logger.info("waiting until all refresh transactions are in a final state");
  ws.ensureTaskLoopRunning();
  let p: OpenedPromise | undefined = undefined;
  const cancelNotifs = ws.addNotificationListener((notif) => {
    if (!p) {
      return;
    }
    if (notif.type === NotificationType.TransactionStateTransition) {
      switch (notif.newTxState.major) {
        case TransactionMajorState.Pending:
        case TransactionMajorState.Aborting:
          break;
        default:
          p.resolve();
      }
    }
  });
  while (1) {
    p = openPromise();
    const txs = await getTransactions(ws, {
      includeRefreshes: true,
      filterByState: "nonfinal",
    });
    let finished = true;
    for (const tx of txs.transactions) {
      if (tx.type !== TransactionType.Refresh) {
        continue;
      }
      switch (tx.txState.major) {
        case TransactionMajorState.Pending:
        case TransactionMajorState.Aborting:
        case TransactionMajorState.Suspended:
        case TransactionMajorState.SuspendedAborting:
          finished = false;
          logger.info(
            `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
          );
          break;
      }
    }
    if (finished) {
      break;
    }
    // Wait until transaction state changed
    await p.promise;
  }
  cancelNotifs();
  logger.info("done waiting until all refreshes are in a final state");
}
async function waitUntilPendingReady(
  ws: InternalWalletState,
  transactionId: string,
): Promise {
  logger.info(`starting waiting for ${transactionId} to be in pending(ready)`);
  ws.ensureTaskLoopRunning();
  let p: OpenedPromise | undefined = undefined;
  const cancelNotifs = ws.addNotificationListener((notif) => {
    if (!p) {
      return;
    }
    if (notif.type === NotificationType.TransactionStateTransition) {
      p.resolve();
    }
  });
  while (1) {
    p = openPromise();
    const tx = await getTransactionById(ws, {
      transactionId,
    });
    if (
      tx.txState.major == TransactionMajorState.Pending &&
      tx.txState.minor === TransactionMinorState.Ready
    ) {
      break;
    }
    // Wait until transaction state changed
    await p.promise;
  }
  logger.info(`done waiting for ${transactionId} to be in pending(ready)`);
  cancelNotifs();
}
/**
 * Wait until a transaction is in a particular state.
 */
export async function waitTransactionState(
  ws: InternalWalletState,
  transactionId: string,
  txState: TransactionState,
): Promise {
  logger.info(
    `starting waiting for ${transactionId} to be in ${JSON.stringify(
      txState,
    )})`,
  );
  ws.ensureTaskLoopRunning();
  let p: OpenedPromise | undefined = undefined;
  const cancelNotifs = ws.addNotificationListener((notif) => {
    if (!p) {
      return;
    }
    if (notif.type === NotificationType.TransactionStateTransition) {
      p.resolve();
    }
  });
  while (1) {
    p = openPromise();
    const tx = await getTransactionById(ws, {
      transactionId,
    });
    if (
      tx.txState.major === txState.major &&
      tx.txState.minor === txState.minor
    ) {
      break;
    }
    // Wait until transaction state changed
    await p.promise;
  }
  logger.info(
    `done waiting for ${transactionId} to be in ${JSON.stringify(txState)}`,
  );
  cancelNotifs();
}
export async function runIntegrationTest2(
  ws: InternalWalletState,
  args: IntegrationTestV2Args,
): Promise {
  // FIXME: Make sure that a task look is running, since we're
  // waiting for notifications.
  logger.info("running test with arguments", args);
  const exchangeInfo = await updateExchangeFromUrl(ws, args.exchangeBaseUrl);
  const currency = exchangeInfo.exchangeDetails.currency;
  const amountToWithdraw = Amounts.parseOrThrow(`${currency}:10`);
  const amountToSpend = Amounts.parseOrThrow(`${currency}:2`);
  logger.info("withdrawing test balance");
  await withdrawTestBalance(ws, {
    amount: Amounts.stringify(amountToWithdraw),
    corebankApiBaseUrl: args.corebankApiBaseUrl,
    exchangeBaseUrl: args.exchangeBaseUrl,
  });
  await waitUntilDone(ws);
  logger.info("done withdrawing test balance");
  const balance = await getBalances(ws);
  logger.trace(JSON.stringify(balance, null, 2));
  const myMerchant: MerchantBackendInfo = {
    baseUrl: args.merchantBaseUrl,
    authToken: args.merchantAuthToken,
  };
  await makePayment(
    ws,
    myMerchant,
    Amounts.stringify(amountToSpend),
    "hello world",
  );
  // Wait until the refresh is done
  await waitUntilDone(ws);
  logger.trace("withdrawing test balance for refund");
  const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
  const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
  const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
  const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
  await withdrawTestBalance(ws, {
    amount: Amounts.stringify(withdrawAmountTwo),
    corebankApiBaseUrl: args.corebankApiBaseUrl,
    exchangeBaseUrl: args.exchangeBaseUrl,
  });
  // Wait until the withdraw is done
  await waitUntilDone(ws);
  const { orderId: refundOrderId } = await makePayment(
    ws,
    myMerchant,
    Amounts.stringify(spendAmountTwo),
    "order that will be refunded",
  );
  const refundUri = await refund(
    ws.http,
    myMerchant,
    refundOrderId,
    "test refund",
    Amounts.stringify(refundAmount),
  );
  logger.trace("refund URI", refundUri);
  await startRefundQueryForUri(ws, refundUri);
  logger.trace("integration test: applied refund");
  // Wait until the refund is done
  await waitUntilDone(ws);
  logger.trace("integration test: making payment after refund");
  await makePayment(
    ws,
    myMerchant,
    Amounts.stringify(spendAmountThree),
    "payment after refund",
  );
  logger.trace("integration test: make payment done");
  await waitUntilDone(ws);
  const peerPushInit = await initiatePeerPushDebit(ws, {
    partialContractTerms: {
      amount: `${currency}:1`,
      summary: "Payment Peer Push Test",
      purse_expiration: AbsoluteTime.toProtocolTimestamp(
        AbsoluteTime.addDuration(
          AbsoluteTime.now(),
          Duration.fromSpec({ hours: 1 }),
        ),
      ),
    },
  });
  await waitUntilPendingReady(ws, peerPushInit.transactionId);
  const peerPushCredit = await preparePeerPushCredit(ws, {
    talerUri: peerPushInit.talerUri,
  });
  await confirmPeerPushCredit(ws, {
    peerPushCreditId: peerPushCredit.peerPushCreditId,
  });
  const peerPullInit = await initiatePeerPullPayment(ws, {
    partialContractTerms: {
      amount: `${currency}:1`,
      summary: "Payment Peer Pull Test",
      purse_expiration: AbsoluteTime.toProtocolTimestamp(
        AbsoluteTime.addDuration(
          AbsoluteTime.now(),
          Duration.fromSpec({ hours: 1 }),
        ),
      ),
    },
  });
  await waitUntilPendingReady(ws, peerPullInit.transactionId);
  const peerPullInc = await preparePeerPullDebit(ws, {
    talerUri: peerPullInit.talerUri,
  });
  await confirmPeerPullDebit(ws, {
    peerPullDebitId: peerPullInc.peerPullDebitId,
  });
  await waitUntilDone(ws);
  logger.trace("integration test: all done!");
}
export async function testPay(
  ws: InternalWalletState,
  args: TestPayArgs,
): Promise {
  logger.trace("creating order");
  const merchant = {
    authToken: args.merchantAuthToken,
    baseUrl: args.merchantBaseUrl,
  };
  const orderResp = await createOrder(
    ws.http,
    merchant,
    args.amount,
    args.summary,
    "taler://fulfillment-success/thank+you",
  );
  logger.trace("created new order with order ID", orderResp.orderId);
  const checkPayResp = await checkPayment(ws.http, merchant, orderResp.orderId);
  const talerPayUri = checkPayResp.taler_pay_uri;
  if (!talerPayUri) {
    console.error("fatal: no taler pay URI received from backend");
    process.exit(1);
  }
  logger.trace("taler pay URI:", talerPayUri);
  const result = await preparePayForUri(ws, talerPayUri);
  if (result.status !== PreparePayResultType.PaymentPossible) {
    throw Error(`unexpected prepare pay status: ${result.status}`);
  }
  const r = await confirmPay(
    ws,
    result.proposalId,
    undefined,
    args.forcedCoinSel,
  );
  if (r.type != ConfirmPayResultType.Done) {
    throw Error("payment not done");
  }
  const purchase = await ws.db
    .mktx((x) => [x.purchases])
    .runReadOnly(async (tx) => {
      return tx.purchases.get(result.proposalId);
    });
  checkLogicInvariant(!!purchase);
  return {
    payCoinSelection: purchase.payInfo?.payCoinSelection!,
  };
}