/*
 This file is part of GNU Taler
 (C) 2019-2025 Taler Systems SA

 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 Implementation of Taler withdrawals, both
 * bank-integrated and manual.
 */

import {
  AbsoluteTime,
  AcceptManualWithdrawalResult,
  AcceptWithdrawalResponse,
  AgeRestriction,
  Amount,
  AmountJson,
  AmountLike,
  AmountString,
  Amounts,
  BankWithdrawDetails,
  BlindedDenominationSignature,
  CoinEnvelope,
  CoinStatus,
  ConfirmWithdrawalRequest,
  CurrencySpecification,
  DenomKeyType,
  DenomSelItem,
  DenomSelectionState,
  Duration,
  EddsaPrivateKeyString,
  ExchangeLegacyBatchWithdrawRequest,
  ExchangeLegacyWithdrawRequest,
  ExchangeListItem,
  ExchangeUpdateStatus,
  ExchangeWalletKycStatus,
  ExchangeWireAccount,
  ExchangeWithdrawRequest,
  ExchangeWithdrawResponse,
  ExchangeWithdrawalDetails,
  ForcedDenomSel,
  GetWithdrawalDetailsForAmountRequest,
  HashCode,
  HttpStatusCode,
  LibtoolVersion,
  Logger,
  NotificationType,
  ObservabilityEventType,
  PrepareBankIntegratedWithdrawalResponse,
  ScopeInfo,
  TalerBankIntegrationHttpClient,
  TalerError,
  TalerErrorCode,
  TalerErrorDetail,
  TalerPreciseTimestamp,
  TalerUriAction,
  Transaction,
  TransactionAction,
  TransactionIdStr,
  TransactionMajorState,
  TransactionMinorState,
  TransactionState,
  TransactionType,
  TransactionWithdrawal,
  URL,
  UnblindedDenominationSignature,
  WalletNotification,
  WithdrawUriInfoResponse,
  WithdrawalDetailsForAmount,
  WithdrawalExchangeAccountDetails,
  WithdrawalType,
  addPaytoQueryParams,
  assertUnreachable,
  checkAccountRestriction,
  checkDbInvariant,
  checkLogicInvariant,
  checkProtocolInvariant,
  codecForBankWithdrawalOperationPostResponse,
  codecForBankWithdrawalOperationStatus,
  codecForCashinConversionResponse,
  codecForConversionBankConfig,
  codecForExchangeLegacyWithdrawBatchResponse,
  codecForExchangeWithdrawResponse,
  codecForLegitimizationNeededResponse,
  codecForReserveStatus,
  encodeCrock,
  getErrorDetailFromException,
  getRandomBytes,
  j2s,
  makeErrorDetail,
  parsePaytoUri,
  parseTalerUri,
  parseWithdrawUri,
  succeedOrThrow,
} from "@gnu-taler/taler-util";
import {
  HttpRequestLibrary,
  HttpResponse,
  readSuccessResponseJsonOrErrorCode,
  readSuccessResponseJsonOrThrow,
  readTalerErrorResponse,
  throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
import {
  PendingTaskType,
  TaskIdStr,
  TaskRunResult,
  TaskRunResultType,
  TransactionContext,
  TransitionResult,
  TransitionResultType,
  cancelableFetch,
  cancelableLongPoll,
  constructTaskIdentifier,
  genericWaitForState,
  genericWaitForStateVal,
  makeCoinAvailable,
  makeCoinsVisible,
  requireExchangeTosAcceptedOrThrow,
  runWithClientCancellation,
} from "./common.js";
import { EddsaKeyPairStrings } from "./crypto/cryptoImplementation.js";
import {
  CoinRecord,
  CoinSourceType,
  DenominationRecord,
  DenominationVerificationStatus,
  OperationRetryRecord,
  PlanchetRecord,
  PlanchetStatus,
  WalletDbAllStoresReadOnlyTransaction,
  WalletDbHelpers,
  WalletDbReadOnlyTransaction,
  WalletDbReadWriteTransaction,
  WalletDbStoresArr,
  WalletStoresV1,
  WgInfo,
  WithdrawalGroupRecord,
  WithdrawalGroupStatus,
  WithdrawalRecordType,
  timestampAbsoluteFromDb,
  timestampPreciseFromDb,
  timestampPreciseToDb,
  timestampProtocolFromDb,
} from "./db.js";
import {
  selectForcedWithdrawalDenominations,
  selectWithdrawalDenominations,
} from "./denomSelection.js";
import {
  isCandidateWithdrawableDenom,
  isWithdrawableDenom,
} from "./denominations.js";
import {
  BalanceThresholdCheckResult,
  ExchangeDetails,
  ReadyExchangeSummary,
  checkIncomingAmountLegalUnderKycBalanceThreshold,
  fetchFreshExchange,
  getExchangePaytoUri,
  getExchangeDetailsInTx,
  getPreferredExchangeForCurrency,
  getScopeForAllExchanges,
  handleStartExchangeWalletKyc,
  listExchanges,
  lookupExchangeByUri,
  markExchangeUsed,
  startUpdateExchangeEntry,
  waitReadyExchange,
} from "./exchanges.js";
import {
  GenericKycStatusReq,
  checkWithdrawalHardLimitExceeded,
  getWithdrawalLimitInfo,
  isKycOperationDue,
  runKycCheckAlgo,
} from "./kyc.js";
import { DbAccess } from "./query.js";
import {
  BalanceEffect,
  TransitionInfo,
  applyNotifyTransition,
  constructTransactionIdentifier,
  isUnsuccessfulTransaction,
  parseTransactionIdentifier,
} from "./transactions.js";
import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js";
import { WalletExecutionContext, getDenomInfo } from "./wallet.js";

/**
 * Logger for this file.
 */
const logger = new Logger("withdraw.ts");

interface TxKycDetails {
  kycAccessToken?: string;
  kycUrl?: string;
  kycPaytoHash?: string;
}

function buildTransactionForBankIntegratedWithdraw(
  wg: WithdrawalGroupRecord,
  scopes: ScopeInfo[],
  ort: OperationRetryRecord | undefined,
  kycDetails: TxKycDetails | undefined,
): TransactionWithdrawal {
  if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
    throw Error("");
  }
  const instructedCurrency =
    wg.instructedAmount === undefined
      ? undefined
      : Amounts.currencyOf(wg.instructedAmount);
  const currency = wg.wgInfo.bankInfo.currency ?? instructedCurrency;
  checkDbInvariant(
    currency !== undefined,
    "wg uninitialized (missing currency)",
  );
  const txState = computeWithdrawalTransactionStatus(wg);

  const zero = Amounts.stringify(Amounts.zeroOfCurrency(currency));
  let txDetails: TransactionWithdrawal = {
    type: TransactionType.Withdrawal,
    txState,
    stId: wg.status,
    scopes,
    txActions: computeWithdrawalTransactionActions(wg),
    exchangeBaseUrl: wg.exchangeBaseUrl,
    amountEffective:
      isUnsuccessfulTransaction(txState) || !wg.denomsSel
        ? zero
        : Amounts.stringify(wg.denomsSel.totalCoinValue),
    amountRaw: !wg.instructedAmount
      ? zero
      : Amounts.stringify(wg.instructedAmount),
    withdrawalDetails: {
      type: WithdrawalType.TalerBankIntegrationApi,
      confirmed: wg.wgInfo.bankInfo.timestampBankConfirmed ? true : false,
      exchangeCreditAccountDetails: wg.wgInfo.exchangeCreditAccounts,
      reservePub: wg.reservePub,
      bankConfirmationUrl: wg.wgInfo.bankInfo.externalConfirmation
        ? undefined
        : wg.wgInfo.bankInfo.confirmUrl,
      externalConfirmation: wg.wgInfo.bankInfo.externalConfirmation,
      reserveIsReady:
        wg.status === WithdrawalGroupStatus.Done ||
        wg.status === WithdrawalGroupStatus.PendingReady,
    },
    kycUrl: kycDetails?.kycUrl,
    kycAccessToken: wg.kycAccessToken,
    kycPaytoHash: wg.kycPaytoHash,
    abortReason: wg.abortReason,
    failReason: wg.failReason,
    timestamp: timestampPreciseFromDb(wg.timestampStart),
    transactionId: constructTransactionIdentifier({
      tag: TransactionType.Withdrawal,
      withdrawalGroupId: wg.withdrawalGroupId,
    }),
  };
  if (ort?.lastError) {
    txDetails.error = ort.lastError;
  }
  if (kycDetails) {
    txDetails = { ...txDetails, ...kycDetails };
  }
  return txDetails;
}

function buildTransactionForManualWithdraw(
  wg: WithdrawalGroupRecord,
  exchangeDetails: ExchangeDetails | undefined,
  scopes: ScopeInfo[],
  ort: OperationRetryRecord | undefined,
  kycDetails: TxKycDetails | undefined,
): TransactionWithdrawal {
  if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual) {
    throw Error(
      `unexpected withdrawal type (got ${wg.wgInfo.withdrawalType}, expected ${WithdrawalRecordType.BankManual}`,
    );
  }

  const plainPaytoUris =
    exchangeDetails?.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];

  checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized");
  checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized");
  checkDbInvariant(wg.exchangeBaseUrl !== undefined, "wg uninitialized");
  const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
    plainPaytoUris,
    wg.reservePub,
    wg.instructedAmount,
  );

  const txState = computeWithdrawalTransactionStatus(wg);

  let txDetails: TransactionWithdrawal = {
    type: TransactionType.Withdrawal,
    stId: wg.status,
    txState,
    scopes,
    txActions: computeWithdrawalTransactionActions(wg),
    amountEffective: isUnsuccessfulTransaction(txState)
      ? Amounts.stringify(Amounts.zeroOfAmount(wg.instructedAmount))
      : Amounts.stringify(wg.denomsSel.totalCoinValue),
    amountRaw: Amounts.stringify(wg.instructedAmount),
    withdrawalDetails: {
      type: WithdrawalType.ManualTransfer,
      reservePub: wg.reservePub,
      exchangePaytoUris,
      exchangeCreditAccountDetails: wg.wgInfo.exchangeCreditAccounts,
      reserveIsReady:
        wg.status === WithdrawalGroupStatus.Done ||
        wg.status === WithdrawalGroupStatus.PendingReady,
      reserveClosingDelay: exchangeDetails?.reserveClosingDelay ?? { d_us: 0 },
    },
    kycUrl: kycDetails?.kycUrl,
    exchangeBaseUrl: wg.exchangeBaseUrl,
    timestamp: timestampPreciseFromDb(wg.timestampStart),
    transactionId: constructTransactionIdentifier({
      tag: TransactionType.Withdrawal,
      withdrawalGroupId: wg.withdrawalGroupId,
    }),
    abortReason: wg.abortReason,
    ...(ort?.lastError ? { error: ort.lastError } : {}),
  };
  if (ort?.lastError) {
    txDetails.error = ort.lastError;
  }
  if (kycDetails) {
    txDetails = { ...txDetails, ...kycDetails };
  }
  return txDetails;
}

export class WithdrawTransactionContext implements TransactionContext {
  readonly transactionId: TransactionIdStr;
  readonly taskId: TaskIdStr;

  constructor(
    public wex: WalletExecutionContext,
    public withdrawalGroupId: string,
  ) {
    this.transactionId = constructTransactionIdentifier({
      tag: TransactionType.Withdrawal,
      withdrawalGroupId,
    });
    this.taskId = constructTaskIdentifier({
      tag: PendingTaskType.Withdraw,
      withdrawalGroupId,
    });
  }

  /**
   * Get the full transaction details for the transaction.
   *
   * Returns undefined if the transaction is in a state where we do not have a
   * transaction item (e.g. if it was deleted).
   */
  async lookupFullTransaction(
    tx: WalletDbAllStoresReadOnlyTransaction,
  ): Promise<Transaction | undefined> {
    const withdrawalGroupRecord = await tx.withdrawalGroups.get(
      this.withdrawalGroupId,
    );
    if (!withdrawalGroupRecord) {
      return undefined;
    }
    const exchangeBaseUrl = withdrawalGroupRecord.exchangeBaseUrl;
    const exchangeDetails =
      exchangeBaseUrl == null
        ? undefined
        : await getExchangeDetailsInTx(tx, exchangeBaseUrl);
    const scopes = await getScopeForAllExchanges(
      tx,
      !exchangeDetails ? [] : [exchangeDetails.exchangeBaseUrl],
    );

    let kycDetails: TxKycDetails | undefined = undefined;

    if (exchangeBaseUrl) {
      switch (withdrawalGroupRecord.status) {
        case WithdrawalGroupStatus.PendingBalanceKyc:
        case WithdrawalGroupStatus.SuspendedBalanceKyc:
        case WithdrawalGroupStatus.PendingKyc:
        case WithdrawalGroupStatus.SuspendedKyc: {
          kycDetails = {
            kycAccessToken: withdrawalGroupRecord.kycAccessToken,
            kycPaytoHash: withdrawalGroupRecord.kycPaytoHash,
            kycUrl: new URL(
              `kyc-spa/${withdrawalGroupRecord.kycAccessToken}`,
              exchangeBaseUrl,
            ).href,
          };
          break;
        }
      }
    }

    const ort = await tx.operationRetries.get(this.taskId);

    switch (withdrawalGroupRecord.wgInfo.withdrawalType) {
      case WithdrawalRecordType.BankIntegrated:
        return buildTransactionForBankIntegratedWithdraw(
          withdrawalGroupRecord,
          scopes,
          ort,
          kycDetails,
        );
      case WithdrawalRecordType.BankManual:
        if (!exchangeDetails) {
          logger.warn(
            `transaction ${this.transactionId} is a manual withdrawal, but no exchange wire details found`,
          );
        }
        return buildTransactionForManualWithdraw(
          withdrawalGroupRecord,
          exchangeDetails,
          scopes,
          ort,
          kycDetails,
        );
    }
    logger.warn(
      `not returning withdrawal transaction details, withdrawal type is ${withdrawalGroupRecord.wgInfo.withdrawalType}`,
    );
    return undefined;
  }

  /**
   * Update the metadata of the transaction in the database.
   */
  async updateTransactionMeta(
    tx: WalletDbReadWriteTransaction<["withdrawalGroups", "transactionsMeta"]>,
  ): Promise<void> {
    const ctx = this;
    const wgRecord = await tx.withdrawalGroups.get(ctx.withdrawalGroupId);
    if (!wgRecord) {
      await tx.transactionsMeta.delete(ctx.transactionId);
      return;
    }

    if (!wgRecord.exchangeBaseUrl) {
      // withdrawal group is in preparation, nothing to update
      return;
    }

    switch (wgRecord.wgInfo.withdrawalType) {
      case WithdrawalRecordType.BankManual:
      case WithdrawalRecordType.BankIntegrated:
        break;
      case WithdrawalRecordType.PeerPullCredit:
      case WithdrawalRecordType.PeerPushCredit:
      case WithdrawalRecordType.Recoup:
        // These withdrawal transactions are internal/hidden.
        return;
      default:
        assertUnreachable(wgRecord.wgInfo);
    }

    let currency: string | undefined;
    if (wgRecord.rawWithdrawalAmount) {
      currency = Amounts.currencyOf(wgRecord.rawWithdrawalAmount);
    }
    if (!currency) {
      return;
    }
    await tx.transactionsMeta.put({
      transactionId: ctx.transactionId,
      status: wgRecord.status,
      timestamp: wgRecord.timestampStart,
      currency,
      exchanges: [wgRecord.exchangeBaseUrl],
    });

    // FIXME: Handle orphaned withdrawals where the p2p or recoup tx was deleted?
  }

  /**
   * Transition a withdrawal transaction.
   * Extra object stores may be accessed during the transition.
   */
  async transition<StoreNameArray extends WalletDbStoresArr = []>(
    opts: { extraStores?: StoreNameArray; transactionLabel?: string },
    f: (
      rec: WithdrawalGroupRecord | undefined,
      tx: WalletDbReadWriteTransaction<
        [
          "withdrawalGroups",
          "transactionsMeta",
          "operationRetries",
          "exchanges",
          "exchangeDetails",
          ...StoreNameArray,
        ]
      >,
    ) => Promise<TransitionResult<WithdrawalGroupRecord>>,
  ): Promise<boolean> {
    const baseStores = [
      "withdrawalGroups" as const,
      "transactionsMeta" as const,
      "operationRetries" as const,
      "exchanges" as const,
      "exchangeDetails" as const,
    ];
    const stores = opts.extraStores
      ? [...baseStores, ...opts.extraStores]
      : baseStores;

    let errorThrown: Error | undefined;
    const didTransition: boolean = await this.wex.db.runReadWriteTx(
      { storeNames: stores },
      async (tx) => {
        const wgRec = await tx.withdrawalGroups.get(this.withdrawalGroupId);
        let oldTxState: TransactionState;
        let oldStId: number;
        if (wgRec) {
          oldTxState = computeWithdrawalTransactionStatus(wgRec);
          oldStId = wgRec.status;
        } else {
          oldTxState = {
            major: TransactionMajorState.None,
          };
          oldStId = 0;
        }
        let res: TransitionResult<WithdrawalGroupRecord> | undefined;
        try {
          res = await f(wgRec, tx);
        } catch (error) {
          if (error instanceof Error) {
            errorThrown = error;
          }
          return false;
        }

        switch (res.type) {
          case TransitionResultType.Transition: {
            await tx.withdrawalGroups.put(res.rec);
            await this.updateTransactionMeta(tx);
            const newTxState = computeWithdrawalTransactionStatus(res.rec);
            applyNotifyTransition(tx.notify, this.transactionId, {
              oldTxState,
              newTxState,
              balanceEffect: res.balanceEffect,
              oldStId,
              newStId: res.rec.status,
            });
            return true;
          }
          case TransitionResultType.Delete:
            await tx.withdrawalGroups.delete(this.withdrawalGroupId);
            await this.updateTransactionMeta(tx);
            applyNotifyTransition(tx.notify, this.transactionId, {
              oldTxState,
              newTxState: {
                major: TransactionMajorState.None,
              },
              balanceEffect: BalanceEffect.Any,
              oldStId,
              newStId: -1,
            });
            return true;
          default:
            return false;
        }
      },
    );
    if (errorThrown) {
      throw errorThrown;
    }
    return didTransition;
  }

