/*
 This file is part of GNU Taler
 (C) 2022 GNUnet e.V.
 (C) 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 {
  AbsoluteTime,
  AmountJson,
  Amounts,
  AsyncFlag,
  CoinRefreshRequest,
  CoinStatus,
  Duration,
  ExchangeEntryState,
  ExchangeEntryStatus,
  ExchangeTosStatus,
  ExchangeUpdateStatus,
  Logger,
  ObservabilityEventType,
  RefreshReason,
  TalerError,
  TalerErrorCode,
  TalerErrorDetail,
  TalerPreciseTimestamp,
  TalerProtocolTimestamp,
  TombstoneIdStr,
  Transaction,
  TransactionIdStr,
  TransactionMajorState,
  TransactionState,
  WalletNotification,
  assertUnreachable,
  checkDbInvariant,
  checkLogicInvariant,
  durationMul,
} from "@gnu-taler/taler-util";
import { HttpRequestOptions, HttpResponse } from "@gnu-taler/taler-util/http";
import {
  BackupProviderRecord,
  CoinHistoryRecord,
  CoinRecord,
  DbPreciseTimestamp,
  DepositGroupRecord,
  ExchangeEntryDbRecordStatus,
  ExchangeEntryDbUpdateStatus,
  ExchangeEntryRecord,
  PeerPullCreditRecord,
  PeerPullPaymentIncomingRecord,
  PeerPushDebitRecord,
  PeerPushPaymentIncomingRecord,
  PurchaseRecord,
  RecoupGroupRecord,
  RefreshGroupRecord,
  WalletDbAllStoresReadOnlyTransaction,
  WalletDbReadWriteTransaction,
  WithdrawalGroupRecord,
  timestampPreciseToDb,
} from "./db.js";
import { ReadyExchangeSummary } from "./exchanges.js";
import { createRefreshGroup } from "./refresh.js";
import { BalanceEffect, applyNotifyTransition } from "./transactions.js";
import { WalletExecutionContext, getDenomInfo } from "./wallet.js";

const logger = new Logger("operations/common.ts");

export interface CoinsSpendInfo {
  coinPubs: string[];
  contributions: AmountJson[];
  refreshReason: RefreshReason;
  /**
   * Transaction for which the coin is spent.
   */
  transactionId: TransactionIdStr;
}

export interface TokensSpendInfo {
  tokenPubs: string[];
  transactionId: TransactionIdStr;
}

export async function makeCoinsVisible(
  wex: WalletExecutionContext,
  tx: WalletDbReadWriteTransaction<["coins", "coinAvailability"]>,
  transactionId: string,
): Promise<void> {
  const coins =
    await tx.coins.indexes.bySourceTransactionId.getAll(transactionId);
  for (const coinRecord of coins) {
    if (!coinRecord.visible) {
      coinRecord.visible = 1;
      await tx.coins.put(coinRecord);
      const ageRestriction = coinRecord.maxAge;
      const car = await tx.coinAvailability.get([
        coinRecord.exchangeBaseUrl,
        coinRecord.denomPubHash,
        ageRestriction,
      ]);
      if (!car) {
        logger.error("missing coin availability record");
        continue;
      }
      const visCount = car.visibleCoinCount ?? 0;
      car.visibleCoinCount = visCount + 1;
      await tx.coinAvailability.put(car);
    }
  }
}

export async function makeCoinAvailable(
  wex: WalletExecutionContext,
  tx: WalletDbReadWriteTransaction<
    ["coins", "coinAvailability", "denominations"]
  >,
  coinRecord: CoinRecord,
): Promise<void> {
  checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
  const existingCoin = await tx.coins.get(coinRecord.coinPub);
  if (existingCoin) {
    return;
  }
  const denom = await tx.denominations.get([
    coinRecord.exchangeBaseUrl,
    coinRecord.denomPubHash,
  ]);
  checkDbInvariant(
    !!denom,
    `denomination of a coin is missing hash: ${coinRecord.denomPubHash}`,
  );
  const ageRestriction = coinRecord.maxAge;
  let car = await tx.coinAvailability.get([
    coinRecord.exchangeBaseUrl,
    coinRecord.denomPubHash,
    ageRestriction,
  ]);
  if (!car) {
    car = {
      maxAge: ageRestriction,
      value: denom.value,
      currency: denom.currency,
      denomPubHash: denom.denomPubHash,
      exchangeBaseUrl: denom.exchangeBaseUrl,
      freshCoinCount: 0,
      visibleCoinCount: 0,
    };
  }
  car.freshCoinCount++;
  await tx.coins.put(coinRecord);
  await tx.coinAvailability.put(car);
}

