/*
 This file is part of GNU Taler
 (C) 2021-2025 Taler Systems S.A.

 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/>
 */

/**
 * Helper functions to run wallet functionality (withdrawal, deposit, refresh)
 * without a database or retry loop.
 *
 * Used for benchmarking, where we want to benchmark the exchange, but the
 * normal wallet would be too sluggish.
 */

/**
 * Imports.
 */
import {
  AbsoluteTime,
  AgeRestriction,
  AmountJson,
  AmountString,
  Amounts,
  BatchDepositRequestCoin,
  DenominationPubKey,
  EddsaPrivateKeyString,
  EddsaPublicKeyString,
  ExchangeBatchDepositRequest,
  ExchangeMeltRequestV2,
  ExchangeRefreshRevealRequestV2,
  Logger,
  TalerCorebankApiClient,
  TalerExchangeHttpClient,
  UnblindedDenominationSignature,
  codecForAny,
  codecForBankWithdrawalOperationPostResponse,
  codecForBatchDepositSuccess,
  encodeCrock,
  getRandomBytes,
  hashWire,
  j2s,
  parsePaytoUri,
  succeedOrThrow,
} from "@gnu-taler/taler-util";
import {
  HttpRequestLibrary,
  readSuccessResponseJsonOrThrow,
} from "@gnu-taler/taler-util/http";
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import { DenominationRecord } from "./db.js";
import { isCandidateWithdrawableDenom } from "./denominations.js";
import { ExchangeInfo, downloadExchangeInfo } from "./exchanges.js";
import { getBankStatusUrl, getBankWithdrawalInfo } from "./withdraw.js";

export { downloadExchangeInfo };

const logger = new Logger("dbless.ts");

export interface ReserveKeypair {
  reservePub: string;
  reservePriv: string;
}

/**
 * Denormalized info about a coin.
 */
export interface CoinInfo {
  coinPub: string;
  coinPriv: string;
  exchangeBaseUrl: string;
  denomSig: UnblindedDenominationSignature;
  denomPub: DenominationPubKey;
  denomPubHash: string;
  feeDeposit: string;
  feeRefresh: string;
  maxAge: number;
}

export interface TopupReserveWithBankArgs {
  http: HttpRequestLibrary;
  reservePub: string;
  corebankApiBaseUrl: string;
  exchangeInfo: ExchangeInfo;
  amount: AmountString;
}

export async function topupReserveWithBank(args: TopupReserveWithBankArgs) {
  const { http, corebankApiBaseUrl, amount, exchangeInfo, reservePub } = args;
  const bankClient = new TalerCorebankApiClient(corebankApiBaseUrl, {
    httpClient: http,
  });
  const bankUser = await bankClient.createRandomBankUser();
  bankClient.setAuth(bankUser);
  const wopi = await bankClient.createWithdrawalOperation(
    bankUser.username,
    amount,
  );
  const bankInfo = await getBankWithdrawalInfo(http, wopi.taler_withdraw_uri);
  const bankStatusUrl = getBankStatusUrl(wopi.taler_withdraw_uri);
  if (!bankInfo.exchange) {
    throw Error("no suggested exchange");
  }
  const plainPaytoUris =
    exchangeInfo.keys.accounts.map((x) => x.payto_uri) ?? [];
  if (plainPaytoUris.length <= 0) {
    throw new Error();
  }
  const httpResp = await http.fetch(bankStatusUrl.href, {
    method: "POST",
    body: {
      reserve_pub: reservePub,
      selected_exchange: plainPaytoUris[0],
    },
  });
  await readSuccessResponseJsonOrThrow(
    httpResp,
    codecForBankWithdrawalOperationPostResponse(),
  );
  await bankClient.confirmWithdrawalOperation(bankUser.username, {
    withdrawalOperationId: wopi.withdrawal_id,
  });
}

export async function withdrawCoin(args: {
  http: HttpRequestLibrary;
  cryptoApi: TalerCryptoInterface;
  reserveKeyPair: ReserveKeypair;
  denom: DenominationRecord;
  exchangeBaseUrl: string;
}): Promise<CoinInfo> {
  const { http, cryptoApi, reserveKeyPair, denom, exchangeBaseUrl } = args;
  const planchet = await cryptoApi.createPlanchet({
    coinIndex: 0,
    denomPub: denom.denomPub,
    feeWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw),
    reservePriv: reserveKeyPair.reservePriv,
    reservePub: reserveKeyPair.reservePub,
    secretSeed: encodeCrock(getRandomBytes(32)),
    value: Amounts.parseOrThrow(denom.value),
  });
  const exchangeClient = new TalerExchangeHttpClient(exchangeBaseUrl);

  const sigResp = await cryptoApi.signWithdrawal({
    amount: Amounts.stringify(denom.value),
    fee: Amounts.stringify(denom.fees.feeWithdraw),
    coinEvs: [planchet.coinEv],
    denomsPubHashes: [planchet.denomPubHash],
    reservePriv: reserveKeyPair.reservePriv,
  });

  const rBatch = succeedOrThrow(
    await exchangeClient.withdraw({
      body: {
        cipher: "ED25519",
        coin_evs: [planchet.coinEv],
        denoms_h: [planchet.denomPubHash],
        reserve_pub: planchet.reservePub,
        reserve_sig: sigResp.sig,
      },
    }),
  );

  const ubSig = await cryptoApi.unblindDenominationSignature({
    planchet,
    evSig: rBatch.ev_sigs[0],
  });

  return {
    coinPriv: planchet.coinPriv,
    coinPub: planchet.coinPub,
    denomSig: ubSig,
    denomPub: denom.denomPub,
    denomPubHash: denom.denomPubHash,
    feeDeposit: Amounts.stringify(denom.fees.feeDeposit),
    feeRefresh: Amounts.stringify(denom.fees.feeRefresh),
    exchangeBaseUrl: args.exchangeBaseUrl,
    maxAge: AgeRestriction.AGE_UNRESTRICTED,
  };
}