  async deleteTransaction(): Promise<void> {
    const res = await this.wex.db.runReadWriteTx(
      {
        storeNames: [
          "withdrawalGroups",
          "planchets",
          "tombstones",
          "transactionsMeta",
        ],
      },
      async (tx) => {
        return this.deleteTransactionInTx(tx);
      },
    );
  }

  async deleteTransactionInTx(
    tx: WalletDbReadWriteTransaction<
      ["withdrawalGroups", "planchets", "tombstones", "transactionsMeta"]
    >,
  ): Promise<void> {
    const notifs: WalletNotification[] = [];
    const rec = await tx.withdrawalGroups.get(this.withdrawalGroupId);
    if (!rec) {
      return;
    }
    const oldTxState = computeWithdrawalTransactionStatus(rec);
    await tx.withdrawalGroups.delete(rec.withdrawalGroupId);
    const planchets = await tx.planchets.indexes.byGroup.getAll(
      rec.withdrawalGroupId,
    );
    for (const p of planchets) {
      await tx.planchets.delete(p.coinPub);
    }
    await this.updateTransactionMeta(tx);
    notifs.push({
      type: NotificationType.TransactionStateTransition,
      transactionId: this.transactionId,
      oldTxState,
      newTxState: {
        major: TransactionMajorState.Deleted,
      },
      newStId: -1,
    });
    return;
  }

  async suspendTransaction(): Promise<void> {
    const { withdrawalGroupId } = this;
    await this.transition(
      {
        transactionLabel: "suspend-transaction-withdraw",
      },
      async (wg, _tx) => {
        if (!wg) {
          logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
          return TransitionResult.stay();
        }
        let newStatus: WithdrawalGroupStatus | undefined = undefined;
        switch (wg.status) {
          case WithdrawalGroupStatus.PendingReady:
            newStatus = WithdrawalGroupStatus.SuspendedReady;
            break;
          case WithdrawalGroupStatus.AbortingBank:
            newStatus = WithdrawalGroupStatus.SuspendedAbortingBank;
            break;
          case WithdrawalGroupStatus.PendingWaitConfirmBank:
            newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank;
            break;
          case WithdrawalGroupStatus.PendingRegisteringBank:
            newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank;
            break;
          case WithdrawalGroupStatus.PendingQueryingStatus:
            newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus;
            break;
          case WithdrawalGroupStatus.PendingKyc:
            newStatus = WithdrawalGroupStatus.SuspendedKyc;
            break;
          default:
            logger.warn(
              `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`,
            );
            return TransitionResult.stay();
        }
        wg.status = newStatus;
        return TransitionResult.transition(wg);
      },
    );
  }

  async abortTransaction(reason?: TalerErrorDetail): Promise<void> {
    const { withdrawalGroupId } = this;
    await this.transition(
      {
        transactionLabel: "abort-transaction-withdraw",
      },
      async (wg, _tx) => {
        // FIXME: When aborting a partially succeeded withdrawal,
        // we need to mark already withdrawn coins as visible.
        if (!wg) {
          logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
          return TransitionResult.stay();
        }
        let newStatus: WithdrawalGroupStatus | undefined = undefined;
        switch (wg.status) {
          case WithdrawalGroupStatus.SuspendedRegisteringBank:
          case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
          case WithdrawalGroupStatus.PendingWaitConfirmBank:
          case WithdrawalGroupStatus.PendingRegisteringBank:
            newStatus = WithdrawalGroupStatus.AbortingBank;
            break;
          case WithdrawalGroupStatus.SuspendedKyc:
          case WithdrawalGroupStatus.SuspendedQueryingStatus:
          case WithdrawalGroupStatus.SuspendedReady:
          case WithdrawalGroupStatus.PendingKyc:
          case WithdrawalGroupStatus.PendingQueryingStatus:
          case WithdrawalGroupStatus.PendingBalanceKyc:
          case WithdrawalGroupStatus.SuspendedBalanceKyc:
          case WithdrawalGroupStatus.PendingBalanceKycInit:
          case WithdrawalGroupStatus.SuspendedBalanceKycInit:
            newStatus = WithdrawalGroupStatus.AbortedExchange;
            break;
          case WithdrawalGroupStatus.PendingReady:
          case WithdrawalGroupStatus.SuspendedRedenominate:
          case WithdrawalGroupStatus.PendingRedenominate:
            newStatus = WithdrawalGroupStatus.SuspendedReady;
            break;
          case WithdrawalGroupStatus.SuspendedAbortingBank:
          case WithdrawalGroupStatus.AbortingBank:
          case WithdrawalGroupStatus.AbortedUserRefused:
            // No transition needed, but not an error
            return TransitionResult.stay();
          case WithdrawalGroupStatus.DialogProposed:
            newStatus = WithdrawalGroupStatus.AbortedUserRefused;
            break;
          case WithdrawalGroupStatus.Done:
          case WithdrawalGroupStatus.FailedBankAborted:
          case WithdrawalGroupStatus.AbortedExchange:
          case WithdrawalGroupStatus.AbortedBank:
          case WithdrawalGroupStatus.FailedAbortingBank:
          case WithdrawalGroupStatus.AbortedOtherWallet:
            // Not allowed
            throw Error("abort not allowed in current state");
          default:
            assertUnreachable(wg.status);
        }
        wg.abortReason = reason;
        wg.status = newStatus;
        return TransitionResult.transition(wg);
      },
    );
  }

  async resumeTransaction(): Promise<void> {
    const { withdrawalGroupId } = this;
    await this.transition(
      {
        transactionLabel: "resume-transaction-withdraw",
      },
      async (wg, _tx) => {
        if (!wg) {
          logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
          return TransitionResult.stay();
        }
        let newStatus: WithdrawalGroupStatus | undefined = undefined;
        switch (wg.status) {
          case WithdrawalGroupStatus.SuspendedReady:
            newStatus = WithdrawalGroupStatus.PendingReady;
            break;
          case WithdrawalGroupStatus.SuspendedAbortingBank:
            newStatus = WithdrawalGroupStatus.AbortingBank;
            break;
          case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
            newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank;
            break;
          case WithdrawalGroupStatus.SuspendedQueryingStatus:
            newStatus = WithdrawalGroupStatus.PendingQueryingStatus;
            break;
          case WithdrawalGroupStatus.SuspendedRegisteringBank:
            newStatus = WithdrawalGroupStatus.PendingRegisteringBank;
            break;
          case WithdrawalGroupStatus.SuspendedKyc:
            newStatus = WithdrawalGroupStatus.PendingKyc;
            break;
          default:
            logger.warn(
              `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`,
            );
            return TransitionResult.stay();
        }
        wg.status = newStatus;
        return TransitionResult.transition(wg);
      },
    );
  }

  async failTransaction(reason?: TalerErrorDetail): Promise<void> {
    const { withdrawalGroupId } = this;
    await this.transition(
      {
        transactionLabel: "fail-transaction-withdraw",
      },
      async (wg, _tx) => {
        if (!wg) {
          logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
          return TransitionResult.stay();
        }
        let newStatus: WithdrawalGroupStatus | undefined = undefined;
        switch (wg.status) {
          case WithdrawalGroupStatus.SuspendedAbortingBank:
          case WithdrawalGroupStatus.AbortingBank:
            newStatus = WithdrawalGroupStatus.FailedAbortingBank;
            break;
          default:
            return TransitionResult.stay();
        }
        wg.status = newStatus;
        wg.failReason = reason;
        return TransitionResult.transition(wg);
      },
    );
  }
}

/**
 * Compute the DD37 transaction state of a withdrawal transaction
 * from the database's withdrawal group record.
 */
export function computeWithdrawalTransactionStatus(
  wgRecord: WithdrawalGroupRecord,
): TransactionState {
  switch (wgRecord.status) {
    case WithdrawalGroupStatus.FailedBankAborted:
      return {
        major: TransactionMajorState.Failed,
      };
    case WithdrawalGroupStatus.PendingRedenominate: {
      // Intentionally not faithful.
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.WithdrawCoins,
      };
    }
    case WithdrawalGroupStatus.SuspendedRedenominate: {
      // Intentionally not faithful.
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.WithdrawCoins,
      };
    }
    case WithdrawalGroupStatus.Done:
      return {
        major: TransactionMajorState.Done,
      };
    case WithdrawalGroupStatus.PendingRegisteringBank:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.BankRegisterReserve,
      };
    case WithdrawalGroupStatus.PendingReady:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.WithdrawCoins,
      };
    case WithdrawalGroupStatus.PendingQueryingStatus:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.ExchangeWaitReserve,
      };
    case WithdrawalGroupStatus.PendingWaitConfirmBank:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.BankConfirmTransfer,
      };
    case WithdrawalGroupStatus.AbortingBank:
      return {
        major: TransactionMajorState.Aborting,
        minor: TransactionMinorState.Bank,
      };
    case WithdrawalGroupStatus.SuspendedAbortingBank:
      return {
        major: TransactionMajorState.SuspendedAborting,
        minor: TransactionMinorState.Bank,
      };
    case WithdrawalGroupStatus.SuspendedQueryingStatus:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.ExchangeWaitReserve,
      };
    case WithdrawalGroupStatus.SuspendedRegisteringBank:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.BankRegisterReserve,
      };
    case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.BankConfirmTransfer,
      };
    case WithdrawalGroupStatus.SuspendedReady:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.WithdrawCoins,
      };
    case WithdrawalGroupStatus.PendingKyc:
      if (!!wgRecord.kycAccessToken) {
        return {
          major: TransactionMajorState.Pending,
          minor: TransactionMinorState.KycRequired,
        };
      } else {
        return {
          major: TransactionMajorState.Pending,
          minor: TransactionMinorState.KycInit,
        };
      }
    case WithdrawalGroupStatus.SuspendedKyc:
      if (!!wgRecord.kycAccessToken) {
        return {
          major: TransactionMajorState.Suspended,
          minor: TransactionMinorState.KycRequired,
        };
      } else {
        return {
          major: TransactionMajorState.Suspended,
          minor: TransactionMinorState.KycInit,
        };
      }
    case WithdrawalGroupStatus.FailedAbortingBank:
      return {
        major: TransactionMajorState.Failed,
        minor: TransactionMinorState.AbortingBank,
      };
    case WithdrawalGroupStatus.AbortedExchange:
      return {
        major: TransactionMajorState.Aborted,
        minor: TransactionMinorState.Exchange,
      };
    case WithdrawalGroupStatus.AbortedBank:
      return {
        major: TransactionMajorState.Aborted,
        minor: TransactionMinorState.Bank,
      };
    case WithdrawalGroupStatus.AbortedUserRefused:
      return {
        major: TransactionMajorState.Aborted,
        minor: TransactionMinorState.Refused,
      };
    case WithdrawalGroupStatus.DialogProposed:
      return {
        major: TransactionMajorState.Dialog,
        minor: TransactionMinorState.Proposed,
      };
    case WithdrawalGroupStatus.AbortedOtherWallet:
      return {
        major: TransactionMajorState.Aborted,
        minor: TransactionMinorState.CompletedByOtherWallet,
      };
    case WithdrawalGroupStatus.PendingBalanceKyc:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.BalanceKycRequired,
      };
    case WithdrawalGroupStatus.SuspendedBalanceKyc:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.BalanceKycRequired,
      };
    case WithdrawalGroupStatus.PendingBalanceKycInit:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.BalanceKycInit,
      };
    case WithdrawalGroupStatus.SuspendedBalanceKycInit:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.BalanceKycInit,
      };
  }
}

/**
 * Compute DD37 transaction actions for a withdrawal transaction
 * based on the database's withdrawal group record.
 */
export function computeWithdrawalTransactionActions(
  wgRecord: WithdrawalGroupRecord,
): TransactionAction[] {
  switch (wgRecord.status) {
    case WithdrawalGroupStatus.FailedBankAborted:
      return [TransactionAction.Delete];
    case WithdrawalGroupStatus.Done:
      return [TransactionAction.Delete];
    case WithdrawalGroupStatus.PendingRegisteringBank:
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Abort,
      ];
    case WithdrawalGroupStatus.PendingReady:
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Abort,
      ];
    case WithdrawalGroupStatus.PendingQueryingStatus:
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Abort,
      ];
    case WithdrawalGroupStatus.PendingWaitConfirmBank:
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Abort,
      ];
    case WithdrawalGroupStatus.AbortingBank:
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Fail,
      ];
    case WithdrawalGroupStatus.SuspendedAbortingBank:
      return [TransactionAction.Resume, TransactionAction.Fail];
    case WithdrawalGroupStatus.SuspendedQueryingStatus:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.SuspendedRegisteringBank:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.SuspendedReady:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.SuspendedRedenominate:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.PendingRedenominate:
      return [
        TransactionAction.Suspend,
        TransactionAction.Abort,
        TransactionAction.Retry,
      ];
    case WithdrawalGroupStatus.PendingKyc:
      return [
        TransactionAction.Suspend,
        TransactionAction.Retry,
        TransactionAction.Abort,
      ];
    case WithdrawalGroupStatus.SuspendedKyc:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.FailedAbortingBank:
    case WithdrawalGroupStatus.AbortedExchange:
    case WithdrawalGroupStatus.AbortedBank:
    case WithdrawalGroupStatus.AbortedOtherWallet:
    case WithdrawalGroupStatus.AbortedUserRefused:
      return [TransactionAction.Delete];
    case WithdrawalGroupStatus.DialogProposed:
      return [TransactionAction.Abort];
    case WithdrawalGroupStatus.PendingBalanceKyc:
      return [
        TransactionAction.Suspend,
        TransactionAction.Retry,
        TransactionAction.Abort,
      ];
    case WithdrawalGroupStatus.SuspendedBalanceKyc:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case WithdrawalGroupStatus.PendingBalanceKycInit:
      return [
        TransactionAction.Suspend,
        TransactionAction.Retry,
        TransactionAction.Abort,
      ];
    case WithdrawalGroupStatus.SuspendedBalanceKycInit:
      return [TransactionAction.Resume, TransactionAction.Abort];
  }
}

async function processWithdrawalGroupRedenominate(
  ctx: WithdrawTransactionContext,
  withdrawalGroup: WithdrawalGroupRecord,
): Promise<TaskRunResult> {
  logger.trace("in processWithdrawalGroupRedenominate");
  const wex = ctx.wex;
  const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
  if (!exchangeBaseUrl) {
    throw Error("invalid state (no exchange base URL)");
  }
  await waitReadyExchange(wex, exchangeBaseUrl, {
    noBail: true,
  });
  await redenominateWithdrawal(
    wex,
    exchangeBaseUrl,
    withdrawalGroup.withdrawalGroupId,
  );
  const didTransition = await ctx.transition({}, async (rec) => {
    switch (rec?.status) {
      case WithdrawalGroupStatus.PendingRedenominate:
        break;
      default:
        return TransitionResult.stay();
    }
    rec.status = WithdrawalGroupStatus.PendingReady;
    return TransitionResult.transition(rec);
  });
  if (!didTransition) {
    return TaskRunResult.backoff();
  }
  return TaskRunResult.progress();
}

