/*
 This file is part of GNU Taler
 (C) 2023 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/>
 */

import { GlobalIDB } from "@gnu-taler/idb-bridge";
import {
  AbsoluteTime,
  AgeRestriction,
  AmountJson,
  AmountResponse,
  Amounts,
  ConvertAmountRequest,
  Duration,
  TransactionAmountMode,
  TransactionType,
  checkDbInvariant,
  strcmp,
} from "@gnu-taler/taler-util";
import { DenominationRecord, timestampProtocolFromDb } from "./db.js";
import { getExchangeWireDetailsInTx } from "./exchanges.js";
import { WalletExecutionContext } from "./wallet.js";

export interface CoinInfo {
  id: string;
  value: AmountJson;
  denomDeposit: AmountJson;
  denomWithdraw: AmountJson;
  denomRefresh: AmountJson;
  totalAvailable: number | undefined;
  exchangeWire: AmountJson | undefined;
  exchangePurse: AmountJson | undefined;
  duration: Duration;
  exchangeBaseUrl: string;
  maxAge: number;
}

/**
 * If the operation going to be plan subtracts
 * or adds amount in the wallet db
 */
export enum OperationType {
  Credit = "credit",
  Debit = "debit",
}

// FIXME: Name conflict ...
interface ExchangeInfo {
  wireFee: AmountJson | undefined;
  purseFee: AmountJson | undefined;
  creditDeadline: AbsoluteTime;
  debitDeadline: AbsoluteTime;
}

function getOperationType(txType: TransactionType): OperationType {
  const operationType =
    txType === TransactionType.Withdrawal
      ? OperationType.Credit
      : txType === TransactionType.Deposit
        ? OperationType.Debit
        : undefined;
  if (!operationType) {
    throw Error(`operation type ${txType} not yet supported`);
  }
  return operationType;
}

interface SelectedCoins {
  totalValue: AmountJson;
  coins: { info: CoinInfo; size: number }[];
  refresh?: RefreshChoice;
}

interface RefreshChoice {
  /**
   * Amount that need to be covered
   */
  gap: AmountJson;
  totalFee: AmountJson;
  selected: CoinInfo;
  totalChangeValue: AmountJson;
  refreshEffective: AmountJson;
  coins: { info: CoinInfo; size: number }[];

  // totalValue: AmountJson;
  // totalDepositFee: AmountJson;
  // totalRefreshFee: AmountJson;
  // totalChangeContribution: AmountJson;
  // totalChangeWithdrawalFee: AmountJson;
}

interface CoinsFilter {
  shouldCalculatePurseFee?: boolean;
  exchanges?: string[];
  wireMethod?: string;
  ageRestricted?: number;
}

interface AvailableCoins {
  list: CoinInfo[];
  exchanges: Record<string, ExchangeInfo>;
}

/**
 * Get all the denoms that can be used for a operation that is limited
 * by the following restrictions.
 * This function is costly (by the database access) but with high chances
 * of being cached
 */
