// Copyright 2023 The AthanorLabs/atomic-swap Authors
// SPDX-License-Identifier: LGPL-3.0-only

package monero

import (
	"context"
	"os"
	"path"
	"testing"
	"time"

	logging "github.com/ipfs/go-log/v2"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/athanorlabs/atomic-swap/coins"
	"github.com/athanorlabs/atomic-swap/common"
	mcrypto "github.com/athanorlabs/atomic-swap/crypto/monero"
)

var moneroWalletRPCPath = path.Join("..", "monero-bin", "monero-wallet-rpc")

func init() {
	_ = logging.SetLogLevel("monero", "debug")
}

func TestClient_Transfer(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
	defer cancel()
	transferAmt := coins.MoneroToPiconero(coins.StrToDecimal("10"))
	transferAmtPlusFees := coins.MoneroToPiconero(coins.StrToDecimal("10.01"))

	cXMRMaker := CreateWalletClient(t)
	MineMinXMRBalance(t, cXMRMaker, transferAmtPlusFees)

	balanceBob := GetBalance(t, cXMRMaker)
	t.Logf("Bob's initial balance: bal=%s XMR, unlocked=%s XMR, blocks-to-unlock=%d",
		coins.FmtPiconeroAsXMR(balanceBob.Balance),
		coins.FmtPiconeroAsXMR(balanceBob.UnlockedBalance),
		balanceBob.BlocksToUnlock)

	transferAmtU64, err := transferAmt.Uint64()
	require.NoError(t, err)
	require.Greater(t, balanceBob.UnlockedBalance, transferAmtU64)

	kpA, err := mcrypto.GenerateKeys()
	require.NoError(t, err)

	kpB, err := mcrypto.GenerateKeys()
	require.NoError(t, err)

	abAddress := mcrypto.SumSpendAndViewKeys(kpA.PublicKeyPair(), kpB.PublicKeyPair()).Address(common.Development)
	vkABPriv := mcrypto.SumPrivateViewKeys(kpA.ViewKey(), kpB.ViewKey())

	// Transfer from Bob's account to the Alice+Bob swap account
	transfer, err := cXMRMaker.Transfer(ctx, abAddress, 0, transferAmt, MinSpendConfirmations)
	require.NoError(t, err)
	t.Logf("Bob sent %s (+fee %s) XMR to A+B address with TX ID %s",
		coins.FmtPiconeroAsXMR(transfer.Amount),
		coins.FmtPiconeroAsXMR(transfer.Fee),
		transfer.TxID)
	require.NoError(t, err)
	require.GreaterOrEqual(t, transfer.Confirmations, uint64(MinSpendConfirmations))
	t.Logf("Bob's TX was mined at height %d with %d confirmations", transfer.Height, transfer.Confirmations)
	cXMRMaker.Close() // Done with bob, make sure no one uses him again
	cXMRMaker = nil

	// Establish Alice's primary wallet
	alicePrimaryWallet := "test-swap-wallet"
	cXMRTaker, err := NewWalletClient(&WalletClientConf{
		Env:                 common.Development,
		WalletFilePath:      path.Join(t.TempDir(), "wallet", alicePrimaryWallet),
		MoneroWalletRPCPath: moneroWalletRPCPath,
	})
	require.NoError(t, err)
	alicePrimaryAddr := cXMRTaker.PrimaryAddress()

	// Alice generates a view-only wallet for A+B to confirm that Bob sent the funds
	conf := cXMRTaker.CreateWalletConf("alice-view-wallet-to-verify-funds")
	abViewCli, err := CreateViewOnlyWalletFromKeys(conf, vkABPriv, abAddress, transfer.Height)
	require.NoError(t, err)
	defer abViewCli.CloseAndRemoveWallet()

	balanceABWal := GetBalance(t, abViewCli)
	height, err := abViewCli.GetHeight()
	require.NoError(t, err)
	t.Logf("A+B View-Only wallet balance: bal=%s unlocked=%s blocks-to-unlock=%d, cur-height=%d",
		coins.FmtPiconeroAsXMR(balanceABWal.Balance),
		coins.FmtPiconeroAsXMR(balanceABWal.UnlockedBalance),
		balanceABWal.BlocksToUnlock, height)
	require.Zero(t, balanceABWal.BlocksToUnlock)
	require.Equal(t, balanceABWal.Balance, balanceABWal.UnlockedBalance)
	require.Equal(t, transferAmtU64, balanceABWal.UnlockedBalance)

	// At this point Alice has received the key from Bob to create an A+B spend wallet.
	// She'll now sweep the funds from the A+B spend wallet into her primary wallet.
	abWalletKeyPair := mcrypto.NewPrivateKeyPair(
		mcrypto.SumPrivateSpendKeys(kpA.SpendKey(), kpB.SpendKey()),
		mcrypto.SumPrivateViewKeys(kpA.ViewKey(), kpB.ViewKey()),
	)
	require.NoError(t, err)
	conf = abViewCli.CreateWalletConf("alice-spend-wallet-to-claim")
	abSpendCli, err := CreateSpendWalletFromKeys(conf, abWalletKeyPair, transfer.Height)
	require.NoError(t, err)
	defer abSpendCli.CloseAndRemoveWallet()
	require.Equal(t, abSpendCli.PrimaryAddress(), abViewCli.PrimaryAddress())

	balanceABWal = GetBalance(t, abSpendCli)
	// Verify that the spend wallet, like the view-only wallet, has the exact amount expected in it
	require.Equal(t, transferAmtU64, balanceABWal.UnlockedBalance)

	// Alice transfers from A+B spend wallet to her primary wallet's address
	transfers, err := abSpendCli.SweepAll(ctx, alicePrimaryAddr, 0, SweepToSelfConfirmations)
	require.NoError(t, err)
	t.Logf("Alice swept AB wallet funds with %d transfers", len(transfers))
	require.Len(t, transfers, 1) // In our case, it should always be a single transaction
	sweepAmount := transfers[0].Amount
	sweepFee := transfers[0].Fee

	t.Logf("Sweep of A+B wallet sent %s XMR with fees %s XMR to Alice's primary wallet",
		coins.FmtPiconeroAsXMR(sweepAmount), coins.FmtPiconeroAsXMR(sweepFee))
	require.Equal(t, transferAmtU64, sweepAmount+sweepFee)
	t.Logf("Alice's sweep transactions was mined at height %d with %d confirmations",
		transfer.Height, transfer.Confirmations)

	// Verify zero balance of A+B wallet after sweep
	balanceABWal = GetBalance(t, abSpendCli)
	require.Equal(t, balanceABWal.Balance, uint64(0))

	balanceAlice := GetBalance(t, cXMRTaker)
	t.Logf("Alice's primary wallet after sweep: bal=%s XMR, unlocked=%s XMR, blocks-to-unlock=%d",
		coins.FmtPiconeroAsXMR(balanceAlice.Balance),
		coins.FmtPiconeroAsXMR(balanceAlice.UnlockedBalance),
		balanceAlice.BlocksToUnlock)
	require.Equal(t, balanceAlice.Balance, sweepAmount)
}