async function processWithdrawalGroupBalanceKyc(
  ctx: WithdrawTransactionContext,
  withdrawalGroup: WithdrawalGroupRecord,
): Promise<TaskRunResult> {
  const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
  if (!withdrawalGroup.denomsSel) {
    throw Error(
      "invalid state: expected withdrawal group to have denom selection",
    );
  }
  const amount = withdrawalGroup.denomsSel.totalCoinValue;
  if (!exchangeBaseUrl) {
    throw Error(
      "invalid state (expected withdrawal group to have exchange base URL)",
    );
  }
  if (!amount) {
    throw Error(
      "invalid state (expected withdrawal group to have effective withdrawal amount)",
    );
  }

  // Wait until either:
  // (a) Withdrawing became legal w.r.t. the balance threshold
  // (b) We received an access token for the KYC process
  const ret = await genericWaitForStateVal(ctx.wex, {
    async checkState(): Promise<BalanceThresholdCheckResult | undefined> {
      const checkRes = await checkIncomingAmountLegalUnderKycBalanceThreshold(
        ctx.wex,
        exchangeBaseUrl,
        amount,
      );
      logger.info(
        `balance check result for ${exchangeBaseUrl} +${amount}: ${j2s(
          checkRes,
        )}`,
      );
      if (checkRes.result === "ok") {
        return checkRes;
      }
      if (
        withdrawalGroup.status ===
          WithdrawalGroupStatus.PendingBalanceKycInit &&
        checkRes.walletKycStatus === ExchangeWalletKycStatus.Legi
      ) {
        return checkRes;
      }
      await handleStartExchangeWalletKyc(ctx.wex, {
        amount: checkRes.nextThreshold,
        exchangeBaseUrl,
      });
      return undefined;
    },
    filterNotification(notif) {
      return (
        (notif.type === NotificationType.ExchangeStateTransition &&
          notif.exchangeBaseUrl === exchangeBaseUrl) ||
        notif.type === NotificationType.BalanceChange
      );
    },
  });

  if (ret.result === "ok") {
    const transitionInfo = await ctx.transition({}, async (wg) => {
      if (!wg) {
        return TransitionResult.stay();
      }
      switch (wg.status) {
        case WithdrawalGroupStatus.PendingBalanceKyc:
        case WithdrawalGroupStatus.PendingBalanceKycInit: {
          wg.status = WithdrawalGroupStatus.PendingReady;
          return TransitionResult.transition(wg);
        }
        default: {
          return TransitionResult.stay();
        }
      }
    });
    if (transitionInfo) {
      return TaskRunResult.progress();
    } else {
      return TaskRunResult.backoff();
    }
  } else if (
    withdrawalGroup.status === WithdrawalGroupStatus.PendingBalanceKycInit &&
    ret.walletKycStatus === ExchangeWalletKycStatus.Legi
  ) {
    await ctx.transition({}, async (wg) => {
      if (!wg) {
        return TransitionResult.stay();
      }
      if (wg.status !== WithdrawalGroupStatus.PendingBalanceKycInit) {
        return TransitionResult.stay();
      }
      wg.status = WithdrawalGroupStatus.PendingBalanceKyc;
      wg.kycAccessToken = ret.walletKycAccessToken;
      delete wg.kycPaytoHash;
      return TransitionResult.transition(wg);
    });
    return TaskRunResult.progress();
  } else {
    throw Error("not reached");
  }
}

/**
 * Perform a simple transition of a withdrawal transaction
 * from one state to another.
 *
 * If the transaction is in a different state, do not do anything.
 */
async function transitionSimple(
  ctx: WithdrawTransactionContext,
  from: WithdrawalGroupStatus,
  to: WithdrawalGroupStatus,
): Promise<void> {
  await ctx.transition({}, async (rec) => {
    switch (rec?.status) {
      case from: {
        rec.status = to;
        return TransitionResult.transition(rec);
      }
    }
    return TransitionResult.stay();
  });
}

/**
 * Handle state "dialog(proposed)" for a withdrawal transaction.
 *
 * In this state, we wait for the user to confirm
 * and also monitor the state of the bank's withdrawal
 * operation.
 */
async function processWithdrawalGroupDialogProposed(
  ctx: WithdrawTransactionContext,
  withdrawalGroup: WithdrawalGroupRecord,
): Promise<TaskRunResult> {
  if (
    withdrawalGroup.wgInfo.withdrawalType !==
    WithdrawalRecordType.BankIntegrated
  ) {
    throw new Error(
      "processWithdrawalGroupDialogProposed called in unexpected state",
    );
  }

  const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri;

  const parsedUri = parseWithdrawUri(talerWithdrawUri);

  checkLogicInvariant(!!parsedUri);

  const wopid = parsedUri.withdrawalOperationId;

  const url = new URL(
    `withdrawal-operation/${wopid}`,
    parsedUri.bankIntegrationApiBaseUrl,
  );

  url.searchParams.set("old_state", "pending");

  const resp = await cancelableLongPoll(ctx.wex, url);

  switch (resp.status) {
    case HttpStatusCode.NotFound: {
      // FIXME: Further inspect the error body
      const err = await readTalerErrorResponse(resp);
      logger.warn(`withdrawal operation not found, aborting: ${j2s(err)}`);
      await transitionSimple(
        ctx,
        WithdrawalGroupStatus.DialogProposed,
        WithdrawalGroupStatus.AbortedBank,
      );
      break;
    }
    case HttpStatusCode.Ok: {
      // If the bank claims that the withdrawal operation is already
      // pending, but we're still in DialogProposed, some other wallet
      // must've completed the withdrawal, we're giving up.
      const body = await readSuccessResponseJsonOrThrow(
        resp,
        codecForBankWithdrawalOperationStatus(),
      );
      if (body.status !== "pending") {
        await transitionSimple(
          ctx,
          WithdrawalGroupStatus.DialogProposed,
          WithdrawalGroupStatus.AbortedOtherWallet,
        );
      }
      break;
    }
  }

  return TaskRunResult.longpollReturnedPending();
}

/**
 * Get information about a withdrawal from
 * a taler://withdraw URI by asking the bank.
 *
 * FIXME: Move into bank client.
 */
export async function getBankWithdrawalInfo(
  http: HttpRequestLibrary,
  talerWithdrawUri: string,
): Promise<BankWithdrawDetails> {
  const uriResult = parseWithdrawUri(talerWithdrawUri);
  if (!uriResult) {
    throw Error(`can't parse URL ${talerWithdrawUri}`);
  }

  const bankApi = new TalerBankIntegrationHttpClient(
    uriResult.bankIntegrationApiBaseUrl,
    http,
  );

  const { body: config } = await bankApi.getConfig();

  const status = succeedOrThrow(
    await bankApi.getWithdrawalOperationById(uriResult.withdrawalOperationId),
  );

  const maxAmount =
    status.max_amount === undefined
      ? undefined
      : Amounts.parseOrThrow(status.max_amount);

  let amount: AmountJson | undefined;
  let editableAmount = false;
  if (status.amount !== undefined) {
    amount = Amounts.parseOrThrow(status.amount);
  } else if (!status.no_amount_to_wallet) {
    amount =
      status.suggested_amount === undefined
        ? undefined
        : Amounts.parseOrThrow(status.suggested_amount);
    editableAmount = true;
  }

  let wireFee: AmountJson | undefined;
  if (status.card_fees) {
    wireFee = Amounts.parseOrThrow(status.card_fees);
  }

  let exchange: string | undefined = undefined;
  let editableExchange = false;
  if (status.required_exchange !== undefined) {
    exchange = status.required_exchange;
  } else {
    exchange = status.suggested_exchange;
    editableExchange = true;
  }
  return {
    operationId: uriResult.withdrawalOperationId,
    apiBaseUrl: uriResult.bankIntegrationApiBaseUrl,
    currency: config.currency,
    amount,
    wireFee,
    confirmTransferUrl: status.confirm_transfer_url,
    senderWire: status.sender_wire,
    exchange,
    editableAmount,
    editableExchange,
    maxAmount,
    wireTypes: status.wire_types,
    status: status.status,
  };
}

/**
 * Return denominations that can potentially used for a withdrawal.
 */
async function getWithdrawableDenoms(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
  currency: string,
): Promise<DenominationRecord[]> {
  return await wex.db.runReadOnlyTx(
    { storeNames: ["denominations"] },
    async (tx) => {
      return getWithdrawableDenomsTx(wex, tx, exchangeBaseUrl, currency);
    },
  );
}

export async function getWithdrawableDenomsTx(
  wex: WalletExecutionContext,
  tx: WalletDbReadOnlyTransaction<["denominations"]>,
  exchangeBaseUrl: string,
  currency: string,
): Promise<DenominationRecord[]> {
  // FIXME(https://bugs.taler.net/n/8446): Use denom groups instead of querying all denominations!
  const allDenoms =
    await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
  return allDenoms
    .filter((d) => d.currency === currency)
    .filter((d) => isWithdrawableDenom(d));
}

/**
 * Generate a planchet for a coin index in a withdrawal group.
 * Does not actually withdraw the coin yet.
 *
 * Split up so that we can parallelize the crypto, but serialize
 * the exchange requests per reserve.
 */
async function processPlanchetGenerate(
  wex: WalletExecutionContext,
  withdrawalGroup: WithdrawalGroupRecord,
  coinIdx: number,
): Promise<void> {
  checkDbInvariant(
    withdrawalGroup.denomsSel !== undefined,
    "can't process uninitialized exchange",
  );
  checkDbInvariant(
    withdrawalGroup.exchangeBaseUrl !== undefined,
    "can't get funding uri from uninitialized wg",
  );
  const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
  let planchet = await wex.db.runReadOnlyTx(
    { storeNames: ["planchets"] },
    async (tx) => {
      return tx.planchets.indexes.byGroupAndIndex.get([
        withdrawalGroup.withdrawalGroupId,
        coinIdx,
      ]);
    },
  );
  if (planchet) {
    return;
  }
  let ci = 0;
  let isSkipped = false;
  let maybeDenomPubHash: string | undefined;
  for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) {
    const d = withdrawalGroup.denomsSel.selectedDenoms[di];
    if (coinIdx >= ci && coinIdx < ci + d.count) {
      maybeDenomPubHash = d.denomPubHash;
      if (coinIdx >= ci + d.count - (d.skip ?? 0)) {
        isSkipped = true;
      }
      break;
    }
    ci += d.count;
  }
  if (isSkipped) {
    return;
  }
  if (!maybeDenomPubHash) {
    throw Error("invariant violated");
  }
  const denomPubHash = maybeDenomPubHash;

  const denom = await wex.db.runReadOnlyTx(
    { storeNames: ["denominations"] },
    async (tx) => {
      return getDenomInfo(wex, tx, exchangeBaseUrl, denomPubHash);
    },
  );
  checkDbInvariant(!!denom, `no denom info for ${denomPubHash}`);
  const r = await wex.cryptoApi.createPlanchet({
    denomPub: denom.denomPub,
    feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw),
    reservePriv: withdrawalGroup.reservePriv,
    reservePub: withdrawalGroup.reservePub,
    value: Amounts.parseOrThrow(denom.value),
    coinIndex: coinIdx,
    secretSeed: withdrawalGroup.secretSeed,
    restrictAge: withdrawalGroup.restrictAge,
  });
  const newPlanchet: PlanchetRecord = {
    blindingKey: r.blindingKey,
    coinEv: r.coinEv,
    coinEvHash: r.coinEvHash,
    coinIdx,
    coinPriv: r.coinPriv,
    coinPub: r.coinPub,
    denomPubHash: r.denomPubHash,
    planchetStatus: PlanchetStatus.Pending,
    withdrawSig: r.withdrawSig,
    withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
    ageCommitmentProof: r.ageCommitmentProof,
    lastError: undefined,
  };
  await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
    const p = await tx.planchets.indexes.byGroupAndIndex.get([
      withdrawalGroup.withdrawalGroupId,
      coinIdx,
    ]);
    if (p) {
      planchet = p;
      return;
    }
    await tx.planchets.put(newPlanchet);
    planchet = newPlanchet;
  });
}

interface WithdrawalRequestBatchArgs {
  coinStartIndex: number;

  batchSize: number;
}

interface WithdrawalBatchResult {
  coinIdxs: number[];
  batchResp: ExchangeWithdrawResponse;
}

/**
 * Transition a transaction from pending(ready)
 * into a pending(kyc|aml) state, in case KYC is required.
 */
async function transitionKycRequired(
  wex: WalletExecutionContext,
  withdrawalGroup: WithdrawalGroupRecord,
  resp: HttpResponse,
  startIdx: number,
  requestCoinIdxs: number[],
): Promise<void> {
  logger.info("withdrawal requires KYC");
  const respJson = await resp.json();
  const legiRequiredResp =
    codecForLegitimizationNeededResponse().decode(respJson);
  const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
  const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);

  await ctx.transition(
    {
      extraStores: ["planchets"],
    },
    async (wg2, tx) => {
      if (!wg2) {
        return TransitionResult.stay();
      }
      for (let i = startIdx; i < requestCoinIdxs.length; i++) {
        const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
          withdrawalGroup.withdrawalGroupId,
          requestCoinIdxs[i],
        ]);
        if (!planchet) {
          continue;
        }
        planchet.planchetStatus = PlanchetStatus.KycRequired;
        await tx.planchets.put(planchet);
      }
      switch (wg2.status) {
        case WithdrawalGroupStatus.PendingReady:
        case WithdrawalGroupStatus.PendingKyc:
          break;
        default:
          return TransitionResult.stay();
      }
      wg2.kycPaytoHash = legiRequiredResp.h_payto;
      wg2.kycLastDeny = timestampPreciseToDb(TalerPreciseTimestamp.now());
      wg2.status = WithdrawalGroupStatus.PendingKyc;
      return TransitionResult.transition(wg2);
    },
  );
}

/**
 * Send the withdrawal request for a generated planchet to the exchange.
 *
 * The verification of the response is done asynchronously to enable parallelism.
 */
async function processPlanchetExchangeLegacyBatchRequest(
  wex: WalletExecutionContext,
  wgContext: WithdrawalGroupStatusInfo,
  args: WithdrawalRequestBatchArgs,
): Promise<WithdrawalBatchResult> {
  const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord;
  logger.info(
    `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`,
  );
  checkDbInvariant(
    withdrawalGroup.exchangeBaseUrl !== undefined,
    "can't get funding uri from uninitialized wg",
  );
  const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;

  const batchReq: ExchangeLegacyBatchWithdrawRequest = { planchets: [] };
  // Indices of coins that are included in the batch request
  const requestCoinIdxs: number[] = [];

  await wex.db.runReadOnlyTx(
    { storeNames: ["planchets", "denominations"] },
    async (tx) => {
      for (
        let coinIdx = args.coinStartIndex;
        coinIdx < args.coinStartIndex + args.batchSize &&
        coinIdx < wgContext.numPlanchets;
        coinIdx++
      ) {
        const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
          withdrawalGroup.withdrawalGroupId,
          coinIdx,
        ]);
        if (!planchet) {
          continue;
        }
        if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
          logger.warn("processPlanchet: planchet already withdrawn");
          continue;
        }
        if (planchet.planchetStatus === PlanchetStatus.AbortedReplaced) {
          continue;
        }
        const denom = await getDenomInfo(
          wex,
          tx,
          exchangeBaseUrl,
          planchet.denomPubHash,
        );

        if (!denom) {
          logger.error("db inconsistent: denom for planchet not found");
          continue;
        }

        const planchetReq: ExchangeLegacyWithdrawRequest = {
          denom_pub_hash: planchet.denomPubHash,
          reserve_sig: planchet.withdrawSig,
          coin_ev: planchet.coinEv,
        };
        batchReq.planchets.push(planchetReq);
        requestCoinIdxs.push(coinIdx);
      }
    },
  );

  if (batchReq.planchets.length == 0) {
    logger.warn("empty withdrawal batch");
    return {
      batchResp: { ev_sigs: [] },
      coinIdxs: [],
    };
  }

  async function storeCoinError(
    errDetail: TalerErrorDetail,
    coinIdx: number,
  ): Promise<void> {
    logger.trace(`withdrawal request failed: ${j2s(errDetail)}`);
    await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
      const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
        withdrawalGroup.withdrawalGroupId,
        coinIdx,
      ]);
      if (!planchet) {
        return;
      }
      planchet.lastError = errDetail;
      await tx.planchets.put(planchet);
    });
  }

  // FIXME: handle individual error codes better!

  const reqUrl = new URL(
    `reserves/${withdrawalGroup.reservePub}/batch-withdraw`,
    withdrawalGroup.exchangeBaseUrl,
  );

  // if (logger.shouldLogTrace()) {
  //   logger.trace(`batch-withdraw request: ${j2s(batchReq)}`);
  // }

  try {
    const resp = await cancelableFetch(wex, reqUrl, {
      method: "POST",
      body: batchReq,
      timeout: Duration.fromSpec({ seconds: 40 }),
    });
    if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
      await transitionKycRequired(
        wex,
        withdrawalGroup,
        resp,
        0,
        requestCoinIdxs,
      );
      return {
        batchResp: { ev_sigs: [] },
        coinIdxs: [],
      };
    }
    if (resp.status === HttpStatusCode.Gone) {
      const e = await readTalerErrorResponse(resp);
      // FIXME: Store in place of the planchet that is actually affected!
      await storeCoinError(e, requestCoinIdxs[0]);
      return {
        batchResp: { ev_sigs: [] },
        coinIdxs: [],
      };
    }
    const r = await readSuccessResponseJsonOrThrow(
      resp,
      codecForExchangeLegacyWithdrawBatchResponse(),
    );
    return {
      coinIdxs: requestCoinIdxs,
      batchResp: { ev_sigs: r.ev_sigs.map((x) => x.ev_sig) },
    };
  } catch (e) {
    const errDetail = getErrorDetailFromException(e);
    // We don't know which coin is affected, so we store the error
    // with the first coin of the batch.
    await storeCoinError(errDetail, requestCoinIdxs[0]);
    return {
      batchResp: { ev_sigs: [] },
      coinIdxs: [],
    };
  }
}

/**
 * Send the withdrawal request for a generated planchet to the exchange.
 *
 * The verification of the response is done asynchronously to enable parallelism.
 */
