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

import {
  AbsoluteTime,
  AmountString,
  Duration,
  TalerErrorCode,
  TransactionMajorState,
  TransactionMinorState,
  TransactionPeerPullCredit,
  TransactionType,
} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import {
  createSimpleTestkudosEnvironmentV3,
  createWalletDaemonWithClient,
  withdrawViaBankV3,
} from "../harness/environments.js";
import {
  GlobalTestState,
  WalletClient,
} from "../harness/harness.js";

const purse_expiration = AbsoluteTime.toProtocolTimestamp(
  AbsoluteTime.addDuration(
    AbsoluteTime.now(),
    Duration.fromSpec({ days: 2 }),
  ),
);

/**
 * Run a test for basic peer-pull payments.
 */
export async function runPeerPullTest(t: GlobalTestState) {
  const [
    { walletClient: wallet1, bankClient, exchange },
    { walletClient: wallet2 },
    { walletClient: wallet3 },
    { walletClient: wallet4 },
  ] = await Promise.all([
    createSimpleTestkudosEnvironmentV3(t),
    createWalletDaemonWithClient(t, {
      name: "w2"
    }),
    createWalletDaemonWithClient(t, {
      name: "w3"
    }),
    createWalletDaemonWithClient(t, {
      name: "w4"
    })
  ]);

  // Withdraw digital cash into the wallet.
  await Promise.all([wallet1, wallet2, wallet3, wallet4].map(async w => {
    const withdrawRes = await withdrawViaBankV3(t, {
      walletClient: w,
      bankClient,
      exchange,
      amount: "TESTKUDOS:20",
    });
    await withdrawRes.withdrawalFinishedCond;
  }));

  async function initPeerPullCredit(summary: string, amount: AmountString = "TESTKUDOS:2"): Promise<TransactionPeerPullCredit> {
    const initiate = await wallet1.call(
      WalletApiOperation.InitiatePeerPullCredit,
      {
        exchangeBaseUrl: exchange.baseUrl,
        partialContractTerms: {
          summary,
          amount,
          purse_expiration,
        },
      },
    );

    await wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
      transactionId: initiate.transactionId,
      txState: {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.Ready
      },
    });

    const tx = await wallet1.call(WalletApiOperation.GetTransactionById, {
      transactionId: initiate.transactionId,
    });
    t.assertDeepEqual(tx.type, TransactionType.PeerPullCredit);
    return tx
  }

  t.logStep("P2P pull amount logic");
  {
    await wallet1.call(WalletApiOperation.AddExchange, {
      exchangeBaseUrl: exchange.baseUrl
    });
    const [
      checkfive,
      checkzero
    ] = await Promise.all([
      wallet1.call(
        WalletApiOperation.CheckPeerPullCredit,
        {
          amount: "TESTKUDOS:5",
          exchangeBaseUrl: exchange.baseUrl,
        },
      ),
      wallet1.call(
        WalletApiOperation.CheckPeerPullCredit,
        {
          amount: "TESTKUDOS:0",
          exchangeBaseUrl: exchange.baseUrl,
        },
      ),
    ]);

    t.assertDeepEqual(checkfive.amountRaw, "TESTKUDOS:5");

    t.assertDeepEqual(checkzero.amountRaw, "TESTKUDOS:0");
    t.assertDeepEqual(checkzero.amountEffective, "TESTKUDOS:0");
    t.assertDeepEqual(checkzero.numCoins, 0);
  }

  t.logStep("P2P pull errors");
  {
    const tx = await initPeerPullCredit("confirm", "TESTKUDOS:1000");
    const insufficient_balance = await t.assertThrowsTalerErrorAsyncLegacy(wallet1.call(
      WalletApiOperation.PreparePeerPullDebit,
      { talerUri: tx.talerUri! }
    ));
    t.assertTrue(insufficient_balance.errorDetail.code === TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE);

    const unknown_purse = await t.assertThrowsTalerErrorAsyncLegacy(wallet1.call(
      WalletApiOperation.PreparePeerPullDebit,
      { talerUri: "taler+http://pay-pull/localhost:8081/MQP1DP1J94ZZWNQS7TRDF1KJZ7V8H74CZF41V90FKXBPN5GNRN6G" }
    ));
    // FIXME this should fail with a proper error code
    t.assertTrue(unknown_purse.errorDetail.code === TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION);
  }

  t.logStep("P2P pull confirm");
  {
    const tx = await initPeerPullCredit("confirm");

    const [prepare2, prepare3, prepare4] = await Promise.all([wallet2, wallet3, wallet4].map(w =>
      w.call(
        WalletApiOperation.PreparePeerPullDebit,
        { talerUri: tx.talerUri! }
      ))
    );

    {
      const idempotent = await wallet2.call(WalletApiOperation.PreparePeerPullDebit,
        { talerUri: tx.talerUri! }
      );
      t.assertTrue(prepare2.transactionId === idempotent.transactionId);
    }

    await wallet2.call(WalletApiOperation.ConfirmPeerPullDebit, {
      transactionId: prepare2.transactionId,
    });
    await wallet3.call(WalletApiOperation.ConfirmPeerPullDebit, {
      transactionId: prepare3.transactionId,
    });

    // Idempotent
    await Promise.all([
      wallet2.call(WalletApiOperation.ConfirmPeerPullDebit, {
        transactionId: prepare2.transactionId,
      }),
      wallet3.call(WalletApiOperation.ConfirmPeerPullDebit, {
        transactionId: prepare3.transactionId,
      }),
    ]);

    await Promise.all([
      wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: tx.transactionId,
        txState: {
          major: TransactionMajorState.Done,
        },
      }),
      wallet2.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: prepare2.transactionId,
        txState: {
          major: TransactionMajorState.Done,
        },
      }),
      wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: prepare3.transactionId,
        txState: {
          major: TransactionMajorState.Aborted,
        },
      }),
      wallet4.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: prepare4.transactionId,
        txState: {
          major: TransactionMajorState.Aborted,
        },
      }),
    ]);

    const completed_purse = await t.assertThrowsTalerErrorAsyncLegacy(wallet1.call(
      WalletApiOperation.PreparePeerPullDebit,
      { talerUri: tx.talerUri! }
    ));
    // FIXME this should fail with a proper error code
    t.assertTrue(completed_purse.errorDetail.code === TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION);
  }

  t.logStep("P2P pull self");
  {
    const tx = await initPeerPullCredit("self");
    const prepare = await wallet1.call(
      WalletApiOperation.PreparePeerPullDebit,
      { talerUri: tx.talerUri! }
    );
    await wallet1.call(WalletApiOperation.ConfirmPeerPullDebit, {
      transactionId: prepare.transactionId,
    })
    await Promise.all([
      wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: tx.transactionId,
        txState: {
          major: TransactionMajorState.Done,
        },
      }),
      wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: prepare.transactionId,
        txState: {
          major: TransactionMajorState.Done,
        },
      }),
    ]);

    // Check scan after completion
    const idempotent = await wallet1.call(WalletApiOperation.PreparePeerPullDebit,
      { talerUri: tx.talerUri! }
    );
    t.assertTrue(prepare.transactionId === idempotent.transactionId);
  }

  t.logStep("P2P pull conflict");
  {
    const tx = await initPeerPullCredit("conflict");

    const [prepare2, prepare3] = await Promise.all([wallet2, wallet3].map(w =>
      w.call(
        WalletApiOperation.PreparePeerPullDebit,
        { talerUri: tx.talerUri! }
      ))
    );

    await exchange.stop();

    await Promise.all([
      wallet2.call(WalletApiOperation.ConfirmPeerPullDebit, {
        transactionId: prepare2.transactionId,
      }),
      wallet3.call(WalletApiOperation.ConfirmPeerPullDebit, {
        transactionId: prepare3.transactionId,
      }),
    ]);

    await exchange.start()
    await Promise.all([
      wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: tx.transactionId,
        txState: {
          major: TransactionMajorState.Done,
        },
      }),
      Promise.race([
        Promise.all([
          wallet2.call(WalletApiOperation.TestingWaitTransactionState, {
            transactionId: prepare2.transactionId,
            txState: {
              major: TransactionMajorState.Done,
            },
          }),
          wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
            transactionId: prepare3.transactionId,
            txState: {
              major: TransactionMajorState.Aborted,
            },
          }),
        ]),
        Promise.all([
          wallet2.call(WalletApiOperation.TestingWaitTransactionState, {
            transactionId: prepare2.transactionId,
            txState: {
              major: TransactionMajorState.Aborted,
            },
          }),
          wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
            transactionId: prepare3.transactionId,
            txState: {
              major: TransactionMajorState.Done,
            },
          }),
        ])
      ])
    ]);
  }

  t.logStep("P2P pull abort");
  {
    const tx = await initPeerPullCredit("abort");

    const [prepare2, prepare3] = await Promise.all([wallet2, wallet3].map(w =>
      w.call(
        WalletApiOperation.PreparePeerPullDebit,
        { talerUri: tx.talerUri! }
      )
    ));

    await wallet1.call(WalletApiOperation.AbortTransaction, {
      transactionId: tx.transactionId,
    });

    await wallet2.call(WalletApiOperation.ConfirmPeerPullDebit, {
      transactionId: prepare2.transactionId,
    });

    await Promise.all([
      wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: tx.transactionId,
        txState: {
          major: TransactionMajorState.Aborted,
        },
      }),
      wallet2.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: prepare2.transactionId,
        txState: {
          major: TransactionMajorState.Aborted,
        },
      }),
      wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: prepare3.transactionId,
        txState: {
          major: TransactionMajorState.Aborted
        },
      }),
    ]);

    const aborted_contract = await t.assertThrowsTalerErrorAsyncLegacy(wallet1.call(
      WalletApiOperation.PreparePeerPullDebit,
      { talerUri: tx.talerUri! }
    ));
    // FIXME this should fail with a proper error code
    t.assertTrue(aborted_contract.errorDetail.code === TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION);
  }

  t.logStep("P2P pull abort before create purse");
  {
    // Make sure the reserve can't be created.
    // This will test the case where the transaction is aborted
    // before the purse could even have been created.
    await exchange.stop();
    const initiate = await wallet1.call(
      WalletApiOperation.InitiatePeerPullCredit,
      {
        exchangeBaseUrl: exchange.baseUrl,
        partialContractTerms: {
          summary: "abort before purse creation",
          amount: "TESTKUDOS:5",
          purse_expiration,
        },
      },
    );
    await wallet1.call(WalletApiOperation.AbortTransaction, {
      transactionId: initiate.transactionId,
    });
    await wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
      transactionId: initiate.transactionId,
      txState: {
        major: TransactionMajorState.Aborting,
        minor: TransactionMinorState.DeletePurse,
      },
    });
    // Restart the exchange for the remainder of the test
    await exchange.start();
    await wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
      transactionId: initiate.transactionId,
      txState: {
        major: TransactionMajorState.Aborted,
      },
    });
  }

  t.logStep("P2P pull expire");
  {
    const tx = await initPeerPullCredit("expire");

    const [prepare2, prepare3] = await Promise.all([wallet2, wallet3].map(w =>
      w.call(
        WalletApiOperation.PreparePeerPullDebit,
        { talerUri: tx.talerUri! }
      )
    ));

    const timetravelOffsetMs = Duration.toMilliseconds(
      Duration.fromSpec({ days: 5 }),
    );

    await exchange.stop();
    exchange.setTimetravel(timetravelOffsetMs);
    await Promise.all([exchange.start(), exchange.runExpireOnce()]);

    await Promise.all([wallet1, wallet2, wallet3].map(w =>
      w.call(WalletApiOperation.TestingSetTimetravel, {
        offsetMs: timetravelOffsetMs,
      })
    ));

    await wallet2.call(WalletApiOperation.ConfirmPeerPullDebit, {
      transactionId: prepare2.transactionId,
    });

    await Promise.all([
      wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: tx.transactionId,
        txState: {
          major: TransactionMajorState.Expired,
        },
      }),
      wallet2.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: prepare2.transactionId,
        txState: {
          major: TransactionMajorState.Aborted,
        },
      }),
      wallet3.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: prepare3.transactionId,
        txState: {
          major: TransactionMajorState.Aborted,
        },
      }),
    ]);

    const expired_purse = await t.assertThrowsTalerErrorAsyncLegacy(wallet1.call(
      WalletApiOperation.PreparePeerPullDebit,
      { talerUri: tx.talerUri! }
    ));
    t.assertTrue(expired_purse.errorDetail.code === TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE);
  }

  await t.runSpanAsync("P2P pull delete", async () => {
    const delAll = async (w: WalletClient) => {
      const txn1 = await w.call(WalletApiOperation.GetTransactionsV2, {
        includeAll: true,
      });
      for (const txn of txn1.transactions) {
        await w.call(WalletApiOperation.DeleteTransaction, {
          transactionId: txn.transactionId,
        });
      }
      const txn2 = await w.call(WalletApiOperation.GetTransactionsV2, {
        includeAll: true,
      });
      t.assertDeepEqual(txn2.transactions.length, 0);
    };
    await Promise.all([wallet1, wallet2, wallet3, wallet4].map(delAll))
  });
}

runPeerPullTest.suites = ["wallet"];
