/*
 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 { GlobalIDB } from "@gnu-taler/idb-bridge";
import {
  AbsoluteTime,
  Amounts,
  assertUnreachable,
  j2s,
  Logger,
  NotificationType,
  ScopeType,
  Transaction,
  TransactionByIdRequest,
  TransactionIdStr,
  TransactionMajorState,
  TransactionRecordFilter,
  TransactionsRequest,
  TransactionsResponse,
  TransactionState,
  TransactionType,
  TransactionWithdrawal,
  WithdrawalTransactionByURIRequest,
} from "@gnu-taler/taler-util";
import {
  constructTaskIdentifier,
  PendingTaskType,
  TaskIdentifiers,
  TaskIdStr,
  TransactionContext,
} from "./common.js";
import {
  DenomLossEventRecord,
  DepositGroupRecord,
  OPERATION_STATUS_NONFINAL_FIRST,
  OPERATION_STATUS_NONFINAL_LAST,
  PeerPullCreditRecord,
  PeerPullDebitRecordStatus,
  PeerPullPaymentIncomingRecord,
  PeerPushCreditStatus,
  PeerPushDebitRecord,
  PeerPushPaymentIncomingRecord,
  PurchaseRecord,
  RefreshGroupRecord,
  RefreshOperationStatus,
  RefundGroupRecord,
  WalletDbReadOnlyTransaction,
  WithdrawalGroupRecord,
  WithdrawalRecordType,
} 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 | 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);
    }
  }
  // FIXME: remove next release
  if (transactionsRequest?.currency) {
    return (
      transactionsRequest.currency.toLowerCase() !== currency.toLowerCase()
    );
  }
  return false;
}

function shouldSkipSearch(
  transactionsRequest: TransactionsRequest | undefined,
  fields: string[],
): boolean {
  if (!transactionsRequest?.search) {
    return false;
  }
  const needle = transactionsRequest.search.trim();
  for (const f of fields) {
    if (f.indexOf(needle) >= 0) {
      return false;
    }
  }
  return true;
}

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

export async function getWithdrawalTransactionByUri(
  wex: WalletExecutionContext,
  request: WithdrawalTransactionByURIRequest,
): Promise<TransactionWithdrawal | undefined> {
  return await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
    const withdrawalGroupRecord =
      await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
        request.talerWithdrawUri,
      );
    if (!withdrawalGroupRecord) {
      return undefined;
    }
    const ctx = new WithdrawTransactionContext(
      wex,
      withdrawalGroupRecord.withdrawalGroupId,
    );
    const dbTxn = await ctx.lookupFullTransaction(tx);
    if (!dbTxn || dbTxn.type !== TransactionType.Withdrawal) {
      return undefined;
    }
    return dbTxn;
  });
}

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

  const filter: TransactionRecordFilter = {};
  if (transactionsRequest?.filterByState) {
    filter.onlyState = transactionsRequest.filterByState;
  }

  await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
    await iterRecordsForPeerPushDebit(tx, filter, async (pi) => {
      const amount = Amounts.parseOrThrow(pi.amount);
      const exchangesInTx = [pi.exchangeBaseUrl];
      if (
        shouldSkipCurrency(transactionsRequest, amount.currency, exchangesInTx)
      ) {
        return;
      }
      if (shouldSkipSearch(transactionsRequest, [])) {
        return;
      }
      const ctx = new PeerPushDebitTransactionContext(wex, pi.pursePub);
      const txDetails = await ctx.lookupFullTransaction(tx);
      if (txDetails) {
        transactions.push(txDetails);
      }
    });

    await iterRecordsForPeerPullDebit(tx, filter, async (pi) => {
      const amount = Amounts.parseOrThrow(pi.amount);
      const exchangesInTx = [pi.exchangeBaseUrl];
      if (
        shouldSkipCurrency(transactionsRequest, amount.currency, exchangesInTx)
      ) {
        return;
      }
      if (shouldSkipSearch(transactionsRequest, [])) {
        return;
      }
      if (
        pi.status !== PeerPullDebitRecordStatus.PendingDeposit &&
        pi.status !== PeerPullDebitRecordStatus.Done
      ) {
        // FIXME: Why?!
        return;
      }

      const ctx = new PeerPullDebitTransactionContext(wex, pi.peerPullDebitId);
      const txDetails = await ctx.lookupFullTransaction(tx);
      if (txDetails) {
        transactions.push(txDetails);
      }
    });

    await iterRecordsForPeerPushCredit(tx, filter, async (pi) => {
      if (!pi.currency) {
        // Legacy transaction
        return;
      }
      const exchangesInTx = [pi.exchangeBaseUrl];
      if (shouldSkipCurrency(transactionsRequest, pi.currency, exchangesInTx)) {
        return;
      }
      if (shouldSkipSearch(transactionsRequest, [])) {
        return;
      }
      if (pi.status === PeerPushCreditStatus.DialogProposed) {
        // We don't report proposed push credit transactions, user needs
        // to scan URI again and confirm to see it.
        return;
      }

      const ctx = new PeerPushCreditTransactionContext(
        wex,
        pi.peerPushCreditId,
      );
      const txDetails = await ctx.lookupFullTransaction(tx);
      if (txDetails) {
        transactions.push(txDetails);
      }
    });

    await iterRecordsForPeerPullCredit(tx, filter, async (pi) => {
      const currency = Amounts.currencyOf(pi.amount);
      const exchangesInTx = [pi.exchangeBaseUrl];
      if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
        return;
      }
      if (shouldSkipSearch(transactionsRequest, [])) {
        return;
      }

      const ctx = new PeerPullCreditTransactionContext(wex, pi.pursePub);
      const txDetails = await ctx.lookupFullTransaction(tx);
      if (txDetails) {
        transactions.push(txDetails);
      }
    });

    await iterRecordsForRefund(tx, filter, async (refundGroup) => {
      const currency = Amounts.currencyOf(refundGroup.amountRaw);

      const exchangesInTx: string[] = [];
      const p = await tx.purchases.get(refundGroup.proposalId);
      if (!p || !p.payInfo || !p.payInfo.payCoinSelection) {
        //refund with no payment
        return;
      }

      // FIXME: This is very slow, should become obsolete with materialized transactions.
      for (const cp of p.payInfo.payCoinSelection.coinPubs) {
        const c = await tx.coins.get(cp);
        if (c?.exchangeBaseUrl) {
          exchangesInTx.push(c.exchangeBaseUrl);
        }
      }

      if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
        return;
      }

      const ctx = new RefundTransactionContext(wex, refundGroup.refundGroupId);
      const txDetails = await ctx.lookupFullTransaction(tx);
      if (txDetails) {
        transactions.push(txDetails);
      }
    });

    await iterRecordsForRefresh(tx, filter, async (rg) => {
      const exchangesInTx = rg.infoPerExchange
        ? Object.keys(rg.infoPerExchange)
        : [];
      if (shouldSkipCurrency(transactionsRequest, rg.currency, exchangesInTx)) {
        return;
      }
      let required = false;
      const opId = TaskIdentifiers.forRefresh(rg);
      if (transactionsRequest?.includeRefreshes) {
        required = true;
      } else if (rg.operationStatus !== RefreshOperationStatus.Finished) {
        const ort = await tx.operationRetries.get(opId);
        if (ort) {
          required = true;
        }
      }
      if (required) {
        const ctx = new RefreshTransactionContext(wex, rg.refreshGroupId);
        const txDetails = await ctx.lookupFullTransaction(tx);
        if (txDetails) {
          transactions.push(txDetails);
        }
      }
    });

    await iterRecordsForWithdrawal(tx, filter, async (wsr) => {
      if (
        wsr.rawWithdrawalAmount === undefined ||
        wsr.exchangeBaseUrl == undefined
      ) {
        // skip prepared withdrawals which has not been confirmed
        return;
      }
      const exchangesInTx = [wsr.exchangeBaseUrl];
      if (
        shouldSkipCurrency(
          transactionsRequest,
          Amounts.currencyOf(wsr.rawWithdrawalAmount),
          exchangesInTx,
        )
      ) {
        return;
      }

      if (shouldSkipSearch(transactionsRequest, [])) {
        return;
      }

      switch (wsr.wgInfo.withdrawalType) {
        case WithdrawalRecordType.PeerPullCredit:
          // Will be reported by the corresponding p2p transaction.
          // FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
          // FIXME: Still report if requested with verbose option?
          return;
        case WithdrawalRecordType.PeerPushCredit:
          // Will be reported by the corresponding p2p transaction.
          // FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
          // FIXME: Still report if requested with verbose option?
          return;
        case WithdrawalRecordType.BankIntegrated:
        case WithdrawalRecordType.BankManual: {
          const ctx = new WithdrawTransactionContext(
            wex,
            wsr.withdrawalGroupId,
          );
          const dbTxn = await ctx.lookupFullTransaction(tx);
          if (!dbTxn) {
            return;
          }
          transactions.push(dbTxn);
          return;
        }
        case WithdrawalRecordType.Recoup:
          // FIXME: Do we also report a transaction here?
          return;
      }
    });

    await iterRecordsForDenomLoss(tx, filter, async (rec) => {
      const amount = Amounts.parseOrThrow(rec.amount);
      const exchangesInTx = [rec.exchangeBaseUrl];
      if (
        shouldSkipCurrency(transactionsRequest, amount.currency, exchangesInTx)
      ) {
        return;
      }
      const ctx = new DenomLossTransactionContext(wex, rec.denomLossEventId);
      const txDetails = await ctx.lookupFullTransaction(tx);
      if (txDetails) {
        transactions.push(txDetails);
      }
    });

    await iterRecordsForDeposit(tx, filter, async (dg) => {
      const amount = Amounts.parseOrThrow(dg.amount);
      const exchangesInTx = dg.infoPerExchange
        ? Object.keys(dg.infoPerExchange)
        : [];
      if (
        shouldSkipCurrency(transactionsRequest, amount.currency, exchangesInTx)
      ) {
        return;
      }

      const ctx = new DepositTransactionContext(wex, dg.depositGroupId);
      const txDetails = await ctx.lookupFullTransaction(tx);
      if (txDetails) {
        transactions.push(txDetails);
      }
    });

    await iterRecordsForPurchase(tx, filter, async (purchase) => {
      const download = purchase.download;
      if (!download) {
        return;
      }
      if (!purchase.payInfo) {
        return;
      }

      const exchangesInTx: string[] = [];
      for (const cp of purchase.payInfo.payCoinSelection?.coinPubs ?? []) {
        const c = await tx.coins.get(cp);
        if (c?.exchangeBaseUrl) {
          exchangesInTx.push(c.exchangeBaseUrl);
        }
      }

      if (
        shouldSkipCurrency(
          transactionsRequest,
          download.currency,
          exchangesInTx,
        )
      ) {
        return;
      }
      const contractTermsRecord = await tx.contractTerms.get(
        download.contractTermsHash,
      );
      if (!contractTermsRecord) {
        return;
      }
      if (
        shouldSkipSearch(transactionsRequest, [
          contractTermsRecord?.contractTermsRaw?.summary || "",
        ])
      ) {
        return;
      }

      const ctx = new PayMerchantTransactionContext(wex, purchase.proposalId);
      const txDetails = await ctx.lookupFullTransaction(tx);
      if (txDetails) {
        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] };
}

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

/**
 * 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();
}

export interface TransitionInfo {
  oldTxState: TransactionState;
  newTxState: TransactionState;
}

/**
 * Notify of a state transition if necessary.
 */