async function processPlanchetExchangeBatchRequest(
  wex: WalletExecutionContext,
  wgContext: WithdrawalGroupStatusInfo,
  args: WithdrawalRequestBatchArgs,
): Promise<WithdrawalBatchResult> {
  const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord;
  logger.info(
    `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`,
  );
  checkDbInvariant(
    withdrawalGroup.exchangeBaseUrl !== undefined,
    "can't get funding uri from uninitialized wg",
  );
  const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
  // Indices of coins that are included in the batch request
  const requestCoinIdxs: number[] = [];
  const coinEvs: CoinEnvelope[] = [];
  const denomHashes: HashCode[] = [];
  checkDbInvariant(
    !!withdrawalGroup.instructedAmount,
    "missing instructed amount in withdrawal group",
  );
  let accAmount = Amounts.zeroOfAmount(withdrawalGroup.instructedAmount);
  let accFee = Amounts.zeroOfAmount(withdrawalGroup.instructedAmount);
  await wex.db.runReadOnlyTx(
    { storeNames: ["planchets", "denominations"] },
    async (tx) => {
      for (
        let coinIdx = args.coinStartIndex;
        coinIdx < args.coinStartIndex + args.batchSize &&
        coinIdx < wgContext.numPlanchets;
        coinIdx++
      ) {
        const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
          withdrawalGroup.withdrawalGroupId,
          coinIdx,
        ]);
        if (!planchet) {
          continue;
        }
        if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
          logger.warn("processPlanchet: planchet already withdrawn");
          continue;
        }
        if (planchet.planchetStatus === PlanchetStatus.AbortedReplaced) {
          continue;
        }
        const denom = await getDenomInfo(
          wex,
          tx,
          exchangeBaseUrl,
          planchet.denomPubHash,
        );

        if (!denom) {
          logger.error("db inconsistent: denom for planchet not found");
          continue;
        }
        accAmount = Amounts.add(accAmount, denom.value).amount;
        accFee = Amounts.add(accFee, denom.feeWithdraw).amount;
        requestCoinIdxs.push(coinIdx);
        coinEvs.push(planchet.coinEv);
        denomHashes.push(planchet.denomPubHash);
      }
    },
  );

  if (coinEvs.length == 0) {
    logger.warn("empty withdrawal batch");
    return {
      batchResp: { ev_sigs: [] },
      coinIdxs: [],
    };
  }

  async function storeCoinError(
    errDetail: TalerErrorDetail,
    coinIdx: number,
  ): Promise<void> {
    logger.trace(`withdrawal request failed: ${j2s(errDetail)}`);
    await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
      const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
        withdrawalGroup.withdrawalGroupId,
        coinIdx,
      ]);
      if (!planchet) {
        return;
      }
      planchet.lastError = errDetail;
      await tx.planchets.put(planchet);
    });
  }

  // FIXME: handle individual error codes better!

  const reqUrl = new URL(`withdraw`, withdrawalGroup.exchangeBaseUrl).href;

  const sigResp = await wex.cryptoApi.signWithdrawal({
    amount: Amounts.stringify(accAmount),
    fee: Amounts.stringify(accFee),
    coinEvs: coinEvs,
    denomsPubHashes: denomHashes,
    reservePriv: withdrawalGroup.reservePriv,
  });

  const batchReq: ExchangeWithdrawRequest = {
    cipher: "ED25519",
    reserve_pub: withdrawalGroup.reservePub,
    coin_evs: coinEvs,
    denoms_h: denomHashes,
    reserve_sig: sigResp.sig,
  };

  try {
    const resp = await wex.http.fetch(reqUrl, {
      method: "POST",
      body: batchReq,
      cancellationToken: wex.cancellationToken,
      timeout: Duration.fromSpec({ seconds: 40 }),
    });
    if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
    }
    switch (resp.status) {
      case HttpStatusCode.UnavailableForLegalReasons: {
        await transitionKycRequired(
          wex,
          withdrawalGroup,
          resp,
          0,
          requestCoinIdxs,
        );
        return {
          batchResp: { ev_sigs: [] },
          coinIdxs: [],
        };
      }
      case HttpStatusCode.Gone:
      case HttpStatusCode.InternalServerError:
      case HttpStatusCode.NotFound: {
        // The concrete handling of the error
        // happens in the caller.
        const e = await readTalerErrorResponse(resp);
        await storeCoinError(e, requestCoinIdxs[0]);
        return {
          batchResp: { ev_sigs: [] },
          coinIdxs: [],
        };
      }
    }
    const r = await readSuccessResponseJsonOrThrow(
      resp,
      codecForExchangeWithdrawResponse(),
    );
    return {
      coinIdxs: requestCoinIdxs,
      batchResp: r,
    };
  } catch (e) {
    // Network error or unexpected response.
    const errDetail = getErrorDetailFromException(e);
    // We don't know which coin is affected, so we store the error
    // with the first coin of the batch.
    await storeCoinError(errDetail, requestCoinIdxs[0]);
    return {
      batchResp: { ev_sigs: [] },
      coinIdxs: [],
    };
  }
}

async function processPlanchetVerifyAndStoreCoin(
  wex: WalletExecutionContext,
  wgContext: WithdrawalGroupStatusInfo,
  coinIdx: number,
  resp: BlindedDenominationSignature,
): Promise<void> {
  const withdrawalGroup = wgContext.wgRecord;
  checkDbInvariant(
    withdrawalGroup.exchangeBaseUrl !== undefined,
    "can't get funding uri from uninitialized wg",
  );
  const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;

  logger.trace(`checking and storing planchet idx=${coinIdx}`);
  const d = await wex.db.runReadOnlyTx(
    { storeNames: ["planchets", "denominations"] },
    async (tx) => {
      const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
        withdrawalGroup.withdrawalGroupId,
        coinIdx,
      ]);
      if (!planchet) {
        return;
      }
      if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
        logger.warn("processPlanchet: planchet already withdrawn");
        return;
      }
      const denomInfo = await getDenomInfo(
        wex,
        tx,
        exchangeBaseUrl,
        planchet.denomPubHash,
      );
      if (!denomInfo) {
        return;
      }
      return {
        planchet,
        denomInfo,
        exchangeBaseUrl: exchangeBaseUrl,
      };
    },
  );

  if (!d) {
    return;
  }

  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId,
  });

  const { planchet, denomInfo } = d;

  const planchetDenomPub = denomInfo.denomPub;
  if (planchetDenomPub.cipher !== DenomKeyType.Rsa) {
    throw Error(`cipher (${planchetDenomPub.cipher}) not supported`);
  }

  const evSig = resp;
  if (!(evSig.cipher === DenomKeyType.Rsa)) {
    throw Error("unsupported cipher");
  }

  const denomSigRsa = await wex.cryptoApi.rsaUnblind({
    bk: planchet.blindingKey,
    blindedSig: evSig.blinded_rsa_signature,
    pk: planchetDenomPub.rsa_public_key,
  });

  const rsaVerifyResp = await wex.cryptoApi.rsaVerify({
    hm: planchet.coinPub,
    pk: planchetDenomPub.rsa_public_key,
    sig: denomSigRsa.sig,
  });

  if (!rsaVerifyResp.valid) {
    await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
      const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
        withdrawalGroup.withdrawalGroupId,
        coinIdx,
      ]);
      if (!planchet) {
        return;
      }
      planchet.lastError = makeErrorDetail(
        TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
        {},
        "invalid signature from the exchange after unblinding",
      );
      await tx.planchets.put(planchet);
    });
    return;
  }

  let denomSig: UnblindedDenominationSignature;
  if (planchetDenomPub.cipher === DenomKeyType.Rsa) {
    denomSig = {
      cipher: planchetDenomPub.cipher,
      rsa_signature: denomSigRsa.sig,
    };
  } else {
    throw Error("unsupported cipher");
  }

  const coin: CoinRecord = {
    blindingKey: planchet.blindingKey,
    coinPriv: planchet.coinPriv,
    coinPub: planchet.coinPub,
    denomPubHash: planchet.denomPubHash,
    denomSig,
    coinEvHash: planchet.coinEvHash,
    exchangeBaseUrl: d.exchangeBaseUrl,
    status: CoinStatus.Fresh,
    coinSource: {
      type: CoinSourceType.Withdraw,
      coinIndex: coinIdx,
      reservePub: withdrawalGroup.reservePub,
      withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
    },
    sourceTransactionId: transactionId,
    maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
    ageCommitmentProof: planchet.ageCommitmentProof,
  };

  const planchetCoinPub = planchet.coinPub;

  wgContext.planchetsFinished.add(planchet.coinPub);

  await wex.db.runReadWriteTx(
    { storeNames: ["planchets", "coins", "coinAvailability", "denominations"] },
    async (tx) => {
      const p = await tx.planchets.get(planchetCoinPub);
      if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
        return;
      }
      p.planchetStatus = PlanchetStatus.WithdrawalDone;
      p.lastError = undefined;
      await tx.planchets.put(p);
      await makeCoinAvailable(wex, tx, coin);
    },
  );
}

async function isValidDenomRecord(
  wex: WalletExecutionContext,
  exchangeMasterPub: string,
  d: DenominationRecord,
): Promise<boolean> {
  const res = await wex.cryptoApi.isValidDenom({
    masterPub: exchangeMasterPub,
    denomPubHash: d.denomPubHash,
    feeDeposit: Amounts.parseOrThrow(d.fees.feeDeposit),
    feeRefresh: Amounts.parseOrThrow(d.fees.feeRefresh),
    feeRefund: Amounts.parseOrThrow(d.fees.feeRefund),
    feeWithdraw: Amounts.parseOrThrow(d.fees.feeWithdraw),
    masterSig: d.masterSig,
    stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
    stampExpireLegal: timestampProtocolFromDb(d.stampExpireLegal),
    stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
    stampStart: timestampProtocolFromDb(d.stampStart),
    value: Amounts.parseOrThrow(d.value),
  });
  return res.valid;
}

/**
 * Make sure that denominations that currently can be used for withdrawal
 * are validated, and the result of validation is stored in the database.
 */
export async function updateWithdrawalDenomsForCurrency(
  wex: WalletExecutionContext,
  currency: string,
): Promise<void> {
  const res = await wex.db.runReadOnlyTx(
    { storeNames: ["exchanges", "exchangeDetails", "denominations"] },
    async (tx) => {
      return await tx.exchanges.getAll();
    },
  );
  for (const exch of res) {
    if (exch.detailsPointer?.currency === currency) {
      await updateWithdrawalDenomsForExchange(wex, exch.baseUrl);
    }
  }
}

/**
 * Make sure that denominations that currently can be used for withdrawal
 * are validated, and the result of validation is stored in the database.
 */
