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

/**
 * @file
 * Implementation of wallet-core operations that are used for testing,
 * but typically not in the production wallet.
 */

/**
 * Imports.
 */
import {
  AbsoluteTime,
  addPaytoQueryParams,
  Amounts,
  AmountString,
  checkLogicInvariant,
  CheckPaymentResponse,
  codecForAny,
  codecForCheckPaymentResponse,
  ConfirmPayResultType,
  Duration,
  IntegrationTestArgs,
  IntegrationTestV2Args,
  j2s,
  Logger,
  NotificationType,
  parsePaytoUri,
  PreparePayResultType,
  TalerCorebankApiClient,
  TestingWaitTransactionRequest,
  TestPayArgs,
  TestPayResult,
  TransactionIdStr,
  TransactionMajorState,
  TransactionMinorState,
  TransactionState,
  TransactionStatePattern,
  TransactionType,
  URL,
  WithdrawTestBalanceRequest,
} from "@gnu-taler/taler-util";
import {
  HttpRequestLibrary,
  readSuccessResponseJsonOrThrow,
} from "@gnu-taler/taler-util/http";
import { getBalances } from "./balance.js";
import { genericWaitForState } from "./common.js";
import { createDepositGroup } from "./deposits.js";
import { fetchFreshExchange } from "./exchanges.js";
import {
  confirmPay,
  preparePayForUri,
  startRefundQueryForUri,
} from "./pay-merchant.js";
import { initiatePeerPullPayment } from "./pay-peer-pull-credit.js";
import {
  confirmPeerPullDebit,
  preparePeerPullDebit,
} from "./pay-peer-pull-debit.js";
import {
  confirmPeerPushCredit,
  preparePeerPushCredit,
} from "./pay-peer-push-credit.js";
import { initiatePeerPushDebit } from "./pay-peer-push-debit.js";
import { getRefreshesForTransaction } from "./refresh.js";
import {
  getTransactionById,
  getTransactions,
  parseTransactionIdentifier,
} from "./transactions.js";
import type { WalletExecutionContext } from "./wallet.js";
import { acceptBankIntegratedWithdrawal } from "./withdraw.js";

const logger = new Logger("operations/testing.ts");

interface MerchantBackendInfo {
  baseUrl: string;
  authToken?: string;
}

export interface WithdrawTestBalanceResult {
  /**
   * Transaction ID of the newly created withdrawal transaction.
   */
  transactionId: TransactionIdStr;

  /**
   * Account of the user registered for the withdrawal.
   */
  accountPaytoUri: string;
}

export async function withdrawTestBalance(
  wex: WalletExecutionContext,
  req: WithdrawTestBalanceRequest,
): Promise<WithdrawTestBalanceResult> {
  wex.ws.refcntIgnoreTos += 1;
  try {
    return await withdrawTestBalanceImpl(wex, req);
  } finally {
    wex.ws.refcntIgnoreTos -= 1;
  }
}

async function withdrawTestBalanceImpl(
  wex: WalletExecutionContext,
  req: WithdrawTestBalanceRequest,
): Promise<WithdrawTestBalanceResult> {
  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,
  );

  const acceptResp = await acceptBankIntegratedWithdrawal(wex, {
    talerWithdrawUri: wresp.taler_withdraw_uri,
    selectedExchange: exchangeBaseUrl,
    forcedDenomSel: req.forcedDenomSel,
    isForeignAccount: req.useForeignAccount,
  });

  // We need to wait until the wallet sent the reserve information
  // to the bank, otherwise the confirmation in the bank would fail.
  await waitTransactionState(wex, {
    transactionId: acceptResp.transactionId,
    txState: {
      major: TransactionMajorState.Pending,
      minor: TransactionMinorState.BankConfirmTransfer,
    },
  });

  await corebankClient.confirmWithdrawalOperation(bankUser.username, {
    withdrawalOperationId: wresp.withdrawal_id,
  });

  return {
    transactionId: acceptResp.transactionId,
    accountPaytoUri: bankUser.accountPaytoUri,
  };
}

/**
 * FIXME: User TalerMerchantInstanceHttpClient instead.
 */
