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

package xmrmaker

import (
	"context"
	"errors"
	"math/big"
	"testing"
	"time"

	"github.com/cockroachdb/apd/v3"
	"github.com/libp2p/go-libp2p/core/peer"

	"github.com/athanorlabs/atomic-swap/coins"
	"github.com/athanorlabs/atomic-swap/common"
	"github.com/athanorlabs/atomic-swap/common/types"
	contracts "github.com/athanorlabs/atomic-swap/ethereum"
	"github.com/athanorlabs/atomic-swap/ethereum/block"
	"github.com/athanorlabs/atomic-swap/ethereum/extethclient"
	"github.com/athanorlabs/atomic-swap/net/message"
	pcommon "github.com/athanorlabs/atomic-swap/protocol"
	"github.com/athanorlabs/atomic-swap/protocol/xmrmaker/offers"
	"github.com/athanorlabs/atomic-swap/tests"

	ethcommon "github.com/ethereum/go-ethereum/common"
	logging "github.com/ipfs/go-log/v2"
	"github.com/stretchr/testify/require"
)

var (
	_                         = logging.SetLogLevel("protocol", "debug")
	_                         = logging.SetLogLevel("xmrmaker", "debug")
	desiredAmount             = coins.EtherToWei(apd.New(33, -2)) // "0.33"
	defaultTimeoutDuration, _ = time.ParseDuration("86400s")      // 1 day = 60s * 60min * 24hr
	testPeerID, _             = peer.Decode("12D3KooWQQRJuKTZ35eiHGNPGDpQqjpJSdaxEMJRxi6NWFrrvQVi")
)

func newTestSwapStateAndDB(t *testing.T) (*Instance, *swapState, *offers.MockDatabase) {
	xmrmaker, db := newTestInstanceAndDB(t)

	swapState, err := newSwapStateFromStart(
		xmrmaker.backend,
		testPeerID,
		types.NewOffer("", new(apd.Decimal), new(apd.Decimal), new(coins.ExchangeRate), types.EthAssetETH),
		types.NewOfferExtra(false),
		xmrmaker.offerManager,
		coins.MoneroToPiconero(coins.StrToDecimal("0.05")),
		desiredAmount,
	)
	require.NoError(t, err)
	return xmrmaker, swapState, db
}

func newTestSwapState(t *testing.T) (*Instance, *swapState) {
	xmrmaker, swapState, _ := newTestSwapStateAndDB(t)
	return xmrmaker, swapState
}

func newTestXMRTakerSendKeysMessage(t *testing.T) (*message.SendKeysMessage, *pcommon.KeysAndProof) {
	keysAndProof, err := pcommon.GenerateKeysAndProof()
	require.NoError(t, err)

	msg := &message.SendKeysMessage{
		PublicSpendKey:     keysAndProof.PublicKeyPair.SpendKey(),
		PrivateViewKey:     keysAndProof.PrivateKeyPair.ViewKey(),
		DLEqProof:          keysAndProof.DLEqProof.Proof(),
		Secp256k1PublicKey: keysAndProof.Secp256k1PublicKey,
	}

	return msg, keysAndProof
}

func newTestSwap(
	t *testing.T,
	ec extethclient.EthClient,
	contract *contracts.SwapCreator,
	claimKey, refundKey types.Hash,
	amount *big.Int,
	timeout time.Duration,
) (*contracts.SwapCreatorSwap, [32]byte, ethcommon.Hash) {
	tm := big.NewInt(int64(timeout.Seconds()))
	txOpts, err := ec.TxOpts(context.Background())
	require.NoError(t, err)
	txOpts.Value = amount

	ethAddr := ec.Address()
	nonce := big.NewInt(0)
	asset := types.EthAssetETH
	tx, err := contract.NewSwap(txOpts, claimKey, refundKey, ethAddr, tm, tm,
		ethcommon.Address(asset), amount, nonce)
	require.NoError(t, err)
	receipt := tests.MineTransaction(t, ec.Raw(), tx)
	require.Equal(t, 1, len(receipt.Logs))

	contractSwapID, err := contracts.GetIDFromLog(receipt.Logs[0])
	require.NoError(t, err)

	t1, t2, err := contracts.GetTimeoutsFromLog(receipt.Logs[0])
	require.NoError(t, err)

	contractSwap := &contracts.SwapCreatorSwap{
		Owner:            ethAddr,
		Claimer:          ethAddr,
		ClaimCommitment:  claimKey,
		RefundCommitment: refundKey,
		Timeout1:         t1,
		Timeout2:         t2,
		Asset:            ethcommon.Address(asset),
		Value:            amount,
		Nonce:            nonce,
	}

	return contractSwap, contractSwapID, tx.Hash()
}

