/*
 This file is part of GNU Taler
 (C) 2015-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 
 */
/**
 * High-level wallet operations that should be indepentent from the underlying
 * browser extension interface.
 */
/**
 * Imports.
 */
import { CryptoWorkerFactory } from "./crypto/workers/cryptoApi";
import { HttpRequestLibrary } from "./util/http";
import {
  oneShotPut,
  oneShotGet,
  runWithWriteTransaction,
  oneShotIter,
  oneShotIterIndex,
} from "./util/query";
import { AmountJson } from "./util/amounts";
import * as Amounts from "./util/amounts";
import {
  acceptWithdrawal,
  getWithdrawalInfo,
  getWithdrawDetailsForUri,
  getWithdrawDetailsForAmount,
} from "./wallet-impl/withdraw";
import {
  abortFailedPayment,
  preparePay,
  confirmPay,
  processDownloadProposal,
  applyRefund,
  getFullRefundFees,
  processPurchasePay,
  processPurchaseQueryRefund,
  processPurchaseApplyRefund,
} from "./wallet-impl/pay";
import {
  CoinRecord,
  CoinStatus,
  CurrencyRecord,
  DenominationRecord,
  ExchangeRecord,
  ProposalRecord,
  PurchaseRecord,
  ReserveRecord,
  Stores,
  ReserveRecordStatus,
} from "./dbTypes";
import { MerchantRefundPermission } from "./talerTypes";
import {
  BenchmarkResult,
  ConfirmPayResult,
  ConfirmReserveRequest,
  CreateReserveRequest,
  CreateReserveResponse,
  HistoryEvent,
  ReturnCoinsRequest,
  SenderWireInfos,
  TipStatus,
  WalletBalance,
  PreparePayResult,
  DownloadedWithdrawInfo,
  WithdrawDetails,
  AcceptWithdrawalResponse,
  PurchaseDetails,
  PendingOperationInfo,
  PendingOperationsResponse,
  HistoryQuery,
  WalletNotification,
  NotificationType,
} from "./walletTypes";
import { Logger } from "./util/logging";
import { assertUnreachable } from "./util/assertUnreachable";
import {
  updateExchangeFromUrl,
  getExchangeTrust,
  getExchangePaytoUri,
} from "./wallet-impl/exchanges";
import { processReserve } from "./wallet-impl/reserves";
import { InternalWalletState } from "./wallet-impl/state";
import { createReserve, confirmReserve } from "./wallet-impl/reserves";
import { processRefreshSession, refresh } from "./wallet-impl/refresh";
import { processWithdrawSession } from "./wallet-impl/withdraw";
import { getHistory } from "./wallet-impl/history";
import { getPendingOperations } from "./wallet-impl/pending";
import { getBalances } from "./wallet-impl/balance";
import { acceptTip, getTipStatus, processTip } from "./wallet-impl/tip";
import { returnCoins } from "./wallet-impl/return";
import { payback } from "./wallet-impl/payback";
import { TimerGroup } from "./util/timer";
import { AsyncCondition } from "./util/promiseUtils";
import { AsyncOpMemoSingle } from "./util/asyncMemo";
/**
 * Wallet protocol version spoken with the exchange
 * and merchant.
 *
 * Uses libtool's current:revision:age versioning.
 */
export const WALLET_PROTOCOL_VERSION = "3:0:0";
export const WALLET_CACHE_BREAKER_CLIENT_VERSION = "3";
const builtinCurrencies: CurrencyRecord[] = [
  {
    auditors: [
      {
        auditorPub: "BW9DC48PHQY4NH011SHHX36DZZ3Q22Y6X7FZ1VD1CMZ2PTFZ6PN0",
        baseUrl: "https://auditor.demo.taler.net/",
        expirationStamp: new Date(2027, 1).getTime(),
      },
    ],
    exchanges: [],
    fractionalDigits: 2,
    name: "KUDOS",
  },
];
const logger = new Logger("wallet.ts");
/**
 * The platform-independent wallet implementation.
 */
