/*
 This file is part of GNU Taler
 (C) 2022-2025 Taler Systems S.A.

 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.

 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

 You should have received a copy of the GNU General Public License along with
 GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

import {
  Amounts,
  CheckPeerPushDebitOkResponse,
  CheckPeerPushDebitRequest,
  CheckPeerPushDebitResponse,
  CoinRefreshRequest,
  ContractTermsUtil,
  ExchangePurseDeposits,
  HostPortPath,
  HttpStatusCode,
  InitiatePeerPushDebitRequest,
  InitiatePeerPushDebitResponse,
  Logger,
  PurseConflict,
  RefreshReason,
  ScopeInfo,
  ScopeType,
  SelectedProspectiveCoin,
  TalerError,
  TalerErrorCode,
  TalerErrorDetail,
  TalerPreciseTimestamp,
  TalerProtocolTimestamp,
  Transaction,
  TransactionAction,
  TransactionIdStr,
  TransactionMajorState,
  TransactionMinorState,
  TransactionState,
  TransactionType,
  assertUnreachable,
  checkDbInvariant,
  encodeCrock,
  getRandomBytes,
  j2s,
  stringifyPayPushUri,
} from "@gnu-taler/taler-util";
import {
  PreviousPayCoins,
  selectPeerCoins,
  selectPeerCoinsInTx,
} from "./coinSelection.js";
import {
  PendingTaskType,
  RecordHandle,
  TaskIdStr,
  TaskRunResult,
  TransactionContext,
  constructTaskIdentifier,
  getGenericRecordHandle,
  runWithClientCancellation,
  spendCoins,
} from "./common.js";
import { EncryptContractRequest } from "./crypto/cryptoTypes.js";
import {
  PeerPushDebitRecord,
  PeerPushDebitStatus,
  WalletIndexedDbTransaction,
  timestampPreciseFromDb,
  timestampPreciseToDb,
  timestampProtocolFromDb,
  timestampProtocolToDb,
} from "./db.js";
import {
  fetchFreshExchange,
  getPreferredExchangeForCurrency,
  getScopeForAllExchanges,
} from "./exchanges.js";
import {
  getTotalPeerPaymentCost,
  getTotalPeerPaymentCostInTx,
  isPurseMerged,
  queryCoinInfosForSelection,
} from "./pay-peer-common.js";
import { createRefreshGroup } from "./refresh.js";
import {
  constructTransactionIdentifier,
  isUnsuccessfulTransaction,
} from "./transactions.js";
import { WalletExecutionContext, walletExchangeClient } from "./wallet.js";
import { updateWithdrawalDenomsForCurrency } from "./withdraw.js";

const logger = new Logger("pay-peer-push-debit.ts");

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

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

  async updateTransactionMeta(tx: WalletIndexedDbTransaction): Promise<void> {
    const rec = await tx.peerPushDebit.get(this.pursePub);
    if (rec == null) {
      await tx.transactionsMeta.delete(this.pursePub);
    } else {
      await tx.transactionsMeta.put({
        currency: Amounts.currencyOf(rec.amount),
        exchanges: [rec.exchangeBaseUrl],
        status: rec.status,
        timestamp: rec.timestampCreated,
        transactionId: this.transactionId,
      });
    }
  }

  /**
   * 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: WalletIndexedDbTransaction,
  ): Promise<Transaction | undefined> {
    const pushDebitRec = await tx.peerPushDebit.get(this.pursePub);
    if (pushDebitRec == null) {
      return undefined;
    }
    const retryRec = await tx.operationRetries.get(this.taskId);

    const ctRec = await tx.contractTerms.get(pushDebitRec.contractTermsHash);
    checkDbInvariant(
      !!ctRec,
      `no contract terms for p2p push ${this.pursePub}`,
    );

    const contractTerms = ctRec.contractTermsRaw;

    let talerUri: string | undefined = undefined;
    switch (pushDebitRec.status) {
      case PeerPushDebitStatus.PendingReady:
      case PeerPushDebitStatus.SuspendedReady:
        talerUri = stringifyPayPushUri({
          exchangeBaseUrl: pushDebitRec.exchangeBaseUrl as HostPortPath, // FIXME: change record type
          contractPriv: pushDebitRec.contractPriv,
        });
    }
    const txState = computePeerPushDebitTransactionState(pushDebitRec);
    return {
      type: TransactionType.PeerPushDebit,
      txState,
      stId: pushDebitRec.status,
      scopes: await getScopeForAllExchanges(tx, [pushDebitRec.exchangeBaseUrl]),
      txActions: computePeerPushDebitTransactionActions(pushDebitRec),
      amountEffective: isUnsuccessfulTransaction(txState)
        ? Amounts.stringify(Amounts.zeroOfAmount(pushDebitRec.totalCost))
        : pushDebitRec.totalCost,
      amountRaw: pushDebitRec.amount,
      exchangeBaseUrl: pushDebitRec.exchangeBaseUrl,
      info: {
        expiration: contractTerms.purse_expiration,
        summary: contractTerms.summary,
        iconId: contractTerms.icon_id,
      },
      timestamp: timestampPreciseFromDb(pushDebitRec.timestampCreated),
      talerUri,
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.PeerPushDebit,
        pursePub: pushDebitRec.pursePub,
      }),
      failReason: pushDebitRec.failReason,
      abortReason: pushDebitRec.abortReason,
      ...(retryRec?.lastError ? { error: retryRec.lastError } : {}),
    };
  }

  async getRecordHandle(
    tx: WalletIndexedDbTransaction,
  ): Promise<
    [PeerPushDebitRecord | undefined, RecordHandle<PeerPushDebitRecord>]
  > {
    return getGenericRecordHandle<PeerPushDebitRecord>(
      this,
      tx as any,
      async () => tx.peerPushDebit.get(this.pursePub),
      async (r) => {
        await tx.peerPushDebit.put(r);
      },
      async () => tx.peerPushDebit.delete(this.pursePub),
      (r) => computePeerPushDebitTransactionState(r),
      (r) => r.status,
      () => this.updateTransactionMeta(tx),
    );
  }

  async userDeleteTransaction(): Promise<void> {
    await this.wex.runLegacyWalletDbTx(async (tx) => {
      await this.deleteTransactionInTx(tx);
    });
  }

  async deleteTransactionInTx(tx: WalletIndexedDbTransaction): Promise<void> {
    const [rec, h] = await this.getRecordHandle(tx);
    if (!rec) {
      return;
    }
    await h.update(undefined);
  }

  async userSuspendTransaction(): Promise<void> {
    await this.wex.runLegacyWalletDbTx(async (tx) => {
      const [rec, h] = await this.getRecordHandle(tx);
      if (!rec) {
        return;
      }
      switch (rec.status) {
        case PeerPushDebitStatus.PendingCreatePurse:
          rec.status = PeerPushDebitStatus.SuspendedCreatePurse;
          break;
        case PeerPushDebitStatus.AbortingDeletePurse:
          rec.status = PeerPushDebitStatus.SuspendedAbortingDeletePurse;
          break;
        case PeerPushDebitStatus.PendingReady:
          rec.status = PeerPushDebitStatus.SuspendedReady;
          break;
        case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
        case PeerPushDebitStatus.SuspendedReady:
        case PeerPushDebitStatus.SuspendedCreatePurse:
        case PeerPushDebitStatus.Done:
        case PeerPushDebitStatus.Aborted:
        case PeerPushDebitStatus.Failed:
        case PeerPushDebitStatus.Expired:
          // Do nothing
          return;
        default:
          assertUnreachable(rec.status);
      }
      await h.update(rec);
    });
    this.wex.taskScheduler.stopShepherdTask(this.taskId);
  }

  async userAbortTransaction(reason?: TalerErrorDetail): Promise<void> {
    await this.wex.runLegacyWalletDbTx(async (tx) => {
      const [rec, h] = await this.getRecordHandle(tx);
      if (!rec) {
        return;
      }
      switch (rec.status) {
        case PeerPushDebitStatus.PendingReady:
        case PeerPushDebitStatus.SuspendedReady:
          rec.abortReason = reason;
          rec.status = PeerPushDebitStatus.AbortingDeletePurse;
          break;
        case PeerPushDebitStatus.SuspendedCreatePurse:
        case PeerPushDebitStatus.PendingCreatePurse:
          // Network request might already be in-flight!
          rec.abortReason = reason;
          rec.status = PeerPushDebitStatus.AbortingDeletePurse;
          break;
        case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
        case PeerPushDebitStatus.Done:
        case PeerPushDebitStatus.AbortingDeletePurse:
        case PeerPushDebitStatus.Aborted:
        case PeerPushDebitStatus.Expired:
        case PeerPushDebitStatus.Failed:
          // Do nothing
          return;
        default:
          assertUnreachable(rec.status);
      }
      await h.update(rec);
    });
    this.wex.taskScheduler.stopShepherdTask(this.taskId);
    this.wex.taskScheduler.startShepherdTask(this.taskId);
  }

  async userResumeTransaction(): Promise<void> {
    await this.wex.runLegacyWalletDbTx(async (tx) => {
      const [rec, h] = await this.getRecordHandle(tx);
      if (!rec) {
        return;
      }
      switch (rec.status) {
        case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
          rec.status = PeerPushDebitStatus.AbortingDeletePurse;
          break;
        case PeerPushDebitStatus.SuspendedReady:
          rec.status = PeerPushDebitStatus.PendingReady;
          break;
        case PeerPushDebitStatus.SuspendedCreatePurse:
          rec.status = PeerPushDebitStatus.PendingCreatePurse;
          break;
        case PeerPushDebitStatus.PendingCreatePurse:
        case PeerPushDebitStatus.AbortingDeletePurse:
        case PeerPushDebitStatus.PendingReady:
        case PeerPushDebitStatus.Done:
        case PeerPushDebitStatus.Aborted:
        case PeerPushDebitStatus.Failed:
        case PeerPushDebitStatus.Expired:
          // Do nothing
          return;
        default:
          assertUnreachable(rec.status);
      }
      await h.update(rec);
    });
    this.wex.taskScheduler.startShepherdTask(this.taskId);
  }

  async failTransaction(
    fromSt: PeerPushDebitStatus,
    reason?: TalerErrorDetail,
  ): Promise<void> {
    const { wex } = this;
    await wex.runLegacyWalletDbTx(async (tx) => {
      const [rec, h] = await this.getRecordHandle(tx);
      if (rec?.status != fromSt) {
        return;
      }
      rec.status = PeerPushDebitStatus.Failed;
      rec.failReason = reason;
      await h.update(rec);
    });
  }

  async userFailTransaction(reason?: TalerErrorDetail): Promise<void> {
    await this.wex.runLegacyWalletDbTx(async (tx) => {
      const [rec, h] = await this.getRecordHandle(tx);
      if (!rec) {
        return;
      }
      switch (rec.status) {
        case PeerPushDebitStatus.AbortingDeletePurse:
        case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
        case PeerPushDebitStatus.PendingReady:
        case PeerPushDebitStatus.SuspendedReady:
        case PeerPushDebitStatus.SuspendedCreatePurse:
        case PeerPushDebitStatus.PendingCreatePurse:
          rec.status = PeerPushDebitStatus.Failed;
          rec.failReason = reason;
          break;
        case PeerPushDebitStatus.Done:
        case PeerPushDebitStatus.Aborted:
        case PeerPushDebitStatus.Failed:
        case PeerPushDebitStatus.Expired:
          // Do nothing
          return;
        default:
          assertUnreachable(rec.status);
      }
      await h.update(rec);
    });
    this.wex.taskScheduler.stopShepherdTask(this.taskId);
    this.wex.taskScheduler.startShepherdTask(this.taskId);
  }
}

/**
 * @deprecated use V2 instead
 */