export function notifyTransition(
  wex: WalletExecutionContext,
  transactionId: string,
  transitionInfo: TransitionInfo | undefined,
  experimentalUserData: any = undefined,
): void {
  if (
    transitionInfo &&
    !(
      transitionInfo.oldTxState.major === transitionInfo.newTxState.major &&
      transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor
    )
  ) {
    wex.ws.notify({
      type: NotificationType.TransactionStateTransition,
      oldTxState: transitionInfo.oldTxState,
      newTxState: transitionInfo.newTxState,
      transactionId,
      experimentalUserData,
    });
  }
}

/**
 * Iterate refresh records based on a filter.
 */
async function iterRecordsForRefresh(
  tx: WalletDbReadOnlyTransaction<["refreshGroups"]>,
  filter: TransactionRecordFilter,
  f: (r: RefreshGroupRecord) => Promise<void>,
): Promise<void> {
  let refreshGroups: RefreshGroupRecord[];
  if (filter.onlyState === "nonfinal") {
    const keyRange = GlobalIDB.KeyRange.bound(
      RefreshOperationStatus.Pending,
      RefreshOperationStatus.Suspended,
    );
    refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll(keyRange);
  } else {
    refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll();
  }

  for (const r of refreshGroups) {
    await f(r);
  }
}

