/*
 This file is part of GNU Taler
 (C) 2019-2020 Taler Systems SA
 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 
 */
/**
 * Implementation of the recoup operation, which allows to recover the
 * value of coins held in a revoked denomination.
 *
 * @author Florian Dold 
 */
/**
 * Imports.
 */
import {
  Amounts,
  codecForRecoupConfirmation,
  codecForReserveStatus,
  CoinStatus,
  encodeCrock,
  getRandomBytes,
  j2s,
  Logger,
  NotificationType,
  RefreshReason,
  TalerProtocolTimestamp,
  URL,
} from "@gnu-taler/taler-util";
import {
  CoinRecord,
  CoinSourceType,
  RecoupGroupRecord,
  RefreshCoinSource,
  WalletStoresV1,
  WithdrawalGroupStatus,
  WithdrawalRecordType,
  WithdrawCoinSource,
} from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { checkDbInvariant } from "../util/invariants.js";
import { GetReadWriteAccess } from "../util/query.js";
import {
  OperationAttemptResult,
  unwrapOperationHandlerResultOrThrow,
} from "../util/retries.js";
import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
import { internalCreateWithdrawalGroup } from "./withdraw.js";
const logger = new Logger("operations/recoup.ts");
/**
 * Store a recoup group record in the database after marking
 * a coin in the group as finished.
 */