func newSwap(
	t *testing.T,
	ss *swapState,
	claimKey,
	refundKey types.Hash,
	amount *big.Int,
	timeout time.Duration,
) ethcommon.Hash {
	if types.IsHashZero(claimKey) {
		claimKey = ss.secp256k1Pub.Keccak256()
	}

	contractSwap, contractSwapID, txHash := newTestSwap(
		t, ss.ETHClient(), ss.SwapCreator(), claimKey, refundKey, amount, timeout,
	)

	ss.contractSwapID = contractSwapID
	ss.contractSwap = contractSwap
	ss.setTimeouts(contractSwap.Timeout1, contractSwap.Timeout2)
	return txHash
}

func TestNewSwapState_generateAndSetKeys(t *testing.T) {
	_, swapState := newTestSwapState(t)
	require.NotNil(t, swapState.privkeys)
	require.NotNil(t, swapState.pubkeys)
	require.NotNil(t, swapState.dleqProof)
}

func TestSwapState_ClaimFunds(t *testing.T) {
	// fix this test, for some reason the event handler
	// runs no matter what, resulting in two calls to `claimFunds`
	// causing the call in this test to fail
	t.Skip("TODO")

	_, swapState := newTestSwapState(t)
	// don't handle Ready logs, as handling Ready logs calls claimFunds()
	// which we want to call in this function instead
	swapState.logReadyCh = nil
	// TODO: we have to sleep b/c otherwise the ready handler is already running
	// for some reason
	time.Sleep(time.Second * 3)

	claimKey := swapState.secp256k1Pub.Keccak256()
	newSwap(t, swapState, claimKey,
		dummySwapKey, big.NewInt(33), defaultTimeoutDuration)

	swapState.setNextExpectedEvent(EventContractReadyType)

	txOpts, err := swapState.ETHClient().TxOpts(swapState.ctx)
	require.NoError(t, err)
	tx, err := swapState.SwapCreator().SetReady(txOpts, *swapState.contractSwap)
	require.NoError(t, err)
	tests.MineTransaction(t, swapState.ETHClient().Raw(), tx)

	receipt, err := swapState.claimFunds()
	require.NoError(t, err)
	require.NotNil(t, receipt)
	require.True(t, swapState.info.Status.IsOngoing())
}

func TestSwapState_handleSendKeysMessage(t *testing.T) {
	_, s := newTestSwapState(t)

	msg := &message.SendKeysMessage{}
	err := s.handleSendKeysMessage(msg)
	require.Equal(t, errMissingKeys, err)

	msg, xmrtakerKeysAndProof := newTestXMRTakerSendKeysMessage(t)

	err = s.handleSendKeysMessage(msg)
	require.NoError(t, err)
	require.Equal(t, EventETHLockedType, s.nextExpectedEvent)
	require.Equal(t, xmrtakerKeysAndProof.PublicKeyPair.SpendKey().String(), s.xmrtakerPublicSpendKey.String())
	require.Equal(t, xmrtakerKeysAndProof.PrivateKeyPair.ViewKey().String(), s.xmrtakerPrivateViewKey.String())
	require.True(t, s.info.Status.IsOngoing())
}

func TestSwapState_HandleProtocolMessage_NotifyETHLocked_ok(t *testing.T) {
	_, s := newTestSwapState(t)
	defer s.cancel()
	s.nextExpectedEvent = EventETHLockedType

	xmrtakerKeysAndProof, err := generateKeys()
	require.NoError(t, err)
	err = s.setXMRTakerKeys(
		xmrtakerKeysAndProof.PublicKeyPair.SpendKey(),
		xmrtakerKeysAndProof.PrivateKeyPair.ViewKey(),
		xmrtakerKeysAndProof.Secp256k1PublicKey,
	)
	require.NoError(t, err)

	duration := common.SwapTimeoutFromEnv(common.Development)
	hash := newSwap(t, s, s.secp256k1Pub.Keccak256(), s.xmrtakerSecp256K1PublicKey.Keccak256(),
		desiredAmount.BigInt(), duration)
	addr := s.SwapCreatorAddr()

	msg := &message.NotifyETHLocked{
		Address:        addr,
		ContractSwapID: s.contractSwapID,
		TxHash:         hash,
		ContractSwap:   s.contractSwap,
	}

	err = s.HandleProtocolMessage(msg)
	require.NoError(t, err)
}

