package sync

import (
	"context"
	"errors"
	"fmt"
	"sync/atomic"
	"testing"
	"time"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	dynamicfake "k8s.io/client-go/dynamic/fake"
	k8testing "k8s.io/client-go/testing"

	provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
	"github.com/grafana/grafana/apps/provisioning/pkg/repository"
	"github.com/grafana/grafana/pkg/infra/tracing"
	"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
	"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)

func TestFullSync_ContextCancelled(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	repo := repository.NewMockRepository(t)
	repoResources := resources.NewMockRepositoryResources(t)
	clients := resources.NewMockResourceClients(t)
	progress := jobs.NewMockJobProgressRecorder(t)
	compareFn := NewMockCompareFn(t)

	repo.On("Config").Return(&provisioning.Repository{
		ObjectMeta: metav1.ObjectMeta{
			Name: "test-repo",
		},
		Spec: provisioning.RepositorySpec{
			Title: "Test Repo",
		},
	})

	compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]ResourceFileChange{{}}, nil)
	progress.On("SetTotal", mock.Anything, 1).Return()

	err := FullSync(ctx, repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
	require.EqualError(t, err, "context canceled")
}

func TestFullSync_Error(t *testing.T) {
	repo := repository.NewMockRepository(t)
	repoResources := resources.NewMockRepositoryResources(t)
	clients := resources.NewMockResourceClients(t)
	progress := jobs.NewMockJobProgressRecorder(t)
	compareFn := NewMockCompareFn(t)

	repo.On("Config").Return(&provisioning.Repository{
		ObjectMeta: metav1.ObjectMeta{
			Name: "test-repo",
		},
	})

	compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, fmt.Errorf("some error"))

	err := FullSync(context.Background(), repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
	require.EqualError(t, err, "compare changes: some error")
}

func TestFullSync_NoChanges(t *testing.T) {
	repo := repository.NewMockRepository(t)
	repoResources := resources.NewMockRepositoryResources(t)
	clients := resources.NewMockResourceClients(t)
	progress := jobs.NewMockJobProgressRecorder(t)
	compareFn := NewMockCompareFn(t)

	repo.On("Config").Return(&provisioning.Repository{
		ObjectMeta: metav1.ObjectMeta{
			Name: "test-repo",
		},
	})

	compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]ResourceFileChange{}, nil)
	progress.On("SetFinalMessage", mock.Anything, "no changes to sync").Return()

	err := FullSync(context.Background(), repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
	require.NoError(t, err)
}

func TestFullSync_SuccessfulFolderCreation(t *testing.T) {
	repo := repository.NewMockRepository(t)
	repoResources := resources.NewMockRepositoryResources(t)
	clients := resources.NewMockResourceClients(t)
	progress := jobs.NewMockJobProgressRecorder(t)
	compareFn := NewMockCompareFn(t)

	repo.On("Config").Return(&provisioning.Repository{
		ObjectMeta: metav1.ObjectMeta{
			Name: "test-repo",
		},
		Spec: provisioning.RepositorySpec{
			Title: "Test Repo",
			Sync: provisioning.SyncOptions{
				Target: provisioning.SyncTargetTypeFolder,
			},
		},
	})

	compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]ResourceFileChange{}, nil)
	progress.On("SetFinalMessage", mock.Anything, "no changes to sync").Return()
	repoResources.On("EnsureFolderExists", mock.Anything, resources.Folder{
		ID:    "test-repo",
		Title: "Test Repo",
		Path:  "",
	}, "").Return(nil)

	err := FullSync(context.Background(), repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
	require.NoError(t, err)
}