async function iterRecordsForWithdrawal(
  tx: WalletDbReadOnlyTransaction<["withdrawalGroups"]>,
  filter: TransactionRecordFilter,
  f: (r: WithdrawalGroupRecord) => Promise<void>,
): Promise<void> {
  let withdrawalGroupRecords: WithdrawalGroupRecord[];
  if (filter.onlyState === "nonfinal") {
    const keyRange = GlobalIDB.KeyRange.bound(
      OPERATION_STATUS_NONFINAL_FIRST,
      OPERATION_STATUS_NONFINAL_LAST,
    );
    withdrawalGroupRecords =
      await tx.withdrawalGroups.indexes.byStatus.getAll(keyRange);
  } else {
    withdrawalGroupRecords =
      await tx.withdrawalGroups.indexes.byStatus.getAll();
  }
  for (const wgr of withdrawalGroupRecords) {
    await f(wgr);
  }
}

async function iterRecordsForDeposit(
  tx: WalletDbReadOnlyTransaction<["depositGroups"]>,
  filter: TransactionRecordFilter,
  f: (r: DepositGroupRecord) => Promise<void>,
): Promise<void> {
  let dgs: DepositGroupRecord[];
  if (filter.onlyState === "nonfinal") {
    const keyRange = GlobalIDB.KeyRange.bound(
      OPERATION_STATUS_NONFINAL_FIRST,
      OPERATION_STATUS_NONFINAL_LAST,
    );
    dgs = await tx.depositGroups.indexes.byStatus.getAll(keyRange);
  } else {
    dgs = await tx.depositGroups.indexes.byStatus.getAll();
  }

  for (const dg of dgs) {
    await f(dg);
  }
}

