/*
 This file is part of GNU Taler
 (C) 2019 GNUnet e.V.

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

/**
 * Functions to compute the wallet's balance.
 *
 * There are multiple definition of the wallet's balance.
 * We use the following terminology:
 *
 * - "available": Balance that is available
 *   for spending from transactions in their final state and
 *   expected to be available from pending refreshes.
 *
 * - "pending-incoming": Expected (positive!) delta
 *   to the available balance that we expect to have
 *   after pending operations reach the "done" state.
 *
 * - "pending-outgoing": Amount that is currently allocated
 *   to be spent, but the spend operation could still be aborted
 *   and part of the pending-outgoing amount could be recovered.
 *
 * - "material": Balance that the wallet believes it could spend *right now*,
 *   without waiting for any operations to complete.
 *   This balance type is important when showing "insufficient balance" error messages.
 *
 * - "age-acceptable": Subset of the material balance that can be spent
 *   with age restrictions applied.
 *
 * - "merchant-acceptable": Subset of the material balance that can be spent with a particular
 *   merchant (restricted via min age, exchange, auditor, wire_method).
 *
 * - "merchant-depositable": Subset of the merchant-acceptable balance that the merchant
 *   can accept via their supported wire methods.
 */

/**
 * Imports.
 */
import { GlobalIDB } from "@gnu-taler/idb-bridge";
import {
  AllowedExchangeInfo,
  AmountJson,
  AmountLike,
  Amounts,
  assertUnreachable,
  BalanceFlag,
  BalancesResponse,
  checkDbInvariant,
  DonauSummaryItem,
  GetBalanceDetailRequest,
  j2s,
  Logger,
  parsePaytoUri,
  ScopeInfo,
  ScopeType,
} from "@gnu-taler/taler-util";
import { ExchangeRestrictionSpec, findMatchingWire } from "./coinSelection.js";
import {
  DepositOperationStatus,
  DonationSummaryRecord,
  ExchangeEntryDbRecordStatus,
  OPERATION_STATUS_NONFINAL_FIRST,
  OPERATION_STATUS_NONFINAL_LAST,
  PeerPullDebitRecordStatus,
  PeerPullPaymentCreditStatus,
  PeerPushCreditStatus,
  PeerPushDebitStatus,
  PurchaseStatus,
  RefreshGroupRecord,
  RefreshOperationStatus,
  WalletDbReadOnlyTransaction,
  WithdrawalGroupStatus,
  WithdrawalRecordType,
} from "./db.js";
import {
  checkExchangeInScopeTx,
  getExchangeScopeInfo,
  getExchangeWireDetailsInTx,
} from "./exchanges.js";
import { getDenomInfo, WalletExecutionContext } from "./wallet.js";

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

interface WalletBalance {
  scopeInfo: ScopeInfo;
  available: AmountJson;
  pendingIncoming: AmountJson;
  pendingOutgoing: AmountJson;
  flagIncomingKyc: boolean;
  flagIncomingConfirmation: boolean;
  flagOutgoingKyc: boolean;
}

function computeRefreshGroupAvailableAmountForExchanges(
  r: RefreshGroupRecord,
  restrictExchanges: AllowedExchangeInfo[] | undefined,
): AmountJson {
  // Don't count finished refreshes, since the refresh already resulted
  // in coins being added to the wallet.
  let available = Amounts.zeroOfCurrency(r.currency);
  if (r.timestampFinished) {
    return available;
  }
  if (!r.infoPerExchange) {
    return available;
  }
  for (const exch of Object.keys(r.infoPerExchange)) {
    const pe = r.infoPerExchange[exch];
    if (
      restrictExchanges == null ||
      restrictExchanges.find((x) => x.exchangeBaseUrl === exch) != null
    ) {
      available = Amounts.add(available, pe.outputEffective).amount;
    }
  }
  return available;
}

