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

/**
 * Imports.
 */
import {
  BridgeIDBKeyRange,
  GlobalIDB,
  IDBKeyRange,
} from "@gnu-taler/idb-bridge";
import {
  AbsoluteTime,
  Amounts,
  assertUnreachable,
  GetTransactionsV2Request,
  j2s,
  Logger,
  makeTalerErrorDetail,
  NotificationType,
  ScopeType,
  TalerErrorCode,
  Transaction,
  TransactionByIdRequest,
  TransactionIdStr,
  TransactionMajorState,
  TransactionsRequest,
  TransactionsResponse,
  TransactionState,
  TransactionType,
  WalletNotification,
} from "@gnu-taler/taler-util";
import {
  constructTaskIdentifier,
  PendingTaskType,
  TaskIdStr,
  TransactionContext,
} from "./common.js";
import {
  DbPreciseTimestamp,
  OPERATION_STATUS_DONE_FIRST,
  OPERATION_STATUS_DONE_LAST,
  OPERATION_STATUS_NONFINAL_FIRST,
  OPERATION_STATUS_NONFINAL_LAST,
  PurchaseStatus,
  timestampPreciseToDb,
  TransactionMetaRecord,
  WalletDbAllStoresReadOnlyTransaction,
  WalletDbAllStoresReadWriteTransaction,
} from "./db.js";
import { DepositTransactionContext } from "./deposits.js";
import { DenomLossTransactionContext } from "./exchanges.js";
import {
  PayMerchantTransactionContext,
  RefundTransactionContext,
} from "./pay-merchant.js";
import { PeerPullCreditTransactionContext } from "./pay-peer-pull-credit.js";
import { PeerPullDebitTransactionContext } from "./pay-peer-pull-debit.js";
import { PeerPushCreditTransactionContext } from "./pay-peer-push-credit.js";
import { PeerPushDebitTransactionContext } from "./pay-peer-push-debit.js";
import { RefreshTransactionContext } from "./refresh.js";
import type { WalletExecutionContext } from "./wallet.js";
import { WithdrawTransactionContext } from "./withdraw.js";

const logger = new Logger("taler-wallet-core:transactions.ts");

function shouldSkipCurrency(
  transactionsRequest:
    | TransactionsRequest
    | GetTransactionsV2Request
    | undefined,
  currency: string,
  exchangesInTransaction: string[],
): boolean {
  if (transactionsRequest?.scopeInfo) {
    const sameCurrency = Amounts.isSameCurrency(
      currency,
      transactionsRequest.scopeInfo.currency,
    );
    switch (transactionsRequest.scopeInfo.type) {
      case ScopeType.Global: {
        return !sameCurrency;
      }
      case ScopeType.Exchange: {
        return (
          !sameCurrency ||
          (exchangesInTransaction.length > 0 &&
            !exchangesInTransaction.includes(transactionsRequest.scopeInfo.url))
        );
      }
      case ScopeType.Auditor: {
        // same currency and same auditor
        throw Error("filering balance in auditor scope is not implemented");
      }
      default:
        assertUnreachable(transactionsRequest.scopeInfo);
    }
  }
  if (transactionsRequest?.currency) {
    return (
      transactionsRequest.currency.toLowerCase() !== currency.toLowerCase()
    );
  }
  return false;
}

/**
 * Fallback order of transactions that have the same timestamp.
 */
const txOrder: { [t in TransactionType]: number } = {
  [TransactionType.Withdrawal]: 1,
  [TransactionType.Payment]: 3,
  [TransactionType.PeerPullCredit]: 4,
  [TransactionType.PeerPullDebit]: 5,
  [TransactionType.PeerPushCredit]: 6,
  [TransactionType.PeerPushDebit]: 7,
  [TransactionType.Refund]: 8,
  [TransactionType.Deposit]: 9,
  [TransactionType.Refresh]: 10,
  [TransactionType.Recoup]: 11,
  [TransactionType.InternalWithdrawal]: 12,
  [TransactionType.DenomLoss]: 13,
};

