/*
 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/>
 */

/**
 * @fileoverview
 * Implementation of the peer-pull-debit transaction, i.e.
 * paying for an invoice the wallet received from another wallet.
 */

import {
  AcceptPeerPullPaymentResponse,
  Amounts,
  CoinRefreshRequest,
  ConfirmPeerPullDebitRequest,
  ContractTermsUtil,
  ExchangePurseDeposits,
  HttpStatusCode,
  Logger,
  ObservabilityEventType,
  PeerContractTerms,
  PreparePeerPullDebitRequest,
  PreparePeerPullDebitResponse,
  PurseConflict,
  RefreshReason,
  ScopeType,
  SelectedProspectiveCoin,
  TalerError,
  TalerErrorCode,
  TalerErrorDetail,
  TalerPreciseTimestamp,
  Transaction,
  TransactionAction,
  TransactionIdStr,
  TransactionMajorState,
  TransactionMinorState,
  TransactionState,
  TransactionType,
  WalletNotification,
  assertUnreachable,
  checkDbInvariant,
  checkLogicInvariant,
  codecForPeerContractTerms,
  decodeCrock,
  eddsaGetPublic,
  encodeCrock,
  getRandomBytes,
  j2s,
  makeTalerErrorDetail,
  parsePayPullUri,
} from "@gnu-taler/taler-util";
import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js";
import {
  PendingTaskType,
  TaskIdStr,
  TaskRunResult,
  TransactionContext,
  TransitionResultType,
  constructTaskIdentifier,
  spendCoins,
} from "./common.js";
import {
  PeerPullDebitRecordStatus,
  PeerPullPaymentIncomingRecord,
  RefreshOperationStatus,
  WalletDbAllStoresReadOnlyTransaction,
  WalletDbReadWriteTransaction,
  timestampPreciseFromDb,
  timestampPreciseToDb,
} from "./db.js";
import { getExchangeScopeInfo, getScopeForAllExchanges } from "./exchanges.js";
import {
  getTotalPeerPaymentCost,
  isPurseDeposited,
  queryCoinInfosForSelection,
  recordCreate,
  recordDelete,
  recordTransition,
  recordTransitionStatus,
  recordUpdateMeta,
} from "./pay-peer-common.js";
import { createRefreshGroup } from "./refresh.js";
import {
  constructTransactionIdentifier,
  isUnsuccessfulTransaction,
  parseTransactionIdentifier,
} from "./transactions.js";
import { WalletExecutionContext, walletExchangeClient } from "./wallet.js";

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

/**
 * Common context for a peer-pull-debit transaction.
 */
export class PeerPullDebitTransactionContext implements TransactionContext {
  readonly transactionId: TransactionIdStr;
  readonly taskId: TaskIdStr;

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