function getBalanceKey(scopeInfo: ScopeInfo): string {
  switch (scopeInfo.type) {
    case ScopeType.Auditor:
      return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
    case ScopeType.Exchange:
      return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
    case ScopeType.Global:
      return `${scopeInfo.type};${scopeInfo.currency}`;
  }
}

function getScopeSortingOrder(scopeInfo: ScopeInfo): number {
  switch (scopeInfo.type) {
    case ScopeType.Global:
      return 0;
    case ScopeType.Auditor:
      return 1;
    case ScopeType.Exchange:
      return 2;
    default:
      assertUnreachable(scopeInfo);
  }
}

class BalancesStore {
  private exchangeScopeCache: Record<string, ScopeInfo> = {};
  private balanceStore: Record<string, WalletBalance> = {};
  private donauSummaryItems: DonauSummaryItem[] | undefined = undefined;

  constructor(
    private wex: WalletExecutionContext,
    private tx: WalletDbReadOnlyTransaction<
      [
        "globalCurrencyAuditors",
        "globalCurrencyExchanges",
        "exchanges",
        "exchangeDetails",
      ]
    >,
  ) {}

  /**
   * Add amount to a balance field, both for
   * the slicing by exchange and currency.
   */
  private async initBalance(
    currency: string,
    exchangeBaseUrl: string,
  ): Promise<WalletBalance> {
    let scopeInfo: ScopeInfo | undefined =
      this.exchangeScopeCache[exchangeBaseUrl];
    if (!scopeInfo) {
      scopeInfo = await getExchangeScopeInfo(
        this.tx,
        exchangeBaseUrl,
        currency,
      );
      this.exchangeScopeCache[exchangeBaseUrl] = scopeInfo;
    }
    const balanceKey = getBalanceKey(scopeInfo);
    const b = this.balanceStore[balanceKey];
    if (!b) {
      const zero = Amounts.zeroOfCurrency(currency);
      this.balanceStore[balanceKey] = {
        scopeInfo,
        available: zero,
        pendingIncoming: zero,
        pendingOutgoing: zero,
        flagIncomingConfirmation: false,
        flagIncomingKyc: false,
        flagOutgoingKyc: false,
      };
    }
    return this.balanceStore[balanceKey];
  }

  async addZero(currency: string, exchangeBaseUrl: string): Promise<void> {
    await this.initBalance(currency, exchangeBaseUrl);
  }

  async addAvailable(
    currency: string,
    exchangeBaseUrl: string,
    amount: AmountLike,
  ): Promise<void> {
    const b = await this.initBalance(currency, exchangeBaseUrl);
    b.available = Amounts.add(b.available, amount).amount;
  }

  async addPendingIncoming(
    currency: string,
    exchangeBaseUrl: string,
    amount: AmountLike,
  ): Promise<void> {
    const b = await this.initBalance(currency, exchangeBaseUrl);
    b.pendingIncoming = Amounts.add(b.pendingIncoming, amount).amount;
  }

  async addPendingOutgoing(
    currency: string,
    exchangeBaseUrl: string,
    amount: AmountLike,
  ): Promise<void> {
    const b = await this.initBalance(currency, exchangeBaseUrl);
    b.pendingOutgoing = Amounts.add(b.pendingOutgoing, amount).amount;
  }

  async setFlagIncomingKyc(
    currency: string,
    exchangeBaseUrl: string,
  ): Promise<void> {
    const b = await this.initBalance(currency, exchangeBaseUrl);
    b.flagIncomingKyc = true;
  }

  async setFlagIncomingConfirmation(
    currency: string,
    exchangeBaseUrl: string,
  ): Promise<void> {
    const b = await this.initBalance(currency, exchangeBaseUrl);
    b.flagIncomingConfirmation = true;
  }

  async setFlagOutgoingKyc(
    currency: string,
    exchangeBaseUrl: string,
  ): Promise<void> {
    const b = await this.initBalance(currency, exchangeBaseUrl);
    b.flagOutgoingKyc = true;
  }