export async function updateWithdrawalDenomsForExchange(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
): Promise<void> {
  logger.trace(
    `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
  );

  const res = await wex.db.runReadOnlyTx(
    { storeNames: ["exchanges", "exchangeDetails", "denominations"] },
    async (tx) => {
      const exchangeDetails = await getExchangeDetailsInTx(
        tx,
        exchangeBaseUrl,
      );
      let denominations: DenominationRecord[] | undefined = [];
      if (exchangeDetails) {
        // FIXME: Use denom groups to speed this up.
        const allDenoms =
          await tx.denominations.indexes.byExchangeBaseUrl.getAll(
            exchangeBaseUrl,
          );
        denominations = allDenoms
          .filter((d) => d.currency === exchangeDetails.currency)
          .filter((d) => isCandidateWithdrawableDenom(d));
      }
      return { exchangeDetails, denominations };
    },
  );

  const exchangeDetails = res.exchangeDetails;

  if (!exchangeDetails) {
    logger.error("exchange details not available");
    return;
  }
  // First do a pass where the validity of candidate denominations
  // is checked and the result is stored in the database.
  const denominations = res.denominations;
  logger.trace(`got ${denominations.length} candidate denominations`);
  const batchSize = 500;
  let current = 0;

  while (current < denominations.length) {
    const updatedDenominations: DenominationRecord[] = [];
    // Do a batch of batchSize
    for (
      let batchIdx = 0;
      batchIdx < batchSize && current < denominations.length;
      batchIdx++, current++
    ) {
      const denom = denominations[current];
      if (
        denom.verificationStatus === DenominationVerificationStatus.Unverified
      ) {
        logger.trace(
          `Validating denomination (${current + 1}/${
            denominations.length
          }) signature of ${denom.denomPubHash}`,
        );
        let valid = false;
        if (wex.ws.config.testing.insecureTrustExchange) {
          valid = true;
        } else {
          valid = await isValidDenomRecord(
            wex,
            exchangeDetails.masterPublicKey,
            denom,
          );
        }
        logger.trace(`Done validating ${denom.denomPubHash}`);
        if (!valid) {
          logger.warn(
            `Signature check for denomination h=${denom.denomPubHash} failed`,
          );
          denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
        } else {
          denom.verificationStatus =
            DenominationVerificationStatus.VerifiedGood;
        }
        updatedDenominations.push(denom);
      }
    }
    if (updatedDenominations.length > 0) {
      logger.trace("writing denomination batch to db");
      await wex.db.runReadWriteTx(
        { storeNames: ["denominations"] },
        async (tx) => {
          for (let i = 0; i < updatedDenominations.length; i++) {
            const denom = updatedDenominations[i];
            await tx.denominations.put(denom);
          }
        },
      );
      wex.ws.denomInfoCache.clear();
      logger.trace("done with DB write");
    }
  }
}

/**
 * Update the information about a reserve that is stored in the wallet
 * by querying the reserve's exchange.
 *
 * If the reserve have funds that are not allocated in a withdrawal group yet
 * and are big enough to withdraw with available denominations,
 * create a new withdrawal group for the remaining amount.
 */
async function processQueryReserve(
  wex: WalletExecutionContext,
  withdrawalGroupId: string,
): Promise<TaskRunResult> {
  const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
  const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
    withdrawalGroupId,
  });
  if (!withdrawalGroup) {
    return TaskRunResult.finished();
  }
  if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
    return TaskRunResult.backoff();
  }
  checkDbInvariant(
    withdrawalGroup.exchangeBaseUrl !== undefined,
    "can't get funding uri from uninitialized wg",
  );
  checkDbInvariant(
    withdrawalGroup.denomsSel !== undefined,
    "can't process uninitialized exchange",
  );
  checkDbInvariant(
    withdrawalGroup.instructedAmount !== undefined,
    "can't process uninitialized exchange",
  );

  const reservePub = withdrawalGroup.reservePub;

  const reserveUrl = new URL(
    `reserves/${reservePub}`,
    withdrawalGroup.exchangeBaseUrl,
  );

  logger.trace(`querying reserve status via ${reserveUrl.href}`);
  const resp = await cancelableLongPoll(wex, reserveUrl, {
    timeout: getReserveRequestTimeout(withdrawalGroup),
  });

  logger.trace(`reserve status code: HTTP ${resp.status}`);

  const result = await readSuccessResponseJsonOrErrorCode(
    resp,
    codecForReserveStatus(),
  );

  if (result.isError) {
    logger.trace(
      `got reserve status error, EC=${result.talerErrorResponse.code}`,
    );
    if (resp.status === HttpStatusCode.NotFound) {
      return TaskRunResult.longpollReturnedPending();
    } else {
      throwUnexpectedRequestError(resp, result.talerErrorResponse);
    }
  }

  logger.trace(`got reserve status ${j2s(result.response)}`);

  // We only allow changing the amount *down*, so that user error
  // in the wire transfer won't result in a giant withdrawal.
  // See https://bugs.taler.net/n/9732
  // We also re-select when the initial selection had zero coins
  // (skipped denoms are not counted).
  let amountChanged =
    Amounts.cmp(
      result.response.balance,
      withdrawalGroup.denomsSel.totalWithdrawCost,
    ) === -1;

  let numActiveDenoms = 0;
  for (const sd of withdrawalGroup.denomsSel.selectedDenoms) {
    numActiveDenoms += sd.count - (sd.skip ?? 0);
  }
  const selectionBad = numActiveDenoms > 0;

  if (amountChanged || selectionBad) {
    // If we change the denom selection, make sure we have
    // fresh info about the exchange.
    await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl, {
      forceUpdate: selectionBad,
    });
    await updateWithdrawalDenomsForExchange(
      wex,
      withdrawalGroup.exchangeBaseUrl,
    );
  }

  const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
  const currency = Amounts.currencyOf(withdrawalGroup.instructedAmount);

  const transitionResult = await ctx.transition(
    {
      extraStores: ["denominations", "bankAccountsV2", "planchets"],
    },
    async (wg, tx) => {
      if (!wg) {
        logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
        return TransitionResult.stay();
      }
      if (wg.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
        return TransitionResult.stay();
      }
      const lastOrigin = result.response.last_origin;
      // If the withdrawal had external confirmation, we don't store the
      // bank account details learned via the reserve here.
      const externalConfirmation =
        wg.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated &&
        wg.wgInfo.bankInfo.externalConfirmation;
      if (lastOrigin != null && !externalConfirmation) {
        await storeKnownBankAccount(tx, currency, lastOrigin);
      }
      if (amountChanged) {
        const planchetKeys = await tx.planchets.indexes.byGroup.getAllKeys(
          wg.withdrawalGroupId,
        );
        for (const pk of planchetKeys) {
          await tx.planchets.delete(pk);
        }
        const candidates = await getWithdrawableDenomsTx(
          wex,
          tx,
          exchangeBaseUrl,
          currency,
        );
        const denomsSel = selectWithdrawalDenominations(
          Amounts.parseOrThrow(result.response.balance),
          candidates,
        );
        wg.denomsSel = denomsSel;
        wg.rawWithdrawalAmount = denomsSel.totalWithdrawCost;
        wg.effectiveWithdrawalAmount = denomsSel.totalCoinValue;
      }
      wg.status = WithdrawalGroupStatus.PendingReady;
      wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
      return TransitionResult.transition(wg);
    },
  );

  if (transitionResult) {
    return TaskRunResult.progress();
  } else {
    return TaskRunResult.backoff();
  }
}

/**
 * Withdrawal context that is kept in-memory.
 *
 * Used to store some cached info during a withdrawal operation.
 */
interface WithdrawalGroupStatusInfo {
  numPlanchets: number;
  planchetsFinished: Set<string>;

  /**
   * Cached withdrawal group record from the database.
   */
  wgRecord: WithdrawalGroupRecord;
}

async function processWithdrawalGroupAbortingBank(
  wex: WalletExecutionContext,
  withdrawalGroup: WithdrawalGroupRecord,
): Promise<TaskRunResult> {
  const { withdrawalGroupId } = withdrawalGroup;
  const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
  const wgInfo = withdrawalGroup.wgInfo;
  if (wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated) {
    throw Error("invalid state (aborting(bank) without bank info");
  }
  const abortUrl = getBankAbortUrl(wgInfo.bankInfo.talerWithdrawUri);
  logger.info(`aborting withdrawal at ${abortUrl}`);
  const abortResp = await cancelableFetch(wex, abortUrl, {
    method: "POST",
    body: {},
  });
  logger.info(`abort response status: ${abortResp.status}`);

  await ctx.transition({}, async (wg) => {
    if (!wg) {
      return TransitionResult.stay();
    }
    wg.status = WithdrawalGroupStatus.AbortedBank;
    wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
    return TransitionResult.transition(wg);
  });
  return TaskRunResult.finished();
}

async function processWithdrawalGroupPendingKyc(
  wex: WalletExecutionContext,
  withdrawalGroup: WithdrawalGroupRecord,
): Promise<TaskRunResult> {
  const ctx = new WithdrawTransactionContext(
    wex,
    withdrawalGroup.withdrawalGroupId,
  );
  const kycPaytoHash = withdrawalGroup.kycPaytoHash;
  if (!kycPaytoHash) {
    throw Error("no kyc info available in pending(kyc)");
  }

  const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
  checkDbInvariant(
    !!exchangeBaseUrl,
    "exchange base URL must be known for KYC",
  );
  const accountPub = withdrawalGroup.reservePub;
  const accountPriv = withdrawalGroup.reservePriv;

  let myKycState: GenericKycStatusReq | undefined;

  const amount = withdrawalGroup.rawWithdrawalAmount;
  checkDbInvariant(!!amount, "amount must be known for KYC");

  if (withdrawalGroup.kycPaytoHash) {
    myKycState = {
      accountPriv,
      accountPub,
      amount,
      exchangeBaseUrl,
      operation: "WITHDRAW",
      paytoHash: withdrawalGroup.kycPaytoHash,
      lastAmlReview: withdrawalGroup.kycLastAmlReview,
      lastCheckCode: withdrawalGroup.kycLastCheckCode,
      lastCheckStatus: withdrawalGroup.kycLastCheckStatus,
      lastDeny: withdrawalGroup.kycLastDeny,
      lastRuleGen: withdrawalGroup.kycLastRuleGen,
      haveAccessToken: withdrawalGroup.kycAccessToken != null,
    };
  }

  if (myKycState == null || isKycOperationDue(myKycState)) {
    return processWithdrawalGroupPendingReady(wex, withdrawalGroup);
  }

  const algoRes = await runKycCheckAlgo(wex, myKycState);

  if (!algoRes.updatedStatus) {
    return algoRes.taskResult;
  }

  const updatedStatus = algoRes.updatedStatus;

  checkProtocolInvariant(algoRes.requiresAuth != true);

  await ctx.transition({}, async (rec) => {
    if (!rec) {
      return TransitionResult.stay();
    }
    rec.kycLastAmlReview = updatedStatus.lastAmlReview;
    rec.kycLastCheckStatus = updatedStatus.lastCheckStatus;
    rec.kycLastCheckCode = updatedStatus.lastCheckCode;
    rec.kycLastDeny = updatedStatus.lastDeny;
    rec.kycLastRuleGen = updatedStatus.lastRuleGen;
    rec.kycAccessToken = updatedStatus.accessToken;
    return TransitionResult.transition(rec);
  });

  return algoRes.taskResult;
}

/**
 * Select new denominations for a withdrawal group.
 * Necessary when denominations expired or got revoked
 * before the withdrawal could complete.
 */
async function redenominateWithdrawal(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
  withdrawalGroupId: string,
): Promise<void> {
  await updateWithdrawalDenomsForExchange(wex, exchangeBaseUrl);
  logger.trace(`redenominating withdrawal group ${withdrawalGroupId}`);
  await wex.db.runReadWriteTx(
    { storeNames: ["withdrawalGroups", "planchets", "denominations"] },
    async (tx) => {
      const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
      if (!wg) {
        return;
      }
      checkDbInvariant(
        wg.exchangeBaseUrl !== undefined,
        "can't get funding uri from uninitialized wg",
      );
      checkDbInvariant(
        wg.denomsSel !== undefined,
        "can't process uninitialized exchange",
      );
      const currency = Amounts.currencyOf(wg.denomsSel.totalWithdrawCost);
      const exchangeBaseUrl = wg.exchangeBaseUrl;

      const candidates = await getWithdrawableDenomsTx(
        wex,
        tx,
        exchangeBaseUrl,
        currency,
      );

      const oldSel = wg.denomsSel;

      if (logger.shouldLogTrace()) {
        logger.trace(`old denom sel: ${j2s(oldSel)}`);
      }

      const zero = Amount.zeroOfCurrency(currency);
      let amountRemaining = zero;
      let prevTotalCoinValue = zero;
      let prevTotalWithdrawalCost = zero;
      let prevHasDenomWithAgeRestriction = false;
      let prevEarliestDepositExpiration = AbsoluteTime.never();
      const prevDenoms: DenomSelItem[] = [];
      let coinIndex = 0;
      for (let i = 0; i < oldSel.selectedDenoms.length; i++) {
        const sel = wg.denomsSel.selectedDenoms[i];
        const denom = await tx.denominations.get([
          exchangeBaseUrl,
          sel.denomPubHash,
        ]);
        if (!denom) {
          throw Error("denom in use but not not found");
        }
        // FIXME: Also check planchet if there was a different error or planchet already withdrawn
        const denomOkay = isWithdrawableDenom(denom);
        const numCoins = sel.count - (sel.skip ?? 0);
        const denomValue = Amount.from(denom.value).mult(numCoins);
        const denomFeeWithdraw = Amount.from(denom.fees.feeWithdraw).mult(
          numCoins,
        );
        if (denomOkay) {
          prevTotalCoinValue = prevTotalCoinValue.add(denomValue);
          prevTotalWithdrawalCost = prevTotalWithdrawalCost.add(
            denomValue,
            denomFeeWithdraw,
          );
          prevDenoms.push({
            count: sel.count,
            denomPubHash: sel.denomPubHash,
            skip: sel.skip,
          });
          prevHasDenomWithAgeRestriction =
            prevHasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
          prevEarliestDepositExpiration = AbsoluteTime.min(
            prevEarliestDepositExpiration,
            timestampAbsoluteFromDb(denom.stampExpireDeposit),
          );
        } else {
          amountRemaining = amountRemaining.add(denomValue, denomFeeWithdraw);
          prevDenoms.push({
            count: sel.count,
            denomPubHash: sel.denomPubHash,
            skip: (sel.skip ?? 0) + numCoins,
          });

          for (let j = 0; j < sel.count; j++) {
            const ci = coinIndex + j;
            const p = await tx.planchets.indexes.byGroupAndIndex.get([
              withdrawalGroupId,
              ci,
            ]);
            if (!p) {
              // Maybe planchet wasn't yet generated.
              // No problem!
              logger.info(
                `not aborting planchet #${coinIndex}, planchet not found`,
              );
              continue;
            }
            logger.info(`aborting planchet #${coinIndex}`);
            p.planchetStatus = PlanchetStatus.AbortedReplaced;
            await tx.planchets.put(p);
          }
        }

        coinIndex += sel.count;
      }

      const newSel = selectWithdrawalDenominations(
        amountRemaining.toJson(),
        candidates,
      );

      if (logger.shouldLogTrace()) {
        logger.trace(`new denom sel: ${j2s(newSel)}`);
      }

      const mergedSel: DenomSelectionState = {
        selectedDenoms: [...prevDenoms, ...newSel.selectedDenoms],
        totalCoinValue: zero
          .add(prevTotalCoinValue, newSel.totalCoinValue)
          .toString(),
        totalWithdrawCost: zero
          .add(prevTotalWithdrawalCost, newSel.totalWithdrawCost)
          .toString(),
        hasDenomWithAgeRestriction:
          prevHasDenomWithAgeRestriction || newSel.hasDenomWithAgeRestriction,
      };
      wg.denomsSel = mergedSel;
      if (logger.shouldLogTrace()) {
        logger.trace(`merged denom sel: ${j2s(mergedSel)}`);
      }
      await tx.withdrawalGroups.put(wg);
    },
  );
}

async function processWithdrawalGroupPendingReady(
  wex: WalletExecutionContext,
  withdrawalGroup: WithdrawalGroupRecord,
): Promise<TaskRunResult> {
  const { withdrawalGroupId } = withdrawalGroup;
  const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);

  logger.trace(
    `processWithdrawalGroupPendingReady in DB state ${
      WithdrawalGroupStatus[withdrawalGroup.status]
    }`,
  );

  checkDbInvariant(
    withdrawalGroup.denomsSel !== undefined,
    "can't process uninitialized exchange",
  );
  checkDbInvariant(
    withdrawalGroup.exchangeBaseUrl !== undefined,
    "can't get funding uri from uninitialized wg",
  );
  const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
  logger.trace(`updating exchange before processing wg`);
  const exch = await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl);

  if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
    logger.warn("Finishing empty withdrawal group (no denoms)");
    await ctx.transition({}, async (wg) => {
      if (!wg) {
        return TransitionResult.stay();
      }
      wg.status = WithdrawalGroupStatus.Done;
      wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
      return TransitionResult.transition(wg);
    });
    return TaskRunResult.finished();
  }

  checkDbInvariant(
    withdrawalGroup.effectiveWithdrawalAmount != null,
    "expected withdrawal group to have effective amount",
  );

  // We check for the balance KYC only after the money has arrived for multiple reasons:
  // (1) The amount might differ (user error or wire fees)
  // (2) We don't want the to have to do KYC at different times

  const kycCheckRes = await checkIncomingAmountLegalUnderKycBalanceThreshold(
    wex,
    withdrawalGroup.exchangeBaseUrl,
    withdrawalGroup.denomsSel.totalCoinValue,
  );

  logger.info(`balance-kyc check result: ${j2s(kycCheckRes)}`);

  if (kycCheckRes.result === "violation") {
    // Do this before we transition so that the exchange is already in the right state.
    await handleStartExchangeWalletKyc(wex, {
      amount: kycCheckRes.nextThreshold,
      exchangeBaseUrl,
    });
    await ctx.transition({}, async (wg) => {
      if (!wg) {
        return TransitionResult.stay();
      }
      wg.status = WithdrawalGroupStatus.PendingBalanceKycInit;
      return TransitionResult.transition(wg);
    });
    return TaskRunResult.progress();
  }

  const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
    .map((x) => x.count)
    .reduce((a, b) => a + b);

  const wgContext: WithdrawalGroupStatusInfo = {
    numPlanchets: numTotalCoins,
    planchetsFinished: new Set<string>(),
    wgRecord: withdrawalGroup,
  };

  await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => {
    const planchets =
      await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
    for (const p of planchets) {
      if (p.planchetStatus === PlanchetStatus.WithdrawalDone) {
        wgContext.planchetsFinished.add(p.coinPub);
      }
    }
  });

  // We sequentially generate planchets, so that
  // large withdrawal groups don't make the wallet unresponsive.
  for (let i = 0; i < numTotalCoins; i++) {
    await processPlanchetGenerate(wex, withdrawalGroup, i);
  }

  const maxBatchSize = 64;

  const exchangeVer = LibtoolVersion.parseVersion(exch.protocolVersionRange);
  if (!exchangeVer) {
    // Should never happen, as version range syntax is checked
    // before info is added to DB.
    throw TalerError.fromDetail(
      TalerErrorCode.GENERIC_INTERNAL_INVARIANT_FAILURE,
      {},
      "exchange has invalid protocol version",
    );
  }

  logger.trace(`withdrawing ${numTotalCoins} coins`);

  for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
    let resp: WithdrawalBatchResult;
    if (exchangeVer.current >= 26) {
      resp = await processPlanchetExchangeBatchRequest(wex, wgContext, {
        batchSize: maxBatchSize,
        coinStartIndex: i,
      });
    } else {
      resp = await processPlanchetExchangeLegacyBatchRequest(wex, wgContext, {
        batchSize: maxBatchSize,
        coinStartIndex: i,
      });
    }

    let work: Promise<void>[] = [];
    work = [];
    for (let j = 0; j < resp.coinIdxs.length; j++) {
      if (!resp.batchResp.ev_sigs[j]) {
        // response may not be available when there is kyc needed
        continue;
      }
      work.push(
        processPlanchetVerifyAndStoreCoin(
          wex,
          wgContext,
          resp.coinIdxs[j],
          resp.batchResp.ev_sigs[j],
        ),
      );
    }
    await Promise.all(work);
  }

  let redenomRequired = false;

  await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => {
    const planchets =
      await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
    for (const p of planchets) {
      if (p.planchetStatus !== PlanchetStatus.Pending) {
        continue;
      }
      if (!p.lastError) {
        continue;
      }
      switch (p.lastError.code) {
        case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_EXPIRED:
        case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_REVOKED:
        case TalerErrorCode.EXCHANGE_GENERIC_KEYS_MISSING:
        case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN:
          redenomRequired = true;
          return;
      }
    }
  });

  if (redenomRequired) {
    logger.warn(`redenomination required for withdrawal ${withdrawalGroupId}`);
    return startRedenomination(ctx, exchangeBaseUrl);
  }

  const errorsPerCoin: Record<number, TalerErrorDetail> = {};
  let numPlanchetErrors = 0;
  let numActive = 0;
  const maxReportedErrors = 5;

  const res = await ctx.transition(
    {
      extraStores: ["coins", "coinAvailability", "planchets"],
    },
    async (wg, tx) => {
      if (!wg) {
        return TransitionResult.stay();
      }

      const groupPlanchets =
        await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
      for (const x of groupPlanchets) {
        switch (x.planchetStatus) {
          case PlanchetStatus.KycRequired:
          case PlanchetStatus.Pending:
            numActive++;
            break;
          case PlanchetStatus.WithdrawalDone:
            break;
        }
        if (x.lastError) {
          numPlanchetErrors++;
          if (numPlanchetErrors < maxReportedErrors) {
            errorsPerCoin[x.coinIdx] = x.lastError;
          }
        }
      }

      if (
        (wg.timestampFinish === undefined ||
          wg.status !== WithdrawalGroupStatus.Done) &&
        numActive === 0
      ) {
        wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
        wg.status = WithdrawalGroupStatus.Done;
        await makeCoinsVisible(wex, tx, ctx.transactionId);
      }
      return TransitionResult.transition(wg);
    },
  );

  if (!res) {
    throw Error("withdrawal group does not exist anymore");
  }

  if (numPlanchetErrors > 0) {
    return {
      type: TaskRunResultType.Error,
      errorDetail: makeErrorDetail(
        TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
        {
          errorsPerCoin,
          numErrors: numPlanchetErrors,
        },
      ),
    };
  }

  return TaskRunResult.backoff();
}

async function startRedenomination(
  ctx: WithdrawTransactionContext,
  exchangeBaseUrl: string,
): Promise<TaskRunResult> {
  // Exchange is broken. Mark exchange as unavailable and re-fetch
  // exchange entry.
  await startUpdateExchangeEntry(ctx.wex, exchangeBaseUrl, {
    forceUnavailable: true,
  });
  const didTransition = await ctx.transition({}, async (rec) => {
    switch (rec?.status) {
      case WithdrawalGroupStatus.PendingReady:
        break;
      default:
        return TransitionResult.stay();
    }
    rec.status = WithdrawalGroupStatus.PendingRedenominate;
    return TransitionResult.transition(rec);
  });
  if (didTransition) {
    return TaskRunResult.progress();
  }
  return TaskRunResult.backoff();
}

export async function processWithdrawalGroup(
  wex: WalletExecutionContext,
  withdrawalGroupId: string,
): Promise<TaskRunResult> {
  if (!wex.ws.networkAvailable) {
    return TaskRunResult.networkRequired();
  }

  logger.trace("processing withdrawal group", withdrawalGroupId);
  const withdrawalGroup = await wex.db.runReadOnlyTx(
    { storeNames: ["withdrawalGroups"] },
    (tx) => tx.withdrawalGroups.get(withdrawalGroupId),
  );

  if (!withdrawalGroup) {
    throw Error(`withdrawal group ${withdrawalGroupId} not found`);
  }

  const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);

  switch (withdrawalGroup.status) {
    case WithdrawalGroupStatus.PendingRegisteringBank:
      return await processBankRegisterReserve(wex, withdrawalGroupId);
    case WithdrawalGroupStatus.PendingQueryingStatus:
      return processQueryReserve(wex, withdrawalGroupId);
    case WithdrawalGroupStatus.PendingWaitConfirmBank:
      return await processReserveBankStatus(wex, withdrawalGroupId);
    case WithdrawalGroupStatus.PendingKyc:
      return await processWithdrawalGroupPendingKyc(wex, withdrawalGroup);
    case WithdrawalGroupStatus.PendingReady:
      // Continue with the actual withdrawal!
      return await processWithdrawalGroupPendingReady(wex, withdrawalGroup);
    case WithdrawalGroupStatus.AbortingBank:
      return await processWithdrawalGroupAbortingBank(wex, withdrawalGroup);
    case WithdrawalGroupStatus.DialogProposed:
      return await processWithdrawalGroupDialogProposed(ctx, withdrawalGroup);
    case WithdrawalGroupStatus.PendingBalanceKyc:
    case WithdrawalGroupStatus.PendingBalanceKycInit:
      return await processWithdrawalGroupBalanceKyc(ctx, withdrawalGroup);
    case WithdrawalGroupStatus.PendingRedenominate:
      return await processWithdrawalGroupRedenominate(ctx, withdrawalGroup);
    case WithdrawalGroupStatus.AbortedBank:
    case WithdrawalGroupStatus.AbortedExchange:
    case WithdrawalGroupStatus.FailedAbortingBank:
    case WithdrawalGroupStatus.SuspendedAbortingBank:
    case WithdrawalGroupStatus.SuspendedKyc:
    case WithdrawalGroupStatus.SuspendedQueryingStatus:
    case WithdrawalGroupStatus.SuspendedReady:
    case WithdrawalGroupStatus.SuspendedRegisteringBank:
    case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
    case WithdrawalGroupStatus.SuspendedBalanceKyc:
    case WithdrawalGroupStatus.SuspendedBalanceKycInit:
    case WithdrawalGroupStatus.Done:
    case WithdrawalGroupStatus.FailedBankAborted:
    case WithdrawalGroupStatus.AbortedUserRefused:
    case WithdrawalGroupStatus.AbortedOtherWallet:
    case WithdrawalGroupStatus.SuspendedRedenominate:
      // Nothing to do.
      return TaskRunResult.finished();
    default:
      assertUnreachable(withdrawalGroup.status);
  }
}

