package pruning

import (
	"encoding/binary"
	"errors"
	"fmt"
	"testing"

	db "github.com/cosmos/cosmos-db"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.uber.org/mock/gomock"

	"cosmossdk.io/log"
	"cosmossdk.io/store/mock"
	"cosmossdk.io/store/pruning/types"
)

const dbErr = "db error"

func TestNewManager(t *testing.T) {
	manager := NewManager(db.NewMemDB(), log.NewNopLogger())
	require.NotNil(t, manager)
	require.Equal(t, types.PruningNothing, manager.GetOptions().GetPruningStrategy())
}

func TestStrategies(t *testing.T) {
	testcases := map[string]struct {
		strategy         types.PruningOptions
		snapshotInterval uint64
		strategyToAssert types.PruningStrategy
		isValid          bool
	}{
		"prune nothing - no snapshot": {
			strategy:         types.NewPruningOptions(types.PruningNothing),
			strategyToAssert: types.PruningNothing,
		},
		"prune nothing - snapshot": {
			strategy:         types.NewPruningOptions(types.PruningNothing),
			strategyToAssert: types.PruningNothing,
			snapshotInterval: 100,
		},
		"prune default - no snapshot": {
			strategy:         types.NewPruningOptions(types.PruningDefault),
			strategyToAssert: types.PruningDefault,
		},
		"prune default - snapshot": {
			strategy:         types.NewPruningOptions(types.PruningDefault),
			strategyToAssert: types.PruningDefault,
			snapshotInterval: 100,
		},
		"prune everything - no snapshot": {
			strategy:         types.NewPruningOptions(types.PruningEverything),
			strategyToAssert: types.PruningEverything,
		},
		"prune everything - snapshot": {
			strategy:         types.NewPruningOptions(types.PruningEverything),
			strategyToAssert: types.PruningEverything,
			snapshotInterval: 100,
		},
		"custom 100-10-15": {
			strategy:         types.NewCustomPruningOptions(100, 15),
			snapshotInterval: 10,
			strategyToAssert: types.PruningCustom,
		},
		"custom 10-10-15": {
			strategy:         types.NewCustomPruningOptions(10, 15),
			snapshotInterval: 10,
			strategyToAssert: types.PruningCustom,
		},
		"custom 100-0-15": {
			strategy:         types.NewCustomPruningOptions(100, 15),
			snapshotInterval: 0,
			strategyToAssert: types.PruningCustom,
		},
	}

	for name, tc := range testcases {
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			manager := NewManager(db.NewMemDB(), log.NewNopLogger())
			require.NotNil(t, manager)

			curStrategy := tc.strategy
			manager.SetSnapshotInterval(tc.snapshotInterval)

			pruneStrategy := curStrategy.GetPruningStrategy()
			require.Equal(t, tc.strategyToAssert, pruneStrategy)

			// Validate strategy parameters
			switch pruneStrategy {
			case types.PruningDefault:
				require.Equal(t, uint64(362880), curStrategy.KeepRecent)
				require.Equal(t, uint64(10), curStrategy.Interval)
			case types.PruningNothing:
				require.Equal(t, uint64(0), curStrategy.KeepRecent)
				require.Equal(t, uint64(0), curStrategy.Interval)
			case types.PruningEverything:
				require.Equal(t, uint64(2), curStrategy.KeepRecent)
				require.Equal(t, uint64(10), curStrategy.Interval)
			default:
				//
			}

			manager.SetOptions(curStrategy)
			require.Equal(t, tc.strategy, manager.GetOptions())

			curKeepRecent := curStrategy.KeepRecent
			snHeight := int64(tc.snapshotInterval - 1)
			for curHeight := int64(0); curHeight < 110000; curHeight++ {
				if tc.snapshotInterval != 0 {
					if curHeight > int64(tc.snapshotInterval) && curHeight%int64(tc.snapshotInterval) == int64(tc.snapshotInterval)-1 {
						snapHeight := curHeight - int64(tc.snapshotInterval) + 1
						manager.AnnounceSnapshotHeight(snapHeight)
						manager.HandleSnapshotHeight(snapHeight)
						snHeight = curHeight
					}
				}

				pruningHeightActual := manager.GetPruningHeight(curHeight)
				curHeightStr := fmt.Sprintf("height: %d", curHeight)

				switch curStrategy.GetPruningStrategy() {
				case types.PruningNothing:
					require.Equal(t, int64(0), pruningHeightActual, curHeightStr)
				default:
					if curHeight > int64(curKeepRecent) && curHeight%int64(curStrategy.Interval) == 0 {
						pruningHeightExpected := curHeight - int64(curKeepRecent) - 1
						if tc.snapshotInterval > 0 && snHeight < pruningHeightExpected {
							pruningHeightExpected = snHeight
						}
						require.Equal(t, pruningHeightExpected, pruningHeightActual, curHeightStr)
					} else {
						require.Equal(t, int64(0), pruningHeightActual, curHeightStr)
					}
				}
			}
		})
	}
}

