/*
 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 
 */
/**
 * Functions to compute the wallet's balance.
 *
 * There are multiple definition of the wallet's balance.
 * We use the following terminology:
 *
 * - "available": Balance that the wallet believes will certainly be available
 *   for spending, modulo any failures of the exchange or double spending issues.
 *   This includes available coins *not* allocated to any
 *   spending/refresh/... operation. Pending withdrawals are *not* counted
 *   towards this balance, because they are not certain to succeed.
 *   Pending refreshes *are* counted towards this balance.
 *   This balance type is nice to show to the user, because it does not
 *   temporarily decrease after payment when we are waiting for refreshes
 *
 * - "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 {
  AmountJson,
  Amounts,
  BalancesResponse,
  canonicalizeBaseUrl,
  GetBalanceDetailRequest,
  Logger,
  parsePaytoUri,
  ScopeType,
} from "@gnu-taler/taler-util";
import {
  AllowedAuditorInfo,
  AllowedExchangeInfo,
  RefreshGroupRecord,
  WalletStoresV1,
} from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { checkLogicInvariant } from "../util/invariants.js";
import { GetReadOnlyAccess } from "../util/query.js";
import { getExchangeDetails } from "./exchanges.js";
/**
 * Logger.
 */
const logger = new Logger("operations/balance.ts");
interface WalletBalance {
  available: AmountJson;
  pendingIncoming: AmountJson;
  pendingOutgoing: AmountJson;
}
/**
 * Compute the available amount that the wallet expects to get
 * out of a refresh group.
 */
function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): 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;
  }
  for (let i = 0; i < r.oldCoinPubs.length; i++) {
    const session = r.refreshSessionPerCoin[i];
    if (session) {
      // We are always assuming the refresh will succeed, thus we
      // report the output as available balance.
      available = Amounts.add(available, session.amountRefreshOutput).amount;
    } else {
      available = Amounts.add(available, r.estimatedOutputPerCoin[i]).amount;
    }
  }
  return available;
}
/**
 * Get balance information.
 */
export async function getBalancesInsideTransaction(
  ws: InternalWalletState,
  tx: GetReadOnlyAccess<{
    coins: typeof WalletStoresV1.coins;
    coinAvailability: typeof WalletStoresV1.coinAvailability;
    refreshGroups: typeof WalletStoresV1.refreshGroups;
    withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
  }>,
): Promise {
  const balanceStore: Record = {};
  /**
   * Add amount to a balance field, both for
   * the slicing by exchange and currency.
   */
  const initBalance = (currency: string): WalletBalance => {
    const b = balanceStore[currency];
    if (!b) {
      balanceStore[currency] = {
        available: Amounts.zeroOfCurrency(currency),
        pendingIncoming: Amounts.zeroOfCurrency(currency),
        pendingOutgoing: Amounts.zeroOfCurrency(currency),
      };
    }
    return balanceStore[currency];
  };
  await tx.coinAvailability.iter().forEach((ca) => {
    const b = initBalance(ca.currency);
    for (let i = 0; i < ca.freshCoinCount; i++) {
      b.available = Amounts.add(b.available, {
        currency: ca.currency,
        fraction: ca.amountFrac,
        value: ca.amountVal,
      }).amount;
    }
  });
  await tx.refreshGroups.iter().forEach((r) => {
    const b = initBalance(r.currency);
    b.available = Amounts.add(
      b.available,
      computeRefreshGroupAvailableAmount(r),
    ).amount;
  });
  await tx.withdrawalGroups.iter().forEach((wds) => {
    if (wds.timestampFinish) {
      return;
    }
    const b = initBalance(Amounts.currencyOf(wds.denomsSel.totalWithdrawCost));
    b.pendingIncoming = Amounts.add(
      b.pendingIncoming,
      wds.denomsSel.totalCoinValue,
    ).amount;
  });
  const balancesResponse: BalancesResponse = {
    balances: [],
  };
  Object.keys(balanceStore)
    .sort()
    .forEach((c) => {
      const v = balanceStore[c];
      balancesResponse.balances.push({
        scopeInfo: {
          type: ScopeType.Global,
          currency: Amounts.currencyOf(v.available),
        },
        available: Amounts.stringify(v.available),
        pendingIncoming: Amounts.stringify(v.pendingIncoming),
        pendingOutgoing: Amounts.stringify(v.pendingOutgoing),
        hasPendingTransactions: false,
        requiresUserInput: false,
      });
    });
  return balancesResponse;
}
/**
 * Get detailed balance information, sliced by exchange and by currency.
 */