function getMerchantAuthHeader(m: MerchantBackendInfo): Record<string, string> {
  if (m.authToken) {
    return {
      Authorization: `Bearer ${m.authToken}`,
    };
  }
  return {};
}

/**
 * FIXME: User TalerMerchantInstanceHttpClient instead.
 */
async function refund(
  http: HttpRequestLibrary,
  merchantBackend: MerchantBackendInfo,
  orderId: string,
  reason: string,
  refundAmount: string,
): Promise<string> {
  const reqUrl = new URL(
    `private/orders/${orderId}/refund`,
    merchantBackend.baseUrl,
  );
  const refundReq = {
    order_id: orderId,
    reason,
    refund: refundAmount,
  };
  const resp = await http.fetch(reqUrl.href, {
    method: "POST",
    body: 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 TalerMerchantInstanceHttpClient 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.fetch(reqUrl, {
    method: "POST",
    body: 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 TalerMerchantInstanceHttpClient instead.
 */
async function checkPayment(
  http: HttpRequestLibrary,
  merchantBackend: MerchantBackendInfo,
  orderId: string,
): Promise<CheckPaymentResponse> {
  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());
}

interface MakePaymentResult {
  orderId: string;
  paymentTransactionId: string;
}

async function makePayment(
  wex: WalletExecutionContext,
  merchant: MerchantBackendInfo,
  amount: string,
  summary: string,
): Promise<MakePaymentResult> {
  const orderResp = await createOrder(
    wex.http,
    merchant,
    amount,
    summary,
    "taler://fulfillment-success/thx",
  );

  logger.trace("created order with orderId", orderResp.orderId);

  let paymentStatus = await checkPayment(wex.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(wex, talerPayUri);

  logger.trace("prepare pay result", preparePayResult);

  if (preparePayResult.status != "payment-possible") {
    throw Error("payment not possible");
  }

  const confirmPayResult = await confirmPay(wex, {
    transactionId: preparePayResult.transactionId,
  });

  logger.trace("confirmPayResult", confirmPayResult);

  paymentStatus = await checkPayment(wex.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,
    paymentTransactionId: preparePayResult.transactionId,
  };
}

export async function runIntegrationTest(
  wex: WalletExecutionContext,
  args: IntegrationTestArgs,
): Promise<void> {
  wex.ws.refcntIgnoreTos += 1;
  try {
    return await runIntegrationTestImpl(wex, args);
  } finally {
    wex.ws.refcntIgnoreTos -= 1;
  }
}

async function runIntegrationTestImpl(
  wex: WalletExecutionContext,
  args: IntegrationTestArgs,
): Promise<void> {
  logger.info("running test with arguments", args);

  const parsedSpendAmount = Amounts.parseOrThrow(args.amountToSpend);
  const currency = parsedSpendAmount.currency;

  logger.info("withdrawing test balance");
  const withdrawRes1 = await withdrawTestBalance(wex, {
    amount: args.amountToWithdraw,
    corebankApiBaseUrl: args.corebankApiBaseUrl,
    exchangeBaseUrl: args.exchangeBaseUrl,
  });
  await waitUntilGivenTransactionsFinal(wex, [withdrawRes1.transactionId]);
  logger.info("done withdrawing test balance");

  const balance = await getBalances(wex);

  logger.trace(JSON.stringify(balance, null, 2));

  const myMerchant: MerchantBackendInfo = {
    baseUrl: args.merchantBaseUrl,
    authToken: args.merchantAuthToken,
  };

  const makePaymentRes = await makePayment(
    wex,
    myMerchant,
    args.amountToSpend,
    "hello world",
  );

  await waitUntilTransactionWithAssociatedRefreshesFinal(
    wex,
    makePaymentRes.paymentTransactionId,
  );

  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`);

  const withdrawRes2 = await withdrawTestBalance(wex, {
    amount: Amounts.stringify(withdrawAmountTwo),
    corebankApiBaseUrl: args.corebankApiBaseUrl,
    exchangeBaseUrl: args.exchangeBaseUrl,
  });

  await waitUntilGivenTransactionsFinal(wex, [withdrawRes2.transactionId]);

  const { orderId: refundOrderId } = await makePayment(
    wex,
    myMerchant,
    Amounts.stringify(spendAmountTwo),
    "order that will be refunded",
  );

  const refundUri = await refund(
    wex.http,
    myMerchant,
    refundOrderId,
    "test refund",
    Amounts.stringify(refundAmount),
  );

  logger.trace("refund URI", refundUri);

  const refundResp = await startRefundQueryForUri(wex, refundUri);

  logger.trace("integration test: applied refund");

  // Wait until the refund is done
  await waitUntilTransactionWithAssociatedRefreshesFinal(
    wex,
    refundResp.transactionId,
  );

  logger.trace("integration test: making payment after refund");

  const paymentResp2 = await makePayment(
    wex,
    myMerchant,
    Amounts.stringify(spendAmountThree),
    "payment after refund",
  );

  logger.trace("integration test: make payment done");

  await waitUntilGivenTransactionsFinal(wex, [
    paymentResp2.paymentTransactionId,
  ]);
  await waitUntilGivenTransactionsFinal(
    wex,
    await getRefreshesForTransaction(wex, paymentResp2.paymentTransactionId),
  );

  logger.trace("integration test: all done!");
}

/**
 * Wait until all transactions are in a final state.
 */
export async function waitUntilAllTransactionsFinal(
  wex: WalletExecutionContext,
): Promise<void> {
  logger.info("waiting until all transactions are in a final state");
  await wex.taskScheduler.ensureRunning();
  await genericWaitForState(wex, {
    filterNotification(notif) {
      if (notif.type !== NotificationType.TransactionStateTransition) {
        return false;
      }
      switch (notif.newTxState.major) {
        case TransactionMajorState.Pending:
        case TransactionMajorState.Aborting:
        case TransactionMajorState.Finalizing:
          return false;
        default:
          return true;
      }
    },
    async checkState() {
      const txs = await getTransactions(wex, {
        includeRefreshes: true,
        filterByState: "nonfinal",
      });
      for (const tx of txs.transactions) {
        switch (tx.txState.major) {
          case TransactionMajorState.Pending:
          case TransactionMajorState.Aborting:
          case TransactionMajorState.Finalizing:
          case TransactionMajorState.Suspended:
          case TransactionMajorState.SuspendedAborting:
            logger.info(
              `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
            );
            return false;
        }
      }
      return true;
    },
  });
  logger.info("done waiting until all transactions are in a final state");
}