  addDonauItem(rec: DonationSummaryRecord): void {
    if (!this.donauSummaryItems) {
      this.donauSummaryItems = [];
    }

    this.donauSummaryItems.push({
      amountReceiptsAvailable: rec.amountReceiptsAvailable,
      amountReceiptsSubmitted: rec.amountReceiptsSubmitted,
      donauBaseUrl: rec.donauBaseUrl,
      year: rec.year,
      legalDomain: rec.legalDomain,
      // Not yet implemented
      amountStatement: undefined,
    });
  }

  toBalancesResponse(): BalancesResponse {
    const balancesResponse: BalancesResponse = {
      balances: [],
    };

    if (this.donauSummaryItems) {
      balancesResponse.donauSummary = this.donauSummaryItems;
    }

    const balanceStore = this.balanceStore;

    Object.keys(balanceStore)
      .sort((a, b) => {
        const balanceA = balanceStore[a];
        const balanceB = balanceStore[b];
        const scopeA = getScopeSortingOrder(balanceA.scopeInfo);
        const scopeB = getScopeSortingOrder(balanceB.scopeInfo);
        if (scopeA !== scopeB) return scopeA - scopeB;
        return a.localeCompare(b);
      })
      .forEach((c) => {
        const v = balanceStore[c];
        const flags: BalanceFlag[] = [];
        if (v.flagIncomingKyc) {
          flags.push(BalanceFlag.IncomingKyc);
        }
        if (v.flagIncomingConfirmation) {
          flags.push(BalanceFlag.IncomingConfirmation);
        }
        if (v.flagOutgoingKyc) {
          flags.push(BalanceFlag.OutgoingKyc);
        }
        balancesResponse.balances.push({
          scopeInfo: v.scopeInfo,
          available: Amounts.stringify(v.available),
          pendingIncoming: Amounts.stringify(v.pendingIncoming),
          pendingOutgoing: Amounts.stringify(v.pendingOutgoing),
          // FIXME: This field is basically not implemented, do we even need it?
          hasPendingTransactions: false,
          // FIXME: This field is basically not implemented, do we even need it?
          requiresUserInput: false,
          flags,
        });
      });
    return balancesResponse;
  }
}

/**
 * Get balance information.
 */