func TestPruningHeight_Inputs(t *testing.T) {
	keepRecent := int64(types.NewPruningOptions(types.PruningEverything).KeepRecent)
	interval := int64(types.NewPruningOptions(types.PruningEverything).Interval)

	testcases := map[string]struct {
		height         int64
		expectedResult int64
		strategy       types.PruningStrategy
	}{
		"currentHeight is negative - prune everything - invalid currentHeight": {
			-1,
			0,
			types.PruningEverything,
		},
		"currentHeight is zero - prune everything - invalid currentHeight": {
			0,
			0,
			types.PruningEverything,
		},
		"currentHeight is positive but within keep recent - prune everything - not kept": {
			keepRecent,
			0,
			types.PruningEverything,
		},
		"currentHeight is positive and equal to keep recent+1 - no kept": {
			keepRecent + 1,
			0,
			types.PruningEverything,
		},
		"currentHeight is positive and greater than keep recent+1 but not multiple of interval - no kept": {
			keepRecent + 2,
			0,
			types.PruningEverything,
		},
		"currentHeight is positive and greater than keep recent+1 and multiple of interval - kept": {
			interval,
			interval - keepRecent - 1,
			types.PruningEverything,
		},
		"pruning nothing, currentHeight is positive and greater than keep recent - not kept": {
			interval,
			0,
			types.PruningNothing,
		},
	}

	for name, tc := range testcases {
		t.Run(name, func(t *testing.T) {
			manager := NewManager(db.NewMemDB(), log.NewNopLogger())
			manager.SetOptions(types.NewPruningOptions(tc.strategy))

			pruningHeightActual := manager.GetPruningHeight(tc.height)
			require.Equal(t, tc.expectedResult, pruningHeightActual)
		})
	}
}

