/*
 This file is part of GNU Taler
 (C) 2019-2025 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 <http://www.gnu.org/licenses/>
 */

/**
 * Implementation of the recoup operation, which allows to recover the
 * value of coins held in a revoked denomination.
 *
 * @author Florian Dold <dold@taler.net>
 */

/**
 * Imports.
 */
import {
  Amounts,
  CoinStatus,
  Logger,
  RefreshReason,
  TalerPreciseTimestamp,
  Transaction,
  TransactionIdStr,
  TransactionType,
  URL,
  WalletNotification,
  checkDbInvariant,
  codecForRecoupConfirmation,
  codecForReserveStatus,
  encodeCrock,
  getRandomBytes,
  j2s,
} from "@gnu-taler/taler-util";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
import {
  PendingTaskType,
  TaskIdStr,
  TaskRunResult,
  TransactionContext,
  cancelableFetch,
  constructTaskIdentifier,
} from "./common.js";
import {
  CoinRecord,
  CoinSourceType,
  RecoupGroupRecord,
  RecoupOperationStatus,
  RefreshCoinSource,
  WalletDbAllStoresReadOnlyTransaction,
  WalletDbReadWriteTransaction,
  WithdrawCoinSource,
  WithdrawalGroupStatus,
  WithdrawalRecordType,
  timestampPreciseToDb,
} from "./db.js";
import { createRefreshGroup } from "./refresh.js";
import { constructTransactionIdentifier } from "./transactions.js";
import { WalletExecutionContext, getDenomInfo } from "./wallet.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.
 */
export async function putGroupAsFinished(
  wex: WalletExecutionContext,
  tx: WalletDbReadWriteTransaction<
    [
      "recoupGroups",
      "denominations",
      "refreshGroups",
      "coins",
      "exchanges",
      "transactionsMeta",
    ]
  >,
  recoupGroup: RecoupGroupRecord,
  coinIdx: number,
): Promise<void> {
  const ctx = new RecoupTransactionContext(wex, recoupGroup.recoupGroupId);
  logger.trace(
    `setting coin ${coinIdx} of ${recoupGroup.coinPubs.length} as finished`,
  );
  if (recoupGroup.timestampFinished) {
    return;
  }
  recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
  await tx.recoupGroups.put(recoupGroup);
  await ctx.updateTransactionMeta(tx);
}

async function recoupRewardCoin(
  wex: WalletExecutionContext,
  recoupGroupId: string,
  coinIdx: number,
  coin: CoinRecord,
): Promise<void> {
  // 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 wex.db.runReadWriteTx(
    {
      storeNames: [
        "recoupGroups",
        "denominations",
        "refreshGroups",
        "coins",
        "exchanges",
        "transactionsMeta",
      ],
    },
    async (tx) => {
      const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
      if (!recoupGroup) {
        return;
      }
      if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
        return;
      }
      await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
    },
  );
}

