/*
 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 {
  AbsoluteTime,
  Amounts,
  codecForAbortTransaction,
  codecForAcceptBankIntegratedWithdrawalRequest,
  codecForAcceptExchangeTosRequest,
  codecForAcceptManualWithdrawalRequet,
  codecForAcceptPeerPullPaymentRequest,
  codecForConfirmPeerPushPaymentRequest,
  codecForAcceptTipRequest,
  codecForAddExchangeRequest,
  codecForAddKnownBankAccounts,
  codecForAny,
  codecForApplyDevExperiment,
  codecForApplyRefundFromPurchaseIdRequest,
  codecForApplyRefundRequest,
  codecForCheckPeerPullPaymentRequest,
  codecForPreparePeerPushCreditRequest,
  codecForConfirmPayRequest,
  codecForCreateDepositGroupRequest,
  codecForDeleteTransactionRequest,
  codecForForceRefreshRequest,
  codecForForgetKnownBankAccounts,
  codecForGetBalanceDetailRequest,
  codecForGetContractTermsDetails,
  codecForGetExchangeTosRequest,
  codecForGetFeeForDeposit,
  codecForGetWithdrawalDetailsForAmountRequest,
  codecForGetWithdrawalDetailsForUri,
  codecForImportDbRequest,
  codecForInitiatePeerPullPaymentRequest,
  codecForInitiatePeerPushPaymentRequest,
  codecForIntegrationTestArgs,
  codecForListKnownBankAccounts,
  codecForMerchantPostOrderResponse,
  codecForPrepareDepositRequest,
  codecForPreparePayRequest,
  codecForPreparePayTemplateRequest,
  codecForPreparePeerPullPaymentRequest,
  codecForCheckPeerPushDebitRequest,
  codecForPrepareRefundRequest,
  codecForPrepareTipRequest,
  codecForRetryTransactionRequest,
  codecForSetCoinSuspendedRequest,
  codecForSetDevModeRequest,
  codecForSetWalletDeviceIdRequest,
  codecForTestPayArgs,
  codecForTrackDepositGroupRequest,
  codecForTransactionByIdRequest,
  codecForTransactionsRequest,
  codecForUserAttentionByIdRequest,
  codecForUserAttentionsRequest,
  codecForWithdrawFakebankRequest,
  codecForWithdrawTestBalance,
  CoinDumpJson,
  CoinRefreshRequest,
  CoinStatus,
  constructPayUri,
  CoreApiResponse,
  DenominationInfo,
  DenomOperationMap,
  Duration,
  durationFromSpec,
  durationMin,
  ExchangeDetailedResponse,
  ExchangeListItem,
  ExchangesListResponse,
  ExchangeTosStatusDetails,
  FeeDescription,
  GetExchangeTosResult,
  InitResponse,
  j2s,
  KnownBankAccounts,
  KnownBankAccountsInfo,
  Logger,
  ManualWithdrawalDetails,
  MerchantUsingTemplateDetails,
  NotificationType,
  parsePayTemplateUri,
  parsePaytoUri,
  RefreshReason,
  TalerErrorCode,
  URL,
  WalletCoreVersion,
  WalletNotification,
} from "@gnu-taler/taler-util";
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import {
  CryptoDispatcher,
  CryptoWorkerFactory,
} from "./crypto/workers/crypto-dispatcher.js";
import {
  AuditorTrustRecord,
  clearDatabase,
  CoinSourceType,
  ConfigRecordKey,
  DenominationRecord,
  ExchangeDetailsRecord,
  exportDb,
  importDb,
  WalletStoresV1,
} from "./db.js";
import {
  applyDevExperiment,
  maybeInitDevMode,
  setDevMode,
} from "./dev-experiments.js";
import { getErrorDetailFromException, TalerError } from "@gnu-taler/taler-util";
import {
  ActiveLongpollInfo,
  ExchangeOperations,
  InternalWalletState,
  MerchantInfo,
  MerchantOperations,
  NotificationListener,
  RecoupOperations,
  RefreshOperations,
} from "./internal-wallet-state.js";
import {
  getUserAttentions,
  getUserAttentionsUnreadCount,
  markAttentionRequestAsRead,
} from "./operations/attention.js";
import { exportBackup } from "./operations/backup/export.js";
import {
  addBackupProvider,
  codecForAddBackupProviderRequest,
  codecForRemoveBackupProvider,
  codecForRunBackupCycle,
  getBackupInfo,
  getBackupRecovery,
  importBackupPlain,
  loadBackupRecovery,
  processBackupForProvider,
  removeBackupProvider,
  runBackupCycle,
} from "./operations/backup/index.js";
import { setWalletDeviceId } from "./operations/backup/state.js";
import { getBalanceDetail, getBalances } from "./operations/balance.js";
import {
  getExchangeTosStatus,
  makeExchangeListItem,
  runOperationWithErrorReporting,
} from "./operations/common.js";
import {
  createDepositGroup,
  getFeeForDeposit,
  prepareDepositGroup,
  processDepositGroup,
  trackDepositGroup,
} from "./operations/deposits.js";
import {
  acceptExchangeTermsOfService,
  downloadTosFromAcceptedFormat,
  getExchangeDetails,
  getExchangeRequestTimeout,
  getExchangeTrust,
  provideExchangeRecordInTx,
  updateExchangeFromUrl,
  updateExchangeFromUrlHandler,
  updateExchangeTermsOfService,
} from "./operations/exchanges.js";
import { getMerchantInfo } from "./operations/merchants.js";
import {
  applyRefund,
  applyRefundFromPurchaseId,
  confirmPay,
  getContractTermsDetails,
  preparePayForUri,
  prepareRefund,
  processPurchase,
} from "./operations/pay-merchant.js";
import {
  acceptIncomingPeerPullPayment,
  confirmPeerPushPayment,
  preparePeerPullCredit,
  preparePeerPushCredit,
  initiatePeerPullPayment,
  initiatePeerPushPayment,
  checkPeerPullPaymentInitiation,
  checkPeerPushDebit,
  processPeerPullCredit,
  processPeerPushInitiation,
  processPeerPullDebit,
  processPeerPushCredit,
} from "./operations/pay-peer.js";
import { getPendingOperations } from "./operations/pending.js";
import {
  createRecoupGroup,
  processRecoupGroup,
  processRecoupGroupHandler,
} from "./operations/recoup.js";
import {
  autoRefresh,
  createRefreshGroup,
  processRefreshGroup,
} from "./operations/refresh.js";
import {
  runIntegrationTest,
  testPay,
  withdrawTestBalance,
} from "./operations/testing.js";
import { acceptTip, prepareTip, processTip } from "./operations/tip.js";
import {
  abortTransaction,
  deleteTransaction,
  getTransactionById,
  getTransactions,
  retryTransaction,
} from "./operations/transactions.js";
import {
  acceptWithdrawalFromUri,
  createManualWithdrawal,
  getExchangeWithdrawalInfo,
  getWithdrawalDetailsForUri,
  processWithdrawalGroup,
} from "./operations/withdraw.js";
import { PendingTaskInfo, PendingTaskType } from "./pending-types.js";
import { assertUnreachable } from "./util/assertUnreachable.js";
import {
  createTimeline,
  selectBestForOverlappingDenominations,
  selectMinimumFee,
} from "./util/denominations.js";
import {
  HttpRequestLibrary,
  readSuccessResponseJsonOrThrow,
} from "@gnu-taler/taler-util/http";
import { checkDbInvariant } from "./util/invariants.js";
import {
  AsyncCondition,
  OpenedPromise,
  openPromise,
} from "./util/promiseUtils.js";
import {
  DbAccess,
  GetReadOnlyAccess,
  GetReadWriteAccess,
} from "./util/query.js";
import { OperationAttemptResult, RetryTags } from "./util/retries.js";
import { TimerAPI, TimerGroup } from "./util/timer.js";
import {
  WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
  WALLET_EXCHANGE_PROTOCOL_VERSION,
  WALLET_MERCHANT_PROTOCOL_VERSION,
} from "./versions.js";
import {
  WalletApiOperation,
  WalletCoreApiClient,
  WalletCoreResponseType,
} from "./wallet-api-types.js";
const builtinAuditors: AuditorTrustRecord[] = [
  {
    currency: "KUDOS",
    auditorPub: "BW9DC48PHQY4NH011SHHX36DZZ3Q22Y6X7FZ1VD1CMZ2PTFZ6PN0",
    auditorBaseUrl: "https://auditor.demo.taler.net/",
    uids: ["5P25XF8TVQP9AW6VYGY2KV47WT5Y3ZXFSJAA570GJPX5SVJXKBVG"],
  },
];
const builtinExchanges: string[] = ["https://exchange.demo.taler.net/"];
const logger = new Logger("wallet.ts");
/**
 * Call the right handler for a pending operation without doing
 * any special error handling.
 */