export async function getBalancesInsideTransaction(
  wex: WalletExecutionContext,
  tx: WalletDbReadOnlyTransaction<
    [
      "exchanges",
      "exchangeDetails",
      "coinAvailability",
      "refreshGroups",
      "depositGroups",
      "withdrawalGroups",
      "globalCurrencyAuditors",
      "globalCurrencyExchanges",
      "peerPushDebit",
      "peerPushCredit",
      "peerPullCredit",
      "peerPullDebit",
      "purchases",
      "coins",
      "donationSummaries",
    ]
  >,
): Promise<BalancesResponse> {
  const balanceStore: BalancesStore = new BalancesStore(wex, tx);

  const keyRangeActive = GlobalIDB.KeyRange.bound(
    OPERATION_STATUS_NONFINAL_FIRST,
    OPERATION_STATUS_NONFINAL_LAST,
  );

  await tx.donationSummaries.iter().forEachAsync(async (rec) => {
    balanceStore.addDonauItem(rec);
  });

  await tx.exchanges.iter().forEachAsync(async (ex) => {
    if (
      ex.entryStatus === ExchangeEntryDbRecordStatus.Used ||
      ex.tosAcceptedTimestamp != null
    ) {
      const det = await getExchangeWireDetailsInTx(tx, ex.baseUrl);
      if (det) {
        await balanceStore.addZero(det.currency, ex.baseUrl);
      }
    }
  });

  await tx.coinAvailability.iter().forEachAsync(async (ca) => {
    const count = ca.visibleCoinCount ?? 0;
    await balanceStore.addZero(ca.currency, ca.exchangeBaseUrl);
    for (let i = 0; i < count; i++) {
      await balanceStore.addAvailable(
        ca.currency,
        ca.exchangeBaseUrl,
        ca.value,
      );
    }
  });

  await tx.refreshGroups.indexes.byStatus
    .iter(keyRangeActive)
    .forEachAsync(async (r) => {
      switch (r.operationStatus) {
        case RefreshOperationStatus.Pending:
        case RefreshOperationStatus.Suspended:
          break;
        default:
          return;
      }
      const perExchange = r.infoPerExchange;
      if (!perExchange) {
        return;
      }
      for (const [e, x] of Object.entries(perExchange)) {
        await balanceStore.addAvailable(r.currency, e, x.outputEffective);
      }
    });

  await tx.withdrawalGroups.indexes.byStatus
    .iter(keyRangeActive)
    .forEachAsync(async (wg) => {
      switch (wg.status) {
        case WithdrawalGroupStatus.AbortedBank:
        case WithdrawalGroupStatus.AbortedExchange:
        case WithdrawalGroupStatus.FailedAbortingBank:
        case WithdrawalGroupStatus.FailedBankAborted:
        case WithdrawalGroupStatus.AbortedOtherWallet:
        case WithdrawalGroupStatus.AbortedUserRefused:
        case WithdrawalGroupStatus.DialogProposed:
        case WithdrawalGroupStatus.Done:
          // Does not count as pendingIncoming
          return;
        case WithdrawalGroupStatus.PendingReady:
        case WithdrawalGroupStatus.PendingRedenominate:
        case WithdrawalGroupStatus.SuspendedRedenominate:
        case WithdrawalGroupStatus.AbortingBank:
        case WithdrawalGroupStatus.PendingQueryingStatus:
        case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
        case WithdrawalGroupStatus.SuspendedReady:
        case WithdrawalGroupStatus.SuspendedRegisteringBank:
        case WithdrawalGroupStatus.SuspendedAbortingBank:
        case WithdrawalGroupStatus.SuspendedQueryingStatus:
          // Pending, but no special flag.
          break;
        case WithdrawalGroupStatus.PendingBalanceKyc:
        case WithdrawalGroupStatus.SuspendedBalanceKyc:
        case WithdrawalGroupStatus.SuspendedKyc:
        case WithdrawalGroupStatus.PendingBalanceKycInit:
        case WithdrawalGroupStatus.SuspendedBalanceKycInit:
        case WithdrawalGroupStatus.PendingKyc: {
          checkDbInvariant(
            wg.denomsSel !== undefined,
            "wg in kyc state should have been initialized",
          );
          checkDbInvariant(
            wg.exchangeBaseUrl !== undefined,
            "wg in kyc state should have been initialized",
          );
          const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue);
          await balanceStore.setFlagIncomingKyc(currency, wg.exchangeBaseUrl);
          break;
        }
        case WithdrawalGroupStatus.PendingRegisteringBank: {
          if (wg.denomsSel && wg.exchangeBaseUrl) {
            const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue);
            await balanceStore.setFlagIncomingConfirmation(
              currency,
              wg.exchangeBaseUrl,
            );
          }
          break;
        }
        case WithdrawalGroupStatus.PendingWaitConfirmBank: {
          checkDbInvariant(
            wg.exchangeBaseUrl !== undefined,
            "withdrawal group in PendingWaitConfirmBank state should have been initialized",
          );

          // FIXME: Consider just having the currency as a fixed field in the DB
          // instead of having it in many locations.
          let currency: string;

          if (wg.denomsSel) {
            currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue);
          } else if (
            wg.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated &&
            wg.wgInfo.bankInfo.currency
          ) {
            currency = wg.wgInfo.bankInfo.currency;
          } else {
            logger.warn(
              "could not determine currency for confirmed withdrawal group",
            );
            break;
          }
          await balanceStore.setFlagIncomingConfirmation(
            currency,
            wg.exchangeBaseUrl,
          );
          break;
        }
        default:
          assertUnreachable(wg.status);
      }
      if (wg.denomsSel && wg.exchangeBaseUrl) {
        // only inform pending incoming if amount and exchange has been selected
        const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue);
        await balanceStore.addPendingIncoming(
          currency,
          wg.exchangeBaseUrl,
          wg.denomsSel.totalCoinValue,
        );
      }
    });

  await tx.peerPushDebit.indexes.byStatus
    .iter(keyRangeActive)
    .forEachAsync(async (ppdRecord) => {
      switch (ppdRecord.status) {
        case PeerPushDebitStatus.AbortingDeletePurse:
        case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
        case PeerPushDebitStatus.PendingReady:
        case PeerPushDebitStatus.SuspendedReady:
        case PeerPushDebitStatus.PendingCreatePurse:
        case PeerPushDebitStatus.SuspendedCreatePurse: {
          const currency = Amounts.currencyOf(ppdRecord.amount);
          await balanceStore.addPendingOutgoing(
            currency,
            ppdRecord.exchangeBaseUrl,
            ppdRecord.totalCost,
          );
          break;
        }
        case PeerPushDebitStatus.Failed:
        case PeerPushDebitStatus.Aborted:
        case PeerPushDebitStatus.Done:
        case PeerPushDebitStatus.Expired:
          break;
        default:
          assertUnreachable(ppdRecord.status);
      }
    });

  await tx.peerPushCredit.indexes.byStatus
    .iter(keyRangeActive)
    .forEachAsync(async (rec) => {
      switch (rec.status) {
        case PeerPushCreditStatus.PendingMerge:
        case PeerPushCreditStatus.PendingBalanceKycInit:
        case PeerPushCreditStatus.PendingBalanceKycRequired:
        case PeerPushCreditStatus.SuspendedBalanceKycInit:
        case PeerPushCreditStatus.PendingMergeKycRequired:
        case PeerPushCreditStatus.SuspendedMerge:
        case PeerPushCreditStatus.SuspendedBalanceKycRequired:
        case PeerPushCreditStatus.SuspendedMergeKycRequired: {
          const currency = Amounts.currencyOf(rec.estimatedAmountEffective);
          const amount = rec.estimatedAmountEffective;
          await balanceStore.addPendingIncoming(
            currency,
            rec.exchangeBaseUrl,
            amount,
          );
          break;
        }
        case PeerPushCreditStatus.PendingWithdrawing:
        case PeerPushCreditStatus.SuspendedWithdrawing: {
          // Here, incoming balance should be covered by internal withdrawal tx
          break;
        }
      }
    });

  await tx.peerPullCredit.indexes.byStatus
    .iter(keyRangeActive)
    .forEachAsync(async (rec) => {
      switch (rec.status) {
        case PeerPullPaymentCreditStatus.SuspendedBalanceKycInit:
        case PeerPullPaymentCreditStatus.PendingBalanceKycInit:
        case PeerPullPaymentCreditStatus.PendingBalanceKycRequired:
        case PeerPullPaymentCreditStatus.SuspendedBalanceKycRequired:
        case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
        case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
        case PeerPullPaymentCreditStatus.PendingReady:
        case PeerPullPaymentCreditStatus.PendingCreatePurse:
        case PeerPullPaymentCreditStatus.SuspendedReady:
        case PeerPullPaymentCreditStatus.SuspendedCreatePurse: {
          const currency = Amounts.currencyOf(rec.amount);
          const amount = rec.estimatedAmountEffective;
          await balanceStore.addPendingIncoming(
            currency,
            rec.exchangeBaseUrl,
            amount,
          );
          break;
        }
      }
    });

  await tx.peerPullDebit.indexes.byStatus
    .iter(keyRangeActive)
    .forEachAsync(async (rec) => {
      switch (rec.status) {
        case PeerPullDebitRecordStatus.PendingDeposit:
        case PeerPullDebitRecordStatus.AbortingRefresh:
        case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
        case PeerPullDebitRecordStatus.SuspendedDeposit:
          const currency = Amounts.currencyOf(rec.amount);
          const amount = rec.coinSel?.totalCost ?? rec.amount;
          await balanceStore.addPendingOutgoing(
            currency,
            rec.exchangeBaseUrl,
            amount,
          );
          break;
      }
    });

  await tx.purchases.indexes.byStatus
    .iter(keyRangeActive)
    .forEachAsync(async (rec) => {
      switch (rec.purchaseStatus) {
        case PurchaseStatus.SuspendedPaying:
        case PurchaseStatus.PendingPaying:
          if (!rec.payInfo || !rec.payInfo.payCoinSelection?.coinPubs) {
            break;
          }
          // FIXME: This is pretty slow, cache?
          const currency = Amounts.currencyOf(rec.payInfo.totalPayCost);
          const sel = rec.payInfo.payCoinSelection;
          for (let i = 0; i < sel.coinPubs.length; i++) {
            const coinPub = sel.coinPubs[i];
            const coinRec = await tx.coins.get(coinPub);
            if (!coinRec) {
              continue;
            }
            await balanceStore.addPendingOutgoing(
              currency,
              coinRec.exchangeBaseUrl,
              sel.coinContributions[i],
            );
          }
      }
    });

  await tx.depositGroups.indexes.byStatus
    .iter(keyRangeActive)
    .forEachAsync(async (dgRecord) => {
      const perExchange = dgRecord.infoPerExchange;
      if (!perExchange) {
        return;
      }
      for (const [e, x] of Object.entries(perExchange)) {
        const currency = Amounts.currencyOf(dgRecord.amount);
        switch (dgRecord.operationStatus) {
          case DepositOperationStatus.SuspendedAggregateKyc:
          case DepositOperationStatus.PendingAggregateKyc:
          case DepositOperationStatus.PendingDepositKyc:
          case DepositOperationStatus.PendingDepositKycAuth:
          case DepositOperationStatus.SuspendedDepositKyc:
          case DepositOperationStatus.SuspendedDepositKycAuth:
            await balanceStore.setFlagOutgoingKyc(currency, e);
        }
        switch (dgRecord.operationStatus) {
          case DepositOperationStatus.SuspendedAggregateKyc:
          case DepositOperationStatus.PendingAggregateKyc:
          case DepositOperationStatus.LegacyPendingTrack:
          case DepositOperationStatus.SuspendedAborting:
          case DepositOperationStatus.SuspendedDeposit:
          case DepositOperationStatus.LegacySuspendedTrack:
          case DepositOperationStatus.PendingDepositKyc:
          case DepositOperationStatus.PendingDepositKycAuth:
          case DepositOperationStatus.SuspendedDepositKyc:
          case DepositOperationStatus.SuspendedDepositKycAuth:
          case DepositOperationStatus.PendingDeposit: {
            const perExchange = dgRecord.infoPerExchange;
            if (perExchange) {
              for (const [e, v] of Object.entries(perExchange)) {
                await balanceStore.addPendingOutgoing(
                  currency,
                  e,
                  v.amountEffective,
                );
              }
            }
          }
        }
      }
    });

  return balanceStore.toBalancesResponse();
}