export async function checkPeerPushDebit(
  wex: WalletExecutionContext,
  req: CheckPeerPushDebitRequest,
): Promise<CheckPeerPushDebitOkResponse> {
  const res = await checkPeerPushDebitV2(wex, req);
  switch (res.type) {
    case "ok":
      return res;
    case "insufficient-balance":
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
        {
          insufficientBalanceDetails: res.insufficientBalanceDetails,
        },
      );
    default:
      assertUnreachable(res);
  }
}

export async function checkPeerPushDebitV2(
  wex: WalletExecutionContext,
  req: CheckPeerPushDebitRequest,
): Promise<CheckPeerPushDebitResponse> {
  return runWithClientCancellation(
    wex,
    "checkPeerPushDebit",
    req.clientCancellationId,
    () => internalCheckPeerPushDebit(wex, req),
  );
}

async function internalCheckPeerPushDebit(
  wex: WalletExecutionContext,
  req: CheckPeerPushDebitRequest,
): Promise<CheckPeerPushDebitResponse> {
  const instructedAmount = Amounts.parseOrThrow(req.amount);
  const currency = instructedAmount.currency;
  logger.trace(
    `checking peer push debit for ${Amounts.stringify(instructedAmount)}`,
  );
  let restrictScope: ScopeInfo | undefined = undefined;
  if (req.restrictScope) {
    restrictScope = req.restrictScope;
  } else if (req.exchangeBaseUrl) {
    restrictScope = {
      type: ScopeType.Exchange,
      currency,
      url: req.exchangeBaseUrl,
    };
  }
  if (Amounts.isZero(req.amount)) {
    const exchangeBaseUrl = await getPreferredExchangeForCurrency(
      wex,
      currency,
      restrictScope,
    );
    if (!exchangeBaseUrl) {
      throw Error("no exchange found for payment");
    }
    return {
      type: "ok",
      amountEffective: req.amount,
      amountRaw: req.amount,
      exchangeBaseUrl,
      maxExpirationDate: TalerProtocolTimestamp.never(),
    };
  }
  const coinSelRes = await selectPeerCoins(wex, {
    instructedAmount,
    restrictScope,
    feesCoveredByCounterparty: false,
  });
  let coins: SelectedProspectiveCoin[] | undefined = undefined;
  switch (coinSelRes.type) {
    case "failure":
      return {
        type: "insufficient-balance",
        insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
      };
    case "prospective":
      coins = coinSelRes.result.prospectiveCoins;
      break;
    case "success":
      coins = coinSelRes.result.coins;
      break;
    default:
      assertUnreachable(coinSelRes);
  }
  logger.trace(`selected peer coins (len=${coins.length})`);
  // We might need to do a refresh later, so it's important
  // that we have up-to-date info about the exchange.
  await fetchFreshExchange(wex, coinSelRes.result.exchangeBaseUrl);
  const totalAmount = await getTotalPeerPaymentCost(wex, coins);
  logger.trace("computed total peer payment cost");
  return {
    type: "ok",
    exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
    amountEffective: Amounts.stringify(totalAmount),
    amountRaw: req.amount,
    maxExpirationDate: coinSelRes.result.maxExpirationDate,
  };
}

