/*
 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,
  WalletNotification,
  assertUnreachable,
  checkDbInvariant,
  encodeCrock,
  getRandomBytes,
  j2s,
  stringifyPayPushUri,
} from "@gnu-taler/taler-util";
import {
  PreviousPayCoins,
  selectPeerCoins,
  selectPeerCoinsInTx,
} from "./coinSelection.js";
import {
  PendingTaskType,
  TaskIdStr,
  TaskRunResult,
  TransactionContext,
  TransitionResultType,
  constructTaskIdentifier,
  runWithClientCancellation,
  spendCoins,
} from "./common.js";
import { EncryptContractRequest } from "./crypto/cryptoTypes.js";
import {
  PeerPushDebitRecord,
  PeerPushDebitStatus,
  WalletDbAllStoresReadOnlyTransaction,
  WalletDbReadWriteTransaction,
  timestampPreciseFromDb,
  timestampPreciseToDb,
  timestampProtocolFromDb,
  timestampProtocolToDb,
} from "./db.js";
import {
  getPreferredExchangeForCurrency,
  getScopeForAllExchanges,
} from "./exchanges.js";
import {
  getTotalPeerPaymentCost,
  getTotalPeerPaymentCostInTx,
  isPurseMerged,
  queryCoinInfosForSelection,
  recordCreate,
  recordDelete,
  recordTransition,
  recordTransitionStatus,
  recordUpdateMeta,
} 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,
    });
  }

  readonly store = "peerPushDebit";
  readonly recordId = this.pursePub;
  readonly recordState = (rec: PeerPushDebitRecord) => ({
    txState: computePeerPushDebitTransactionState(rec),
    stId: rec.status,
  });
  readonly recordMeta = (rec: PeerPushDebitRecord) => ({
    transactionId: this.transactionId,
    status: rec.status,
    timestamp: rec.timestampCreated,
    currency: Amounts.currencyOf(rec.amount),
    exchanges: [rec.exchangeBaseUrl],
  });
  updateTransactionMeta = (
    tx: WalletDbReadWriteTransaction<["peerPushDebit", "transactionsMeta"]>,
  ) => recordUpdateMeta(this, tx);

  /**
   * 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 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 deleteTransaction(): Promise<void> {
    const res = await this.wex.db.runReadWriteTx(
      { storeNames: ["peerPushDebit", "transactionsMeta"] },
      this.deleteTransactionInTx.bind(this),
    );
    for (const notif of res.notifs) {
      this.wex.ws.notify(notif);
    }
  }

  async deleteTransactionInTx(
    tx: WalletDbReadWriteTransaction<["peerPushDebit", "transactionsMeta"]>,
  ): Promise<{ notifs: WalletNotification[] }> {
    return recordDelete(this, tx);
  }

  async suspendTransaction(): Promise<void> {
    await recordTransition(this, {}, async (rec) => {
      switch (rec.status) {
        case PeerPushDebitStatus.PendingCreatePurse:
          rec.status = PeerPushDebitStatus.SuspendedCreatePurse;
          return TransitionResultType.Transition;
        case PeerPushDebitStatus.AbortingDeletePurse:
          rec.status = PeerPushDebitStatus.SuspendedAbortingDeletePurse;
          return TransitionResultType.Transition;
        case PeerPushDebitStatus.PendingReady:
          rec.status = PeerPushDebitStatus.SuspendedReady;
          return TransitionResultType.Transition;
        case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
        case PeerPushDebitStatus.SuspendedReady:
        case PeerPushDebitStatus.SuspendedCreatePurse:
        case PeerPushDebitStatus.Done:
        case PeerPushDebitStatus.Aborted:
        case PeerPushDebitStatus.Failed:
        case PeerPushDebitStatus.Expired:
          // Do nothing
          return TransitionResultType.Stay;
        default:
          assertUnreachable(rec.status);
      }
    });
    this.wex.taskScheduler.stopShepherdTask(this.taskId);
  }

  async abortTransaction(reason?: TalerErrorDetail): Promise<void> {
    await recordTransition(this, {}, async (rec) => {
      switch (rec.status) {
        case PeerPushDebitStatus.PendingReady:
        case PeerPushDebitStatus.SuspendedReady:
          rec.abortReason = reason;
          rec.status = PeerPushDebitStatus.AbortingDeletePurse;
          return TransitionResultType.Transition;
        case PeerPushDebitStatus.SuspendedCreatePurse:
        case PeerPushDebitStatus.PendingCreatePurse:
          // Network request might already be in-flight!
          rec.abortReason = reason;
          rec.status = PeerPushDebitStatus.AbortingDeletePurse;
          return TransitionResultType.Transition;
        case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
        case PeerPushDebitStatus.Done:
        case PeerPushDebitStatus.AbortingDeletePurse:
        case PeerPushDebitStatus.Aborted:
        case PeerPushDebitStatus.Expired:
        case PeerPushDebitStatus.Failed:
          // Do nothing
          return TransitionResultType.Stay;
        default:
          assertUnreachable(rec.status);
      }
    });
    this.wex.taskScheduler.stopShepherdTask(this.taskId);
    this.wex.taskScheduler.startShepherdTask(this.taskId);
  }

  async resumeTransaction(): Promise<void> {
    await recordTransition(this, {}, async (rec) => {
      switch (rec.status) {
        case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
          rec.status = PeerPushDebitStatus.AbortingDeletePurse;
          return TransitionResultType.Transition;
        case PeerPushDebitStatus.SuspendedReady:
          rec.status = PeerPushDebitStatus.PendingReady;
          return TransitionResultType.Transition;
        case PeerPushDebitStatus.SuspendedCreatePurse:
          rec.status = PeerPushDebitStatus.PendingCreatePurse;
          return TransitionResultType.Transition;
        case PeerPushDebitStatus.PendingCreatePurse:
        case PeerPushDebitStatus.AbortingDeletePurse:
        case PeerPushDebitStatus.PendingReady:
        case PeerPushDebitStatus.Done:
        case PeerPushDebitStatus.Aborted:
        case PeerPushDebitStatus.Failed:
        case PeerPushDebitStatus.Expired:
          // Do nothing
          return TransitionResultType.Stay;
        default:
          assertUnreachable(rec.status);
      }
    });
    this.wex.taskScheduler.startShepherdTask(this.taskId);
  }

  async failTransaction(reason?: TalerErrorDetail): Promise<void> {
    await recordTransition(this, {}, async (rec) => {
      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;
          return TransitionResultType.Transition;
        case PeerPushDebitStatus.Done:
        case PeerPushDebitStatus.Aborted:
        case PeerPushDebitStatus.Failed:
        case PeerPushDebitStatus.Expired:
          // Do nothing
          return TransitionResultType.Stay;
        default:
          assertUnreachable(rec.status);
      }
    });
    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})`);
  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();
    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 recordTransition(ctx, {}, async (rec) => {
    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),
        };
        return TransitionResultType.Transition;
      }
      default:
        return TransitionResultType.Stay;
    }
  });
  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.db.runReadOnlyTx(
    { storeNames: ["contractTerms"] },
    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);
    }

    let transitionDone = false;
    await recordTransition(
      ctx,
      {
        extraStores: [
          "coinAvailability",
          "coinHistory",
          "coins",
          "contractTerms",
          "denominations",
          "exchanges",
          "refreshGroups",
          "refreshSessions",
        ],
      },
      async (rec, tx) => {
        if (rec.coinSel != null) {
          return TransitionResultType.Stay;
        }

        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,
        });

        transitionDone = true;
        return TransitionResultType.Transition;
      },
    );
    if (transitionDone) {
      return TaskRunResult.progress();
    } else {
      return TaskRunResult.backoff();
    }
  }

  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(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 recordTransitionStatus(
            ctx,
            PeerPushDebitStatus.PendingCreatePurse,
            PeerPushDebitStatus.AbortingDeletePurse,
          );
          return TaskRunResult.progress();
        case HttpStatusCode.Conflict:
          // Handle double-spending
          return handlePurseCreationConflict(
            wex,
            peerPushInitiation,
            resp.body,
          );
        case HttpStatusCode.Forbidden:
        case HttpStatusCode.NotFound:
          await ctx.failTransaction(resp.detail);
          return TaskRunResult.finished();
        default:
          assertUnreachable(resp);
      }
    }
  }

  // All batches done!
  const resp = await exchangeClient.getPurseStatusAtDeposit(pursePub);
  switch (resp.case) {
    case "ok":
      await recordTransitionStatus(
        ctx,
        PeerPushDebitStatus.PendingCreatePurse,
        PeerPushDebitStatus.PendingReady,
      );
      return TaskRunResult.progress();
    case HttpStatusCode.Gone:
      // FIXME we need PeerPushDebitStatus.ExpiredDeletePurse
      await recordTransitionStatus(
        ctx,
        PeerPushDebitStatus.PendingCreatePurse,
        PeerPushDebitStatus.AbortingDeletePurse,
      );
      return TaskRunResult.progress();
    case HttpStatusCode.NotFound:
      await ctx.failTransaction(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(resp.detail);
      return TaskRunResult.finished();
  }

  await recordTransition(
    ctx,
    {
      extraStores: [
        "coinAvailability",
        "coinHistory",
        "coins",
        "denominations",
        "refreshGroups",
        "refreshSessions",
      ],
    },
    async (rec, tx) => {
      if (rec.status !== PeerPushDebitStatus.AbortingDeletePurse) {
        return TransitionResultType.Stay;
      }
      const currency = Amounts.currencyOf(rec.amount);
      const coinPubs: CoinRefreshRequest[] = [];

      if (!rec.coinSel) {
        return TransitionResultType.Stay;
      }

      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;
      return TransitionResultType.Transition;
    },
  );

  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 recordTransitionStatus(
          ctx,
          PeerPushDebitStatus.PendingReady,
          PeerPushDebitStatus.Done,
        );
        return TaskRunResult.progress();
      }
    }
    case HttpStatusCode.Gone:
      logger.info(`purse ${pursePub} is gone, aborting peer-push-debit`);
      await recordTransition(
        ctx,
        {
          extraStores: [
            "coinAvailability",
            "coinHistory",
            "coins",
            "denominations",
            "refreshGroups",
            "refreshSessions",
          ],
        },
        async (rec, tx) => {
          if (rec.status !== PeerPushDebitStatus.PendingReady) {
            return TransitionResultType.Stay;
          }
          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;
          return TransitionResultType.Transition;
        },
      );
      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.db.runReadOnlyTx(
    { storeNames: ["peerPushDebit"] },
    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 recordCreate(
    ctx,
    {
      extraStores: [
        "coinAvailability",
        "coinHistory",
        "coins",
        "contractTerms",
        "denominations",
        "exchangeDetails",
        "exchanges",
        "refreshGroups",
        "refreshSessions",
        "globalCurrencyExchanges",
        "globalCurrencyAuditors",
      ],
    },
    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;
      return 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,
      };
  }
}
