/*
 This file is part of GNU Taler
 (C) 2022 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 {
  AbsoluteTime,
  AmountJson,
  AmountString,
  Amounts,
  DenomKeyType,
  DenominationPubKey,
  Duration,
  TalerProtocolTimestamp,
  j2s,
} from "@gnu-taler/taler-util";
import test from "ava";
import {
  AvailableCoinsOfDenom,
  CoinSelectionTally,
  emptyTallyForPeerPayment,
  testing_getMaxDepositAmountForAvailableCoins,
  testing_selectGreedy,
} from "./coinSelection.js";

const inTheDistantFuture = AbsoluteTime.toProtocolTimestamp(
  AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ hours: 1 })),
);

const inThePast = AbsoluteTime.toProtocolTimestamp(
  AbsoluteTime.subtractDuraction(
    AbsoluteTime.now(),
    Duration.fromSpec({ hours: 1 }),
  ),
);

test("p2p: should select the coin", (t) => {
  const instructedAmount = Amounts.parseOrThrow("LOCAL:2");
  const tally = emptyTallyForPeerPayment({
    instructedAmount,
  });
  t.log(`tally before: ${j2s(tally)}`);
  const coins = testing_selectGreedy(
    {
      wireFeesPerExchange: {},
    },
    createCandidates([
      {
        amount: "LOCAL:10" as AmountString,
        numAvailable: 5,
        depositFee: "LOCAL:0.1" as AmountString,
        fromExchange: "http://exchange.localhost/",
      },
    ]),
    tally,
  );

  t.log(`coins: ${j2s(coins)}`);
  t.log(`tally: ${j2s(tally)}`);

  t.assert(coins != null);

  t.deepEqual(coins, {
    "hash0;32;http://exchange.localhost/": {
      exchangeBaseUrl: "http://exchange.localhost/",
      denomPubHash: "hash0",
      maxAge: 32,
      contributions: [Amounts.parseOrThrow("LOCAL:2.1")],
    },
  });
});

test("p2p: should select 3 coins", (t) => {
  const instructedAmount = Amounts.parseOrThrow("LOCAL:20");
  const tally = emptyTallyForPeerPayment({
    instructedAmount,
  });
  const coins = testing_selectGreedy(
    {
      wireFeesPerExchange: {},
    },
    createCandidates([
      {
        amount: "LOCAL:10" as AmountString,
        numAvailable: 5,
        depositFee: "LOCAL:0.1" as AmountString,
        fromExchange: "http://exchange.localhost/",
      },
    ]),
    tally,
  );

  t.deepEqual(coins, {
    "hash0;32;http://exchange.localhost/": {
      exchangeBaseUrl: "http://exchange.localhost/",
      denomPubHash: "hash0",
      maxAge: 32,
      contributions: [
        Amounts.parseOrThrow("LOCAL:10"),
        Amounts.parseOrThrow("LOCAL:10"),
        Amounts.parseOrThrow("LOCAL:0.3"),
      ],
    },
  });
});

test("p2p: can't select since the instructed amount is too high", (t) => {
  const instructedAmount = Amounts.parseOrThrow("LOCAL:60");
  const tally = emptyTallyForPeerPayment({
    instructedAmount,
  });
  const coins = testing_selectGreedy(
    {
      wireFeesPerExchange: {},
    },
    createCandidates([
      {
        amount: "LOCAL:10" as AmountString,
        numAvailable: 5,
        depositFee: "LOCAL:0.1" as AmountString,
        fromExchange: "http://exchange.localhost/",
      },
    ]),
    tally,
  );

  t.is(coins, undefined);
});

test("pay: select one coin to pay with fee", (t) => {
  const payment = Amounts.parseOrThrow("LOCAL:2");
  const exchangeWireFee = Amounts.parseOrThrow("LOCAL:0.1");
  const zero = Amounts.zeroOfCurrency(payment.currency);
  const tally = {
    amountPayRemaining: payment,
    amountDepositFeeLimitRemaining: zero,
    customerDepositFees: zero,
    customerWireFees: zero,
    wireFeeCoveredForExchange: new Set<string>(),
    lastDepositFee: zero,
    totalDepositFees: zero,
  } satisfies CoinSelectionTally;
  const coins = testing_selectGreedy(
    {
      wireFeesPerExchange: { "http://exchange.localhost/": exchangeWireFee },
    },
    createCandidates([
      {
        amount: "LOCAL:10" as AmountString,
        numAvailable: 5,
        depositFee: "LOCAL:0.1" as AmountString,
        fromExchange: "http://exchange.localhost/",
      },
    ]),
    tally,
  );

  t.deepEqual(coins, {
    "hash0;32;http://exchange.localhost/": {
      exchangeBaseUrl: "http://exchange.localhost/",
      denomPubHash: "hash0",
      maxAge: 32,
      contributions: [Amounts.parseOrThrow("LOCAL:2.2")],
    },
  });

  t.deepEqual(tally, {
    amountPayRemaining: Amounts.parseOrThrow("LOCAL:0"),
    amountDepositFeeLimitRemaining: zero,
    customerDepositFees: Amounts.parse("LOCAL:0.1"),
    customerWireFees: Amounts.parse("LOCAL:0.1"),
    wireFeeCoveredForExchange: new Set(["http://exchange.localhost/"]),
    lastDepositFee: Amounts.parse("LOCAL:0.1"),
    totalDepositFees: Amounts.parse("LOCAL:0.1"),
  });
});

function createCandidates(
  ar: {
    amount: AmountString;
    depositFee: AmountString;
    numAvailable: number;
    fromExchange: string;
  }[],
): AvailableCoinsOfDenom[] {
  return ar.map((r, idx) => {
    return {
      denomPub: {
        age_mask: 0,
        cipher: DenomKeyType.Rsa,
        rsa_public_key: "PPP",
      },
      denomPubHash: `hash${idx}`,
      value: r.amount,
      feeDeposit: r.depositFee,
      feeRefresh: "LOCAL:0" as AmountString,
      feeRefund: "LOCAL:0" as AmountString,
      feeWithdraw: "LOCAL:0" as AmountString,
      stampExpireDeposit: inTheDistantFuture,
      stampExpireLegal: inTheDistantFuture,
      stampExpireWithdraw: inTheDistantFuture,
      stampStart: inThePast,
      exchangeBaseUrl: r.fromExchange,
      numAvailable: r.numAvailable,
      maxAge: 32,
    };
  });
}

test("p2p: regression STATER", (t) => {
  const candidates = [
    {
      denomPub: {
        age_mask: 349441,
        cipher: "RSA",
        rsa_public_key:
          "040000WTR9ERP6FYDM4581C1WY4DX6EA6ZP0RKDEY1VCEG1HGZQDB1E1MT0HSPWKVWYY8GN99YG8JV2BQHCV608V3AP00HZ44M4R2RDK3MEG1HY3H5VP2YESFDXC8C2J0BT6E662JJYN4MCFR8Q8ZFD7ZCA8HGBNVG4JMTS5MBDTF9CX3JC25H702K1FG2C54HR48767D18F2H11HMVK7EEF51QRGE08T704VRCNZ6WTM3Z73Z5DW4W26GBEWTDZZ4HX94HRJEH8YENXAW5T5E39TQQN7MZ7HEPB59BQWB0DDMM8MAE274BV3HC2AJVCSXFJSKBAK1B9HKERPWF7Z5556VJG6YJ9236G5SFM3RC22PJM2SXHYBWFV1WBAYF1F2026C0CM5Q3RPQETHCWZTEX8KJ2J1K904002",
      },
      denomPubHash:
        "TF5S4VJ8P3NN0SM5R1KW5MP665KEFMGAT2RPR70BMG0WQ5A72J53GDDE0YSCTWEXHRW8FMMX3X27RQK4D1VH69GVJBYR5RSJY3X5FS8",
      feeDeposit: "STATER:1",
      feeRefresh: "STATER:0",
      feeRefund: "STATER:0",
      feeWithdraw: "STATER:0",
      stampExpireDeposit: {
        t_s: 1772722025,
      },
      stampExpireLegal: {
        t_s: 1961938025,
      },
      stampExpireWithdraw: {
        t_s: 1709650025,
      },
      stampStart: {
        t_s: 1709045225,
      },
      value: "STATER:2",
      exchangeBaseUrl: "https://exchange.taler.grothoff.org/",
      numAvailable: 6,
      maxAge: 32,
    },
    {
      denomPub: {
        age_mask: 349441,
        cipher: "RSA",
        rsa_public_key:
          "040000Y84BTTQCZ28AS2KZ867V05WES3YPN34X51DNF14ADGW2HNG9YFXCCNVQ2JA9ZT3KSBD17ZN9Y71KGWAWEFYMHE0S61DW63WN58VWRXQ92440V1JSZDD7FDTYEVNGG8ZVARVZ4GGF1RCDM93R28M067S5CPRZFCCQBRFFM9YDK2W06WDXE96BDCB8MZEYPHSGK5CTDY6XJE18EMRWYRBAG0H8P6QGQS73REXX66PTJ3MRX3AK3ARZF8417QKMZZPNS1JV5EYPAC7X8R1F9G1GWAQXVVQ2XTA5NMVMNJDJ0KEM93AXD4W2C7XMVJFSQN8RVB9KZ8JXWGN1YJQK7P6476HV896THKQ05QK4F0C65P4HA7QDX84C91F42PZVMH8AMYMA2NBXEYXS0EV8NXZHMZ30JF04002",
      },
      denomPubHash:
        "WCMKBGR8ZKJ62YZXCRNT3EHPFQQ2M0B5CGZXW0PYA76G8PPXJMXZ7Q3WBP2DA3Z4BF21K3X9AG769RYCC39C3PT0R1DCTJA2PRTSHSR",
      feeDeposit: "STATER:1",
      feeRefresh: "STATER:0",
      feeRefund: "STATER:0",
      feeWithdraw: "STATER:0",
      stampExpireDeposit: {
        t_s: 1772722025,
      },
      stampExpireLegal: {
        t_s: 1961938025,
      },
      stampExpireWithdraw: {
        t_s: 1709650025,
      },
      stampStart: {
        t_s: 1709045225,
      },
      value: "STATER:1",
      exchangeBaseUrl: "https://exchange.taler.grothoff.org/",
      numAvailable: 1,
      maxAge: 32,
    },
  ];
  const instructedAmount = Amounts.parseOrThrow("STATER:1");
  const tally = emptyTallyForPeerPayment({
    instructedAmount,
  });
  const res = testing_selectGreedy(
    {
      wireFeesPerExchange: {},
    },
    candidates as any,
    tally,
  );
  t.assert(!!res);
});

function makeCurrencyHelper(currency: string) {
  return (sx: TemplateStringsArray, ...vx: any[]) => {
    const s = String.raw({ raw: sx }, ...vx);
    return Amounts.parseOrThrow(`${currency}:${s}`);
  };
}

type TestCoin = [AmountJson, number];

const kudos = makeCurrencyHelper("kudos");

function defaultFeeConfig(
  value: AmountJson,
  totalAvailable: number,
): AvailableCoinsOfDenom {
  return {
    denomPub: undefined as any as DenominationPubKey,
    denomPubHash: "123",
    feeDeposit: `KUDOS:0.01`,
    feeRefresh: `KUDOS:0.01`,
    feeRefund: `KUDOS:0.01`,
    feeWithdraw: `KUDOS:0.01`,
    exchangeBaseUrl: "2",
    maxAge: 0,
    numAvailable: totalAvailable,
    stampExpireDeposit: TalerProtocolTimestamp.never(),
    stampExpireLegal: TalerProtocolTimestamp.never(),
    stampExpireWithdraw: TalerProtocolTimestamp.never(),
    stampStart: TalerProtocolTimestamp.never(),
    value: Amounts.stringify(value),
  };
}

test("deposit max 35", (t) => {
  const coinList: TestCoin[] = [
    [kudos`2`, 5],
    [kudos`5`, 5],
  ];
  const result = testing_getMaxDepositAmountForAvailableCoins(
    {
      currency: "KUDOS",
    },
    {
      coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
      currentWireFeePerExchange: {
        "2": kudos`0`,
      },
    },
  );
  t.is(Amounts.stringifyValue(result.rawAmount), "34.9");
  t.is(Amounts.stringifyValue(result.effectiveAmount), "35");
});

test("deposit max 35 with wirefee", (t) => {
  const coinList: TestCoin[] = [
    [kudos`2`, 5],
    [kudos`5`, 5],
  ];
  const result = testing_getMaxDepositAmountForAvailableCoins(
    {
      currency: "KUDOS",
    },
    {
      coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
      currentWireFeePerExchange: {
        "2": kudos`1`,
      },
    },
  );
  t.is(Amounts.stringifyValue(result.rawAmount), "33.9");
  t.is(Amounts.stringifyValue(result.effectiveAmount), "35");
});

test("deposit max repeated denom", (t) => {
  const coinList: TestCoin[] = [
    [kudos`2`, 1],
    [kudos`2`, 1],
    [kudos`5`, 1],
  ];
  const result = testing_getMaxDepositAmountForAvailableCoins(
    {
      currency: "KUDOS",
    },
    {
      coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
      currentWireFeePerExchange: {
        "2": kudos`0`,
      },
    },
  );
  t.is(Amounts.stringifyValue(result.rawAmount), "8.97");
  t.is(Amounts.stringifyValue(result.effectiveAmount), "9");
});

test("demo: deposit max after withdraw raw 25", (t) => {
  const coinList: TestCoin[] = [
    [kudos`0.1`, 8],
    [kudos`1`, 0],
    [kudos`2`, 2],
    [kudos`5`, 0],
    [kudos`10`, 2],
  ];
  const result = testing_getMaxDepositAmountForAvailableCoins(
    {
      currency: "KUDOS",
    },
    {
      coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
      currentWireFeePerExchange: {
        "2": kudos`0.01`,
      },
    },
  );
  t.is(Amounts.stringifyValue(result.effectiveAmount), "24.8");
  t.is(Amounts.stringifyValue(result.rawAmount), "24.67");

  // 8 x  0.1
  // 2 x  0.2
  // 2 x 10.0
  // total effective 24.8
  // deposit fee 12 x 0.01 = 0.12
  // wire fee 0.01
  // total raw: 24.8 - 0.13 = 24.67

  // current wallet impl fee 0.14
});

test("demo: deposit max after withdraw raw 13", (t) => {
  const coinList: TestCoin[] = [
    [kudos`0.1`, 8],
    [kudos`1`, 0],
    [kudos`2`, 1],
    [kudos`5`, 0],
    [kudos`10`, 1],
  ];
  const result = testing_getMaxDepositAmountForAvailableCoins(
    {
      currency: "KUDOS",
    },
    {
      coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
      currentWireFeePerExchange: {
        "2": kudos`0.01`,
      },
    },
  );
  t.is(Amounts.stringifyValue(result.effectiveAmount), "12.8");
  t.is(Amounts.stringifyValue(result.rawAmount), "12.69");

  // 8 x  0.1
  // 1 x  0.2
  // 1 x 10.0
  // total effective 12.8
  // deposit fee 10 x 0.01 = 0.10
  // wire fee 0.01
  // total raw: 12.8 - 0.11 = 12.69

  // current wallet impl fee 0.14
});

test("overpay when remaining < depositFee", (t) => {
  const instructedAmount = Amounts.parseOrThrow("LOCAL:2.1");
  const tally = emptyTallyForPeerPayment({
    instructedAmount,
  });
  tally.amountDepositFeeLimitRemaining = Amounts.parseOrThrow("LOCAL:0.5");

  const coins = testing_selectGreedy(
    {
      wireFeesPerExchange: {},
    },
    createCandidates([
      {
        amount: "LOCAL:2" as AmountString,
        numAvailable: 1,
        depositFee: "LOCAL:0.2" as AmountString,
        fromExchange: "http://exchange.localhost/",
      },
      {
        amount: "LOCAL:1" as AmountString,
        numAvailable: 1,
        depositFee: "LOCAL:0.2" as AmountString,
        fromExchange: "http://exchange.localhost/",
      },
    ]),
    tally,
  );

  t.assert(coins != null);

  t.deepEqual(coins, {
    "hash0;32;http://exchange.localhost/": {
      exchangeBaseUrl: "http://exchange.localhost/",
      denomPubHash: "hash0",
      maxAge: 32,
      contributions: [Amounts.parseOrThrow("LOCAL:2")],
    },
    "hash1;32;http://exchange.localhost/": {
      exchangeBaseUrl: "http://exchange.localhost/",
      denomPubHash: "hash1",
      maxAge: 32,
      contributions: [Amounts.parseOrThrow("LOCAL:0.1")],
    },
  });
});