async function recoupRefreshCoin(
  wex: WalletExecutionContext,
  recoupGroupId: string,
  coinIdx: number,
  coin: CoinRecord,
  cs: RefreshCoinSource,
): Promise<void> {
  const d = await wex.db.runReadOnlyTx(
    { storeNames: ["coins", "denominations"] },
    async (tx) => {
      const denomInfo = await getDenomInfo(
        wex,
        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;
  }

  const recoupRequest = await wex.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 cancelableFetch(wex, reqUrl, {
    method: "POST",
    body: 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 wex.db.runReadWriteTx(
    {
      storeNames: [
        "coins",
        "denominations",
        "recoupGroups",
        "refreshGroups",
        "transactionsMeta",
        "exchanges",
      ],
    },
    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 getDenomInfo(
        wex,
        tx,
        oldCoin.exchangeBaseUrl,
        oldCoin.denomPubHash,
      );
      const revokedCoinDenom = await getDenomInfo(
        wex,
        tx,
        revokedCoin.exchangeBaseUrl,
        revokedCoin.denomPubHash,
      );
      checkDbInvariant(
        !!oldCoinDenom,
        `no denom for coin, hash ${oldCoin.denomPubHash}`,
      );
      checkDbInvariant(
        !!revokedCoinDenom,
        `no revoked denom for coin, hash ${revokedCoin.denomPubHash}`,
      );
      revokedCoin.status = CoinStatus.Dormant;
      // FIXME: Schedule recoup for the sum of refreshes, based on the coin event history.
      // recoupGroup.scheduleRefreshCoins.push({
      //   coinPub: oldCoin.coinPub,
      //   amount: Amounts.stringify(refreshAmount),
      // });
      await tx.coins.put(revokedCoin);
      await tx.coins.put(oldCoin);
      await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
    },
  );
}

export async function recoupWithdrawCoin(
  wex: WalletExecutionContext,
  recoupGroupId: string,
  coinIdx: number,
  coin: CoinRecord,
  cs: WithdrawCoinSource,
): Promise<void> {
  const reservePub = cs.reservePub;
  const denomInfo = await wex.db.runReadOnlyTx(
    { storeNames: ["denominations"] },
    async (tx) => {
      const denomInfo = await getDenomInfo(
        wex,
        tx,
        coin.exchangeBaseUrl,
        coin.denomPubHash,
      );
      return denomInfo;
    },
  );
  if (!denomInfo) {
    // FIXME:  We should at least emit some pending operation / warning for this?
    return;
  }

  const recoupRequest = await wex.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 cancelableFetch(wex, reqUrl, {
    method: "POST",
    body: 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 wex.db.runReadWriteTx(
    {
      storeNames: [
        "coins",
        "denominations",
        "recoupGroups",
        "refreshGroups",
        "transactionsMeta",
        "exchanges",
      ],
    },
    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(wex, tx, recoupGroup, coinIdx);
    },
  );
}

export async function processRecoupGroup(
  wex: WalletExecutionContext,
  recoupGroupId: string,
): Promise<TaskRunResult> {
  if (!wex.ws.networkAvailable) {
    return TaskRunResult.networkRequired();
  }

  let recoupGroup = await wex.db.runReadOnlyTx(
    { storeNames: ["recoupGroups"] },
    async (tx) => {
      return tx.recoupGroups.get(recoupGroupId);
    },
  );
  if (!recoupGroup) {
    return TaskRunResult.finished();
  }
  if (recoupGroup.timestampFinished) {
    logger.trace("recoup group finished");
    return TaskRunResult.finished();
  }
  const ps = recoupGroup.coinPubs.map(async (x, i) => {
    try {
      await processRecoupForCoin(wex, recoupGroupId, i);
    } catch (e) {
      logger.warn(`processRecoup failed: ${e}`);
      throw e;
    }
  });
  await Promise.all(ps);

  recoupGroup = await wex.db.runReadOnlyTx(
    { storeNames: ["recoupGroups"] },
    async (tx) => {
      return tx.recoupGroups.get(recoupGroupId);
    },
  );
  if (!recoupGroup) {
    return TaskRunResult.finished();
  }

  for (const b of recoupGroup.recoupFinishedPerCoin) {
    if (!b) {
      return TaskRunResult.finished();
    }
  }

  logger.info("all recoups of recoup group are finished");

  const reserveSet = new Set<string>();
  const reservePrivMap: Record<string, string> = {};
  for (let i = 0; i < recoupGroup.coinPubs.length; i++) {
    const coinPub = recoupGroup.coinPubs[i];
    await wex.db.runReadOnlyTx(
      { storeNames: ["coins", "reserves"] },
      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 cancelableFetch(wex, reserveUrl);

    const result = await readSuccessResponseJsonOrThrow(
      resp,
      codecForReserveStatus(),
    );
    await internalCreateWithdrawalGroup(wex, {
      amount: Amounts.parseOrThrow(result.balance),
      exchangeBaseUrl: recoupGroup.exchangeBaseUrl,
      reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
      reserveKeyPair: {
        pub: reservePub,
        priv: reservePrivMap[reservePub],
      },
      wgInfo: {
        withdrawalType: WithdrawalRecordType.Recoup,
      },
    });
  }

  const ctx = new RecoupTransactionContext(wex, recoupGroupId);

  await wex.db.runReadWriteTx(
    {
      storeNames: [
        "coinAvailability",
        "coinHistory",
        "coins",
        "denominations",
        "recoupGroups",
        "refreshGroups",
        "refreshSessions",
        "transactionsMeta",
        "exchanges",
      ],
    },
    async (tx) => {
      const rg2 = await tx.recoupGroups.get(recoupGroupId);
      if (!rg2) {
        return;
      }
      rg2.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
      rg2.operationStatus = RecoupOperationStatus.Finished;
      if (rg2.scheduleRefreshCoins.length > 0) {
        await createRefreshGroup(
          wex,
          tx,
          Amounts.currencyOf(rg2.scheduleRefreshCoins[0].amount),
          rg2.scheduleRefreshCoins,
          RefreshReason.Recoup,
          constructTransactionIdentifier({
            tag: TransactionType.Recoup,
            recoupGroupId: rg2.recoupGroupId,
          }),
        );
      }
      await tx.recoupGroups.put(rg2);
      await ctx.updateTransactionMeta(tx);
    },
  );
  return TaskRunResult.finished();
}

export class RecoupTransactionContext implements TransactionContext {
  public transactionId: TransactionIdStr;
  public taskId: TaskIdStr;

  constructor(
    public wex: WalletExecutionContext,
    private recoupGroupId: string,
  ) {
    this.transactionId = constructTransactionIdentifier({
      tag: TransactionType.Recoup,
      recoupGroupId,
    });
    this.taskId = constructTaskIdentifier({
      tag: PendingTaskType.Recoup,
      recoupGroupId,
    });
  }

  async updateTransactionMeta(
    tx: WalletDbReadWriteTransaction<
      ["recoupGroups", "exchanges", "transactionsMeta"]
    >,
  ): Promise<void> {
    const recoupRec = await tx.recoupGroups.get(this.recoupGroupId);
    if (!recoupRec) {
      await tx.transactionsMeta.delete(this.transactionId);
      return;
    }
    const exch = await tx.exchanges.get(recoupRec.exchangeBaseUrl);
    if (!exch || !exch.detailsPointer) {
      await tx.transactionsMeta.delete(this.transactionId);
      return;
    }
    await tx.transactionsMeta.put({
      transactionId: this.transactionId,
      status: recoupRec.operationStatus,
      timestamp: recoupRec.timestampStarted,
      currency: exch.detailsPointer?.currency,
      exchanges: [recoupRec.exchangeBaseUrl],
    });
  }

  abortTransaction(): Promise<void> {
    throw new Error("Method not implemented.");
  }

  suspendTransaction(): Promise<void> {
    throw new Error("Method not implemented.");
  }

  resumeTransaction(): Promise<void> {
    throw new Error("Method not implemented.");
  }

  failTransaction(): Promise<void> {
    throw new Error("Method not implemented.");
  }

  async deleteTransaction(): Promise<void> {
    const res = await this.wex.db.runReadWriteTx(
      {
        storeNames: [
          "recoupGroups",
          "tombstones",
          "exchanges",
          "transactionsMeta",
        ],
      },
      async (tx) => {
        return this.deleteTransactionInTx(tx);
      },
    );
    for (const notif of res.notifs) {
      this.wex.ws.notify(notif);
    }
  }

  async deleteTransactionInTx(
    tx: WalletDbReadWriteTransaction<
      ["recoupGroups", "tombstones", "exchanges", "transactionsMeta"]
    >,
  ): Promise<{ notifs: WalletNotification[] }> {
    const notifs: WalletNotification[] = [];
    const rec = await tx.recoupGroups.get(this.recoupGroupId);
    if (!rec) {
      return { notifs };
    }
    await tx.recoupGroups.delete(this.recoupGroupId);
    await this.updateTransactionMeta(tx);
    return { notifs };
  }

  lookupFullTransaction(
    tx: WalletDbAllStoresReadOnlyTransaction,
  ): Promise<Transaction | undefined> {
    throw new Error("Method not implemented.");
  }
}

export async function createRecoupGroup(
  wex: WalletExecutionContext,
  tx: WalletDbReadWriteTransaction<
    [
      "recoupGroups",
      "denominations",
      "refreshGroups",
      "coins",
      "exchanges",
      "transactionsMeta",
    ]
  >,
  exchangeBaseUrl: string,
  coinPubs: string[],
): Promise<string> {
  const recoupGroupId = encodeCrock(getRandomBytes(32));

  const ctx = new RecoupTransactionContext(wex, recoupGroupId);
  const recoupGroup: RecoupGroupRecord = {
    recoupGroupId,
    exchangeBaseUrl: exchangeBaseUrl,
    coinPubs: coinPubs,
    timestampFinished: undefined,
    timestampStarted: timestampPreciseToDb(TalerPreciseTimestamp.now()),
    recoupFinishedPerCoin: coinPubs.map(() => false),
    scheduleRefreshCoins: [],
    operationStatus: RecoupOperationStatus.Pending,
  };

  for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
    const coinPub = coinPubs[coinIdx];
    const coin = await tx.coins.get(coinPub);
    if (!coin) {
      await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
      continue;
    }
    await tx.coins.put(coin);
  }

  await tx.recoupGroups.put(recoupGroup);
  await ctx.updateTransactionMeta(tx);

  wex.taskScheduler.startShepherdTask(ctx.taskId);

  return recoupGroupId;
}

/**
 * Run the recoup protocol for a single coin in a recoup group.
 */
async function processRecoupForCoin(
  wex: WalletExecutionContext,
  recoupGroupId: string,
  coinIdx: number,
): Promise<void> {
  const coin = await wex.db.runReadOnlyTx(
    { storeNames: ["coins", "recoupGroups"] },
    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.Reward:
      return recoupRewardCoin(wex, recoupGroupId, coinIdx, coin);
    case CoinSourceType.Refresh:
      return recoupRefreshCoin(wex, recoupGroupId, coinIdx, coin, cs);
    case CoinSourceType.Withdraw:
      return recoupWithdrawCoin(wex, recoupGroupId, coinIdx, coin, cs);
    default:
      throw Error("unknown coin source type");
  }
}
