package core

import (
	"bytes"
	"encoding/binary"
	"fmt"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"git.marketally.com/tutus-one/tutus-chain/internal/testchain"
	"git.marketally.com/tutus-one/tutus-chain/pkg/config"
	"git.marketally.com/tutus-one/tutus-chain/pkg/core/block"
	"git.marketally.com/tutus-one/tutus-chain/pkg/core/chaindump"
	"git.marketally.com/tutus-one/tutus-chain/pkg/core/state"
	"git.marketally.com/tutus-one/tutus-chain/pkg/core/storage"
	"git.marketally.com/tutus-one/tutus-chain/pkg/core/transaction"
	"git.marketally.com/tutus-one/tutus-chain/pkg/io"
	"git.marketally.com/tutus-one/tutus-chain/pkg/smartcontract"
	"git.marketally.com/tutus-one/tutus-chain/pkg/smartcontract/trigger"
	"git.marketally.com/tutus-one/tutus-chain/pkg/util"
	"git.marketally.com/tutus-one/tutus-chain/pkg/vm/opcode"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.uber.org/zap/zaptest"
)

func TestVerifyHeader(t *testing.T) {
	bc := newTestChain(t)
	prev := bc.topBlock.Load().(*block.Block).Header
	t.Run("Invalid", func(t *testing.T) {
		t.Run("Hash", func(t *testing.T) {
			h := prev.Hash()
			h[0] = ^h[0]
			hdr := newBlock(bc.config.ProtocolConfiguration, 1, h).Header
			require.ErrorIs(t, bc.verifyHeader(&hdr, &prev), ErrHdrHashMismatch)
		})
		t.Run("Index", func(t *testing.T) {
			hdr := newBlock(bc.config.ProtocolConfiguration, 3, prev.Hash()).Header
			require.ErrorIs(t, bc.verifyHeader(&hdr, &prev), ErrHdrIndexMismatch)
		})
		t.Run("Timestamp", func(t *testing.T) {
			hdr := newBlock(bc.config.ProtocolConfiguration, 1, prev.Hash()).Header
			hdr.Timestamp = 0
			require.ErrorIs(t, bc.verifyHeader(&hdr, &prev), ErrHdrInvalidTimestamp)
		})
	})
	t.Run("Valid", func(t *testing.T) {
		hdr := newBlock(bc.config.ProtocolConfiguration, 1, prev.Hash()).Header
		require.NoError(t, bc.verifyHeader(&hdr, &prev))
	})
}

func TestAddBlock(t *testing.T) {
	const size = 3
	bc := newTestChain(t)
	blocks, err := bc.genBlocks(size)
	require.NoError(t, err)

	lastBlock := blocks[len(blocks)-1]
	assert.Equal(t, lastBlock.Index, bc.HeaderHeight())
	assert.Equal(t, lastBlock.Hash(), bc.CurrentHeaderHash())

	// This one tests persisting blocks, so it does need to persist()
	_, err = bc.persist()
	require.NoError(t, err)

	key := make([]byte, 1+util.Uint256Size)
	key[0] = byte(storage.DataExecutable)
	for _, block := range blocks {
		copy(key[1:], block.Hash().BytesBE())
		_, err := bc.dao.Store.Get(key)
		require.NoErrorf(t, err, "block %s not persisted", block.Hash())
	}

	assert.Equal(t, lastBlock.Index, bc.BlockHeight())
	assert.Equal(t, lastBlock.Hash(), bc.CurrentHeaderHash())
}

