package v0

import (
	"fmt"
	"math/rand"
	"os"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	abci "github.com/tendermint/tendermint/abci/types"
	cfg "github.com/tendermint/tendermint/config"
	"github.com/tendermint/tendermint/libs/log"
	"github.com/tendermint/tendermint/mempool/mock"
	"github.com/tendermint/tendermint/p2p"
	bcproto "github.com/tendermint/tendermint/proto/tendermint/blockchain"
	"github.com/tendermint/tendermint/proxy"
	sm "github.com/tendermint/tendermint/state"
	"github.com/tendermint/tendermint/store"
	"github.com/tendermint/tendermint/types"
	dbm "github.com/tendermint/tm-db"
)

var rng = rand.New(rand.NewSource(time.Now().UnixNano()))

type reactorTestSuite struct {
	reactor *Reactor
	app     proxy.AppConns

	peerID p2p.NodeID

	blockchainChannel   *p2p.Channel
	blockchainInCh      chan p2p.Envelope
	blockchainOutCh     chan p2p.Envelope
	blockchainPeerErrCh chan p2p.PeerError

	peerUpdatesCh chan p2p.PeerUpdate
	peerUpdates   *p2p.PeerUpdatesCh
}

func setup(
	t *testing.T,
	genDoc *types.GenesisDoc,
	privVals []types.PrivValidator,
	maxBlockHeight int64,
	chBuf uint,
) *reactorTestSuite {
	t.Helper()

	require.Len(t, privVals, 1, "only one validator can be supported")

	app := &abci.BaseApplication{}
	cc := proxy.NewLocalClientCreator(app)

	proxyApp := proxy.NewAppConns(cc)
	require.NoError(t, proxyApp.Start())

	blockDB := dbm.NewMemDB()
	stateDB := dbm.NewMemDB()
	stateStore := sm.NewStore(stateDB)
	blockStore := store.NewBlockStore(blockDB)

	state, err := stateStore.LoadFromDBOrGenesisDoc(genDoc)
	require.NoError(t, err)

	fastSync := true
	db := dbm.NewMemDB()
	stateStore = sm.NewStore(db)

	blockExec := sm.NewBlockExecutor(
		stateStore,
		log.TestingLogger(),
		proxyApp.Consensus(),
		mock.Mempool{},
		sm.EmptyEvidencePool{},
	)
	require.NoError(t, stateStore.Save(state))

	for blockHeight := int64(1); blockHeight <= maxBlockHeight; blockHeight++ {
		lastCommit := types.NewCommit(blockHeight-1, 0, types.BlockID{}, nil)

		if blockHeight > 1 {
			lastBlockMeta := blockStore.LoadBlockMeta(blockHeight - 1)
			lastBlock := blockStore.LoadBlock(blockHeight - 1)

			vote, err := types.MakeVote(
				lastBlock.Header.Height,
				lastBlockMeta.BlockID,
				state.Validators,
				privVals[0],
				lastBlock.Header.ChainID,
				time.Now(),
			)
			require.NoError(t, err)

			lastCommit = types.NewCommit(
				vote.Height,
				vote.Round,
				lastBlockMeta.BlockID,
				[]types.CommitSig{vote.CommitSig()},
			)
		}

		thisBlock := makeBlock(blockHeight, state, lastCommit)
		thisParts := thisBlock.MakePartSet(types.BlockPartSizeBytes)
		blockID := types.BlockID{Hash: thisBlock.Hash(), PartSetHeader: thisParts.Header()}

		state, _, err = blockExec.ApplyBlock(state, blockID, thisBlock)
		require.NoError(t, err)

		blockStore.SaveBlock(thisBlock, thisParts, lastCommit)
	}

	pID := make([]byte, 16)
	_, err = rng.Read(pID)
	require.NoError(t, err)

	peerUpdatesCh := make(chan p2p.PeerUpdate, chBuf)

	rts := &reactorTestSuite{
		app:                 proxyApp,
		blockchainInCh:      make(chan p2p.Envelope, chBuf),
		blockchainOutCh:     make(chan p2p.Envelope, chBuf),
		blockchainPeerErrCh: make(chan p2p.PeerError, chBuf),
		peerUpdatesCh:       peerUpdatesCh,
		peerUpdates:         p2p.NewPeerUpdates(peerUpdatesCh),
		peerID:              p2p.NodeID(fmt.Sprintf("%x", pID)),
	}

	rts.blockchainChannel = p2p.NewChannel(
		BlockchainChannel,
		new(bcproto.Message),
		rts.blockchainInCh,
		rts.blockchainOutCh,
		rts.blockchainPeerErrCh,
	)

	reactor, err := NewReactor(
		log.TestingLogger().With("module", "blockchain", "node", rts.peerID),
		state.Copy(),
		blockExec,
		blockStore,
		nil,
		rts.blockchainChannel,
		rts.peerUpdates,
		fastSync,
	)

	require.NoError(t, err)
	rts.reactor = reactor

	require.NoError(t, rts.reactor.Start())
	require.True(t, rts.reactor.IsRunning())

	t.Cleanup(func() {
		require.NoError(t, rts.reactor.Stop())
		require.NoError(t, rts.app.Stop())
		require.False(t, rts.reactor.IsRunning())
	})

	return rts
}