/**
 * Get detailed balance information, sliced by exchange and by currency.
 */
export async function getBalances(
  wex: WalletExecutionContext,
): Promise<BalancesResponse> {
  logger.trace("starting to compute balance");

  const wbal = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
    return getBalancesInsideTransaction(wex, tx);
  });

  logger.trace("finished computing wallet balance");

  return wbal;
}

export interface PaymentRestrictionsForBalance {
  currency: string;

  minAge: number;

  /**
   * Restriction by the *sender* on the exchange.
   */
  restrictSenderScope: ScopeInfo | undefined;

  /**
   * Restrictions of the *receiver* on the exchanges.
   */
  restrictReceiverExchanges: ExchangeRestrictionSpec | undefined;

  /**
   * Restriction of the receiver on the supported wire methods.
   */
  restrictWireMethods: string[] | undefined;

  depositPaytoUri: string | undefined;
}

export interface AcceptableExchanges {
  /**
   * Exchanges accepted by the merchant, but wire method might not match.
   */
  acceptableExchanges: string[];

  /**
   * Exchanges accepted by the merchant, including a matching
   * wire method, i.e. the merchant can deposit coins there.
   */
  depositableExchanges: string[];
}

export interface PaymentBalanceDetails {
  /**
   * Balance of type "available" (see balance.ts for definition).
   */
  balanceAvailable: AmountJson;