export class Wallet {
  private ws: InternalWalletState;
  private timerGroup: TimerGroup = new TimerGroup();
  private latch = new AsyncCondition();
  private stopped: boolean = false;
  private memoRunRetryLoop = new AsyncOpMemoSingle();
  get db(): IDBDatabase {
    return this.ws.db;
  }
  constructor(
    db: IDBDatabase,
    http: HttpRequestLibrary,
    cryptoWorkerFactory: CryptoWorkerFactory,
  ) {
    this.ws = new InternalWalletState(db, http, cryptoWorkerFactory);
  }
  getExchangePaytoUri(exchangeBaseUrl: string, supportedTargetTypes: string[]) {
    return getExchangePaytoUri(this.ws, exchangeBaseUrl, supportedTargetTypes);
  }
  getWithdrawDetailsForAmount(baseUrl: any, amount: AmountJson): any {
    return getWithdrawDetailsForAmount(this.ws, baseUrl, amount);
  }
  addNotificationListener(f: (n: WalletNotification) => void): void {
    this.ws.addNotificationListener(f);
  }
  /**
   * Execute one operation based on the pending operation info record.
   */
  async processOnePendingOperation(
    pending: PendingOperationInfo,
    forceNow: boolean = false,
  ): Promise {
    console.log("running pending", pending);
    switch (pending.type) {
      case "bug":
        // Nothing to do, will just be displayed to the user
        return;
      case "dirty-coin":
        await refresh(this.ws, pending.coinPub);
        break;
      case "exchange-update":
        await updateExchangeFromUrl(this.ws, pending.exchangeBaseUrl, forceNow);
        break;
      case "refresh":
        await processRefreshSession(this.ws, pending.refreshSessionId, forceNow);
        break;
      case "reserve":
        await processReserve(this.ws, pending.reservePub, forceNow);
        break;
      case "withdraw":
        await processWithdrawSession(this.ws, pending.withdrawSessionId, forceNow);
        break;
      case "proposal-choice":
        // Nothing to do, user needs to accept/reject
        break;
      case "proposal-download":
        await processDownloadProposal(this.ws, pending.proposalId, forceNow);
        break;
      case "tip":
        await processTip(this.ws, pending.tipId, forceNow);
        break;
      case "pay":
        await processPurchasePay(this.ws, pending.proposalId, forceNow);
        break;
      case "refund-query":
        await processPurchaseQueryRefund(this.ws, pending.proposalId, forceNow);
        break;
      case "refund-apply":
        await processPurchaseApplyRefund(this.ws, pending.proposalId, forceNow);
        break;
      default:
        assertUnreachable(pending);
    }
  }
  /**
   * Process pending operations.
   */
  public async runPending(forceNow: boolean = false): Promise {
    const onlyDue = !forceNow;
    const pendingOpsResponse = await this.getPendingOperations(onlyDue);
    for (const p of pendingOpsResponse.pendingOperations) {
      try {
        await this.processOnePendingOperation(p, forceNow);
      } catch (e) {
        console.error(e);
      }
    }
  }
  /**
   * Run the wallet until there are no more pending operations that give
   * liveness left.  The wallet will be in a stopped state when this function
   * returns without resolving to an exception.
   */
  public async runUntilDone(): Promise {
    const p = new Promise((resolve, reject) => {
      // Run this asynchronously
      this.addNotificationListener(n => {
        if (
          n.type === NotificationType.WaitingForRetry &&
          n.numGivingLiveness == 0
        ) {
          logger.trace("no liveness-giving operations left, stopping");
          this.stop();
        }
      });
      this.runRetryLoop().catch(e => {
        console.log("exception in wallet retry loop");
        reject(e);
      });
    });
    await p;
  }
  /**
   * Process pending operations and wait for scheduled operations in
   * a loop until the wallet is stopped explicitly.
   */
  public async runRetryLoop(): Promise {
    // Make sure we only run one main loop at a time.
    return this.memoRunRetryLoop.memo(async () => {
      try {
        await this.runRetryLoopImpl();
      } catch (e) {
        console.error("error during retry loop execution", e);
        throw e;
      }
    });
  }
  private async runRetryLoopImpl(): Promise {
    while (!this.stopped) {
      console.log("running wallet retry loop iteration");
      let pending = await this.getPendingOperations(true);
      if (pending.pendingOperations.length === 0) {
        const allPending = await this.getPendingOperations(false);
        let numPending = 0;
        let numGivingLiveness = 0;
        for (const p of allPending.pendingOperations) {
          numPending++;
          if (p.givesLifeness) {
            numGivingLiveness++;
          }
        }
        let dt;
        if (
          allPending.pendingOperations.length === 0 ||
          allPending.nextRetryDelay.d_ms === Number.MAX_SAFE_INTEGER
        ) {
          // Wait for 5 seconds
          dt = 5000;
        } else {
          dt = Math.min(5000, allPending.nextRetryDelay.d_ms);
        }
        const timeout = this.timerGroup.resolveAfter(dt);
        this.ws.notify({
          type: NotificationType.WaitingForRetry,
          numGivingLiveness,
          numPending,
        });
        await Promise.race([timeout, this.latch.wait()]);
        console.log("timeout done");
      } else {
        logger.trace("running pending operations that are due");
        // FIXME: maybe be a bit smarter about executing these
        // operations in parallel?
        for (const p of pending.pendingOperations) {
          try {
            console.log("running", p);
            await this.processOnePendingOperation(p);
          } catch (e) {
            console.error(e);
          }
          this.ws.notify({ type: NotificationType.Wildcard });
        }
      }
    }
    logger.trace("exiting wallet retry loop");
  }
  /**
   * Insert the hard-coded defaults for exchanges, coins and
   * auditors into the database, unless these defaults have
   * already been applied.
   */
  async fillDefaults() {
    await runWithWriteTransaction(
      this.db,
      [Stores.config, Stores.currencies],
      async tx => {
        let applied = false;
        await tx.iter(Stores.config).forEach(x => {
          if (x.key == "currencyDefaultsApplied" && x.value == true) {
            applied = true;
          }
        });
        if (!applied) {
          for (let c of builtinCurrencies) {
            await tx.put(Stores.currencies, c);
          }
        }
      },
    );
  }
  /**
   * Check if a payment for the given taler://pay/ URI is possible.
   *
   * If the payment is possible, the signature are already generated but not
   * yet send to the merchant.
   */
  async preparePay(talerPayUri: string): Promise {
    return preparePay(this.ws, talerPayUri);
  }
  /**
   * Refresh all dirty coins.
   * The returned promise resolves only after all refresh
   * operations have completed.
   */
  async refreshDirtyCoins(): Promise<{ numRefreshed: number }> {
    let n = 0;
    const coins = await oneShotIter(this.db, Stores.coins).toArray();
    for (let coin of coins) {
      if (coin.status == CoinStatus.Dirty) {
        try {
          await this.refresh(coin.coinPub);
        } catch (e) {
          console.log("error during refresh");
        }
        n += 1;
      }
    }
    return { numRefreshed: n };
  }
  /**
   * Add a contract to the wallet and sign coins, and send them.
   */
  async confirmPay(
    proposalId: string,
    sessionIdOverride: string | undefined,
  ): Promise {
    try {
      return await confirmPay(this.ws, proposalId, sessionIdOverride);
    } finally {
      this.latch.trigger();
    }
  }
  /**
   * First fetch information requred to withdraw from the reserve,
   * then deplete the reserve, withdrawing coins until it is empty.
   *
   * The returned promise resolves once the reserve is set to the
   * state DORMANT.
   */
  async processReserve(reservePub: string): Promise {
    try {
      return await processReserve(this.ws, reservePub);
    } finally {
      this.latch.trigger();
    }
  }
  /**
   * Create a reserve, but do not flag it as confirmed yet.
   *
   * Adds the corresponding exchange as a trusted exchange if it is neither
   * audited nor trusted already.
   */
  async createReserve(
    req: CreateReserveRequest,
  ): Promise {
    try {
      return createReserve(this.ws, req);
    } finally {
      this.latch.trigger();
    }
  }
  /**
   * Mark an existing reserve as confirmed.  The wallet will start trying
   * to withdraw from that reserve.  This may not immediately succeed,
   * since the exchange might not know about the reserve yet, even though the
   * bank confirmed its creation.
   *
   * A confirmed reserve should be shown to the user in the UI, while
   * an unconfirmed reserve should be hidden.
   */
  async confirmReserve(req: ConfirmReserveRequest): Promise {
    try {
      return confirmReserve(this.ws, req);
    } finally {
      this.latch.trigger();
    }
  }
  /**
   * Check if and how an exchange is trusted and/or audited.
   */
  async getExchangeTrust(
    exchangeInfo: ExchangeRecord,
  ): Promise<{ isTrusted: boolean; isAudited: boolean }> {
    return getExchangeTrust(this.ws, exchangeInfo);
  }
  async getWithdrawDetailsForUri(
    talerWithdrawUri: string,
    maybeSelectedExchange?: string,
  ): Promise {
    return getWithdrawDetailsForUri(
      this.ws,
      talerWithdrawUri,
      maybeSelectedExchange,
    );
  }
  /**
   * Update or add exchange DB entry by fetching the /keys and /wire information.
   * Optionally link the reserve entry to the new or existing
   * exchange entry in then DB.
   */
  async updateExchangeFromUrl(
    baseUrl: string,
    force: boolean = false,
  ): Promise {
    try {
      return updateExchangeFromUrl(this.ws, baseUrl, force);
    } finally {
      this.latch.trigger();
    }
  }
  /**
   * Get detailed balance information, sliced by exchange and by currency.
   */
  async getBalances(): Promise {
    return this.ws.memoGetBalance.memo(() => getBalances(this.ws));
  }
  async refresh(oldCoinPub: string, force: boolean = false): Promise {
    try {
      return refresh(this.ws, oldCoinPub, force);
    } catch (e) {
      this.latch.trigger();
    }
  }
  async findExchange(
    exchangeBaseUrl: string,
  ): Promise {
    return await oneShotGet(this.db, Stores.exchanges, exchangeBaseUrl);
  }
  /**
   * Retrive the full event history for this wallet.
   */
  async getHistory(
    historyQuery?: HistoryQuery,
  ): Promise<{ history: HistoryEvent[] }> {
    return getHistory(this.ws, historyQuery);
  }
  async getPendingOperations(
    onlyDue: boolean = false,
  ): Promise {
    return this.ws.memoGetPending.memo(() =>
      getPendingOperations(this.ws, onlyDue),
    );
  }
  async getDenoms(exchangeUrl: string): Promise {
    const denoms = await oneShotIterIndex(
      this.db,
      Stores.denominations.exchangeBaseUrlIndex,
      exchangeUrl,
    ).toArray();
    return denoms;
  }
  async getProposal(proposalId: string): Promise {
    const proposal = await oneShotGet(this.db, Stores.proposals, proposalId);
    return proposal;
  }
  async getExchanges(): Promise {
    return await oneShotIter(this.db, Stores.exchanges).toArray();
  }
  async getCurrencies(): Promise {
    return await oneShotIter(this.db, Stores.currencies).toArray();
  }
  async updateCurrency(currencyRecord: CurrencyRecord): Promise {
    logger.trace("updating currency to", currencyRecord);
    await oneShotPut(this.db, Stores.currencies, currencyRecord);
  }
  async getReserves(exchangeBaseUrl: string): Promise {
    return await oneShotIter(this.db, Stores.reserves).filter(
      r => r.exchangeBaseUrl === exchangeBaseUrl,
    );
  }
  async getCoinsForExchange(exchangeBaseUrl: string): Promise {
    return await oneShotIter(this.db, Stores.coins).filter(
      c => c.exchangeBaseUrl === exchangeBaseUrl,
    );
  }
  async getCoins(): Promise {
    return await oneShotIter(this.db, Stores.coins).toArray();
  }
  async payback(coinPub: string): Promise {
    return payback(this.ws, coinPub);
  }
  async getPaybackReserves(): Promise {
    return await oneShotIter(this.db, Stores.reserves).filter(
      r => r.hasPayback,
    );
  }
  /**
   * Stop ongoing processing.
   */
  stop() {
    this.stopped = true;
    this.timerGroup.stopCurrentAndFutureTimers();
    this.ws.cryptoApi.stop();
  }
  async getSenderWireInfos(): Promise {
    const m: { [url: string]: Set } = {};
    await oneShotIter(this.db, Stores.exchanges).forEach(x => {
      const wi = x.wireInfo;
      if (!wi) {
        return;
      }
      const s = (m[x.baseUrl] = m[x.baseUrl] || new Set());
      Object.keys(wi.feesForType).map(k => s.add(k));
    });
    const exchangeWireTypes: { [url: string]: string[] } = {};
    Object.keys(m).map(e => {
      exchangeWireTypes[e] = Array.from(m[e]);
    });
    const senderWiresSet: Set = new Set();
    await oneShotIter(this.db, Stores.senderWires).forEach(x => {
      senderWiresSet.add(x.paytoUri);
    });
    const senderWires: string[] = Array.from(senderWiresSet);
    return {
      exchangeWireTypes,
      senderWires,
    };
  }
  /**
   * Trigger paying coins back into the user's account.
   */
  async returnCoins(req: ReturnCoinsRequest): Promise {
    return returnCoins(this.ws, req);
  }
  /**
   * Accept a refund, return the contract hash for the contract
   * that was involved in the refund.
   */
  async applyRefund(talerRefundUri: string): Promise {
    return applyRefund(this.ws, talerRefundUri);
  }
  async getPurchase(
    contractTermsHash: string,
  ): Promise {
    return oneShotGet(this.db, Stores.purchases, contractTermsHash);
  }
  async getFullRefundFees(
    refundPermissions: MerchantRefundPermission[],
  ): Promise {
    return getFullRefundFees(this.ws, refundPermissions);
  }
  async acceptTip(talerTipUri: string): Promise {
    try {
      return acceptTip(this.ws, talerTipUri);
    } catch (e) {
      this.latch.trigger();
    }
  }
  async getTipStatus(talerTipUri: string): Promise {
    return getTipStatus(this.ws, talerTipUri);
  }
  async abortFailedPayment(contractTermsHash: string): Promise {
    try {
      return abortFailedPayment(this.ws, contractTermsHash);
    } finally {
      this.latch.trigger();
    }
  }
  public async handleNotifyReserve() {
    const reserves = await oneShotIter(this.db, Stores.reserves).toArray();
    for (const r of reserves) {
      if (r.reserveStatus === ReserveRecordStatus.WAIT_CONFIRM_BANK) {
        try {
          this.processReserve(r.reservePub);
        } catch (e) {
          console.error(e);
        }
      }
    }
  }
  /**
   * Remove unreferenced / expired data from the wallet's database
   * based on the current system time.
   */
  async collectGarbage() {
    // FIXME(#5845)
    // We currently do not garbage-collect the wallet database.  This might change
    // after the feature has been properly re-designed, and we have come up with a
    // strategy to test it.
  }
  /**
   * Get information about a withdrawal from
   * a taler://withdraw URI.
   */
  async getWithdrawalInfo(
    talerWithdrawUri: string,
  ): Promise {
    try {
      return getWithdrawalInfo(this.ws, talerWithdrawUri);
    } finally {
      this.latch.trigger();
    }
  }
  async acceptWithdrawal(
    talerWithdrawUri: string,
    selectedExchange: string,
  ): Promise {
    try {
      return acceptWithdrawal(this.ws, talerWithdrawUri, selectedExchange);
    } finally {
      this.latch.trigger();
    }
  }
  async getPurchaseDetails(hc: string): Promise {
    const purchase = await oneShotGet(this.db, Stores.purchases, hc);
    if (!purchase) {
      throw Error("unknown purchase");
    }
    const refundsDoneAmounts = Object.values(purchase.refundsDone).map(x =>
      Amounts.parseOrThrow(x.refund_amount),
    );
    const refundsPendingAmounts = Object.values(
      purchase.refundsPending,
    ).map(x => Amounts.parseOrThrow(x.refund_amount));
    const totalRefundAmount = Amounts.sum([
      ...refundsDoneAmounts,
      ...refundsPendingAmounts,
    ]).amount;
    const refundsDoneFees = Object.values(purchase.refundsDone).map(x =>
      Amounts.parseOrThrow(x.refund_amount),
    );
    const refundsPendingFees = Object.values(purchase.refundsPending).map(x =>
      Amounts.parseOrThrow(x.refund_amount),
    );
    const totalRefundFees = Amounts.sum([
      ...refundsDoneFees,
      ...refundsPendingFees,
    ]).amount;
    const totalFees = totalRefundFees;
    return {
      contractTerms: purchase.contractTerms,
      hasRefund: purchase.lastRefundStatusTimestamp !== undefined,
      totalRefundAmount: totalRefundAmount,
      totalRefundAndRefreshFees: totalFees,
    };
  }
  benchmarkCrypto(repetitions: number): Promise {
    return this.ws.cryptoApi.benchmark(repetitions);
  }
}