async function handlePurseCreationConflict(
  wex: WalletExecutionContext,
  peerPushInitiation: PeerPushDebitRecord,
  conflict: PurseConflict,
): Promise<TaskRunResult> {
  const pursePub = peerPushInitiation.pursePub;
  const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
  if (conflict.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
    await ctx.failTransaction(peerPushInitiation.status, { ...conflict });
    return TaskRunResult.finished();
  }

  const brokenCoinPub = conflict.coin_pub;
  logger.trace(`excluded broken coin pub=${brokenCoinPub}`);

  const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount);
  const currency = instructedAmount.currency;
  const sel = peerPushInitiation.coinSel;
  const exchangeBaseUrl = peerPushInitiation.exchangeBaseUrl;

  checkDbInvariant(
    !!sel,
    `no coin selected for peer push initiation ${peerPushInitiation.pursePub}`,
  );

  const repair: PreviousPayCoins = [];

  for (let i = 0; i < sel.coinPubs.length; i++) {
    if (sel.coinPubs[i] != brokenCoinPub) {
      repair.push({
        coinPub: sel.coinPubs[i],
        contribution: Amounts.parseOrThrow(sel.contributions[i]),
      });
    }
  }

  // FIXME: We don't handle the case where we would
  // have sufficient funds at another exchange,
  // but not at the one selected first. Tricky!
  const coinSelRes = await selectPeerCoins(wex, {
    instructedAmount,
    restrictScope: {
      type: ScopeType.Exchange,
      currency,
      url: exchangeBaseUrl,
    },
    repair,
    feesCoveredByCounterparty: false,
  });

  switch (coinSelRes.type) {
    case "failure":
    case "prospective":
      // FIXME: Details!
      throw Error(
        "insufficient balance to re-select coins to repair double spending",
      );
    case "success":
      break;
    default:
      assertUnreachable(coinSelRes);
  }

  await ctx.wex.runLegacyWalletDbTx(async (tx) => {
    const [rec, h] = await ctx.getRecordHandle(tx);
    if (!rec) {
      return;
    }
    switch (rec.status) {
      case PeerPushDebitStatus.PendingCreatePurse:
      case PeerPushDebitStatus.SuspendedCreatePurse: {
        const sel = coinSelRes.result;
        rec.coinSel = {
          coinPubs: sel.coins.map((x) => x.coinPub),
          contributions: sel.coins.map((x) => x.contribution),
        };
        await h.update(rec);
      }
    }
  });
  return TaskRunResult.progress();
}