func TestGetPruningHeight(t *testing.T) {
	specs := map[string]struct {
		initDBState int64
		opts        types.PruningOptions
		setup       func(manager *Manager)
		exp         map[int64]int64
	}{
		"init from store - no snap": {
			initDBState: 10,
			opts:        types.PruningOptions{KeepRecent: 5, Interval: 10, Strategy: types.PruningCustom},
			setup: func(mgr *Manager) {
				mgr.SetSnapshotInterval(15)
			},
			exp: map[int64]int64{
				20: 9, // initDBState - 1
				30: 9, // initDBState - 1
				45: 0, // not a prune height
			},
		},
		"init from store - snap landed": {
			initDBState: 10,
			opts:        types.PruningOptions{KeepRecent: 5, Interval: 10, Strategy: types.PruningCustom},
			setup: func(mgr *Manager) {
				mgr.SetSnapshotInterval(15)
				mgr.AnnounceSnapshotHeight(15)
				mgr.HandleSnapshotHeight(15)
			},
			exp: map[int64]int64{
				10: 4,  // 10 - 5 (keep) - 1
				15: 0,  // not on prune interval
				20: 14, // 20 - 5 (keep) - 1
				30: 24, // 30 - 5 (keep) - 1
				40: 29, // 15 (last completed snap) + 15 (snap interval) - 1
			},
		},
		"init from store - snap in-flight": {
			initDBState: 10,
			opts:        types.PruningOptions{KeepRecent: 5, Interval: 10, Strategy: types.PruningCustom},
			setup: func(mgr *Manager) {
				mgr.SetSnapshotInterval(15)
				mgr.AnnounceSnapshotHeight(15)
			},
			exp: map[int64]int64{
				10: 4, // 10 - 5 (keep) - 1
				20: 9, // 10 - 5 (keep) - 1
			},
		},
		"init from store - delayed in-flight snap": {
			initDBState: 10,
			opts:        types.PruningOptions{KeepRecent: 5, Interval: 10, Strategy: types.PruningCustom},
			setup: func(mgr *Manager) {
				mgr.SetSnapshotInterval(15)
				mgr.AnnounceSnapshotHeight(15)
				mgr.AnnounceSnapshotHeight(30)
				mgr.HandleSnapshotHeight(30)
			},
			exp: map[int64]int64{
				10: 4,  // 10 - 5 (keep) - 1
				20: 14, // 15 (in-flight) - 1
				30: 14, // 15 (in-flight) - 1
				40: 14, // 15 (in-flight) - 1
			},
		},
		"empty store": {
			opts: types.PruningOptions{KeepRecent: 5, Interval: 10, Strategy: types.PruningCustom},
			setup: func(mgr *Manager) {
				mgr.SetSnapshotInterval(15)
			},
			exp: map[int64]int64{
				10: 4,  // 10 -5 (keep) -1
				20: 14, // 20 -5 (keep) -1
			},
		},
		"empty snap interval set": {
			initDBState: 10,
			opts:        types.PruningOptions{KeepRecent: 5, Interval: 10, Strategy: types.PruningCustom},
			setup:       func(mgr *Manager) {},
			exp: map[int64]int64{
				10: 4,  // 10 -5 (keep) -1
				20: 14, // 20 -5 (keep) -1
			},
		},
		"prune nothing set": {
			initDBState: 10,
			opts:        types.PruningOptions{Strategy: types.PruningNothing, Interval: 10, KeepRecent: 5},
			setup: func(mgr *Manager) {
				mgr.SetSnapshotInterval(15)
			},
			exp: map[int64]int64{
				10: 0, // nothing
				20: 0, // nothing
				30: 0, // nothing
			},
		},
		"empty prune interval": {
			initDBState: 10,
			opts:        types.PruningOptions{Strategy: types.PruningCustom, KeepRecent: 5},
			setup: func(mgr *Manager) {
				mgr.SetSnapshotInterval(15)
			},
			exp: map[int64]int64{
				10: 0, // interval required
				20: 0, // interval required
				30: 0, // interval required
			},
		},
		"height <= keep": {
			initDBState: 10,
			opts:        types.PruningOptions{Strategy: types.PruningCustom, Interval: 1, KeepRecent: 5},
			setup: func(mgr *Manager) {
				mgr.SetSnapshotInterval(15)
			},
			exp: map[int64]int64{
				0: 0, // interval required
				4: 0, // interval required
				5: 0, // interval required
			},
		},
		"height not on prune interval": {
			initDBState: 10,
			opts:        types.PruningOptions{Strategy: types.PruningCustom, Interval: 2},
			setup: func(mgr *Manager) {
				mgr.SetSnapshotInterval(15)
			},
			exp: map[int64]int64{
				0: 0, // excluded
				1: 0, // not on prune interval
				2: 1, // 2 - 1
				3: 0, // not on prune interval
				4: 3, // 2 - 1
			},
		},
	}
	for name, spec := range specs {
		t.Run(name, func(t *testing.T) {
			memDB := db.NewMemDB()
			if spec.initDBState != 0 {
				require.NoError(t, storePruningSnapshotHeight(memDB, spec.initDBState))
			}
			mgr2 := NewManager(memDB, log.NewNopLogger())
			mgr2.SetOptions(spec.opts)
			require.NoError(t, mgr2.LoadSnapshotHeights(memDB))
			spec.setup(mgr2)

			for height, exp := range spec.exp {
				gotHeight := mgr2.GetPruningHeight(height)
				assert.Equal(t, exp, gotHeight, "height: %d", height)
			}
		})
	}
}

func TestHandleSnapshotHeight_DbErr_Panic(t *testing.T) {
	ctrl := gomock.NewController(t)

	// Setup
	dbMock := mock.NewMockDB(ctrl)

	dbMock.EXPECT().SetSync(gomock.Any(), gomock.Any()).Return(errors.New(dbErr)).Times(1)

	manager := NewManager(dbMock, log.NewNopLogger())
	manager.SetSnapshotInterval(1)
	manager.SetOptions(types.NewPruningOptions(types.PruningEverything))
	require.NotNil(t, manager)

	defer func() {
		if r := recover(); r == nil {
			t.Fail()
		}
	}()

	manager.HandleSnapshotHeight(10)
}