export async function getTransactionById(
  wex: WalletExecutionContext,
  req: TransactionByIdRequest,
): Promise<Transaction> {
  const parsedTx = parseTransactionIdentifier(req.transactionId);

  if (!parsedTx) {
    throw Error("invalid transaction ID");
  }

  switch (parsedTx.tag) {
    case TransactionType.InternalWithdrawal:
    case TransactionType.Withdrawal:
    case TransactionType.DenomLoss:
    case TransactionType.Recoup:
    case TransactionType.PeerPushDebit:
    case TransactionType.PeerPushCredit:
    case TransactionType.Refresh:
    case TransactionType.PeerPullCredit:
    case TransactionType.Payment:
    case TransactionType.Deposit:
    case TransactionType.PeerPullDebit:
    case TransactionType.Refund: {
      const ctx = await getContextForTransaction(wex, req.transactionId);
      const txDetails = await wex.db.runAllStoresReadOnlyTx({}, async (tx) =>
        ctx.lookupFullTransaction(tx, {
          includeContractTerms: req.includeContractTerms,
        }),
      );
      if (!txDetails) {
        throw Error("transaction not found");
      }
      return txDetails;
    }
  }
}

export function isUnsuccessfulTransaction(state: TransactionState): boolean {
  return (
    state.major === TransactionMajorState.Aborted ||
    state.major === TransactionMajorState.Expired ||
    state.major === TransactionMajorState.Aborting ||
    state.major === TransactionMajorState.Deleted ||
    state.major === TransactionMajorState.Failed
  );
}

function checkFilterIncludes(
  req: GetTransactionsV2Request | undefined,
  mtx: TransactionMetaRecord,
): boolean {
  if (shouldSkipCurrency(req, mtx.currency, mtx.exchanges)) {
    return false;
  }

  const parsedTx = parseTransactionIdentifier(mtx.transactionId);
  if (
    parsedTx?.tag === TransactionType.Refresh &&
    !(req?.includeRefreshes || req?.includeAll)
  ) {
    return false;
  }

  if (!req?.includeAll) {
    if (
      parsedTx?.tag === TransactionType.Payment &&
      mtx.status === PurchaseStatus.DoneRepurchaseDetected
    ) {
      return false;
    }
  }

  let included: boolean;

  const filter = req?.filterByState;
  switch (filter) {
    case "done":
      included =
        mtx.status >= OPERATION_STATUS_DONE_FIRST &&
        mtx.status <= OPERATION_STATUS_DONE_LAST;
      break;
    case "final":
      included = !(
        mtx.status >= OPERATION_STATUS_NONFINAL_FIRST &&
        mtx.status <= OPERATION_STATUS_NONFINAL_LAST
      );
      break;
    case "nonfinal":
      included =
        mtx.status >= OPERATION_STATUS_NONFINAL_FIRST &&
        mtx.status <= OPERATION_STATUS_NONFINAL_LAST;
      break;
    case undefined:
      included = true;
      break;
    default:
      assertUnreachable(filter);
  }
  return included;
}

async function addFiltered(
  wex: WalletExecutionContext,
  tx: WalletDbAllStoresReadOnlyTransaction,
  req: GetTransactionsV2Request | undefined,
  target: Transaction[],
  source: TransactionMetaRecord[],
): Promise<number> {
  let numAdded: number = 0;
  for (const mtx of source) {
    if (req?.limit != null && target.length >= Math.abs(req.limit)) {
      break;
    }
    if (checkFilterIncludes(req, mtx)) {
      const ctx = await getContextForTransaction(wex, mtx.transactionId);
      const txDetails = await ctx.lookupFullTransaction(tx);
      // FIXME: This means that in some cases we can return fewer transactions
      // than requested.
      if (!txDetails) {
        logger.warn(
          `full transaction details missing for transaction ${mtx.transactionId}`,
        );
        continue;
      }
      numAdded += 1;
      target.push(txDetails);
    }
  }
  return numAdded;
}

/**
 * Sort transactions in-place according to the request.
 */
