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

package db

import (
	"errors"
	"testing"
	"time"

	"github.com/ChainSafe/chaindb"
	ethcommon "github.com/ethereum/go-ethereum/common"
	logging "github.com/ipfs/go-log/v2"
	"github.com/libp2p/go-libp2p/core/peer"
	"github.com/stretchr/testify/require"

	"github.com/athanorlabs/atomic-swap/coins"
	"github.com/athanorlabs/atomic-swap/common/types"
	"github.com/athanorlabs/atomic-swap/common/vjson"
	"github.com/athanorlabs/atomic-swap/protocol/swap"
)

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

var testPeerID, _ = peer.Decode("12D3KooWQQRJuKTZ35eiHGNPGDpQqjpJSdaxEMJRxi6NWFrrvQVi")

// infoAsJSON converts an Info object to a JSON string. Converting
// the struct to JSON is the easiest way to compare 2 structs for
// equality, as there are many pointer fields.
func infoAsJSON(t *testing.T, info *swap.Info) string {
	jsonData, err := vjson.MarshalStruct(info)
	require.NoError(t, err)
	return string(jsonData)
}

func TestDatabase_OfferTable(t *testing.T) {
	db, err := NewDatabase(&chaindb.Config{
		DataDir:  t.TempDir(),
		InMemory: true,
	})
	require.NoError(t, err)

	// put swap to ensure iterator over offers is ok
	infoA := &swap.Info{
		Version:              swap.CurInfoVersion,
		PeerID:               testPeerID,
		OfferID:              types.Hash{0x1},
		Provides:             coins.ProvidesXMR,
		ProvidedAmount:       coins.StrToDecimal("0.1"),
		ExpectedAmount:       coins.StrToDecimal("1"),
		ExchangeRate:         coins.StrToExchangeRate("0.1"),
		EthAsset:             types.EthAsset{},
		Status:               types.ExpectingKeys,
		LastStatusUpdateTime: time.Now(),
		MoneroStartHeight:    12345,
		StartTime:            time.Now().Add(-30 * time.Minute),
		EndTime:              nil,
		Timeout1:             nil,
		Timeout2:             nil,
	}

	err = db.PutSwap(infoA)
	require.NoError(t, err)

	one := coins.StrToDecimal("1")
	oneEx := coins.ToExchangeRate(one)
	offerA := types.NewOffer(coins.ProvidesXMR, one, one, oneEx, types.EthAssetETH)
	err = db.PutOffer(offerA)
	require.NoError(t, err)

	offerB := types.NewOffer(coins.ProvidesXMR, one, one, oneEx, types.EthAssetETH)
	err = db.PutOffer(offerB)
	require.NoError(t, err)

	offers, err := db.GetAllOffers()
	require.NoError(t, err)
	require.Equal(t, 2, len(offers))

	err = db.ClearAllOffers()
	require.NoError(t, err)

	offers, err = db.GetAllOffers()
	require.NoError(t, err)
	require.Equal(t, 0, len(offers))
}