  /**
   * Balance of type "material" (see balance.ts for definition).
   */
  balanceMaterial: AmountJson;

  /**
   * Balance of type "age-acceptable" (see balance.ts for definition).
   */
  balanceAgeAcceptable: AmountJson;

  /**
   * Balance of type "receiver-acceptable" (see balance.ts for definition).
   */
  balanceReceiverAcceptable: AmountJson;

  /**
   * Balance of type "receiver-depositable" (see balance.ts for definition).
   */
  balanceReceiverDepositable: AmountJson;

  /**
   * Balance that's depositable with the exchange.
   * This balance is reduced by the exchange's debit restrictions
   * and wire fee configuration.
   */
  balanceExchangeDepositable: AmountJson;

  /**
   * Estimated maximum amount that the wallet could pay for, under the assumption
   * that the merchant pays absolutely no fees.
   */
  maxMerchantEffectiveDepositAmount: AmountJson;
}

export async function getPaymentBalanceDetails(
  wex: WalletExecutionContext,
  req: PaymentRestrictionsForBalance,
): Promise<PaymentBalanceDetails> {
  return await wex.db.runReadOnlyTx(
    {
      storeNames: [
        "coinAvailability",
        "refreshGroups",
        "exchanges",
        "exchangeDetails",
        "globalCurrencyAuditors",
        "globalCurrencyExchanges",
        "denominations",
      ],
    },
    async (tx) => {
      return getPaymentBalanceDetailsInTx(wex, tx, req);
    },
  );
}