function sortTransactions(
  req: GetTransactionsV2Request | undefined,
  transactions: Transaction[],
): void {
  let sortSign: number;
  if (req?.limit != null && req.limit < 0) {
    sortSign = -1;
  } else {
    sortSign = 1;
  }
  const txCmp = (h1: Transaction, h2: Transaction) => {
    // Order transactions by timestamp.  Newest transactions come first.
    const tsCmp = AbsoluteTime.cmp(
      AbsoluteTime.fromPreciseTimestamp(h1.timestamp),
      AbsoluteTime.fromPreciseTimestamp(h2.timestamp),
    );
    // If the timestamp is exactly the same, order by transaction type.
    if (tsCmp === 0) {
      return Math.sign(txOrder[h1.type] - txOrder[h2.type]);
    }
    return sortSign * tsCmp;
  };
  transactions.sort(txCmp);
}

async function findOffsetTransaction(
  tx: WalletDbAllStoresReadOnlyTransaction,
  req?: GetTransactionsV2Request,
): Promise<TransactionMetaRecord | undefined> {
  let forwards = req?.limit == null || req.limit >= 0;
  let closestTimestamp: DbPreciseTimestamp | undefined = undefined;
  if (req?.offsetTransactionId) {
    const res = await tx.transactionsMeta.get(req.offsetTransactionId);
    if (res) {
      return res;
    }
    if (req.offsetTimestamp) {
      closestTimestamp = timestampPreciseToDb(req.offsetTimestamp);
    } else {
      throw Error(
        "offset transaction not found and no offset timestamp specified",
      );
    }
  } else if (req?.offsetTimestamp) {
    const dbStamp = timestampPreciseToDb(req.offsetTimestamp);
    const res = await tx.transactionsMeta.indexes.byTimestamp.get(dbStamp);
    if (res) {
      return res;
    }
    closestTimestamp = timestampPreciseToDb(req.offsetTimestamp);
  } else {
    return undefined;
  }

  // We didn't find a precise offset transaction.
  // This must mean that it was deleted.
  // Depending on the direction, find the prev/next
  // transaction and use it as an offset.

  if (forwards) {
    // We don't want to skip transactions in pagination,
    // so get the transaction before the timestamp

    // Slow query, but should not happen often!
    const recs = await tx.transactionsMeta.indexes.byTimestamp.getAll(
      BridgeIDBKeyRange.upperBound(closestTimestamp, false),
    );
    if (recs.length > 0) {
      return recs[recs.length - 1];
    }
    return undefined;
  } else {
    // Likewise, get the transaction after the timestamp
    const recs = await tx.transactionsMeta.indexes.byTimestamp.getAll(
      BridgeIDBKeyRange.lowerBound(closestTimestamp, false),
      1,
    );
    return recs[0];
  }
}