async function putGroupAsFinished(
  ws: InternalWalletState,
  tx: GetReadWriteAccess<{
    recoupGroups: typeof WalletStoresV1.recoupGroups;
    denominations: typeof WalletStoresV1.denominations;
    refreshGroups: typeof WalletStoresV1.refreshGroups;
    coins: typeof WalletStoresV1.coins;
  }>,
  recoupGroup: RecoupGroupRecord,
  coinIdx: number,
): Promise {
  logger.trace(
    `setting coin ${coinIdx} of ${recoupGroup.coinPubs.length} as finished`,
  );
  if (recoupGroup.timestampFinished) {
    return;
  }
  recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
  await tx.recoupGroups.put(recoupGroup);
}
async function recoupTipCoin(
  ws: InternalWalletState,
  recoupGroupId: string,
  coinIdx: number,
  coin: CoinRecord,
): Promise {
  // We can't really recoup a coin we got via tipping.
  // Thus we just put the coin to sleep.
  // FIXME: somehow report this to the user
  await ws.db
    .mktx((stores) => [
      stores.recoupGroups,
      stores.denominations,
      stores.refreshGroups,
      stores.coins,
    ])
    .runReadWrite(async (tx) => {
      const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
      if (!recoupGroup) {
        return;
      }
      if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
        return;
      }
      await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
    });
}
async function recoupWithdrawCoin(
  ws: InternalWalletState,
  recoupGroupId: string,
  coinIdx: number,
  coin: CoinRecord,
  cs: WithdrawCoinSource,
): Promise {
  const reservePub = cs.reservePub;
  const denomInfo = await ws.db
    .mktx((x) => [x.denominations])
    .runReadOnly(async (tx) => {
      const denomInfo = await ws.getDenomInfo(
        ws,
        tx,
        coin.exchangeBaseUrl,
        coin.denomPubHash,
      );
      return denomInfo;
    });
  if (!denomInfo) {
    // FIXME:  We should at least emit some pending operation / warning for this?
    return;
  }
  ws.notify({
    type: NotificationType.RecoupStarted,
  });
  const recoupRequest = await ws.cryptoApi.createRecoupRequest({
    blindingKey: coin.blindingKey,
    coinPriv: coin.coinPriv,
    coinPub: coin.coinPub,
    denomPub: denomInfo.denomPub,
    denomPubHash: coin.denomPubHash,
    denomSig: coin.denomSig,
  });
  const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
  logger.trace(`requesting recoup via ${reqUrl.href}`);
  const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
  const recoupConfirmation = await readSuccessResponseJsonOrThrow(
    resp,
    codecForRecoupConfirmation(),
  );
  logger.trace(`got recoup confirmation ${j2s(recoupConfirmation)}`);
  if (recoupConfirmation.reserve_pub !== reservePub) {
    throw Error(`Coin's reserve doesn't match reserve on recoup`);
  }
  // FIXME: verify that our expectations about the amount match
  await ws.db
    .mktx((x) => [x.coins, x.denominations, x.recoupGroups, x.refreshGroups])
    .runReadWrite(async (tx) => {
      const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
      if (!recoupGroup) {
        return;
      }
      if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
        return;
      }
      const updatedCoin = await tx.coins.get(coin.coinPub);
      if (!updatedCoin) {
        return;
      }
      updatedCoin.status = CoinStatus.Dormant;
      await tx.coins.put(updatedCoin);
      await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
    });
  ws.notify({
    type: NotificationType.RecoupFinished,
  });
}
async function recoupRefreshCoin(
  ws: InternalWalletState,
  recoupGroupId: string,
  coinIdx: number,
  coin: CoinRecord,
  cs: RefreshCoinSource,
): Promise {
  const d = await ws.db
    .mktx((x) => [x.coins, x.denominations])
    .runReadOnly(async (tx) => {
      const denomInfo = await ws.getDenomInfo(
        ws,
        tx,
        coin.exchangeBaseUrl,
        coin.denomPubHash,
      );
      if (!denomInfo) {
        return;
      }
      return { denomInfo };
    });
  if (!d) {
    // FIXME:  We should at least emit some pending operation / warning for this?
    return;
  }
  ws.notify({
    type: NotificationType.RecoupStarted,
  });
  const recoupRequest = await ws.cryptoApi.createRecoupRefreshRequest({
    blindingKey: coin.blindingKey,
    coinPriv: coin.coinPriv,
    coinPub: coin.coinPub,
    denomPub: d.denomInfo.denomPub,
    denomPubHash: coin.denomPubHash,
    denomSig: coin.denomSig,
  });
  const reqUrl = new URL(
    `/coins/${coin.coinPub}/recoup-refresh`,
    coin.exchangeBaseUrl,
  );
  logger.trace(`making recoup request for ${coin.coinPub}`);
  const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
  const recoupConfirmation = await readSuccessResponseJsonOrThrow(
    resp,
    codecForRecoupConfirmation(),
  );
  if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) {
    throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`);
  }
  await ws.db
    .mktx((x) => [x.coins, x.denominations, x.recoupGroups, x.refreshGroups])
    .runReadWrite(async (tx) => {
      const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
      if (!recoupGroup) {
        return;
      }
      if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
        return;
      }
      const oldCoin = await tx.coins.get(cs.oldCoinPub);
      const revokedCoin = await tx.coins.get(coin.coinPub);
      if (!revokedCoin) {
        logger.warn("revoked coin for recoup not found");
        return;
      }
      if (!oldCoin) {
        logger.warn("refresh old coin for recoup not found");
        return;
      }
      const oldCoinDenom = await ws.getDenomInfo(
        ws,
        tx,
        oldCoin.exchangeBaseUrl,
        oldCoin.denomPubHash,
      );
      const revokedCoinDenom = await ws.getDenomInfo(
        ws,
        tx,
        revokedCoin.exchangeBaseUrl,
        revokedCoin.denomPubHash,
      );
      checkDbInvariant(!!oldCoinDenom);
      checkDbInvariant(!!revokedCoinDenom);
      revokedCoin.status = CoinStatus.Dormant;
      if (!revokedCoin.spendAllocation) {
        // We don't know what happened to this coin
        logger.error(
          `can't refresh-recoup coin ${revokedCoin.coinPub}, no spendAllocation known`,
        );
      } else {
        let residualAmount = Amounts.sub(
          revokedCoinDenom.value,
          revokedCoin.spendAllocation.amount,
        ).amount;
        recoupGroup.scheduleRefreshCoins.push({
          coinPub: oldCoin.coinPub,
          amount: Amounts.stringify(residualAmount),
        });
      }
      await tx.coins.put(revokedCoin);
      await tx.coins.put(oldCoin);
      await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
    });
}
export async function processRecoupGroup(
  ws: InternalWalletState,
  recoupGroupId: string,
  options: {
    forceNow?: boolean;
  } = {},
): Promise {
  await unwrapOperationHandlerResultOrThrow(
    await processRecoupGroupHandler(ws, recoupGroupId, options),
  );
  return;
}
export async function processRecoupGroupHandler(
  ws: InternalWalletState,
  recoupGroupId: string,
  options: {
    forceNow?: boolean;
  } = {},
): Promise {
  const forceNow = options.forceNow ?? false;
  let recoupGroup = await ws.db
    .mktx((x) => [x.recoupGroups])
    .runReadOnly(async (tx) => {
      return tx.recoupGroups.get(recoupGroupId);
    });
  if (!recoupGroup) {
    return OperationAttemptResult.finishedEmpty();
  }
  if (recoupGroup.timestampFinished) {
    logger.trace("recoup group finished");
    return OperationAttemptResult.finishedEmpty();
  }
  const ps = recoupGroup.coinPubs.map(async (x, i) => {
    try {
      await processRecoup(ws, recoupGroupId, i);
    } catch (e) {
      logger.warn(`processRecoup failed: ${e}`);
      throw e;
    }
  });
  await Promise.all(ps);
  recoupGroup = await ws.db
    .mktx((x) => [x.recoupGroups])
    .runReadOnly(async (tx) => {
      return tx.recoupGroups.get(recoupGroupId);
    });
  if (!recoupGroup) {
    return OperationAttemptResult.finishedEmpty();
  }
  for (const b of recoupGroup.recoupFinishedPerCoin) {
    if (!b) {
      return OperationAttemptResult.finishedEmpty();
    }
  }
  logger.info("all recoups of recoup group are finished");
  const reserveSet = new Set();
  const reservePrivMap: Record = {};
  for (let i = 0; i < recoupGroup.coinPubs.length; i++) {
    const coinPub = recoupGroup.coinPubs[i];
    await ws.db
      .mktx((x) => [x.coins, x.reserves])
      .runReadOnly(async (tx) => {
        const coin = await tx.coins.get(coinPub);
        if (!coin) {
          throw Error(`Coin ${coinPub} not found, can't request recoup`);
        }
        if (coin.coinSource.type === CoinSourceType.Withdraw) {
          const reserve = await tx.reserves.indexes.byReservePub.get(
            coin.coinSource.reservePub,
          );
          if (!reserve) {
            return;
          }
          reserveSet.add(coin.coinSource.reservePub);
          reservePrivMap[coin.coinSource.reservePub] = reserve.reservePriv;
        }
      });
  }
  for (const reservePub of reserveSet) {
    const reserveUrl = new URL(
      `reserves/${reservePub}`,
      recoupGroup.exchangeBaseUrl,
    );
    logger.info(`querying reserve status for recoup via ${reserveUrl}`);
    const resp = await ws.http.get(reserveUrl.href);
    const result = await readSuccessResponseJsonOrThrow(
      resp,
      codecForReserveStatus(),
    );
    await internalCreateWithdrawalGroup(ws, {
      amount: Amounts.parseOrThrow(result.balance),
      exchangeBaseUrl: recoupGroup.exchangeBaseUrl,
      reserveStatus: WithdrawalGroupStatus.QueryingStatus,
      reserveKeyPair: {
        pub: reservePub,
        priv: reservePrivMap[reservePub],
      },
      wgInfo: {
        withdrawalType: WithdrawalRecordType.Recoup,
      },
    });
  }
  await ws.db
    .mktx((x) => [
      x.recoupGroups,
      x.coinAvailability,
      x.denominations,
      x.refreshGroups,
      x.coins,
    ])
    .runReadWrite(async (tx) => {
      const rg2 = await tx.recoupGroups.get(recoupGroupId);
      if (!rg2) {
        return;
      }
      rg2.timestampFinished = TalerProtocolTimestamp.now();
      if (rg2.scheduleRefreshCoins.length > 0) {
        const refreshGroupId = await createRefreshGroup(
          ws,
          tx,
          rg2.scheduleRefreshCoins,
          RefreshReason.Recoup,
        );
        processRefreshGroup(ws, refreshGroupId.refreshGroupId).catch((e) => {
          logger.error(`error while refreshing after recoup ${e}`);
        });
      }
      await tx.recoupGroups.put(rg2);
    });
  return OperationAttemptResult.finishedEmpty();
}
export async function createRecoupGroup(
  ws: InternalWalletState,
  tx: GetReadWriteAccess<{
    recoupGroups: typeof WalletStoresV1.recoupGroups;
    denominations: typeof WalletStoresV1.denominations;
    refreshGroups: typeof WalletStoresV1.refreshGroups;
    coins: typeof WalletStoresV1.coins;
  }>,
  exchangeBaseUrl: string,
  coinPubs: string[],
): Promise {
  const recoupGroupId = encodeCrock(getRandomBytes(32));
  const recoupGroup: RecoupGroupRecord = {
    recoupGroupId,
    exchangeBaseUrl: exchangeBaseUrl,
    coinPubs: coinPubs,
    timestampFinished: undefined,
    timestampStarted: TalerProtocolTimestamp.now(),
    recoupFinishedPerCoin: coinPubs.map(() => false),
    scheduleRefreshCoins: [],
  };
  for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
    const coinPub = coinPubs[coinIdx];
    const coin = await tx.coins.get(coinPub);
    if (!coin) {
      await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
      continue;
    }
    await tx.coins.put(coin);
  }
  await tx.recoupGroups.put(recoupGroup);
  return recoupGroupId;
}
/**
 * Run the recoup protocol for a single coin in a recoup group.
 */
async function processRecoup(
  ws: InternalWalletState,
  recoupGroupId: string,
  coinIdx: number,
): Promise {
  const coin = await ws.db
    .mktx((x) => [x.recoupGroups, x.coins])
    .runReadOnly(async (tx) => {
      const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
      if (!recoupGroup) {
        return;
      }
      if (recoupGroup.timestampFinished) {
        return;
      }
      if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
        return;
      }
      const coinPub = recoupGroup.coinPubs[coinIdx];
      const coin = await tx.coins.get(coinPub);
      if (!coin) {
        throw Error(`Coin ${coinPub} not found, can't request recoup`);
      }
      return coin;
    });
  if (!coin) {
    return;
  }
  const cs = coin.coinSource;
  switch (cs.type) {
    case CoinSourceType.Tip:
      return recoupTipCoin(ws, recoupGroupId, coinIdx, coin);
    case CoinSourceType.Refresh:
      return recoupRefreshCoin(ws, recoupGroupId, coinIdx, coin, cs);
    case CoinSourceType.Withdraw:
      return recoupWithdrawCoin(ws, recoupGroupId, coinIdx, coin, cs);
    default:
      throw Error("unknown coin source type");
  }
}