func Test_walletClient_SweepAll_nothingToSweepReturnsError(t *testing.T) {
	emptyWallet := CreateWalletClient(t)
	takerWallet := CreateWalletClient(t)

	addrResp, err := takerWallet.GetAddress(0)
	require.NoError(t, err)

	destAddr, err := mcrypto.NewAddress(addrResp.Address, common.Development)
	require.NoError(t, err)

	_, err = emptyWallet.SweepAll(context.Background(), destAddr, 0, SweepToSelfConfirmations)
	require.ErrorContains(t, err, "no balance to sweep")
}

func TestClient_CloseAndRemoveWallet(t *testing.T) {
	password := t.Name()
	walletPath := path.Join(t.TempDir(), "wallet", "test-wallet")
	c, err := NewWalletClient(&WalletClientConf{
		Env:                 common.Development,
		WalletFilePath:      walletPath,
		WalletPassword:      password,
		MoneroWalletRPCPath: moneroWalletRPCPath,
	})
	require.NoError(t, err)
	info, err := os.Stat(walletPath)
	require.NoError(t, err)
	assert.Greater(t, info.Size(), int64(0))
	c.CloseAndRemoveWallet()
	_, err = os.Stat(walletPath)
	require.ErrorIs(t, err, os.ErrNotExist)
}

