//                           _       _
// __      _____  __ ___   ___  __ _| |_ ___
// \ \ /\ / / _ \/ _` \ \ / / |/ _` | __/ _ \
//  \ V  V /  __/ (_| |\ V /| | (_| | ||  __/
//   \_/\_/ \___|\__,_| \_/ |_|\__,_|\__\___|
//
//  Copyright © 2016 - 2026 Weaviate B.V. All rights reserved.
//
//  CONTACT: hello@weaviate.io
//

package backup

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/sirupsen/logrus/hooks/test"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"

	"github.com/weaviate/weaviate/entities/backup"
	"github.com/weaviate/weaviate/entities/modulecapabilities"
	"github.com/weaviate/weaviate/usecases/auth/authorization/mocks"
)

const (
	nodeName = "Node-1"
)

var errNotFound = backup.NewErrNotFound(errors.New("not found"))

func (r *backupper) waitForCompletion(n, ms int) backup.Status {
	for i := 0; i < n; i++ {
		time.Sleep(time.Millisecond * time.Duration(ms))
		if i < 1 {
			continue
		}
		if x := r.lastOp.get(); x.Status != "" {
			return x.Status
		}
	}
	return ""
}

func TestBackupOnStatus(t *testing.T) {
	t.Parallel()
	var (
		backendName = "s3"
		id          = "1234"
		ctx         = context.Background()
		starTime    = time.Date(2022, 1, 1, 1, 0, 0, 0, time.UTC)
		nodeHome    = id + "/" + nodeName
		path        = "bucket/backups/" + nodeHome
		req         = StatusRequest{
			Method:  OpCreate,
			ID:      id,
			Backend: backendName,
		}
	)

	t.Run("ActiveState", func(t *testing.T) {
		m := createManager(nil, nil, nil, nil)
		m.backupper.lastOp.reqState = reqState{
			Starttime: starTime,
			ID:        id,
			Status:    backup.Transferring,
			Path:      path,
		}
		want := &StatusResponse{
			Method: OpCreate,
			ID:     id,
			Status: backup.Transferring,
		}
		st := m.OnStatus(ctx, &req)
		assert.Equal(t, want, st)
	})

	t.Run("GetBackupProvider", func(t *testing.T) {
		want := &StatusResponse{
			Method: OpCreate,
			ID:     id,
			Status: backup.Failed,
		}
		m := createManager(nil, nil, nil, ErrAny)
		got := m.OnStatus(ctx, &req)
		assert.Contains(t, got.Err, req.Backend)
		want.Err = got.Err
		assert.Equal(t, want, got)
	})

	t.Run("MetadataNotFound", func(t *testing.T) {
		want := &StatusResponse{
			Method: OpCreate,
			ID:     id,
			Status: backup.Failed,
		}
		backend := &fakeBackend{}
		backend.On("GetObject", ctx, nodeHome, BackupFile).Return(nil, ErrAny)
		backend.On("GetObject", ctx, id, BackupFile).Return(nil, ErrAny)

		m := createManager(nil, nil, backend, nil)
		got := m.OnStatus(ctx, &req)
		assert.Contains(t, got.Err, errMetaNotFound.Error())
		want.Err = got.Err
		assert.Equal(t, want, got)
	})

	t.Run("ReadFromMetadata", func(t *testing.T) {
		want := &StatusResponse{
			Method: OpCreate,
			ID:     id,
			Status: backup.Success,
		}
		backend := &fakeBackend{}
		bytes := marshalMeta(backup.BackupDescriptor{Status: string(backup.Success)})
		backend.On("GetObject", ctx, nodeHome, BackupFile).Return(bytes, nil)
		backend.On("HomeDir", mock.Anything, mock.Anything, mock.Anything).Return(path)
		m := createManager(nil, nil, backend, nil)
		got := m.OnStatus(ctx, &req)
		assert.Equal(t, want, got)
	})
}