func TestRemoveOldTransfers(t *testing.T) {
	// Creating proper number of transfers/blocks takes unnecessary time, so emulate
	// some DB with stale entries.
	bc := newTestChain(t)
	h, err := bc.GetHeader(bc.GetHeaderHash(0))
	require.NoError(t, err)
	older := h.Timestamp - 1000
	newer := h.Timestamp + 1000
	acc1 := util.Uint160{1}
	acc2 := util.Uint160{2}
	acc3 := util.Uint160{3}
	ttl := state.TokenTransferLog{Raw: []byte{1}} // It's incorrect, but who cares.

	for i := range uint32(3) {
		bc.dao.PutTokenTransferLog(acc1, older, i, false, &ttl)
	}
	for i := range uint32(3) {
		bc.dao.PutTokenTransferLog(acc2, newer, i, false, &ttl)
	}
	for i := range uint32(2) {
		bc.dao.PutTokenTransferLog(acc3, older, i, true, &ttl)
	}
	for i := range uint32(2) {
		bc.dao.PutTokenTransferLog(acc3, newer, i, true, &ttl)
	}

	_, err = bc.dao.Persist()
	require.NoError(t, err)
	_ = bc.gcBlockTimes.Add(0, h.Timestamp)
	_ = bc.removeOldTransfers(0)

	for i := range uint32(2) {
		log, err := bc.dao.GetTokenTransferLog(acc1, older, i, false)
		require.NoError(t, err)
		require.Equal(t, 0, len(log.Raw))
	}

	log, err := bc.dao.GetTokenTransferLog(acc1, older, 2, false)
	require.NoError(t, err)
	require.NotEqual(t, 0, len(log.Raw))

	for i := range uint32(3) {
		log, err = bc.dao.GetTokenTransferLog(acc2, newer, i, false)
		require.NoError(t, err)
		require.NotEqual(t, 0, len(log.Raw))
	}

	log, err = bc.dao.GetTokenTransferLog(acc3, older, 0, true)
	require.NoError(t, err)
	require.Equal(t, 0, len(log.Raw))

	log, err = bc.dao.GetTokenTransferLog(acc3, older, 1, true)
	require.NoError(t, err)
	require.NotEqual(t, 0, len(log.Raw))

	for i := range uint32(2) {
		log, err = bc.dao.GetTokenTransferLog(acc3, newer, i, true)
		require.NoError(t, err)
		require.NotEqual(t, 0, len(log.Raw))
	}
}

func checkNewBlockchainErr(t *testing.T, cfg func(c *config.Config), store storage.Store, errText string) {
	unitTestNetCfg, err := config.Load("../../config", testchain.Network())
	require.NoError(t, err)
	cfg(&unitTestNetCfg)
	log := zaptest.NewLogger(t)
	_, err = NewBlockchain(store, unitTestNetCfg.Blockchain(), log)
	if len(errText) != 0 {
		require.Error(t, err)
		require.True(t, strings.Contains(err.Error(), errText))
	} else {
		require.NoError(t, err)
	}
}

func TestNewBlockchainIncosistencies(t *testing.T) {
	t.Run("state exchange without state root", func(t *testing.T) {
		checkNewBlockchainErr(t, func(c *config.Config) {
			c.ProtocolConfiguration.P2PStateExchangeExtensions = true
		}, storage.NewMemoryStore(), "P2PStatesExchangeExtensions are enabled, but StateRootInHeader is off")
	})
	t.Run("trusted header without state sync extensions", func(t *testing.T) {
		checkNewBlockchainErr(t, func(c *config.Config) {
			c.ApplicationConfiguration.TrustedHeader = config.HashIndex{
				Hash:  util.Uint256{1, 2, 3},
				Index: 1,
			}
		}, storage.NewMemoryStore(), "TrustedHeader can not be used without P2PStateExchangeExtensions or NeoFSStateSyncExtensions")
	})
}