async function callOperationHandler(
  ws: InternalWalletState,
  pending: PendingTaskInfo,
  forceNow = false,
): Promise {
  switch (pending.type) {
    case PendingTaskType.ExchangeUpdate:
      return await updateExchangeFromUrlHandler(ws, pending.exchangeBaseUrl, {
        forceNow,
      });
    case PendingTaskType.Refresh:
      return await processRefreshGroup(ws, pending.refreshGroupId, {
        forceNow,
      });
    case PendingTaskType.Withdraw:
      return await processWithdrawalGroup(ws, pending.withdrawalGroupId, {
        forceNow,
      });
    case PendingTaskType.TipPickup:
      return await processTip(ws, pending.tipId, { forceNow });
    case PendingTaskType.Purchase:
      return await processPurchase(ws, pending.proposalId, { forceNow });
    case PendingTaskType.Recoup:
      return await processRecoupGroupHandler(ws, pending.recoupGroupId, {
        forceNow,
      });
    case PendingTaskType.ExchangeCheckRefresh:
      return await autoRefresh(ws, pending.exchangeBaseUrl);
    case PendingTaskType.Deposit: {
      return await processDepositGroup(ws, pending.depositGroupId, {
        forceNow,
      });
    }
    case PendingTaskType.Backup:
      return await processBackupForProvider(ws, pending.backupProviderBaseUrl);
    case PendingTaskType.PeerPushInitiation:
      return await processPeerPushInitiation(ws, pending.pursePub);
    case PendingTaskType.PeerPullInitiation:
      return await processPeerPullCredit(ws, pending.pursePub);
    case PendingTaskType.PeerPullDebit:
      return await processPeerPullDebit(ws, pending.peerPullPaymentIncomingId);
    case PendingTaskType.PeerPushCredit:
      return await processPeerPushCredit(ws, pending.peerPushPaymentIncomingId);
    default:
      return assertUnreachable(pending);
  }
  throw Error(`not reached ${pending.type}`);
}
/**
 * Process pending operations.
 */
export async function runPending(
  ws: InternalWalletState,
  forceNow = false,
): Promise {
  const pendingOpsResponse = await getPendingOperations(ws);
  for (const p of pendingOpsResponse.pendingOperations) {
    if (!forceNow && !AbsoluteTime.isExpired(p.timestampDue)) {
      continue;
    }
    await runOperationWithErrorReporting(ws, p.id, async () => {
      logger.trace(`running pending ${JSON.stringify(p, undefined, 2)}`);
      return await callOperationHandler(ws, p, forceNow);
    });
  }
}
export interface RetryLoopOpts {
  /**
   * Stop when the number of retries is exceeded for any pending
   * operation.
   */
  maxRetries?: number;
  /**
   * Stop the retry loop when all lifeness-giving pending operations
   * are done.
   *
   * Defaults to false.
   */
  stopWhenDone?: boolean;
}
export interface TaskLoopResult {
  /**
   * Was the maximum number of retries exceeded in a task?
   */
  retriesExceeded: boolean;
}
/**
 * Main retry loop of the wallet.
 *
 * Looks up pending operations from the wallet, runs them, repeat.
 */
async function runTaskLoop(
  ws: InternalWalletState,
  opts: RetryLoopOpts = {},
): Promise {
  logger.info(`running task loop opts=${j2s(opts)}`);
  let retriesExceeded = false;
  for (let iteration = 0; !ws.stopped; iteration++) {
    const pending = await getPendingOperations(ws);
    logger.trace(`pending operations: ${j2s(pending)}`);
    let numGivingLiveness = 0;
    let numDue = 0;
    let minDue: AbsoluteTime = AbsoluteTime.never();
    for (const p of pending.pendingOperations) {
      const maxRetries = opts.maxRetries;
      if (maxRetries && p.retryInfo && p.retryInfo.retryCounter > maxRetries) {
        retriesExceeded = true;
        logger.warn(
          `skipping, as ${maxRetries} retries are exceeded in an operation of type ${p.type}`,
        );
        continue;
      }
      if (p.givesLifeness) {
        numGivingLiveness++;
      }
      if (!p.isDue) {
        continue;
      }
      minDue = AbsoluteTime.min(minDue, p.timestampDue);
      numDue++;
    }
    if (opts.stopWhenDone && numGivingLiveness === 0 && iteration !== 0) {
      logger.warn(`stopping, as no pending operations have lifeness`);
      return {
        retriesExceeded,
      };
    }
    // Make sure that we run tasks that don't give lifeness at least
    // one time.
    if (iteration !== 0 && numDue === 0) {
      // We've executed pending, due operations at least one.
      // Now we don't have any more operations available,
      // and need to wait.
      // Wait for at most 5 seconds to the next check.
      const dt = durationMin(
        durationFromSpec({
          seconds: 5,
        }),
        Duration.getRemaining(minDue),
      );
      logger.trace(`waiting for at most ${dt.d_ms} ms`);
      const timeout = ws.timerGroup.resolveAfter(dt);
      ws.notify({
        type: NotificationType.WaitingForRetry,
        numGivingLiveness,
        numDue,
        numPending: pending.pendingOperations.length,
      });
      // Wait until either the timeout, or we are notified (via the latch)
      // that more work might be available.
      await Promise.race([timeout, ws.latch.wait()]);
    } else {
      logger.trace(
        `running ${pending.pendingOperations.length} pending operations`,
      );
      for (const p of pending.pendingOperations) {
        if (!AbsoluteTime.isExpired(p.timestampDue)) {
          continue;
        }
        await runOperationWithErrorReporting(ws, p.id, async () => {
          logger.trace(`running pending ${JSON.stringify(p, undefined, 2)}`);
          return await callOperationHandler(ws, p);
        });
        ws.notify({
          type: NotificationType.PendingOperationProcessed,
          id: p.id,
        });
      }
    }
  }
  logger.trace("exiting wallet retry loop");
  return {
    retriesExceeded,
  };
}
/**
 * Insert the hard-coded defaults for exchanges, coins and
 * auditors into the database, unless these defaults have
 * already been applied.
 */