const AGE_MASK_GROUPS = "8:10:12:14:16:18"
  .split(":")
  .map((n) => parseInt(n, 10));

export async function getExchangeWithdrawalInfo(
  wex: WalletExecutionContext,
  exchangeBaseUrl: string,
  instructedAmount: AmountJson,
  withdrawalType: WithdrawalRecordType,
  ageRestricted: number | undefined,
): Promise<ExchangeWithdrawalDetails> {
  logger.trace("updating exchange");
  const exchange = await fetchFreshExchange(wex, exchangeBaseUrl, {});

  wex.cancellationToken.throwIfCancelled();

  if (exchange.currency != instructedAmount.currency) {
    // Specifying the amount in the conversion input currency is not yet supported.
    // We might add support for it later.
    throw new Error(
      `withdrawal only supported when specifying target currency ${exchange.currency}`,
    );
  }

  const withdrawalAccountsList = await fetchWithdrawalAccountInfo(wex, {
    exchange,
    instructedAmount,
    withdrawalType,
  });

  let candidateDenoms: DenominationRecord[];
  let selectedDenoms: DenomSelectionState;

  if (Amounts.isNonZero(instructedAmount)) {
    logger.trace("updating withdrawal denoms");
    await updateWithdrawalDenomsForExchange(wex, exchangeBaseUrl);

    wex.cancellationToken.throwIfCancelled();

    logger.trace("getting candidate denoms");
    candidateDenoms = await getWithdrawableDenoms(
      wex,
      exchangeBaseUrl,
      instructedAmount.currency,
    );

    wex.cancellationToken.throwIfCancelled();

    logger.trace("selecting withdrawal denoms");
    // FIXME: Why not in a transaction?
    selectedDenoms = selectWithdrawalDenominations(
      instructedAmount,
      candidateDenoms,
    );

    logger.trace("selection done");
  } else {
    candidateDenoms = [];
    selectedDenoms = {
      totalCoinValue: Amounts.stringify(instructedAmount),
      totalWithdrawCost: Amounts.stringify(instructedAmount),
      selectedDenoms: [],
      hasDenomWithAgeRestriction: false,
    };

    logger.trace("selection skipped, amount is zero");
  }

  const exchangeWireAccounts: string[] = [];

  for (const account of exchange.wireInfo.accounts) {
    exchangeWireAccounts.push(account.payto_uri);
  }

  let versionMatch;
  if (exchange.protocolVersionRange) {
    versionMatch = LibtoolVersion.compare(
      WALLET_EXCHANGE_PROTOCOL_VERSION,
      exchange.protocolVersionRange,
    );

    if (
      versionMatch &&
      !versionMatch.compatible &&
      versionMatch.currentCmp === -1
    ) {
      logger.warn(
        `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
          `(exchange has ${exchange.protocolVersionRange}), checking for updates`,
      );
    }
  }

  let tosAccepted = false;
  if (exchange.tosAcceptedTimestamp) {
    if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) {
      tosAccepted = true;
    }
  }

  const paytoUris = exchange.wireInfo.accounts.map((x) => x.payto_uri);
  if (!paytoUris) {
    throw Error("exchange is in invalid state");
  }

  const ret: ExchangeWithdrawalDetails = {
    exchangePaytoUris: paytoUris,
    exchangeWireAccounts,
    exchangeCreditAccountDetails: withdrawalAccountsList,
    selectedDenoms,
    termsOfServiceAccepted: tosAccepted,
    withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
    withdrawalAmountRaw: Amounts.stringify(instructedAmount),
    // TODO: remove hardcoding, this should be calculated from the denominations info
    // force enabled for testing
    ageRestrictionOptions: selectedDenoms.hasDenomWithAgeRestriction
      ? AGE_MASK_GROUPS
      : undefined,
    scopeInfo: exchange.scopeInfo,
    ...getWithdrawalLimitInfo(exchange, instructedAmount),
  };
  return ret;
}

async function getWithdrawalDetailsForBankInfo(
  wex: WalletExecutionContext,
  info: BankWithdrawDetails,
): Promise<WithdrawUriInfoResponse> {
  if (info.exchange) {
    try {
      // If the exchange entry doesn't exist yet,
      // it'll be created as an ephemeral entry.
      await fetchFreshExchange(wex, info.exchange);
    } catch (e) {
      // We still continued if it failed, as other exchanges might be available.
      // We don't want to fail if the bank-suggested exchange is broken/offline.
      logger.trace(
        `querying bank-suggested exchange (${info.exchange}) failed`,
      );
    }
  }

  const currency = info.currency;

  let possibleExchanges: ExchangeListItem[];
  if (!info.editableExchange && info.exchange !== undefined) {
    const ex: ExchangeListItem = await lookupExchangeByUri(wex, {
      exchangeBaseUrl: info.exchange,
    });
    possibleExchanges = [ex];
  } else {
    const listExchangesResp = await listExchanges(wex, {});

    for (const exchange of listExchangesResp.exchanges) {
      if (exchange.currency !== currency) {
        continue;
      }
    }

    possibleExchanges = listExchangesResp.exchanges.filter((x) => {
      return (
        x.currency === currency &&
        (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready ||
          x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate)
      );
    });
  }

  return {
    operationId: info.operationId,
    confirmTransferUrl: info.confirmTransferUrl,
    status: info.status,
    currency,
    editableAmount: info.editableAmount,
    editableExchange: info.editableExchange,
    maxAmount: info.maxAmount ? Amounts.stringify(info.maxAmount) : undefined,
    amount: info.amount ? Amounts.stringify(info.amount) : undefined,
    defaultExchangeBaseUrl: info.exchange,
    possibleExchanges,
    wireFee: info.wireFee ? Amounts.stringify(info.wireFee) : undefined,
  };
}

/**
 * Get more information about a taler://withdraw URI.
 *
 * As side effects, the bank (via the bank integration API) is queried
 * and the exchange suggested by the bank is ephemerally added
 * to the wallet's list of known exchanges.
 */
export async function getWithdrawalDetailsForUri(
  wex: WalletExecutionContext,
  talerWithdrawUri: string,
): Promise<WithdrawUriInfoResponse> {
  logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
  const info = await getBankWithdrawalInfo(wex.http, talerWithdrawUri);
  logger.trace(`got bank info`);
  return getWithdrawalDetailsForBankInfo(wex, info);
}

export function augmentPaytoUrisForWithdrawal(
  plainPaytoUris: string[],
  reservePub: string,
  instructedAmount: AmountLike,
): string[] {
  return plainPaytoUris.map((x) =>
    addPaytoQueryParams(x, {
      amount: Amounts.stringify(instructedAmount),
      message: `Taler ${reservePub}`,
    }),
  );
}

export function augmentPaytoUrisForKycTransfer(
  plainPaytoUris: string[],
  reservePub: string,
  tinyAmount: AmountLike,
): string[] {
  return plainPaytoUris.map((x) =>
    addPaytoQueryParams(x, {
      amount: Amounts.stringify(tinyAmount),
      message: `Taler KYC ${reservePub}`,
    }),
  );
}

/**
 * Get payto URIs that can be used to fund a withdrawal operation.
 */
export async function getFundingPaytoUris(
  tx: WalletDbReadOnlyTransaction<
    ["withdrawalGroups", "exchanges", "exchangeDetails"]
  >,
  withdrawalGroupId: string,
): Promise<string[]> {
  const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
  checkDbInvariant(!!withdrawalGroup, `no withdrawal for ${withdrawalGroupId}`);
  checkDbInvariant(
    withdrawalGroup.exchangeBaseUrl !== undefined,
    "can't get funding uri from uninitialized wg",
  );
  checkDbInvariant(
    withdrawalGroup.instructedAmount !== undefined,
    "can't get funding uri from uninitialized wg",
  );
  const exchangeDetails = await getExchangeDetailsInTx(
    tx,
    withdrawalGroup.exchangeBaseUrl,
  );
  if (!exchangeDetails) {
    logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`);
    return [];
  }
  const plainPaytoUris =
    exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
  if (!plainPaytoUris) {
    logger.error(
      `exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`,
    );
    return [];
  }
  return augmentPaytoUrisForWithdrawal(
    plainPaytoUris,
    withdrawalGroup.reservePub,
    withdrawalGroup.instructedAmount,
  );
}

async function getWithdrawalGroupRecordTx(
  db: DbAccess<typeof WalletStoresV1>,
  req: {
    withdrawalGroupId: string;
  },
): Promise<WithdrawalGroupRecord | undefined> {
  return await db.runReadOnlyTx({ storeNames: ["withdrawalGroups"] }, (tx) =>
    tx.withdrawalGroups.get(req.withdrawalGroupId),
  );
}

export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
  return { d_ms: 60000 };
}

export function getBankStatusUrl(talerWithdrawUri: string): URL {
  const uriResult = parseWithdrawUri(talerWithdrawUri);
  if (!uriResult) {
    throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
  }
  const url = new URL(
    `withdrawal-operation/${uriResult.withdrawalOperationId}`,
    uriResult.bankIntegrationApiBaseUrl,
  );
  return url;
}

export function getBankAbortUrl(talerWithdrawUri: string): URL {
  const uriResult = parseWithdrawUri(talerWithdrawUri);
  if (!uriResult) {
    throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
  }
  const url = new URL(
    `withdrawal-operation/${uriResult.withdrawalOperationId}/abort`,
    uriResult.bankIntegrationApiBaseUrl,
  );
  return url;
}

async function registerReserveWithBank(
  wex: WalletExecutionContext,
  withdrawalGroupId: string,
  isFlexibleAmount: boolean,
): Promise<TaskRunResult> {
  const withdrawalGroup = await wex.db.runReadOnlyTx(
    { storeNames: ["withdrawalGroups"] },
    (tx) => tx.withdrawalGroups.get(withdrawalGroupId),
  );
  const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
  switch (withdrawalGroup?.status) {
    case WithdrawalGroupStatus.PendingWaitConfirmBank:
    case WithdrawalGroupStatus.PendingRegisteringBank:
      break;
    default:
      return TaskRunResult.finished();
  }
  if (
    withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
  ) {
    throw Error("expecting withdrawal type = bank integrated");
  }
  const bankInfo = withdrawalGroup.wgInfo.bankInfo;
  if (!bankInfo) {
    throw Error(
      "BUG: Tried to register reserve with bank, but bankInfo unavailable",
    );
  }
  const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
  const reqBody = {
    reserve_pub: withdrawalGroup.reservePub,
    selected_exchange: bankInfo.exchangePaytoUri,
  } as any;
  if (isFlexibleAmount) {
    reqBody.amount = withdrawalGroup.instructedAmount;
  }
  logger.trace(`isFlexibleAmount: ${isFlexibleAmount}`);
  logger.info(`registering reserve with bank: ${j2s(reqBody)}`);

  if (wex.ws.devExperimentState.pretendPostWopFailed) {
    logger.warn(
      `dev experiment: pretending permanent error response, aborting withdrawal`,
    );
    await transitionSimple(
      ctx,
      WithdrawalGroupStatus.PendingRegisteringBank,
      WithdrawalGroupStatus.FailedBankAborted,
    );
    return TaskRunResult.progress();
  }

  const httpResp = await cancelableFetch(wex, bankStatusUrl, {
    method: "POST",
    body: reqBody,
    timeout: getReserveRequestTimeout(withdrawalGroup),
  });

  switch (httpResp.status) {
    case HttpStatusCode.NotFound: {
      // FIXME: Inspect particular status code
      const err = await readTalerErrorResponse(httpResp);
      logger.warn(`withdrawal operation not found, aborting: ${j2s(err)}`);
      await transitionSimple(
        ctx,
        WithdrawalGroupStatus.PendingRegisteringBank,
        WithdrawalGroupStatus.FailedBankAborted,
      );
      return TaskRunResult.progress();
    }
    case HttpStatusCode.Conflict:
      await transitionSimple(
        ctx,
        WithdrawalGroupStatus.PendingRegisteringBank,
        WithdrawalGroupStatus.FailedBankAborted,
      );
      return TaskRunResult.progress();
  }

  const status = await readSuccessResponseJsonOrThrow(
    httpResp,
    codecForBankWithdrawalOperationPostResponse(),
  );

  await ctx.transition({}, async (r) => {
    if (!r) {
      return TransitionResult.stay();
    }
    switch (r.status) {
      case WithdrawalGroupStatus.PendingRegisteringBank:
      case WithdrawalGroupStatus.PendingWaitConfirmBank:
        break;
      default:
        return TransitionResult.stay();
    }
    if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
      throw Error("invariant failed");
    }
    r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb(
      AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
    );
    r.status = WithdrawalGroupStatus.PendingWaitConfirmBank;
    r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
    return TransitionResult.transition(r);
  });

  return TaskRunResult.progress();
}

async function transitionBankAborted(
  ctx: WithdrawTransactionContext,
): Promise<TaskRunResult> {
  logger.info("bank aborted the withdrawal");
  await ctx.transition({}, async (r) => {
    if (!r) {
      return TransitionResult.stay();
    }
    switch (r.status) {
      case WithdrawalGroupStatus.PendingRegisteringBank:
      case WithdrawalGroupStatus.PendingWaitConfirmBank:
        break;
      default:
        return TransitionResult.stay();
    }
    if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
      throw Error("invariant failed");
    }
    const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
    r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
    r.status = WithdrawalGroupStatus.FailedBankAborted;
    return TransitionResult.transition(r);
  });
  return TaskRunResult.progress();
}