func TestFullSync_FolderCreationFailed(t *testing.T) {
	repo := repository.NewMockRepository(t)
	repoResources := resources.NewMockRepositoryResources(t)
	clients := resources.NewMockResourceClients(t)
	progress := jobs.NewMockJobProgressRecorder(t)
	compareFn := NewMockCompareFn(t)

	repo.On("Config").Return(&provisioning.Repository{
		ObjectMeta: metav1.ObjectMeta{
			Name: "test-repo",
		},
		Spec: provisioning.RepositorySpec{
			Title: "Test Repo",
			Sync: provisioning.SyncOptions{
				Target: provisioning.SyncTargetTypeFolder,
			},
		},
	})

	repoResources.On("EnsureFolderExists", mock.Anything, resources.Folder{
		ID:    "test-repo",
		Title: "Test Repo",
		Path:  "",
	}, "").Return(fmt.Errorf("folder creation failed"))

	err := FullSync(context.Background(), repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
	require.Error(t, err)
	require.Contains(t, err.Error(), "create root folder: folder creation failed")
}

func TestFullSync_FolderCreationFailedWithInstanceTarget(t *testing.T) {
	repo := repository.NewMockRepository(t)
	repoResources := resources.NewMockRepositoryResources(t)
	clients := resources.NewMockResourceClients(t)
	progress := jobs.NewMockJobProgressRecorder(t)
	compareFn := NewMockCompareFn(t)

	repo.On("Config").Return(&provisioning.Repository{
		ObjectMeta: metav1.ObjectMeta{
			Name: "test-repo",
		},
		Spec: provisioning.RepositorySpec{
			Title: "Test Repo",
			Sync: provisioning.SyncOptions{
				Target: provisioning.SyncTargetTypeInstance,
			},
		},
	})

	// No folder creation should be attempted with instance target
	// But we should still test the error path for completeness
	compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
		Return(nil, fmt.Errorf("compare error"))

	err := FullSync(context.Background(), repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
	require.Error(t, err)
	require.Contains(t, err.Error(), "compare changes: compare error")
}

func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
	tests := []struct {
		name          string
		setupMocks    func(*repository.MockRepository, *resources.MockRepositoryResources, *resources.MockResourceClients, *jobs.MockJobProgressRecorder, *MockCompareFn)
		changes       []ResourceFileChange
		expectedError string
		description   string
	}{
		{
			name:        "too many errors",
			description: "Should return an error when too many errors occur",
			changes: []ResourceFileChange{
				{
					Action: repository.FileActionCreated,
					Path:   "dashboards/one.json",
				},
				{
					Action: repository.FileActionCreated,
					Path:   "dashboards/two.json",
				},
				{
					Action: repository.FileActionCreated,
					Path:   "dashboards/three.json",
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				var callCount int64 = 0
				progress.On("TooManyErrors").Return(func() error {
					if atomic.AddInt64(&callCount, 1) > 1 {
						return fmt.Errorf("too many errors")
					}
					return nil
				})

				progress.On("HasDirPathFailedCreation", mock.MatchedBy(func(path string) bool {
					return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json"
				})).Return(false).Maybe()

				repoResources.On("WriteResourceFromFile", mock.Anything, mock.MatchedBy(func(path string) bool {
					return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json"
				}), "").Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil).Maybe()

				progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
					return result.Action() == repository.FileActionCreated &&
						(result.Path() == "dashboards/one.json" || result.Path() == "dashboards/two.json" || result.Path() == "dashboards/three.json")
				})).Return().Maybe()
			},
			expectedError: "too many errors",
		},
		{
			name:        "successful apply with file creation",
			description: "Should successfully apply changes when creating a new file",
			changes: []ResourceFileChange{
				{
					Action: repository.FileActionCreated,
					Path:   "dashboards/test.json",
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)
				progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)

				repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
					Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)

				progress.On("Record", mock.Anything, jobs.NewGroupKindResult(
					"test-dashboard", "dashboards", "Dashboard").WithAction(repository.FileActionCreated).WithPath("dashboards/test.json").Build()).Return()
			},
		},
		{
			name:        "failed apply with file creation",
			description: "Should record an error when creating a new file",
			changes: []ResourceFileChange{
				{
					Action: repository.FileActionCreated,
					Path:   "dashboards/test.json",
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)
				progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)

				repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
					Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error"))

				progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
					return result.Action() == repository.FileActionCreated &&
						result.Path() == "dashboards/test.json" &&
						result.Name() == "test-dashboard" &&
						result.Kind() == "Dashboard" &&
						result.Group() == "dashboards" &&
						result.Error() != nil &&
						result.Error().Error() == "writing resource from file dashboards/test.json: write error"
				})).Return()
			},
		},
		{
			name:        "successful apply with file update",
			description: "Should successfully apply changes when updating an existing file",
			changes: []ResourceFileChange{
				{
					Action: repository.FileActionUpdated,
					Path:   "dashboards/test.json",
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)
				progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)

				repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
					Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)

				progress.On("Record", mock.Anything, jobs.NewGroupKindResult(
					"test-dashboard",
					"dashboards",
					"Dashboard",
				).WithPath("dashboards/test.json").
					WithAction(repository.FileActionUpdated).
					Build()).Return()
			},
		},
		{
			name:        "failed apply with file update",
			description: "Should record an error when updating a file",
			changes: []ResourceFileChange{
				{
					Action: repository.FileActionUpdated,
					Path:   "dashboards/test.json",
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)
				progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)

				repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
					Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error"))

				progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
					return result.Action() == repository.FileActionUpdated &&
						result.Path() == "dashboards/test.json" &&
						result.Name() == "test-dashboard" &&
						result.Kind() == "Dashboard" &&
						result.Group() == "dashboards" &&
						result.Error() != nil &&
						result.Error().Error() == "writing resource from file dashboards/test.json: write error"
				})).Return()
			},
		},
		{
			name:        "successful apply with folder creation",
			description: "Should successfully apply changes when creating a new folder",
			changes: []ResourceFileChange{
				{
					Action: repository.FileActionCreated,
					Path:   "one/two/three/",
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)
				progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false)

				repoResources.On("EnsureFolderPathExist", mock.Anything, "one/two/three/").Return("some-folder", nil)
				progress.On("Record", mock.Anything, jobs.NewGroupKindResult(
					"some-folder",
					"folder.grafana.app",
					"Folder",
				).WithPath("one/two/three/").
					WithAction(repository.FileActionCreated).
					Build()).Return()
			},
		},
		{
			name:        "failed apply folder creation",
			description: "Should record an error when creating a new folder",
			changes: []ResourceFileChange{
				{
					Action: repository.FileActionCreated,
					Path:   "one/two/three/",
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)
				progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false)

				repoResources.On(
					"EnsureFolderPathExist",
					mock.Anything,
					"one/two/three/",
				).Return("some-folder", errors.New("folder creation error"))
				progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
					return result.Action() == repository.FileActionCreated &&
						result.Path() == "one/two/three/" &&
						result.Name() == "" &&
						result.Kind() == "Folder" &&
						result.Group() == "folder.grafana.app" &&
						result.Error() != nil &&
						result.Error().Error() == "ensuring folder exists at path one/two/three/: folder creation error"
				})).Return()
			},
		},
		{
			name:        "successful apply with file deletion",
			description: "Should successfully apply changes when deleting an existing file",
			changes: []ResourceFileChange{
				{
					Action: repository.FileActionDeleted,
					Path:   "dashboards/test.json",
					Existing: &provisioning.ResourceListItem{
						Name:     "test-dashboard",
						Resource: "dashboards",
						Group:    "dashboards",
					},
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)

				scheme := runtime.NewScheme()
				require.NoError(t, metav1.AddMetaToScheme(scheme))
				listGVK := schema.GroupVersionKind{
					Group:   resources.DashboardResource.Group,
					Version: resources.DashboardResource.Version,
					Kind:    "DashboardList",
				}

				scheme.AddKnownTypeWithName(listGVK, &metav1.PartialObjectMetadataList{})
				scheme.AddKnownTypeWithName(schema.GroupVersionKind{
					Group:   resources.DashboardResource.Group,
					Version: resources.DashboardResource.Version,
					Kind:    resources.DashboardResource.Resource,
				}, &metav1.PartialObjectMetadata{})

				fakeDynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{
					resources.DashboardResource: listGVK.Kind,
				})

				fakeDynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) {
					return true, nil, nil
				})

				clients.On("ForResource", mock.Anything, schema.GroupVersionResource{
					Group:    "dashboards",
					Resource: "dashboards",
				}).Return(fakeDynamicClient.Resource(resources.DashboardResource), schema.GroupVersionKind{
					Kind:    "Dashboard",
					Group:   "dashboards",
					Version: "v1",
				}, nil)

				progress.On("Record", mock.Anything, jobs.NewGroupKindResult(
					"test-dashboard",
					"dashboards",
					"Dashboard",
				).WithPath("dashboards/test.json").
					WithAction(repository.FileActionDeleted).
					Build()).Return()
			},
		},
		{
			name:        "file delete error",
			description: "Should return an error when deleting a file",
			changes: []ResourceFileChange{
				{
					Action: repository.FileActionDeleted,
					Path:   "dashboards/test.json",
					Existing: &provisioning.ResourceListItem{
						Name:     "test-dashboard",
						Resource: "dashboards",
						Group:    "dashboards",
					},
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)

				scheme := runtime.NewScheme()
				require.NoError(t, metav1.AddMetaToScheme(scheme))
				listGVK := schema.GroupVersionKind{
					Group:   resources.DashboardResource.Group,
					Version: resources.DashboardResource.Version,
					Kind:    "DashboardList",
				}

				scheme.AddKnownTypeWithName(listGVK, &metav1.PartialObjectMetadataList{})
				scheme.AddKnownTypeWithName(schema.GroupVersionKind{
					Group:   resources.DashboardResource.Group,
					Version: resources.DashboardResource.Version,
					Kind:    resources.DashboardResource.Resource,
				}, &metav1.PartialObjectMetadata{})

				fakeDynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{
					resources.DashboardResource: listGVK.Kind,
				})

				fakeDynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) {
					return true, nil, fmt.Errorf("delete failed")
				})

				clients.On("ForResource", mock.Anything, schema.GroupVersionResource{
					Group:    "dashboards",
					Resource: "dashboards",
				}).Return(fakeDynamicClient.Resource(resources.DashboardResource), schema.GroupVersionKind{
					Kind:    "Dashboard",
					Group:   "dashboards",
					Version: "v1",
				}, nil)

				progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
					return result.Action() == repository.FileActionDeleted &&
						result.Path() == "dashboards/test.json" &&
						result.Name() == "test-dashboard" &&
						result.Kind() == "Dashboard" &&
						result.Group() == "dashboards" &&
						result.Error() != nil &&
						result.Error().Error() == "deleting resource dashboards/Dashboard test-dashboard: delete failed"
				})).Return()
			},
		},
		{
			name:        "without existing for delete",
			description: "Should record an error when deleting a file",
			changes: []ResourceFileChange{
				{
					Action:   repository.FileActionDeleted,
					Path:     "dashboards/test.json",
					Existing: nil,
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)
				progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
					return result.Action() == repository.FileActionDeleted &&
						result.Path() == "dashboards/test.json" &&
						result.Error() != nil &&
						result.Error().Error() == "processing deletion for file dashboards/test.json: missing existing reference"
				})).Return()
			},
		},
		{
			name:        "without existing name for delete",
			description: "Should record an error when deleting a file",
			changes: []ResourceFileChange{
				{
					Action:   repository.FileActionDeleted,
					Path:     "dashboards/test.json",
					Existing: &provisioning.ResourceListItem{},
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)
				progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
					return result.Action() == repository.FileActionDeleted &&
						result.Path() == "dashboards/test.json" &&
						result.Error() != nil &&
						result.Error().Error() == "processing deletion for file dashboards/test.json: missing existing reference"
				})).Return()
			},
		},
		{
			name:        "error finding client for delete",
			description: "Should record an error when deleting a file",
			changes: []ResourceFileChange{
				{
					Action: repository.FileActionDeleted,
					Path:   "dashboards/test.json",
					Existing: &provisioning.ResourceListItem{
						Name:     "test-dashboard",
						Group:    "dashboards",
						Resource: "dashboards",
					},
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)

				clients.On("ForResource", mock.Anything, schema.GroupVersionResource{
					Group:    "dashboards",
					Resource: "dashboards",
				}).Return(nil, schema.GroupVersionKind{}, errors.New("didn't work"))

				progress.On("Record", mock.Anything, jobs.NewGroupKindResult(
					"test-dashboard",
					"dashboards",
					"dashboards", // could not find a real kind
				).WithPath("dashboards/test.json").
					WithAction(repository.FileActionDeleted).
					WithError(fmt.Errorf("get client for deleted object: %w", errors.New("didn't work"))).
					Build()).Return()
			},
		},
		{
			name:        "successful apply with folder deletion",
			description: "Should successfully apply changes when deleting an existing folder",
			changes: []ResourceFileChange{
				{
					Action: repository.FileActionDeleted,
					Path:   "to-be-deleted/",
					Existing: &provisioning.ResourceListItem{
						Name:     "test-folder",
						Resource: "Folder",
						Group:    "folders",
					},
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)
				progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false)

				scheme := runtime.NewScheme()
				require.NoError(t, metav1.AddMetaToScheme(scheme))
				listGVK := schema.GroupVersionKind{
					Group:   resources.FolderResource.Group,
					Version: resources.FolderResource.Version,
					Kind:    "FolderList",
				}

				scheme.AddKnownTypeWithName(listGVK, &metav1.PartialObjectMetadataList{})
				scheme.AddKnownTypeWithName(schema.GroupVersionKind{
					Group:   resources.FolderResource.Group,
					Version: resources.FolderResource.Version,
					Kind:    resources.FolderResource.Resource,
				}, &metav1.PartialObjectMetadata{})

				fakeDynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{
					resources.FolderResource: listGVK.Kind,
				})

				fakeDynamicClient.PrependReactor("delete", "folders", func(action k8testing.Action) (bool, runtime.Object, error) {
					return true, nil, nil
				})

				clients.On("ForResource", mock.Anything, schema.GroupVersionResource{
					Group:    "folders",
					Resource: "Folder",
				}).Return(fakeDynamicClient.Resource(resources.FolderResource), schema.GroupVersionKind{
					Kind:    "Folder",
					Group:   "folders",
					Version: "v1",
				}, nil)

				progress.On("Record", mock.Anything, jobs.NewGroupKindResult(
					"test-folder",
					"folders",
					"Folder",
				).WithPath("to-be-deleted/").
					WithAction(repository.FileActionDeleted).
					Build()).Return()
			},
		},
		{
			name:        "failed to apply with folder deletion",
			description: "Should record an error when deleting a folder",
			changes: []ResourceFileChange{
				{
					Action: repository.FileActionDeleted,
					Path:   "to-be-deleted/",
					Existing: &provisioning.ResourceListItem{
						Name:     "test-folder",
						Resource: "Folder",
						Group:    "folders",
					},
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)
				progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false)

				scheme := runtime.NewScheme()
				require.NoError(t, metav1.AddMetaToScheme(scheme))
				listGVK := schema.GroupVersionKind{
					Group:   resources.FolderResource.Group,
					Version: resources.FolderResource.Version,
					Kind:    "FolderList",
				}

				scheme.AddKnownTypeWithName(listGVK, &metav1.PartialObjectMetadataList{})
				scheme.AddKnownTypeWithName(schema.GroupVersionKind{
					Group:   resources.FolderResource.Group,
					Version: resources.FolderResource.Version,
					Kind:    resources.FolderResource.Resource,
				}, &metav1.PartialObjectMetadata{})

				fakeDynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{
					resources.FolderResource: listGVK.Kind,
				})

				fakeDynamicClient.PrependReactor("delete", "folders", func(action k8testing.Action) (bool, runtime.Object, error) {
					return true, nil, fmt.Errorf("delete failed")
				})

				clients.On("ForResource", mock.Anything, schema.GroupVersionResource{
					Group:    "folders",
					Resource: "Folder",
				}).Return(fakeDynamicClient.Resource(resources.FolderResource), schema.GroupVersionKind{
					Kind:    "Folder",
					Group:   "folders",
					Version: "v1",
				}, nil)

				progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
					return result.Action() == repository.FileActionDeleted &&
						result.Path() == "to-be-deleted/" &&
						result.Name() == "test-folder" &&
						result.Kind() == "Folder" &&
						result.Group() == "folders" &&
						result.Error() != nil &&
						result.Error().Error() == "deleting resource folders/Folder test-folder: delete failed"
				})).Return()
			},
		},
		{
			name:        "operation timeout after 15 seconds",
			description: "Should record timeout error when operation takes longer than 15 seconds",
			changes: []ResourceFileChange{
				{
					Action: repository.FileActionCreated,
					Path:   "dashboards/slow.json",
				},
			},
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
				progress.On("TooManyErrors").Return(nil)
				progress.On("HasDirPathFailedCreation", "dashboards/slow.json").Return(false)

				repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/slow.json", "").
					Run(func(args mock.Arguments) {
						ctx := args.Get(0).(context.Context)
						select {
						case <-ctx.Done():
							return
						case <-time.After(20 * time.Second):
							return
						}
					}).
					Return("", schema.GroupVersionKind{}, context.DeadlineExceeded)

				// applyChange records the error from WriteResourceFromFile
				progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
					return result.Action() == repository.FileActionCreated &&
						result.Path() == "dashboards/slow.json" &&
						result.Error() != nil &&
						result.Error().Error() == "writing resource from file dashboards/slow.json: context deadline exceeded"
				})).Return().Once()
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			repo := repository.NewMockRepository(t)
			repoResources := resources.NewMockRepositoryResources(t)
			clients := resources.NewMockResourceClients(t)
			progress := jobs.NewMockJobProgressRecorder(t)
			compareFn := NewMockCompareFn(t)

			tt.setupMocks(repo, repoResources, clients, progress, compareFn)
			compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tt.changes, nil)
			repo.On("Config").Return(&provisioning.Repository{
				ObjectMeta: metav1.ObjectMeta{
					Name: "test-repo",
				},
				Spec: provisioning.RepositorySpec{
					Title: "Test Repo",
				},
			})

			progress.On("SetTotal", mock.Anything, len(tt.changes)).Return()
			err := FullSync(context.Background(), repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
			if tt.expectedError != "" {
				require.EqualError(t, err, tt.expectedError, tt.description)
			} else {
				require.NoError(t, err, tt.description)
			}
		})
	}
}