/**
 * Spend coins. Marks the coins as used, adds a coin history items
 * and creates refresh group.
 */
export async function spendCoins(
  wex: WalletExecutionContext,
  tx: WalletDbReadWriteTransaction<
    [
      "coins",
      "coinHistory",
      "coinAvailability",
      "refreshGroups",
      "refreshSessions",
      "denominations",
      "transactionsMeta",
    ]
  >,
  csi: CoinsSpendInfo,
): Promise<void> {
  if (csi.coinPubs.length != csi.contributions.length) {
    throw Error("assertion failed");
  }
  if (csi.coinPubs.length === 0) {
    return;
  }
  let refreshCoinPubs: CoinRefreshRequest[] = [];
  for (let i = 0; i < csi.coinPubs.length; i++) {
    const coin = await tx.coins.get(csi.coinPubs[i]);
    if (!coin) {
      throw Error("coin allocated for payment doesn't exist anymore");
    }
    const denom = await getDenomInfo(
      wex,
      tx,
      coin.exchangeBaseUrl,
      coin.denomPubHash,
    );
    checkDbInvariant(
      !!denom,
      `denomination of a coin is missing hash: ${coin.denomPubHash}`,
    );
    const coinAvailability = await tx.coinAvailability.get([
      coin.exchangeBaseUrl,
      coin.denomPubHash,
      coin.maxAge,
    ]);
    checkDbInvariant(
      !!coinAvailability,
      `age denom info is missing for ${coin.maxAge}`,
    );
    const contrib = csi.contributions[i];
    coin.status = CoinStatus.Dormant;
    const remaining = Amounts.sub(denom.value, contrib);
    if (remaining.saturated) {
      throw Error("not enough remaining balance on coin for payment");
    }
    refreshCoinPubs.push({
      amount: Amounts.stringify(remaining.amount),
      coinPub: coin.coinPub,
    });
    if (coinAvailability.freshCoinCount === 0) {
      throw Error(
        `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
      );
    }
    coinAvailability.freshCoinCount--;
    if (coin.visible) {
      if (!coinAvailability.visibleCoinCount) {
        logger.error("coin availability inconsistent");
      } else {
        coinAvailability.visibleCoinCount--;
      }
    }
    let histEntry: CoinHistoryRecord | undefined = await tx.coinHistory.get(
      coin.coinPub,
    );
    if (!histEntry) {
      histEntry = {
        coinPub: coin.coinPub,
        history: [],
      };
    }
    histEntry.history.push({
      type: "spend",
      transactionId: csi.transactionId,
      amount: Amounts.stringify(contrib),
    });
    await tx.coinHistory.put(histEntry);
    await tx.coins.put(coin);
    await tx.coinAvailability.put(coinAvailability);
  }

  await createRefreshGroup(
    wex,
    tx,
    Amounts.currencyOf(csi.contributions[0]),
    refreshCoinPubs,
    csi.refreshReason,
    csi.transactionId,
  );
}

export async function spendTokens(
  tx: WalletDbReadWriteTransaction<["tokens", "purchases"]>,
  tsi: TokensSpendInfo,
): Promise<void> {
  if (tsi.tokenPubs.length === 0) {
    return;
  }

  for (const pub of tsi.tokenPubs) {
    const token = await tx.tokens.get(pub);
    if (!token) {
      throw Error(`token allocated for payment doesn't exist anymore`);
    }

    if (token.transactionId) {
      if (token.transactionId !== tsi.transactionId) {
        throw Error(`token already being spent in a different transaction`);
      } else {
        return;
      }
    }

    logger.trace(`spending token ${token.tokenUsePub} in ${tsi.transactionId}`);
    token.transactionId = tsi.transactionId;
    await tx.tokens.put(token);
  }
}

export enum TombstoneTag {
  DeleteWithdrawalGroup = "delete-withdrawal-group",
  DeleteReserve = "delete-reserve",
  DeletePayment = "delete-payment",
  DeleteReward = "delete-reward",
  DeleteRefreshGroup = "delete-refresh-group",
  DeleteDepositGroup = "delete-deposit-group",
  DeleteRefund = "delete-refund",
  DeletePeerPullDebit = "delete-peer-pull-debit",
  DeletePeerPushDebit = "delete-peer-push-debit",
  DeletePeerPullCredit = "delete-peer-pull-credit",
  DeletePeerPushCredit = "delete-peer-push-credit",
}

export function getExchangeTosStatusFromRecord(
  exchange: ExchangeEntryRecord,
): ExchangeTosStatus {
  if (exchange.tosCurrentEtag == null) {
    return ExchangeTosStatus.MissingTos;
  }
  if (!exchange.tosAcceptedEtag) {
    return ExchangeTosStatus.Proposed;
  }
  if (exchange.tosAcceptedEtag == exchange.tosCurrentEtag) {
    return ExchangeTosStatus.Accepted;
  }
  return ExchangeTosStatus.Proposed;
}

export function getExchangeUpdateStatusFromRecord(
  r: ExchangeEntryRecord,
): ExchangeUpdateStatus {
  switch (r.updateStatus) {
    case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
      return ExchangeUpdateStatus.UnavailableUpdate;
    case ExchangeEntryDbUpdateStatus.Initial:
      return ExchangeUpdateStatus.Initial;
    case ExchangeEntryDbUpdateStatus.InitialUpdate:
      return ExchangeUpdateStatus.InitialUpdate;
    case ExchangeEntryDbUpdateStatus.Ready:
      return ExchangeUpdateStatus.Ready;
    case ExchangeEntryDbUpdateStatus.ReadyUpdate:
      return ExchangeUpdateStatus.ReadyUpdate;
    case ExchangeEntryDbUpdateStatus.Suspended:
      return ExchangeUpdateStatus.Suspended;
    case ExchangeEntryDbUpdateStatus.OutdatedUpdate:
      return ExchangeUpdateStatus.OutdatedUpdate;
    default:
      assertUnreachable(r.updateStatus);
  }
}

export function getExchangeEntryStatusFromRecord(
  r: ExchangeEntryRecord,
): ExchangeEntryStatus {
  switch (r.entryStatus) {
    case ExchangeEntryDbRecordStatus.Ephemeral:
      return ExchangeEntryStatus.Ephemeral;
    case ExchangeEntryDbRecordStatus.Preset:
      return ExchangeEntryStatus.Preset;
    case ExchangeEntryDbRecordStatus.Used:
      return ExchangeEntryStatus.Used;
    default:
      assertUnreachable(r.entryStatus);
  }
}

/**
 * Compute the state of an exchange entry from the DB
 * record.
 */
export function getExchangeState(r: ExchangeEntryRecord): ExchangeEntryState {
  return {
    exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
    exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
    tosStatus: getExchangeTosStatusFromRecord(r),
  };
}

export type ParsedTombstone =
  | {
      tag: TombstoneTag.DeleteWithdrawalGroup;
      withdrawalGroupId: string;
    }
  | { tag: TombstoneTag.DeleteRefund; refundGroupId: string }
  | { tag: TombstoneTag.DeleteReserve; reservePub: string }
  | { tag: TombstoneTag.DeleteRefreshGroup; refreshGroupId: string }
  | { tag: TombstoneTag.DeleteReward; walletTipId: string }
  | { tag: TombstoneTag.DeletePayment; proposalId: string };

export function constructTombstone(p: ParsedTombstone): TombstoneIdStr {
  switch (p.tag) {
    case TombstoneTag.DeleteWithdrawalGroup:
      return `tmb:${p.tag}:${p.withdrawalGroupId}` as TombstoneIdStr;
    case TombstoneTag.DeleteRefund:
      return `tmb:${p.tag}:${p.refundGroupId}` as TombstoneIdStr;
    case TombstoneTag.DeleteReserve:
      return `tmb:${p.tag}:${p.reservePub}` as TombstoneIdStr;
    case TombstoneTag.DeletePayment:
      return `tmb:${p.tag}:${p.proposalId}` as TombstoneIdStr;
    case TombstoneTag.DeleteRefreshGroup:
      return `tmb:${p.tag}:${p.refreshGroupId}` as TombstoneIdStr;
    case TombstoneTag.DeleteReward:
      return `tmb:${p.tag}:${p.walletTipId}` as TombstoneIdStr;
    default:
      assertUnreachable(p);
  }
}

/**
 * Uniform interface for a particular wallet transaction.
 */
export interface TransactionManager {
  get taskId(): TaskIdStr;
  get transactionId(): TransactionIdStr;
  fail(): Promise<void>;
  abort(): Promise<void>;
  suspend(): Promise<void>;
  resume(): Promise<void>;
  process(): Promise<TaskRunResult>;
}

export enum TaskRunResultType {
  Finished = "finished",
  Backoff = "backoff",
  Progress = "progress",
  Error = "error",
  LongpollReturnedPending = "longpoll-returned-pending",
  ScheduleLater = "schedule-later",
  NetworkRequired = "network-required",
}

export type TaskRunResult =
  | TaskRunFinishedResult
  | TaskRunErrorResult
  | TaskRunBackoffResult
  | TaskRunProgressResult
  | TaskRunLongpollReturnedPendingResult
  | TaskRunScheduleLaterResult
  | TaskRunNetworkRequiredResult;

export namespace TaskRunResult {
  /**
   * Task is finished and does not need to be processed again.
   */
  export function finished(): TaskRunResult {
    return {
      type: TaskRunResultType.Finished,
    };
  }
  /**
   * Task is waiting for something, should be invoked
   * again with exponentiall back-off until some other
   * result is returned.
   */
  export function backoff(): TaskRunResult {
    return {
      type: TaskRunResultType.Backoff,
    };
  }
  /**
   * Task made progress and should be processed again.
   */
  export function progress(): TaskRunResult {
    return {
      type: TaskRunResultType.Progress,
    };
  }
  /**
   * Run the task again at a fixed time in the future.
   */
  export function runAgainAt(runAt: AbsoluteTime): TaskRunResult {
    return {
      type: TaskRunResultType.ScheduleLater,
      runAt,
    };
  }
  /**
   * Longpolling returned, but what we're waiting for
   * is still pending on the other side.
   */
  export function longpollReturnedPending(): TaskRunLongpollReturnedPendingResult {
    return {
      type: TaskRunResultType.LongpollReturnedPending,
    };
  }
  /**
   * Network connection is required to complete the task.
   * When network is back, the transaction will be retried.
   */
  export function networkRequired(): TaskRunResult {
    return {
      type: TaskRunResultType.NetworkRequired,
    };
  }
  /**
   * Network connection is required to complete the task.
   * When network is back, the transaction will be retried.
   */
  export function error(detail: TalerErrorDetail): TaskRunResult {
    return {
      type: TaskRunResultType.Error,
      errorDetail: detail,
    };
  }
}

export interface TaskRunFinishedResult {
  type: TaskRunResultType.Finished;
}

export interface TaskRunBackoffResult {
  type: TaskRunResultType.Backoff;
}

export interface TaskRunProgressResult {
  type: TaskRunResultType.Progress;
}

export interface TaskRunScheduleLaterResult {
  type: TaskRunResultType.ScheduleLater;
  runAt: AbsoluteTime;
}

export interface TaskRunLongpollReturnedPendingResult {
  type: TaskRunResultType.LongpollReturnedPending;
}

export interface TaskRunNetworkRequiredResult {
  type: TaskRunResultType.NetworkRequired;
}

export interface TaskRunErrorResult {
  type: TaskRunResultType.Error;
  errorDetail: TalerErrorDetail;
}

export interface DbRetryInfo {
  firstTry: DbPreciseTimestamp;
  nextRetry: DbPreciseTimestamp;
  retryCounter: number;
}

export interface RetryPolicy {
  readonly backoffDelta: Duration;
  readonly backoffBase: number;
  readonly maxTimeout: Duration;
}

const defaultRetryPolicy: RetryPolicy = {
  backoffBase: 1.5,
  backoffDelta: Duration.fromSpec({ seconds: 1 }),
  maxTimeout: Duration.fromSpec({ minutes: 2 }),
};

function updateTimeout(
  r: DbRetryInfo,
  p: RetryPolicy = defaultRetryPolicy,
): void {
  const now = AbsoluteTime.now();
  if (now.t_ms === "never") {
    throw Error("assertion failed");
  }
  if (p.backoffDelta.d_ms === "forever") {
    r.nextRetry = timestampPreciseToDb(
      AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
    );
    return;
  }

  const nextIncrement =
    p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);

  const t =
    now.t_ms +
    (p.maxTimeout.d_ms === "forever"
      ? nextIncrement
      : Math.min(p.maxTimeout.d_ms, nextIncrement));
  r.nextRetry = timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t));
}