func TestSwapState_HandleProtocolMessage_NotifyETHLocked_invalid(t *testing.T) {
	_, s := newTestSwapState(t)
	defer s.cancel()
	s.nextExpectedEvent = EventETHLockedType

	xmrtakerKeysAndProof, err := generateKeys()
	require.NoError(t, err)
	err = s.setXMRTakerKeys(
		xmrtakerKeysAndProof.PublicKeyPair.SpendKey(),
		xmrtakerKeysAndProof.PrivateKeyPair.ViewKey(),
		xmrtakerKeysAndProof.Secp256k1PublicKey,
	)
	require.NoError(t, err)

	msg := &message.NotifyETHLocked{}
	err = s.HandleProtocolMessage(msg)
	require.True(t, errors.Is(err, errMissingAddress))
}

func TestSwapState_HandleProtocolMessage_NotifyETHLocked_timeout(t *testing.T) {
	_, s := newTestSwapState(t)
	defer s.cancel()

	xmrtakerKeysAndProof, err := generateKeys()
	require.NoError(t, err)
	err = s.setXMRTakerKeys(
		xmrtakerKeysAndProof.PublicKeyPair.SpendKey(),
		xmrtakerKeysAndProof.PrivateKeyPair.ViewKey(),
		xmrtakerKeysAndProof.Secp256k1PublicKey,
	)
	require.NoError(t, err)

	duration, err := time.ParseDuration("5s")
	require.NoError(t, err)
	_ = newSwap(t, s, s.secp256k1Pub.Keccak256(), s.xmrtakerSecp256K1PublicKey.Keccak256(),
		desiredAmount.BigInt(), duration)
	addr := s.SwapCreatorAddr()

	err = s.setContract(addr)
	require.NoError(t, err)
	err = s.setNextExpectedEvent(EventContractReadyType)
	require.NoError(t, err)
	require.Equal(t, duration, s.t2.Sub(s.t1))
	require.Equal(t, EventContractReadyType, s.nextExpectedEvent)

	go s.runT1ExpirationHandler()

	for status := range s.SwapManager().GetStatusChan(s.OfferID()) {
		if status == types.CompletedSuccess {
			break
		} else if !status.IsOngoing() {
			t.Fatalf("got wrong exit status %s, expected CompletedSuccess", status)
		}
	}

	require.Equal(t, types.CompletedSuccess, s.info.Status)
}

func TestSwapState_handleRefund(t *testing.T) {
	_, s, db := newTestSwapStateAndDB(t)
	db.EXPECT().PutOffer(s.offer)

	xmrtakerKeysAndProof, err := generateKeys()
	require.NoError(t, err)
	err = s.setXMRTakerKeys(
		xmrtakerKeysAndProof.PublicKeyPair.SpendKey(),
		xmrtakerKeysAndProof.PrivateKeyPair.ViewKey(),
		xmrtakerKeysAndProof.Secp256k1PublicKey,
	)
	require.NoError(t, err)

	duration, err := time.ParseDuration("10m")
	require.NoError(t, err)

	refundKey := xmrtakerKeysAndProof.Secp256k1PublicKey.Keccak256()
	newSwap(t, s, dummySwapKey, refundKey, desiredAmount.BigInt(), duration)

	// lock XMR
	err = s.lockFunds(coins.MoneroToPiconero(s.info.ProvidedAmount))
	require.NoError(t, err)

	// call refund w/ XMRTaker's spend key
	secret := xmrtakerKeysAndProof.PrivateKeyPair.SpendKeyBytes()
	var sc [32]byte
	copy(sc[:], common.Reverse(secret))

	txOpts, err := s.ETHClient().TxOpts(s.ctx)
	require.NoError(t, err)
	tx, err := s.SwapCreator().Refund(txOpts, *s.contractSwap, sc)
	require.NoError(t, err)
	receipt, err := block.WaitForReceipt(s.Backend.Ctx(), s.ETHClient().Raw(), tx.Hash())
	require.NoError(t, err)
	require.Equal(t, 1, len(receipt.Logs))

	// runContractEventWatcher will trigger EventETHRefunded,
	// which will then set the next expected event to EventExit.
	statusCh := s.SwapManager().GetStatusChan(s.info.OfferID)
	for status := range statusCh {
		if !status.IsOngoing() {
			break
		}
	}

	require.Equal(t, types.CompletedRefund, s.info.Status)
}