func TestBlockchain_InitWithIncompleteStateJump(t *testing.T) {
	var (
		stateSyncInterval        = 4
		maxTraceable      uint32 = 6
	)
	spountCfg := func(c *config.Config) {
		c.ApplicationConfiguration.RemoveUntraceableBlocks = true
		c.ProtocolConfiguration.StateRootInHeader = true
		c.ProtocolConfiguration.P2PStateExchangeExtensions = true
		c.ProtocolConfiguration.StateSyncInterval = stateSyncInterval
		c.ProtocolConfiguration.MaxTraceableBlocks = maxTraceable
		c.ApplicationConfiguration.KeepOnlyLatestState = true
	}
	bcSpout := newTestChainWithCustomCfg(t, spountCfg)

	// Generate some content.
	for range bcSpout.GetConfig().StandbyCommittee {
		require.NoError(t, bcSpout.AddBlock(bcSpout.newBlock()))
	}

	// reach next to the latest state sync point and pretend that we've just restored
	stateSyncPoint := (int(bcSpout.BlockHeight())/stateSyncInterval + 1) * stateSyncInterval
	for i := bcSpout.BlockHeight() + 1; i <= uint32(stateSyncPoint); i++ {
		require.NoError(t, bcSpout.AddBlock(bcSpout.newBlock()))
	}
	require.Equal(t, uint32(stateSyncPoint), bcSpout.BlockHeight())
	b := bcSpout.newBlock()
	require.NoError(t, bcSpout.AddHeaders(&b.Header))

	// put storage items with STTemp prefix
	batch := storage.NewMemCachedStore(bcSpout.dao.Store)
	tempPrefix := storage.STTempStorage
	if bcSpout.dao.Version.StoragePrefix == tempPrefix {
		tempPrefix = storage.STStorage
	}
	bPrefix := make([]byte, 1)
	bPrefix[0] = byte(bcSpout.dao.Version.StoragePrefix)
	bcSpout.dao.Store.Seek(storage.SeekRange{Prefix: bPrefix}, func(k, v []byte) bool {
		key := bytes.Clone(k)
		key[0] = byte(tempPrefix)
		value := bytes.Clone(v)
		batch.Put(key, value)
		return true
	})
	_, err := batch.Persist()
	require.NoError(t, err)

	boltCfg := func(c *config.Config) {
		spountCfg(c)
		c.ApplicationConfiguration.KeepOnlyLatestState = true
	}
	// manually store statejump stage to check statejump recover process
	bPrefix[0] = byte(storage.SYSStateChangeStage)
	t.Run("invalid state jump stage format", func(t *testing.T) {
		bcSpout.dao.Store.Put(bPrefix, []byte{0x01, 0x02})
		checkNewBlockchainErr(t, boltCfg, bcSpout.dao.Store, "invalid state jump stage format")
	})
	t.Run("missing state sync point", func(t *testing.T) {
		bcSpout.dao.Store.Put(bPrefix, []byte{byte(stateJumpStarted)})
		checkNewBlockchainErr(t, boltCfg, bcSpout.dao.Store, "failed to get state sync point from the storage")
	})
	t.Run("invalid RemoveUntraceableBlocks setting", func(t *testing.T) {
		bcSpout.dao.Store.Put(bPrefix, []byte{byte(stateJumpStarted)})
		point := make([]byte, 4)
		binary.LittleEndian.PutUint32(point, uint32(stateSyncPoint))
		bcSpout.dao.Store.Put([]byte{byte(storage.SYSStateSyncPoint)}, point)
		checkNewBlockchainErr(t, func(c *config.Config) {
			boltCfg(c)
			c.ApplicationConfiguration.RemoveUntraceableBlocks = false
		}, bcSpout.dao.Store, "P2PStateExchangeExtensions can be enabled either on MPT-complete node")
	})
	t.Run("invalid state sync point", func(t *testing.T) {
		bcSpout.dao.Store.Put(bPrefix, []byte{byte(stateJumpStarted)})
		point := make([]byte, 4)
		binary.LittleEndian.PutUint32(point, bcSpout.lastHeaderIndex()+1)
		bcSpout.dao.Store.Put([]byte{byte(storage.SYSStateSyncPoint)}, point)
		checkNewBlockchainErr(t, boltCfg, bcSpout.dao.Store, "invalid state sync point")
	})
	for _, stage := range []stateChangeStage{stateJumpStarted, newStorageItemsAdded, staleBlocksRemoved, 0x03} {
		t.Run(fmt.Sprintf("state jump stage %d", stage), func(t *testing.T) {
			bcSpout.dao.Store.Put(bPrefix, []byte{byte(stage)})
			point := make([]byte, 4)
			binary.LittleEndian.PutUint32(point, uint32(stateSyncPoint))
			bcSpout.dao.Store.Put([]byte{byte(storage.SYSStateSyncPoint)}, point)
			var errText string
			if stage == 0x03 {
				errText = "unknown state jump stage"
			}
			checkNewBlockchainErr(t, spountCfg, bcSpout.dao.GetPrivate().Store, errText) // don't persist changes since bcSpout.dao.Store will be reused by subsequent tests.
		})
	}
}