func TestClient_GetAccounts(t *testing.T) {
	c, err := NewWalletClient(&WalletClientConf{
		Env:                 common.Development,
		WalletFilePath:      path.Join(t.TempDir(), "wallet", "test-wallet"),
		MoneroWalletRPCPath: moneroWalletRPCPath,
	})
	require.NoError(t, err)
	defer c.Close()
	resp, err := c.GetAccounts()
	require.NoError(t, err)
	require.Equal(t, 1, len(resp.SubaddressAccounts))
}

func TestClient_GetHeight(t *testing.T) {
	c, err := NewWalletClient(&WalletClientConf{
		Env:                 common.Development,
		WalletFilePath:      path.Join(t.TempDir(), "wallet", "test-wallet"),
		MoneroWalletRPCPath: moneroWalletRPCPath,
	})
	require.NoError(t, err)
	defer c.Close()

	walletHeight, err := c.GetHeight()
	require.NoError(t, err)
	chainHeight, err := c.(*walletClient).getChainHeight()
	require.NoError(t, err)
	require.GreaterOrEqual(t, chainHeight, walletHeight)
	require.LessOrEqual(t, chainHeight-walletHeight, uint64(2))
}

func TestCallGenerateFromKeys(t *testing.T) {
	kp, err := mcrypto.GenerateKeys()
	require.NoError(t, err)

	c, err := NewWalletClient(&WalletClientConf{
		Env:                 common.Development,
		WalletFilePath:      path.Join(t.TempDir(), "wallet", "not-used"),
		MoneroWalletRPCPath: moneroWalletRPCPath,
	})
	require.NoError(t, err)
	defer c.Close()

	height, err := c.GetHeight()
	require.NoError(t, err)

	addr, err := c.GetAddress(0)
	require.NoError(t, err)
	t.Logf("Address %s", addr.Address)

	// initial wallet automatically closed when a new wallet is opened
	err = c.(*walletClient).generateFromKeys(
		kp.SpendKey(),
		kp.ViewKey(),
		kp.PublicKeyPair().Address(common.Mainnet),
		height,
		"swap-deposit-wallet",
		"",
	)
	require.NoError(t, err)

	addr, err = c.GetAddress(0)
	require.NoError(t, err)
	t.Logf("Address %s", addr.Address)
}

// this tests calling generateFromkeys passing an address derived in
// a non-standard manner; ie. the public view key in the address doesn't
// match the private view key passed in.
func TestCallGenerateFromKeys_UnusualAddress(t *testing.T) {
	kp, err := mcrypto.GenerateKeys()
	require.NoError(t, err)

	kp2, err := mcrypto.GenerateKeys()
	require.NoError(t, err)

	// create keypair with priv spend key of kp, but a different priv view key
	// use the address of this keypair in the call to `generateFromKeys`
	kp3 := mcrypto.NewPrivateKeyPair(kp.SpendKey(), kp2.ViewKey())
	address := kp3.PublicKeyPair().Address(common.Mainnet)
	t.Log("address", address)

	conf := &WalletClientConf{
		Env:                 common.Development,
		WalletFilePath:      path.Join(t.TempDir(), "wallet", "not-used"),
		MoneroWalletRPCPath: moneroWalletRPCPath,
	}
	err = conf.Fill()
	require.NoError(t, err)

	c, err := createWalletFromKeys(
		conf,
		0,
		kp.SpendKey(),
		kp.ViewKey(),
		kp3.PublicKeyPair().Address(common.Mainnet),
	)
	require.NoError(t, err)

	res, err := c.GetAddress(0)
	require.NoError(t, err)
	require.Equal(t, address.String(), res.Address)
}