  readonly store = "peerPullDebit";
  readonly recordId = this.peerPullDebitId;
  readonly recordState = (rec: PeerPullPaymentIncomingRecord) => ({
    txState: computePeerPullDebitTransactionState(rec),
    stId: rec.status,
  });
  readonly recordMeta = (rec: PeerPullPaymentIncomingRecord) => ({
    transactionId: this.transactionId,
    status: rec.status,
    timestamp: rec.timestampCreated,
    currency: Amounts.currencyOf(rec.amount),
    exchanges: [rec.exchangeBaseUrl],
  });
  updateTransactionMeta = (
    tx: WalletDbReadWriteTransaction<["peerPullDebit", "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 pi = await tx.peerPullDebit.get(this.peerPullDebitId);
    if (!pi) {
      return undefined;
    }
    const ort = await tx.operationRetries.get(this.taskId);
    const txState = computePeerPullDebitTransactionState(pi);
    const ctRec = await tx.contractTerms.get(pi.contractTermsHash);
    checkDbInvariant(!!ctRec, `no contract terms for ${this.transactionId}`);
    const contractTerms = ctRec.contractTermsRaw;
    return {
      type: TransactionType.PeerPullDebit,
      txState,
      stId: pi.status,
      scopes: await getScopeForAllExchanges(tx, [pi.exchangeBaseUrl]),
      txActions: computePeerPullDebitTransactionActions(pi),
      amountEffective: isUnsuccessfulTransaction(txState)
        ? Amounts.stringify(Amounts.zeroOfAmount(pi.amount))
        : pi.coinSel?.totalCost
          ? pi.coinSel?.totalCost
          : Amounts.stringify(pi.amount),
      amountRaw: Amounts.stringify(pi.amount),
      exchangeBaseUrl: pi.exchangeBaseUrl,
      info: {
        expiration: contractTerms.purse_expiration,
        summary: contractTerms.summary,
        iconId: contractTerms.icon_id,
      },
      abortReason: pi.abortReason,
      failReason: pi.failReason,
      timestamp: timestampPreciseFromDb(pi.timestampCreated),
      transactionId: this.transactionId,
      ...(ort?.lastError ? { error: ort.lastError } : {}),
    };
  }

  async deleteTransaction(): Promise<void> {
    const res = await this.wex.db.runReadWriteTx(
      { storeNames: ["peerPullDebit", "transactionsMeta"] },
      this.deleteTransactionInTx.bind(this),
    );
    for (const notif of res.notifs) {
      this.wex.ws.notify(notif);
    }
  }

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

  async suspendTransaction(): Promise<void> {
    await recordTransition(this, {}, async (rec) => {
      switch (rec.status) {
        case PeerPullDebitRecordStatus.DialogProposed:
        case PeerPullDebitRecordStatus.Done:
        case PeerPullDebitRecordStatus.SuspendedDeposit:
        case PeerPullDebitRecordStatus.Aborted:
        case PeerPullDebitRecordStatus.Failed:
        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
          return TransitionResultType.Stay;
        case PeerPullDebitRecordStatus.PendingDeposit:
          rec.status = PeerPullDebitRecordStatus.SuspendedDeposit;
          return TransitionResultType.Transition;
        case PeerPullDebitRecordStatus.AbortingRefresh:
          rec.status = PeerPullDebitRecordStatus.SuspendedAbortingRefresh;
          return TransitionResultType.Transition;
        default:
          assertUnreachable(rec.status);
      }
    });
    this.wex.taskScheduler.stopShepherdTask(this.taskId);
  }

  async resumeTransaction(): Promise<void> {
    await recordTransition(this, {}, async (rec) => {
      switch (rec.status) {
        case PeerPullDebitRecordStatus.SuspendedDeposit:
          rec.status = PeerPullDebitRecordStatus.PendingDeposit;
          return TransitionResultType.Transition;
        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
          rec.status = PeerPullDebitRecordStatus.AbortingRefresh;
          return TransitionResultType.Transition;
        case PeerPullDebitRecordStatus.Aborted:
        case PeerPullDebitRecordStatus.AbortingRefresh:
        case PeerPullDebitRecordStatus.Failed:
        case PeerPullDebitRecordStatus.DialogProposed:
        case PeerPullDebitRecordStatus.Done:
        case PeerPullDebitRecordStatus.PendingDeposit:
          return TransitionResultType.Stay;
      }
    });
    this.wex.taskScheduler.startShepherdTask(this.taskId);
  }

  async failTransaction(reason?: TalerErrorDetail): Promise<void> {
    await recordTransition(this, {}, async (rec) => {
      switch (rec.status) {
        case PeerPullDebitRecordStatus.SuspendedDeposit:
        case PeerPullDebitRecordStatus.PendingDeposit:
        case PeerPullDebitRecordStatus.AbortingRefresh:
        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
          // FIXME: Should we also abort the corresponding refresh session?!
          rec.status = PeerPullDebitRecordStatus.Failed;
          rec.failReason = reason;
          return TransitionResultType.Transition;
        default:
          return TransitionResultType.Stay;
      }
    });
    this.wex.taskScheduler.stopShepherdTask(this.taskId);
  }

  async abortTransaction(reason?: TalerErrorDetail): Promise<void> {
    await recordTransition(
      this,
      {
        extraStores: [
          "coinAvailability",
          "coinHistory",
          "coins",
          "denominations",
          "refreshGroups",
          "refreshSessions",
        ],
      },
      async (pi, tx) => {
        switch (pi.status) {
          case PeerPullDebitRecordStatus.SuspendedDeposit:
          case PeerPullDebitRecordStatus.PendingDeposit:
            break;
          default:
            return TransitionResultType.Stay;
        }
        const currency = Amounts.currencyOf(pi.totalCostEstimated);
        const coinPubs: CoinRefreshRequest[] = [];

        if (!pi.coinSel) {
          throw Error("invalid db state");
        }

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

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

        pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
        pi.abortRefreshGroupId = refresh.refreshGroupId;
        pi.abortReason = reason;
        return TransitionResultType.Transition;
      },
    );
    this.wex.taskScheduler.stopShepherdTask(this.taskId);
    this.wex.taskScheduler.startShepherdTask(this.taskId);
  }
}

async function handlePurseCreationConflict(
  ctx: PeerPullDebitTransactionContext,
  peerPullInc: PeerPullPaymentIncomingRecord,
  conflict: PurseConflict,
): Promise<TaskRunResult> {
  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(peerPullInc.amount);
  const currency = instructedAmount.currency;
  const exchangeBaseUrl = peerPullInc.exchangeBaseUrl;

  const sel = peerPullInc.coinSel;
  checkDbInvariant(
    !!sel,
    `no coin selected for peer pull deposit ${peerPullInc.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]),
      });
    }
  }

  const coinSelRes = await selectPeerCoins(ctx.wex, {
    instructedAmount,
    repair,
    restrictScope: {
      type: ScopeType.Exchange,
      currency,
      url: exchangeBaseUrl,
    },
  });

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

  const totalAmount = await getTotalPeerPaymentCost(
    ctx.wex,
    coinSelRes.result.coins,
  );

  await recordTransition(ctx, {}, async (rec) => {
    switch (rec.status) {
      case PeerPullDebitRecordStatus.PendingDeposit:
      case PeerPullDebitRecordStatus.SuspendedDeposit: {
        const sel = coinSelRes.result;
        rec.coinSel = {
          coinPubs: sel.coins.map((x) => x.coinPub),
          contributions: sel.coins.map((x) => x.contribution),
          totalCost: Amounts.stringify(totalAmount),
        };
        return TransitionResultType.Transition;
      }
      default:
        return TransitionResultType.Stay;
    }
  });
  return TaskRunResult.progress();
}

async function processPeerPullDebitDialogProposed(
  wex: WalletExecutionContext,
  pullIni: PeerPullPaymentIncomingRecord,
): Promise<TaskRunResult> {
  const ctx = new PeerPullDebitTransactionContext(wex, pullIni.peerPullDebitId);
  const exchangeClient = walletExchangeClient(pullIni.exchangeBaseUrl, wex);
  const resp = await exchangeClient.getPurseStatusAtDeposit(
    pullIni.pursePub,
    true,
  );

  switch (resp.case) {
    case "ok":
      break;
    case HttpStatusCode.Gone:
      // Exchange says that purse doesn't exist anymore => expired!
      await recordTransitionStatus(
        ctx,
        PeerPullDebitRecordStatus.DialogProposed,
        PeerPullDebitRecordStatus.Aborted,
      );
      return TaskRunResult.finished();
    case HttpStatusCode.NotFound:
      await ctx.failTransaction(resp.detail);
      return TaskRunResult.finished();
    default:
      assertUnreachable(resp);
  }

  if (isPurseDeposited(resp.body)) {
    logger.info("purse completed by another wallet");
    await recordTransitionStatus(
      ctx,
      PeerPullDebitRecordStatus.DialogProposed,
      PeerPullDebitRecordStatus.Aborted,
    );
    return TaskRunResult.finished();
  }

  return TaskRunResult.longpollReturnedPending();
}

async function processPeerPullDebitPendingDeposit(
  wex: WalletExecutionContext,
  peerPullInc: PeerPullPaymentIncomingRecord,
): Promise<TaskRunResult> {
  const ctx = new PeerPullDebitTransactionContext(
    wex,
    peerPullInc.peerPullDebitId,
  );
  const { pursePub, coinSel } = peerPullInc;

  const exchangeBaseUrl = peerPullInc.exchangeBaseUrl;

  // This can happen when there was a prospective coin selection.
  if (coinSel == null) {
    const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
    const currency = instructedAmount.currency;

    const coinSelRes = await selectPeerCoins(wex, {
      instructedAmount,
      restrictScope: {
        type: ScopeType.Exchange,
        currency,
        url: exchangeBaseUrl,
      },
    });
    if (logger.shouldLogTrace()) {
      logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
    }

    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":
        // Coin selection is *still* only prospective!
        // FIXME: Really report this as error?
        throw Error("insufficient balance (locked behind refresh)");
      case "success":
        coins = coinSelRes.result.coins;
        break;
      default:
        assertUnreachable(coinSelRes);
    }

    const totalAmount = await getTotalPeerPaymentCost(wex, coins);

    // FIXME: Missing notification here!
    const info = await recordTransition(
      ctx,
      {
        extraStores: [
          "coinAvailability",
          "coinHistory",
          "coins",
          "denominations",
          "exchanges",
          "refreshGroups",
          "refreshSessions",
        ],
      },
      async (rec, tx) => {
        if (
          rec.status !== PeerPullDebitRecordStatus.PendingDeposit ||
          rec.coinSel != null
        ) {
          return TransitionResultType.Stay;
        }
        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.PayPeerPull,
        });
        rec.coinSel = {
          coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
          contributions: coinSelRes.result.coins.map((x) => x.contribution),
          totalCost: Amounts.stringify(totalAmount),
        };
        return TransitionResultType.Transition;
      },
    );
    if (info != null) {
      return TaskRunResult.progress();
    } else {
      return TaskRunResult.backoff();
    }
  }
  const exchangeClient = walletExchangeClient(peerPullInc.exchangeBaseUrl, wex);

  // FIXME: We could skip batches that we've already submitted.

  const coins = await queryCoinInfosForSelection(wex, coinSel);

  const maxBatchSize = 64;

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

    wex.oc.observe({
      type: ObservabilityEventType.Message,
      contents: `Depositing batch at ${i}/${coins.length} of size ${batchSize}`,
    });

    const batchCoins = coins.slice(i, i + batchSize);
    const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
      exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
      pursePub: peerPullInc.pursePub,
      coins: batchCoins,
    });

    const depositPayload: ExchangePurseDeposits = {
      deposits: depositSigsResp.deposits,
    };

    if (logger.shouldLogTrace()) {
      logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
    }
    const resp = await exchangeClient.depositIntoPurse(
      pursePub,
      depositPayload,
    );
    switch (resp.case) {
      case "ok":
        continue;
      case HttpStatusCode.Gone: {
        await ctx.abortTransaction(
          makeTalerErrorDetail(
            TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE,
            {},
          ),
        );
        return TaskRunResult.finished();
      }
      case HttpStatusCode.Conflict:
        return handlePurseCreationConflict(ctx, peerPullInc, resp.body);
      case HttpStatusCode.Forbidden:
      case HttpStatusCode.NotFound:
        await ctx.failTransaction(resp.detail);
        return TaskRunResult.finished();
      default:
        assertUnreachable(resp);
    }
  }
  // All batches succeeded, we can transition!
  await recordTransitionStatus(
    ctx,
    PeerPullDebitRecordStatus.PendingDeposit,
    PeerPullDebitRecordStatus.Done,
  );
  return TaskRunResult.finished();
}

async function processPeerPullDebitAbortingRefresh(
  wex: WalletExecutionContext,
  peerPullInc: PeerPullPaymentIncomingRecord,
): Promise<TaskRunResult> {
  const peerPullDebitId = peerPullInc.peerPullDebitId;
  const abortRefreshGroupId = peerPullInc.abortRefreshGroupId;
  checkLogicInvariant(!!abortRefreshGroupId);
  const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId);
  await recordTransition(
    ctx,
    { extraStores: ["refreshGroups"] },
    async (rec, tx) => {
      const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
      if (refreshGroup == null) {
        // Maybe it got manually deleted? Means that we should
        // just go into failed.
        logger.warn("no aborting refresh group found for deposit group");
        rec.status = PeerPullDebitRecordStatus.Failed;
        return TransitionResultType.Transition;
      } else {
        switch (refreshGroup.operationStatus) {
          case RefreshOperationStatus.Finished:
            rec.status = PeerPullDebitRecordStatus.Aborted;
            return TransitionResultType.Transition;
          case RefreshOperationStatus.Failed: {
            rec.status = PeerPullDebitRecordStatus.Failed;
            return TransitionResultType.Transition;
          }
          default:
            return TransitionResultType.Stay;
        }
      }
    },
  );
  // FIXME: Shouldn't this be finished in some cases?!
  return TaskRunResult.backoff();
}

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

  const peerPullInc = await wex.db.runReadOnlyTx(
    { storeNames: ["peerPullDebit"] },
    async (tx) => tx.peerPullDebit.get(peerPullDebitId),
  );
  if (!peerPullInc) {
    throw Error("peer pull debit not found");
  }

  switch (peerPullInc.status) {
    case PeerPullDebitRecordStatus.DialogProposed:
      return processPeerPullDebitDialogProposed(wex, peerPullInc);
    case PeerPullDebitRecordStatus.PendingDeposit:
      return processPeerPullDebitPendingDeposit(wex, peerPullInc);
    case PeerPullDebitRecordStatus.AbortingRefresh:
      return processPeerPullDebitAbortingRefresh(wex, peerPullInc);
    case PeerPullDebitRecordStatus.Done:
    case PeerPullDebitRecordStatus.Aborted:
    case PeerPullDebitRecordStatus.Failed:
    case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
    case PeerPullDebitRecordStatus.SuspendedDeposit:
      return TaskRunResult.finished();
    default:
      assertUnreachable(peerPullInc.status);
  }
}

export async function confirmPeerPullDebit(
  wex: WalletExecutionContext,
  req: ConfirmPeerPullDebitRequest,
): Promise<AcceptPeerPullPaymentResponse> {
  const parsed = parseTransactionIdentifier(req.transactionId);
  if (!parsed || parsed.tag !== TransactionType.PeerPullDebit) {
    throw Error("invalid peer-pull-debit transaction identifier");
  }

  const peerPullInc = await wex.db.runReadOnlyTx(
    { storeNames: ["peerPullDebit"] },
    async (tx) => tx.peerPullDebit.get(parsed.peerPullDebitId),
  );

  if (peerPullInc == null) {
    throw Error(
      `can't accept unknown incoming p2p pull payment (${req.transactionId})`,
    );
  }