async function getAvailableCoins(
  wex: WalletExecutionContext,
  op: TransactionType,
  currency: string,
  filters: CoinsFilter = {},
): Promise<AvailableCoins> {
  const operationType = getOperationType(op);

  return await wex.db.runReadOnlyTx(
    {
      storeNames: [
        "exchanges",
        "exchangeDetails",
        "denominations",
        "coinAvailability",
      ],
    },
    async (tx) => {
      const list: CoinInfo[] = [];
      const exchanges: Record<string, ExchangeInfo> = {};

      const databaseExchanges = await tx.exchanges.iter().toArray();
      const filteredExchanges =
        filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl);

      for (const exchangeBaseUrl of filteredExchanges) {
        const exchangeDetails = await getExchangeWireDetailsInTx(
          tx,
          exchangeBaseUrl,
        );
        // 1.- exchange has same currency
        if (exchangeDetails?.currency !== currency) {
          continue;
        }

        let deadline = AbsoluteTime.never();
        // 2.- exchange supports wire method
        let wireFee: AmountJson | undefined;
        if (filters.wireMethod) {
          const wireMethodWithDates =
            exchangeDetails.wireInfo.feesForType[filters.wireMethod];

          if (!wireMethodWithDates) {
            throw Error(
              `exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`,
            );
          }
          const wireMethodFee = wireMethodWithDates.find((x) => {
            return AbsoluteTime.isBetween(
              AbsoluteTime.now(),
              AbsoluteTime.fromProtocolTimestamp(x.startStamp),
              AbsoluteTime.fromProtocolTimestamp(x.endStamp),
            );
          });

          if (!wireMethodFee) {
            throw Error(
              `exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`,
            );
          }
          wireFee = Amounts.parseOrThrow(wireMethodFee.wireFee);
          deadline = AbsoluteTime.min(
            deadline,
            AbsoluteTime.fromProtocolTimestamp(wireMethodFee.endStamp),
          );
        }
        // exchanges[exchangeBaseUrl].wireFee = wireMethodFee;

        // 3.- exchange supports wire method
        let purseFee: AmountJson | undefined;
        if (filters.shouldCalculatePurseFee) {
          const purseFeeFound = exchangeDetails.globalFees.find((x) => {
            return AbsoluteTime.isBetween(
              AbsoluteTime.now(),
              AbsoluteTime.fromProtocolTimestamp(x.startDate),
              AbsoluteTime.fromProtocolTimestamp(x.endDate),
            );
          });
          if (!purseFeeFound) {
            throw Error(
              `exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`,
            );
          }
          purseFee = Amounts.parseOrThrow(purseFeeFound.purseFee);
          deadline = AbsoluteTime.min(
            deadline,
            AbsoluteTime.fromProtocolTimestamp(purseFeeFound.endDate),
          );
        }

        let creditDeadline = AbsoluteTime.never();
        let debitDeadline = AbsoluteTime.never();
        //4.- filter coins restricted by age
        if (operationType === OperationType.Credit) {
          // FIXME: Use denom groups instead of querying all denominations!
          const ds =
            await tx.denominations.indexes.byExchangeBaseUrl.getAll(
              exchangeBaseUrl,
            );
          for (const denom of ds) {
            const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
              timestampProtocolFromDb(denom.stampExpireWithdraw),
            );
            const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
              timestampProtocolFromDb(denom.stampExpireDeposit),
            );
            creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
            debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
            list.push(
              buildCoinInfoFromDenom(
                denom,
                purseFee,
                wireFee,
                AgeRestriction.AGE_UNRESTRICTED,
                Number.MAX_SAFE_INTEGER, // Max withdrawable from single denom
              ),
            );
          }
        } else {
          const ageLower = filters.ageRestricted ?? 0;
          const ageUpper = AgeRestriction.AGE_UNRESTRICTED;

          const myExchangeCoins =
            await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
              GlobalIDB.KeyRange.bound(
                [exchangeDetails.exchangeBaseUrl, ageLower, 1],
                [
                  exchangeDetails.exchangeBaseUrl,
                  ageUpper,
                  Number.MAX_SAFE_INTEGER,
                ],
              ),
            );
          //5.- save denoms with how many coins are available
          // FIXME: Check that the individual denomination is audited!
          // FIXME: Should we exclude denominations that are
          // not spendable anymore?
          for (const coinAvail of myExchangeCoins) {
            const denom = await tx.denominations.get([
              coinAvail.exchangeBaseUrl,
              coinAvail.denomPubHash,
            ]);
            checkDbInvariant(
              !!denom,
              `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`,
            );
            if (denom.isRevoked || !denom.isOffered) {
              continue;
            }
            const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
              timestampProtocolFromDb(denom.stampExpireWithdraw),
            );
            const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
              timestampProtocolFromDb(denom.stampExpireDeposit),
            );
            creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
            debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
            list.push(
              buildCoinInfoFromDenom(
                denom,
                purseFee,
                wireFee,
                coinAvail.maxAge,
                coinAvail.freshCoinCount,
              ),
            );
          }
        }

        exchanges[exchangeBaseUrl] = {
          purseFee,
          wireFee,
          debitDeadline,
          creditDeadline,
        };
      }

      return { list, exchanges };
    },
  );
}

function buildCoinInfoFromDenom(
  denom: DenominationRecord,
  purseFee: AmountJson | undefined,
  wireFee: AmountJson | undefined,
  maxAge: number,
  total: number,
): CoinInfo {
  return {
    id: denom.denomPubHash,
    denomWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw),
    denomDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
    denomRefresh: Amounts.parseOrThrow(denom.fees.feeRefresh),
    exchangePurse: purseFee,
    exchangeWire: wireFee,
    exchangeBaseUrl: denom.exchangeBaseUrl,
    duration: AbsoluteTime.difference(
      AbsoluteTime.now(),
      AbsoluteTime.fromProtocolTimestamp(
        timestampProtocolFromDb(denom.stampExpireDeposit),
      ),
    ),
    totalAvailable: total,
    value: Amounts.parseOrThrow(denom.value),
    maxAge,
  };
}