func TestDatabase_GetAllOffers_InvalidEntry(t *testing.T) {
	db, err := NewDatabase(&chaindb.Config{
		DataDir:  t.TempDir(),
		InMemory: true,
	})
	require.NoError(t, err)
	defer func() { require.NoError(t, db.Close()) }()

	// Put a bad offer that won't deserialize in the database
	badOfferID := types.Hash{0x1, 0x2, 0x3}
	err = db.offerTable.Put(badOfferID[:], []byte(`{"key":"value"}`))
	require.NoError(t, err)

	// Put a good offer in the database
	goodOffer := types.NewOffer(
		coins.ProvidesXMR,
		coins.StrToDecimal("1"),
		coins.StrToDecimal("2"),
		coins.ToExchangeRate(coins.StrToDecimal("0.10")),
		types.EthAssetETH,
	)
	err = db.PutOffer(goodOffer)
	require.NoError(t, err)

	// Put a swap entry tied to the bad offer in the database
	swapEntry := &swap.Info{
		Version:              swap.CurInfoVersion,
		PeerID:               testPeerID,
		OfferID:              badOfferID,
		Provides:             coins.ProvidesXMR,
		ProvidedAmount:       coins.StrToDecimal("0.1"),
		ExpectedAmount:       coins.StrToDecimal("1"),
		ExchangeRate:         coins.StrToExchangeRate("0.1"),
		EthAsset:             types.EthAsset{},
		Status:               types.ExpectingKeys,
		LastStatusUpdateTime: time.Now(),
		MoneroStartHeight:    12345,
		Timeout1:             nil,
		Timeout2:             nil,
		StartTime:            time.Now().Add(-30 * time.Minute),
		EndTime:              nil,
	}
	err = db.PutSwap(swapEntry)
	require.NoError(t, err)

	// Establish a baseline that both the good and bad entries exist before calling GetAllOffers
	exists, err := db.offerTable.Has(goodOffer.ID[:])
	require.NoError(t, err)
	require.True(t, exists)

	exists, err = db.offerTable.Has(badOfferID[:])
	require.NoError(t, err)
	require.True(t, exists)

	exists, err = db.swapTable.Has(badOfferID[:])
	require.NoError(t, err)
	require.True(t, exists)

	// Only the good offer should be returned by GetAllOffers
	offers, err := db.GetAllOffers()
	require.NoError(t, err)
	require.Equal(t, 1, len(offers))
	require.EqualValues(t, goodOffer.ID[:], offers[0].ID[:])

	// GetAllOffers should have pruned the bad offer, but left the good offer
	exists, err = db.offerTable.Has(goodOffer.ID[:])
	require.NoError(t, err)
	require.True(t, exists)

	exists, err = db.offerTable.Has(badOfferID[:])
	require.NoError(t, err)
	require.False(t, exists) // offer entry was pruned

	exists, err = db.swapTable.Has(badOfferID[:])
	require.NoError(t, err)
	require.False(t, exists) // swap info tied to removed offer pruned
}

func TestDatabase_SwapTable(t *testing.T) {
	cfg := &chaindb.Config{
		DataDir:  t.TempDir(),
		InMemory: true,
	}

	db, err := NewDatabase(cfg)
	require.NoError(t, err)

	one := coins.StrToDecimal("1")
	oneEx := coins.ToExchangeRate(one)
	ethAsset := types.EthAsset(ethcommon.HexToAddress("0xa1E32d14AC4B6d8c1791CAe8E9baD46a1E15B7a8"))

	offerA := types.NewOffer(coins.ProvidesXMR, one, one, oneEx, ethAsset)
	err = db.PutOffer(offerA)
	require.NoError(t, err)

	startTime := time.Now().Add(-2 * time.Minute)
	timeout1 := time.Now().Add(30 * time.Minute)
	timeout2 := time.Now().Add(60 * time.Minute)

	infoA := &swap.Info{
		Version:              swap.CurInfoVersion,
		PeerID:               testPeerID,
		OfferID:              offerA.ID,
		Provides:             offerA.Provides,
		ProvidedAmount:       offerA.MinAmount,
		ExpectedAmount:       offerA.MinAmount,
		ExchangeRate:         offerA.ExchangeRate,
		EthAsset:             offerA.EthAsset,
		Status:               types.ContractReady,
		LastStatusUpdateTime: time.Now(),
		MoneroStartHeight:    12345,
		StartTime:            startTime,
		EndTime:              nil,
		Timeout1:             &timeout1,
		Timeout2:             &timeout2,
	}
	err = db.PutSwap(infoA)
	require.NoError(t, err)

	infoB := &swap.Info{
		Version:              swap.CurInfoVersion,
		PeerID:               testPeerID,
		OfferID:              types.Hash{0x2},
		Provides:             coins.ProvidesXMR,
		ProvidedAmount:       coins.StrToDecimal("1.5"),
		ExpectedAmount:       coins.StrToDecimal("0.15"),
		ExchangeRate:         coins.ToExchangeRate(coins.StrToDecimal("0.1")),
		EthAsset:             ethAsset,
		Status:               types.XMRLocked,
		LastStatusUpdateTime: time.Now(),
		MoneroStartHeight:    12345,
		StartTime:            startTime,
		EndTime:              nil,
		Timeout1:             &timeout1,
		Timeout2:             &timeout2,
	}
	err = db.PutSwap(infoB)
	require.NoError(t, err)

	res, err := db.GetSwap(offerA.ID)
	require.NoError(t, err)
	require.Equal(t, infoAsJSON(t, infoA), infoAsJSON(t, res))

	swaps, err := db.GetAllSwaps()
	require.NoError(t, err)
	require.Equal(t, 2, len(swaps))
}