func TestChainWithVolatileNumOfValidators(t *testing.T) {
	bc := newTestChainWithCustomCfg(t, func(c *config.Config) {
		c.ProtocolConfiguration.ValidatorsCount = 0
		c.ProtocolConfiguration.CommitteeHistory = map[uint32]uint32{
			0:  1,
			4:  4,
			24: 6,
		}
		c.ProtocolConfiguration.ValidatorsHistory = map[uint32]uint32{
			0: 1,
			4: 4,
		}
		require.NoError(t, c.ProtocolConfiguration.Validate())
	})
	require.Equal(t, uint32(0), bc.BlockHeight())

	priv0 := testchain.PrivateKeyByID(0)

	vals := bc.ComputeNextBlockValidators()
	script, err := smartcontract.CreateDefaultMultiSigRedeemScript(vals)
	require.NoError(t, err)
	curWit := transaction.Witness{
		VerificationScript: script,
	}
	for i := 1; i < 26; i++ {
		comm, err := bc.GetCommittee()
		require.NoError(t, err)
		if i < 5 {
			require.Equal(t, 1, len(comm))
		} else if i < 25 {
			require.Equal(t, 4, len(comm))
		} else {
			require.Equal(t, 6, len(comm))
		}
		// Mimic consensus.
		if bc.config.ShouldUpdateCommitteeAt(uint32(i)) {
			vals = bc.ComputeNextBlockValidators()
		} else {
			vals, err = bc.GetNextBlockValidators()
		}
		require.NoError(t, err)
		if i < 4 {
			require.Equalf(t, 1, len(vals), "at %d", i)
		} else {
			require.Equalf(t, 4, len(vals), "at %d", i)
		}
		require.NoError(t, err)
		script, err := smartcontract.CreateDefaultMultiSigRedeemScript(vals)
		require.NoError(t, err)
		nextWit := transaction.Witness{
			VerificationScript: script,
		}
		b := &block.Block{
			Header: block.Header{
				NextConsensus: nextWit.ScriptHash(),
				Script:        curWit,
			},
		}
		curWit = nextWit
		b.PrevHash = bc.GetHeaderHash(uint32(i) - 1)
		b.Timestamp = uint64(time.Now().UTC().Unix())*1000 + uint64(i)
		b.Index = uint32(i)
		b.RebuildMerkleRoot()
		if i < 5 {
			signa := priv0.SignHashable(uint32(bc.config.Magic), b)
			b.Script.InvocationScript = append([]byte{byte(opcode.PUSHDATA1), byte(len(signa))}, signa...)
		} else {
			b.Script.InvocationScript = testchain.Sign(b)
		}
		err = bc.AddBlock(b)
		require.NoErrorf(t, err, "at %d", i)
	}
}

func setSigner(tx *transaction.Transaction, h util.Uint160) {
	tx.Signers = []transaction.Signer{{
		Account: h,
		Scopes:  transaction.Global,
	}}
}

// This test checks that value of BaseExecFee returned from corresponding Blockchain's method matches
// the one provided to the constructor of new interop context.
func TestBlockchain_BaseExecFeeBaseStoragePrice_Compat(t *testing.T) {
	bc := newTestChain(t)

	check := func(t *testing.T) {
		ic := bc.newInteropContext(trigger.Application, bc.dao, bc.topBlock.Load().(*block.Block), nil)
		require.Equal(t, bc.GetBaseExecFee(), ic.BaseExecFee())
		require.Equal(t, bc.GetStoragePrice(), ic.BaseStorageFee())
	}
	t.Run("zero block", func(t *testing.T) {
		check(t)
	})
	t.Run("non-zero block", func(t *testing.T) {
		require.NoError(t, bc.AddBlock(bc.newBlock()))
		check(t)
	})
}

func TestBlockchain_IsRunning(t *testing.T) {
	chain := initTestChain(t, nil, nil)
	require.False(t, chain.isRunning.Load().(bool))
	oldPersisted := atomic.LoadUint32(&chain.persistedHeight)

	go chain.Run()
	require.NoError(t, chain.AddBlock(chain.newBlock()))
	require.Eventually(t, func() bool {
		persisted := atomic.LoadUint32(&chain.persistedHeight)
		return persisted > oldPersisted
	}, 2*persistInterval, 100*time.Millisecond)
	require.True(t, chain.isRunning.Load().(bool))

	chain.Close()
	require.False(t, chain.isRunning.Load().(bool))
}