async function processPeerPushDebitCreateReserve(
  wex: WalletExecutionContext,
  peerPushInitiation: PeerPushDebitRecord,
): Promise<TaskRunResult> {
  const { pursePub, purseExpiration, contractTermsHash, exchangeBaseUrl } =
    peerPushInitiation;
  const ctx = new PeerPushDebitTransactionContext(wex, pursePub);

  logger.trace(`processing ${ctx.transactionId} pending(create-reserve)`);

  const contractTermsRecord = await wex.runLegacyWalletDbTx(async (tx) => {
    return tx.contractTerms.get(contractTermsHash);
  });

  if (!contractTermsRecord) {
    throw Error(
      `db invariant failed, contract terms for ${ctx.transactionId} missing`,
    );
  }

  const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount);
  const currency = instructedAmount.currency;

  if (!peerPushInitiation.coinSel) {
    const coinSelRes = await selectPeerCoins(wex, {
      instructedAmount,
      restrictScope: {
        type: ScopeType.Exchange,
        currency,
        url: exchangeBaseUrl,
      },
      feesCoveredByCounterparty: false,
    });

    switch (coinSelRes.type) {
      case "failure":
        throw TalerError.fromDetail(
          TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
          {
            insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
          },
        );
      case "prospective":
        throw Error("insufficient funds (blocked on refresh)");
      case "success":
        break;
      default:
        assertUnreachable(coinSelRes);
    }

    return await wex.runLegacyWalletDbTx(async (tx) => {
      const [rec, h] = await ctx.getRecordHandle(tx);
      if (!rec) {
        return TaskRunResult.backoff();
      }
      if (rec.coinSel != null) {
        return TaskRunResult.backoff();
      }
      rec.coinSel = {
        coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
        contributions: coinSelRes.result.coins.map((x) => x.contribution),
      };
      // FIXME: Instead of directly doing a spendCoin here,
      // we might want to mark the coins as used and spend them
      // after we've been able to create the purse.
      await spendCoins(wex, tx, {
        transactionId: ctx.transactionId,
        coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
        contributions: coinSelRes.result.coins.map((x) =>
          Amounts.parseOrThrow(x.contribution),
        ),
        refreshReason: RefreshReason.PayPeerPush,
      });
      await h.update(rec);
      return TaskRunResult.progress();
    });
  }

  const purseAmount = peerPushInitiation.amount;

  const purseSigResp = await wex.cryptoApi.signPurseCreation({
    hContractTerms: contractTermsHash,
    mergePub: peerPushInitiation.mergePub,
    minAge: 0,
    purseAmount,
    purseExpiration: timestampProtocolFromDb(purseExpiration),
    pursePriv: peerPushInitiation.pursePriv,
  });

  const coins = await queryCoinInfosForSelection(
    wex,
    peerPushInitiation.coinSel,
  );

  const encryptContractRequest: EncryptContractRequest = {
    contractTerms: contractTermsRecord.contractTermsRaw,
    mergePriv: peerPushInitiation.mergePriv,
    pursePriv: peerPushInitiation.pursePriv,
    pursePub: peerPushInitiation.pursePub,
    contractPriv: peerPushInitiation.contractPriv,
    contractPub: peerPushInitiation.contractPub,
    nonce: peerPushInitiation.contractEncNonce,
  };

  const econtractResp = await wex.cryptoApi.encryptContractForMerge(
    encryptContractRequest,
  );

  const maxBatchSize = 64;
  const exchangeClient = walletExchangeClient(exchangeBaseUrl, wex);

  for (let i = 0; i < coins.length; i += maxBatchSize) {
    const batchSize = Math.min(maxBatchSize, coins.length - i);
    const batchCoins = coins.slice(i, i + batchSize);

    const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
      exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
      pursePub: peerPushInitiation.pursePub,
      coins: batchCoins,
    });

    if (i == 0) {
      // First batch creates the purse!

      logger.trace(`encrypt contract request: ${j2s(encryptContractRequest)}`);

      const reqBody = {
        // Older wallets do not have amountPurse
        amount: purseAmount,
        merge_pub: peerPushInitiation.mergePub,
        purse_sig: purseSigResp.sig,
        h_contract_terms: contractTermsHash,
        purse_expiration: timestampProtocolFromDb(purseExpiration),
        deposits: depositSigsResp.deposits,
        min_age: 0,
        econtract: econtractResp.econtract,
      };

      const resp = await exchangeClient.createPurseFromDeposit(
        peerPushInitiation.pursePub,
        reqBody,
      );
      switch (resp.case) {
        case "ok":
          // Possibly on to the next batch.
          continue;
        case HttpStatusCode.Forbidden:
        case HttpStatusCode.NotFound:
          await ctx.failTransaction(peerPushInitiation.status, resp.detail);
          return TaskRunResult.finished();
        case HttpStatusCode.Conflict:
          return handlePurseCreationConflict(
            wex,
            peerPushInitiation,
            resp.body,
          );
        case HttpStatusCode.TooEarly:
          return TaskRunResult.backoff();
        default:
          assertUnreachable(resp);
      }
    } else {
      const depositPayload: ExchangePurseDeposits = {
        deposits: depositSigsResp.deposits,
      };
      const resp = await exchangeClient.depositIntoPurse(
        peerPushInitiation.pursePub,
        depositPayload,
      );
      switch (resp.case) {
        case "ok":
          // Possibly on to the next batch.
          continue;
        case HttpStatusCode.Gone:
          // FIXME we need PeerPushDebitStatus.ExpiredDeletePurse
          await wex.runLegacyWalletDbTx(async (tx) => {
            const [rec, h] = await ctx.getRecordHandle(tx);
            if (!rec) {
              return;
            }
            switch (rec.status) {
              case PeerPushDebitStatus.PendingCreatePurse:
                rec.status = PeerPushDebitStatus.AbortingDeletePurse;
                break;
              default:
                // Do nothing
                return;
            }
            await h.update(rec);
          });
          return TaskRunResult.progress();
        case HttpStatusCode.Conflict:
          // Handle double-spending
          return handlePurseCreationConflict(
            wex,
            peerPushInitiation,
            resp.body,
          );
        case HttpStatusCode.Forbidden:
        case HttpStatusCode.NotFound:
          await ctx.failTransaction(peerPushInitiation.status, resp.detail);
          return TaskRunResult.finished();
        default:
          assertUnreachable(resp);
      }
    }
  }

  // All batches done!
  const resp = await exchangeClient.getPurseStatusAtDeposit(pursePub);
  switch (resp.case) {
    case "ok":
      await wex.runLegacyWalletDbTx(async (tx) => {
        const [rec, h] = await ctx.getRecordHandle(tx);
        if (!rec) {
          return;
        }
        switch (rec.status) {
          case PeerPushDebitStatus.PendingCreatePurse:
            rec.status = PeerPushDebitStatus.PendingReady;
            break;
          default:
            // Do nothing
            return;
        }
        await h.update(rec);
      });
      return TaskRunResult.progress();
    case HttpStatusCode.Gone:
      // FIXME we need PeerPushDebitStatus.ExpiredDeletePurse
      await wex.runLegacyWalletDbTx(async (tx) => {
        const [rec, h] = await ctx.getRecordHandle(tx);
        if (!rec) {
          return;
        }
        switch (rec.status) {
          case PeerPushDebitStatus.PendingCreatePurse:
            rec.status = PeerPushDebitStatus.AbortingDeletePurse;
            break;
          default:
            // Do nothing
            return;
        }
        await h.update(rec);
      });
      return TaskRunResult.progress();
    case HttpStatusCode.NotFound:
      await ctx.failTransaction(peerPushInitiation.status, resp.detail);
      return TaskRunResult.finished();
    default:
      assertUnreachable(resp);
  }
}