export interface FindDenomOptions {}

export function findDenomOrThrow(
  exchangeInfo: ExchangeInfo,
  amount: AmountString,
  options: FindDenomOptions = {},
): DenominationRecord {
  for (const d of exchangeInfo.keys.currentDenominations) {
    const value: AmountJson = Amounts.parseOrThrow(d.value);
    if (Amounts.cmp(value, amount) === 0 && isCandidateWithdrawableDenom(d)) {
      return d;
    }
  }
  throw new Error("no matching denomination found");
}

export async function depositCoin(args: {
  http: HttpRequestLibrary;
  cryptoApi: TalerCryptoInterface;
  exchangeBaseUrl: string;
  coin: CoinInfo;
  amount: AmountString;
  depositPayto?: string;
  merchantPriv: string;
  contractTermsHash?: string;
  // 16 bytes, crockford encoded
  wireSalt?: string;
}): Promise<void> {
  return await depositCoinBatch({
    coins: [args.coin],
    amounts: [args.amount],
    cryptoApi: args.cryptoApi,
    exchangeBaseUrl: args.exchangeBaseUrl,
    http: args.http,
    merchantPriv: args.merchantPriv,
    depositPayto: args.depositPayto,
    contractTermsHash: args.contractTermsHash,
    wireSalt: args.wireSalt,
  });
}

export async function depositCoinBatch(args: {
  http: HttpRequestLibrary;
  cryptoApi: TalerCryptoInterface;
  exchangeBaseUrl: string;
  coins: CoinInfo[];
  amounts: AmountString[];
  depositPayto?: string;
  merchantPriv: string;
  contractTermsHash?: string;
  // 16 bytes, crockford encoded
  wireSalt?: string;
}): Promise<void> {
  const { coins, amounts, http, cryptoApi } = args;
  const depositPayto =
    args.depositPayto ?? "payto://x-taler-bank/localhost/foo?receiver-name=foo";
  const wireSalt = args.wireSalt ?? encodeCrock(getRandomBytes(16));
  const timestampNow = AbsoluteTime.toProtocolTimestamp(AbsoluteTime.now());
  const contractTermsHash =
    args.contractTermsHash ?? encodeCrock(getRandomBytes(64));
  const depositTimestamp = timestampNow;
  const refundDeadline = timestampNow;
  const wireTransferDeadline = timestampNow;
  let merchantPriv: EddsaPrivateKeyString;
  let merchantPub: EddsaPublicKeyString;
  if (args.merchantPriv) {
    merchantPriv = args.merchantPriv;
    const res = await cryptoApi.eddsaGetPublic({
      priv: merchantPriv,
    });
    merchantPub = res.pub;
  } else {
    const res = await cryptoApi.createEddsaKeypair({});
    merchantPriv = res.priv;
    merchantPub = res.pub;
  }
  if (coins.length != amounts.length) {
    throw Error(`coins and amounts must match`);
  }

  const depositCoins: BatchDepositRequestCoin[] = [];
  for (let i = 0; i < coins.length; i++) {
    const coin = coins[i];
    const amount = amounts[i];
    const dp = await cryptoApi.signDepositPermission({
      coinPriv: coin.coinPriv,
      coinPub: coin.coinPub,
      contractTermsHash,
      denomKeyType: coin.denomPub.cipher,
      denomPubHash: coin.denomPubHash,
      denomSig: coin.denomSig,
      exchangeBaseUrl: args.exchangeBaseUrl,
      feeDeposit: Amounts.parseOrThrow(coin.feeDeposit),
      merchantPub,
      spendAmount: Amounts.parseOrThrow(amount),
      timestamp: depositTimestamp,
      refundDeadline: refundDeadline,
      wireInfoHash: hashWire(depositPayto, wireSalt),
    });
    depositCoins.push({
      contribution: Amounts.stringify(dp.contribution),
      coin_pub: dp.coin_pub,
      coin_sig: dp.coin_sig,
      denom_pub_hash: dp.h_denom,
      ub_sig: dp.ub_sig,
    });
  }
  const merchantContractSigResp = await cryptoApi.signContractTermsHash({
    contractTermsHash,
    merchantPriv: args.merchantPriv,
  });
  const requestBody: ExchangeBatchDepositRequest = {
    coins: depositCoins,
    merchant_sig: merchantContractSigResp.sig,
    merchant_payto_uri: depositPayto,
    wire_salt: wireSalt,
    h_contract_terms: contractTermsHash,
    timestamp: depositTimestamp,
    wire_transfer_deadline: wireTransferDeadline,
    refund_deadline: refundDeadline,
    merchant_pub: merchantPub,
  };
  const url = new URL(`batch-deposit`, args.exchangeBaseUrl);
  const httpResp = await http.fetch(url.href, {
    method: "POST",
    body: requestBody,
  });
  await readSuccessResponseJsonOrThrow(httpResp, codecForBatchDepositSuccess());
}