export async function waitTasksDone(
  wex: WalletExecutionContext,
): Promise<void> {
  await genericWaitForState(wex, {
    async checkState() {
      return wex.taskScheduler.isIdle();
    },
    filterNotification(notif) {
      return notif.type === NotificationType.Idle;
    },
  });
}

/**
 * Wait until all chosen transactions are in a final state.
 */
export async function waitUntilGivenTransactionsFinal(
  wex: WalletExecutionContext,
  transactionIds: string[],
): Promise<void> {
  logger.info(
    `waiting until given ${transactionIds.length} transactions are in a final state`,
  );
  logger.info(`transaction IDs are: ${j2s(transactionIds)}`);
  if (transactionIds.length === 0) {
    return;
  }

  const txIdSet = new Set(transactionIds);

  await genericWaitForState(wex, {
    filterNotification(notif) {
      if (notif.type !== NotificationType.TransactionStateTransition) {
        return false;
      }
      if (!txIdSet.has(notif.transactionId)) {
        return false;
      }
      switch (notif.newTxState.major) {
        case TransactionMajorState.Pending:
        case TransactionMajorState.Aborting:
        case TransactionMajorState.Suspended:
        case TransactionMajorState.SuspendedAborting:
          return false;
      }
      return true;
    },
    async checkState() {
      const txs = await getTransactions(wex, {
        includeRefreshes: true,
        filterByState: "nonfinal",
      });
      for (const tx of txs.transactions) {
        if (!txIdSet.has(tx.transactionId)) {
          // Don't look at this transaction, we're not interested in it.
          continue;
        }
        switch (tx.txState.major) {
          case TransactionMajorState.Pending:
          case TransactionMajorState.Finalizing:
          case TransactionMajorState.Aborting:
          case TransactionMajorState.Suspended:
          case TransactionMajorState.SuspendedAborting:
            logger.info(
              `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
            );
            return false;
        }
      }
      // No transaction is pending, we're done waiting!
      return true;
    },
  });
  logger.info("done waiting until given transactions are in a final state");
}

export async function waitUntilRefreshesDone(
  wex: WalletExecutionContext,
): Promise<void> {
  logger.info("waiting until all refresh transactions are in a final state");

  await genericWaitForState(wex, {
    filterNotification(notif) {
      if (notif.type !== NotificationType.TransactionStateTransition) {
        return false;
      }
      switch (notif.newTxState.major) {
        case TransactionMajorState.Pending:
        case TransactionMajorState.Aborting:
          return false;
        default:
          return true;
      }
    },
    async checkState() {
      const txs = await getTransactions(wex, {
        includeRefreshes: true,
        filterByState: "nonfinal",
      });
      for (const tx of txs.transactions) {
        if (tx.type !== TransactionType.Refresh) {
          continue;
        }
        switch (tx.txState.major) {
          case TransactionMajorState.Pending:
          case TransactionMajorState.Finalizing:
          case TransactionMajorState.Aborting:
          case TransactionMajorState.Suspended:
          case TransactionMajorState.SuspendedAborting:
            logger.info(
              `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
            );
            return false;
        }
      }
      return true;
    },
  });
  logger.info("done waiting until all refreshes are in a final state");
}