export function computeDbBackoff(retryCounter: number): DbPreciseTimestamp {
  const now = AbsoluteTime.now();
  if (now.t_ms === "never") {
    throw Error("assertion failed");
  }
  const p = defaultRetryPolicy;
  if (p.backoffDelta.d_ms === "forever") {
    throw Error("assertion failed");
  }

  const nextIncrement =
    p.backoffDelta.d_ms * Math.pow(p.backoffBase, retryCounter);

  const t =
    now.t_ms +
    (p.maxTimeout.d_ms === "forever"
      ? nextIncrement
      : Math.min(p.maxTimeout.d_ms, nextIncrement));
  return timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t));
}

export namespace DbRetryInfo {
  export function reset(p: RetryPolicy = defaultRetryPolicy): DbRetryInfo {
    const now = TalerPreciseTimestamp.now();
    const info: DbRetryInfo = {
      firstTry: timestampPreciseToDb(now),
      nextRetry: timestampPreciseToDb(now),
      retryCounter: 0,
    };
    updateTimeout(info, p);
    return info;
  }

  export function increment(
    r: DbRetryInfo | undefined,
    p: RetryPolicy = defaultRetryPolicy,
  ): DbRetryInfo {
    if (!r) {
      return reset(p);
    }
    const r2 = { ...r };
    r2.retryCounter++;
    updateTimeout(r2, p);
    return r2;
  }
}