export async function getTransactionsV2(
  wex: WalletExecutionContext,
  transactionsRequest?: GetTransactionsV2Request,
): Promise<TransactionsResponse> {
  // The implementation of this function is optimized for
  // the common fast path of requesting transactions
  // in an ascending order.
  // Other requests are more difficult to implement
  // in a performant way due to IndexedDB limitations.

  const resultTransactions: Transaction[] = [];

  await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
    let forwards =
      transactionsRequest?.limit == null || transactionsRequest.limit >= 0;
    let limit =
      transactionsRequest?.limit != null
        ? Math.abs(transactionsRequest.limit)
        : undefined;
    let offsetMtx = await findOffsetTransaction(tx, transactionsRequest);

    if (limit == null && offsetMtx == null) {
      // Fast path for returning *everything* that matches the filter.
      // FIXME: We could use the DB for filtering here
      const res = await tx.transactionsMeta.indexes.byStatus.getAll();
      await addFiltered(wex, tx, transactionsRequest, resultTransactions, res);
    } else if (!forwards) {
      // Descending, backwards request.
      // Slow implementation. Doing it properly would require using cursors,
      // which are also slow in IndexedDB.
      const res = await tx.transactionsMeta.indexes.byTimestamp.getAll();
      res.reverse();
      let start: number;
      if (offsetMtx != null) {
        const needleTxId = offsetMtx.transactionId;
        start = res.findIndex((x) => x.transactionId === needleTxId);
        if (start < 0) {
          throw Error("offset transaction not found");
        }
      } else {
        start = 0;
      }
      for (let i = start; i < res.length; i++) {
        if (limit != null && resultTransactions.length >= limit) {
          break;
        }
        await addFiltered(wex, tx, transactionsRequest, resultTransactions, [
          res[i],
        ]);
      }
    } else {
      // Forward request
      let query: BridgeIDBKeyRange | undefined = undefined;
      if (offsetMtx != null) {
        query = GlobalIDB.KeyRange.lowerBound(offsetMtx.timestamp, true);
      }
      while (true) {
        const res = await tx.transactionsMeta.indexes.byTimestamp.getAll(
          query,
          limit,
        );
        if (res.length === 0) {
          break;
        }
        await addFiltered(
          wex,
          tx,
          transactionsRequest,
          resultTransactions,
          res,
        );
        if (limit != null && resultTransactions.length >= limit) {
          break;
        }
        // Continue after last result
        query = BridgeIDBKeyRange.lowerBound(
          res[res.length - 1].timestamp,
          true,
        );
      }
    }
  });

  sortTransactions(transactionsRequest, resultTransactions);

  return {
    transactions: resultTransactions,
  };
}

/**
 * Retrieve the full event history for this wallet.
 */
export async function getTransactions(
  wex: WalletExecutionContext,
  transactionsRequest?: TransactionsRequest,
): Promise<TransactionsResponse> {
  const transactions: Transaction[] = [];

  let keyRange: IDBKeyRange | undefined = undefined;

  if (transactionsRequest?.filterByState === "nonfinal") {
    keyRange = GlobalIDB.KeyRange.bound(
      OPERATION_STATUS_NONFINAL_FIRST,
      OPERATION_STATUS_NONFINAL_LAST,
    );
  }

  await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
    const allMetaTransactions =
      await tx.transactionsMeta.indexes.byStatus.getAll(keyRange);
    for (const metaTx of allMetaTransactions) {
      if (
        shouldSkipCurrency(
          transactionsRequest,
          metaTx.currency,
          metaTx.exchanges,
        )
      ) {
        continue;
      }

      const parsedTx = parseTransactionIdentifier(metaTx.transactionId);
      if (
        parsedTx?.tag === TransactionType.Refresh &&
        !transactionsRequest?.includeRefreshes
      ) {
        continue;
      }

      const ctx = await getContextForTransaction(wex, metaTx.transactionId);
      const txDetails = await ctx.lookupFullTransaction(tx);
      if (!txDetails) {
        continue;
      }
      transactions.push(txDetails);
    }
  });

  // One-off checks, because of a bug where the wallet previously
  // did not migrate the DB correctly and caused these amounts
  // to be missing sometimes.
  for (let tx of transactions) {
    if (!tx.amountEffective) {
      logger.warn(`missing amountEffective in ${j2s(tx)}`);
    }
    if (!tx.amountRaw) {
      logger.warn(`missing amountRaw in ${j2s(tx)}`);
    }
    if (!tx.timestamp) {
      logger.warn(`missing timestamp in ${j2s(tx)}`);
    }
  }

  const isPending = (x: Transaction) =>
    x.txState.major === TransactionMajorState.Pending ||
    x.txState.major === TransactionMajorState.Aborting ||
    x.txState.major === TransactionMajorState.Dialog;

  let sortSign: number;
  if (transactionsRequest?.sort == "descending") {
    sortSign = -1;
  } else {
    sortSign = 1;
  }

  const txCmp = (h1: Transaction, h2: Transaction) => {
    // Order transactions by timestamp.  Newest transactions come first.
    const tsCmp = AbsoluteTime.cmp(
      AbsoluteTime.fromPreciseTimestamp(h1.timestamp),
      AbsoluteTime.fromPreciseTimestamp(h2.timestamp),
    );
    // If the timestamp is exactly the same, order by transaction type.
    if (tsCmp === 0) {
      return Math.sign(txOrder[h1.type] - txOrder[h2.type]);
    }
    return sortSign * tsCmp;
  };

  if (transactionsRequest?.sort === "stable-ascending") {
    transactions.sort(txCmp);
    return { transactions };
  }

  const txPending = transactions.filter((x) => isPending(x));
  const txNotPending = transactions.filter((x) => !isPending(x));

  txPending.sort(txCmp);
  txNotPending.sort(txCmp);

  return { transactions: [...txPending, ...txNotPending] };
}

