package engine

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"testing"

	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/object"
	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/blobstor"
	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/blobstor/common"
	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/blobstor/teststore"
	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/internal/testutil"
	meta "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/metabase"
	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/pilorama"
	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/shard"
	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/shard/mode"
	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/util/logger"
	apistatus "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/client/status"
	cidtest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id/test"
	objectSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object"
	"github.com/stretchr/testify/require"
	"go.uber.org/zap/zaptest"
)

const errSmallSize = 256

type testEngine struct {
	ng     *StorageEngine
	dir    string
	shards [2]*testShard
}

type testShard struct {
	id               *shard.ID
	smallFileStorage *teststore.TestStore
	largeFileStorage *teststore.TestStore
}

func newEngineWithErrorThreshold(t testing.TB, dir string, errThreshold uint32) *testEngine {
	if dir == "" {
		var err error

		dir, err = os.MkdirTemp("", "*")
		require.NoError(t, err)
		t.Cleanup(func() { _ = os.RemoveAll(dir) })
	}

	var testShards [2]*testShard

	te := testNewEngine(t,
		WithShardPoolSize(1),
		WithErrorThreshold(errThreshold),
	).
		setShardsNumOpts(t, 2, func(id int) []shard.Option {
			storages, smallFileStorage, largeFileStorage := newTestStorages(filepath.Join(dir, strconv.Itoa(id)), errSmallSize)
			testShards[id] = &testShard{
				smallFileStorage: smallFileStorage,
				largeFileStorage: largeFileStorage,
			}
			return []shard.Option{
				shard.WithLogger(&logger.Logger{Logger: zaptest.NewLogger(t)}),
				shard.WithBlobStorOptions(blobstor.WithStorages(storages)),
				shard.WithMetaBaseOptions(
					meta.WithPath(filepath.Join(dir, fmt.Sprintf("%d.metabase", id))),
					meta.WithPermissions(0700),
					meta.WithEpochState(epochState{}),
				),
				shard.WithPiloramaOptions(
					pilorama.WithPath(filepath.Join(dir, fmt.Sprintf("%d.pilorama", id))),
					pilorama.WithPerm(0700)),
			}
		})
	e := te.engine
	require.NoError(t, e.Open())
	require.NoError(t, e.Init(context.Background()))

	for i, id := range te.shardIDs {
		testShards[i].id = id
	}

	return &testEngine{
		ng:     e,
		dir:    dir,
		shards: testShards,
	}
}

func TestErrorReporting(t *testing.T) {
	t.Run("ignore errors by default", func(t *testing.T) {
		te := newEngineWithErrorThreshold(t, "", 0)

		obj := testutil.GenerateObjectWithCID(cidtest.ID())
		obj.SetPayload(make([]byte, errSmallSize))

		var prm shard.PutPrm
		prm.SetObject(obj)
		te.ng.mtx.RLock()
		_, err := te.ng.shards[te.shards[0].id.String()].Shard.Put(context.Background(), prm)
		te.ng.mtx.RUnlock()
		require.NoError(t, err)

		_, err = te.ng.Get(context.Background(), GetPrm{addr: object.AddressOf(obj)})
		require.NoError(t, err)

		checkShardState(t, te.ng, te.shards[0].id, 0, mode.ReadWrite)
		checkShardState(t, te.ng, te.shards[1].id, 0, mode.ReadWrite)

		for _, shard := range te.shards {
			shard.largeFileStorage.SetOption(teststore.WithGet(func(common.GetPrm) (common.GetRes, error) {
				return common.GetRes{}, teststore.ErrDiskExploded
			}))
		}

		for i := uint32(1); i < 3; i++ {
			_, err = te.ng.Get(context.Background(), GetPrm{addr: object.AddressOf(obj)})
			require.Error(t, err)
			checkShardState(t, te.ng, te.shards[0].id, i, mode.ReadWrite)
			checkShardState(t, te.ng, te.shards[1].id, 0, mode.ReadWrite)
		}
	})
	t.Run("with error threshold", func(t *testing.T) {
		const errThreshold = 3

		te := newEngineWithErrorThreshold(t, "", errThreshold)

		obj := testutil.GenerateObjectWithCID(cidtest.ID())
		obj.SetPayload(make([]byte, errSmallSize))

		var prm shard.PutPrm
		prm.SetObject(obj)
		te.ng.mtx.RLock()
		_, err := te.ng.shards[te.shards[0].id.String()].Put(context.Background(), prm)
		te.ng.mtx.RUnlock()
		require.NoError(t, err)

		_, err = te.ng.Get(context.Background(), GetPrm{addr: object.AddressOf(obj)})
		require.NoError(t, err)

		checkShardState(t, te.ng, te.shards[0].id, 0, mode.ReadWrite)
		checkShardState(t, te.ng, te.shards[1].id, 0, mode.ReadWrite)

		for _, shard := range te.shards {
			shard.largeFileStorage.SetOption(teststore.WithGet(func(common.GetPrm) (common.GetRes, error) {
				return common.GetRes{}, teststore.ErrDiskExploded
			}))
		}

		for i := uint32(1); i < errThreshold; i++ {
			_, err = te.ng.Get(context.Background(), GetPrm{addr: object.AddressOf(obj)})
			require.Error(t, err)
			checkShardState(t, te.ng, te.shards[0].id, i, mode.ReadWrite)
			checkShardState(t, te.ng, te.shards[1].id, 0, mode.ReadWrite)
		}

		for i := uint32(0); i < 2; i++ {
			_, err = te.ng.Get(context.Background(), GetPrm{addr: object.AddressOf(obj)})
			require.Error(t, err)
			checkShardState(t, te.ng, te.shards[0].id, errThreshold+i, mode.DegradedReadOnly)
			checkShardState(t, te.ng, te.shards[1].id, 0, mode.ReadWrite)
		}

		require.NoError(t, te.ng.SetShardMode(te.shards[0].id, mode.ReadWrite, false))
		checkShardState(t, te.ng, te.shards[0].id, errThreshold+1, mode.ReadWrite)

		require.NoError(t, te.ng.SetShardMode(te.shards[0].id, mode.ReadWrite, true))
		checkShardState(t, te.ng, te.shards[0].id, 0, mode.ReadWrite)
	})
}