async function processPeerPushDebitAbortingDeletePurse(
  wex: WalletExecutionContext,
  peerPushInitiation: PeerPushDebitRecord,
): Promise<TaskRunResult> {
  const { pursePub, pursePriv, exchangeBaseUrl } = peerPushInitiation;
  const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
  const exchangeClient = walletExchangeClient(exchangeBaseUrl, wex);
  const sigResp = await wex.cryptoApi.signDeletePurse({
    pursePriv,
  });
  const resp = await exchangeClient.deletePurse(pursePub, sigResp.sig);

  switch (resp.case) {
    case "ok":
    case HttpStatusCode.NotFound:
      break;
    case HttpStatusCode.Conflict:
      throw Error("purse deletion conflict");
    case HttpStatusCode.Forbidden:
      await ctx.failTransaction(peerPushInitiation.status, resp.detail);
      return TaskRunResult.finished();
  }

  const currency = Amounts.currencyOf(peerPushInitiation.amount);

  await updateWithdrawalDenomsForCurrency(wex, currency);

  await wex.runLegacyWalletDbTx(async (tx) => {
    const [rec, h] = await ctx.getRecordHandle(tx);
    if (!rec) {
      return;
    }

    if (rec.status !== PeerPushDebitStatus.AbortingDeletePurse) {
      return;
    }

    const currency = Amounts.currencyOf(rec.amount);
    const coinPubs: CoinRefreshRequest[] = [];

    if (!rec.coinSel) {
      return;
    }

    for (let i = 0; i < rec.coinSel.coinPubs.length; i++) {
      coinPubs.push({
        amount: rec.coinSel.contributions[i],
        coinPub: rec.coinSel.coinPubs[i],
      });
    }
    rec.status = PeerPushDebitStatus.Aborted;

    const refresh = await createRefreshGroup(
      wex,
      tx,
      currency,
      coinPubs,
      RefreshReason.AbortPeerPushDebit,
      ctx.transactionId,
    );
    rec.abortRefreshGroupId = refresh.refreshGroupId;

    await h.update(rec);
  });

  return TaskRunResult.backoff();
}