async function iterRecordsForDenomLoss(
  tx: WalletDbReadOnlyTransaction<["denomLossEvents"]>,
  filter: TransactionRecordFilter,
  f: (r: DenomLossEventRecord) => Promise<void>,
): Promise<void> {
  let dgs: DenomLossEventRecord[];
  if (filter.onlyState === "nonfinal") {
    const keyRange = GlobalIDB.KeyRange.bound(
      OPERATION_STATUS_NONFINAL_FIRST,
      OPERATION_STATUS_NONFINAL_LAST,
    );
    dgs = await tx.denomLossEvents.indexes.byStatus.getAll(keyRange);
  } else {
    dgs = await tx.denomLossEvents.indexes.byStatus.getAll();
  }

  for (const dg of dgs) {
    await f(dg);
  }
}

async function iterRecordsForRefund(
  tx: WalletDbReadOnlyTransaction<["refundGroups"]>,
  filter: TransactionRecordFilter,
  f: (r: RefundGroupRecord) => Promise<void>,
): Promise<void> {
  if (filter.onlyState === "nonfinal") {
    const keyRange = GlobalIDB.KeyRange.bound(
      OPERATION_STATUS_NONFINAL_FIRST,
      OPERATION_STATUS_NONFINAL_LAST,
    );
    await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f);
  } else {
    await tx.refundGroups.iter().forEachAsync(f);
  }
}

async function iterRecordsForPurchase(
  tx: WalletDbReadOnlyTransaction<["purchases"]>,
  filter: TransactionRecordFilter,
  f: (r: PurchaseRecord) => Promise<void>,
): Promise<void> {
  if (filter.onlyState === "nonfinal") {
    const keyRange = GlobalIDB.KeyRange.bound(
      OPERATION_STATUS_NONFINAL_FIRST,
      OPERATION_STATUS_NONFINAL_LAST,
    );
    await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f);
  } else {
    await tx.purchases.indexes.byStatus.iter().forEachAsync(f);
  }
}

async function iterRecordsForPeerPullCredit(
  tx: WalletDbReadOnlyTransaction<["peerPullCredit"]>,
  filter: TransactionRecordFilter,
  f: (r: PeerPullCreditRecord) => Promise<void>,
): Promise<void> {
  if (filter.onlyState === "nonfinal") {
    const keyRange = GlobalIDB.KeyRange.bound(
      OPERATION_STATUS_NONFINAL_FIRST,
      OPERATION_STATUS_NONFINAL_LAST,
    );
    await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
  } else {
    await tx.peerPullCredit.indexes.byStatus.iter().forEachAsync(f);
  }
}

async function iterRecordsForPeerPullDebit(
  tx: WalletDbReadOnlyTransaction<["peerPullDebit"]>,
  filter: TransactionRecordFilter,
  f: (r: PeerPullPaymentIncomingRecord) => Promise<void>,
): Promise<void> {
  if (filter.onlyState === "nonfinal") {
    const keyRange = GlobalIDB.KeyRange.bound(
      OPERATION_STATUS_NONFINAL_FIRST,
      OPERATION_STATUS_NONFINAL_LAST,
    );
    await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
  } else {
    await tx.peerPullDebit.indexes.byStatus.iter().forEachAsync(f);
  }
}

async function iterRecordsForPeerPushDebit(
  tx: WalletDbReadOnlyTransaction<["peerPushDebit"]>,
  filter: TransactionRecordFilter,
  f: (r: PeerPushDebitRecord) => Promise<void>,
): Promise<void> {
  if (filter.onlyState === "nonfinal") {
    const keyRange = GlobalIDB.KeyRange.bound(
      OPERATION_STATUS_NONFINAL_FIRST,
      OPERATION_STATUS_NONFINAL_LAST,
    );
    await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
  } else {
    await tx.peerPushDebit.indexes.byStatus.iter().forEachAsync(f);
  }
}

async function iterRecordsForPeerPushCredit(
  tx: WalletDbReadOnlyTransaction<["peerPushCredit"]>,
  filter: TransactionRecordFilter,
  f: (r: PeerPushPaymentIncomingRecord) => Promise<void>,
): Promise<void> {
  if (filter.onlyState === "nonfinal") {
    const keyRange = GlobalIDB.KeyRange.bound(
      OPERATION_STATUS_NONFINAL_FIRST,
      OPERATION_STATUS_NONFINAL_LAST,
    );
    await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
  } else {
    await tx.peerPushCredit.indexes.byStatus.iter().forEachAsync(f);
  }
}