func TestHandleSnapshotHeight_LoadFromDisk(t *testing.T) {
	snapshotInterval := uint64(10)

	// Setup
	db := db.NewMemDB()
	manager := NewManager(db, log.NewNopLogger())
	require.NotNil(t, manager)

	manager.SetOptions(types.NewPruningOptions(types.PruningEverything))
	manager.SetSnapshotInterval(snapshotInterval)

	expected := 0
	for snapshotHeight := int64(-1); snapshotHeight < 100; snapshotHeight++ {
		snapshotHeightStr := fmt.Sprintf("snapshot height: %d", snapshotHeight)
		if snapshotHeight > int64(snapshotInterval) && snapshotHeight%int64(snapshotInterval) == 1 {
			// Test flush
			manager.HandleSnapshotHeight(snapshotHeight - 1)
			expected = 1
		}

		loadedSnapshotHeights, err := loadPruningSnapshotHeights(db)
		require.NoError(t, err)
		require.Equal(t, expected, len(loadedSnapshotHeights), snapshotHeightStr)

		// Test load back
		err = manager.LoadSnapshotHeights(db)
		require.NoError(t, err)

		loadedSnapshotHeights, err = loadPruningSnapshotHeights(db)
		require.NoError(t, err)
		require.Equal(t, expected, len(loadedSnapshotHeights), snapshotHeightStr)
	}
}

func TestLoadPruningSnapshotHeights(t *testing.T) {
	var (
		manager = NewManager(db.NewMemDB(), log.NewNopLogger())
		err     error
	)
	require.NotNil(t, manager)

	// must not be PruningNothing
	manager.SetOptions(types.NewPruningOptions(types.PruningDefault))

	testcases := map[string]struct {
		getFlushedPruningSnapshotHeights func() []int64
		expectedResult                   error
	}{
		"negative snapshotPruningHeight - error": {
			getFlushedPruningSnapshotHeights: func() []int64 {
				return []int64{5, -2, 3}
			},
			expectedResult: &NegativeHeightsError{Height: -2},
		},
		"non-negative - success": {
			getFlushedPruningSnapshotHeights: func() []int64 {
				return []int64{5, 0, 3}
			},
		},
	}

	for name, tc := range testcases {
		t.Run(name, func(t *testing.T) {
			db := db.NewMemDB()

			if tc.getFlushedPruningSnapshotHeights != nil {
				err = db.Set(pruneSnapshotHeightsKey, int64SliceToBytes(tc.getFlushedPruningSnapshotHeights()...))
				require.NoError(t, err)
			}

			err = manager.LoadSnapshotHeights(db)
			require.Equal(t, tc.expectedResult, err)
		})
	}
}

func TestLoadSnapshotHeights_PruneNothing(t *testing.T) {
	manager := NewManager(db.NewMemDB(), log.NewNopLogger())
	require.NotNil(t, manager)

	manager.SetOptions(types.NewPruningOptions(types.PruningNothing))

	require.Nil(t, manager.LoadSnapshotHeights(db.NewMemDB()))
}

func TestInt64SliceToBytes(t *testing.T) {
	tests := []struct {
		name   string
		input  []int64
		expect []byte
	}{
		{
			name:   "empty slice",
			input:  []int64{},
			expect: []byte{},
		},
		{
			name:  "single value",
			input: []int64{1},
			expect: func() []byte {
				b := make([]byte, 8)
				binary.BigEndian.PutUint64(b, 1)
				return b
			}(),
		},
		{
			name:  "multiple values",
			input: []int64{1, 2},
			expect: func() []byte {
				b := make([]byte, 16)
				binary.BigEndian.PutUint64(b[0:], 1)
				binary.BigEndian.PutUint64(b[8:], 2)
				return b
			}(),
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := int64SliceToBytes(tt.input...)
			require.Equal(t, tt.expect, got, "bytes mismatch")
		})
	}
}

func BenchmarkInt64SliceToBytes(b *testing.B) {
	data := make([]int64, 1024)
	for i := range data {
		data[i] = int64(i)
	}

	for b.Loop() {
		_ = int64SliceToBytes(data...)
	}
}
