package sync

import (
	"context"
	"fmt"
	"testing"

	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"

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

func TestChanges(t *testing.T) {
	t.Run("start the same", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "simplelocal/dashboard.json", Hash: "xyz", Blob: true},
		}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "simplelocal/dashboard.json", Hash: "xyz"},
			},
		}

		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Empty(t, changes)
	})

	t.Run("create a source file", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "muta.json", Hash: "xyz", Blob: true},
		}
		target := &provisioning.ResourceList{}

		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Len(t, changes, 1)
		require.Equal(t, ResourceFileChange{
			Action: repository.FileActionCreated,
			Path:   "muta.json",
		}, changes[0])
	})
	t.Run("empty file path", func(t *testing.T) {
		source := []repository.FileTreeEntry{}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "", Resource: "dashboard", Group: "dashboard.grafana.app"},
			},
		}
		_, err := Changes(source, target)
		require.EqualError(t, err, "empty path on a non folder")
	})

	t.Run("empty path with folder resource", func(t *testing.T) {
		source := []repository.FileTreeEntry{}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "", Resource: resources.FolderResource.Resource, Group: resources.FolderResource.Group},
			},
		}

		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Empty(t, changes)
	})

	t.Run("create empty folder structure for folders with unsupported file types", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "one/two/first.md", Hash: "xyz", Blob: true},
			{Path: "other/second.md", Hash: "xyz", Blob: true},
		}

		target := &provisioning.ResourceList{}
		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Len(t, changes, 2)

		require.Equal(t, ResourceFileChange{
			Action: repository.FileActionCreated,
			Path:   "one/two/",
		}, changes[0])
		require.Equal(t, ResourceFileChange{
			Action: repository.FileActionCreated,
			Path:   "other/",
		}, changes[1])
	})

	t.Run("keep empty folders when unsupported file types are present", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "one/two/first.md", Hash: "xyz", Blob: true},
			{Path: "other/second.md", Hash: "xyz", Blob: true},
		}

		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "one/two/", Resource: "folders"},
				{Path: "other/", Resource: "folders"},
			},
		}
		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Empty(t, changes)
	})
	t.Run("keep common path to unsupported file types", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "common/first.md", Hash: "xyz", Blob: true},
			{Path: "alsocommon/second.md", Hash: "xyz", Blob: true},
		}

		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "common/", Resource: "folders"},
				{Path: "common/not-common/", Resource: "folders", Name: "uncommon-name", Hash: "xyz"},
				{Path: "alsocommon/", Resource: "folders"},
			},
		}
		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Len(t, changes, 1)
		require.Equal(t, ResourceFileChange{
			Action: repository.FileActionDeleted,
			Path:   "common/not-common/",
			Existing: &provisioning.ResourceListItem{
				Path:     "common/not-common/",
				Resource: "folders",
				Name:     "uncommon-name",
				Hash:     "xyz",
			},
		}, changes[0], "the uncommon path should be deleted")
	})

	t.Run("delete a source file", func(t *testing.T) {
		source := []repository.FileTreeEntry{}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "adsl62h.yaml", Hash: "xyz", Group: "dashboard.grafana.app", Resource: "dashboards", Name: "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c"},
			},
		}

		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Len(t, changes, 1)
		require.Equal(t, ResourceFileChange{
			Action: repository.FileActionDeleted,
			Path:   "adsl62h.yaml",
			Existing: &provisioning.ResourceListItem{
				Path:     "adsl62h.yaml",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
				Name:     "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c",
				Hash:     "xyz",
			},
		}, changes[0])
	})

	t.Run("folder deletion order", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "x/y/z/ignored.md", Blob: true}, // ignored
			{Path: "aaa/bbb.yaml", Hash: "xyz", Blob: true},
		}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "a.json"},
				{Path: "x/y/file.json"},
				{Path: "aaa/", Resource: "folders"}, // Folder... no action required
				{Path: "aaa/bbb.yaml", Hash: "xyz"},
				{Path: "zzz/longest/path/here.json"},
				{Path: "short/file.yml"},
			},
		}
		changes, err := Changes(source, target)
		require.NoError(t, err)

		order := make([]string, len(changes))
		for i := range changes {
			order[i] = changes[i].Path
		}
		require.Equal(t, []string{
			"zzz/longest/path/here.json", // not sorted yet
			"x/y/file.json",
			"x/y/z/",
			"short/file.yml",
			"a.json",
		}, order)
	})

	t.Run("modify a file", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "adsl62h.yaml", Hash: "modified", Blob: true},
		}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{
					Path:     "adsl62h.yaml",
					Group:    "dashboard.grafana.app",
					Resource: "dashboards",
					Name:     "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c",
					Hash:     "original",
				},
			},
		}

		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Len(t, changes, 1)
		require.Equal(t, ResourceFileChange{
			Action: repository.FileActionUpdated,
			Path:   "adsl62h.yaml",
			Existing: &provisioning.ResourceListItem{
				Path:     "adsl62h.yaml",
				Group:    "dashboard.grafana.app",
				Resource: "dashboards",
				Name:     "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c",
				Hash:     "original",
			},
		}, changes[0])
	})

	t.Run("keep folder with hidden files", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "folder/.hidden.json", Hash: "xyz", Blob: true},
		}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "folder/", Resource: "folders"},
			},
		}
		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Empty(t, changes, "folder should be kept when it contains hidden files")
	})

	t.Run("keep folder with invalid hidden paths", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "folder/.invalid/path.json", Hash: "xyz", Blob: true},
		}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "folder/", Resource: "folders"},
			},
		}
		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Empty(t, changes, "folder should be kept when it contains invalid hidden paths")
	})

	t.Run("keep folder with hidden folders", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "folder/.hidden/valid.json", Hash: "xyz", Blob: true},
		}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "folder/", Resource: "folders"},
			},
		}
		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Empty(t, changes, "folder should be kept when it contains hidden folders")
	})

	t.Run("unhidden path from hidden file", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "folder/.hidden/dashboard.json", Hash: "xyz", Blob: true},
		}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "folder/", Resource: "folders"},
			},
		}
		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Empty(t, changes, "hidden file should not be unhidden")
	})
	t.Run("hidden path to file", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "one/two/.hidden/dashboard.json", Hash: "xyz", Blob: true},
		}
		target := &provisioning.ResourceList{}
		expected := []ResourceFileChange{
			{
				Action: repository.FileActionCreated,
				Path:   "one/two/",
			},
		}
		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Equal(t, expected, changes)
	})
	t.Run("hidden path to folder", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "one/two/.hidden/folder/", Hash: "xyz", Blob: true},
		}
		target := &provisioning.ResourceList{}
		expected := []ResourceFileChange{
			{
				Action: repository.FileActionCreated,
				Path:   "one/two/",
			},
		}
		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Equal(t, expected, changes)
	})
	t.Run("hidden at the root", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: ".hidden/dashboard.json", Hash: "xyz", Blob: true},
		}

		target := &provisioning.ResourceList{}
		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Empty(t, changes)
	})

	t.Run("nested folder with space is created correctly", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "abc/dash.json", Hash: "abc", Blob: true},
			{Path: "abc/nested folder/nested-dashboard.json", Hash: "xyz", Blob: true},
		}

		target := &provisioning.ResourceList{}

		expected := []ResourceFileChange{
			{
				Action: repository.FileActionCreated,
				Path:   "abc/nested folder/nested-dashboard.json",
			},
			{
				Action: repository.FileActionCreated,
				Path:   "abc/dash.json",
			},
		}

		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Equal(t, expected, changes, "Expected diff to correctly include nested folder contents")
	})

	t.Run("error on empty path for non-folder resource", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "", Hash: "xyz", Blob: true},
		}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "", Resource: "dashboard", Group: "dashboard.grafana.app"},
			},
		}

		changes, err := Changes(source, target)
		require.Error(t, err)
		require.Contains(t, err.Error(), "empty path on a non folder")
		require.Nil(t, changes)
	})

	t.Run("complex nested folder hierarchy with mixed file types", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "root/folder1/dashboard.json", Hash: "abc", Blob: true},
			{Path: "root/folder1/subfolder/.gitkeep", Hash: "def", Blob: true},
			{Path: "root/folder2/alert.json", Hash: "ghi", Blob: true},
			{Path: "root/folder2/README.md", Hash: "jkl", Blob: true},
		}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "root/", Resource: "folders"},
				{Path: "root/folder1/", Resource: "folders"},
				{Path: "root/folder2/", Resource: "folders"},
				{Path: "root/folder1/dashboard.json", Hash: "abc", Resource: "dashboard"},
				{Path: "root/folder2/alert.json", Hash: "old", Resource: "alert"},
			},
		}

		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Len(t, changes, 2)
		require.Equal(t, "root/folder1/subfolder/", changes[0].Path)
		require.Equal(t, "root/folder2/alert.json", changes[1].Path)
	})

	t.Run("folder path suffix handling", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "folder1/", Hash: "abc", Blob: false},
			{Path: "folder2/", Hash: "def", Blob: false},
			{Path: "folder3", Hash: "ghi", Blob: false},
			{Path: "folder4/", Hash: "jkl", Blob: false},
		}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "folder1", Resource: resources.FolderResource.Resource, Group: resources.FolderResource.Group},
				{Path: "folder2/", Resource: "folders", Group: resources.FolderResource.Group},
				{Path: "folder3", Resource: "folders", Group: resources.FolderResource.Group},
				{Path: "folder4/", Resource: "folders", Group: resources.FolderResource.Group},
			},
		}

		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Empty(t, changes, "Should handle folder paths with and without trailing slash")
	})

	t.Run("empty source with populated target", func(t *testing.T) {
		source := []repository.FileTreeEntry{}
		target := &provisioning.ResourceList{
			Items: []provisioning.ResourceListItem{
				{Path: "folder1/", Resource: "folders"},
				{Path: "folder1/dashboard.json", Resource: "dashboard"},
				{Path: "folder2/", Resource: "folders"},
			},
		}

		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Len(t, changes, 3)

		// Verify deletion order (deepest first)
		require.Equal(t, "folder1/dashboard.json", changes[0].Path)
		require.Equal(t, "folder1/", changes[1].Path)
		require.Equal(t, "folder2/", changes[2].Path)

		for _, change := range changes {
			require.Equal(t, repository.FileActionDeleted, change.Action)
		}
	})

	t.Run("empty target with populated source", func(t *testing.T) {
		source := []repository.FileTreeEntry{
			{Path: "folder1/", Hash: "abc", Blob: false},
			{Path: "folder1/dashboard.json", Hash: "def", Blob: true},
			{Path: "folder2/", Hash: "ghi", Blob: false},
		}
		target := &provisioning.ResourceList{}

		changes, err := Changes(source, target)
		require.NoError(t, err)
		require.Len(t, changes, 3) // Only non-blob entries should create changes

		require.Equal(t, ResourceFileChange{
			Action: repository.FileActionCreated,
			Path:   "folder1/dashboard.json",
		}, changes[0])

		require.Equal(t, ResourceFileChange{
			Action: repository.FileActionCreated,
			Path:   "folder1/",
		}, changes[1])

		require.Equal(t, ResourceFileChange{
			Action: repository.FileActionCreated,
			Path:   "folder2/",
		}, changes[2])
	})

	t.Run("report correct changes with .keep files", func(t *testing.T) {
		// Replicating how `source` is actually being passed in `Changes` function
		source := []repository.FileTreeEntry{
			{Path: "folder1/", Hash: "abc", Blob: false},
			{Path: "folder1/.keep", Hash: "abc", Blob: true},
			{Path: "folder1/dashboard.json", Hash: "def", Blob: true},
			{Path: "folder2/", Hash: "ghi", Blob: false},
			{Path: "folder2/.keep", Hash: "ghi", Blob: true},
		}
		target := &provisioning.ResourceList{}

		changes, err := Changes(source, target)
		require.NoError(t, err)
		// 2 folders and 1 file, so 3 changes in total
		require.Len(t, changes, 3)

		require.Equal(t, ResourceFileChange{
			Action: repository.FileActionCreated,
			Path:   "folder1/dashboard.json",
		}, changes[0])

		require.Equal(t, ResourceFileChange{
			Action: repository.FileActionCreated,
			Path:   "folder1/",
		}, changes[1])

		require.Equal(t, ResourceFileChange{
			Action: repository.FileActionCreated,
			Path:   "folder2/",
		}, changes[2])
	})
}