func TestNewBlockchain_InitHardforks(t *testing.T) {
	t.Run("nil set", func(t *testing.T) {
		bc := newTestChainWithCustomCfg(t, func(c *config.Config) {
			c.ProtocolConfiguration.Hardforks = nil
			require.NoError(t, c.ProtocolConfiguration.Validate())
		})
		require.Equal(t, map[string]uint32{
			config.HFAspidochelone.String(): 0,
			config.HFBasilisk.String():      0,
			config.HFCockatrice.String():    0,
			config.HFDomovoi.String():       0,
			config.HFEchidna.String():       0,
		}, bc.GetConfig().Hardforks)
	})
	t.Run("empty set", func(t *testing.T) {
		bc := newTestChainWithCustomCfg(t, func(c *config.Config) {
			c.ProtocolConfiguration.Hardforks = map[string]uint32{}
			require.NoError(t, c.ProtocolConfiguration.Validate())
		})
		require.Equal(t, map[string]uint32{}, bc.GetConfig().Hardforks)
	})
	t.Run("missing old", func(t *testing.T) {
		bc := newTestChainWithCustomCfg(t, func(c *config.Config) {
			c.ProtocolConfiguration.Hardforks = map[string]uint32{config.HFBasilisk.String(): 5}
			require.NoError(t, c.ProtocolConfiguration.Validate())
		})
		require.Equal(t, map[string]uint32{
			config.HFAspidochelone.String(): 0,
			config.HFBasilisk.String():      5,
		}, bc.GetConfig().Hardforks)
	})
	t.Run("missing new", func(t *testing.T) {
		bc := newTestChainWithCustomCfg(t, func(c *config.Config) {
			c.ProtocolConfiguration.Hardforks = map[string]uint32{config.HFAspidochelone.String(): 5}
			require.NoError(t, c.ProtocolConfiguration.Validate())
		})
		require.Equal(t, map[string]uint32{
			config.HFAspidochelone.String(): 5,
		}, bc.GetConfig().Hardforks)
	})
	t.Run("all present", func(t *testing.T) {
		bc := newTestChainWithCustomCfg(t, func(c *config.Config) {
			c.ProtocolConfiguration.Hardforks = map[string]uint32{config.HFAspidochelone.String(): 5, config.HFBasilisk.String(): 10, config.HFCockatrice.String(): 15, config.HFDomovoi.String(): 20, config.HFEchidna.String(): 25}
			require.NoError(t, c.ProtocolConfiguration.Validate())
		})
		require.Equal(t, map[string]uint32{
			config.HFAspidochelone.String(): 5,
			config.HFBasilisk.String():      10,
			config.HFCockatrice.String():    15,
			config.HFDomovoi.String():       20,
			config.HFEchidna.String():       25,
		}, bc.GetConfig().Hardforks)
	})
}

type nopCloserStorage struct {
	storage.Store
}

func (nopCloserStorage) Close() error {
	return nil
}

func TestBlockchainRestoreStateRootInHeader(t *testing.T) {
	bc := newTestChainWithCustomCfg(t, func(c *config.Config) {
		c.ProtocolConfiguration.StateRootInHeader = true
	})
	require.NoError(t, bc.AddBlock(bc.newBlock()))
	require.NoError(t, bc.AddBlock(bc.newBlock()))
	require.NoError(t, bc.AddBlock(bc.newBlock()))

	w := io.NewBufBinWriter()
	require.NoError(t, chaindump.Dump(bc, w.BinWriter, 0, 4))
	require.NoError(t, w.Err)

	data := w.Bytes()
	fcfg := func(c *config.Config) { c.ProtocolConfiguration.StateRootInHeader = true }
	initChain := func(st storage.Store) *Blockchain {
		chain := initTestChain(t, st, fcfg)
		go chain.Run()
		return chain
	}

	t.Run("empty MemoryStore", func(t *testing.T) {
		bc := initChain(storage.NewMemoryStore())
		r := io.NewBinReaderFromBuf(data)
		require.NoError(t, chaindump.Restore(bc, r, 0, 4, nil))
		bc.Close()
	})
	t.Run("not empty MemoryStore, one block", func(t *testing.T) {
		st := nopCloserStorage{Store: storage.NewMemoryStore()}

		bc := initChain(st)
		r := io.NewBinReaderFromBuf(data)
		require.NoError(t, chaindump.Restore(bc, r, 0, 1, nil))
		expected := bc.stateRoot.CurrentLocalStateRoot()
		bc.Close()

		bc = initChain(st)
		actual := bc.stateRoot.CurrentLocalStateRoot()
		require.Equal(t, expected, actual)
		r = io.NewBinReaderFromBuf(data)
		require.NoError(t, chaindump.Restore(bc, r, 1, 1, nil))
		bc.Close()
	})
	t.Run("not empty MemoryStore, multiple blocks", func(t *testing.T) {
		st := nopCloserStorage{Store: storage.NewMemoryStore()}
		{
			bc := initChain(st)
			r := io.NewBinReaderFromBuf(data)
			require.NoError(t, chaindump.Restore(bc, r, 0, 2, nil))
			expected := bc.stateRoot.CurrentLocalStateRoot()
			bc.Close()

			bc = initChain(st)
			actual := bc.stateRoot.CurrentLocalStateRoot()
			require.Equal(t, expected, actual)
			r = io.NewBinReaderFromBuf(data)
			require.NoError(t, chaindump.Restore(bc, r, 2, 1, nil))
			bc.Close()
		}
	})
}