export async function convertDepositAmount(
  wex: WalletExecutionContext,
  req: ConvertAmountRequest,
): Promise<AmountResponse> {
  const amount = Amounts.parseOrThrow(req.amount);
  // const filter = getCoinsFilter(req);

  const denoms = await getAvailableCoins(
    wex,
    TransactionType.Deposit,
    amount.currency,
    {},
  );
  const result = convertDepositAmountForAvailableCoins(
    denoms,
    amount,
    req.type,
  );

  return {
    effectiveAmount: Amounts.stringify(result.effective),
    rawAmount: Amounts.stringify(result.raw),
  };
}

const LOG_REFRESH = false;
const LOG_DEPOSIT = false;

export function convertDepositAmountForAvailableCoins(
  denoms: AvailableCoins,
  amount: AmountJson,
  mode: TransactionAmountMode,
): AmountAndRefresh {
  const zero = Amounts.zeroOfCurrency(amount.currency);
  if (!denoms.list.length) {
    // no coins in the database
    return { effective: zero, raw: zero };
  }
  const depositDenoms = rankDenominationForDeposit(denoms.list, mode);

  //FIXME: we are not taking into account
  // * exchanges with multiple accounts
  // * wallet with multiple exchanges
  const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero;
  const adjustedAmount = Amounts.add(amount, wireFee).amount;

  const selected = selectGreedyCoins(depositDenoms, adjustedAmount);

  const gap = Amounts.sub(amount, selected.totalValue).amount;

  const result = getTotalEffectiveAndRawForDeposit(
    selected.coins,
    amount.currency,
  );
  result.raw = Amounts.sub(result.raw, wireFee).amount;

  if (Amounts.isZero(gap)) {
    // exact amount founds
    return result;
  }

  if (LOG_DEPOSIT) {
    const logInfo = selected.coins.map((c) => {
      return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`;
    });
    console.log(
      "deposit used:",
      logInfo.join(", "),
      "gap:",
      Amounts.stringifyValue(gap),
    );
  }

  const refreshDenoms = rankDenominationForRefresh(denoms.list);
  // FIXME: looking for refresh AFTER selecting greedy is not optimal
  const refreshCoin = searchBestRefreshCoin(
    depositDenoms,
    refreshDenoms,
    gap,
    mode,
  );

  if (refreshCoin) {
    const fee = Amounts.sub(result.effective, result.raw).amount;
    const effective = Amounts.add(
      result.effective,
      refreshCoin.refreshEffective,
    ).amount;
    const raw = Amounts.sub(effective, fee, refreshCoin.totalFee).amount;
    // found with change
    return {
      effective,
      raw,
      refresh: refreshCoin,
    };
  }

  // there is a gap, but no refresh coin was found
  return result;
}

export async function convertWithdrawalAmount(
  wex: WalletExecutionContext,
  req: ConvertAmountRequest,
): Promise<AmountResponse> {
  const amount = Amounts.parseOrThrow(req.amount);

  const denoms = await getAvailableCoins(
    wex,
    TransactionType.Withdrawal,
    amount.currency,
    {},
  );

  const result = convertWithdrawalAmountFromAvailableCoins(
    denoms,
    amount,
    req.type,
  );

  return {
    effectiveAmount: Amounts.stringify(result.effective),
    rawAmount: Amounts.stringify(result.raw),
  };
}

export function convertWithdrawalAmountFromAvailableCoins(
  denoms: AvailableCoins,
  amount: AmountJson,
  mode: TransactionAmountMode,
) {
  const zero = Amounts.zeroOfCurrency(amount.currency);
  if (!denoms.list.length) {
    // no coins in the database
    return { effective: zero, raw: zero };
  }
  const withdrawDenoms = rankDenominationForWithdrawals(denoms.list, mode);

  const selected = selectGreedyCoins(withdrawDenoms, amount);

  return getTotalEffectiveAndRawForWithdrawal(selected.coins, amount.currency);
}

/** *****************************************************
 * HELPERS
 * *****************************************************
 */

function searchBestRefreshCoin(
  depositDenoms: SelectableElement[],
  refreshDenoms: Record<string, SelectableElement[]>,
  amount: AmountJson,
  mode: TransactionAmountMode,
): RefreshChoice | undefined {
  let choice: RefreshChoice | undefined = undefined;
  let refreshIdx = 0;
  refreshIteration: while (refreshIdx < depositDenoms.length) {
    const d = depositDenoms[refreshIdx];

    const denomContribution =
      mode === TransactionAmountMode.Effective
        ? d.value
        : Amounts.sub(d.value, d.info.denomRefresh, d.info.denomDeposit).amount;

    const changeAfterDeposit = Amounts.sub(denomContribution, amount).amount;
    if (Amounts.isZero(changeAfterDeposit)) {
      //this coin is not big enough to use for refresh
      //since the list is sorted, we can break here
      break refreshIteration;
    }

    const withdrawDenoms = refreshDenoms[d.info.exchangeBaseUrl];
    const change = selectGreedyCoins(withdrawDenoms, changeAfterDeposit);

    const zero = Amounts.zeroOfCurrency(amount.currency);
    const withdrawChangeFee = change.coins.reduce((cur, prev) => {
      return Amounts.add(
        cur,
        Amounts.mult(prev.info.denomWithdraw, prev.size).amount,
      ).amount;
    }, zero);

    const withdrawChangeValue = change.coins.reduce((cur, prev) => {
      return Amounts.add(cur, Amounts.mult(prev.info.value, prev.size).amount)
        .amount;
    }, zero);

    const totalFee = Amounts.add(
      d.info.denomDeposit,
      d.info.denomRefresh,
      withdrawChangeFee,
    ).amount;

    if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) {
      //found cheaper change
      choice = {
        gap: amount,
        totalFee: totalFee,
        totalChangeValue: change.totalValue, //change after refresh
        refreshEffective: Amounts.sub(d.info.value, withdrawChangeValue).amount, // what of the denom used is not recovered
        selected: d.info,
        coins: change.coins,
      };
    }
    refreshIdx++;
  }
  if (choice) {
    if (LOG_REFRESH) {
      const logInfo = choice.coins.map((c) => {
        return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`;
      });
      console.log(
        "refresh used:",
        Amounts.stringifyValue(choice.selected.value),
        "change:",
        logInfo.join(", "),
        "fee:",
        Amounts.stringifyValue(choice.totalFee),
        "refreshEffective:",
        Amounts.stringifyValue(choice.refreshEffective),
        "totalChangeValue:",
        Amounts.stringifyValue(choice.totalChangeValue),
      );
    }
  }
  return choice;
}