/**
 * Re-create materialized transactions from scratch.
 *
 * Used for migrations.
 */
export async function rematerializeTransactions(
  wex: WalletExecutionContext,
  tx: WalletDbAllStoresReadWriteTransaction,
): Promise<void> {
  logger.trace("re-materializing transactions");

  const allTxMeta = await tx.transactionsMeta.getAll();
  for (const txMeta of allTxMeta) {
    await tx.transactionsMeta.delete(txMeta.transactionId);
  }

  await tx.peerPushDebit.iter().forEachAsync(async (x) => {
    const ctx = new PeerPushDebitTransactionContext(wex, x.pursePub);
    await ctx.updateTransactionMeta(tx);
  });

  await tx.peerPushCredit.iter().forEachAsync(async (x) => {
    const ctx = new PeerPushCreditTransactionContext(wex, x.peerPushCreditId);
    await ctx.updateTransactionMeta(tx);
  });

  await tx.peerPullCredit.iter().forEachAsync(async (x) => {
    const ctx = new PeerPullCreditTransactionContext(wex, x.pursePub);
    await ctx.updateTransactionMeta(tx);
  });

  await tx.peerPullDebit.iter().forEachAsync(async (x) => {
    const ctx = new PeerPullDebitTransactionContext(wex, x.peerPullDebitId);
    await ctx.updateTransactionMeta(tx);
  });

  await tx.refundGroups.iter().forEachAsync(async (x) => {
    const ctx = new RefundTransactionContext(wex, x.refundGroupId);
    await ctx.updateTransactionMeta(tx);
  });

  await tx.refreshGroups.iter().forEachAsync(async (x) => {
    const ctx = new RefreshTransactionContext(wex, x.refreshGroupId);
    await ctx.updateTransactionMeta(tx);
  });

  await tx.withdrawalGroups.iter().forEachAsync(async (x) => {
    const ctx = new WithdrawTransactionContext(wex, x.withdrawalGroupId);
    await ctx.updateTransactionMeta(tx);
  });

  await tx.denomLossEvents.iter().forEachAsync(async (x) => {
    const ctx = new DenomLossTransactionContext(wex, x.denomLossEventId);
    await ctx.updateTransactionMeta(tx);
  });

  await tx.depositGroups.iter().forEachAsync(async (x) => {
    const ctx = new DepositTransactionContext(wex, x.depositGroupId);
    await ctx.updateTransactionMeta(tx);
  });

  await tx.purchases.iter().forEachAsync(async (x) => {
    const ctx = new PayMerchantTransactionContext(wex, x.proposalId);
    await ctx.updateTransactionMeta(tx);
  });
}

export type ParsedTransactionIdentifier =
  | { tag: TransactionType.Deposit; depositGroupId: string }
  | { tag: TransactionType.Payment; proposalId: string }
  | { tag: TransactionType.PeerPullDebit; peerPullDebitId: string }
  | { tag: TransactionType.PeerPullCredit; pursePub: string }
  | { tag: TransactionType.PeerPushCredit; peerPushCreditId: string }
  | { tag: TransactionType.PeerPushDebit; pursePub: string }
  | { tag: TransactionType.Refresh; refreshGroupId: string }
  | { tag: TransactionType.Refund; refundGroupId: string }
  | { tag: TransactionType.Withdrawal; withdrawalGroupId: string }
  | { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string }
  | { tag: TransactionType.Recoup; recoupGroupId: string }
  | { tag: TransactionType.DenomLoss; denomLossEventId: string };