async function processBankRegisterReserve(
  wex: WalletExecutionContext,
  withdrawalGroupId: string,
): Promise<TaskRunResult> {
  const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
  const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
    withdrawalGroupId,
  });
  if (!withdrawalGroup) {
    return TaskRunResult.finished();
  }

  if (
    withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
  ) {
    throw Error("wrong withdrawal record type");
  }
  const bankInfo = withdrawalGroup.wgInfo.bankInfo;
  if (!bankInfo) {
    throw Error("no bank info in bank-integrated withdrawal");
  }

  const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri);
  if (!uriResult) {
    throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`);
  }
  const url = new URL(
    `withdrawal-operation/${uriResult.withdrawalOperationId}`,
    uriResult.bankIntegrationApiBaseUrl,
  );

  const statusResp = await cancelableFetch(wex, url, {
    timeout: getReserveRequestTimeout(withdrawalGroup),
  });

  // FIXME: Consider looking at the exact taler error code
  switch (statusResp.status) {
    case HttpStatusCode.NotFound:
      await transitionSimple(
        ctx,
        WithdrawalGroupStatus.PendingRegisteringBank,
        WithdrawalGroupStatus.FailedBankAborted,
      );
      return TaskRunResult.progress();
    case HttpStatusCode.Conflict:
      await transitionSimple(
        ctx,
        WithdrawalGroupStatus.PendingRegisteringBank,
        WithdrawalGroupStatus.AbortedOtherWallet,
      );
      return TaskRunResult.progress();
    default:
      break;
  }

  const status = await readSuccessResponseJsonOrThrow(
    statusResp,
    codecForBankWithdrawalOperationStatus(),
  );

  // Legacy libeufin-bank behavior
  if (status.status === "aborted") {
    return transitionBankAborted(ctx);
  }

  // FIXME: Put confirm transfer URL in the DB!

  const isFlexibleAmount = status.amount == null;

  return await registerReserveWithBank(
    wex,
    withdrawalGroupId,
    isFlexibleAmount,
  );
}

async function processReserveBankStatus(
  wex: WalletExecutionContext,
  withdrawalGroupId: string,
): Promise<TaskRunResult> {
  const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
    withdrawalGroupId,
  });

  if (!withdrawalGroup) {
    return TaskRunResult.finished();
  }

  const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);

  if (
    withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
  ) {
    throw Error("wrong withdrawal record type");
  }
  const bankInfo = withdrawalGroup.wgInfo.bankInfo;
  if (!bankInfo) {
    throw Error("no bank info in bank-integrated withdrawal");
  }

  const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri);
  if (!uriResult) {
    throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`);
  }
  const bankStatusUrl = new URL(
    `withdrawal-operation/${uriResult.withdrawalOperationId}`,
    uriResult.bankIntegrationApiBaseUrl,
  );
  bankStatusUrl.searchParams.set("long_poll_ms", "30000");
  bankStatusUrl.searchParams.set("old_state", "selected");

  logger.info(`long-polling for withdrawal operation at ${bankStatusUrl.href}`);
  const statusResp = await cancelableFetch(wex, bankStatusUrl, {
    timeout: getReserveRequestTimeout(withdrawalGroup),
  });
  logger.info(
    `long-polling for withdrawal operation returned status ${statusResp.status}`,
  );

  const status = await readSuccessResponseJsonOrThrow(
    statusResp,
    codecForBankWithdrawalOperationStatus(),
  );

  if (logger.shouldLogTrace()) {
    logger.trace(`response body: ${j2s(status)}`);
  }

  if (status.status === "aborted") {
    return transitionBankAborted(ctx);
  }

  if (status.status != "confirmed") {
    return TaskRunResult.longpollReturnedPending();
  }

  let denomSel: undefined | DenomSelectionState = undefined;

  if (withdrawalGroup.denomsSel == null) {
    const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
    if (!exchangeBaseUrl) {
      throw Error("invalid state");
    }
    if (!status.amount) {
      throw Error("bank did not provide amount");
    }
    const instructedAmount = Amounts.parseOrThrow(status.amount);
    denomSel = await getInitialDenomsSelection(
      wex,
      exchangeBaseUrl,
      instructedAmount,
      undefined,
    );
  }

  const transitionInfo = await ctx.transition({}, async (r) => {
    if (!r) {
      return TransitionResult.stay();
    }
    // Re-check reserve status within transaction
    switch (r.status) {
      case WithdrawalGroupStatus.PendingWaitConfirmBank:
        break;
      default:
        return TransitionResult.stay();
    }
    if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
      throw Error("invariant failed");
    }
    if (status.status == "confirmed") {
      logger.info("withdrawal: transfer confirmed by bank.");
      const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
      r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
      r.status = WithdrawalGroupStatus.PendingQueryingStatus;
      if (denomSel != null) {
        r.denomsSel = denomSel;
        r.rawWithdrawalAmount = denomSel.totalWithdrawCost;
        r.effectiveWithdrawalAmount = denomSel.totalCoinValue;
        r.instructedAmount = denomSel.totalWithdrawCost;
      }
      return TransitionResult.transition(r);
    } else {
      return TransitionResult.stay();
    }
  });

  if (transitionInfo) {
    return TaskRunResult.progress();
  } else {
    return TaskRunResult.backoff();
  }
}

export interface PrepareCreateWithdrawalGroupResult {
  withdrawalGroup: WithdrawalGroupRecord;
  transactionId: string;
  creationInfo?: {
    amount: AmountJson;
    canonExchange: string;
  };
}

async function getInitialDenomsSelection(
  wex: WalletExecutionContext,
  exchange: string,
  amount: AmountJson,
  forcedDenoms: ForcedDenomSel | undefined,
): Promise<DenomSelectionState> {
  if (wex.ws.devExperimentState.pretendNoDenoms) {
    return selectWithdrawalDenominations(amount, []);
  }

  const currency = Amounts.currencyOf(amount);
  await updateWithdrawalDenomsForExchange(wex, exchange);
  const denoms = await getWithdrawableDenoms(wex, exchange, currency);

  if (forcedDenoms) {
    logger.warn("using forced denom selection");
    return selectForcedWithdrawalDenominations(amount, denoms, forcedDenoms);
  } else {
    return selectWithdrawalDenominations(amount, denoms);
  }
}

export async function internalPrepareCreateWithdrawalGroup(
  wex: WalletExecutionContext,
  args: {
    reserveStatus: WithdrawalGroupStatus;
    amount?: AmountJson;
    exchangeBaseUrl: string | undefined;
    forcedWithdrawalGroupId?: string;
    forcedDenomSel?: ForcedDenomSel;
    reserveKeyPair?: EddsaKeyPairStrings;
    restrictAge?: number;
    wgInfo: WgInfo;
    isForeignAccount?: boolean;
  },
): Promise<PrepareCreateWithdrawalGroupResult> {
  const reserveKeyPair =
    args.reserveKeyPair ?? (await wex.cryptoApi.createEddsaKeypair({}));
  const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
  const secretSeed = encodeCrock(getRandomBytes(32));
  const exchangeBaseUrl = args.exchangeBaseUrl;
  const amount = args.amount;

  let withdrawalGroupId: string;

  if (args.forcedWithdrawalGroupId) {
    withdrawalGroupId = args.forcedWithdrawalGroupId;
    const wgId = withdrawalGroupId;
    const existingWg = await wex.db.runReadOnlyTx(
      { storeNames: ["withdrawalGroups"] },
      (tx) => tx.withdrawalGroups.get(wgId),
    );

    if (existingWg) {
      const transactionId = constructTransactionIdentifier({
        tag: TransactionType.Withdrawal,
        withdrawalGroupId: existingWg.withdrawalGroupId,
      });
      return { withdrawalGroup: existingWg, transactionId };
    }
  } else {
    withdrawalGroupId = encodeCrock(getRandomBytes(32));
  }

  let initialDenomSel: DenomSelectionState | undefined;

  if (amount !== undefined && exchangeBaseUrl !== undefined) {
    initialDenomSel = await getInitialDenomsSelection(
      wex,
      exchangeBaseUrl,
      amount,
      args.forcedDenomSel,
    );
  }

  const withdrawalGroup: WithdrawalGroupRecord = {
    // next fields will be undefined if exchange or amount is not specified
    denomsSel: initialDenomSel,
    exchangeBaseUrl: exchangeBaseUrl,
    instructedAmount:
      amount === undefined ? undefined : Amounts.stringify(amount),
    rawWithdrawalAmount: initialDenomSel?.totalWithdrawCost,
    effectiveWithdrawalAmount: initialDenomSel?.totalCoinValue,
    // end of optional fields
    timestampStart: timestampPreciseToDb(now),
    secretSeed,
    reservePriv: reserveKeyPair.priv,
    reservePub: reserveKeyPair.pub,
    status: args.reserveStatus,
    withdrawalGroupId,
    restrictAge: args.restrictAge,
    timestampFinish: undefined,
    wgInfo: args.wgInfo,
    isForeignAccount: args.isForeignAccount,
  };

  if (exchangeBaseUrl !== undefined) {
    await fetchFreshExchange(wex, exchangeBaseUrl);
  }

  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Withdrawal,
    withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
  });

  return {
    withdrawalGroup,
    transactionId,
    creationInfo:
      !amount || !exchangeBaseUrl
        ? undefined
        : {
            amount,
            canonExchange: exchangeBaseUrl,
          },
  };
}

export interface PerformCreateWithdrawalGroupResult {
  withdrawalGroup: WithdrawalGroupRecord;
}

export async function internalPerformCreateWithdrawalGroup(
  wex: WalletExecutionContext,
  tx: WalletDbReadWriteTransaction<
    ["withdrawalGroups", "reserves", "exchanges"]
  >,
  prep: PrepareCreateWithdrawalGroupResult,
): Promise<PerformCreateWithdrawalGroupResult> {
  const { withdrawalGroup } = prep;
  const existingWg = await tx.withdrawalGroups.get(
    withdrawalGroup.withdrawalGroupId,
  );
  if (existingWg) {
    return {
      withdrawalGroup: existingWg,
    };
  }
  await tx.withdrawalGroups.add(withdrawalGroup);
  await tx.reserves.put({
    reservePub: withdrawalGroup.reservePub,
    reservePriv: withdrawalGroup.reservePriv,
  });

  if (!prep.creationInfo) {
    return {
      withdrawalGroup,
    };
  }
  return internalPerformExchangeWasUsed(
    wex,
    tx,
    prep.creationInfo.canonExchange,
    withdrawalGroup,
  );
}

async function internalPerformExchangeWasUsed(
  wex: WalletExecutionContext,
  tx: WalletDbReadWriteTransaction<["exchanges"]>,
  canonExchange: string,
  withdrawalGroup: WithdrawalGroupRecord,
): Promise<PerformCreateWithdrawalGroupResult> {
  const exchange = await tx.exchanges.get(canonExchange);
  if (exchange) {
    exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now());
    await tx.exchanges.put(exchange);
  }

  const oldTxState = {
    major: TransactionMajorState.None,
    minor: undefined,
    internalId: 0,
  };
  const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup);
  const transitionInfo: TransitionInfo = {
    oldTxState,
    newTxState,
    balanceEffect: BalanceEffect.Any,
    oldStId: 0,
    newStId: withdrawalGroup.status,
  };

  await markExchangeUsed(tx, canonExchange);

  const ctx = new WithdrawTransactionContext(
    wex,
    withdrawalGroup.withdrawalGroupId,
  );

  wex.taskScheduler.startShepherdTask(ctx.taskId);

  applyNotifyTransition(tx.notify, ctx.transactionId, transitionInfo);

  return {
    withdrawalGroup,
  };
}

/**
 * Create a withdrawal group.
 *
 * If a forcedWithdrawalGroupId is given and a
 * withdrawal group with this ID already exists,
 * the existing one is returned.  No conflict checking
 * of the other arguments is done in that case.
 */
export async function internalCreateWithdrawalGroup(
  wex: WalletExecutionContext,
  args: {
    reserveStatus: WithdrawalGroupStatus;
    exchangeBaseUrl: string | undefined;
    amount?: AmountJson;
    forcedWithdrawalGroupId?: string;
    forcedDenomSel?: ForcedDenomSel;
    reserveKeyPair?: EddsaKeyPairStrings;
    restrictAge?: number;
    wgInfo: WgInfo;
    isForeignAccount?: boolean;
  },
): Promise<WithdrawalGroupRecord> {
  const prep = await internalPrepareCreateWithdrawalGroup(wex, args);
  const ctx = new WithdrawTransactionContext(
    wex,
    prep.withdrawalGroup.withdrawalGroupId,
  );
  const res = await wex.db.runReadWriteTx(
    {
      storeNames: [
        "withdrawalGroups",
        "reserves",
        "exchanges",
        "exchangeDetails",
        "transactionsMeta",
        "operationRetries",
      ],
    },
    async (tx) => {
      const res = await internalPerformCreateWithdrawalGroup(wex, tx, prep);
      await ctx.updateTransactionMeta(tx);
      return res;
    },
  );
  return res.withdrawalGroup;
}

export async function prepareBankIntegratedWithdrawal(
  wex: WalletExecutionContext,
  req: {
    talerWithdrawUri: string;
    isForeignAccount?: boolean;
  },
): Promise<PrepareBankIntegratedWithdrawalResponse> {
  const existingWithdrawalGroup = await wex.db.runReadOnlyTx(
    { storeNames: ["withdrawalGroups"] },
    (tx) =>
      tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(req.talerWithdrawUri),
  );

  const parsedUri = parseTalerUri(req.talerWithdrawUri);
  if (parsedUri?.type !== TalerUriAction.Withdraw) {
    throw TalerError.fromDetail(TalerErrorCode.WALLET_TALER_URI_MALFORMED, {});
  }

  const externalConfirmation = parsedUri.externalConfirmation;

  logger.trace(
    `creating withdrawal with externalConfirmation=${externalConfirmation}`,
  );

  const withdrawInfo = await getBankWithdrawalInfo(
    wex.http,
    req.talerWithdrawUri,
  );

  const info = await getWithdrawalDetailsForBankInfo(wex, withdrawInfo);

  if (existingWithdrawalGroup) {
    return {
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.Withdrawal,
        withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
      }),
      info,
    };
  }

  switch (info.status) {
    case "aborted":
    case "selected":
    case "confirmed":
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
        {},
        `withdrawal is in status ${info.status}, unable to proceed`,
      );
    default:
      break;
  }

  /**
   * Withdrawal group without exchange and amount
   * this is an special case when the user haven't yet
   * choose. We are still tracking this object since the state
   * can change from the bank side or another wallet with the
   * same URI
   */
  const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
    exchangeBaseUrl: undefined,
    wgInfo: {
      withdrawalType: WithdrawalRecordType.BankIntegrated,
      bankInfo: {
        talerWithdrawUri: req.talerWithdrawUri,
        confirmUrl: withdrawInfo.confirmTransferUrl,
        timestampBankConfirmed: undefined,
        timestampReserveInfoPosted: undefined,
        wireTypes: withdrawInfo.wireTypes,
        currency: withdrawInfo.currency,
        senderWire: withdrawInfo.senderWire,
        externalConfirmation,
      },
    },
    isForeignAccount: req.isForeignAccount || externalConfirmation,
    reserveStatus: WithdrawalGroupStatus.DialogProposed,
  });

  const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
  const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);

  wex.taskScheduler.startShepherdTask(ctx.taskId);

  return {
    transactionId: ctx.transactionId,
    info,
  };
}

/**
 * Store account as known bank account.
 *
 * Return ID of the new bank account or undefined
 * if the account already exists.
 */
async function storeKnownBankAccount(
  tx: WalletDbReadWriteTransaction<["bankAccountsV2"]>,
  instructedCurrency: string,
  senderWire: string,
): Promise<string | undefined> {
  const existingAccount =
    await tx.bankAccountsV2.indexes.byPaytoUri.get(senderWire);
  if (existingAccount) {
    // Add currency for existing known bank account if necessary
    if (existingAccount.currencies?.includes(instructedCurrency)) {
      existingAccount.currencies = [
        instructedCurrency,
        ...(existingAccount.currencies ?? []),
      ];
      existingAccount.currencies.sort();
      await tx.bankAccountsV2.put(existingAccount);
    }
    return undefined;
  }

  const myId = `acct:${encodeCrock(getRandomBytes(32))}`;
  await tx.bankAccountsV2.put({
    currencies: [instructedCurrency],
    kycCompleted: false,
    paytoUri: senderWire,
    bankAccountId: myId,
    label: undefined,
  });
  return myId;
}