/**
 * Process the "pending(ready)" state of a peer-push-debit transaction.
 */
async function processPeerPushDebitReady(
  wex: WalletExecutionContext,
  peerPushInitiation: PeerPushDebitRecord,
): Promise<TaskRunResult> {
  logger.trace("processing peer-push-debit pending(ready)");
  const pursePub = peerPushInitiation.pursePub;
  const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
  const exchangeClient = walletExchangeClient(
    peerPushInitiation.exchangeBaseUrl,
    wex,
  );
  const resp = await exchangeClient.getPurseStatusAtMerge(pursePub, true);

  switch (resp.case) {
    case "ok": {
      if (!isPurseMerged(resp.body)) {
        return TaskRunResult.longpollReturnedPending();
      } else {
        await wex.runLegacyWalletDbTx(async (tx) => {
          const [rec, h] = await ctx.getRecordHandle(tx);
          if (!rec) {
            return;
          }
          switch (rec.status) {
            case PeerPushDebitStatus.PendingReady:
              rec.status = PeerPushDebitStatus.Done;
              break;
            default:
              // Do nothing
              return;
          }
          await h.update(rec);
        });
        return TaskRunResult.progress();
      }
    }
    case HttpStatusCode.Gone:
      logger.info(`purse ${pursePub} is gone, aborting peer-push-debit`);
      await wex.runLegacyWalletDbTx(async (tx) => {
        const [rec, h] = await ctx.getRecordHandle(tx);
        if (!rec) {
          return;
        }
        if (rec.status !== PeerPushDebitStatus.PendingReady) {
          return;
        }
        const currency = Amounts.currencyOf(rec.amount);
        const coinPubs: CoinRefreshRequest[] = [];

        if (rec.coinSel) {
          for (let i = 0; i < rec.coinSel.coinPubs.length; i++) {
            coinPubs.push({
              amount: rec.coinSel.contributions[i],
              coinPub: rec.coinSel.coinPubs[i],
            });
          }

          const refresh = await createRefreshGroup(
            wex,
            tx,
            currency,
            coinPubs,
            RefreshReason.AbortPeerPushDebit,
            ctx.transactionId,
          );

          rec.abortRefreshGroupId = refresh.refreshGroupId;
        }
        rec.status = PeerPushDebitStatus.Aborted;
        await h.update(rec);
      });
      return TaskRunResult.backoff();
    case HttpStatusCode.NotFound:
      throw Error("peer push credit disappeared");
    default:
      assertUnreachable(resp);
  }
}

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

  const peerPushInitiation = await wex.runLegacyWalletDbTx(async (tx) => {
    return tx.peerPushDebit.get(pursePub);
  });
  if (!peerPushInitiation) {
    throw Error("peer push payment not found");
  }

  switch (peerPushInitiation.status) {
    case PeerPushDebitStatus.PendingCreatePurse:
      return processPeerPushDebitCreateReserve(wex, peerPushInitiation);
    case PeerPushDebitStatus.PendingReady:
      return processPeerPushDebitReady(wex, peerPushInitiation);
    case PeerPushDebitStatus.AbortingDeletePurse:
      return processPeerPushDebitAbortingDeletePurse(wex, peerPushInitiation);
    default: {
      const txState = computePeerPushDebitTransactionState(peerPushInitiation);
      logger.warn(
        `not processing peer-push-debit transaction in state ${j2s(txState)}`,
      );
    }
  }

  return TaskRunResult.finished();
}