func TestManagerCoordinatedBackup(t *testing.T) {
	t.Parallel()
	var (
		cls         = "Class-A"
		cls2        = "Class-B"
		backendName = "gcs"
		backupID    = "1"
		ctx         = context.Background()
		nodeHome    = backupID + "/" + nodeName
		path        = "bucket/backups/" + nodeHome
		req         = Request{
			Method:   OpCreate,
			ID:       backupID,
			Classes:  []string{cls, cls2},
			Backend:  backendName,
			Duration: time.Millisecond * 20,
		}
		any = mock.Anything
	)

	t.Run("BackendUnregistered", func(t *testing.T) {
		backendError := errors.New("I do not exist")
		bm := createManager(nil, nil, nil, backendError)
		ret := bm.OnCanCommit(ctx, &req)
		assert.Contains(t, ret.Err, backendName)
	})

	t.Run("ClassNotBackupable", func(t *testing.T) {
		backend := &fakeBackend{}
		backend.On("HomeDir", mock.Anything, mock.Anything, mock.Anything).Return(path)
		backend.On("GetObject", ctx, nodeHome, BackupFile).Return(nil, errNotFound)
		sourcer := &fakeSourcer{}
		sourcer.On("Backupable", ctx, req.Classes).Return(ErrAny)
		bm := createManager(sourcer, nil, backend, nil)

		resp := bm.OnCanCommit(ctx, &req)
		assert.Contains(t, resp.Err, ErrAny.Error())
		assert.Equal(t, resp.Timeout, time.Duration(0))
	})

	t.Run("InitializeBackend", func(t *testing.T) {
		backend := &fakeBackend{}
		backend.On("HomeDir", mock.Anything, mock.Anything, mock.Anything).Return(path)
		backend.On("GetObject", ctx, nodeHome, BackupFile).Return(nil, errNotFound)
		sourcer := &fakeSourcer{}
		sourcer.On("Backupable", ctx, req.Classes).Return(nil)
		backend.On("Initialize", ctx, nodeHome).Return(errors.New("init meta failed"))
		bm := createManager(sourcer, nil, backend, nil)

		resp := bm.OnCanCommit(ctx, &req)
		assert.Contains(t, resp.Err, "init")
		assert.Equal(t, resp.Timeout, time.Duration(0))
	})

	t.Run("AnotherBackupIsInProgress", func(t *testing.T) {
		// first
		sourcer := &fakeSourcer{}
		sourcer.On("Backupable", ctx, req.Classes).Return(nil)
		sourcer.On("CreateBackup", mock.Anything, mock.Anything).Return(nil, nil)
		sourcer.On("ReleaseBackup", mock.Anything, mock.Anything).Return(nil)
		var ch <-chan backup.ClassDescriptor
		sourcer.On("BackupDescriptors", any, any, any).Return(ch)

		backend := &fakeBackend{}
		backend.On("GetObject", ctx, nodeHome, BackupFile).Return(nil, backup.ErrNotFound{})
		backend.On("HomeDir", mock.Anything, mock.Anything, mock.Anything).Return(path)
		backend.On("Initialize", ctx, mock.Anything).Return(nil)
		m := createManager(sourcer, nil, backend, nil)
		// second
		resp1 := m.OnCanCommit(ctx, &req)
		want1 := &CanCommitResponse{
			Method:  OpCreate,
			ID:      req.ID,
			Timeout: req.Duration,
		}
		assert.Equal(t, resp1, want1)
		resp := m.OnCanCommit(ctx, &req)
		assert.Contains(t, resp.Err, "already in progress")
		assert.Equal(t, resp.Timeout, time.Duration(0))
	})

	t.Run("Success", func(t *testing.T) {
		var (
			sourcePath = t.TempDir()
			sourcer    = &fakeSourcer{}
			backend    = newFakeBackend()
		)

		sourcer.On("Backupable", ctx, req.Classes).Return(nil)
		ch := fakeBackupDescriptor(genClassDescriptions(t, sourcePath, cls, cls2)...)
		sourcer.On("BackupDescriptors", any, backupID, mock.Anything).Return(ch)
		sourcer.On("ReleaseBackup", ctx, backupID, mock.Anything).Return(nil)

		backend.On("HomeDir", mock.Anything, mock.Anything, mock.Anything).Return(path)
		backend.On("SourceDataPath").Return(sourcePath)
		backend.On("GetObject", ctx, nodeHome, BackupFile).Return(nil, errNotFound)
		backend.On("Initialize", ctx, nodeHome).Return(nil)
		backend.On("PutObject", mock.Anything, nodeHome, BackupFile, mock.Anything).Return(nil).Once()
		backend.On("Write", mock.Anything, nodeHome, mock.Anything, mock.Anything).Return(any, nil)
		m := createManager(sourcer, nil, backend, nil)

		req := req
		req.Duration = time.Hour
		got := m.OnCanCommit(ctx, &req)
		want := &CanCommitResponse{OpCreate, req.ID, _TimeoutShardCommit, ""}
		assert.Equal(t, got, want)

		err := m.OnCommit(ctx, &StatusRequest{OpCreate, req.ID, backendName, "", ""})
		assert.Nil(t, err)
		m.backupper.waitForCompletion(20, 50)
		assert.Equal(t, string(backup.Success), backend.meta.Status)
		assert.Equal(t, "", backend.meta.Error)
	})

	t.Run("AbortBeforeCommit", func(t *testing.T) {
		var (
			sourcePath = t.TempDir()
			sourcer    = &fakeSourcer{}
			backend    = newFakeBackend()
		)

		sourcer.On("Backupable", ctx, req.Classes).Return(nil)
		ch := fakeBackupDescriptor(genClassDescriptions(t, sourcePath, cls, cls2)...)
		sourcer.On("BackupDescriptors", any, backupID, mock.Anything).Return(ch)
		sourcer.On("ReleaseBackup", ctx, backupID, mock.Anything).Return(nil)

		backend.On("HomeDir", mock.Anything, mock.Anything, mock.Anything).Return(path)
		backend.On("SourceDataPath").Return(sourcePath)

		backend.On("GetObject", ctx, nodeHome, BackupFile).Return(nil, errNotFound)
		backend.On("Initialize", ctx, nodeHome).Return(nil)
		backend.On("PutObject", mock.Anything, nodeHome, BackupFile, mock.Anything).Return(nil).Once()
		backend.On("Write", mock.Anything, nodeHome, mock.Anything, mock.Anything).Return(any, nil)
		m := createManager(sourcer, nil, backend, nil)

		req := req
		req.Duration = time.Hour
		got := m.OnCanCommit(ctx, &req)
		want := &CanCommitResponse{OpCreate, req.ID, _TimeoutShardCommit, ""}
		assert.Equal(t, got, want)

		err := m.OnAbort(ctx, &AbortRequest{OpCreate, req.ID, backendName, "", ""})
		assert.Nil(t, err)
		m.backupper.waitForCompletion(20, 50)
		assert.Contains(t, m.backupper.lastAsyncError.Error(), "abort")
	})

	t.Run("AbortCommit", func(t *testing.T) {
		var (
			sourcePath = t.TempDir()
			sourcer    = &fakeSourcer{}
			backend    = newFakeBackend()
			m          = createManager(sourcer, nil, backend, nil)
		)

		sourcer.On("Backupable", ctx, req.Classes).Return(nil)
		ch := fakeBackupDescriptor(genClassDescriptions(t, sourcePath, cls, cls2)...)
		sourcer.On("BackupDescriptors", any, backupID, mock.Anything).Return(ch).RunFn = func(a mock.Arguments) {
			m.OnAbort(ctx, &AbortRequest{OpCreate, req.ID, backendName, "", ""})
			// give the abort request time to propagate
			time.Sleep(10 * time.Millisecond)
		}
		sourcer.On("ReleaseBackup", ctx, backupID, mock.Anything).Return(nil)
		// backend
		backend.On("HomeDir", mock.Anything, mock.Anything, mock.Anything).Return(path)
		backend.On("SourceDataPath").Return(sourcePath)
		backend.On("GetObject", ctx, nodeHome, BackupFile).Return(nil, errNotFound)
		backend.On("Initialize", ctx, nodeHome).Return(nil)
		backend.On("PutObject", mock.Anything, nodeHome, BackupFile, mock.Anything).Return(nil).Once()
		backend.On("Write", mock.Anything, nodeHome, mock.Anything, mock.Anything).Return(any, nil)

		req := req
		req.Duration = time.Hour
		got := m.OnCanCommit(ctx, &req)
		want := &CanCommitResponse{OpCreate, req.ID, _TimeoutShardCommit, ""}
		assert.Equal(t, got, want)

		err := m.OnCommit(ctx, &StatusRequest{OpCreate, req.ID, backendName, "", ""})
		assert.Nil(t, err)
		m.backupper.waitForCompletion(20, 50)
		assert.Equal(t, string(backup.Cancelled), backend.meta.Status)
		errMsg := context.Canceled.Error()
		assert.Equal(t, errMsg, backend.meta.Error)
		assert.Contains(t, m.backupper.lastAsyncError.Error(), errMsg)
	})

	t.Run("ExpirationTimeout", func(t *testing.T) {
		var (
			sourcePath = t.TempDir()
			sourcer    = &fakeSourcer{}
			backend    = newFakeBackend()
		)

		sourcer.On("Backupable", ctx, req.Classes).Return(nil)
		ch := fakeBackupDescriptor(genClassDescriptions(t, sourcePath, cls, cls2)...)
		sourcer.On("BackupDescriptors", any, backupID, mock.Anything).Return(ch)
		sourcer.On("ReleaseBackup", ctx, backupID, mock.Anything).Return(nil)

		backend.On("HomeDir", mock.Anything, mock.Anything, mock.Anything).Return(path)
		backend.On("SourceDataPath").Return(sourcePath)
		backend.On("GetObject", ctx, nodeHome, BackupFile).Return(nil, errNotFound)
		backend.On("Initialize", ctx, nodeHome).Return(nil)
		backend.On("PutObject", mock.Anything, nodeHome, BackupFile, mock.Anything).Return(nil).Once()
		backend.On("Write", mock.Anything, backupID, mock.Anything, mock.Anything).Return(any, nil)
		m := createManager(sourcer, nil, backend, nil)

		req := req
		req.Duration = time.Millisecond * 10
		got := m.OnCanCommit(ctx, &req)
		want := &CanCommitResponse{OpCreate, req.ID, req.Duration, ""}
		assert.Equal(t, got, want)

		m.backupper.waitForCompletion(20, 50)
		assert.Contains(t, m.backupper.lastAsyncError.Error(), "timed out")
	})
}