export async function getBalances(
  ws: InternalWalletState,
): Promise {
  logger.trace("starting to compute balance");
  const wbal = await ws.db
    .mktx((x) => [
      x.coins,
      x.coinAvailability,
      x.refreshGroups,
      x.purchases,
      x.withdrawalGroups,
    ])
    .runReadOnly(async (tx) => {
      return getBalancesInsideTransaction(ws, tx);
    });
  logger.trace("finished computing wallet balance");
  return wbal;
}
/**
 * Information about the balance for a particular payment to a particular
 * merchant.
 */
export interface MerchantPaymentBalanceDetails {
  balanceAvailable: AmountJson;
}
export interface MerchantPaymentRestrictionsForBalance {
  currency: string;
  minAge: number;
  acceptedExchanges: AllowedExchangeInfo[];
  acceptedAuditors: AllowedAuditorInfo[];
  acceptedWireMethods: string[];
}
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[];
}
/**
 * Get all exchanges that are acceptable for a particular payment.
 */
export async function getAcceptableExchangeBaseUrls(
  ws: InternalWalletState,
  req: MerchantPaymentRestrictionsForBalance,
): Promise {
  const acceptableExchangeUrls = new Set();
  const depositableExchangeUrls = new Set();
  await ws.db
    .mktx((x) => [x.exchanges, x.exchangeDetails, x.auditorTrust])
    .runReadOnly(async (tx) => {
      // FIXME: We should have a DB index to look up all exchanges
      // for a particular auditor ...
      const canonExchanges = new Set();
      const canonAuditors = new Set();
      for (const exchangeHandle of req.acceptedExchanges) {
        const normUrl = canonicalizeBaseUrl(exchangeHandle.exchangeBaseUrl);
        canonExchanges.add(normUrl);
      }
      for (const auditorHandle of req.acceptedAuditors) {
        const normUrl = canonicalizeBaseUrl(auditorHandle.auditorBaseUrl);
        canonAuditors.add(normUrl);
      }
      await tx.exchanges.iter().forEachAsync(async (exchange) => {
        const dp = exchange.detailsPointer;
        if (!dp) {
          return;
        }
        const { currency, masterPublicKey } = dp;
        const exchangeDetails = await tx.exchangeDetails.indexes.byPointer.get([
          exchange.baseUrl,
          currency,
          masterPublicKey,
        ]);
        if (!exchangeDetails) {
          return;
        }
        let acceptable = false;
        if (canonExchanges.has(exchange.baseUrl)) {
          acceptableExchangeUrls.add(exchange.baseUrl);
          acceptable = true;
        }
        for (const exchangeAuditor of exchangeDetails.auditors) {
          if (canonAuditors.has(exchangeAuditor.auditor_url)) {
            acceptableExchangeUrls.add(exchange.baseUrl);
            acceptable = true;
            break;
          }
        }
        if (!acceptable) {
          return;
        }
        // FIXME: Also consider exchange and auditor public key
        // instead of just base URLs?
        let wireMethodSupported = false;
        for (const acc of exchangeDetails.wireInfo.accounts) {
          const pp = parsePaytoUri(acc.payto_uri);
          checkLogicInvariant(!!pp);
          for (const wm of req.acceptedWireMethods) {
            if (pp.targetType === wm) {
              wireMethodSupported = true;
              break;
            }
            if (wireMethodSupported) {
              break;
            }
          }
        }
        acceptableExchangeUrls.add(exchange.baseUrl);
        if (wireMethodSupported) {
          depositableExchangeUrls.add(exchange.baseUrl);
        }
      });
    });
  return {
    acceptableExchanges: [...acceptableExchangeUrls],
    depositableExchanges: [...depositableExchangeUrls],
  };
}
export interface MerchantPaymentBalanceDetails {
  /**
   * 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 "merchant-acceptable" (see balance.ts for definition).
   */
  balanceMerchantAcceptable: AmountJson;
  /**
   * Balance of type "merchant-depositable" (see balance.ts for definition).
   */
  balanceMerchantDepositable: AmountJson;
}
export async function getMerchantPaymentBalanceDetails(
  ws: InternalWalletState,
  req: MerchantPaymentRestrictionsForBalance,
): Promise {
  const acceptability = await getAcceptableExchangeBaseUrls(ws, req);
  const d: MerchantPaymentBalanceDetails = {
    balanceAvailable: Amounts.zeroOfCurrency(req.currency),
    balanceMaterial: Amounts.zeroOfCurrency(req.currency),
    balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency),
    balanceMerchantAcceptable: Amounts.zeroOfCurrency(req.currency),
    balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency),
  };
  await ws.db
    .mktx((x) => [
      x.coins,
      x.coinAvailability,
      x.refreshGroups,
      x.purchases,
      x.withdrawalGroups,
    ])
    .runReadOnly(async (tx) => {
      await tx.coinAvailability.iter().forEach((ca) => {
        if (ca.currency != req.currency) {
          return;
        }
        const singleCoinAmount: AmountJson = {
          currency: ca.currency,
          fraction: ca.amountFrac,
          value: ca.amountVal,
        };
        const coinAmount: AmountJson = Amounts.mult(
          singleCoinAmount,
          ca.freshCoinCount,
        ).amount;
        d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount;
        d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount;
        if (ca.maxAge === 0 || ca.maxAge > req.minAge) {
          d.balanceAgeAcceptable = Amounts.add(
            d.balanceAgeAcceptable,
            coinAmount,
          ).amount;
          if (acceptability.acceptableExchanges.includes(ca.exchangeBaseUrl)) {
            d.balanceMerchantAcceptable = Amounts.add(
              d.balanceMerchantAcceptable,
              coinAmount,
            ).amount;
            if (
              acceptability.depositableExchanges.includes(ca.exchangeBaseUrl)
            ) {
              d.balanceMerchantDepositable = Amounts.add(
                d.balanceMerchantDepositable,
                coinAmount,
              ).amount;
            }
          }
        }
      });
      await tx.refreshGroups.iter().forEach((r) => {
        if (r.currency != req.currency) {
          return;
        }
        d.balanceAvailable = Amounts.add(
          d.balanceAvailable,
          computeRefreshGroupAvailableAmount(r),
        ).amount;
      });
    });
  return d;
}
export async function getBalanceDetail(
  ws: InternalWalletState,
  req: GetBalanceDetailRequest,
): Promise {
  const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = [];
  const wires = new Array();
  await ws.db
    .mktx((x) => [x.exchanges, x.exchangeDetails])
    .runReadOnly(async (tx) => {
      const allExchanges = await tx.exchanges.iter().toArray();
      for (const e of allExchanges) {
        const details = await getExchangeDetails(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 getMerchantPaymentBalanceDetails(ws, {
    currency: req.currency,
    acceptedAuditors: [],
    acceptedExchanges: exchanges,
    acceptedWireMethods: wires,
    minAge: 0,
  });
}
export interface PeerPaymentRestrictionsForBalance {
  currency: string;
  restrictExchangeTo?: string;
}
export interface PeerPaymentBalanceDetails {
  /**
   * Balance of type "available" (see balance.ts for definition).
   */
  balanceAvailable: AmountJson;
  /**
   * Balance of type "material" (see balance.ts for definition).
   */
  balanceMaterial: AmountJson;
}
export async function getPeerPaymentBalanceDetailsInTx(
  ws: InternalWalletState,
  tx: GetReadOnlyAccess<{
    coinAvailability: typeof WalletStoresV1.coinAvailability;
    refreshGroups: typeof WalletStoresV1.refreshGroups;
  }>,
  req: PeerPaymentRestrictionsForBalance,
): Promise {
  let balanceAvailable = Amounts.zeroOfCurrency(req.currency);
  let balanceMaterial = Amounts.zeroOfCurrency(req.currency);
  await tx.coinAvailability.iter().forEach((ca) => {
    if (ca.currency != req.currency) {
      return;
    }
    if (
      req.restrictExchangeTo &&
      req.restrictExchangeTo !== ca.exchangeBaseUrl
    ) {
      return;
    }
    const singleCoinAmount: AmountJson = {
      currency: ca.currency,
      fraction: ca.amountFrac,
      value: ca.amountVal,
    };
    const coinAmount: AmountJson = Amounts.mult(
      singleCoinAmount,
      ca.freshCoinCount,
    ).amount;
    balanceAvailable = Amounts.add(balanceAvailable, coinAmount).amount;
    balanceMaterial = Amounts.add(balanceMaterial, coinAmount).amount;
  });
  await tx.refreshGroups.iter().forEach((r) => {
    balanceAvailable = Amounts.add(
      balanceAvailable,
      computeRefreshGroupAvailableAmount(r),
    ).amount;
  });
  return {
    balanceAvailable,
    balanceMaterial,
  };
}