/**
 * Initiate sending a peer-to-peer push payment.
 */
export async function initiatePeerPushDebit(
  wex: WalletExecutionContext,
  req: InitiatePeerPushDebitRequest,
): Promise<InitiatePeerPushDebitResponse> {
  const instructedAmount = Amounts.parseOrThrow(
    req.partialContractTerms.amount,
  );
  const purseExpiration = req.partialContractTerms.purse_expiration;
  const contractTerms = { ...req.partialContractTerms };

  const pursePair = await wex.cryptoApi.createEddsaKeypair({});
  const mergePair = await wex.cryptoApi.createEddsaKeypair({});

  const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({});

  const pursePub = pursePair.pub;

  const ctx = new PeerPushDebitTransactionContext(wex, pursePub);

  const contractEncNonce = encodeCrock(getRandomBytes(24));

  const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);

  await updateWithdrawalDenomsForCurrency(wex, instructedAmount.currency);

  let exchangeBaseUrl;

  await wex.runLegacyWalletDbTx(async (tx) => {
    const coinSelRes = await selectPeerCoinsInTx(wex, tx, {
      instructedAmount,
      // Any (single!) exchange that is in scope works.
      restrictScope: req.restrictScope,
      feesCoveredByCounterparty: false,
    });

    let coins: SelectedProspectiveCoin[] | undefined = undefined;

    switch (coinSelRes.type) {
      case "failure":
        throw TalerError.fromDetail(
          TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
          {
            insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
          },
        );
      case "prospective":
        coins = coinSelRes.result.prospectiveCoins;
        break;
      case "success":
        coins = coinSelRes.result.coins;
        break;
      default:
        assertUnreachable(coinSelRes);
    }

    logger.trace(j2s(coinSelRes));

    const sel = coinSelRes.result;

    logger.trace(
      `peer debit instructed amount: ${Amounts.stringify(instructedAmount)}`,
    );
    logger.trace(
      `peer debit contract terms amount: ${Amounts.stringify(
        contractTerms.amount,
      )}`,
    );
    logger.trace(
      `peer debit deposit fees: ${Amounts.stringify(sel.totalDepositFees)}`,
    );

    const totalAmount = await getTotalPeerPaymentCostInTx(wex, tx, coins);
    const ppi: PeerPushDebitRecord = {
      amount: Amounts.stringify(instructedAmount),
      restrictScope: req.restrictScope,
      contractPriv: contractKeyPair.priv,
      contractPub: contractKeyPair.pub,
      contractTermsHash: hContractTerms,
      exchangeBaseUrl: sel.exchangeBaseUrl,
      mergePriv: mergePair.priv,
      mergePub: mergePair.pub,
      purseExpiration: timestampProtocolToDb(purseExpiration),
      pursePriv: pursePair.priv,
      pursePub: pursePair.pub,
      timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
      status: PeerPushDebitStatus.PendingCreatePurse,
      contractEncNonce,
      totalCost: Amounts.stringify(totalAmount),
    };

    if (coinSelRes.type === "success") {
      ppi.coinSel = {
        coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
        contributions: coinSelRes.result.coins.map((x) => x.contribution),
      };
      // FIXME: Instead of directly doing a spendCoin here,
      // we might want to mark the coins as used and spend them
      // after we've been able to create the purse.
      await spendCoins(wex, tx, {
        transactionId: ctx.transactionId,
        coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
        contributions: coinSelRes.result.coins.map((x) =>
          Amounts.parseOrThrow(x.contribution),
        ),
        refreshReason: RefreshReason.PayPeerPush,
      });
    }
    await tx.contractTerms.put({
      h: hContractTerms,
      contractTermsRaw: contractTerms,
    });
    exchangeBaseUrl = coinSelRes.result.exchangeBaseUrl;
    const [oldRec, h] = await ctx.getRecordHandle(tx);
    if (oldRec) {
      throw Error("record for peer-push-debit already exists");
    }
    await h.update(ppi);
  });

  wex.taskScheduler.startShepherdTask(ctx.taskId);

  return {
    contractPriv: contractKeyPair.priv,
    mergePriv: mergePair.priv,
    pursePub: pursePair.pub,
    exchangeBaseUrl: exchangeBaseUrl!,
    transactionId: constructTransactionIdentifier({
      tag: TransactionType.PeerPushDebit,
      pursePub: pursePair.pub,
    }),
  };
}