async function fillDefaults(ws: InternalWalletState): Promise {
  await ws.db
    .mktx((x) => [x.config, x.auditorTrust, x.exchanges, x.exchangeDetails])
    .runReadWrite(async (tx) => {
      const appliedRec = await tx.config.get("currencyDefaultsApplied");
      let alreadyApplied = appliedRec ? !!appliedRec.value : false;
      if (alreadyApplied) {
        logger.trace("defaults already applied");
        return;
      }
      logger.info("importing default exchanges and auditors");
      for (const c of builtinAuditors) {
        await tx.auditorTrust.put(c);
      }
      for (const baseUrl of builtinExchanges) {
        const now = AbsoluteTime.now();
        provideExchangeRecordInTx(ws, tx, baseUrl, now);
      }
      await tx.config.put({
        key: ConfigRecordKey.CurrencyDefaultsApplied,
        value: true,
      });
    });
}
/**
 * Get the exchange ToS in the requested format.
 * Try to download in the accepted format not cached.
 */
async function getExchangeTos(
  ws: InternalWalletState,
  exchangeBaseUrl: string,
  acceptedFormat?: string[],
): Promise {
  // FIXME: download ToS in acceptable format if passed!
  const { exchangeDetails } = await updateExchangeFromUrl(ws, exchangeBaseUrl);
  const tosDetails = await ws.db
    .mktx((x) => [x.exchangeTos])
    .runReadOnly(async (tx) => {
      return await getExchangeTosStatusDetails(tx, exchangeDetails);
    });
  const content = tosDetails.content;
  const currentEtag = tosDetails.currentVersion;
  const contentType = tosDetails.contentType;
  if (
    content === undefined ||
    currentEtag === undefined ||
    contentType === undefined
  ) {
    throw Error("exchange is in invalid state");
  }
  if (
    acceptedFormat &&
    acceptedFormat.findIndex((f) => f === contentType) !== -1
  ) {
    return {
      acceptedEtag: exchangeDetails.tosAccepted?.etag,
      currentEtag,
      content,
      contentType,
      tosStatus: getExchangeTosStatus(exchangeDetails),
    };
  }
  const tosDownload = await downloadTosFromAcceptedFormat(
    ws,
    exchangeBaseUrl,
    getExchangeRequestTimeout(),
    acceptedFormat,
  );
  if (tosDownload.tosContentType === contentType) {
    return {
      acceptedEtag: exchangeDetails.tosAccepted?.etag,
      currentEtag,
      content,
      contentType,
      tosStatus: getExchangeTosStatus(exchangeDetails),
    };
  }
  await updateExchangeTermsOfService(ws, exchangeBaseUrl, tosDownload);
  return {
    acceptedEtag: exchangeDetails.tosAccepted?.etag,
    currentEtag: tosDownload.tosEtag,
    content: tosDownload.tosText,
    contentType: tosDownload.tosContentType,
    tosStatus: getExchangeTosStatus(exchangeDetails),
  };
}
/**
 * List bank accounts known to the wallet from
 * previous withdrawals.
 */
async function listKnownBankAccounts(
  ws: InternalWalletState,
  currency?: string,
): Promise {
  const accounts: KnownBankAccountsInfo[] = [];
  await ws.db
    .mktx((x) => [x.bankAccounts])
    .runReadOnly(async (tx) => {
      const knownAccounts = await tx.bankAccounts.iter().toArray();
      for (const r of knownAccounts) {
        if (currency && currency !== r.currency) {
          continue;
        }
        const payto = parsePaytoUri(r.uri);
        if (payto) {
          accounts.push({
            uri: payto,
            alias: r.alias,
            kyc_completed: r.kycCompleted,
            currency: r.currency,
          });
        }
      }
    });
  return { accounts };
}
/**
 */
async function addKnownBankAccounts(
  ws: InternalWalletState,
  payto: string,
  alias: string,
  currency: string,
): Promise {
  await ws.db
    .mktx((x) => [x.bankAccounts])
    .runReadWrite(async (tx) => {
      tx.bankAccounts.put({
        uri: payto,
        alias: alias,
        currency: currency,
        kycCompleted: false,
      });
    });
  return;
}
/**
 */
