/*
 This file is part of GNU Taler
 (C) 2022 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 
 */
/**
 * Imports.
 */
import {
  AgeCommitmentProof,
  AmountJson,
  AmountString,
  Amounts,
  Codec,
  CoinPublicKeyString,
  CoinStatus,
  HttpStatusCode,
  Logger,
  NotificationType,
  PayPeerInsufficientBalanceDetails,
  TalerError,
  TalerErrorCode,
  TalerProtocolTimestamp,
  UnblindedSignature,
  buildCodecForObject,
  codecForAmountString,
  codecForTimestamp,
  codecOptional,
  j2s,
  strcmp,
} from "@gnu-taler/taler-util";
import { SpendCoinDetails } from "../crypto/cryptoImplementation.js";
import {
  DenominationRecord,
  KycPendingInfo,
  KycUserType,
  PeerPushPaymentCoinSelection,
  ReserveRecord,
} from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { checkDbInvariant } from "../util/invariants.js";
import { getPeerPaymentBalanceDetailsInTx } from "./balance.js";
import { getTotalRefreshCost } from "./refresh.js";
const logger = new Logger("operations/peer-to-peer.ts");
interface SelectedPeerCoin {
  coinPub: string;
  coinPriv: string;
  contribution: AmountString;
  denomPubHash: string;
  denomSig: UnblindedSignature;
  ageCommitmentProof: AgeCommitmentProof | undefined;
}
interface PeerCoinSelectionDetails {
  exchangeBaseUrl: string;
  /**
   * Info of Coins that were selected.
   */
  coins: SelectedPeerCoin[];
  /**
   * How much of the deposit fees is the customer paying?
   */
  depositFees: AmountJson;
}
/**
 * Information about a selected coin for peer to peer payments.
 */
interface CoinInfo {
  /**
   * Public key of the coin.
   */
  coinPub: string;
  coinPriv: string;
  /**
   * Deposit fee for the coin.
   */
  feeDeposit: AmountJson;
  value: AmountJson;
  denomPubHash: string;
  denomSig: UnblindedSignature;
  maxAge: number;
  ageCommitmentProof?: AgeCommitmentProof;
}
export type SelectPeerCoinsResult =
  | { type: "success"; result: PeerCoinSelectionDetails }
  | {
      type: "failure";
      insufficientBalanceDetails: PayPeerInsufficientBalanceDetails;
    };
/**
 * Get information about the coin selected for signatures
 * @param ws
 * @param csel
 * @returns
 */