/**
 * Returns a copy of the list sorted for the best denom to withdraw first
 */
function rankDenominationForWithdrawals(
  denoms: CoinInfo[],
  mode: TransactionAmountMode,
): SelectableElement[] {
  const copyList = [...denoms];
  /// Rank coins
  copyList.sort((d1, d2) => {
    // the best coin to use is
    // 1.- the one that contrib more and pay less fee
    // 2.- it takes more time before expires

    //different exchanges may have different wireFee
    //ranking should take the relative contribution in the exchange
    //which is (value - denomFee / fixedFee)

    const rate1 = Amounts.isZero(d1.denomWithdraw)
      ? Number.MIN_SAFE_INTEGER
      : Amounts.divmod(d1.value, d1.denomWithdraw).quotient;
    const rate2 = Amounts.isZero(d2.denomWithdraw)
      ? Number.MIN_SAFE_INTEGER
      : Amounts.divmod(d2.value, d2.denomWithdraw).quotient;
    const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1;
    return (
      contribCmp ||
      Duration.cmp(d1.duration, d2.duration) ||
      strcmp(d1.id, d2.id)
    );
  });

  return copyList.map((info) => {
    switch (mode) {
      case TransactionAmountMode.Effective: {
        // if the user instructed "effective" then we need to selected
        // greedy total coin value
        return {
          info,
          value: info.value,
          total: Number.MAX_SAFE_INTEGER,
        };
      }
      case TransactionAmountMode.Raw: {
        // if the user instructed "raw" then we need to selected
        // greedy total coin raw amount (without fee)
        return {
          info,
          value: Amounts.add(info.value, info.denomWithdraw).amount,
          total: Number.MAX_SAFE_INTEGER,
        };
      }
    }
  });
}

/**
 * Returns a copy of the list sorted for the best denom to deposit first
 *
 * @param denoms
 * @returns
 */