func Test_getMoneroWalletRPCBin(t *testing.T) {
	wd, err := os.Getwd()
	require.NoError(t, err)
	defer os.Chdir(wd)
	os.Chdir("..")
	walletRPCPath, err := getMoneroWalletRPCBin()
	require.NoError(t, err)
	// monero-bin/monero-wallet-rpc should take precedence over any monero-wallet-rpc in
	// the user's path if the relative path to the binary exists.
	require.Equal(t, "monero-bin/monero-wallet-rpc", walletRPCPath)
}

func Test_validateMonerodConfigs_dev(t *testing.T) {
	env := common.Development
	node, err := findWorkingNode(env, common.ConfigDefaultsForEnv(env).MoneroNodes)
	require.NoError(t, err)
	require.NotNil(t, node)
}

func Test_validateMonerodConfigs_stagenet(t *testing.T) {
	env := common.Stagenet
	node, err := findWorkingNode(env, common.ConfigDefaultsForEnv(env).MoneroNodes)
	require.NoError(t, err)
	require.NotNil(t, node)
}

func Test_validateMonerodConfigs_mainnet(t *testing.T) {
	env := common.Mainnet
	node, err := findWorkingNode(env, common.ConfigDefaultsForEnv(env).MoneroNodes)
	require.NoError(t, err)
	require.NotNil(t, node)
}

func Test_validateMonerodConfig_misMatchedEnv(t *testing.T) {
	node := &common.MoneroNode{
		Host: "127.0.0.1",
		Port: common.DefaultMoneroDaemonDevPort,
	}
	err := validateMonerodNode(common.Mainnet, node)
	require.Error(t, err)
	require.Contains(t, err.Error(), "is not a mainnet node")
}

func Test_validateMonerodConfig_invalidPort(t *testing.T) {
	nonUsedPort, err := common.GetFreeTCPPort()
	require.NoError(t, err)
	node := &common.MoneroNode{
		Host: "127.0.0.1",
		Port: nonUsedPort,
	}
	err = validateMonerodNode(common.Development, node)
	require.Error(t, err)
	require.Contains(t, err.Error(), "connection refused")
}

func Test_walletClient_waitForConfirmations_contextCancelled(t *testing.T) {
	const amount = 10
	const numConfirmations = 999999999 // won't be achieved before our context is cancelled

	minBal := coins.MoneroToPiconero(coins.StrToDecimal("10.01")) // add a little extra for fees
	destAddr, err := mcrypto.NewAddress(blockRewardAddress, common.Development)
	require.NoError(t, err)

	c := CreateWalletClient(t)
	MineMinXMRBalance(t, c, minBal)

	// Cancel the context after the transfer has started
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	_, err = c.Transfer(ctx, destAddr, 0, coins.NewPiconeroAmount(amount), numConfirmations)
	require.ErrorIs(t, err, context.DeadlineExceeded)
}

func TestCreateWalletFromKeys(t *testing.T) {
	// First wallet is just to get the height and generate the config for the 2nd
	primaryCli, err := NewWalletClient(&WalletClientConf{
		Env:                 common.Development,
		WalletFilePath:      path.Join(t.TempDir(), "wallet", "not-used"),
		MoneroWalletRPCPath: moneroWalletRPCPath,
	})
	require.NoError(t, err)
	defer primaryCli.Close()

	height, err := primaryCli.GetHeight()
	require.NoError(t, err)

	kp, err := mcrypto.GenerateKeys()
	require.NoError(t, err)

	conf := primaryCli.CreateWalletConf("ab-wallet")
	abCli, err := CreateSpendWalletFromKeys(conf, kp, height)
	require.NoError(t, err)
	defer abCli.CloseAndRemoveWallet()
	require.Equal(t, kp.PublicKeyPair().Address(common.Development).String(), abCli.PrimaryAddress().String())
}