  const ctx = new PeerPullDebitTransactionContext(wex, parsed.peerPullDebitId);

  const exchangeBaseUrl = peerPullInc.exchangeBaseUrl;

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

  const coinSelRes = await selectPeerCoins(wex, {
    instructedAmount,
    restrictScope: {
      type: ScopeType.Exchange,
      currency,
      url: exchangeBaseUrl,
    },
  });
  if (logger.shouldLogTrace()) {
    logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
  }

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

  const totalAmount = await getTotalPeerPaymentCost(wex, coins);

  await recordTransition(
    ctx,
    {
      extraStores: [
        "coinAvailability",
        "coinHistory",
        "coins",
        "denominations",
        "exchanges",
        "refreshGroups",
        "refreshSessions",
      ],
    },
    async (rec, tx) => {
      if (rec.status !== PeerPullDebitRecordStatus.DialogProposed) {
        return TransitionResultType.Stay;
      }
      if (coinSelRes.type == "success") {
        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.PayPeerPull,
        });
        rec.coinSel = {
          coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
          contributions: coinSelRes.result.coins.map((x) => x.contribution),
          totalCost: Amounts.stringify(totalAmount),
        };
      }
      rec.status = PeerPullDebitRecordStatus.PendingDeposit;
      return TransitionResultType.Transition;
    },
  );
  wex.taskScheduler.stopShepherdTask(ctx.taskId);
  wex.taskScheduler.startShepherdTask(ctx.taskId);

  return {
    transactionId: ctx.transactionId,
  };
}