export async function getPaymentBalanceDetailsInTx(
  wex: WalletExecutionContext,
  tx: WalletDbReadOnlyTransaction<
    [
      "coinAvailability",
      "refreshGroups",
      "exchanges",
      "exchangeDetails",
      "globalCurrencyAuditors",
      "globalCurrencyExchanges",
      "denominations",
    ]
  >,
  req: PaymentRestrictionsForBalance,
): Promise<PaymentBalanceDetails> {
  const d: PaymentBalanceDetails = {
    balanceAvailable: Amounts.zeroOfCurrency(req.currency),
    balanceMaterial: Amounts.zeroOfCurrency(req.currency),
    balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency),
    balanceReceiverAcceptable: Amounts.zeroOfCurrency(req.currency),
    balanceReceiverDepositable: Amounts.zeroOfCurrency(req.currency),
    maxMerchantEffectiveDepositAmount: Amounts.zeroOfCurrency(req.currency),
    balanceExchangeDepositable: Amounts.zeroOfCurrency(req.currency),
  };

  logger.trace(`computing balance details for ${j2s(req)}`);

  const availableCoins = await tx.coinAvailability.getAll();

  for (const ca of availableCoins) {
    if (ca.currency != req.currency) {
      continue;
    }

    const denom = await getDenomInfo(
      wex,
      tx,
      ca.exchangeBaseUrl,
      ca.denomPubHash,
    );
    if (!denom) {
      continue;
    }

    // Skip exchanges if excluded by the receiver.
    if (
      req.restrictSenderScope &&
      !(await checkExchangeInScopeTx(
        tx,
        ca.exchangeBaseUrl,
        req.restrictSenderScope,
      ))
    ) {
      continue;
    }

    const wireDetails = await getExchangeWireDetailsInTx(
      tx,
      ca.exchangeBaseUrl,
    );
    if (!wireDetails) {
      continue;
    }

    const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value);
    const coinAmount: AmountJson = Amounts.mult(
      singleCoinAmount,
      ca.freshCoinCount,
    ).amount;

    let wireOkay = false;
    if (req.restrictWireMethods == null) {
      wireOkay = true;
    } else {
      for (const wm of req.restrictWireMethods) {
        const wmf = findMatchingWire(wm, req.depositPaytoUri, wireDetails);
        if (wmf) {
          wireOkay = true;
          break;
        }
      }
    }

    if (wireOkay) {
      d.balanceExchangeDepositable = Amounts.add(
        d.balanceExchangeDepositable,
        coinAmount,
      ).amount;
    }

    let ageOkay = ca.maxAge === 0 || ca.maxAge > req.minAge;

    let merchantExchangeAcceptable = false;

    if (!req.restrictReceiverExchanges) {
      merchantExchangeAcceptable = true;
    } else {
      for (const ex of req.restrictReceiverExchanges.exchanges) {
        if (ex.exchangeBaseUrl === ca.exchangeBaseUrl) {
          merchantExchangeAcceptable = true;
          break;
        }
      }
      for (const acceptedAuditor of req.restrictReceiverExchanges.auditors) {
        for (const exchangeAuditor of wireDetails.auditors) {
          if (acceptedAuditor.auditorBaseUrl === exchangeAuditor.auditor_url) {
            merchantExchangeAcceptable = true;
            break;
          }
        }
      }
    }

    const merchantExchangeDepositable = merchantExchangeAcceptable && wireOkay;

    d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount;
    d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount;
    if (ageOkay) {
      d.balanceAgeAcceptable = Amounts.add(
        d.balanceAgeAcceptable,
        coinAmount,
      ).amount;
      if (merchantExchangeAcceptable) {
        d.balanceReceiverAcceptable = Amounts.add(
          d.balanceReceiverAcceptable,
          coinAmount,
        ).amount;
        if (merchantExchangeDepositable) {
          d.balanceReceiverDepositable = Amounts.add(
            d.balanceReceiverDepositable,
            coinAmount,
          ).amount;
        }
      }
    }

    if (
      ageOkay &&
      wireOkay &&
      merchantExchangeAcceptable &&
      merchantExchangeDepositable
    ) {
      d.maxMerchantEffectiveDepositAmount = Amounts.add(
        d.maxMerchantEffectiveDepositAmount,
        Amounts.mult(ca.value, ca.freshCoinCount).amount,
      ).amount;

      d.maxMerchantEffectiveDepositAmount = Amounts.sub(
        d.maxMerchantEffectiveDepositAmount,
        Amounts.mult(denom.feeDeposit, ca.freshCoinCount).amount,
      ).amount;
    }
  }

  await tx.refreshGroups.iter().forEach((r) => {
    if (r.currency != req.currency) {
      return;
    }
    const balRefresh = computeRefreshGroupAvailableAmountForExchanges(
      r,
      req.restrictReceiverExchanges?.exchanges,
    );
    d.balanceAvailable = Amounts.add(d.balanceAvailable, balRefresh).amount;
  });

  return d;
}

export async function getBalanceDetail(
  wex: WalletExecutionContext,
  req: GetBalanceDetailRequest,
): Promise<PaymentBalanceDetails> {
  const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = [];
  const wires = new Array<string>();
  await wex.db.runReadOnlyTx(
    { storeNames: ["exchanges", "exchangeDetails"] },
    async (tx) => {
      const allExchanges = await tx.exchanges.iter().toArray();
      for (const e of allExchanges) {
        const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
        if (!details || req.currency !== details.currency) {
          continue;
        }
        details.wireInfo.accounts.forEach((a) => {
          const payto = parsePaytoUri(a.payto_uri);
          if (payto && !wires.includes(payto.targetType)) {
            wires.push(payto.targetType);
          }
        });
        exchanges.push({
          exchangePub: details.masterPublicKey,
          exchangeBaseUrl: e.baseUrl,
        });
      }
    },
  );

  return await getPaymentBalanceDetails(wex, {
    currency: req.currency,
    restrictSenderScope: undefined,
    restrictReceiverExchanges: {
      auditors: [],
      exchanges,
    },
    restrictWireMethods: wires,
    minAge: 0,
    depositPaytoUri: undefined,
  });
}