export function computePeerPushDebitTransactionActions(
  ppiRecord: PeerPushDebitRecord,
): TransactionAction[] {
  switch (ppiRecord.status) {
    case PeerPushDebitStatus.PendingCreatePurse:
      return [
        TransactionAction.Retry,
        TransactionAction.Abort,
        TransactionAction.Suspend,
      ];
    case PeerPushDebitStatus.PendingReady:
      return [
        TransactionAction.Retry,
        TransactionAction.Abort,
        TransactionAction.Suspend,
      ];
    case PeerPushDebitStatus.Aborted:
      return [TransactionAction.Delete];
    case PeerPushDebitStatus.AbortingDeletePurse:
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Fail,
      ];
    case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
      return [TransactionAction.Resume, TransactionAction.Fail];
    case PeerPushDebitStatus.SuspendedCreatePurse:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case PeerPushDebitStatus.SuspendedReady:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case PeerPushDebitStatus.Done:
      return [TransactionAction.Delete];
    case PeerPushDebitStatus.Expired:
      return [TransactionAction.Delete];
    case PeerPushDebitStatus.Failed:
      return [TransactionAction.Delete];
  }
}

export function computePeerPushDebitTransactionState(
  ppiRecord: PeerPushDebitRecord,
): TransactionState {
  switch (ppiRecord.status) {
    case PeerPushDebitStatus.PendingCreatePurse:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.CreatePurse,
      };
    case PeerPushDebitStatus.PendingReady:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.Ready,
      };
    case PeerPushDebitStatus.Aborted:
      return {
        major: TransactionMajorState.Aborted,
      };
    case PeerPushDebitStatus.AbortingDeletePurse:
      return {
        major: TransactionMajorState.Aborting,
        minor: TransactionMinorState.DeletePurse,
      };
    case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
      return {
        major: TransactionMajorState.SuspendedAborting,
        minor: TransactionMinorState.DeletePurse,
      };
    case PeerPushDebitStatus.SuspendedCreatePurse:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.CreatePurse,
      };
    case PeerPushDebitStatus.SuspendedReady:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.Ready,
      };
    case PeerPushDebitStatus.Done:
      return {
        major: TransactionMajorState.Done,
      };
    case PeerPushDebitStatus.Failed:
      return {
        major: TransactionMajorState.Failed,
      };
    case PeerPushDebitStatus.Expired:
      return {
        major: TransactionMajorState.Expired,
      };
  }
}