/**
 * Look up information about an incoming peer pull payment.
 * Store the results in the wallet DB.
 */
export async function preparePeerPullDebit(
  wex: WalletExecutionContext,
  req: PreparePeerPullDebitRequest,
): Promise<PreparePeerPullDebitResponse> {
  const uri = parsePayPullUri(req.talerUri);

  if (!uri) {
    throw Error("got invalid taler://pay-pull URI");
  }

  const existing = await wex.db.runReadOnlyTx(
    {
      storeNames: [
        "peerPullDebit",
        "contractTerms",
        "exchangeDetails",
        "exchanges",
        "globalCurrencyAuditors",
        "globalCurrencyExchanges",
      ],
    },
    async (tx) => {
      const peerPullDebitRecord =
        await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([
          uri.exchangeBaseUrl,
          uri.contractPriv,
        ]);
      if (!peerPullDebitRecord) {
        return;
      }
      const contractTerms = await tx.contractTerms.get(
        peerPullDebitRecord.contractTermsHash,
      );
      if (!contractTerms) {
        return;
      }
      const currency = Amounts.currencyOf(peerPullDebitRecord.amount);
      const scopeInfo = await getExchangeScopeInfo(
        tx,
        peerPullDebitRecord.exchangeBaseUrl,
        currency,
      );
      return {
        peerPullDebitRecord,
        contractTerms,
        scopeInfo,
        exchangeBaseUrl: peerPullDebitRecord.exchangeBaseUrl,
      };
    },
  );

  if (existing) {
    return {
      amount: existing.peerPullDebitRecord.amount,
      amountRaw: existing.peerPullDebitRecord.amount,
      amountEffective: existing.peerPullDebitRecord.totalCostEstimated,
      contractTerms: existing.contractTerms.contractTermsRaw,
      scopeInfo: existing.scopeInfo,
      exchangeBaseUrl: existing.exchangeBaseUrl,
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.PeerPullDebit,
        peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
      }),
    };
  }

  const exchangeBaseUrl = uri.exchangeBaseUrl;
  const contractPriv = uri.contractPriv;
  const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
  const exchangeClient = walletExchangeClient(exchangeBaseUrl, wex);

  const contractResp = await exchangeClient.getContract(contractPub);
  switch (contractResp.case) {
    case "ok":
      break;
    case HttpStatusCode.NotFound:
      // FIXME: appropriated error code
      throw Error("unknown P2P contract");
    default:
      assertUnreachable(contractResp);
  }
  const pursePub = contractResp.body.purse_pub;

  const dec = await wex.cryptoApi.decryptContractForDeposit({
    ciphertext: contractResp.body.econtract,
    contractPriv: contractPriv,
    pursePub: pursePub,
  });

  const resp = await exchangeClient.getPurseStatusAtMerge(pursePub);
  switch (resp.case) {
    case "ok":
      break;
    case HttpStatusCode.Gone:
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE,
        {},
      );
    case HttpStatusCode.NotFound:
      // FIXME: appropriated error code
      throw Error("unknown peer pull debit");
    default:
      assertUnreachable(resp);
  }

  if (isPurseDeposited(resp.body)) {
    logger.info("purse completed by another wallet");
    // FIXME: appropriated error code
    throw Error("peer pull debit already completed");
  }

  const peerPullDebitId = encodeCrock(getRandomBytes(32));

  let contractTerms: PeerContractTerms;

  if (dec.contractTerms) {
    contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
    // FIXME: Check that the purseStatus balance matches contract terms amount
  } else {
    // FIXME: In this case, where do we get the purse expiration from?!
    // https://bugs.gnunet.org/view.php?id=7706
    throw Error("pull payments without contract terms not supported yet");
  }

  const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);

  // FIXME: Why don't we compute the totalCost here?!

  const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
  const currency = Amounts.currencyOf(instructedAmount);

  const coinSelRes = await selectPeerCoins(wex, {
    instructedAmount,
    restrictScope: {
      type: ScopeType.Exchange,
      currency,
      url: exchangeBaseUrl,
    },
  });
  if (logger.shouldLogTrace()) {
    logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
  }

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

  const totalAmount = await getTotalPeerPaymentCost(wex, coins);

  const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId);
  await recordCreate(
    ctx,
    {
      extraStores: ["contractTerms"],
      label: "create-transaction-peer-pull-credit",
    },
    async (tx) => {
      await tx.contractTerms.put({
        h: contractTermsHash,
        contractTermsRaw: contractTerms,
      });
      return {
        peerPullDebitId,
        contractPriv: contractPriv,
        exchangeBaseUrl: exchangeBaseUrl,
        pursePub: pursePub,
        timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
        contractTermsHash,
        amount: contractTerms.amount,
        status: PeerPullDebitRecordStatus.DialogProposed,
        totalCostEstimated: Amounts.stringify(totalAmount),
      };
    },
  );
  wex.taskScheduler.startShepherdTask(ctx.taskId);

  const scopeInfo = await wex.db.runAllStoresReadOnlyTx({}, (tx) => {
    return getExchangeScopeInfo(tx, exchangeBaseUrl, currency);
  });

  return {
    amount: contractTerms.amount,
    amountEffective: Amounts.stringify(totalAmount),
    amountRaw: contractTerms.amount,
    contractTerms: contractTerms,
    scopeInfo,
    exchangeBaseUrl,
    transactionId: ctx.transactionId,
  };
}