async function waitUntilTransactionPendingReady(
  wex: WalletExecutionContext,
  transactionId: string,
): Promise<void> {
  return await waitTransactionState(wex, {
    transactionId: transactionId as TransactionIdStr,
    txState: {
      major: TransactionMajorState.Pending,
      minor: TransactionMinorState.Ready,
    },
  });
}

function matchState(
  st: TransactionState,
  pat: TransactionStatePattern,
): boolean {
  return (
    (pat.major === "*" || st.major === pat.major) &&
    (pat.minor === "*" || st.minor === pat.minor)
  );
}

/**
 * Wait until a transaction is in a particular state.
 */
export async function waitTransactionState(
  wex: WalletExecutionContext,
  req: TestingWaitTransactionRequest,
): Promise<void> {
  const transactionId = req.transactionId;
  const txState = req.txState;
  const logId = req.logId ?? "none";
  logger.info(
    `starting waiting for ${transactionId} to be in ${JSON.stringify(
      txState,
    )}) (start logId: ${logId})`,
  );
  let timeoutPromise;
  if (req.timeout != null) {
    const durationMs = Duration.fromSpec(req.timeout).d_ms;
    checkLogicInvariant(durationMs !== "forever");
    timeoutPromise = new Promise((resolve, reject) => {
      wex.ws.timerGroup.after(durationMs, () => {
        // Cancel the waiter.
        wex.cts?.cancel();
        reject(new Error(`waiting timed out (timeout logId: ${logId}`));
      });
    });
  } else {
    // No timeout => never resolve!
    timeoutPromise = new Promise(() => {});
  }
  const waitPromise = genericWaitForState(wex, {
    async checkState() {
      const tx = await getTransactionById(wex, {
        transactionId,
      });
      logger.info(
        `current state for ${transactionId} is ${JSON.stringify(
          tx.txState,
        )} (update logId: ${logId})`,
      );
      if (req.requireError) {
        if (tx.error == null) {
          return false;
        }
      }
      if (Array.isArray(txState)) {
        for (const myState of txState) {
          if (matchState(tx.txState, myState)) {
            return true;
          }
        }
        return false;
      } else if (typeof txState === "number") {
        return tx.stId === txState;
      } else {
        return matchState(tx.txState, txState);
      }
    },
    filterNotification: (notif) =>
      notif.type === NotificationType.TransactionStateTransition &&
      notif.transactionId === transactionId,
  });
  await Promise.race([timeoutPromise, waitPromise]);
  logger.info(
    `done waiting for ${transactionId} to be in ${JSON.stringify(
      txState,
    )} (done logId: ${logId})`,
  );
}