export function constructTransactionIdentifier(
  pTxId: ParsedTransactionIdentifier,
): TransactionIdStr {
  switch (pTxId.tag) {
    case TransactionType.Deposit:
      return `txn:${pTxId.tag}:${pTxId.depositGroupId}` as TransactionIdStr;
    case TransactionType.Payment:
      return `txn:${pTxId.tag}:${pTxId.proposalId}` as TransactionIdStr;
    case TransactionType.PeerPullCredit:
      return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
    case TransactionType.PeerPullDebit:
      return `txn:${pTxId.tag}:${pTxId.peerPullDebitId}` as TransactionIdStr;
    case TransactionType.PeerPushCredit:
      return `txn:${pTxId.tag}:${pTxId.peerPushCreditId}` as TransactionIdStr;
    case TransactionType.PeerPushDebit:
      return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
    case TransactionType.Refresh:
      return `txn:${pTxId.tag}:${pTxId.refreshGroupId}` as TransactionIdStr;
    case TransactionType.Refund:
      return `txn:${pTxId.tag}:${pTxId.refundGroupId}` as TransactionIdStr;
    case TransactionType.Withdrawal:
      return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
    case TransactionType.InternalWithdrawal:
      return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
    case TransactionType.Recoup:
      return `txn:${pTxId.tag}:${pTxId.recoupGroupId}` as TransactionIdStr;
    case TransactionType.DenomLoss:
      return `txn:${pTxId.tag}:${pTxId.denomLossEventId}` as TransactionIdStr;
    default:
      assertUnreachable(pTxId);
  }
}

/**
 * Parse a transaction identifier string into a typed, structured representation.
 */
export function parseTransactionIdentifier(
  transactionId: string,
): ParsedTransactionIdentifier | undefined {
  const txnParts = transactionId.split(":");

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

  const [prefix, type, ...rest] = txnParts;

  if (prefix != "txn") {
    throw Error("invalid transaction identifier");
  }

  switch (type) {
    case TransactionType.Deposit:
      return { tag: TransactionType.Deposit, depositGroupId: rest[0] };
    case TransactionType.Payment:
      return { tag: TransactionType.Payment, proposalId: rest[0] };
    case TransactionType.PeerPullCredit:
      return { tag: TransactionType.PeerPullCredit, pursePub: rest[0] };
    case TransactionType.PeerPullDebit:
      return {
        tag: TransactionType.PeerPullDebit,
        peerPullDebitId: rest[0],
      };
    case TransactionType.PeerPushCredit:
      return {
        tag: TransactionType.PeerPushCredit,
        peerPushCreditId: rest[0],
      };
    case TransactionType.PeerPushDebit:
      return { tag: TransactionType.PeerPushDebit, pursePub: rest[0] };
    case TransactionType.Refresh:
      return { tag: TransactionType.Refresh, refreshGroupId: rest[0] };
    case TransactionType.Refund:
      return {
        tag: TransactionType.Refund,
        refundGroupId: rest[0],
      };
    case TransactionType.Withdrawal:
      return {
        tag: TransactionType.Withdrawal,
        withdrawalGroupId: rest[0],
      };
    case TransactionType.DenomLoss:
      return {
        tag: TransactionType.DenomLoss,
        denomLossEventId: rest[0],
      };
    default:
      return undefined;
  }
}