export function computePeerPullDebitTransactionState(
  pullDebitRecord: PeerPullPaymentIncomingRecord,
): TransactionState {
  switch (pullDebitRecord.status) {
    case PeerPullDebitRecordStatus.DialogProposed:
      return {
        major: TransactionMajorState.Dialog,
        minor: TransactionMinorState.Proposed,
      };
    case PeerPullDebitRecordStatus.PendingDeposit:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.Deposit,
      };
    case PeerPullDebitRecordStatus.Done:
      return {
        major: TransactionMajorState.Done,
      };
    case PeerPullDebitRecordStatus.SuspendedDeposit:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.Deposit,
      };
    case PeerPullDebitRecordStatus.Aborted:
      return {
        major: TransactionMajorState.Aborted,
      };
    case PeerPullDebitRecordStatus.AbortingRefresh:
      return {
        major: TransactionMajorState.Aborting,
        minor: TransactionMinorState.Refresh,
      };
    case PeerPullDebitRecordStatus.Failed:
      return {
        major: TransactionMajorState.Failed,
      };
    case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
      return {
        major: TransactionMajorState.SuspendedAborting,
        minor: TransactionMinorState.Refresh,
      };
  }
}

export function computePeerPullDebitTransactionActions(
  pullDebitRecord: PeerPullPaymentIncomingRecord,
): TransactionAction[] {
  switch (pullDebitRecord.status) {
    case PeerPullDebitRecordStatus.DialogProposed:
      return [TransactionAction.Retry, TransactionAction.Delete];
    case PeerPullDebitRecordStatus.PendingDeposit:
      return [TransactionAction.Abort, TransactionAction.Suspend];
    case PeerPullDebitRecordStatus.Done:
      return [TransactionAction.Delete];
    case PeerPullDebitRecordStatus.SuspendedDeposit:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case PeerPullDebitRecordStatus.Aborted:
      return [TransactionAction.Delete];
    case PeerPullDebitRecordStatus.AbortingRefresh:
      return [TransactionAction.Fail, TransactionAction.Suspend];
    case PeerPullDebitRecordStatus.Failed:
      return [TransactionAction.Delete];
    case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
      return [TransactionAction.Resume, TransactionAction.Fail];
  }
}