/**
 * Timestamp after which the wallet would do an auto-refresh.
 */
export function getAutoRefreshExecuteThreshold(d: {
  stampExpireWithdraw: TalerProtocolTimestamp;
  stampExpireDeposit: TalerProtocolTimestamp;
}): AbsoluteTime {
  const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
    d.stampExpireWithdraw,
  );
  const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
    d.stampExpireDeposit,
  );
  const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
  const deltaDiv = durationMul(delta, 0.5);
  return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
}

/**
 * Type and schema definitions for pending tasks in the wallet.
 *
 * These are only used internally, and are not part of the stable public
 * interface to the wallet.
 */

export enum PendingTaskType {
  ExchangeUpdate = "exchange-update",
  ExchangeAutoRefresh = "exchange-auto-refresh",
  ExchangeWalletKyc = "exchange-wallet-kyc",
  Purchase = "purchase",
  Refresh = "refresh",
  Recoup = "recoup",
  RewardPickup = "reward-pickup",
  Withdraw = "withdraw",
  Deposit = "deposit",
  Backup = "backup",
  PeerPushDebit = "peer-push-debit",
  PeerPullCredit = "peer-pull-credit",
  PeerPushCredit = "peer-push-credit",
  PeerPullDebit = "peer-pull-debit",
}

