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

/**
 * Imports.
 */
import {
  AcceptPeerPullPaymentResponse,
  Amounts,
  CoinRefreshRequest,
  ConfirmPeerPullDebitRequest,
  ContractTermsUtil,
  ExchangePurseDeposits,
  HttpStatusCode,
  Logger,
  NotificationType,
  ObservabilityEventType,
  PeerContractTerms,
  PreparePeerPullDebitRequest,
  PreparePeerPullDebitResponse,
  RefreshReason,
  SelectedProspectiveCoin,
  TalerError,
  TalerErrorCode,
  TalerPreciseTimestamp,
  TalerProtocolViolationError,
  Transaction,
  TransactionAction,
  TransactionIdStr,
  TransactionMajorState,
  TransactionMinorState,
  TransactionState,
  TransactionType,
  assertUnreachable,
  checkDbInvariant,
  checkLogicInvariant,
  codecForAny,
  codecForExchangeGetContractResponse,
  codecForPeerContractTerms,
  decodeCrock,
  eddsaGetPublic,
  encodeCrock,
  getRandomBytes,
  j2s,
  parsePayPullUri,
} from "@gnu-taler/taler-util";
import {
  HttpResponse,
  readSuccessResponseJsonOrThrow,
  readTalerErrorResponse,
} from "@gnu-taler/taler-util/http";
import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js";
import {
  PendingTaskType,
  TaskIdStr,
  TaskRunResult,
  TaskRunResultType,
  TransactionContext,
  TransitionResultType,
  constructTaskIdentifier,
  spendCoins,
} from "./common.js";
import {
  PeerPullDebitRecordStatus,
  PeerPullPaymentIncomingRecord,
  RefreshOperationStatus,
  WalletDbAllStoresReadOnlyTransaction,
  WalletStoresV1,
  timestampPreciseFromDb,
  timestampPreciseToDb,
} from "./db.js";
import {
  codecForExchangePurseStatus,
  getTotalPeerPaymentCost,
  queryCoinInfosForSelection,
} from "./pay-peer-common.js";
import { DbReadWriteTransaction, StoreNames } from "./query.js";
import { createRefreshGroup } from "./refresh.js";
import {
  constructTransactionIdentifier,
  isUnsuccessfulTransaction,
  notifyTransition,
  parseTransactionIdentifier,
} from "./transactions.js";
import { WalletExecutionContext } 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 {
  wex: WalletExecutionContext;
  readonly transactionId: TransactionIdStr;
  readonly taskId: TaskIdStr;
  peerPullDebitId: string;

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

  /**
   * 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,
      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,
      },
      timestamp: timestampPreciseFromDb(pi.timestampCreated),
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.PeerPullDebit,
        peerPullDebitId: pi.peerPullDebitId,
      }),
      ...(ort?.lastError ? { error: ort.lastError } : {}),
    };
  }

  async deleteTransaction(): Promise<void> {
    const transactionId = this.transactionId;
    const ws = this.wex;
    const peerPullDebitId = this.peerPullDebitId;
    await ws.db.runReadWriteTx(
      { storeNames: ["peerPullDebit", "tombstones"] },
      async (tx) => {
        const debit = await tx.peerPullDebit.get(peerPullDebitId);
        if (debit) {
          await tx.peerPullDebit.delete(peerPullDebitId);
          await tx.tombstones.put({ id: transactionId });
        }
      },
    );
  }

  async suspendTransaction(): Promise<void> {
    const taskId = this.taskId;
    const transactionId = this.transactionId;
    const wex = this.wex;
    const peerPullDebitId = this.peerPullDebitId;
    const transitionInfo = await wex.db.runReadWriteTx(
      { storeNames: ["peerPullDebit"] },
      async (tx) => {
        const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
        if (!pullDebitRec) {
          logger.warn(`peer pull debit ${peerPullDebitId} not found`);
          return;
        }
        let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
        switch (pullDebitRec.status) {
          case PeerPullDebitRecordStatus.DialogProposed:
            break;
          case PeerPullDebitRecordStatus.Done:
            break;
          case PeerPullDebitRecordStatus.PendingDeposit:
            newStatus = PeerPullDebitRecordStatus.SuspendedDeposit;
            break;
          case PeerPullDebitRecordStatus.SuspendedDeposit:
            break;
          case PeerPullDebitRecordStatus.Aborted:
            break;
          case PeerPullDebitRecordStatus.AbortingRefresh:
            newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh;
            break;
          case PeerPullDebitRecordStatus.Failed:
            break;
          case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
            break;
          default:
            assertUnreachable(pullDebitRec.status);
        }
        if (newStatus != null) {
          const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
          pullDebitRec.status = newStatus;
          const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
          await tx.peerPullDebit.put(pullDebitRec);
          return {
            oldTxState,
            newTxState,
          };
        }
        return undefined;
      },
    );
    notifyTransition(wex, transactionId, transitionInfo);
    wex.taskScheduler.stopShepherdTask(taskId);
  }

  async resumeTransaction(): Promise<void> {
    const ctx = this;
    await ctx.transition(async (pi) => {
      switch (pi.status) {
        case PeerPullDebitRecordStatus.SuspendedDeposit:
          pi.status = PeerPullDebitRecordStatus.PendingDeposit;
          return TransitionResultType.Transition;
        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
          pi.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(): Promise<void> {
    const ctx = this;
    await ctx.transition(async (pi) => {
      switch (pi.status) {
        case PeerPullDebitRecordStatus.SuspendedDeposit:
        case PeerPullDebitRecordStatus.PendingDeposit:
        case PeerPullDebitRecordStatus.AbortingRefresh:
        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
          // FIXME: Should we also abort the corresponding refresh session?!
          pi.status = PeerPullDebitRecordStatus.Failed;
          return TransitionResultType.Transition;
        default:
          return TransitionResultType.Stay;
      }
    });
    this.wex.taskScheduler.stopShepherdTask(this.taskId);
  }

  async abortTransaction(): Promise<void> {
    const ctx = this;
    await ctx.transitionExtra(
      {
        extraStores: [
          "coinAvailability",
          "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(
          ctx.wex,
          tx,
          currency,
          coinPubs,
          RefreshReason.AbortPeerPullDebit,
          this.transactionId,
        );

        pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
        pi.abortRefreshGroupId = refresh.refreshGroupId;
        return TransitionResultType.Transition;
      },
    );
  }

  async transition(
    f: (rec: PeerPullPaymentIncomingRecord) => Promise<TransitionResultType>,
  ): Promise<void> {
    return this.transitionExtra(
      {
        extraStores: [],
      },
      f,
    );
  }

  async transitionExtra<
    StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
  >(
    opts: { extraStores: StoreNameArray },
    f: (
      rec: PeerPullPaymentIncomingRecord,
      tx: DbReadWriteTransaction<
        typeof WalletStoresV1,
        ["peerPullDebit", ...StoreNameArray]
      >,
    ) => Promise<TransitionResultType>,
  ): Promise<void> {
    const wex = this.wex;
    const extraStores = opts.extraStores ?? [];
    const transitionInfo = await wex.db.runReadWriteTx(
      { storeNames: ["peerPullDebit", ...extraStores] },
      async (tx) => {
        const pi = await tx.peerPullDebit.get(this.peerPullDebitId);
        if (!pi) {
          throw Error("peer pull payment not found anymore");
        }
        const oldTxState = computePeerPullDebitTransactionState(pi);
        const res = await f(pi, tx);
        switch (res) {
          case TransitionResultType.Transition: {
            await tx.peerPullDebit.put(pi);
            const newTxState = computePeerPullDebitTransactionState(pi);
            return {
              oldTxState,
              newTxState,
            };
          }
          default:
            return undefined;
        }
      },
    );
    wex.taskScheduler.stopShepherdTask(this.taskId);
    notifyTransition(wex, this.transactionId, transitionInfo);
    wex.taskScheduler.startShepherdTask(this.taskId);
  }
}

async function handlePurseCreationConflict(
  ctx: PeerPullDebitTransactionContext,
  peerPullInc: PeerPullPaymentIncomingRecord,
  resp: HttpResponse,
): Promise<TaskRunResult> {
  const ws = ctx.wex;
  const errResp = await readTalerErrorResponse(resp);
  if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
    await ctx.failTransaction();
    return TaskRunResult.finished();
  }

  // FIXME: Properly parse!
  const brokenCoinPub = (errResp as any).coin_pub;
  logger.trace(`excluded broken coin pub=${brokenCoinPub}`);

  if (!brokenCoinPub) {
    // FIXME: Details!
    throw new TalerProtocolViolationError();
  }

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

  const sel = peerPullInc.coinSel;
  if (!sel) {
    throw Error("invalid state (coin selection expected)");
  }

  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(ws, {
    instructedAmount,
    repair,
  });

  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(
    ws,
    coinSelRes.result.coins,
  );

  await ws.db.runReadWriteTx({ storeNames: ["peerPullDebit"] }, async (tx) => {
    const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId);
    if (!myPpi) {
      return;
    }
    switch (myPpi.status) {
      case PeerPullDebitRecordStatus.PendingDeposit:
      case PeerPullDebitRecordStatus.SuspendedDeposit: {
        const sel = coinSelRes.result;
        myPpi.coinSel = {
          coinPubs: sel.coins.map((x) => x.coinPub),
          contributions: sel.coins.map((x) => x.contribution),
          totalCost: Amounts.stringify(totalAmount),
        };
        break;
      }
      default:
        return;
    }
    await tx.peerPullDebit.put(myPpi);
  });
  return TaskRunResult.backoff();
}

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

  const pursePub = peerPullInc.pursePub;

  const coinSel = peerPullInc.coinSel;

  if (!coinSel) {
    const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);

    const coinSelRes = await selectPeerCoins(wex, {
      instructedAmount,
    });
    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":
        throw Error("insufficient balance (locked behind refresh)");
      case "success":
        coins = coinSelRes.result.coins;
        break;
      default:
        assertUnreachable(coinSelRes);
    }

    const peerPullDebitId = peerPullInc.peerPullDebitId;
    const totalAmount = await getTotalPeerPaymentCost(wex, coins);

    // FIXME: Missing notification here!

    const transitionDone = await wex.db.runReadWriteTx(
      {
        storeNames: [
          "coinAvailability",
          "coinHistory",
          "coins",
          "denominations",
          "exchanges",
          "peerPullDebit",
          "refreshGroups",
          "refreshSessions",
        ],
      },
      async (tx) => {
        const pi = await tx.peerPullDebit.get(peerPullDebitId);
        if (!pi) {
          return false;
        }
        if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) {
          return false;
        }
        if (pi.coinSel) {
          return false;
        }
        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,
        });
        pi.coinSel = {
          coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
          contributions: coinSelRes.result.coins.map((x) => x.contribution),
          totalCost: Amounts.stringify(totalAmount),
        };
        await tx.peerPullDebit.put(pi);
        return true;
      },
    );
    if (transitionDone) {
      return TaskRunResult.progress();
    } else {
      return TaskRunResult.backoff();
    }
  }

  const purseDepositUrl = new URL(
    `purses/${pursePub}/deposit`,
    peerPullInc.exchangeBaseUrl,
  );

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

  const coins = await queryCoinInfosForSelection(wex, coinSel);

  const maxBatchSize = 100;

  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 httpResp = await wex.http.fetch(purseDepositUrl.href, {
      method: "POST",
      body: depositPayload,
      cancellationToken: wex.cancellationToken,
    });

    switch (httpResp.status) {
      case HttpStatusCode.Ok: {
        const resp = await readSuccessResponseJsonOrThrow(
          httpResp,
          codecForAny(),
        );
        logger.trace(`purse deposit response: ${j2s(resp)}`);
        continue;
      }
      case HttpStatusCode.Gone: {
        await ctx.abortTransaction();
        return TaskRunResult.backoff();
      }
      case HttpStatusCode.Conflict: {
        return handlePurseCreationConflict(ctx, peerPullInc, httpResp);
      }
      default: {
        const errResp = await readTalerErrorResponse(httpResp);
        return {
          type: TaskRunResultType.Error,
          errorDetail: errResp,
        };
      }
    }
  }

  // All batches succeeded, we can transition!

  await ctx.transition(async (r) => {
    if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) {
      return TransitionResultType.Stay;
    }
    r.status = PeerPullDebitRecordStatus.Done;
    return TransitionResultType.Transition;
  });
  return TaskRunResult.finished();
}

async function processPeerPullDebitAbortingRefresh(
  wex: WalletExecutionContext,
  peerPullInc: PeerPullPaymentIncomingRecord,
): Promise<TaskRunResult> {
  const peerPullDebitId = peerPullInc.peerPullDebitId;
  const abortRefreshGroupId = peerPullInc.abortRefreshGroupId;
  checkLogicInvariant(!!abortRefreshGroupId);
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.PeerPullDebit,
    peerPullDebitId,
  });
  const transitionInfo = await wex.db.runReadWriteTx(
    { storeNames: ["peerPullDebit", "refreshGroups"] },
    async (tx) => {
      const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
      let newOpState: PeerPullDebitRecordStatus | undefined;
      if (!refreshGroup) {
        // Maybe it got manually deleted? Means that we should
        // just go into failed.
        logger.warn("no aborting refresh group found for deposit group");
        newOpState = PeerPullDebitRecordStatus.Failed;
      } else {
        if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
          newOpState = PeerPullDebitRecordStatus.Aborted;
        } else if (
          refreshGroup.operationStatus === RefreshOperationStatus.Failed
        ) {
          newOpState = PeerPullDebitRecordStatus.Failed;
        }
      }
      if (newOpState) {
        const newDg = await tx.peerPullDebit.get(peerPullDebitId);
        if (!newDg) {
          return;
        }
        const oldTxState = computePeerPullDebitTransactionState(newDg);
        newDg.status = newOpState;
        const newTxState = computePeerPullDebitTransactionState(newDg);
        await tx.peerPullDebit.put(newDg);
        return { oldTxState, newTxState };
      }
      return undefined;
    },
  );
  notifyTransition(wex, transactionId, transitionInfo);
  // 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) => {
      return tx.peerPullDebit.get(peerPullDebitId);
    },
  );
  if (!peerPullInc) {
    throw Error("peer pull debit not found");
  }

  switch (peerPullInc.status) {
    case PeerPullDebitRecordStatus.PendingDeposit:
      return await processPeerPullDebitPendingDeposit(wex, peerPullInc);
    case PeerPullDebitRecordStatus.AbortingRefresh:
      return await processPeerPullDebitAbortingRefresh(wex, peerPullInc);
  }
  return TaskRunResult.finished();
}

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

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

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

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

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

  const coinSelRes = await selectPeerCoins(wex, {
    instructedAmount,
  });
  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);

  // FIXME: Missing notification here!

  await wex.db.runReadWriteTx(
    {
      storeNames: [
        "coinAvailability",
        "coinHistory",
        "coins",
        "denominations",
        "exchanges",
        "peerPullDebit",
        "refreshGroups",
        "refreshSessions",
      ],
    },
    async (tx) => {
      const pi = await tx.peerPullDebit.get(peerPullDebitId);
      if (!pi) {
        throw Error();
      }
      if (pi.status !== PeerPullDebitRecordStatus.DialogProposed) {
        return;
      }
      if (coinSelRes.type == "success") {
        await spendCoins(wex, tx, {
          transactionId,
          coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
          contributions: coinSelRes.result.coins.map((x) =>
            Amounts.parseOrThrow(x.contribution),
          ),
          refreshReason: RefreshReason.PayPeerPull,
        });
        pi.coinSel = {
          coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
          contributions: coinSelRes.result.coins.map((x) => x.contribution),
          totalCost: Amounts.stringify(totalAmount),
        };
      }
      pi.status = PeerPullDebitRecordStatus.PendingDeposit;
      await tx.peerPullDebit.put(pi);
    },
  );

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

  wex.taskScheduler.startShepherdTask(ctx.taskId);

  return {
    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"] },
    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;
      }
      return { peerPullDebitRecord, contractTerms };
    },
  );

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

  const exchangeBaseUrl = uri.exchangeBaseUrl;
  const contractPriv = uri.contractPriv;
  const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));

  const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);

  const contractHttpResp = await wex.http.fetch(getContractUrl.href);

  const contractResp = await readSuccessResponseJsonOrThrow(
    contractHttpResp,
    codecForExchangeGetContractResponse(),
  );

  const pursePub = contractResp.purse_pub;

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

  const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);

  const purseHttpResp = await wex.http.fetch(getPurseUrl.href);

  const purseStatus = await readSuccessResponseJsonOrThrow(
    purseHttpResp,
    codecForExchangePurseStatus(),
  );

  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 coinSelRes = await selectPeerCoins(wex, {
    instructedAmount,
  });
  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 wex.db.runReadWriteTx(
    { storeNames: ["peerPullDebit", "contractTerms"] },
    async (tx) => {
      await tx.contractTerms.put({
        h: contractTermsHash,
        contractTermsRaw: contractTerms,
      }),
        await tx.peerPullDebit.add({
          peerPullDebitId,
          contractPriv: contractPriv,
          exchangeBaseUrl: exchangeBaseUrl,
          pursePub: pursePub,
          timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
          contractTermsHash,
          amount: contractTerms.amount,
          status: PeerPullDebitRecordStatus.DialogProposed,
          totalCostEstimated: Amounts.stringify(totalAmount),
        });
    },
  );

  return {
    amount: contractTerms.amount,
    amountEffective: Amounts.stringify(totalAmount),
    amountRaw: contractTerms.amount,
    contractTerms: contractTerms,
    peerPullDebitId,
    transactionId: constructTransactionIdentifier({
      tag: TransactionType.PeerPullDebit,
      peerPullDebitId: peerPullDebitId,
    }),
  };
}

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