export async function queryCoinInfosForSelection(
  ws: InternalWalletState,
  csel: PeerPushPaymentCoinSelection,
): Promise {
  let infos: SpendCoinDetails[] = [];
  await ws.db
    .mktx((x) => [x.coins, x.denominations])
    .runReadOnly(async (tx) => {
      for (let i = 0; i < csel.coinPubs.length; i++) {
        const coin = await tx.coins.get(csel.coinPubs[i]);
        if (!coin) {
          throw Error("coin not found anymore");
        }
        const denom = await ws.getDenomInfo(
          ws,
          tx,
          coin.exchangeBaseUrl,
          coin.denomPubHash,
        );
        if (!denom) {
          throw Error("denom for coin not found anymore");
        }
        infos.push({
          coinPriv: coin.coinPriv,
          coinPub: coin.coinPub,
          denomPubHash: coin.denomPubHash,
          denomSig: coin.denomSig,
          ageCommitmentProof: coin.ageCommitmentProof,
          contribution: csel.contributions[i],
        });
      }
    });
  return infos;
}
export interface PeerCoinRepair {
  exchangeBaseUrl: string;
  coinPubs: CoinPublicKeyString[];
  contribs: AmountJson[];
}
export interface PeerCoinSelectionRequest {
  instructedAmount: AmountJson;
  /**
   * Instruct the coin selection to repair this coin
   * selection instead of selecting completely new coins.
   */
  repair?: PeerCoinRepair;
}
export async function selectPeerCoins(
  ws: InternalWalletState,
  req: PeerCoinSelectionRequest,
): Promise {
  const instructedAmount = req.instructedAmount;
  if (Amounts.isZero(instructedAmount)) {
    // Other parts of the code assume that we have at least
    // one coin to spend.
    throw new Error("amount of zero not allowed");
  }
  return await ws.db
    .mktx((x) => [
      x.exchanges,
      x.contractTerms,
      x.coins,
      x.coinAvailability,
      x.denominations,
      x.refreshGroups,
      x.peerPushPaymentInitiations,
    ])
    .runReadWrite(async (tx) => {
      const exchanges = await tx.exchanges.iter().toArray();
      const exchangeFeeGap: { [url: string]: AmountJson } = {};
      const currency = Amounts.currencyOf(instructedAmount);
      for (const exch of exchanges) {
        if (exch.detailsPointer?.currency !== currency) {
          continue;
        }
        // FIXME: Can't we do this faster by using coinAvailability?
        const coins = (
          await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl)
        ).filter((x) => x.status === CoinStatus.Fresh);
        const coinInfos: CoinInfo[] = [];
        for (const coin of coins) {
          const denom = await ws.getDenomInfo(
            ws,
            tx,
            coin.exchangeBaseUrl,
            coin.denomPubHash,
          );
          if (!denom) {
            throw Error("denom not found");
          }
          coinInfos.push({
            coinPub: coin.coinPub,
            feeDeposit: Amounts.parseOrThrow(denom.feeDeposit),
            value: Amounts.parseOrThrow(denom.value),
            denomPubHash: denom.denomPubHash,
            coinPriv: coin.coinPriv,
            denomSig: coin.denomSig,
            maxAge: coin.maxAge,
            ageCommitmentProof: coin.ageCommitmentProof,
          });
        }
        if (coinInfos.length === 0) {
          continue;
        }
        coinInfos.sort(
          (o1, o2) =>
            -Amounts.cmp(o1.value, o2.value) ||
            strcmp(o1.denomPubHash, o2.denomPubHash),
        );
        let amountAcc = Amounts.zeroOfCurrency(currency);
        let depositFeesAcc = Amounts.zeroOfCurrency(currency);
        const resCoins: {
          coinPub: string;
          coinPriv: string;
          contribution: AmountString;
          denomPubHash: string;
          denomSig: UnblindedSignature;
          ageCommitmentProof: AgeCommitmentProof | undefined;
        }[] = [];
        let lastDepositFee = Amounts.zeroOfCurrency(currency);
        if (req.repair) {
          for (let i = 0; i < req.repair.coinPubs.length; i++) {
            const contrib = req.repair.contribs[i];
            const coin = await tx.coins.get(req.repair.coinPubs[i]);
            if (!coin) {
              throw Error("repair not possible, coin not found");
            }
            const denom = await ws.getDenomInfo(
              ws,
              tx,
              coin.exchangeBaseUrl,
              coin.denomPubHash,
            );
            checkDbInvariant(!!denom);
            resCoins.push({
              coinPriv: coin.coinPriv,
              coinPub: coin.coinPub,
              contribution: Amounts.stringify(contrib),
              denomPubHash: coin.denomPubHash,
              denomSig: coin.denomSig,
              ageCommitmentProof: coin.ageCommitmentProof,
            });
            const depositFee = Amounts.parseOrThrow(denom.feeDeposit);
            lastDepositFee = depositFee;
            amountAcc = Amounts.add(
              amountAcc,
              Amounts.sub(contrib, depositFee).amount,
            ).amount;
            depositFeesAcc = Amounts.add(depositFeesAcc, depositFee).amount;
          }
        }
        for (const coin of coinInfos) {
          if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
            break;
          }
          const gap = Amounts.add(
            coin.feeDeposit,
            Amounts.sub(instructedAmount, amountAcc).amount,
          ).amount;
          const contrib = Amounts.min(gap, coin.value);
          amountAcc = Amounts.add(
            amountAcc,
            Amounts.sub(contrib, coin.feeDeposit).amount,
          ).amount;
          depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount;
          resCoins.push({
            coinPriv: coin.coinPriv,
            coinPub: coin.coinPub,
            contribution: Amounts.stringify(contrib),
            denomPubHash: coin.denomPubHash,
            denomSig: coin.denomSig,
            ageCommitmentProof: coin.ageCommitmentProof,
          });
          lastDepositFee = coin.feeDeposit;
        }
        if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
          const res: PeerCoinSelectionDetails = {
            exchangeBaseUrl: exch.baseUrl,
            coins: resCoins,
            depositFees: depositFeesAcc,
          };
          return { type: "success", result: res };
        }
        const diff = Amounts.sub(instructedAmount, amountAcc).amount;
        exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, diff).amount;
        continue;
      }
      // We were unable to select coins.
      // Now we need to produce error details.
      const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
        currency,
      });
      const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {};
      let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency);
      for (const exch of exchanges) {
        if (exch.detailsPointer?.currency !== currency) {
          continue;
        }
        const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
          currency,
          restrictExchangeTo: exch.baseUrl,
        });
        let gap =
          exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency);
        if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) {
          // Show fee gap only if we should've been able to pay with the material amount
          gap = Amounts.zeroOfCurrency(currency);
        }
        perExchange[exch.baseUrl] = {
          balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable),
          balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial),
          feeGapEstimate: Amounts.stringify(gap),
        };
        maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap);
      }
      const errDetails: PayPeerInsufficientBalanceDetails = {
        amountRequested: Amounts.stringify(instructedAmount),
        balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable),
        balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial),
        feeGapEstimate: Amounts.stringify(maxFeeGapEstimate),
        perExchange,
      };
      return { type: "failure", insufficientBalanceDetails: errDetails };
    });
}
export async function getTotalPeerPaymentCost(
  ws: InternalWalletState,
  pcs: SelectedPeerCoin[],
): Promise {
  return ws.db
    .mktx((x) => [x.coins, x.denominations])
    .runReadOnly(async (tx) => {
      const costs: AmountJson[] = [];
      for (let i = 0; i < pcs.length; i++) {
        const coin = await tx.coins.get(pcs[i].coinPub);
        if (!coin) {
          throw Error("can't calculate payment cost, coin not found");
        }
        const denom = await tx.denominations.get([
          coin.exchangeBaseUrl,
          coin.denomPubHash,
        ]);
        if (!denom) {
          throw Error(
            "can't calculate payment cost, denomination for coin not found",
          );
        }
        const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
          .iter(coin.exchangeBaseUrl)
          .filter((x) =>
            Amounts.isSameCurrency(
              DenominationRecord.getValue(x),
              pcs[i].contribution,
            ),
          );
        const amountLeft = Amounts.sub(
          DenominationRecord.getValue(denom),
          pcs[i].contribution,
        ).amount;
        const refreshCost = getTotalRefreshCost(
          allDenoms,
          DenominationRecord.toDenomInfo(denom),
          amountLeft,
          ws.config.testing.denomselAllowLate,
        );
        costs.push(Amounts.parseOrThrow(pcs[i].contribution));
        costs.push(refreshCost);
      }
      const zero = Amounts.zeroOfAmount(pcs[0].contribution);
      return Amounts.sum([zero, ...costs]).amount;
    });
}
interface ExchangePurseStatus {
  balance: AmountString;
  deposit_timestamp?: TalerProtocolTimestamp;
  merge_timestamp?: TalerProtocolTimestamp;
}
export const codecForExchangePurseStatus = (): Codec =>
  buildCodecForObject()
    .property("balance", codecForAmountString())
    .property("deposit_timestamp", codecOptional(codecForTimestamp))
    .property("merge_timestamp", codecOptional(codecForTimestamp))
    .build("ExchangePurseStatus");