/**
 * Parsed representation of task identifiers.
 */
export type ParsedTaskIdentifier =
  | {
      tag: PendingTaskType.Withdraw;
      withdrawalGroupId: string;
    }
  | { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string }
  | { tag: PendingTaskType.ExchangeAutoRefresh; exchangeBaseUrl: string }
  | { tag: PendingTaskType.ExchangeWalletKyc; exchangeBaseUrl: string }
  | { tag: PendingTaskType.Backup; backupProviderBaseUrl: string }
  | { tag: PendingTaskType.Deposit; depositGroupId: string }
  | { tag: PendingTaskType.PeerPullDebit; peerPullDebitId: string }
  | { tag: PendingTaskType.PeerPullCredit; pursePub: string }
  | { tag: PendingTaskType.PeerPushCredit; peerPushCreditId: string }
  | { tag: PendingTaskType.PeerPushDebit; pursePub: string }
  | { tag: PendingTaskType.Purchase; proposalId: string }
  | { tag: PendingTaskType.Recoup; recoupGroupId: string }
  | { tag: PendingTaskType.RewardPickup; walletRewardId: string }
  | { tag: PendingTaskType.Refresh; refreshGroupId: string };

export function parseTaskIdentifier(x: string): ParsedTaskIdentifier {
  const task = x.split(":");

  if (task.length < 2) {
    throw Error("task id should have al least 2 parts separated by ':'");
  }

  const [type, ...rest] = task;
  switch (type) {
    case PendingTaskType.Backup:
      return { tag: type, backupProviderBaseUrl: decodeURIComponent(rest[0]) };
    case PendingTaskType.Deposit:
      return { tag: type, depositGroupId: rest[0] };
    case PendingTaskType.ExchangeUpdate:
      return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
    case PendingTaskType.ExchangeAutoRefresh:
      return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
    case PendingTaskType.ExchangeWalletKyc:
      return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
    case PendingTaskType.PeerPullCredit:
      return { tag: type, pursePub: rest[0] };
    case PendingTaskType.PeerPullDebit:
      return { tag: type, peerPullDebitId: rest[0] };
    case PendingTaskType.PeerPushCredit:
      return { tag: type, peerPushCreditId: rest[0] };
    case PendingTaskType.PeerPushDebit:
      return { tag: type, pursePub: rest[0] };
    case PendingTaskType.Purchase:
      return { tag: type, proposalId: rest[0] };
    case PendingTaskType.Recoup:
      return { tag: type, recoupGroupId: rest[0] };
    case PendingTaskType.Refresh:
      return { tag: type, refreshGroupId: rest[0] };
    case PendingTaskType.RewardPickup:
      return { tag: type, walletRewardId: rest[0] };
    case PendingTaskType.Withdraw:
      return { tag: type, withdrawalGroupId: rest[0] };
    default:
      throw Error("invalid task identifier");
  }
}