function rankDenominationForDeposit(
  denoms: CoinInfo[],
  mode: TransactionAmountMode,
): SelectableElement[] {
  const copyList = [...denoms];
  // Rank coins
  copyList.sort((d1, d2) => {
    // the best coin to use is
    // 1.- the one that contrib more and pay less fee
    // 2.- it takes more time before expires

    // different exchanges may have different wireFee
    // ranking should take the relative contribution in the exchange
    // which is (value - denomFee / fixedFee)
    const rate1 = Amounts.isZero(d1.denomDeposit)
      ? Number.MIN_SAFE_INTEGER
      : Amounts.divmod(d1.value, d1.denomDeposit).quotient;
    const rate2 = Amounts.isZero(d2.denomDeposit)
      ? Number.MIN_SAFE_INTEGER
      : Amounts.divmod(d2.value, d2.denomDeposit).quotient;

    const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1;
    return (
      contribCmp ||
      Duration.cmp(d1.duration, d2.duration) ||
      strcmp(d1.id, d2.id)
    );
  });

  return copyList.map((info) => {
    switch (mode) {
      case TransactionAmountMode.Effective: {
        // if the user instructed "effective" then we need to selected
        // greedy total coin value
        return {
          info,
          value: info.value,
          total: info.totalAvailable ?? 0,
        };
      }
      case TransactionAmountMode.Raw: {
        // if the user instructed "raw" then we need to selected
        // greedy total coin raw amount (without fee)
        return {
          info,
          value: Amounts.sub(info.value, info.denomDeposit).amount,
          total: info.totalAvailable ?? 0,
        };
      }
    }
  });
}

/**
 * Returns a copy of the list sorted for the best denom to withdraw first
 */
function rankDenominationForRefresh(
  denoms: CoinInfo[],
): Record<string, SelectableElement[]> {
  const groupByExchange: Record<string, CoinInfo[]> = {};
  for (const d of denoms) {
    if (!groupByExchange[d.exchangeBaseUrl]) {
      groupByExchange[d.exchangeBaseUrl] = [];
    }
    groupByExchange[d.exchangeBaseUrl].push(d);
  }

  const result: Record<string, SelectableElement[]> = {};
  for (const d of denoms) {
    result[d.exchangeBaseUrl] = rankDenominationForWithdrawals(
      groupByExchange[d.exchangeBaseUrl],
      TransactionAmountMode.Raw,
    );
  }
  return result;
}

interface SelectableElement {
  total: number;
  value: AmountJson;
  info: CoinInfo;
}

function selectGreedyCoins(
  coins: SelectableElement[],
  limit: AmountJson,
): SelectedCoins {
  const result: SelectedCoins = {
    totalValue: Amounts.zeroOfCurrency(limit.currency),
    coins: [],
  };
  if (!coins.length) return result;

  let denomIdx = 0;
  iterateDenoms: while (denomIdx < coins.length) {
    const denom = coins[denomIdx];
    // let total = denom.total;
    const left = Amounts.sub(limit, result.totalValue).amount;

    if (Amounts.isZero(denom.value)) {
      // 0 contribution denoms should be the last
      break iterateDenoms;
    }

    // use Amounts.divmod instead of iterate
    const div = Amounts.divmod(left, denom.value);
    const size = Math.min(div.quotient, denom.total);
    if (size > 0) {
      const mul = Amounts.mult(denom.value, size).amount;
      const progress = Amounts.add(result.totalValue, mul).amount;

      result.totalValue = progress;
      result.coins.push({ info: denom.info, size });
      denom.total = denom.total - size;
    }

    // go next denom
    denomIdx++;
  }

  return result;
}

type AmountWithFee = { raw: AmountJson; effective: AmountJson };
type AmountAndRefresh = AmountWithFee & { refresh?: RefreshChoice };

function getTotalEffectiveAndRawForDeposit(
  list: { info: CoinInfo; size: number }[],
  currency: string,
): AmountWithFee {
  const init = {
    raw: Amounts.zeroOfCurrency(currency),
    effective: Amounts.zeroOfCurrency(currency),
  };
  return list.reduce((prev, cur) => {
    const ef = Amounts.mult(cur.info.value, cur.size).amount;
    const rw = Amounts.mult(
      Amounts.sub(cur.info.value, cur.info.denomDeposit).amount,
      cur.size,
    ).amount;

    prev.effective = Amounts.add(prev.effective, ef).amount;
    prev.raw = Amounts.add(prev.raw, rw).amount;
    return prev;
  }, init);
}

function getTotalEffectiveAndRawForWithdrawal(
  list: { info: CoinInfo; size: number }[],
  currency: string,
): AmountWithFee {
  const init = {
    raw: Amounts.zeroOfCurrency(currency),
    effective: Amounts.zeroOfCurrency(currency),
  };
  return list.reduce((prev, cur) => {
    const ef = Amounts.mult(cur.info.value, cur.size).amount;
    const rw = Amounts.mult(
      Amounts.add(cur.info.value, cur.info.denomWithdraw).amount,
      cur.size,
    ).amount;

    prev.effective = Amounts.add(prev.effective, ef).amount;
    prev.raw = Amounts.add(prev.raw, rw).amount;
    return prev;
  }, init);
}