export function talerPaytoFromExchangeReserve(
  exchangeBaseUrl: string,
  reservePub: string,
): string {
  const url = new URL(exchangeBaseUrl);
  let proto: string;
  if (url.protocol === "http:") {
    proto = "taler-reserve-http";
  } else if (url.protocol === "https:") {
    proto = "taler-reserve";
  } else {
    throw Error(`unsupported exchange base URL protocol (${url.protocol})`);
  }
  let path = url.pathname;
  if (!path.endsWith("/")) {
    path = path + "/";
  }
  return `payto://${proto}/${url.host}${url.pathname}${reservePub}`;
}
export async function getMergeReserveInfo(
  ws: InternalWalletState,
  req: {
    exchangeBaseUrl: string;
  },
): Promise {
  // We have to eagerly create the key pair outside of the transaction,
  // due to the async crypto API.
  const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
  const mergeReserveRecord: ReserveRecord = await ws.db
    .mktx((x) => [x.exchanges, x.reserves, x.withdrawalGroups])
    .runReadWrite(async (tx) => {
      const ex = await tx.exchanges.get(req.exchangeBaseUrl);
      checkDbInvariant(!!ex);
      if (ex.currentMergeReserveRowId != null) {
        const reserve = await tx.reserves.get(ex.currentMergeReserveRowId);
        checkDbInvariant(!!reserve);
        return reserve;
      }
      const reserve: ReserveRecord = {
        reservePriv: newReservePair.priv,
        reservePub: newReservePair.pub,
      };
      const insertResp = await tx.reserves.put(reserve);
      checkDbInvariant(typeof insertResp.key === "number");
      reserve.rowId = insertResp.key;
      ex.currentMergeReserveRowId = reserve.rowId;
      await tx.exchanges.put(ex);
      return reserve;
    });
  return mergeReserveRecord;
}