async function forgetKnownBankAccounts(
  ws: InternalWalletState,
  payto: string,
): Promise {
  await ws.db
    .mktx((x) => [x.bankAccounts])
    .runReadWrite(async (tx) => {
      const account = await tx.bankAccounts.get(payto);
      if (!account) {
        throw Error(`account not found: ${payto}`);
      }
      tx.bankAccounts.delete(account.uri);
    });
  return;
}
async function getExchangeTosStatusDetails(
  tx: GetReadOnlyAccess<{ exchangeTos: typeof WalletStoresV1.exchangeTos }>,
  exchangeDetails: ExchangeDetailsRecord,
): Promise {
  let exchangeTos = await tx.exchangeTos.get([
    exchangeDetails.exchangeBaseUrl,
    exchangeDetails.tosCurrentEtag,
  ]);
  if (!exchangeTos) {
    exchangeTos = {
      etag: "not-available",
      termsOfServiceContentType: "text/plain",
      termsOfServiceText: "terms of service unavailable",
      exchangeBaseUrl: exchangeDetails.exchangeBaseUrl,
    };
  }
  return {
    acceptedVersion: exchangeDetails.tosAccepted?.etag,
    content: exchangeTos.termsOfServiceText,
    contentType: exchangeTos.termsOfServiceContentType,
    currentVersion: exchangeTos.etag,
  };
}
async function getExchanges(
  ws: InternalWalletState,
): Promise {
  const exchanges: ExchangeListItem[] = [];
  await ws.db
    .mktx((x) => [
      x.exchanges,
      x.exchangeDetails,
      x.exchangeTos,
      x.denominations,
      x.operationRetries,
    ])
    .runReadOnly(async (tx) => {
      const exchangeRecords = await tx.exchanges.iter().toArray();
      for (const r of exchangeRecords) {
        const exchangeDetails = await getExchangeDetails(tx, r.baseUrl);
        const opRetryRecord = await tx.operationRetries.get(
          RetryTags.forExchangeUpdate(r),
        );
        exchanges.push(
          makeExchangeListItem(r, exchangeDetails, opRetryRecord?.lastError),
        );
      }
    });
  return { exchanges };
}
async function getExchangeDetailedInfo(
  ws: InternalWalletState,
  exchangeBaseurl: string,
): Promise {
  //TODO: should we use the forceUpdate parameter?
  const exchange = await ws.db
    .mktx((x) => [
      x.exchanges,
      x.exchangeTos,
      x.exchangeDetails,
      x.denominations,
    ])
    .runReadOnly(async (tx) => {
      const ex = await tx.exchanges.get(exchangeBaseurl);
      const dp = ex?.detailsPointer;
      if (!dp) {
        return;
      }
      const { currency } = dp;
      const exchangeDetails = await getExchangeDetails(tx, ex.baseUrl);
      if (!exchangeDetails) {
        return;
      }
      const denominationRecords =
        await tx.denominations.indexes.byExchangeBaseUrl
          .iter(ex.baseUrl)
          .toArray();
      if (!denominationRecords) {
        return;
      }
      const tos = await getExchangeTosStatusDetails(tx, exchangeDetails);
      const denominations: DenominationInfo[] = denominationRecords.map((x) =>
        DenominationRecord.toDenomInfo(x),
      );
      return {
        info: {
          exchangeBaseUrl: ex.baseUrl,
          currency,
          tos,
          paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
          auditors: exchangeDetails.auditors,
          wireInfo: exchangeDetails.wireInfo,
          globalFees: exchangeDetails.globalFees,
        },
        denominations,
      };
    });
  if (!exchange) {
    throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
  }
  const denoms = exchange.denominations.map((d) => ({
    ...d,
    group: Amounts.stringifyValue(d.value),
  }));
  const denomFees: DenomOperationMap = {
    deposit: createTimeline(
      denoms,
      "denomPubHash",
      "stampStart",
      "stampExpireDeposit",
      "feeDeposit",
      "group",
      selectBestForOverlappingDenominations,
    ),
    refresh: createTimeline(
      denoms,
      "denomPubHash",
      "stampStart",
      "stampExpireWithdraw",
      "feeRefresh",
      "group",
      selectBestForOverlappingDenominations,
    ),
    refund: createTimeline(
      denoms,
      "denomPubHash",
      "stampStart",
      "stampExpireWithdraw",
      "feeRefund",
      "group",
      selectBestForOverlappingDenominations,
    ),
    withdraw: createTimeline(
      denoms,
      "denomPubHash",
      "stampStart",
      "stampExpireWithdraw",
      "feeWithdraw",
      "group",
      selectBestForOverlappingDenominations,
    ),
  };
  const transferFees = Object.entries(
    exchange.info.wireInfo.feesForType,
  ).reduce((prev, [wireType, infoForType]) => {
    const feesByGroup = [
      ...infoForType.map((w) => ({
        ...w,
        fee: Amounts.stringify(w.closingFee),
        group: "closing",
      })),
      ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
    ];
    prev[wireType] = createTimeline(
      feesByGroup,
      "sig",
      "startStamp",
      "endStamp",
      "fee",
      "group",
      selectMinimumFee,
    );
    return prev;
  }, {} as Record);
  const globalFeesByGroup = [
    ...exchange.info.globalFees.map((w) => ({
      ...w,
      fee: w.accountFee,
      group: "account",
    })),
    ...exchange.info.globalFees.map((w) => ({
      ...w,
      fee: w.historyFee,
      group: "history",
    })),
    ...exchange.info.globalFees.map((w) => ({
      ...w,
      fee: w.purseFee,
      group: "purse",
    })),
  ];
  const globalFees = createTimeline(
    globalFeesByGroup,
    "signature",
    "startDate",
    "endDate",
    "fee",
    "group",
    selectMinimumFee,
  );
  return {
    exchange: {
      ...exchange.info,
      denomFees,
      transferFees,
      globalFees,
    },
  };
}
async function setCoinSuspended(
  ws: InternalWalletState,
  coinPub: string,
  suspended: boolean,
): Promise {
  await ws.db
    .mktx((x) => [x.coins, x.coinAvailability])
    .runReadWrite(async (tx) => {
      const c = await tx.coins.get(coinPub);
      if (!c) {
        logger.warn(`coin ${coinPub} not found, won't suspend`);
        return;
      }
      const coinAvailability = await tx.coinAvailability.get([
        c.exchangeBaseUrl,
        c.denomPubHash,
        c.maxAge,
      ]);
      checkDbInvariant(!!coinAvailability);
      if (suspended) {
        if (c.status !== CoinStatus.Fresh) {
          return;
        }
        if (coinAvailability.freshCoinCount === 0) {
          throw Error(
            `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
          );
        }
        coinAvailability.freshCoinCount--;
        c.status = CoinStatus.FreshSuspended;
      } else {
        if (c.status == CoinStatus.Dormant) {
          return;
        }
        coinAvailability.freshCoinCount++;
        c.status = CoinStatus.Fresh;
      }
      await tx.coins.put(c);
      await tx.coinAvailability.put(coinAvailability);
    });
}
/**
 * Dump the public information of coins we have in an easy-to-process format.
 */
async function dumpCoins(ws: InternalWalletState): Promise {
  const coinsJson: CoinDumpJson = { coins: [] };
  logger.info("dumping coins");
  await ws.db
    .mktx((x) => [x.coins, x.denominations, x.withdrawalGroups])
    .runReadOnly(async (tx) => {
      const coins = await tx.coins.iter().toArray();
      for (const c of coins) {
        const denom = await tx.denominations.get([
          c.exchangeBaseUrl,
          c.denomPubHash,
        ]);
        if (!denom) {
          logger.warn("no denom found for coin");
          continue;
        }
        const cs = c.coinSource;
        let refreshParentCoinPub: string | undefined;
        if (cs.type == CoinSourceType.Refresh) {
          refreshParentCoinPub = cs.oldCoinPub;
        }
        let withdrawalReservePub: string | undefined;
        if (cs.type == CoinSourceType.Withdraw) {
          withdrawalReservePub = cs.reservePub;
        }
        const denomInfo = await ws.getDenomInfo(
          ws,
          tx,
          c.exchangeBaseUrl,
          c.denomPubHash,
        );
        if (!denomInfo) {
          logger.warn("no denomination found for coin");
          continue;
        }
        coinsJson.coins.push({
          coin_pub: c.coinPub,
          denom_pub: denomInfo.denomPub,
          denom_pub_hash: c.denomPubHash,
          denom_value: Amounts.stringify({
            value: denom.amountVal,
            currency: denom.currency,
            fraction: denom.amountFrac,
          }),
          exchange_base_url: c.exchangeBaseUrl,
          refresh_parent_coin_pub: refreshParentCoinPub,
          withdrawal_reserve_pub: withdrawalReservePub,
          coin_status: c.status,
          ageCommitmentProof: c.ageCommitmentProof,
          spend_allocation: c.spendAllocation
            ? {
                amount: c.spendAllocation.amount,
                id: c.spendAllocation.id,
              }
            : undefined,
        });
      }
    });
  return coinsJson;
}
/**
 * Get an API client from an internal wallet state object.
 */
export async function getClientFromWalletState(
  ws: InternalWalletState,
): Promise {
  let id = 0;
  const client: WalletCoreApiClient = {
    async call(op, payload): Promise {
      const res = await handleCoreApiRequest(ws, op, `${id++}`, payload);
      switch (res.type) {
        case "error":
          throw TalerError.fromUncheckedDetail(res.error);
        case "response":
          return res.result;
      }
    },
  };
  return client;
}
declare const __VERSION__: string;
declare const __GIT_HASH__: string;
const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
/**
 * Implementation of the "wallet-core" API.
 */
async function dispatchRequestInternal(
  ws: InternalWalletState,
  operation: WalletApiOperation,
  payload: unknown,
): Promise> {
  if (!ws.initCalled && operation !== WalletApiOperation.InitWallet) {
    throw Error(
      `wallet must be initialized before running operation ${operation}`,
    );
  }
  // FIXME: Can we make this more type-safe by using the request/response type
  // definitions we already have?
  switch (operation) {
    case WalletApiOperation.InitWallet: {
      logger.trace("initializing wallet");
      ws.initCalled = true;
      if (typeof payload === "object" && (payload as any).skipDefaults) {
        logger.trace("skipping defaults");
      } else {
        logger.trace("filling defaults");
        await fillDefaults(ws);
      }
      await maybeInitDevMode(ws);
      const resp: InitResponse = {
        versionInfo: getVersion(ws),
      };
      return resp;
    }
    case WalletApiOperation.WithdrawTestkudos: {
      await withdrawTestBalance(ws, {
        amount: "TESTKUDOS:10",
        bankBaseUrl: "https://bank.test.taler.net/",
        bankAccessApiBaseUrl: "https://bank.test.taler.net/",
        exchangeBaseUrl: "https://exchange.test.taler.net/",
      });
      return {
        versionInfo: getVersion(ws),
      };
    }
    case WalletApiOperation.WithdrawTestBalance: {
      const req = codecForWithdrawTestBalance().decode(payload);
      await withdrawTestBalance(ws, req);
      return {};
    }
    case WalletApiOperation.RunIntegrationTest: {
      const req = codecForIntegrationTestArgs().decode(payload);
      await runIntegrationTest(ws, req);
      return {};
    }
    case WalletApiOperation.TestPay: {
      const req = codecForTestPayArgs().decode(payload);
      return await testPay(ws, req);
    }
    case WalletApiOperation.GetTransactions: {
      const req = codecForTransactionsRequest().decode(payload);
      return await getTransactions(ws, req);
    }
    case WalletApiOperation.GetTransactionById: {
      const req = codecForTransactionByIdRequest().decode(payload);
      return await getTransactionById(ws, req);
    }
    case WalletApiOperation.AddExchange: {
      const req = codecForAddExchangeRequest().decode(payload);
      await updateExchangeFromUrl(ws, req.exchangeBaseUrl, {
        forceNow: req.forceUpdate,
      });
      return {};
    }
    case WalletApiOperation.ListExchanges: {
      return await getExchanges(ws);
    }
    case WalletApiOperation.GetExchangeDetailedInfo: {
      const req = codecForAddExchangeRequest().decode(payload);
      return await getExchangeDetailedInfo(ws, req.exchangeBaseUrl);
    }
    case WalletApiOperation.ListKnownBankAccounts: {
      const req = codecForListKnownBankAccounts().decode(payload);
      return await listKnownBankAccounts(ws, req.currency);
    }
    case WalletApiOperation.AddKnownBankAccounts: {
      const req = codecForAddKnownBankAccounts().decode(payload);
      await addKnownBankAccounts(ws, req.payto, req.alias, req.currency);
      return {};
    }
    case WalletApiOperation.ForgetKnownBankAccounts: {
      const req = codecForForgetKnownBankAccounts().decode(payload);
      await forgetKnownBankAccounts(ws, req.payto);
      return {};
    }
    case WalletApiOperation.GetWithdrawalDetailsForUri: {
      const req = codecForGetWithdrawalDetailsForUri().decode(payload);
      return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri);
    }
    case WalletApiOperation.AcceptManualWithdrawal: {
      const req = codecForAcceptManualWithdrawalRequet().decode(payload);
      const res = await createManualWithdrawal(ws, {
        amount: Amounts.parseOrThrow(req.amount),
        exchangeBaseUrl: req.exchangeBaseUrl,
        restrictAge: req.restrictAge,
      });
      return res;
    }
    case WalletApiOperation.GetWithdrawalDetailsForAmount: {
      const req =
        codecForGetWithdrawalDetailsForAmountRequest().decode(payload);
      const wi = await getExchangeWithdrawalInfo(
        ws,
        req.exchangeBaseUrl,
        Amounts.parseOrThrow(req.amount),
        req.restrictAge,
      );
      const resp: ManualWithdrawalDetails = {
        amountRaw: req.amount,
        amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue),
        paytoUris: wi.exchangePaytoUris,
        tosAccepted: wi.termsOfServiceAccepted,
        ageRestrictionOptions: wi.ageRestrictionOptions,
      };
      return resp;
    }
    case WalletApiOperation.GetBalances: {
      return await getBalances(ws);
    }
    case WalletApiOperation.GetBalanceDetail: {
      const req = codecForGetBalanceDetailRequest().decode(payload);
      return await getBalanceDetail(ws, req);
    }
    case WalletApiOperation.GetUserAttentionRequests: {
      const req = codecForUserAttentionsRequest().decode(payload);
      return await getUserAttentions(ws, req);
    }
    case WalletApiOperation.MarkAttentionRequestAsRead: {
      const req = codecForUserAttentionByIdRequest().decode(payload);
      return await markAttentionRequestAsRead(ws, req);
    }
    case WalletApiOperation.GetUserAttentionUnreadCount: {
      const req = codecForUserAttentionsRequest().decode(payload);
      return await getUserAttentionsUnreadCount(ws, req);
    }
    case WalletApiOperation.GetPendingOperations: {
      return await getPendingOperations(ws);
    }
    case WalletApiOperation.SetExchangeTosAccepted: {
      const req = codecForAcceptExchangeTosRequest().decode(payload);
      await acceptExchangeTermsOfService(ws, req.exchangeBaseUrl, req.etag);
      return {};
    }
    case WalletApiOperation.ApplyRefund: {
      const req = codecForApplyRefundRequest().decode(payload);
      return await applyRefund(ws, req.talerRefundUri);
    }
    case WalletApiOperation.ApplyRefundFromPurchaseId: {
      const req = codecForApplyRefundFromPurchaseIdRequest().decode(payload);
      return await applyRefundFromPurchaseId(ws, req.purchaseId);
    }
    case WalletApiOperation.AcceptBankIntegratedWithdrawal: {
      const req =
        codecForAcceptBankIntegratedWithdrawalRequest().decode(payload);
      return await acceptWithdrawalFromUri(ws, {
        selectedExchange: req.exchangeBaseUrl,
        talerWithdrawUri: req.talerWithdrawUri,
        forcedDenomSel: req.forcedDenomSel,
        restrictAge: req.restrictAge,
      });
    }
    case WalletApiOperation.GetExchangeTos: {
      const req = codecForGetExchangeTosRequest().decode(payload);
      return getExchangeTos(ws, req.exchangeBaseUrl, req.acceptedFormat);
    }
    case WalletApiOperation.GetContractTermsDetails: {
      const req = codecForGetContractTermsDetails().decode(payload);
      return getContractTermsDetails(ws, req.proposalId);
    }
    case WalletApiOperation.RetryPendingNow: {
      await runPending(ws, true);
      return {};
    }
    case WalletApiOperation.PreparePayForUri: {
      const req = codecForPreparePayRequest().decode(payload);
      return await preparePayForUri(ws, req.talerPayUri);
    }
    case WalletApiOperation.PreparePayForTemplate: {
      const req = codecForPreparePayTemplateRequest().decode(payload);
      const url = parsePayTemplateUri(req.talerPayTemplateUri);
      const templateDetails: MerchantUsingTemplateDetails = {};
      if (!url) {
        throw Error("invalid taler-template URI");
      }
      if (
        url.templateParams.amount &&
        typeof url.templateParams.amount === "string"
      ) {
        templateDetails.amount =
          req.templateParams.amount ?? url.templateParams.amount;
      }
      if (
        url.templateParams.summary &&
        typeof url.templateParams.summary === "string"
      ) {
        templateDetails.summary =
          req.templateParams.summary ?? url.templateParams.summary;
      }
      const reqUrl = new URL(
        `templates/${url.templateId}`,
        url.merchantBaseUrl,
      );
      const httpReq = await ws.http.postJson(reqUrl.href, templateDetails);
      const resp = await readSuccessResponseJsonOrThrow(
        httpReq,
        codecForMerchantPostOrderResponse(),
      );
      const payUri = constructPayUri(
        url.merchantBaseUrl,
        resp.order_id,
        "",
        resp.token,
      );
      return await preparePayForUri(ws, payUri);
    }
    case WalletApiOperation.ConfirmPay: {
      const req = codecForConfirmPayRequest().decode(payload);
      return await confirmPay(ws, req.proposalId, req.sessionId);
    }
    case WalletApiOperation.AbortTransaction: {
      const req = codecForAbortTransaction().decode(payload);
      await abortTransaction(ws, req.transactionId, req.forceImmediateAbort);
      return {};
    }
    case WalletApiOperation.DumpCoins: {
      return await dumpCoins(ws);
    }
    case WalletApiOperation.SetCoinSuspended: {
      const req = codecForSetCoinSuspendedRequest().decode(payload);
      await setCoinSuspended(ws, req.coinPub, req.suspended);
      return {};
    }
    case WalletApiOperation.ForceRefresh: {
      const req = codecForForceRefreshRequest().decode(payload);
      if (req.coinPubList.length == 0) {
        throw Error("refusing to create empty refresh group");
      }
      const refreshGroupId = await ws.db
        .mktx((x) => [
          x.refreshGroups,
          x.coinAvailability,
          x.denominations,
          x.coins,
        ])
        .runReadWrite(async (tx) => {
          let coinPubs: CoinRefreshRequest[] = [];
          for (const c of req.coinPubList) {
            const coin = await tx.coins.get(c);
            if (!coin) {
              throw Error(`coin (pubkey ${c}) not found`);
            }
            const denom = await ws.getDenomInfo(
              ws,
              tx,
              coin.exchangeBaseUrl,
              coin.denomPubHash,
            );
            checkDbInvariant(!!denom);
            coinPubs.push({
              coinPub: c,
              amount: denom?.value,
            });
          }
          return await createRefreshGroup(
            ws,
            tx,
            Amounts.currencyOf(coinPubs[0].amount),
            coinPubs,
            RefreshReason.Manual,
          );
        });
      processRefreshGroup(ws, refreshGroupId.refreshGroupId, {
        forceNow: true,
      }).catch((x) => {
        logger.error(x);
      });
      return {
        refreshGroupId,
      };
    }
    case WalletApiOperation.PrepareTip: {
      const req = codecForPrepareTipRequest().decode(payload);
      return await prepareTip(ws, req.talerTipUri);
    }
    case WalletApiOperation.PrepareRefund: {
      const req = codecForPrepareRefundRequest().decode(payload);
      return await prepareRefund(ws, req.talerRefundUri);
    }
    case WalletApiOperation.AcceptTip: {
      const req = codecForAcceptTipRequest().decode(payload);
      return await acceptTip(ws, req.walletTipId);
    }
    case WalletApiOperation.ExportBackupPlain: {
      return exportBackup(ws);
    }
    case WalletApiOperation.AddBackupProvider: {
      const req = codecForAddBackupProviderRequest().decode(payload);
      return await addBackupProvider(ws, req);
    }
    case WalletApiOperation.RunBackupCycle: {
      const req = codecForRunBackupCycle().decode(payload);
      await runBackupCycle(ws, req);
      return {};
    }
    case WalletApiOperation.RemoveBackupProvider: {
      const req = codecForRemoveBackupProvider().decode(payload);
      await removeBackupProvider(ws, req);
      return {};
    }
    case WalletApiOperation.ExportBackupRecovery: {
      const resp = await getBackupRecovery(ws);
      return resp;
    }
    case WalletApiOperation.ImportBackupRecovery: {
      const req = codecForAny().decode(payload);
      await loadBackupRecovery(ws, req);
      return {};
    }
    case WalletApiOperation.GetBackupInfo: {
      const resp = await getBackupInfo(ws);
      return resp;
    }
    case WalletApiOperation.GetFeeForDeposit: {
      const req = codecForGetFeeForDeposit().decode(payload);
      return await getFeeForDeposit(ws, req);
    }
    case WalletApiOperation.PrepareDeposit: {
      const req = codecForPrepareDepositRequest().decode(payload);
      return await prepareDepositGroup(ws, req);
    }
    case WalletApiOperation.CreateDepositGroup: {
      const req = codecForCreateDepositGroupRequest().decode(payload);
      return await createDepositGroup(ws, req);
    }
    case WalletApiOperation.TrackDepositGroup: {
      const req = codecForTrackDepositGroupRequest().decode(payload);
      return trackDepositGroup(ws, req);
    }
    case WalletApiOperation.DeleteTransaction: {
      const req = codecForDeleteTransactionRequest().decode(payload);
      await deleteTransaction(ws, req.transactionId);
      return {};
    }
    case WalletApiOperation.RetryTransaction: {
      const req = codecForRetryTransactionRequest().decode(payload);
      await retryTransaction(ws, req.transactionId);
      return {};
    }
    case WalletApiOperation.SetWalletDeviceId: {
      const req = codecForSetWalletDeviceIdRequest().decode(payload);
      await setWalletDeviceId(ws, req.walletDeviceId);
      return {};
    }
    case WalletApiOperation.ListCurrencies: {
      return await ws.db
        .mktx((x) => [x.auditorTrust, x.exchangeTrust])
        .runReadOnly(async (tx) => {
          const trustedAuditors = await tx.auditorTrust.iter().toArray();
          const trustedExchanges = await tx.exchangeTrust.iter().toArray();
          return {
            trustedAuditors: trustedAuditors.map((x) => ({
              currency: x.currency,
              auditorBaseUrl: x.auditorBaseUrl,
              auditorPub: x.auditorPub,
            })),
            trustedExchanges: trustedExchanges.map((x) => ({
              currency: x.currency,
              exchangeBaseUrl: x.exchangeBaseUrl,
              exchangeMasterPub: x.exchangeMasterPub,
            })),
          };
        });
    }
    case WalletApiOperation.WithdrawFakebank: {
      const req = codecForWithdrawFakebankRequest().decode(payload);
      const amount = Amounts.parseOrThrow(req.amount);
      const details = await getExchangeWithdrawalInfo(
        ws,
        req.exchange,
        amount,
        undefined,
      );
      const wres = await createManualWithdrawal(ws, {
        amount: amount,
        exchangeBaseUrl: req.exchange,
      });
      const paytoUri = details.exchangePaytoUris[0];
      const pt = parsePaytoUri(paytoUri);
      if (!pt) {
        throw Error("failed to parse payto URI");
      }
      const components = pt.targetPath.split("/");
      const creditorAcct = components[components.length - 1];
      logger.info(`making testbank transfer to '${creditorAcct}'`);
      const fbReq = await ws.http.postJson(
        new URL(`${creditorAcct}/admin/add-incoming`, req.bank).href,
        {
          amount: Amounts.stringify(amount),
          reserve_pub: wres.reservePub,
          debit_account:
            "payto://x-taler-bank/localhost/testdebtor?receiver-name=Foo",
        },
      );
      const fbResp = await readSuccessResponseJsonOrThrow(fbReq, codecForAny());
      logger.info(`started fakebank withdrawal: ${j2s(fbResp)}`);
      return {};
    }
    case WalletApiOperation.TestCrypto: {
      return await ws.cryptoApi.hashString({ str: "hello world" });
    }
    case WalletApiOperation.ClearDb:
      await clearDatabase(ws.db.idbHandle());
      return {};
    case WalletApiOperation.Recycle: {
      const backup = await exportBackup(ws);
      await clearDatabase(ws.db.idbHandle());
      await importBackupPlain(ws, backup);
      return {};
    }
    case WalletApiOperation.ExportDb: {
      const dbDump = await exportDb(ws.db.idbHandle());
      return dbDump;
    }
    case WalletApiOperation.ImportDb: {
      const req = codecForImportDbRequest().decode(payload);
      await importDb(ws.db.idbHandle(), req.dump);
      return [];
    }
    case WalletApiOperation.CheckPeerPushDebit: {
      const req = codecForCheckPeerPushDebitRequest().decode(payload);
      return await checkPeerPushDebit(ws, req);
    }
    case WalletApiOperation.PreparePeerPushCredit: {
      const req = codecForPreparePeerPushCreditRequest().decode(payload);
      return await preparePeerPushCredit(ws, req);
    }
    case WalletApiOperation.InitiatePeerPushDebit: {
      const req = codecForInitiatePeerPushPaymentRequest().decode(payload);
      return await initiatePeerPushPayment(ws, req);
    }
    case WalletApiOperation.PreparePeerPushCredit: {
      const req = codecForPreparePeerPushCreditRequest().decode(payload);
      return await preparePeerPushCredit(ws, req);
    }
    case WalletApiOperation.ConfirmPeerPushCredit: {
      const req = codecForConfirmPeerPushPaymentRequest().decode(payload);
      return await confirmPeerPushPayment(ws, req);
    }
    case WalletApiOperation.CheckPeerPullCredit: {
      const req = codecForPreparePeerPullPaymentRequest().decode(payload);
      return await checkPeerPullPaymentInitiation(ws, req);
    }
    case WalletApiOperation.InitiatePeerPullCredit: {
      const req = codecForInitiatePeerPullPaymentRequest().decode(payload);
      return await initiatePeerPullPayment(ws, req);
    }
    case WalletApiOperation.PreparePeerPullDebit: {
      const req = codecForCheckPeerPullPaymentRequest().decode(payload);
      return await preparePeerPullCredit(ws, req);
    }
    case WalletApiOperation.ConfirmPeerPullDebit: {
      const req = codecForAcceptPeerPullPaymentRequest().decode(payload);
      return await acceptIncomingPeerPullPayment(ws, req);
    }
    case WalletApiOperation.ApplyDevExperiment: {
      const req = codecForApplyDevExperiment().decode(payload);
      await applyDevExperiment(ws, req.devExperimentUri);
      return {};
    }
    case WalletApiOperation.SetDevMode: {
      const req = codecForSetDevModeRequest().decode(payload);
      await setDevMode(ws, req.devModeEnabled);
      return {};
    }
    case WalletApiOperation.GetVersion: {
      return getVersion(ws);
    }
    //default:
    //  assertUnreachable(operation);
  }
  throw TalerError.fromDetail(
    TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,
    {
      operation,
    },
    "unknown operation",
  );
}
export function getVersion(ws: InternalWalletState): WalletCoreVersion {
  const version: WalletCoreVersion = {
    hash: GIT_HASH,
    version: VERSION,
    exchange: WALLET_EXCHANGE_PROTOCOL_VERSION,
    merchant: WALLET_MERCHANT_PROTOCOL_VERSION,
    bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
    devMode: ws.devModeActive,
  };
  return version;
}
function translateLegacyOperationNames(operation: string): string {
  switch (operation) {
    case "initiatePeerPullPayment":
      return WalletApiOperation.InitiatePeerPullCredit.toString();
    case "initiatePeerPushPayment":
      return WalletApiOperation.InitiatePeerPushDebit.toString();
    case "checkPeerPullPayment":
      return WalletApiOperation.PreparePeerPullDebit.toString();
    case "acceptPeerPullPayment":
      return WalletApiOperation.ConfirmPeerPullDebit.toString();
    case "checkPeerPushPayment":
      return WalletApiOperation.PreparePeerPushCredit.toString();
    case "acceptPeerPushPayment":
      return WalletApiOperation.ConfirmPeerPushCredit.toString();
  }
  return operation;
}
/**
 * Handle a request to the wallet-core API.
 */
export async function handleCoreApiRequest(
  ws: InternalWalletState,
  operation: string,
  id: string,
  payload: unknown,
): Promise {
  operation = translateLegacyOperationNames(operation);
  try {
    const result = await dispatchRequestInternal(ws, operation as any, payload);
    return {
      type: "response",
      operation,
      id,
      result,
    };
  } catch (e: any) {
    const err = getErrorDetailFromException(e);
    logger.info(`finished wallet core request with error: ${j2s(err)}`);
    return {
      type: "error",
      operation,
      id,
      error: err,
    };
  }
}
/**
 * Public handle to a running wallet.
 */
export class Wallet {
  private ws: InternalWalletState;
  private _client: WalletCoreApiClient | undefined;
  private constructor(
    db: DbAccess,
    http: HttpRequestLibrary,
    timer: TimerAPI,
    cryptoWorkerFactory: CryptoWorkerFactory,
  ) {
    this.ws = new InternalWalletStateImpl(db, http, timer, cryptoWorkerFactory);
  }
  get client(): WalletCoreApiClient {
    if (!this._client) {
      throw Error();
    }
    return this._client;
  }
  /**
   * Trust the exchange, do not validate signatures.
   * Only used to benchmark the exchange.
   */
  setInsecureTrustExchange(): void {
    this.ws.insecureTrustExchange = true;
  }
  setBatchWithdrawal(enable: boolean): void {
    this.ws.batchWithdrawal = enable;
  }
  static async create(
    db: DbAccess,
    http: HttpRequestLibrary,
    timer: TimerAPI,
    cryptoWorkerFactory: CryptoWorkerFactory,
  ): Promise {
    const w = new Wallet(db, http, timer, cryptoWorkerFactory);
    w._client = await getClientFromWalletState(w.ws);
    return w;
  }
  addNotificationListener(f: (n: WalletNotification) => void): void {
    return this.ws.addNotificationListener(f);
  }
  stop(): void {
    this.ws.stop();
  }
  runPending(forceNow = false): Promise {
    return runPending(this.ws, forceNow);
  }
  runTaskLoop(opts?: RetryLoopOpts): Promise {
    return runTaskLoop(this.ws, opts);
  }
  handleCoreApiRequest(
    operation: string,
    id: string,
    payload: unknown,
  ): Promise {
    return handleCoreApiRequest(this.ws, operation, id, payload);
  }
}
/**
 * Internal state of the wallet.
 *
 * This ties together all the operation implementations.
 */
class InternalWalletStateImpl implements InternalWalletState {
  /**
   * @see {@link InternalWalletState.activeLongpoll}
   */
  activeLongpoll: ActiveLongpollInfo = {};
  cryptoApi: TalerCryptoInterface;
  cryptoDispatcher: CryptoDispatcher;
  merchantInfoCache: Record = {};
  insecureTrustExchange = false;
  batchWithdrawal = false;
  readonly timerGroup: TimerGroup;
  latch = new AsyncCondition();
  stopped = false;
  listeners: NotificationListener[] = [];
  initCalled = false;
  devModeActive = false;
  exchangeOps: ExchangeOperations = {
    getExchangeDetails,
    getExchangeTrust,
    updateExchangeFromUrl,
  };
  recoupOps: RecoupOperations = {
    createRecoupGroup,
    processRecoupGroup,
  };
  merchantOps: MerchantOperations = {
    getMerchantInfo,
  };
  refreshOps: RefreshOperations = {
    createRefreshGroup,
  };
  // FIXME: Use an LRU cache here.
  private denomCache: Record = {};
  /**
   * Promises that are waiting for a particular resource.
   */
  private resourceWaiters: Record[]> = {};
  /**
   * Resources that are currently locked.
   */
  private resourceLocks: Set = new Set();
  constructor(
    // FIXME: Make this a getter and make
    // the actual value nullable.
    // Check if we are in a DB migration / garbage collection
    // and throw an error in that case.
    public db: DbAccess,
    public http: HttpRequestLibrary,
    public timer: TimerAPI,
    cryptoWorkerFactory: CryptoWorkerFactory,
  ) {
    this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory);
    this.cryptoApi = this.cryptoDispatcher.cryptoApi;
    this.timerGroup = new TimerGroup(timer);
  }
  async getDenomInfo(
    ws: InternalWalletState,
    tx: GetReadWriteAccess<{
      denominations: typeof WalletStoresV1.denominations;
    }>,
    exchangeBaseUrl: string,
    denomPubHash: string,
  ): Promise {
    const key = `${exchangeBaseUrl}:${denomPubHash}`;
    const cached = this.denomCache[key];
    if (cached) {
      return cached;
    }
    const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]);
    if (d) {
      return DenominationRecord.toDenomInfo(d);
    }
    return undefined;
  }
  notify(n: WalletNotification): void {
    logger.trace("Notification", j2s(n));
    for (const l of this.listeners) {
      const nc = JSON.parse(JSON.stringify(n));
      setTimeout(() => {
        l(nc);
      }, 0);
    }
  }
  addNotificationListener(f: (n: WalletNotification) => void): void {
    this.listeners.push(f);
  }
  /**
   * Stop ongoing processing.
   */
  stop(): void {
    logger.trace("stopping (at internal wallet state)");
    this.stopped = true;
    this.timerGroup.stopCurrentAndFutureTimers();
    this.cryptoDispatcher.stop();
    for (const key of Object.keys(this.activeLongpoll)) {
      logger.trace(`cancelling active longpoll ${key}`);
      this.activeLongpoll[key].cancel();
    }
  }
  async runUntilDone(
    req: {
      maxRetries?: number;
    } = {},
  ): Promise {
    await runTaskLoop(this, { ...req, stopWhenDone: true });
  }
  /**
   * Run an async function after acquiring a list of locks, identified
   * by string tokens.
   */
  async runSequentialized(
    tokens: string[],
    f: () => Promise,
  ): Promise {
    // Make sure locks are always acquired in the same order
    tokens = [...tokens].sort();
    for (const token of tokens) {
      if (this.resourceLocks.has(token)) {
        const p = openPromise();
        let waitList = this.resourceWaiters[token];
        if (!waitList) {
          waitList = this.resourceWaiters[token] = [];
        }
        waitList.push(p);
        await p.promise;
      }
      this.resourceLocks.add(token);
    }
    try {
      logger.trace(`begin exclusive execution on ${JSON.stringify(tokens)}`);
      const result = await f();
      logger.trace(`end exclusive execution on ${JSON.stringify(tokens)}`);
      return result;
    } finally {
      for (const token of tokens) {
        this.resourceLocks.delete(token);
        let waiter = (this.resourceWaiters[token] ?? []).shift();
        if (waiter) {
          waiter.resolve();
        }
      }
    }
  }
}