export async function waitUntilTransactionWithAssociatedRefreshesFinal(
  wex: WalletExecutionContext,
  transactionId: string,
): Promise<void> {
  await waitUntilGivenTransactionsFinal(wex, [transactionId]);
  await waitUntilGivenTransactionsFinal(
    wex,
    await getRefreshesForTransaction(wex, transactionId),
  );
}

export async function waitUntilTransactionFinal(
  wex: WalletExecutionContext,
  transactionId: string,
): Promise<void> {
  await waitUntilGivenTransactionsFinal(wex, [transactionId]);
}

export async function runIntegrationTest2(
  wex: WalletExecutionContext,
  args: IntegrationTestV2Args,
): Promise<void> {
  wex.ws.refcntIgnoreTos += 1;
  try {
    return await runIntegrationTest2Impl(wex, args);
  } finally {
    wex.ws.refcntIgnoreTos -= 1;
  }
}

async function runIntegrationTest2Impl(
  wex: WalletExecutionContext,
  args: IntegrationTestV2Args,
): Promise<void> {
  await wex.taskScheduler.ensureRunning();
  logger.info("running test with arguments", args);

  const exchangeInfo = await fetchFreshExchange(wex, args.exchangeBaseUrl);

  const currency = exchangeInfo.currency;

  const amountToWithdraw = Amounts.parseOrThrow(`${currency}:10`);
  const amountToSpend = Amounts.parseOrThrow(`${currency}:2`);

  logger.info("withdrawing test balance");
  const withdrawalRes = await withdrawTestBalance(wex, {
    amount: Amounts.stringify(amountToWithdraw),
    corebankApiBaseUrl: args.corebankApiBaseUrl,
    exchangeBaseUrl: args.exchangeBaseUrl,
  });
  await waitUntilTransactionFinal(wex, withdrawalRes.transactionId);
  logger.info("done withdrawing test balance");

  const balance = await getBalances(wex);

  logger.trace(JSON.stringify(balance, null, 2));

  const myMerchant: MerchantBackendInfo = {
    baseUrl: args.merchantBaseUrl,
    authToken: args.merchantAuthToken,
  };

  const makePaymentRes = await makePayment(
    wex,
    myMerchant,
    Amounts.stringify(amountToSpend),
    "hello world",
  );

  await waitUntilTransactionWithAssociatedRefreshesFinal(
    wex,
    makePaymentRes.paymentTransactionId,
  );

  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`);

  const withdrawalRes2 = await withdrawTestBalance(wex, {
    amount: Amounts.stringify(withdrawAmountTwo),
    corebankApiBaseUrl: args.corebankApiBaseUrl,
    exchangeBaseUrl: args.exchangeBaseUrl,
  });

  // Wait until the withdraw is done
  await waitUntilTransactionFinal(wex, withdrawalRes2.transactionId);

  const { orderId: refundOrderId } = await makePayment(
    wex,
    myMerchant,
    Amounts.stringify(spendAmountTwo),
    "order that will be refunded",
  );

  const refundUri = await refund(
    wex.http,
    myMerchant,
    refundOrderId,
    "test refund",
    Amounts.stringify(refundAmount),
  );

  logger.trace("refund URI", refundUri);

  const refundResp = await startRefundQueryForUri(wex, refundUri);

  logger.trace("integration test: applied refund");

  // Wait until the refund is done
  await waitUntilTransactionWithAssociatedRefreshesFinal(
    wex,
    refundResp.transactionId,
  );

  logger.trace("integration test: making payment after refund");

  const makePaymentRes2 = await makePayment(
    wex,
    myMerchant,
    Amounts.stringify(spendAmountThree),
    "payment after refund",
  );

  await waitUntilTransactionWithAssociatedRefreshesFinal(
    wex,
    makePaymentRes2.paymentTransactionId,
  );

  logger.trace("integration test: make payment done");

  const peerPushInit = await initiatePeerPushDebit(wex, {
    partialContractTerms: {
      amount: `${currency}:1` as AmountString,
      summary: "Payment Peer Push Test",
      purse_expiration: AbsoluteTime.toProtocolTimestamp(
        AbsoluteTime.addDuration(
          AbsoluteTime.now(),
          Duration.fromSpec({ hours: 1 }),
        ),
      ),
    },
  });

  await waitUntilTransactionPendingReady(wex, peerPushInit.transactionId);
  const txDetails = await getTransactionById(wex, {
    transactionId: peerPushInit.transactionId,
  });

  if (txDetails.type !== TransactionType.PeerPushDebit) {
    throw Error("internal invariant failed");
  }

  if (!txDetails.talerUri) {
    throw Error("internal invariant failed");
  }

  const peerPushCredit = await preparePeerPushCredit(wex, {
    talerUri: txDetails.talerUri,
  });

  await confirmPeerPushCredit(wex, {
    transactionId: peerPushCredit.transactionId,
  });

  const peerPullInit = await initiatePeerPullPayment(wex, {
    partialContractTerms: {
      amount: `${currency}:1` as AmountString,
      summary: "Payment Peer Pull Test",
      purse_expiration: AbsoluteTime.toProtocolTimestamp(
        AbsoluteTime.addDuration(
          AbsoluteTime.now(),
          Duration.fromSpec({ hours: 1 }),
        ),
      ),
    },
  });

  await waitUntilTransactionPendingReady(wex, peerPullInit.transactionId);

  const peerPullInc = await preparePeerPullDebit(wex, {
    talerUri: peerPullInit.talerUri,
  });

  await confirmPeerPullDebit(wex, {
    transactionId: peerPullInc.transactionId,
  });

  await waitUntilTransactionWithAssociatedRefreshesFinal(
    wex,
    peerPullInc.transactionId,
  );

  await waitUntilTransactionWithAssociatedRefreshesFinal(
    wex,
    peerPullInit.transactionId,
  );

  await waitUntilTransactionWithAssociatedRefreshesFinal(
    wex,
    peerPushCredit.transactionId,
  );

  await waitUntilTransactionWithAssociatedRefreshesFinal(
    wex,
    peerPushInit.transactionId,
  );

  let depositPayto = withdrawalRes.accountPaytoUri;

  const parsedPayto = parsePaytoUri(depositPayto);
  if (!parsedPayto) {
    throw Error("invalid payto");
  }

  // Work around libeufin-bank bug where receiver-name is missing
  if (!parsedPayto.params["receiver-name"]) {
    depositPayto = addPaytoQueryParams(depositPayto, {
      "receiver-name": "Test",
    });
  }

  await createDepositGroup(wex, {
    amount: `${currency}:5` as AmountString,
    depositPaytoUri: depositPayto,
  });

  logger.trace("integration test: all done!");
}

export async function testPay(
  wex: WalletExecutionContext,
  args: TestPayArgs,
): Promise<TestPayResult> {
  logger.trace("creating order");
  const merchant = {
    authToken: args.merchantAuthToken,
    baseUrl: args.merchantBaseUrl,
  };
  const orderResp = await createOrder(
    wex.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(
    wex.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(wex, talerPayUri);
  if (result.status !== PreparePayResultType.PaymentPossible) {
    throw Error(`unexpected prepare pay status: ${result.status}`);
  }
  const r = await confirmPay(wex, {
    transactionId: result.transactionId,
    forcedCoinSel: args.forcedCoinSel,
  });
  if (r.type != ConfirmPayResultType.Done) {
    throw Error("payment not done");
  }
  const purchase = await wex.db.runReadOnlyTx(
    { storeNames: ["purchases"] },
    async (tx) => {
      const parsedTx = parseTransactionIdentifier(r.transactionId);
      checkLogicInvariant(parsedTx?.tag === TransactionType.Payment);
      return tx.purchases.get(parsedTx.proposalId);
    },
  );
  checkLogicInvariant(!!purchase);
  return {
    numCoins: purchase.payInfo?.payCoinSelection?.coinContributions.length ?? 0,
  };
}