function maybeTaskFromTransaction(
  transactionId: string,
): TaskIdStr | undefined {
  const parsedTx = parseTransactionIdentifier(transactionId);

  if (!parsedTx) {
    throw Error("invalid transaction identifier");
  }

  // FIXME: We currently don't cancel active long-polling tasks here.

  switch (parsedTx.tag) {
    case TransactionType.PeerPullCredit:
      return constructTaskIdentifier({
        tag: PendingTaskType.PeerPullCredit,
        pursePub: parsedTx.pursePub,
      });
    case TransactionType.Deposit:
      return constructTaskIdentifier({
        tag: PendingTaskType.Deposit,
        depositGroupId: parsedTx.depositGroupId,
      });
    case TransactionType.InternalWithdrawal:
    case TransactionType.Withdrawal:
      return constructTaskIdentifier({
        tag: PendingTaskType.Withdraw,
        withdrawalGroupId: parsedTx.withdrawalGroupId,
      });
    case TransactionType.Payment:
      return constructTaskIdentifier({
        tag: PendingTaskType.Purchase,
        proposalId: parsedTx.proposalId,
      });
    case TransactionType.Refresh:
      return constructTaskIdentifier({
        tag: PendingTaskType.Refresh,
        refreshGroupId: parsedTx.refreshGroupId,
      });
    case TransactionType.PeerPullDebit:
      return constructTaskIdentifier({
        tag: PendingTaskType.PeerPullDebit,
        peerPullDebitId: parsedTx.peerPullDebitId,
      });
    case TransactionType.PeerPushCredit:
      return constructTaskIdentifier({
        tag: PendingTaskType.PeerPushCredit,
        peerPushCreditId: parsedTx.peerPushCreditId,
      });
    case TransactionType.PeerPushDebit:
      return constructTaskIdentifier({
        tag: PendingTaskType.PeerPushDebit,
        pursePub: parsedTx.pursePub,
      });
    case TransactionType.Refund:
      // Nothing to do for a refund transaction.
      return undefined;
    case TransactionType.Recoup:
      return constructTaskIdentifier({
        tag: PendingTaskType.Recoup,
        recoupGroupId: parsedTx.recoupGroupId,
      });
    case TransactionType.DenomLoss:
      // Nothing to do for denom loss
      return undefined;
    default:
      assertUnreachable(parsedTx);
  }
}

/**
 * Immediately retry the underlying operation
 * of a transaction.
 */
export async function retryTransaction(
  wex: WalletExecutionContext,
  transactionId: string,
): Promise<void> {
  logger.info(`resetting retry timeout for ${transactionId}`);
  const taskId = maybeTaskFromTransaction(transactionId);
  if (taskId) {
    await wex.taskScheduler.resetTaskRetries(taskId);
  }
}

/**
 * Reset the task retry counter for all tasks.
 */
export async function retryAll(wex: WalletExecutionContext): Promise<void> {
  await wex.taskScheduler.ensureRunning();
  const tasks = wex.taskScheduler.getActiveTasks();
  for (const task of tasks) {
    await wex.taskScheduler.resetTaskRetries(task);
  }
}

/**
 * Restart all the running tasks.
 */
export async function restartAll(wex: WalletExecutionContext): Promise<void> {
  await wex.taskScheduler.reload();
}

async function getContextForTransaction(
  wex: WalletExecutionContext,
  transactionId: string,
): Promise<TransactionContext> {
  const tx = parseTransactionIdentifier(transactionId);
  if (!tx) {
    throw Error("invalid transaction ID");
  }
  switch (tx.tag) {
    case TransactionType.Deposit:
      return new DepositTransactionContext(wex, tx.depositGroupId);
    case TransactionType.Refresh:
      return new RefreshTransactionContext(wex, tx.refreshGroupId);
    case TransactionType.InternalWithdrawal:
    case TransactionType.Withdrawal:
      return new WithdrawTransactionContext(wex, tx.withdrawalGroupId);
    case TransactionType.Payment:
      return new PayMerchantTransactionContext(wex, tx.proposalId);
    case TransactionType.PeerPullCredit:
      return new PeerPullCreditTransactionContext(wex, tx.pursePub);
    case TransactionType.PeerPushDebit:
      return new PeerPushDebitTransactionContext(wex, tx.pursePub);
    case TransactionType.PeerPullDebit:
      return new PeerPullDebitTransactionContext(wex, tx.peerPullDebitId);
    case TransactionType.PeerPushCredit:
      return new PeerPushCreditTransactionContext(wex, tx.peerPushCreditId);
    case TransactionType.Refund:
      return new RefundTransactionContext(wex, tx.refundGroupId);
    case TransactionType.Recoup:
      //return new RecoupTransactionContext(ws, tx.recoupGroupId);
      throw new Error("not yet supported");
    case TransactionType.DenomLoss:
      return new DenomLossTransactionContext(wex, tx.denomLossEventId);
    default:
      assertUnreachable(tx);
  }
}