export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskIdStr {
  switch (p.tag) {
    case PendingTaskType.Backup:
      return `${p.tag}:${p.backupProviderBaseUrl}` as TaskIdStr;
    case PendingTaskType.Deposit:
      return `${p.tag}:${p.depositGroupId}` as TaskIdStr;
    case PendingTaskType.ExchangeUpdate:
      return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskIdStr;
    case PendingTaskType.ExchangeWalletKyc:
      return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskIdStr;
    case PendingTaskType.ExchangeAutoRefresh:
      return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskIdStr;
    case PendingTaskType.PeerPullDebit:
      return `${p.tag}:${p.peerPullDebitId}` as TaskIdStr;
    case PendingTaskType.PeerPushCredit:
      return `${p.tag}:${p.peerPushCreditId}` as TaskIdStr;
    case PendingTaskType.PeerPullCredit:
      return `${p.tag}:${p.pursePub}` as TaskIdStr;
    case PendingTaskType.PeerPushDebit:
      return `${p.tag}:${p.pursePub}` as TaskIdStr;
    case PendingTaskType.Purchase:
      return `${p.tag}:${p.proposalId}` as TaskIdStr;
    case PendingTaskType.Recoup:
      return `${p.tag}:${p.recoupGroupId}` as TaskIdStr;
    case PendingTaskType.Refresh:
      return `${p.tag}:${p.refreshGroupId}` as TaskIdStr;
    case PendingTaskType.RewardPickup:
      return `${p.tag}:${p.walletRewardId}` as TaskIdStr;
    case PendingTaskType.Withdraw:
      return `${p.tag}:${p.withdrawalGroupId}` as TaskIdStr;
    default:
      assertUnreachable(p);
  }
}

export namespace TaskIdentifiers {
  export function forWithdrawal(wg: WithdrawalGroupRecord): TaskIdStr {
    return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskIdStr;
  }
  export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskIdStr {
    return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
      exch.baseUrl,
    )}` as TaskIdStr;
  }
  export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskIdStr {
    return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
      exchBaseUrl,
    )}` as TaskIdStr;
  }
  export function forExchangeAutoRefreshFromUrl(
    exchBaseUrl: string,
  ): TaskIdStr {
    return `${PendingTaskType.ExchangeAutoRefresh}:${encodeURIComponent(
      exchBaseUrl,
    )}` as TaskIdStr;
  }
  export function forRefresh(
    refreshGroupRecord: RefreshGroupRecord,
  ): TaskIdStr {
    return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskIdStr;
  }
  export function forPay(purchaseRecord: PurchaseRecord): TaskIdStr {
    return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskIdStr;
  }
  export function forRecoup(recoupRecord: RecoupGroupRecord): TaskIdStr {
    return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskIdStr;
  }
  export function forDeposit(depositRecord: DepositGroupRecord): TaskIdStr {
    return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskIdStr;
  }
  export function forBackup(backupRecord: BackupProviderRecord): TaskIdStr {
    return `${PendingTaskType.Backup}:${encodeURIComponent(
      backupRecord.baseUrl,
    )}` as TaskIdStr;
  }
  export function forPeerPushPaymentInitiation(
    ppi: PeerPushDebitRecord,
  ): TaskIdStr {
    return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskIdStr;
  }
  export function forPeerPullPaymentInitiation(
    ppi: PeerPullCreditRecord,
  ): TaskIdStr {
    return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskIdStr;
  }
  export function forPeerPullPaymentDebit(
    ppi: PeerPullPaymentIncomingRecord,
  ): TaskIdStr {
    return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskIdStr;
  }
  export function forPeerPushCredit(
    ppi: PeerPushPaymentIncomingRecord,
  ): TaskIdStr {
    return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskIdStr;
  }
}

/**
 * Result of a transaction transition.
 */
export enum TransitionResultType {
  Transition = 1,
  Stay = 2,
  Delete = 3,
}