export async function confirmWithdrawal(
  wex: WalletExecutionContext,
  req: ConfirmWithdrawalRequest,
): Promise<AcceptWithdrawalResponse> {
  const parsedTx = parseTransactionIdentifier(req.transactionId);
  let selectedExchange: string = req.exchangeBaseUrl;
  const instructedAmount =
    req.amount == null ? undefined : Amounts.parseOrThrow(req.amount);

  if (parsedTx?.tag !== TransactionType.Withdrawal) {
    throw Error("invalid withdrawal transaction ID");
  }
  const withdrawalGroup = await wex.db.runReadOnlyTx(
    { storeNames: ["withdrawalGroups"] },
    (tx) => tx.withdrawalGroups.get(parsedTx.withdrawalGroupId),
  );

  if (!withdrawalGroup) {
    throw Error("withdrawal group not found");
  }

  if (
    withdrawalGroup.wgInfo.withdrawalType !==
    WithdrawalRecordType.BankIntegrated
  ) {
    throw Error("not a bank integrated withdrawal");
  }

  await wex.db.runReadOnlyTx(
    { storeNames: ["exchangeBaseUrlFixups"] },
    async (tx) => {
      const rec = await tx.exchangeBaseUrlFixups.get(selectedExchange);
      if (rec) {
        selectedExchange = rec.replacement;
      }
    },
  );

  let instructedCurrency: string;
  if (instructedAmount) {
    instructedCurrency = instructedAmount.currency;
  } else {
    if (!withdrawalGroup.wgInfo.bankInfo.currency) {
      throw Error("currency must be provided by bank");
    }
    instructedCurrency = withdrawalGroup.wgInfo.bankInfo.currency;
  }

  const exchange = await fetchFreshExchange(wex, selectedExchange);
  requireExchangeTosAcceptedOrThrow(wex, exchange);

  if (req.amount && checkWithdrawalHardLimitExceeded(exchange, req.amount)) {
    throw Error("withdrawal would exceed hard KYC limit");
  }

  const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri;

  /**
   * The only reason this could be undefined is because it is an old wallet
   * database before adding the prepareWithdrawal feature
   */
  let bankWireTypes: string[];
  let bankCurrency: string;
  if (
    withdrawalGroup.wgInfo.bankInfo.wireTypes === undefined ||
    withdrawalGroup.wgInfo.bankInfo.currency === undefined
  ) {
    const withdrawInfo = await getBankWithdrawalInfo(
      wex.http,
      talerWithdrawUri,
    );
    bankWireTypes = withdrawInfo.wireTypes;
    bankCurrency = withdrawInfo.currency;
  } else {
    bankWireTypes = withdrawalGroup.wgInfo.bankInfo.wireTypes;
    bankCurrency = withdrawalGroup.wgInfo.bankInfo.currency;
  }

  if (exchange.currency !== bankCurrency) {
    throw Error("currency mismatch between exchange and bank");
  }

  const exchangePaytoUri = await getExchangePaytoUri(
    wex,
    selectedExchange,
    bankWireTypes,
  );

  let withdrawalAccountList: WithdrawalExchangeAccountDetails[] = [];
  if (instructedAmount) {
    withdrawalAccountList = await fetchWithdrawalAccountInfo(wex, {
      withdrawalType: withdrawalGroup.wgInfo.withdrawalType,
      exchange,
      instructedAmount,
    });
  }

  const senderWire = withdrawalGroup.wgInfo.bankInfo.senderWire;

  if (senderWire && !withdrawalGroup.isForeignAccount) {
    logger.info(`sender wire is ${senderWire}`);
    const parsedSenderWire = parsePaytoUri(senderWire);
    if (!parsedSenderWire) {
      throw Error("invalid payto URI");
    }
    let acceptable = false;
    for (const acc of withdrawalAccountList) {
      const parsedExchangeWire = parsePaytoUri(acc.paytoUri);
      if (!parsedExchangeWire) {
        continue;
      }
      const checkRes = checkAccountRestriction(
        senderWire,
        acc.creditRestrictions ?? [],
      );
      if (!checkRes.ok) {
        continue;
      }
      acceptable = true;
      break;
    }
    if (!acceptable) {
      // Might be acceptable if it's a withdrawal from a
      // foreign account that is not properly marked as such.
      logger.warn("no account acceptable by the exchange");
      throw Error(
        `Exchange ${selectedExchange} not usable for withdrawal, as account ${senderWire} is not acceptable to the exchange`,
      );
    }

    logger.info(`adding account ${senderWire} to know bank accounts`);

    const bankAccountId = await wex.db.runReadWriteTx(
      { storeNames: ["bankAccountsV2"] },
      (tx) => storeKnownBankAccount(tx, instructedCurrency, senderWire),
    );

    if (bankAccountId) {
      wex.ws.notify({
        type: NotificationType.BankAccountChange,
        bankAccountId,
      });
    }
  }

  const ctx = new WithdrawTransactionContext(
    wex,
    withdrawalGroup.withdrawalGroupId,
  );

  let initialDenoms: DenomSelectionState | undefined;

  if (instructedAmount != null) {
    initialDenoms = await getInitialDenomsSelection(
      wex,
      exchange.exchangeBaseUrl,
      instructedAmount,
      req.forcedDenomSel,
    );
    // Disallow withdrawals with no coins, they don't make sense.
    // We only make an exception for the dev experiment.
    if (
      wex.ws.devExperimentState.pretendNoDenoms != true &&
      initialDenoms.selectedDenoms.length === 0
    ) {
      // Just in case, force talking to the exchange again,
      // so a retry of the withdrawal might work.
      await startUpdateExchangeEntry(wex, exchange.exchangeBaseUrl, {
        forceUpdate: true,
      });
      throw TalerError.fromUncheckedDetail({
        code: TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
        hint: "No denominations could be selected for withdrawal",
      });
    }
  }

  await ctx.transition(
    {
      extraStores: ["exchanges"],
    },
    async (rec, tx) => {
      if (!rec) {
        return TransitionResult.stay();
      }
      switch (rec.status) {
        case WithdrawalGroupStatus.Done:
        case WithdrawalGroupStatus.PendingReady:
        case WithdrawalGroupStatus.PendingRegisteringBank:
        case WithdrawalGroupStatus.PendingWaitConfirmBank: {
          // Be idempotent.
          return TransitionResult.stay();
        }
        case WithdrawalGroupStatus.AbortedOtherWallet: {
          throw TalerError.fromDetail(
            TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
            {},
          );
        }
        case WithdrawalGroupStatus.DialogProposed: {
          rec.exchangeBaseUrl = exchange.exchangeBaseUrl;
          rec.instructedAmount = req.amount;
          rec.restrictAge = req.restrictAge;
          if (initialDenoms != null) {
            rec.denomsSel = initialDenoms;
            rec.rawWithdrawalAmount = initialDenoms.totalWithdrawCost;
            rec.effectiveWithdrawalAmount = initialDenoms.totalCoinValue;
          } else {
            rec.denomsSel = undefined;
            rec.rawWithdrawalAmount = Amounts.stringify(
              Amounts.zeroOfCurrency(instructedCurrency),
            );
            rec.effectiveWithdrawalAmount = Amounts.stringify(
              Amounts.zeroOfCurrency(instructedCurrency),
            );
          }
          checkDbInvariant(
            rec.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated,
            "withdrawal type mismatch",
          );
          rec.wgInfo.exchangeCreditAccounts = withdrawalAccountList;
          rec.wgInfo.bankInfo.exchangePaytoUri = exchangePaytoUri;
          rec.status = WithdrawalGroupStatus.PendingRegisteringBank;

          await internalPerformExchangeWasUsed(
            wex,
            tx,
            exchange.exchangeBaseUrl,
            withdrawalGroup,
          );

          return TransitionResult.transition(rec);
        }

        default: {
          throw TalerError.fromDetail(
            TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED,
            {
              message: `unable to confirm withdrawal in current state`,
              txState: computeWithdrawalTransactionStatus(rec),
              debugStateNum: rec.status,
            },
          );
        }
      }
    },
  );

  await wex.taskScheduler.resetTaskRetries(ctx.taskId);

  return {
    transactionId: req.transactionId as TransactionIdStr,
    confirmTransferUrl: withdrawalGroup.wgInfo.bankInfo.confirmUrl,
  };
}

/**
 * Accept a bank-integrated withdrawal.
 *
 * Before returning, the wallet tries to register the reserve with the bank.
 *
 * Thus after this call returns, the withdrawal operation can be confirmed
 * with the bank.
 */
export async function acceptBankIntegratedWithdrawal(
  wex: WalletExecutionContext,
  req: {
    talerWithdrawUri: string;
    selectedExchange: string;
    forcedDenomSel?: ForcedDenomSel;
    restrictAge?: number;
    amount?: AmountLike;
    isForeignAccount?: boolean;
  },
): Promise<AcceptWithdrawalResponse> {
  wex.oc.observe({
    type: ObservabilityEventType.Message,
    contents: "at start of acceptBankIntegratedWithdrawal",
  });

  const selectedExchange = req.selectedExchange;
  logger.info(
    `preparing withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`,
  );

  const p = await prepareBankIntegratedWithdrawal(wex, {
    talerWithdrawUri: req.talerWithdrawUri,
    isForeignAccount: req.isForeignAccount,
  });

  wex.oc.observe({
    type: ObservabilityEventType.Message,
    contents: "prepared acceptBankIntegratedWithdrawal",
  });

  let amount: AmountString | undefined;
  if (p.info.amount == null) {
    if (req.amount == null) {
      if (p.info.editableAmount) {
        throw Error(
          "amount required, as withdrawal operation has flexible amount",
        );
      }
      // Amount will be determined by the bank only after withdrawal has
      // been confirmed by the wallet.
      amount = undefined;
    } else {
      amount = Amounts.stringify(req.amount);
    }
  } else {
    if (req.amount == null) {
      amount = p.info.amount;
    } else {
      if (
        Amounts.cmp(p.info.amount, req.amount) != 0 &&
        !p.info.editableAmount
      ) {
        throw Error(
          `mismatched amount, amount is fixed by bank (${p.info.amount}) but client provided different amount (${req.amount})`,
        );
      }
      amount = Amounts.stringify(req.amount);
    }
  }

  logger.info(`confirming withdrawal with tx ${p.transactionId}`);
  await confirmWithdrawal(wex, {
    amount: amount == null ? undefined : Amounts.stringify(amount),
    exchangeBaseUrl: selectedExchange,
    transactionId: p.transactionId,
    restrictAge: req.restrictAge,
    forcedDenomSel: req.forcedDenomSel,
  });

  wex.oc.observe({
    type: ObservabilityEventType.Message,
    contents: "confirmed acceptBankIntegratedWithdrawal",
  });

  const newWithdrawralGroup = await wex.db.runReadOnlyTx(
    { storeNames: ["withdrawalGroups"] },
    (tx) =>
      tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(req.talerWithdrawUri),
  );

  checkDbInvariant(
    newWithdrawralGroup !== undefined,
    "withdrawal don't exist after confirm",
  );

  return {
    confirmTransferUrl: p.info.confirmTransferUrl,
    transactionId: p.transactionId,
  };
}

async function fetchAccount(
  wex: WalletExecutionContext,
  instructedAmount: AmountJson,
  scopeInfo: ScopeInfo,
  acct: ExchangeWireAccount,
  reservePub: string | undefined,
): Promise<WithdrawalExchangeAccountDetails> {
  let paytoUri: string;
  let transferAmount: AmountString | undefined;
  let currencySpecification: CurrencySpecification | undefined = undefined;
  if (acct.conversion_url != null) {
    const reqUrl = new URL("cashin-rate", acct.conversion_url);
    reqUrl.searchParams.set(
      "amount_credit",
      Amounts.stringify(instructedAmount),
    );
    const httpResp = await cancelableFetch(wex, reqUrl);
    const respOrErr = await readSuccessResponseJsonOrErrorCode(
      httpResp,
      codecForCashinConversionResponse(),
    );
    if (respOrErr.isError) {
      return {
        status: "error",
        paytoUri: acct.payto_uri,
        conversionError: respOrErr.talerErrorResponse,
      };
    }
    const resp = respOrErr.response;
    paytoUri = acct.payto_uri;
    transferAmount = resp.amount_debit;
    const configUrl = new URL("config", acct.conversion_url);
    const configResp = await cancelableFetch(wex, configUrl);
    const configRespOrError = await readSuccessResponseJsonOrErrorCode(
      configResp,
      codecForConversionBankConfig(),
    );
    if (configRespOrError.isError) {
      return {
        status: "error",
        paytoUri: acct.payto_uri,
        conversionError: configRespOrError.talerErrorResponse,
      };
    }
    const configParsed = configRespOrError.response;
    currencySpecification = configParsed.fiat_currency_specification;
  } else {
    paytoUri = acct.payto_uri;
    transferAmount = Amounts.stringify(instructedAmount);

    // fetch currency specification from DB
    const resp = await wex.db.runReadOnlyTx(
      { storeNames: ["currencyInfo"] },
      (tx) => WalletDbHelpers.getCurrencyInfo(tx, scopeInfo),
    );

    if (resp) {
      currencySpecification = resp.currencySpec;
    }
  }
  paytoUri = addPaytoQueryParams(paytoUri, {
    amount: Amounts.stringify(transferAmount),
  });
  if (reservePub != null) {
    paytoUri = addPaytoQueryParams(paytoUri, {
      message: `Taler ${reservePub}`,
    });
  }
  const acctInfo: WithdrawalExchangeAccountDetails = {
    status: "ok",
    paytoUri,
    transferAmount,
    bankLabel: acct.bank_label,
    priority: acct.priority,
    currencySpecification,
    creditRestrictions: acct.credit_restrictions,
  };
  acctInfo.transferAmount = transferAmount;
  return acctInfo;
}

/**
 * Gather information about bank accounts that can be used for
 * withdrawals.  This includes accounts that are in a different
 * currency and require conversion.
 */
async function fetchWithdrawalAccountInfo(
  wex: WalletExecutionContext,
  req: {
    exchange: ReadyExchangeSummary;
    instructedAmount: AmountJson;
    reservePub?: string;
    withdrawalType: WithdrawalRecordType;
  },
): Promise<WithdrawalExchangeAccountDetails[]> {
  switch (req.withdrawalType) {
    case WithdrawalRecordType.PeerPullCredit:
    case WithdrawalRecordType.PeerPushCredit:
    case WithdrawalRecordType.Recoup:
      return [];
  }
  const { exchange } = req;
  const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = [];
  for (let acct of exchange.wireInfo.accounts) {
    const acctInfo = await fetchAccount(
      wex,
      req.instructedAmount,
      req.exchange.scopeInfo,
      acct,
      req.reservePub,
    );
    withdrawalAccounts.push(acctInfo);
  }
  withdrawalAccounts.sort((x1, x2) => {
    // Accounts without explicit priority have prio 0.
    const n1 = x1.priority ?? 0;
    const n2 = x2.priority ?? 0;
    return Math.sign(n2 - n1);
  });
  return withdrawalAccounts;
}

/**
 * Create a manual withdrawal operation.
 *
 * Adds the corresponding exchange as a trusted exchange if it is neither
 * audited nor trusted already.
 *
 * Asynchronously starts the withdrawal.
 */
export async function createManualWithdrawal(
  wex: WalletExecutionContext,
  req: {
    exchangeBaseUrl: string;
    amount: AmountLike;
    restrictAge?: number;
    forcedDenomSel?: ForcedDenomSel;
    forceReservePriv?: EddsaPrivateKeyString;
  },
): Promise<AcceptManualWithdrawalResult> {
  const { exchangeBaseUrl } = req;
  const amount = Amounts.parseOrThrow(req.amount);
  const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);

  if (exchange.currency != amount.currency) {
    throw Error(
      "manual withdrawal with conversion from foreign currency is not yet supported",
    );
  }

  if (checkWithdrawalHardLimitExceeded(exchange, req.amount)) {
    throw Error("withdrawal would exceed hard KYC limit");
  }

  let reserveKeyPair: EddsaKeyPairStrings;
  if (req.forceReservePriv) {
    const pubResp = await wex.cryptoApi.eddsaGetPublic({
      priv: req.forceReservePriv,
    });

    reserveKeyPair = {
      priv: req.forceReservePriv,
      pub: pubResp.pub,
    };
  } else {
    reserveKeyPair = await wex.cryptoApi.createEddsaKeypair({});
  }

  const withdrawalAccountsList = await fetchWithdrawalAccountInfo(wex, {
    exchange,
    instructedAmount: amount,
    reservePub: reserveKeyPair.pub,
    withdrawalType: WithdrawalRecordType.BankManual,
  });

  const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
    amount: amount,
    wgInfo: {
      withdrawalType: WithdrawalRecordType.BankManual,
      exchangeCreditAccounts: withdrawalAccountsList,
    },
    exchangeBaseUrl: req.exchangeBaseUrl,
    forcedDenomSel: req.forcedDenomSel,
    restrictAge: req.restrictAge,
    reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
    reserveKeyPair,
  });

  const ctx = new WithdrawTransactionContext(
    wex,
    withdrawalGroup.withdrawalGroupId,
  );

  const exchangePaytoUris = await wex.db.runReadOnlyTx(
    { storeNames: ["withdrawalGroups", "exchanges", "exchangeDetails"] },
    (tx) => getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId),
  );

  wex.ws.notify({
    type: NotificationType.BalanceChange,
    hintTransactionId: ctx.transactionId,
  });

  wex.taskScheduler.startShepherdTask(ctx.taskId);

  return {
    reservePub: withdrawalGroup.reservePub,
    exchangePaytoUris: exchangePaytoUris,
    withdrawalAccountsList: withdrawalAccountsList,
    transactionId: ctx.transactionId,
  };
}

/**
 * Wait until a withdrawal operation is final.
 */
export async function waitWithdrawalFinal(
  wex: WalletExecutionContext,
  withdrawalGroupId: string,
): Promise<void> {
  const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
  wex.taskScheduler.startShepherdTask(ctx.taskId);

  await genericWaitForState(wex, {
    filterNotification(notif) {
      return (
        notif.type === NotificationType.TransactionStateTransition &&
        notif.transactionId === ctx.transactionId
      );
    },
    async checkState() {
      // Check if withdrawal is final
      const wg = await ctx.wex.db.runReadOnlyTx(
        { storeNames: ["withdrawalGroups"] },
        (tx) => tx.withdrawalGroups.get(ctx.withdrawalGroupId),
      );
      if (!wg) {
        // Must've been deleted, we consider that final.
        return true;
      }
      switch (wg.status) {
        case WithdrawalGroupStatus.AbortedBank:
        case WithdrawalGroupStatus.AbortedExchange:
        case WithdrawalGroupStatus.Done:
        case WithdrawalGroupStatus.FailedAbortingBank:
        case WithdrawalGroupStatus.FailedBankAborted:
          // Transaction is final
          return true;
      }

      return false;
    },
  });
}

export async function getWithdrawalDetailsForAmount(
  wex: WalletExecutionContext,
  req: GetWithdrawalDetailsForAmountRequest,
): Promise<WithdrawalDetailsForAmount> {
  return runWithClientCancellation(
    wex,
    "getWithdrawalDetailsForAmount",
    req.clientCancellationId,
    async () => internalGetWithdrawalDetailsForAmount(wex, req),
  );
}

export async function internalGetWithdrawalDetailsForAmount(
  wex: WalletExecutionContext,
  req: GetWithdrawalDetailsForAmountRequest,
): Promise<WithdrawalDetailsForAmount> {
  let exchangeBaseUrl: string | undefined;
  if (req.exchangeBaseUrl) {
    exchangeBaseUrl = req.exchangeBaseUrl;
  } else if (req.restrictScope) {
    exchangeBaseUrl = await getPreferredExchangeForCurrency(
      wex,
      req.restrictScope.currency,
      req.restrictScope,
    );
  }
  if (!exchangeBaseUrl) {
    throw Error("could not find exchange for withdrawal");
  }
  const wi = await getExchangeWithdrawalInfo(
    wex,
    exchangeBaseUrl,
    Amounts.parseOrThrow(req.amount),
    WithdrawalRecordType.BankManual,
    req.restrictAge,
  );
  let numCoins = 0;
  for (const x of wi.selectedDenoms.selectedDenoms) {
    numCoins += x.count;
  }
  const resp: WithdrawalDetailsForAmount = {
    exchangeBaseUrl,
    amountRaw: req.amount,
    amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue),
    paytoUris: wi.exchangePaytoUris,
    tosAccepted: wi.termsOfServiceAccepted,
    ageRestrictionOptions: wi.ageRestrictionOptions,
    withdrawalAccountsList: wi.exchangeCreditAccountDetails,
    numCoins,
    scopeInfo: wi.scopeInfo,
    kycHardLimit: wi.kycHardLimit,
    kycSoftLimit: wi.kycSoftLimit,
  };
  return resp;
}