/**
 * Suspends a pending transaction, stopping any associated network activities,
 * but with a chance of trying again at a later time. This could be useful if
 * a user needs to save battery power or bandwidth and an operation is expected
 * to take longer (such as a backup, recovery or very large withdrawal operation).
 */
export async function suspendTransaction(
  wex: WalletExecutionContext,
  transactionId: string,
): Promise<void> {
  const ctx = await getContextForTransaction(wex, transactionId);
  await ctx.suspendTransaction();
}

export async function failTransaction(
  wex: WalletExecutionContext,
  transactionId: string,
): Promise<void> {
  const ctx = await getContextForTransaction(wex, transactionId);
  await ctx.failTransaction(
    makeTalerErrorDetail(
      TalerErrorCode.WALLET_TRANSACTION_ABANDONED_BY_USER,
      {},
    ),
  );
}

/**
 * Resume a suspended transaction.
 */
export async function resumeTransaction(
  wex: WalletExecutionContext,
  transactionId: string,
): Promise<void> {
  const ctx = await getContextForTransaction(wex, transactionId);
  await ctx.resumeTransaction();
}

/**
 * Permanently delete a transaction based on the transaction ID.
 */
export async function deleteTransaction(
  wex: WalletExecutionContext,
  transactionId: string,
): Promise<void> {
  const ctx = await getContextForTransaction(wex, transactionId);
  await ctx.deleteTransaction();
  if (ctx.taskId) {
    wex.taskScheduler.stopShepherdTask(ctx.taskId);
  }
}

export async function abortTransaction(
  wex: WalletExecutionContext,
  transactionId: string,
): Promise<void> {
  const ctx = await getContextForTransaction(wex, transactionId);
  await ctx.abortTransaction(
    makeTalerErrorDetail(TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, {}),
  );
}

export interface TransitionInfo {
  // FIXME: Get rid in favor of oldStId
  oldTxState: TransactionState;
  // FIXME: Get rid in favor of newStId
  newTxState: TransactionState;

  balanceEffect: BalanceEffect;

  newStId: number;
  oldStId: number;
}

export enum BalanceEffect {
  /**
   * There is no effect on the balance.
   */
  None = 0,
  /**
   * Balance change is internal and should not
   * change the user-visible balance.
   */
  PreserveUserVisible = 1,
  /**
   * The balance preserves the user-visible balance,
   * but might affect user-visible flags.
   */
  Flags = 2,
  /**
   * Balance change can have any effect.
   */
  Any = 3,
}

/**
 * Call the given notification function with a
 * balance effect notification, if necessary.
 */
export function applyNotifyBalanceEffect(
  notify: (n: WalletNotification) => void,
  transactionId: string,
  balanceEffect: BalanceEffect,
): void {
  if (balanceEffect > BalanceEffect.None) {
    notify({
      type: NotificationType.BalanceChange,
      hintTransactionId: transactionId,
      isInternal: balanceEffect <= BalanceEffect.PreserveUserVisible,
    });
  }
}

/**
 * Notify of a state transition and balance change if necessary.
 */
export function applyNotifyTransition(
  notify: (n: WalletNotification) => void,
  transactionId: string,
  transitionInfo: TransitionInfo | undefined,
  experimentalUserData: any = undefined,
): void {
  if (
    transitionInfo &&
    !(
      transitionInfo.oldStId === transitionInfo.newStId &&
      transitionInfo.oldTxState.major === transitionInfo.newTxState.major &&
      transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor
    )
  ) {
    notify({
      type: NotificationType.TransactionStateTransition,
      oldTxState: transitionInfo.oldTxState,
      newTxState: transitionInfo.newTxState,
      transactionId,
      experimentalUserData,
      newStId: transitionInfo.newStId,
    });

    applyNotifyBalanceEffect(
      notify,
      transactionId,
      transitionInfo.balanceEffect,
    );
  }
}