export type TransitionResult<R> =
  | { type: TransitionResultType.Stay }
  | {
      type: TransitionResultType.Transition;
      rec: R;
      balanceEffect: BalanceEffect;
    }
  | { type: TransitionResultType.Delete };

export const TransitionResult = {
  stay<T>(): TransitionResult<T> {
    return { type: TransitionResultType.Stay };
  },
  delete<T>(): TransitionResult<T> {
    return { type: TransitionResultType.Delete };
  },
  transition<T>(
    rec: T,
    balanceEffect: BalanceEffect = BalanceEffect.Any,
  ): TransitionResult<T> {
    return {
      type: TransitionResultType.Transition,
      rec,
      balanceEffect,
    };
  },
};

/**
 * Transaction context.
 * Uniform interface to all transactions.
 */
export interface TransactionContext {
  get taskId(): TaskIdStr | undefined;
  get transactionId(): TransactionIdStr;
  abortTransaction(reason?: TalerErrorDetail): Promise<void>;
  suspendTransaction(): Promise<void>;
  resumeTransaction(): Promise<void>;
  failTransaction(reason?: TalerErrorDetail): Promise<void>;
  deleteTransaction(): Promise<void>;
  lookupFullTransaction(
    tx: WalletDbAllStoresReadOnlyTransaction,
    args?: LookupFullTransactionOpts,
  ): Promise<Transaction | undefined>;
}

export interface LookupFullTransactionOpts {
  includeContractTerms?: boolean;
}

declare const __taskIdStr: unique symbol;
export type TaskIdStr = string & { [__taskIdStr]: true };

/**
 * Wait until the wallet is in a particular state.
 *
 * Two functions must be provided:
 * 1. checkState, which checks if the wallet is in the
 *    desired state.
 * 2. filterNotification, which checks whether a notification
 *    might have lead to a state change.
 */
export async function genericWaitForState(
  wex: WalletExecutionContext,
  args: {
    checkState: () => Promise<boolean>;
    filterNotification: (notif: WalletNotification) => boolean;
  },
): Promise<void> {
  await wex.taskScheduler.ensureRunning();

  // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
  const flag = new AsyncFlag();
  // Raise purchaseNotifFlag whenever we get a notification
  // about our refresh.
  const cancelNotif = wex.ws.addNotificationListener((notif) => {
    if (args.filterNotification(notif)) {
      flag.raise();
    }
  });
  const unregisterOnCancelled = wex.cancellationToken.onCancelled((reason) => {
    cancelNotif();
    flag.raise();
  });

  try {
    while (true) {
      if (wex.cancellationToken.isCancelled) {
        throw Error("cancelled");
      }
      if (await args.checkState()) {
        return;
      }
      // Wait for the next transition
      await flag.wait();
      flag.reset();
    }
  } catch (e) {
    unregisterOnCancelled();
    cancelNotif();
    throw e;
  }
}

/**
 * Wait until the wallet is in a particular state.
 *
 * Two functions must be provided:
 * 1. checkState, which checks if the wallet is in the
 *    desired state.
 * 2. filterNotification, which checks whether a notification
 *    might have lead to a state change.
 */
export async function genericWaitForStateVal<T>(
  wex: WalletExecutionContext,
  args: {
    checkState: () => Promise<T | undefined>;
    filterNotification: (notif: WalletNotification) => boolean;
  },
): Promise<T> {
  await wex.taskScheduler.ensureRunning();

  // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
  const flag = new AsyncFlag();
  // Raise purchaseNotifFlag whenever we get a notification
  // about our refresh.
  const cancelNotif = wex.ws.addNotificationListener((notif) => {
    if (args.filterNotification(notif)) {
      flag.raise();
    }
  });
  const unregisterOnCancelled = wex.cancellationToken.onCancelled((reason) => {
    cancelNotif();
    flag.raise();
  });

  try {
    while (true) {
      if (wex.cancellationToken.isCancelled) {
        throw Error("cancelled");
      }
      const val = await args.checkState();
      if (val != null) {
        return val;
      }
      // Wait for the next transition
      await flag.wait();
      flag.reset();
    }
  } catch (e) {
    unregisterOnCancelled();
    cancelNotif();
    throw e;
  }
}

export function requireExchangeTosAcceptedOrThrow(
  wex: WalletExecutionContext,
  exchange: ReadyExchangeSummary,
): void {
  if (wex.ws.refcntIgnoreTos > 0) {
    logger.warn(
      `ignoring ToS acceptance state, refcntIgnoreTos=${wex.ws.refcntIgnoreTos}`,
    );
    return;
  }
  switch (exchange.tosStatus) {
    case ExchangeTosStatus.Accepted:
    case ExchangeTosStatus.MissingTos:
      break;
    default:
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED,
        {
          exchangeBaseUrl: exchange.exchangeBaseUrl,
          currentEtag: exchange.tosCurrentEtag,
          tosStatus: exchange.tosStatus,
        },
      );
  }
}

