/*
 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,
  TransactionPeerPushDebit,
  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 purseExpiration = AbsoluteTime.toProtocolTimestamp(
  AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ days: 2 })),
);

/**
 * Run a test for basic peer-push payments.
 */
export async function runPeerPushTest(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.
  const withdrawRes = await withdrawViaBankV3(t, {
    walletClient: wallet1,
    bankClient,
    exchange,
    amount: "TESTKUDOS:200",
  });
  await withdrawRes.withdrawalFinishedCond;

  async function initPeerPushDebit(
    summary: string,
    amount: AmountString = "TESTKUDOS:5",
  ): Promise<TransactionPeerPushDebit> {
    const initiate = await wallet1.call(
      WalletApiOperation.InitiatePeerPushDebit,
      {
        partialContractTerms: {
          summary,
          amount,
          purse_expiration: purseExpiration,
        },
      },
    );

    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.PeerPushDebit);
    return tx;
  }

  t.logStep("P2P push amount logic");
  {
    const [maxpeer, checkzero, checkfive] = await Promise.all([
      wallet1.call(WalletApiOperation.GetMaxPeerPushDebitAmount, {
        currency: "TESTKUDOS",
      }),
      wallet1.call(WalletApiOperation.CheckPeerPushDebit, {
        amount: "TESTKUDOS:0",
      }),
      wallet1.call(WalletApiOperation.CheckPeerPushDebit, {
        amount: "TESTKUDOS:5",
      }),
    ]);

    t.assertDeepEqual(maxpeer.exchangeBaseUrl, exchange.baseUrl);
    t.assertAmountEquals(maxpeer.rawAmount, "TESTKUDOS:192.27");
    t.assertAmountEquals(maxpeer.effectiveAmount, "TESTKUDOS:196.12");

    t.assertAmountEquals(checkzero.amountRaw, "TESTKUDOS:0");
    t.assertAmountEquals(checkzero.amountEffective, "TESTKUDOS:0");

    t.assertAmountEquals(checkfive.amountRaw, "TESTKUDOS:5");
    t.assertAmountEquals(checkfive.amountEffective, "TESTKUDOS:5.49");
  }

  t.logStep("P2P push errors");
  {
    const ex1 = await t.assertThrowsTalerErrorAsyncLegacy(
      wallet1.call(WalletApiOperation.InitiatePeerPushDebit, {
        partialContractTerms: {
          summary: "(this will fail)",
          amount: "TESTKUDOS:250",
          purse_expiration: purseExpiration,
        },
      }),
    );
    t.assertTrue(
      ex1.errorDetail.code === TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
    );
    // FIXME propagate the error correctly
    // t.assertTrue(ex1.errorDetail.code === TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE);

    const unknown_purse = await t.assertThrowsTalerErrorAsyncLegacy(
      wallet1.call(WalletApiOperation.PreparePeerPushCredit, {
        talerUri:
          "taler+http://pay-push/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 push confirm");
  {
    const tx = await initPeerPushDebit("confirm");

    // Check balance update
    {
      const bal = await wallet1.call(WalletApiOperation.GetBalances, {});
      t.assertAmountEquals(bal.balances[0].pendingOutgoing, "TESTKUDOS:5.49");
    }

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

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

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

    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,
            },
          }),
        ]),
      ]),
      wallet4.call(WalletApiOperation.TestingWaitTransactionState, {
        transactionId: prepare4.transactionId,
        txState: {
          major: TransactionMajorState.Aborted,
        },
      }),
    ]);

    {
      const bal = await wallet1.call(WalletApiOperation.GetBalances, {});
      t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:190.63");
    }

    // Check scan after completion
    const prepare1 = await wallet1.call(
      WalletApiOperation.PreparePeerPushCredit,
      { talerUri: tx.talerUri! },
    ); // FIXME this should fails
    await wallet1.call(WalletApiOperation.ConfirmPeerPushCredit, {
      transactionId: prepare1.transactionId,
    });
    // FIXME this should also fail
  }

  t.logStep("P2P pull self");
  {
    const tx = await initPeerPushDebit("self");
    const prepare = await wallet1.call(
      WalletApiOperation.PreparePeerPushCredit,
      { talerUri: tx.talerUri! }
    );
    await wallet1.call(WalletApiOperation.ConfirmPeerPushCredit, {
      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.PreparePeerPushCredit,
      { talerUri: tx.talerUri! }
    );
    t.assertTrue(prepare.transactionId === idempotent.transactionId);
  }

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

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

    await exchange.stop();

    await Promise.all([
      wallet2.call(WalletApiOperation.ConfirmPeerPushCredit, {
        transactionId: prepare2.transactionId,
      }),
      wallet3.call(WalletApiOperation.ConfirmPeerPushCredit, {
        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 push abort");
  {
    const tx = await initPeerPushDebit("abort");

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

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

    await wallet2.call(WalletApiOperation.ConfirmPeerPushCredit, {
      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,
        },
      }),
    ]);
  }

  await t.runSpanAsync("P2P push abort before create purse", async () => {
    // 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.InitiatePeerPushDebit,
      {
        partialContractTerms: {
          summary: "abort before purse creation",
          amount: "TESTKUDOS:5",
          purse_expiration: purseExpiration,
        },
      },
    );
    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 push expire");
  {
    const tx = await initPeerPushDebit("expire");

    const [prepare2, prepare3] = await Promise.all(
      [wallet2, wallet3].map((w) =>
        w.call(WalletApiOperation.PreparePeerPushCredit, {
          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.client.call(WalletApiOperation.ConfirmPeerPushCredit, {
      transactionId: prepare2.transactionId,
    });

    t.logStep("P2P start wait for expiration");

    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,
        },
      }),
    ]);
  }

  // Test deleting p2p transaction.
  // Tests against a regression.
  await t.runSpanAsync("P2P push 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))
  });
}

runPeerPushTest.suites = ["wallet"];