func TestCompare(t *testing.T) {
	tests := []struct {
		name            string
		setupMocks      func(*repository.MockRepository, *resources.MockRepositoryResources)
		expectedError   string
		expectedChanges []ResourceFileChange
		description     string
	}{
		{
			name:        "error listing current resources",
			description: "Should return error when listing current resources fails",
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources) {
				repoResources.On("List", mock.Anything).Return(nil, fmt.Errorf("listing failed"))
			},
			expectedError: "error listing current: listing failed",
		},
		{
			name:        "error reading tree",
			description: "Should return error when reading tree fails",
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources) {
				repoResources.On("List", mock.Anything).Return(&provisioning.ResourceList{}, nil)
				repo.On("ReadTree", mock.Anything, "current-ref").Return(nil, fmt.Errorf("read tree failed"))
			},
			expectedError: "error reading tree: read tree failed",
		},
		{
			name:        "no changes between source and target",
			description: "Should return empty changes when source and target are identical",
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources) {
				target := &provisioning.ResourceList{
					Items: []provisioning.ResourceListItem{
						{Path: "dashboard.json", Hash: "xyz"},
					},
				}
				source := []repository.FileTreeEntry{
					{Path: "dashboard.json", Hash: "xyz", Blob: true},
				}

				repoResources.On("List", mock.Anything).Return(target, nil)
				repo.On("ReadTree", mock.Anything, "current-ref").Return(source, nil)
			},
			expectedChanges: []ResourceFileChange{},
		},
		{
			name:        "compare function error",
			description: "Should return error when comparing fails",
			setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources) {
				target := &provisioning.ResourceList{
					Items: []provisioning.ResourceListItem{
						// Empty path to trigger error
						{Path: "", Hash: "xyz", Resource: "dashboard", Group: "dashboard.grafana.app"},
					},
				}
				source := []repository.FileTreeEntry{
					{Path: "dashboard.json", Hash: "xyz", Blob: true},
				}
				repoResources.On("List", mock.Anything).Return(target, nil)
				repo.On("ReadTree", mock.Anything, "current-ref").Return(source, nil)
			},
			expectedError: "calculate changes: empty path on a non folder",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			repo := repository.NewMockRepository(t)
			repoResources := resources.NewMockRepositoryResources(t)

			tt.setupMocks(repo, repoResources)

			changes, err := Compare(context.Background(), repo, repoResources, "current-ref")

			if tt.expectedError != "" {
				require.EqualError(t, err, tt.expectedError, tt.description)
				require.Nil(t, changes)
			} else {
				require.NoError(t, err, tt.description)
				require.Equal(t, tt.expectedChanges, changes, tt.description)
			}
		})
	}
}