/**
 * Run a request, but cancel the wallet execution context as soon as the client
 * submits another request of the same type.
 */
export async function runWithClientCancellation<R, T>(
  wex: WalletExecutionContext,
  operation: string,
  clientCancellationId: string | undefined,
  handler: () => Promise<T>,
): Promise<T> {
  const clientCancelKey = clientCancellationId
    ? `ccid:${operation}:${clientCancellationId}`
    : undefined;
  const cts = wex.cts;
  if (clientCancelKey && cts) {
    const prevCts = wex.ws.clientCancellationMap.get(clientCancelKey);
    if (prevCts) {
      wex.oc.observe({
        type: ObservabilityEventType.Message,
        contents: `Cancelling previous key ${clientCancelKey}`,
      });
      prevCts.cancel(
        `cancelled by subsequent request with same cancellation ID`,
      );
    } else {
      wex.oc.observe({
        type: ObservabilityEventType.Message,
        contents: `No previous key ${clientCancelKey}`,
      });
    }
    wex.oc.observe({
      type: ObservabilityEventType.Message,
      contents: `Setting clientCancelKey ${clientCancelKey} to ${cts}`,
    });
    wex.ws.clientCancellationMap.set(clientCancelKey, cts);
  }
  try {
    return await handler();
  } finally {
    wex.oc.observe({
      type: ObservabilityEventType.Message,
      contents: `Deleting clientCancelKey ${clientCancelKey} to ${cts}`,
    });
    if (clientCancelKey && wex.cts && !wex.cts.token.isCancelled) {
      wex.ws.clientCancellationMap.delete(clientCancelKey);
    }
  }
}

/**
 * Run a queued longpool fetch with cancellation token and timeout_ms
 */
export async function cancelableLongPoll(
  wex: WalletExecutionContext,
  url: URL,
  opt?: HttpRequestOptions,
): Promise<HttpResponse> {
  const longPoll = async (timeoutMs: number) => {
    url.searchParams.set("timeout_ms", `${timeoutMs}`);
    return cancelableFetch(wex, url, opt);
  };
  return wex.ws.longpollQueue.run(url, wex.cancellationToken, longPoll);
}

/**
 * Fetch with cancellation token
 */
export async function cancelableFetch(
  wex: WalletExecutionContext,
  url: URL,
  opt?: HttpRequestOptions,
): Promise<HttpResponse> {
  return wex.http.fetch(url.href, {
    ...opt,
    cancellationToken: wex.cancellationToken,
  });
}

export interface RecordHandle<T> {
  rec: T | undefined;
  update(newRec: T | undefined, eff?: BalanceEffect): Promise<void>;
}

/**
 * Generic helper for transitioning transactions.
 */
export async function getGenericRecordHandle<T>(
  ctx: TransactionContext,
  tx: WalletDbReadWriteTransaction<any>,
  getRec: () => Promise<T | undefined>,
  storeRec: (r: T) => Promise<void>,
  deleteRec: () => Promise<void>,
  toSt: (r: T) => TransactionState,
  toStId: (r: T) => number,
  updateMeta: () => Promise<void>,
): Promise<[T | undefined, RecordHandle<T>]> {
  const rec = await getRec();
  let oldTxState: TransactionState;
  let oldStId: number;
  if (rec) {
    oldTxState = toSt(rec);
    oldStId = toStId(rec);
  } else {
    oldTxState = { major: TransactionMajorState.None };
    oldStId = 0;
  }
  const update = async (newRec: T | undefined, eff?: BalanceEffect) => {
    let newTxState: TransactionState;
    let newStId: number;
    if (newRec != null) {
      newTxState = toSt(newRec);
      newStId = toStId(newRec);
      await storeRec(newRec);
    } else if (rec != null) {
      newTxState = { major: TransactionMajorState.Deleted };
      newStId = -1;
      await deleteRec();
    } else {
      // Neither old or new record. Nothing to do.
      return;
    }
    await updateMeta();
    applyNotifyTransition(tx.notify, ctx.transactionId, {
      oldTxState,
      newTxState,
      balanceEffect: eff ?? BalanceEffect.Any,
      oldStId,
      newStId,
    });
  };
  return [rec, { rec, update }];
}