func TestDatabase_GetAllSwaps_InvalidEntry(t *testing.T) {
	db, err := NewDatabase(&chaindb.Config{
		DataDir:  t.TempDir(),
		InMemory: true,
	})
	require.NoError(t, err)

	startTime := time.Now().Add(-2 * time.Minute)
	timeout1 := time.Now().Add(30 * time.Minute)
	timeout2 := time.Now().Add(60 * time.Minute)

	goodInfo := &swap.Info{
		Version:              swap.CurInfoVersion,
		PeerID:               testPeerID,
		OfferID:              types.Hash{0x1, 0x2, 0x3},
		Provides:             coins.ProvidesXMR,
		ProvidedAmount:       coins.StrToDecimal("1.5"),
		ExpectedAmount:       coins.StrToDecimal("0.15"),
		ExchangeRate:         coins.ToExchangeRate(coins.StrToDecimal("0.1")),
		EthAsset:             types.EthAsset{},
		Status:               types.ETHLocked,
		LastStatusUpdateTime: time.Now(),
		MoneroStartHeight:    12345,
		StartTime:            startTime,
		EndTime:              nil,
		Timeout1:             &timeout1,
		Timeout2:             &timeout2,
	}
	err = db.PutSwap(goodInfo)
	require.NoError(t, err)

	// Put a bad entry in the database
	badInfoID := types.Hash{0x4, 0x5, 0x6}
	err = db.swapTable.Put(badInfoID[:], []byte(`{"key":"value"}`))
	require.NoError(t, err)

	// Establish a baseline that both the good and bad entries exist before calling GetAllSwaps
	exists, err := db.swapTable.Has(goodInfo.OfferID[:])
	require.NoError(t, err)
	require.True(t, exists)

	exists, err = db.swapTable.Has(badInfoID[:])
	require.NoError(t, err)
	require.True(t, exists)

	// Only the good offer should be returned by GetAllSwaps
	swaps, err := db.GetAllSwaps()
	require.NoError(t, err)
	require.Equal(t, 1, len(swaps))
	require.EqualValues(t, goodInfo.OfferID[:], swaps[0].OfferID[:])

	// GetAllSwaps should have pruned the bad swap info entry, but left the good entry
	exists, err = db.swapTable.Has(goodInfo.OfferID[:])
	require.NoError(t, err)
	require.True(t, exists) // entry still exists

	exists, err = db.swapTable.Has(badInfoID[:])
	require.NoError(t, err)
	require.False(t, exists) // entry was pruned
}

func TestDatabase_SwapTable_Update(t *testing.T) {
	cfg := &chaindb.Config{
		DataDir:  t.TempDir(),
		InMemory: true,
	}

	db, err := NewDatabase(cfg)
	require.NoError(t, err)

	id := types.Hash{0x1}
	startTime := time.Now().Add(-2 * time.Minute)
	timeout1 := time.Now().Add(30 * time.Minute)
	timeout2 := time.Now().Add(60 * time.Minute)

	infoA := &swap.Info{
		Version:              swap.CurInfoVersion,
		PeerID:               testPeerID,
		OfferID:              id,
		Provides:             coins.ProvidesXMR,
		ProvidedAmount:       coins.StrToDecimal("0.1"),
		ExpectedAmount:       coins.StrToDecimal("1"),
		ExchangeRate:         coins.StrToExchangeRate("0.1"),
		EthAsset:             types.EthAsset{},
		Status:               types.XMRLocked,
		LastStatusUpdateTime: time.Now(),
		MoneroStartHeight:    12345,
		StartTime:            startTime,
		EndTime:              nil,
		Timeout1:             &timeout1,
		Timeout2:             &timeout2,
	}
	err = db.PutSwap(infoA)
	require.NoError(t, err)

	// infoB mostly the same as infoA (same ID, importantly), but with
	// a couple updated fields.
	infoB, err := infoA.DeepCopy()
	require.NoError(t, err)
	infoB.Status = types.CompletedSuccess
	infoB.MarkSwapComplete()

	err = db.PutSwap(infoB)
	require.NoError(t, err)

	res, err := db.GetSwap(id)
	require.NoError(t, err)
	require.Equal(t, infoAsJSON(t, infoB), infoAsJSON(t, res))
}

func TestDatabase_SwapTable_GetSwap_err(t *testing.T) {
	cfg := &chaindb.Config{
		DataDir:  t.TempDir(),
		InMemory: true,
	}

	db, err := NewDatabase(cfg)
	require.NoError(t, err)

	_, err = db.GetSwap(types.Hash{0x1})
	require.True(t, errors.Is(chaindb.ErrKeyNotFound, err))
}