func genClassDescriptions(t *testing.T, sourcePath string, classes ...string) []backup.ClassDescriptor {
	ret := make([]backup.ClassDescriptor, len(classes))
	rawbytes := []byte("raw")
	subDir := filepath.Join(sourcePath, "dir1")
	if err := os.MkdirAll(subDir, os.ModePerm); err != nil {
		t.Fatalf("create test subdirectory %s: %v", subDir, err)
	}
	files := []string{"dir1/file1", "dir1/file2", "counter.txt", "version.txt", "prop.txt"}
	for _, p := range files {
		p = filepath.Join(sourcePath, p)
		if err := os.WriteFile(p, rawbytes, os.ModePerm); err != nil {
			t.Fatalf("create test file %s: %v", p, err)
		}
	}

	for i, cls := range classes {
		ret[i] = backup.ClassDescriptor{
			Name: cls, Schema: rawbytes, ShardingState: rawbytes,
			Shards: []*backup.ShardDescriptor{
				{
					Name: "Shard1", Node: "Node-1",
					Files:                 files[0:2],
					DocIDCounterPath:      files[2],
					ShardVersionPath:      files[3],
					PropLengthTrackerPath: files[4],
					DocIDCounter:          rawbytes,
					Version:               rawbytes,
					PropLengthTracker:     rawbytes,
				},
			},
		}
	}
	return ret
}

func fakeBackupDescriptor(descs ...backup.ClassDescriptor) <-chan backup.ClassDescriptor {
	ch := make(chan backup.ClassDescriptor, len(descs))
	go func() {
		for _, cls := range descs {
			ch <- cls
		}
		close(ch)
	}()

	return ch
}

func createManager(sourcer Sourcer, schema schemaManger, backend modulecapabilities.BackupBackend, backendErr error) *Handler {
	backends := &fakeBackupBackendProvider{backend, backendErr}
	if sourcer == nil {
		sourcer = &fakeSourcer{}
	}
	if schema == nil {
		schema = &fakeSchemaManger{nodeName: nodeName}
	}

	logger, _ := test.NewNullLogger()
	return NewHandler(logger, mocks.NewMockAuthorizer(), schema, sourcer, backends, fakeRbacBackupWrapper{}, fakeRbacBackupWrapper{})
}

type fakeRbacBackupWrapper struct{}

func (r fakeRbacBackupWrapper) Snapshot() ([]byte, error) {
	return nil, nil
}

func (r fakeRbacBackupWrapper) Restore([]byte) error {
	return nil
}
