/*
 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 
 */
import {
  AbsoluteTime,
  AgeCommitment,
  AgeRestriction,
  AmountJson,
  Amounts,
  amountToPretty,
  codecForExchangeMeltResponse,
  codecForExchangeRevealResponse,
  CoinPublicKeyString,
  CoinRefreshRequest,
  CoinStatus,
  DenominationInfo,
  DenomKeyType,
  Duration,
  durationFromSpec,
  durationMul,
  encodeCrock,
  ExchangeMeltRequest,
  ExchangeProtocolVersion,
  ExchangeRefreshRevealRequest,
  fnutil,
  getErrorDetailFromException,
  getRandomBytes,
  HashCodeString,
  HttpStatusCode,
  j2s,
  Logger,
  makeErrorDetail,
  NotificationType,
  RefreshGroupId,
  RefreshReason,
  TalerError,
  TalerErrorCode,
  TalerErrorDetail,
  TalerPreciseTimestamp,
  TransactionAction,
  TransactionMajorState,
  TransactionState,
  TransactionType,
  URL,
} from "@gnu-taler/taler-util";
import {
  readSuccessResponseJsonOrThrow,
  readUnexpectedResponseDetails,
} from "@gnu-taler/taler-util/http";
import { TalerCryptoInterface } from "../crypto/cryptoImplementation.js";
import {
  DerivedRefreshSession,
  RefreshNewDenomInfo,
} from "../crypto/cryptoTypes.js";
import { CryptoApiStoppedError } from "../crypto/workers/crypto-dispatcher.js";
import {
  CoinRecord,
  CoinSourceType,
  DenominationRecord,
  RefreshCoinStatus,
  RefreshGroupRecord,
  RefreshOperationStatus,
  RefreshReasonDetails,
  WalletStoresV1,
} from "../db.js";
import {
  isWithdrawableDenom,
  PendingTaskType,
  RefreshSessionRecord,
} from "../index.js";
import {
  EXCHANGE_COINS_LOCK,
  InternalWalletState,
} from "../internal-wallet-state.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
import { selectWithdrawalDenominations } from "../util/coinSelection.js";
import { checkDbInvariant } from "../util/invariants.js";
import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js";
import {
  constructTaskIdentifier,
  makeCoinAvailable,
  makeCoinsVisible,
  TaskRunResult,
  TaskRunResultType,
} from "./common.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import {
  constructTransactionIdentifier,
  notifyTransition,
} from "./transactions.js";
const logger = new Logger("refresh.ts");
/**
 * Get the amount that we lose when refreshing a coin of the given denomination
 * with a certain amount left.
 *
 * If the amount left is zero, then the refresh cost
 * is also considered to be zero.  If a refresh isn't possible (e.g. due to lack of
 * the right denominations), then the cost is the full amount left.
 *
 * Considers refresh fees, withdrawal fees after refresh and amounts too small
 * to refresh.
 */