export async function refreshCoin(req: {
  http: HttpRequestLibrary;
  cryptoApi: TalerCryptoInterface;
  oldCoin: CoinInfo;
  newDenoms: DenominationRecord[];
}): Promise<void> {
  const { cryptoApi, oldCoin, http } = req;
  const refreshSessionSeed = encodeCrock(getRandomBytes(64));
  const session = await cryptoApi.deriveRefreshSessionV2({
    feeRefresh: Amounts.parseOrThrow(oldCoin.feeRefresh),
    kappa: 3,
    meltCoinDenomPubHash: oldCoin.denomPubHash,
    meltCoinPriv: oldCoin.coinPriv,
    meltCoinPub: oldCoin.coinPub,
    sessionPublicSeed: refreshSessionSeed,
    newCoinDenoms: req.newDenoms.map((x) => ({
      count: 1,
      denomPub: x.denomPub,
      denomPubHash: x.denomPubHash,
      feeWithdraw: x.fees.feeWithdraw,
      value: x.value,
    })),
    meltCoinMaxAge: oldCoin.maxAge,
  });

  const meltReqBody: ExchangeMeltRequestV2 = {
    old_coin_pub: oldCoin.coinPub,
    old_denom_pub_h: oldCoin.denomPubHash,
    old_denom_sig: oldCoin.denomSig,
    // old_age_commitment_h: maybeAch,
    refresh_seed: refreshSessionSeed,
    confirm_sig: session.confirmSig,
    coin_evs: session.planchets.map((x) => x.map((y) => y.coinEv)),
    denoms_h: req.newDenoms.map((x) => x.denomPubHash),
    value_with_fee: Amounts.stringify(session.meltValueWithFee),
  };

  logger.info(`requesting melt: ${j2s(meltReqBody)}`);

  const exchangeClient = new TalerExchangeHttpClient(oldCoin.exchangeBaseUrl);

  const meltResponse = succeedOrThrow(
    await exchangeClient.postMelt({ body: meltReqBody }),
  );

  const norevealIndex = meltResponse.noreveal_index;

  const revealRequest: ExchangeRefreshRevealRequestV2 = {
    rc: session.hash,
    signatures: session.signatures.filter((v, i) => i != norevealIndex),
    // age_commitment: ...
  };

  succeedOrThrow(await exchangeClient.postRevealMelt({ body: revealRequest }));

  logger.info("requesting reveal done");

  // We could unblind here, but we only use this function to
  // benchmark the exchange.
}

/**
 * Create a reserve for testing withdrawals.
 *
 * The reserve is created using the test-only API "/admin/add-incoming".
 */
export async function createTestingReserve(args: {
  http: HttpRequestLibrary;
  corebankApiBaseUrl: string;
  amount: string;
  reservePub: string;
  exchangeInfo: ExchangeInfo;
}): Promise<void> {
  const { http, corebankApiBaseUrl, amount, reservePub } = args;
  const paytoUri = args.exchangeInfo.keys.accounts[0].payto_uri;
  const pt = parsePaytoUri(paytoUri);
  if (!pt) {
    throw Error("failed to parse payto URI");
  }
  const components = pt.targetPath.split("/");
  const creditorAcct = components[components.length - 1];
  const fbReq = await http.fetch(
    new URL(
      `accounts/${creditorAcct}/taler-wire-gateway/admin/add-incoming`,
      corebankApiBaseUrl,
    ).href,
    {
      method: "POST",
      body: {
        amount,
        reserve_pub: reservePub,
        debit_account: "payto://x-taler-bank/localhost/testdebtor",
      },
    },
  );
  await readSuccessResponseJsonOrThrow(fbReq, codecForAny());
}

/**
 * Check the status of a reserve, use long-polling to wait
 * until the reserve actually has been created.
 */
export async function checkReserve(
  http: HttpRequestLibrary,
  exchangeBaseUrl: string,
  reservePub: string,
  longpollTimeoutMs: number = 500,
): Promise<void> {
  const reqUrl = new URL(`reserves/${reservePub}`, exchangeBaseUrl);
  if (longpollTimeoutMs) {
    reqUrl.searchParams.set("timeout_ms", `${longpollTimeoutMs}`);
  }
  const resp = await http.fetch(reqUrl.href, {
    method: "GET",
  });
  if (resp.status !== 200) {
    throw new Error("reserve not okay");
  }
}