// test that if the protocol exits early, and XMRTaker refunds, XMRMaker can reclaim his monero
func TestSwapState_Exit_Reclaim(t *testing.T) {
	_, s, db := newTestSwapStateAndDB(t)
	db.EXPECT().PutOffer(s.offer)

	xmrtakerKeysAndProof, err := generateKeys()
	require.NoError(t, err)
	err = s.setXMRTakerKeys(
		xmrtakerKeysAndProof.PublicKeyPair.SpendKey(),
		xmrtakerKeysAndProof.PrivateKeyPair.ViewKey(),
		xmrtakerKeysAndProof.Secp256k1PublicKey,
	)
	require.NoError(t, err)

	duration, err := time.ParseDuration("10m")
	require.NoError(t, err)

	refundKey := xmrtakerKeysAndProof.Secp256k1PublicKey.Keccak256()
	newSwap(t, s, dummySwapKey, refundKey, desiredAmount.BigInt(), duration)

	// lock XMR
	err = s.lockFunds(coins.MoneroToPiconero(s.info.ProvidedAmount))
	require.NoError(t, err)

	balAfterLock, err := s.XMRClient().GetBalance(0)
	require.NoError(t, err)
	t.Logf("Balance after locking funds: %s XMR (%d blocks to unlock)",
		coins.FmtPiconeroAsXMR(balAfterLock.Balance), balAfterLock.BlocksToUnlock)

	// call refund w/ XMRTaker's secret
	secret := xmrtakerKeysAndProof.DLEqProof.Secret()
	var sc [32]byte
	copy(sc[:], secret[:])

	s.nextExpectedEvent = EventContractReadyType

	txOpts, err := s.ETHClient().TxOpts(s.ctx)
	require.NoError(t, err)
	tx, err := s.SwapCreator().Refund(txOpts, *s.contractSwap, sc)
	require.NoError(t, err)
	receipt := tests.MineTransaction(t, s.ETHClient().Raw(), tx)

	require.Equal(t, 1, len(receipt.Logs))
	require.Equal(t, 3, len(receipt.Logs[0].Topics))
	require.Equal(t, refundedTopic, receipt.Logs[0].Topics[0])

	// runContractEventWatcher will trigger EventETHRefunded,
	// which will then set the next expected event to EventExit.
	for status := range s.SwapManager().GetStatusChan(s.info.OfferID) {
		if !status.IsOngoing() {
			require.Equal(t, types.CompletedRefund.String(), status.String())
			break
		}
	}

	balance, err := s.XMRClient().GetBalance(0)
	require.NoError(t, err)
	t.Logf("End balance after refund: %s XMR (%d blocks to unlock)",
		coins.FmtPiconeroAsXMR(balance.Balance), balance.BlocksToUnlock)
	require.Greater(t, balance.Balance, balAfterLock.Balance) // increased by refund (minus some fees)
	require.Equal(t, types.CompletedRefund, s.info.Status)
}

func TestSwapState_Exit_Aborted(t *testing.T) {
	_, s, db := newTestSwapStateAndDB(t)
	db.EXPECT().PutOffer(s.offer)

	s.nextExpectedEvent = EventETHLockedType
	err := s.Exit()
	require.NoError(t, err)
	require.Equal(t, types.CompletedAbort, s.info.Status)
}

func TestSwapState_Exit_Aborted_1(t *testing.T) {
	_, s, db := newTestSwapStateAndDB(t)
	db.EXPECT().PutOffer(s.offer)

	s.nextExpectedEvent = EventETHRefundedType
	err := s.Exit()
	require.True(t, errors.Is(err, errUnexpectedMessageType))
	require.Equal(t, types.CompletedAbort, s.info.Status)
}

func TestSwapState_Exit_Success(t *testing.T) {
	b, s := newTestSwapState(t)
	s.nextExpectedEvent = EventNoneType
	min := coins.StrToDecimal("0.1")
	max := coins.StrToDecimal("0.2")
	rate := coins.ToExchangeRate(coins.StrToDecimal("0.1"))
	s.offer = types.NewOffer(coins.ProvidesXMR, min, max, rate, types.EthAssetETH)
	s.updateStatus(types.CompletedSuccess)
	err := s.Exit()
	require.NoError(t, err)

	// since the swap was successful, the offer should not have been re-added.
	o, oe, _ := b.offerManager.GetOffer(s.offer.ID)
	require.Nil(t, o)
	require.Nil(t, oe)
}

func TestSwapState_Exit_Refunded(t *testing.T) {
	b, s, db := newTestSwapStateAndDB(t)

	b.net.(*MockP2pHost).EXPECT().Advertise()

	min := coins.StrToDecimal("0.1")
	max := coins.StrToDecimal("0.2")
	rate := coins.ToExchangeRate(coins.StrToDecimal("0.1"))
	s.offer = types.NewOffer(coins.ProvidesXMR, min, max, rate, types.EthAssetETH)
	db.EXPECT().PutOffer(s.offer)
	_, err := b.MakeOffer(s.offer, false)
	require.NoError(t, err)

	s.updateStatus(types.CompletedRefund)
	err = s.Exit()
	require.NoError(t, err)

	// since the swap was not successful, the offer should be re-added to the offer manager.
	o, oe, err := b.offerManager.GetOffer(s.offer.ID)
	require.NoError(t, err)
	require.NotNil(t, o)
	require.NotNil(t, oe)
}