export function getTotalRefreshCost(
  denoms: DenominationRecord[],
  refreshedDenom: DenominationInfo,
  amountLeft: AmountJson,
  denomselAllowLate: boolean,
): AmountJson {
  const withdrawAmount = Amounts.sub(
    amountLeft,
    refreshedDenom.feeRefresh,
  ).amount;
  const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x]));
  const withdrawDenoms = selectWithdrawalDenominations(
    withdrawAmount,
    denoms,
    denomselAllowLate,
  );
  const resultingAmount = Amounts.add(
    Amounts.zeroOfCurrency(withdrawAmount.currency),
    ...withdrawDenoms.selectedDenoms.map(
      (d) => Amounts.mult(denomMap[d.denomPubHash].value, d.count).amount,
    ),
  ).amount;
  const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
  logger.trace(
    `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty(
      totalCost,
    )}`,
  );
  return totalCost;
}
function updateGroupStatus(rg: RefreshGroupRecord): { final: boolean } {
  const allFinal = fnutil.all(
    rg.statusPerCoin,
    (x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Failed,
  );
  const anyFailed = fnutil.any(
    rg.statusPerCoin,
    (x) => x === RefreshCoinStatus.Failed,
  );
  if (allFinal) {
    if (anyFailed) {
      rg.timestampFinished = TalerPreciseTimestamp.now();
      rg.operationStatus = RefreshOperationStatus.Failed;
    } else {
      rg.timestampFinished = TalerPreciseTimestamp.now();
      rg.operationStatus = RefreshOperationStatus.Finished;
    }
    return { final: true };
  }
  return { final: false };
}
/**
 * Create a refresh session for one particular coin inside a refresh group.
 *
 * If the session already exists, return the existing one.
 *
 * If the session doesn't need to be created (refresh group gone or session already
 * finished), return undefined.
 */
async function provideRefreshSession(
  ws: InternalWalletState,
  refreshGroupId: string,
  coinIndex: number,
): Promise {
  logger.trace(
    `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`,
  );
  const d = await ws.db
    .mktx((x) => [x.refreshGroups, x.coins, x.refreshSessions])
    .runReadWrite(async (tx) => {
      const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
      if (!refreshGroup) {
        return;
      }
      if (
        refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished
      ) {
        return;
      }
      const existingRefreshSession = await tx.refreshSessions.get([
        refreshGroupId,
        coinIndex,
      ]);
      const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex];
      const coin = await tx.coins.get(oldCoinPub);
      if (!coin) {
        throw Error("Can't refresh, coin not found");
      }
      return { refreshGroup, coin, existingRefreshSession };
    });
  if (!d) {
    return undefined;
  }
  if (d.existingRefreshSession) {
    return d.existingRefreshSession;
  }
  const { refreshGroup, coin } = d;
  const { exchange } = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl);
  if (!exchange) {
    throw Error("db inconsistent: exchange of coin not found");
  }
  // FIXME: use helper functions from withdraw.ts
  // to update and filter withdrawable denoms.
  const { availableAmount, availableDenoms } = await ws.db
    .mktx((x) => [x.denominations])
    .runReadOnly(async (tx) => {
      const oldDenom = await ws.getDenomInfo(
        ws,
        tx,
        exchange.baseUrl,
        coin.denomPubHash,
      );
      if (!oldDenom) {
        throw Error("db inconsistent: denomination for coin not found");
      }
      // FIXME: use an index here, based on the withdrawal expiration time.
      const availableDenoms: DenominationRecord[] =
        await tx.denominations.indexes.byExchangeBaseUrl
          .iter(exchange.baseUrl)
          .toArray();
      const availableAmount = Amounts.sub(
        refreshGroup.inputPerCoin[coinIndex],
        oldDenom.feeRefresh,
      ).amount;
      return { availableAmount, availableDenoms };
    });
  const newCoinDenoms = selectWithdrawalDenominations(
    availableAmount,
    availableDenoms,
    ws.config.testing.denomselAllowLate,
  );
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Refresh,
    refreshGroupId,
  });
  if (newCoinDenoms.selectedDenoms.length === 0) {
    logger.trace(
      `not refreshing, available amount ${amountToPretty(
        availableAmount,
      )} too small`,
    );
    const transitionInfo = await ws.db
      .mktx((x) => [x.coins, x.coinAvailability, x.refreshGroups])
      .runReadWrite(async (tx) => {
        const rg = await tx.refreshGroups.get(refreshGroupId);
        if (!rg) {
          return;
        }
        const oldTxState = computeRefreshTransactionState(rg);
        rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
        const updateRes = updateGroupStatus(rg);
        if (updateRes.final) {
          await makeCoinsVisible(ws, tx, transactionId);
        }
        await tx.refreshGroups.put(rg);
        const newTxState = computeRefreshTransactionState(rg);
        return { oldTxState, newTxState };
      });
    ws.notify({ type: NotificationType.BalanceChange });
    notifyTransition(ws, transactionId, transitionInfo);
    return;
  }
  const sessionSecretSeed = encodeCrock(getRandomBytes(64));
  // Store refresh session for this coin in the database.
  const newSession = await ws.db
    .mktx((x) => [x.refreshGroups, x.coins, x.refreshSessions])
    .runReadWrite(async (tx) => {
      const rg = await tx.refreshGroups.get(refreshGroupId);
      if (!rg) {
        return;
      }
      const existingSession = await tx.refreshSessions.get([
        refreshGroupId,
        coinIndex,
      ]);
      if (existingSession) {
        return;
      }
      const newSession: RefreshSessionRecord = {
        coinIndex,
        refreshGroupId,
        norevealIndex: undefined,
        sessionSecretSeed: sessionSecretSeed,
        newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({
          count: x.count,
          denomPubHash: x.denomPubHash,
        })),
        amountRefreshOutput: Amounts.stringify(newCoinDenoms.totalCoinValue),
      };
      await tx.refreshSessions.put(newSession);
      return newSession;
    });
  logger.trace(
    `created refresh session for coin #${coinIndex} in ${refreshGroupId}`,
  );
  return newSession;
}
function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration {
  return Duration.fromSpec({
    seconds: 5,
  });
}
async function refreshMelt(
  ws: InternalWalletState,
  refreshGroupId: string,
  coinIndex: number,
): Promise {
  const d = await ws.db
    .mktx((x) => [x.refreshGroups, x.refreshSessions, x.coins, x.denominations])
    .runReadWrite(async (tx) => {
      const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
      if (!refreshGroup) {
        return;
      }
      const refreshSession = await tx.refreshSessions.get([
        refreshGroupId,
        coinIndex,
      ]);
      if (!refreshSession) {
        return;
      }
      if (refreshSession.norevealIndex !== undefined) {
        return;
      }
      const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
      checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
      const oldDenom = await ws.getDenomInfo(
        ws,
        tx,
        oldCoin.exchangeBaseUrl,
        oldCoin.denomPubHash,
      );
      checkDbInvariant(
        !!oldDenom,
        "denomination for melted coin doesn't exist",
      );
      const newCoinDenoms: RefreshNewDenomInfo[] = [];
      for (const dh of refreshSession.newDenoms) {
        const newDenom = await ws.getDenomInfo(
          ws,
          tx,
          oldCoin.exchangeBaseUrl,
          dh.denomPubHash,
        );
        checkDbInvariant(
          !!newDenom,
          "new denomination for refresh not in database",
        );
        newCoinDenoms.push({
          count: dh.count,
          denomPub: newDenom.denomPub,
          denomPubHash: newDenom.denomPubHash,
          feeWithdraw: newDenom.feeWithdraw,
          value: Amounts.stringify(newDenom.value),
        });
      }
      return { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession };
    });
  if (!d) {
    return;
  }
  const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d;
  let exchangeProtocolVersion: ExchangeProtocolVersion;
  switch (d.oldDenom.denomPub.cipher) {
    case DenomKeyType.Rsa: {
      exchangeProtocolVersion = ExchangeProtocolVersion.V12;
      break;
    }
    default:
      throw Error("unsupported key type");
  }
  const derived = await ws.cryptoApi.deriveRefreshSession({
    exchangeProtocolVersion,
    kappa: 3,
    meltCoinDenomPubHash: oldCoin.denomPubHash,
    meltCoinPriv: oldCoin.coinPriv,
    meltCoinPub: oldCoin.coinPub,
    feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
    meltCoinMaxAge: oldCoin.maxAge,
    meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
    newCoinDenoms,
    sessionSecretSeed: refreshSession.sessionSecretSeed,
  });
  const reqUrl = new URL(
    `coins/${oldCoin.coinPub}/melt`,
    oldCoin.exchangeBaseUrl,
  );
  let maybeAch: HashCodeString | undefined;
  if (oldCoin.ageCommitmentProof) {
    maybeAch = AgeRestriction.hashCommitment(
      oldCoin.ageCommitmentProof.commitment,
    );
  }
  const meltReqBody: ExchangeMeltRequest = {
    coin_pub: oldCoin.coinPub,
    confirm_sig: derived.confirmSig,
    denom_pub_hash: oldCoin.denomPubHash,
    denom_sig: oldCoin.denomSig,
    rc: derived.hash,
    value_with_fee: Amounts.stringify(derived.meltValueWithFee),
    age_commitment_hash: maybeAch,
  };
  const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
    return await ws.http.postJson(reqUrl.href, meltReqBody, {
      timeout: getRefreshRequestTimeout(refreshGroup),
    });
  });
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Refresh,
    refreshGroupId,
  });
  if (resp.status === HttpStatusCode.NotFound) {
    const errDetails = await readUnexpectedResponseDetails(resp);
    const transitionInfo = await ws.db
      .mktx((x) => [
        x.refreshGroups,
        x.refreshSessions,
        x.coins,
        x.coinAvailability,
      ])
      .runReadWrite(async (tx) => {
        const rg = await tx.refreshGroups.get(refreshGroupId);
        if (!rg) {
          return;
        }
        if (rg.timestampFinished) {
          return;
        }
        if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
          return;
        }
        const oldTxState = computeRefreshTransactionState(rg);
        rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
        const refreshSession = await tx.refreshSessions.get([
          refreshGroupId,
          coinIndex,
        ]);
        if (!refreshSession) {
          throw Error(
            "db invariant failed: missing refresh session in database",
          );
        }
        refreshSession.lastError = errDetails;
        const updateRes = updateGroupStatus(rg);
        if (updateRes.final) {
          await makeCoinsVisible(ws, tx, transactionId);
        }
        await tx.refreshGroups.put(rg);
        await tx.refreshSessions.put(refreshSession);
        const newTxState = computeRefreshTransactionState(rg);
        return {
          oldTxState,
          newTxState,
        };
      });
    ws.notify({ type: NotificationType.BalanceChange });
    notifyTransition(ws, transactionId, transitionInfo);
    return;
  }
  if (resp.status === HttpStatusCode.Conflict) {
    // Just log for better diagnostics here, error status
    // will be handled later.
    logger.error(
      `melt request for ${Amounts.stringify(
        derived.meltValueWithFee,
      )} failed in refresh group ${refreshGroupId} due to conflict`,
    );
  }
  const meltResponse = await readSuccessResponseJsonOrThrow(
    resp,
    codecForExchangeMeltResponse(),
  );
  const norevealIndex = meltResponse.noreveal_index;
  refreshSession.norevealIndex = norevealIndex;
  await ws.db
    .mktx((x) => [x.refreshGroups, x.refreshSessions])
    .runReadWrite(async (tx) => {
      const rg = await tx.refreshGroups.get(refreshGroupId);
      if (!rg) {
        return;
      }
      if (rg.timestampFinished) {
        return;
      }
      const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
      if (!rs) {
        return;
      }
      if (rs.norevealIndex !== undefined) {
        return;
      }
      rs.norevealIndex = norevealIndex;
      await tx.refreshSessions.put(rs);
    });
}
export async function assembleRefreshRevealRequest(args: {
  cryptoApi: TalerCryptoInterface;
  derived: DerivedRefreshSession;
  norevealIndex: number;
  oldCoinPub: CoinPublicKeyString;
  oldCoinPriv: string;
  newDenoms: {
    denomPubHash: string;
    count: number;
  }[];
  oldAgeCommitment?: AgeCommitment;
}): Promise {
  const {
    derived,
    norevealIndex,
    cryptoApi,
    oldCoinPriv,
    oldCoinPub,
    newDenoms,
  } = args;
  const privs = Array.from(derived.transferPrivs);
  privs.splice(norevealIndex, 1);
  const planchets = derived.planchetsForGammas[norevealIndex];
  if (!planchets) {
    throw Error("refresh index error");
  }
  const newDenomsFlat: string[] = [];
  const linkSigs: string[] = [];
  for (let i = 0; i < newDenoms.length; i++) {
    const dsel = newDenoms[i];
    for (let j = 0; j < dsel.count; j++) {
      const newCoinIndex = linkSigs.length;
      const linkSig = await cryptoApi.signCoinLink({
        coinEv: planchets[newCoinIndex].coinEv,
        newDenomHash: dsel.denomPubHash,
        oldCoinPriv: oldCoinPriv,
        oldCoinPub: oldCoinPub,
        transferPub: derived.transferPubs[norevealIndex],
      });
      linkSigs.push(linkSig.sig);
      newDenomsFlat.push(dsel.denomPubHash);
    }
  }
  const req: ExchangeRefreshRevealRequest = {
    coin_evs: planchets.map((x) => x.coinEv),
    new_denoms_h: newDenomsFlat,
    transfer_privs: privs,
    transfer_pub: derived.transferPubs[norevealIndex],
    link_sigs: linkSigs,
    old_age_commitment: args.oldAgeCommitment?.publicKeys,
  };
  return req;
}
async function refreshReveal(
  ws: InternalWalletState,
  refreshGroupId: string,
  coinIndex: number,
): Promise {
  logger.trace(
    `doing refresh reveal for ${refreshGroupId} (old coin ${coinIndex})`,
  );
  const d = await ws.db
    .mktx((x) => [x.refreshGroups, x.refreshSessions, x.coins, x.denominations])
    .runReadOnly(async (tx) => {
      const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
      if (!refreshGroup) {
        return;
      }
      const refreshSession = await tx.refreshSessions.get([
        refreshGroupId,
        coinIndex,
      ]);
      if (!refreshSession) {
        return;
      }
      const norevealIndex = refreshSession.norevealIndex;
      if (norevealIndex === undefined) {
        throw Error("can't reveal without melting first");
      }
      const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
      checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
      const oldDenom = await ws.getDenomInfo(
        ws,
        tx,
        oldCoin.exchangeBaseUrl,
        oldCoin.denomPubHash,
      );
      checkDbInvariant(
        !!oldDenom,
        "denomination for melted coin doesn't exist",
      );
      const newCoinDenoms: RefreshNewDenomInfo[] = [];
      for (const dh of refreshSession.newDenoms) {
        const newDenom = await ws.getDenomInfo(
          ws,
          tx,
          oldCoin.exchangeBaseUrl,
          dh.denomPubHash,
        );
        checkDbInvariant(
          !!newDenom,
          "new denomination for refresh not in database",
        );
        newCoinDenoms.push({
          count: dh.count,
          denomPub: newDenom.denomPub,
          denomPubHash: newDenom.denomPubHash,
          feeWithdraw: newDenom.feeWithdraw,
          value: Amounts.stringify(newDenom.value),
        });
      }
      return {
        oldCoin,
        oldDenom,
        newCoinDenoms,
        refreshSession,
        refreshGroup,
        norevealIndex,
      };
    });
  if (!d) {
    return;
  }
  const {
    oldCoin,
    oldDenom,
    newCoinDenoms,
    refreshSession,
    refreshGroup,
    norevealIndex,
  } = d;
  let exchangeProtocolVersion: ExchangeProtocolVersion;
  switch (d.oldDenom.denomPub.cipher) {
    case DenomKeyType.Rsa: {
      exchangeProtocolVersion = ExchangeProtocolVersion.V12;
      break;
    }
    default:
      throw Error("unsupported key type");
  }
  const derived = await ws.cryptoApi.deriveRefreshSession({
    exchangeProtocolVersion,
    kappa: 3,
    meltCoinDenomPubHash: oldCoin.denomPubHash,
    meltCoinPriv: oldCoin.coinPriv,
    meltCoinPub: oldCoin.coinPub,
    feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
    newCoinDenoms,
    meltCoinMaxAge: oldCoin.maxAge,
    meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
    sessionSecretSeed: refreshSession.sessionSecretSeed,
  });
  const reqUrl = new URL(
    `refreshes/${derived.hash}/reveal`,
    oldCoin.exchangeBaseUrl,
  );
  const req = await assembleRefreshRevealRequest({
    cryptoApi: ws.cryptoApi,
    derived,
    newDenoms: newCoinDenoms,
    norevealIndex: norevealIndex,
    oldCoinPriv: oldCoin.coinPriv,
    oldCoinPub: oldCoin.coinPub,
    oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment,
  });
  const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
    return await ws.http.postJson(reqUrl.href, req, {
      timeout: getRefreshRequestTimeout(refreshGroup),
    });
  });
  const reveal = await readSuccessResponseJsonOrThrow(
    resp,
    codecForExchangeRevealResponse(),
  );
  const coins: CoinRecord[] = [];
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Refresh,
    refreshGroupId,
  });
  for (let i = 0; i < refreshSession.newDenoms.length; i++) {
    const ncd = newCoinDenoms[i];
    for (let j = 0; j < refreshSession.newDenoms[i].count; j++) {
      const newCoinIndex = coins.length;
      const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex];
      if (ncd.denomPub.cipher !== DenomKeyType.Rsa) {
        throw Error("cipher unsupported");
      }
      const evSig = reveal.ev_sigs[newCoinIndex].ev_sig;
      const denomSig = await ws.cryptoApi.unblindDenominationSignature({
        planchet: {
          blindingKey: pc.blindingKey,
          denomPub: ncd.denomPub,
        },
        evSig,
      });
      const coin: CoinRecord = {
        blindingKey: pc.blindingKey,
        coinPriv: pc.coinPriv,
        coinPub: pc.coinPub,
        denomPubHash: ncd.denomPubHash,
        denomSig,
        exchangeBaseUrl: oldCoin.exchangeBaseUrl,
        status: CoinStatus.Fresh,
        coinSource: {
          type: CoinSourceType.Refresh,
          refreshGroupId,
          oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
        },
        sourceTransactionId: transactionId,
        coinEvHash: pc.coinEvHash,
        maxAge: pc.maxAge,
        ageCommitmentProof: pc.ageCommitmentProof,
        spendAllocation: undefined,
      };
      coins.push(coin);
    }
  }
  const transitionInfo = await ws.db
    .mktx((x) => [
      x.coins,
      x.denominations,
      x.coinAvailability,
      x.refreshGroups,
      x.refreshSessions,
    ])
    .runReadWrite(async (tx) => {
      const rg = await tx.refreshGroups.get(refreshGroupId);
      if (!rg) {
        logger.warn("no refresh session found");
        return;
      }
      const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
      if (!rs) {
        return;
      }
      const oldTxState = computeRefreshTransactionState(rg);
      rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
      updateGroupStatus(rg);
      for (const coin of coins) {
        await makeCoinAvailable(ws, tx, coin);
      }
      await makeCoinsVisible(ws, tx, transactionId);
      await tx.refreshGroups.put(rg);
      const newTxState = computeRefreshTransactionState(rg);
      return { oldTxState, newTxState };
    });
  notifyTransition(ws, transactionId, transitionInfo);
  logger.trace("refresh finished (end of reveal)");
}
export async function processRefreshGroup(
  ws: InternalWalletState,
  refreshGroupId: string,
  options: Record = {},
): Promise {
  logger.trace(`processing refresh group ${refreshGroupId}`);
  const refreshGroup = await ws.db
    .mktx((x) => [x.refreshGroups])
    .runReadOnly(async (tx) => tx.refreshGroups.get(refreshGroupId));
  if (!refreshGroup) {
    return TaskRunResult.finished();
  }
  if (refreshGroup.timestampFinished) {
    return TaskRunResult.finished();
  }
  // Process refresh sessions of the group in parallel.
  logger.trace("processing refresh sessions for old coins");
  let errors: TalerErrorDetail[] = [];
  let inShutdown = false;
  const ps = refreshGroup.oldCoinPubs.map((x, i) =>
    processRefreshSession(ws, refreshGroupId, i).catch((x) => {
      if (x instanceof CryptoApiStoppedError) {
        inShutdown = true;
        logger.info(
          "crypto API stopped while processing refresh group, probably the wallet is currently shutting down.",
        );
        return;
      }
      if (x instanceof TalerError) {
        logger.warn("process refresh session got exception (TalerError)");
        logger.warn(`exc ${x}`);
        logger.warn(`exc stack ${x.stack}`);
        logger.warn(`error detail: ${j2s(x.errorDetail)}`);
      } else {
        logger.warn("process refresh session got exception");
        logger.warn(`exc ${x}`);
        logger.warn(`exc stack ${x.stack}`);
      }
      errors.push(getErrorDetailFromException(x));
    }),
  );
  try {
    logger.info("waiting for refreshes");
    await Promise.all(ps);
    logger.info("refresh group finished");
  } catch (e) {
    logger.warn("process refresh sessions got exception");
    logger.warn(`exception: ${e}`);
  }
  if (inShutdown) {
    return TaskRunResult.pending();
  }
  if (errors.length > 0) {
    return {
      type: TaskRunResultType.Error,
      errorDetail: makeErrorDetail(
        TalerErrorCode.WALLET_REFRESH_GROUP_INCOMPLETE,
        {
          numErrors: errors.length,
          errors: errors.slice(0, 5),
        },
      ),
    };
  }
  return TaskRunResult.pending();
}
async function processRefreshSession(
  ws: InternalWalletState,
  refreshGroupId: string,
  coinIndex: number,
): Promise {
  logger.trace(
    `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
  );
  let { refreshGroup, refreshSession } = await ws.db
    .mktx((x) => [x.refreshGroups, x.refreshSessions])
    .runReadOnly(async (tx) => {
      const rg = await tx.refreshGroups.get(refreshGroupId);
      const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
      return {
        refreshGroup: rg,
        refreshSession: rs,
      };
    });
  if (!refreshGroup) {
    return;
  }
  if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) {
    return;
  }
  if (!refreshSession) {
    refreshSession = await provideRefreshSession(ws, refreshGroupId, coinIndex);
  }
  if (!refreshSession) {
    if (refreshGroup.statusPerCoin[coinIndex] !== RefreshCoinStatus.Finished) {
      throw Error(
        "BUG: refresh session was not created and coin not marked as finished",
      );
    }
    return;
  }
  if (refreshSession.norevealIndex === undefined) {
    await refreshMelt(ws, refreshGroupId, coinIndex);
  }
  await refreshReveal(ws, refreshGroupId, coinIndex);
}
export interface RefreshOutputInfo {
  outputPerCoin: AmountJson[];
}
export async function calculateRefreshOutput(
  ws: InternalWalletState,
  tx: GetReadOnlyAccess<{
    denominations: typeof WalletStoresV1.denominations;
    coins: typeof WalletStoresV1.coins;
    refreshGroups: typeof WalletStoresV1.refreshGroups;
    coinAvailability: typeof WalletStoresV1.coinAvailability;
  }>,
  currency: string,
  oldCoinPubs: CoinRefreshRequest[],
): Promise {
  const estimatedOutputPerCoin: AmountJson[] = [];
  const denomsPerExchange: Record = {};
  const getDenoms = async (
    exchangeBaseUrl: string,
  ): Promise => {
    if (denomsPerExchange[exchangeBaseUrl]) {
      return denomsPerExchange[exchangeBaseUrl];
    }
    const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
      .iter(exchangeBaseUrl)
      .filter((x) => {
        return isWithdrawableDenom(x, ws.config.testing.denomselAllowLate);
      });
    denomsPerExchange[exchangeBaseUrl] = allDenoms;
    return allDenoms;
  };
  for (const ocp of oldCoinPubs) {
    const coin = await tx.coins.get(ocp.coinPub);
    checkDbInvariant(!!coin, "coin must be in database");
    const denom = await ws.getDenomInfo(
      ws,
      tx,
      coin.exchangeBaseUrl,
      coin.denomPubHash,
    );
    checkDbInvariant(
      !!denom,
      "denomination for existing coin must be in database",
    );
    const refreshAmount = ocp.amount;
    const denoms = await getDenoms(coin.exchangeBaseUrl);
    const cost = getTotalRefreshCost(
      denoms,
      denom,
      Amounts.parseOrThrow(refreshAmount),
      ws.config.testing.denomselAllowLate,
    );
    const output = Amounts.sub(refreshAmount, cost).amount;
    estimatedOutputPerCoin.push(output);
  }
  return {
    outputPerCoin: estimatedOutputPerCoin,
  };
}
async function applyRefresh(
  ws: InternalWalletState,
  tx: GetReadWriteAccess<{
    denominations: typeof WalletStoresV1.denominations;
    coins: typeof WalletStoresV1.coins;
    refreshGroups: typeof WalletStoresV1.refreshGroups;
    coinAvailability: typeof WalletStoresV1.coinAvailability;
  }>,
  oldCoinPubs: CoinRefreshRequest[],
  refreshGroupId: string,
): Promise {
  for (const ocp of oldCoinPubs) {
    const coin = await tx.coins.get(ocp.coinPub);
    checkDbInvariant(!!coin, "coin must be in database");
    const denom = await ws.getDenomInfo(
      ws,
      tx,
      coin.exchangeBaseUrl,
      coin.denomPubHash,
    );
    checkDbInvariant(
      !!denom,
      "denomination for existing coin must be in database",
    );
    switch (coin.status) {
      case CoinStatus.Dormant:
        break;
      case CoinStatus.Fresh: {
        coin.status = CoinStatus.Dormant;
        const coinAv = await tx.coinAvailability.get([
          coin.exchangeBaseUrl,
          coin.denomPubHash,
          coin.maxAge,
        ]);
        checkDbInvariant(!!coinAv);
        checkDbInvariant(coinAv.freshCoinCount > 0);
        coinAv.freshCoinCount--;
        await tx.coinAvailability.put(coinAv);
        break;
      }
      case CoinStatus.FreshSuspended: {
        // For suspended coins, we don't have to adjust coin
        // availability, as they are not counted as available.
        coin.status = CoinStatus.Dormant;
        break;
      }
      default:
        assertUnreachable(coin.status);
    }
    if (!coin.spendAllocation) {
      coin.spendAllocation = {
        amount: Amounts.stringify(ocp.amount),
        // id: `txn:refresh:${refreshGroupId}`,
        id: constructTransactionIdentifier({
          tag: TransactionType.Refresh,
          refreshGroupId,
        }),
      };
    }
    await tx.coins.put(coin);
  }
}
/**
 * Create a refresh group for a list of coins.
 *
 * Refreshes the remaining amount on the coin, effectively capturing the remaining
 * value in the refresh group.
 *
 * The caller must also ensure that the coins that should be refreshed exist
 * in the current database transaction.
 */
export async function createRefreshGroup(
  ws: InternalWalletState,
  tx: GetReadWriteAccess<{
    denominations: typeof WalletStoresV1.denominations;
    coins: typeof WalletStoresV1.coins;
    refreshGroups: typeof WalletStoresV1.refreshGroups;
    coinAvailability: typeof WalletStoresV1.coinAvailability;
  }>,
  currency: string,
  oldCoinPubs: CoinRefreshRequest[],
  reason: RefreshReason,
  reasonDetails?: RefreshReasonDetails,
): Promise {
  const refreshGroupId = encodeCrock(getRandomBytes(32));
  const outInfo = await calculateRefreshOutput(ws, tx, currency, oldCoinPubs);
  const estimatedOutputPerCoin = outInfo.outputPerCoin;
  await applyRefresh(ws, tx, oldCoinPubs, refreshGroupId);
  const refreshGroup: RefreshGroupRecord = {
    operationStatus: RefreshOperationStatus.Pending,
    currency,
    timestampFinished: undefined,
    statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
    oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
    reasonDetails,
    reason,
    refreshGroupId,
    inputPerCoin: oldCoinPubs.map((x) => x.amount),
    expectedOutputPerCoin: estimatedOutputPerCoin.map((x) =>
      Amounts.stringify(x),
    ),
    timestampCreated: TalerPreciseTimestamp.now(),
  };
  if (oldCoinPubs.length == 0) {
    logger.warn("created refresh group with zero coins");
    refreshGroup.timestampFinished = TalerPreciseTimestamp.now();
    refreshGroup.operationStatus = RefreshOperationStatus.Finished;
  }
  await tx.refreshGroups.put(refreshGroup);
  logger.trace(`created refresh group ${refreshGroupId}`);
  return {
    refreshGroupId,
  };
}
/**
 * Timestamp after which the wallet would do the next check for an auto-refresh.
 */
function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime {
  const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
    d.stampExpireWithdraw,
  );
  const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
    d.stampExpireDeposit,
  );
  const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
  const deltaDiv = durationMul(delta, 0.75);
  return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
}
/**
 * Timestamp after which the wallet would do an auto-refresh.
 */
function getAutoRefreshExecuteThreshold(d: DenominationRecord): AbsoluteTime {
  const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
    d.stampExpireWithdraw,
  );
  const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
    d.stampExpireDeposit,
  );
  const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
  const deltaDiv = durationMul(delta, 0.5);
  return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
}
export async function autoRefresh(
  ws: InternalWalletState,
  exchangeBaseUrl: string,
): Promise {
  logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`);
  // We must make sure that the exchange is up-to-date so that
  // can refresh into new denominations.
  await updateExchangeFromUrl(ws, exchangeBaseUrl, {
    forceNow: true,
  });
  let minCheckThreshold = AbsoluteTime.addDuration(
    AbsoluteTime.now(),
    durationFromSpec({ days: 1 }),
  );
  await ws.db
    .mktx((x) => [
      x.coins,
      x.denominations,
      x.coinAvailability,
      x.refreshGroups,
      x.exchanges,
    ])
    .runReadWrite(async (tx) => {
      const exchange = await tx.exchanges.get(exchangeBaseUrl);
      if (!exchange || !exchange.detailsPointer) {
        return;
      }
      const coins = await tx.coins.indexes.byBaseUrl
        .iter(exchangeBaseUrl)
        .toArray();
      const refreshCoins: CoinRefreshRequest[] = [];
      for (const coin of coins) {
        if (coin.status !== CoinStatus.Fresh) {
          continue;
        }
        const denom = await tx.denominations.get([
          exchangeBaseUrl,
          coin.denomPubHash,
        ]);
        if (!denom) {
          logger.warn("denomination not in database");
          continue;
        }
        const executeThreshold = getAutoRefreshExecuteThreshold(denom);
        if (AbsoluteTime.isExpired(executeThreshold)) {
          refreshCoins.push({
            coinPub: coin.coinPub,
            amount: denom.value,
          });
        } else {
          const checkThreshold = getAutoRefreshCheckThreshold(denom);
          minCheckThreshold = AbsoluteTime.min(
            minCheckThreshold,
            checkThreshold,
          );
        }
      }
      if (refreshCoins.length > 0) {
        const res = await createRefreshGroup(
          ws,
          tx,
          exchange.detailsPointer?.currency,
          refreshCoins,
          RefreshReason.Scheduled,
        );
        logger.trace(
          `created refresh group for auto-refresh (${res.refreshGroupId})`,
        );
      }
      //      logger.trace(
      //        `current wallet time: ${AbsoluteTime.toIsoString(AbsoluteTime.now())}`,
      //      );
      logger.trace(
        `next refresh check at ${AbsoluteTime.toIsoString(minCheckThreshold)}`,
      );
      exchange.nextRefreshCheckStampMs =
        AbsoluteTime.toStampMs(minCheckThreshold);
      await tx.exchanges.put(exchange);
    });
  return TaskRunResult.finished();
}
export function computeRefreshTransactionState(
  rg: RefreshGroupRecord,
): TransactionState {
  switch (rg.operationStatus) {
    case RefreshOperationStatus.Finished:
      return {
        major: TransactionMajorState.Done,
      };
    case RefreshOperationStatus.Failed:
      return {
        major: TransactionMajorState.Failed,
      };
    case RefreshOperationStatus.Pending:
      return {
        major: TransactionMajorState.Pending,
      };
    case RefreshOperationStatus.Suspended:
      return {
        major: TransactionMajorState.Suspended,
      };
  }
}
export function computeRefreshTransactionActions(
  rg: RefreshGroupRecord,
): TransactionAction[] {
  switch (rg.operationStatus) {
    case RefreshOperationStatus.Finished:
      return [TransactionAction.Delete];
    case RefreshOperationStatus.Failed:
      return [TransactionAction.Delete];
    case RefreshOperationStatus.Pending:
      return [TransactionAction.Suspend, TransactionAction.Fail];
    case RefreshOperationStatus.Suspended:
      return [TransactionAction.Resume, TransactionAction.Fail];
  }
}
export async function suspendRefreshGroup(
  ws: InternalWalletState,
  refreshGroupId: string,
): Promise {
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Refresh,
    refreshGroupId,
  });
  let res = await ws.db
    .mktx((x) => [x.refreshGroups])
    .runReadWrite(async (tx) => {
      const dg = await tx.refreshGroups.get(refreshGroupId);
      if (!dg) {
        logger.warn(
          `can't suspend refresh group, refreshGroupId=${refreshGroupId} not found`,
        );
        return undefined;
      }
      const oldState = computeRefreshTransactionState(dg);
      switch (dg.operationStatus) {
        case RefreshOperationStatus.Finished:
          return undefined;
        case RefreshOperationStatus.Pending: {
          dg.operationStatus = RefreshOperationStatus.Suspended;
          await tx.refreshGroups.put(dg);
          return {
            oldTxState: oldState,
            newTxState: computeRefreshTransactionState(dg),
          };
        }
        case RefreshOperationStatus.Suspended:
          return undefined;
      }
      return undefined;
    });
  if (res) {
    ws.notify({
      type: NotificationType.TransactionStateTransition,
      transactionId,
      oldTxState: res.oldTxState,
      newTxState: res.newTxState,
    });
  }
}
export async function resumeRefreshGroup(
  ws: InternalWalletState,
  refreshGroupId: string,
): Promise {
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Refresh,
    refreshGroupId,
  });
  const transitionInfo = await ws.db
    .mktx((x) => [x.refreshGroups])
    .runReadWrite(async (tx) => {
      const dg = await tx.refreshGroups.get(refreshGroupId);
      if (!dg) {
        logger.warn(
          `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`,
        );
        return;
      }
      const oldState = computeRefreshTransactionState(dg);
      switch (dg.operationStatus) {
        case RefreshOperationStatus.Finished:
          return;
        case RefreshOperationStatus.Pending: {
          return;
        }
        case RefreshOperationStatus.Suspended:
          dg.operationStatus = RefreshOperationStatus.Pending;
          await tx.refreshGroups.put(dg);
          return {
            oldTxState: oldState,
            newTxState: computeRefreshTransactionState(dg),
          };
      }
      return undefined;
    });
  ws.workAvailable.trigger();
  notifyTransition(ws, transactionId, transitionInfo);
}
export async function failRefreshGroup(
  ws: InternalWalletState,
  refreshGroupId: string,
): Promise {
  throw Error("action cancel-aborting not allowed on refreshes");
}
export async function abortRefreshGroup(
  ws: InternalWalletState,
  refreshGroupId: string,
): Promise {
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Refresh,
    refreshGroupId,
  });
  const transitionInfo = await ws.db
    .mktx((x) => [x.refreshGroups])
    .runReadWrite(async (tx) => {
      const dg = await tx.refreshGroups.get(refreshGroupId);
      if (!dg) {
        logger.warn(
          `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`,
        );
        return;
      }
      const oldState = computeRefreshTransactionState(dg);
      let newStatus: RefreshOperationStatus | undefined;
      switch (dg.operationStatus) {
        case RefreshOperationStatus.Finished:
          break;
        case RefreshOperationStatus.Pending:
        case RefreshOperationStatus.Suspended:
          newStatus = RefreshOperationStatus.Failed;
          break;
        case RefreshOperationStatus.Failed:
          break;
        default:
          assertUnreachable(dg.operationStatus);
      }
      if (newStatus) {
        dg.operationStatus = newStatus;
        await tx.refreshGroups.put(dg);
      }
      return {
        oldTxState: oldState,
        newTxState: computeRefreshTransactionState(dg),
      };
    });
  ws.workAvailable.trigger();
  notifyTransition(ws, transactionId, transitionInfo);
}