func simulateRouter(primary *reactorTestSuite, suites []*reactorTestSuite, dropChErr bool) {
	// create a mapping for efficient suite lookup by peer ID
	suitesByPeerID := make(map[p2p.NodeID]*reactorTestSuite)
	for _, suite := range suites {
		suitesByPeerID[suite.peerID] = suite
	}

	// Simulate a router by listening for all outbound envelopes and proxying the
	// envelope to the respective peer (suite).
	go func() {
		for envelope := range primary.blockchainOutCh {
			if envelope.Broadcast {
				for _, s := range suites {
					// broadcast to everyone except source
					if s.peerID != primary.peerID {
						s.blockchainInCh <- p2p.Envelope{
							From:    primary.peerID,
							To:      s.peerID,
							Message: envelope.Message,
						}
					}
				}
			} else {
				suitesByPeerID[envelope.To].blockchainInCh <- p2p.Envelope{
					From:    primary.peerID,
					To:      envelope.To,
					Message: envelope.Message,
				}
			}
		}
	}()

	go func() {
		for pErr := range primary.blockchainPeerErrCh {
			if dropChErr {
				primary.reactor.Logger.Debug("dropped peer error", "err", pErr.Err)
			} else {
				primary.peerUpdatesCh <- p2p.PeerUpdate{
					PeerID: pErr.PeerID,
					Status: p2p.PeerStatusRemoved,
				}
			}
		}
	}()
}

func TestReactor_AbruptDisconnect(t *testing.T) {
	config := cfg.ResetTestRoot("blockchain_reactor_test")
	defer os.RemoveAll(config.RootDir)

	genDoc, privVals := randGenesisDoc(config, 1, false, 30)
	maxBlockHeight := int64(64)
	testSuites := []*reactorTestSuite{
		setup(t, genDoc, privVals, maxBlockHeight, 0),
		setup(t, genDoc, privVals, 0, 0),
	}

	require.Equal(t, maxBlockHeight, testSuites[0].reactor.store.Height())

	for _, s := range testSuites {
		simulateRouter(s, testSuites, true)

		// connect reactor to every other reactor
		for _, ss := range testSuites {
			if s.peerID != ss.peerID {
				s.peerUpdatesCh <- p2p.PeerUpdate{
					Status: p2p.PeerStatusUp,
					PeerID: ss.peerID,
				}
			}
		}
	}

	secondaryPool := testSuites[1].reactor.pool
	require.Eventually(
		t,
		func() bool {
			height, _, _ := secondaryPool.GetStatus()
			return secondaryPool.MaxPeerHeight() > 0 && height > 0 && height < 10
		},
		10*time.Second,
		10*time.Millisecond,
		"expected node to be partially synced",
	)

	// Remove synced node from the syncing node which should not result in any
	// deadlocks or race conditions within the context of poolRoutine.
	testSuites[1].peerUpdatesCh <- p2p.PeerUpdate{
		Status: p2p.PeerStatusDown,
		PeerID: testSuites[0].peerID,
	}
}