func TestBlobstorFailback(t *testing.T) {
	dir, err := os.MkdirTemp("", "*")
	require.NoError(t, err)
	t.Cleanup(func() { require.NoError(t, os.RemoveAll(dir)) })

	te := newEngineWithErrorThreshold(t, dir, 1)

	objs := make([]*objectSDK.Object, 0, 2)
	for _, size := range []int{15, errSmallSize + 1} {
		obj := testutil.GenerateObjectWithCID(cidtest.ID())
		obj.SetPayload(make([]byte, size))

		var prm shard.PutPrm
		prm.SetObject(obj)
		te.ng.mtx.RLock()
		_, err = te.ng.shards[te.shards[0].id.String()].Shard.Put(context.Background(), prm)
		te.ng.mtx.RUnlock()
		require.NoError(t, err)
		objs = append(objs, obj)
	}

	for i := range objs {
		addr := object.AddressOf(objs[i])
		_, err = te.ng.Get(context.Background(), GetPrm{addr: addr})
		require.NoError(t, err)
		_, err = te.ng.GetRange(context.Background(), RngPrm{addr: addr})
		require.NoError(t, err)
	}

	checkShardState(t, te.ng, te.shards[0].id, 0, mode.ReadWrite)
	require.NoError(t, te.ng.Close())

	p1 := te.ng.shards[te.shards[0].id.String()].Shard.DumpInfo().BlobStorInfo.SubStorages[1].Path
	p2 := te.ng.shards[te.shards[1].id.String()].Shard.DumpInfo().BlobStorInfo.SubStorages[1].Path
	tmp := filepath.Join(dir, "tmp")
	require.NoError(t, os.Rename(p1, tmp))
	require.NoError(t, os.Rename(p2, p1))
	require.NoError(t, os.Rename(tmp, p2))

	te = newEngineWithErrorThreshold(t, dir, 1)

	for i := range objs {
		addr := object.AddressOf(objs[i])
		getRes, err := te.ng.Get(context.Background(), GetPrm{addr: addr})
		require.NoError(t, err)
		require.Equal(t, objs[i], getRes.Object())

		rngRes, err := te.ng.GetRange(context.Background(), RngPrm{addr: addr, off: 1, ln: 10})
		require.NoError(t, err)
		require.Equal(t, objs[i].Payload()[1:11], rngRes.Object().Payload())

		_, err = te.ng.GetRange(context.Background(), RngPrm{addr: addr, off: errSmallSize + 10, ln: 1})
		require.ErrorAs(t, err, &apistatus.ObjectOutOfRange{})
	}

	checkShardState(t, te.ng, te.shards[0].id, 1, mode.DegradedReadOnly)
	checkShardState(t, te.ng, te.shards[1].id, 0, mode.ReadWrite)
}

func checkShardState(t *testing.T, e *StorageEngine, id *shard.ID, errCount uint32, mode mode.Mode) {
	e.mtx.RLock()
	sh := e.shards[id.String()]
	e.mtx.RUnlock()

	require.Equal(t, errCount, sh.errorCount.Load())
	require.Equal(t, mode, sh.GetMode())
}