func TestReactor_NoBlockResponse(t *testing.T) {
	config := cfg.ResetTestRoot("blockchain_reactor_test")
	defer os.RemoveAll(config.RootDir)

	genDoc, privVals := randGenesisDoc(config, 1, false, 30)
	maxBlockHeight := int64(65)
	testSuites := []*reactorTestSuite{
		setup(t, genDoc, privVals, maxBlockHeight, 0),
		setup(t, genDoc, privVals, 0, 0),
	}

	require.Equal(t, maxBlockHeight, testSuites[0].reactor.store.Height())

	for _, s := range testSuites {
		simulateRouter(s, testSuites, true)

		// connect reactor to every other reactor
		for _, ss := range testSuites {
			if s.peerID != ss.peerID {
				s.peerUpdatesCh <- p2p.PeerUpdate{
					Status: p2p.PeerStatusUp,
					PeerID: ss.peerID,
				}
			}
		}
	}

	testCases := []struct {
		height   int64
		existent bool
	}{
		{maxBlockHeight + 2, false},
		{10, true},
		{1, true},
		{100, false},
	}

	secondaryPool := testSuites[1].reactor.pool
	require.Eventually(
		t,
		func() bool { return secondaryPool.MaxPeerHeight() > 0 && secondaryPool.IsCaughtUp() },
		10*time.Second,
		10*time.Millisecond,
		"expected node to be fully synced",
	)

	for _, tc := range testCases {
		block := testSuites[1].reactor.store.LoadBlock(tc.height)
		if tc.existent {
			require.True(t, block != nil)
		} else {
			require.Nil(t, block)
		}
	}
}

func TestReactor_BadBlockStopsPeer(t *testing.T) {
	config := cfg.ResetTestRoot("blockchain_reactor_test")
	defer os.RemoveAll(config.RootDir)

	maxBlockHeight := int64(48)
	genDoc, privVals := randGenesisDoc(config, 1, false, 30)

	testSuites := []*reactorTestSuite{
		setup(t, genDoc, privVals, maxBlockHeight, 1000), // fully synced node
		setup(t, genDoc, privVals, 0, 1000),
		setup(t, genDoc, privVals, 0, 1000),
		setup(t, genDoc, privVals, 0, 1000),
		setup(t, genDoc, privVals, 0, 1000), // new node
	}

	require.Equal(t, maxBlockHeight, testSuites[0].reactor.store.Height())

	for _, s := range testSuites[:len(testSuites)-1] {
		simulateRouter(s, testSuites, true)

		// connect reactor to every other reactor except the new node
		for _, ss := range testSuites[:len(testSuites)-1] {
			if s.peerID != ss.peerID {
				s.peerUpdatesCh <- p2p.PeerUpdate{
					Status: p2p.PeerStatusUp,
					PeerID: ss.peerID,
				}
			}
		}
	}

	require.Eventually(
		t,
		func() bool {
			caughtUp := true
			for _, s := range testSuites[1 : len(testSuites)-1] {
				if s.reactor.pool.MaxPeerHeight() == 0 || !s.reactor.pool.IsCaughtUp() {
					caughtUp = false
				}
			}

			return caughtUp
		},
		10*time.Minute,
		10*time.Millisecond,
		"expected all nodes to be fully synced",
	)

	for _, s := range testSuites[:len(testSuites)-1] {
		require.Len(t, s.reactor.pool.peers, 3)
	}

	// Mark testSuites[3] as an invalid peer which will cause newSuite to disconnect
	// from this peer.
	otherGenDoc, otherPrivVals := randGenesisDoc(config, 1, false, 30)
	otherSuite := setup(t, otherGenDoc, otherPrivVals, maxBlockHeight, 0)
	testSuites[3].reactor.store = otherSuite.reactor.store

	// add a fake peer just so we do not wait for the consensus ticker to timeout
	otherSuite.reactor.pool.SetPeerRange("00ff", 10, 10)

	// start the new peer's faux router
	newSuite := testSuites[len(testSuites)-1]
	simulateRouter(newSuite, testSuites, false)

	// connect all nodes to the new peer
	for _, s := range testSuites[:len(testSuites)-1] {
		newSuite.peerUpdatesCh <- p2p.PeerUpdate{
			Status: p2p.PeerStatusUp,
			PeerID: s.peerID,
		}
	}

	// wait for the new peer to catch up and become fully synced
	require.Eventually(
		t,
		func() bool { return newSuite.reactor.pool.MaxPeerHeight() > 0 && newSuite.reactor.pool.IsCaughtUp() },
		10*time.Minute,
		10*time.Millisecond,
		"expected new node to be fully synced",
	)

	require.Eventuallyf(
		t,
		func() bool { return len(newSuite.reactor.pool.peers) < len(testSuites)-1 },
		10*time.Minute,
		10*time.Millisecond,
		"invalid number of peers; expected < %d, got: %d",
		len(testSuites)-1,
		len(newSuite.reactor.pool.peers),
	)
}
