package provisioning

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"strings"
	"testing"
	"time"

	provisioningAPIServer "github.com/grafana/grafana/pkg/registry/apis/provisioning"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"

	provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
	"github.com/grafana/grafana/apps/provisioning/pkg/repository"
	"github.com/grafana/grafana/pkg/apimachinery/utils"
	"github.com/grafana/grafana/pkg/infra/usagestats"
	"github.com/grafana/grafana/pkg/tests/apis"
	"github.com/grafana/grafana/pkg/util/testutil"
)

func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) {
	testutil.SkipIntegrationTestInShortMode(t)

	helper := runGrafana(t)
	createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
	ctx := context.Background()

	inputFiles := []string{
		"testdata/github-readonly.json.tmpl",
		"testdata/local-readonly.json.tmpl",
	}

	for _, inputFilePath := range inputFiles {
		t.Run(inputFilePath, func(t *testing.T) {
			input := helper.RenderObject(t, inputFilePath, nil)
			name := mustNestedString(input.Object, "metadata", "name")

			_, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
			require.NoError(t, err, "failed to create resource")

			output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
			require.NoError(t, err, "failed to read back resource")

			// Move encrypted token mutation
			token, found, err := unstructured.NestedString(output.Object, "secure", "token", "name")
			require.NoError(t, err, "secure token name is not a string")
			if found {
				require.True(t, strings.HasPrefix("inline-", token)) // name created automatically
				err = unstructured.SetNestedField(input.Object, token, "secure", "token", "name")
				require.NoError(t, err, "unable to copy secure token")
			}

			// Marshal as real objects to ",omitempty" values are tested properly
			expectedRepo := unstructuredToRepository(t, input)
			returnedRepo := unstructuredToRepository(t, output)
			require.Equal(t, expectedRepo.Spec, returnedRepo.Spec)

			// A viewer should not be able to see the same thing
			var statusCode int
			rsp := helper.ViewerREST.Get().
				Namespace("default").
				Resource("repositories").
				Name(name).
				Do(context.Background())
			require.Error(t, rsp.Error())
			rsp.StatusCode(&statusCode)
			require.Equal(t, http.StatusForbidden, statusCode)

			// Viewer can see file listing
			rsp = helper.AdminREST.Get().
				Namespace("default").
				Resource("repositories").
				Name(name).
				Suffix("files/").
				Do(context.Background())
			require.NoError(t, rsp.Error())

			// Verify that we can list refs
			rsp = helper.AdminREST.Get().
				Namespace("default").
				Resource("repositories").
				Name(name).
				Suffix("refs").
				Do(context.Background())

			if expectedRepo.Spec.Type == provisioning.LocalRepositoryType {
				require.ErrorContains(t, rsp.Error(), "does not support versioned operations")
			} else {
				require.NoError(t, rsp.Error())
				refs := &provisioning.RefList{}
				err = rsp.Into(refs)
				require.NoError(t, err)
				require.True(t, len(refs.Items) >= 1, "should have at least one ref")

				var foundBranch bool
				for _, ref := range refs.Items {
					// FIXME: this assertion should be improved for all git types and take things from config
					if ref.Name == "integration-test" {
						require.Equal(t, "0f3370c212b04b9704e00f6926ef339bf91c7a1b", ref.Hash)
						require.Equal(t, "https://github.com/grafana/grafana-git-sync-demo/tree/integration-test", ref.RefURL)
						foundBranch = true
					}
				}

				require.True(t, foundBranch, "branch should be found")
			}
		})
	}

	// Viewer can see settings listing
	t.Run("viewer has access to list", func(t *testing.T) {
		require.EventuallyWithT(t, func(collect *assert.CollectT) {
			settings := &provisioning.RepositoryViewList{}
			rsp := helper.ViewerREST.Get().
				Namespace("default").
				Suffix("settings").
				Do(context.Background())
			if !assert.NoError(collect, rsp.Error()) {
				return
			}

			err := rsp.Into(settings)
			if !assert.NoError(collect, err) {
				return
			}
			if !assert.Len(collect, settings.Items, len(inputFiles)) {
				return
			}

			for _, i := range settings.Items {
				switch i.Type {
				case provisioning.LocalRepositoryType:
					assert.Equal(collect, i.Path, helper.ProvisioningPath)
				case provisioning.GitHubRepositoryType:
					assert.Equal(collect, i.URL, "https://github.com/grafana/grafana-git-sync-demo")
					assert.Equal(collect, i.Path, "grafana/")
				default:
					assert.NotEmpty(collect, i.Path)
					assert.NotEmpty(collect, i.URL)
				}
			}

			assert.ElementsMatch(collect, []provisioning.RepositoryType{
				provisioning.LocalRepositoryType,
				provisioning.GitHubRepositoryType,
			}, settings.AvailableRepositoryTypes)
		}, time.Second*10, time.Millisecond*100, "Expected settings to match")
	})

	t.Run("Repositories are reported in stats", func(t *testing.T) {
		require.EventuallyWithT(t, func(collect *assert.CollectT) {
			report := apis.DoRequest(helper.K8sTestHelper, apis.RequestParams{
				Method: http.MethodGet,
				Path:   "/api/admin/usage-report-preview",
				User:   helper.Org1.Admin,
			}, &usagestats.Report{})

			stats := map[string]any{}
			for k, v := range report.Result.Metrics {
				if strings.HasPrefix(k, "stats.repository.") {
					stats[k] = v
				}
			}
			assert.Equal(collect, map[string]any{
				"stats.repository.github.count": 1.0,
				"stats.repository.local.count":  1.0,
			}, stats)
		}, time.Second*10, time.Millisecond*100, "Expected stats to match")
	})
}

func TestIntegrationProvisioning_RepositoryValidation(t *testing.T) {
	testutil.SkipIntegrationTestInShortMode(t)

	helper := runGrafana(t)
	ctx := context.Background()

	for _, testCase := range []struct {
		name        string
		repo        *unstructured.Unstructured
		expectedErr string
	}{
		{
			name: "should succeed with valid local repository",
			repo: func() *unstructured.Unstructured {
				return helper.RenderObject(t, "testdata/local-readonly.json.tmpl", map[string]any{
					"Name":        "valid-repo",
					"SyncEnabled": true,
				})
			}(),
		},
		{
			name: "should error if mutually exclusive finalizers are set",
			repo: func() *unstructured.Unstructured {
				localTmp := helper.RenderObject(t, "testdata/local-readonly.json.tmpl", map[string]any{
					"Name":        "repo-with-invalid-finalizers",
					"SyncEnabled": true,
				})

				// Setting finalizers to trigger a failure
				localTmp.SetFinalizers([]string{
					repository.CleanFinalizer,
					repository.ReleaseOrphanResourcesFinalizer,
					repository.RemoveOrphanResourcesFinalizer,
				})

				return localTmp
			}(),
			expectedErr: "cannot have both remove and release orphan resources finalizers",
		},
	} {
		t.Run(testCase.name, func(t *testing.T) {
			_, err := helper.Repositories.Resource.Create(ctx, testCase.repo, metav1.CreateOptions{})
			if testCase.expectedErr == "" {
				assert.NoError(t, err)
			} else {
				assert.Error(t, err)
				assert.ErrorContains(t, err, testCase.expectedErr)
			}
		})
	}

	// Test Git repository path validation - ensure child paths are rejected
	t.Run("Git repository path validation", func(t *testing.T) {
		baseURL := "https://github.com/grafana/test-repo-path-validation"

		pathTests := []struct {
			name        string
			path        string
			expectError error
		}{
			{
				name:        "first repo with path 'demo/nested' should succeed",
				path:        "demo/nested",
				expectError: nil,
			},
			{
				name:        "second repo with child path 'demo/nested/again' should fail",
				path:        "demo/nested/again",
				expectError: provisioningAPIServer.ErrRepositoryParentFolderConflict,
			},
			{
				name:        "third repo with parent path 'demo' should fail",
				path:        "demo",
				expectError: provisioningAPIServer.ErrRepositoryParentFolderConflict,
			},
			{
				name:        "fourth repo with nested child path 'demo/nested/nested-second' should fail",
				path:        "demo/nested/again/two",
				expectError: provisioningAPIServer.ErrRepositoryParentFolderConflict,
			},
			{
				name:        "fifth repo with duplicate path 'demo/nested' should fail",
				path:        "demo/nested",
				expectError: provisioningAPIServer.ErrRepositoryDuplicatePath,
			},
		}

		for i, test := range pathTests {
			t.Run(test.name, func(t *testing.T) {
				repoName := fmt.Sprintf("git-path-test-%d", i+1)
				gitRepo := helper.RenderObject(t, "testdata/github-readonly.json.tmpl", map[string]any{
					"Name":        repoName,
					"URL":         baseURL,
					"Path":        test.path,
					"SyncEnabled": false, // Disable sync to avoid external dependencies
					"SyncTarget":  "folder",
				})

				_, err := helper.Repositories.Resource.Create(ctx, gitRepo, metav1.CreateOptions{FieldValidation: "Strict"})

				if test.expectError != nil {
					require.Error(t, err, "Expected error for repository with path: %s", test.path)
					require.ErrorContains(t, err, test.expectError.Error(), "Error should contain expected message for path: %s", test.path)
					var statusError *apierrors.StatusError
					if errors.As(err, &statusError) {
						require.Equal(t, metav1.StatusReasonInvalid, statusError.ErrStatus.Reason, "Should be a validation error")
						require.Equal(t, http.StatusUnprocessableEntity, int(statusError.ErrStatus.Code), "Should return 422 status code")
					}
				} else {
					require.NoError(t, err, "Expected success for repository with path: %s", test.path)
				}
			})
		}
	})
}

func TestIntegrationProvisioning_FailInvalidSchema(t *testing.T) {
	testutil.SkipIntegrationTestInShortMode(t)

	t.Skip("Reenable this test once we enforce schema validation for provisioning")

	helper := runGrafana(t)
	ctx := context.Background()

	const repo = "invalid-schema-tmp"
	// Set up the repository and the file to import.
	helper.CopyToProvisioningPath(t, "testdata/invalid-dashboard-schema.json", "invalid-dashboard-schema.json")

	localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
		"Name":        repo,
		"SyncEnabled": true,
	})
	_, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{})
	require.NoError(t, err)

	// Make sure the repo can read and validate the file
	_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "invalid-dashboard-schema.json")
	status := helper.RequireApiErrorStatus(err, metav1.StatusReasonBadRequest, http.StatusBadRequest)
	require.Equal(t, status.Message, "Dry run failed: Dashboard.dashboard.grafana.app \"invalid-schema-uid\" is invalid: [spec.panels.0.repeatDirection: Invalid value: conflicting values \"h\" and \"this is not an allowed value\", spec.panels.0.repeatDirection: Invalid value: conflicting values \"v\" and \"this is not an allowed value\"]")

	const invalidSchemaUid = "invalid-schema-uid"
	_, err = helper.DashboardsV1.Resource.Get(ctx, invalidSchemaUid, metav1.GetOptions{})
	require.Error(t, err, "invalid dashboard shouldn't exist")
	require.True(t, apierrors.IsNotFound(err))

	helper.DebugState(t, repo, "BEFORE PULL JOB WITH INVALID SCHEMA")

	spec := provisioning.JobSpec{
		Action: provisioning.JobActionPull,
		Pull:   &provisioning.SyncJobOptions{},
	}

	result := helper.TriggerJobAndWaitForComplete(t, repo, spec)
	job := &provisioning.Job{}
	err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, job)
	require.NoError(t, err, "should convert to Job object")

	assert.Equal(t, provisioning.JobStateError, job.Status.State)
	assert.Equal(t, job.Status.Message, "completed with errors")
	assert.Equal(t, job.Status.Errors[0], "Dashboard.dashboard.grafana.app \"invalid-schema-uid\" is invalid: [spec.panels.0.repeatDirection: Invalid value: conflicting values \"h\" and \"this is not an allowed value\", spec.panels.0.repeatDirection: Invalid value: conflicting values \"v\" and \"this is not an allowed value\"]")

	_, err = helper.DashboardsV1.Resource.Get(ctx, invalidSchemaUid, metav1.GetOptions{})
	require.Error(t, err, "invalid dashboard shouldn't have been created")
	require.True(t, apierrors.IsNotFound(err))

	err = helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{}, "files", "invalid-dashboard-schema.json")
	require.NoError(t, err, "should delete the resource file")
}

func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) {
	testutil.SkipIntegrationTestInShortMode(t)

	helper := runGrafana(t)
	ctx := context.Background()

	// FIXME: instead of using an existing GitHub repository, we should create a new one for the tests and a branch
	// This was the previous structure
	// ghmock.WithRequestMatchHandler(ghmock.GetReposGitTreesByOwnerByRepoByTreeSha,
	// 	ghHandleTree(t, map[string][]*gh.TreeEntry{
	// 		"deadbeef": {
	// 			treeEntryDir("grafana", "subtree"),
	// 		},
	// 		"subtree": {
	// 			treeEntry("dashboard.json", helper.LoadFile("testdata/all-panels.json")),
	// 			treeEntryDir("subdir", "subtree2"),
	// 			treeEntry("subdir/dashboard2.yaml", helper.LoadFile("testdata/text-options.json")),
	// 		},
	// 	})),

	// FIXME: uncomment these to implement webhook integration tests.
	// helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient(
	// 	ghmock.WithRequestMatchHandler(ghmock.GetReposHooksByOwnerByRepo, ghAlwaysWrite(t, []*gh.Hook{})),
	// 	ghmock.WithRequestMatchHandler(ghmock.PostReposHooksByOwnerByRepo, ghAlwaysWrite(t, &gh.Hook{ID: gh.Ptr(int64(123))})),
	// )

	const repo = "github-create-test"
	testRepo := TestRepo{
		Name:               repo,
		Template:           "testdata/github-readonly.json.tmpl",
		Target:             "folder",
		ExpectedDashboards: 3,
		ExpectedFolders:    3, // Folder sync creates an additional folder for the repository itself
	}

	helper.CreateRepo(t, testRepo)

	// By now, we should have synced, meaning we have data to read in the local Grafana instance!

	found, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
	require.NoError(t, err, "can list values")

	names := []string{}
	for _, v := range found.Items {
		names = append(names, v.GetName())
	}
	require.Len(t, names, 3, "should have three dashboards")
	assert.Contains(t, names, "adg5vbj", "should contain dashboard.json's contents")
	assert.Contains(t, names, "admfz74", "should contain dashboard2.yaml's contents")
	assert.Contains(t, names, "adn5mxb", "should contain dashboard2.yaml's contents")

	err = helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{})
	require.NoError(t, err, "should delete values")

	require.EventuallyWithT(t, func(collect *assert.CollectT) {
		found, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
		assert.NoError(t, err, "can list values")
		assert.Equal(collect, 0, len(found.Items), "expected dashboards to be deleted")
	}, time.Second*20, time.Millisecond*10, "Expected dashboards to be deleted")

	// Wait for repository to be fully deleted before subtests run
	require.EventuallyWithT(t, func(collect *assert.CollectT) {
		_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{})
		assert.True(collect, apierrors.IsNotFound(err), "repository should be deleted")
	}, time.Second*10, time.Millisecond*50, "repository should be deleted before subtests")

	t.Run("github url cleanup", func(t *testing.T) {
		tests := []struct {
			name   string
			input  string
			output string
		}{
			{
				name:   "simple-url",
				input:  "https://github.com/dprokop/grafana-git-sync-test",
				output: "https://github.com/dprokop/grafana-git-sync-test",
			},
			{
				name:   "trim-dot-git",
				input:  "https://github.com/dprokop/grafana-git-sync-test.git",
				output: "https://github.com/dprokop/grafana-git-sync-test",
			},
			{
				name:   "trim-slash",
				input:  "https://github.com/dprokop/grafana-git-sync-test/",
				output: "https://github.com/dprokop/grafana-git-sync-test",
			},
		}

		for _, test := range tests {
			t.Run(test.name, func(t *testing.T) {
				// Create repository directly without health checks since we're only testing URL cleanup
				input := helper.RenderObject(t, "testdata/github-readonly.json.tmpl", map[string]any{
					"Name":        test.name,
					"URL":         test.input,
					"SyncTarget":  "folder",
					"SyncEnabled": false, // Disable sync since we're just testing URL cleanup,
					"Path":        fmt.Sprintf("grafana-%s/", test.name),
				})

				_, err := helper.Repositories.Resource.Create(ctx, input, metav1.CreateOptions{})
				require.NoError(t, err, "failed to create resource")

				obj, err := helper.Repositories.Resource.Get(ctx, test.name, metav1.GetOptions{})
				require.NoError(t, err, "failed to read back resource")

				url, _, err := unstructured.NestedString(obj.Object, "spec", "github", "url")
				require.NoError(t, err, "failed to read URL")
				require.Equal(t, test.output, url)
			})
		}
	})
}

func TestIntegrationProvisioning_RepositoryLimits(t *testing.T) {
	testutil.SkipIntegrationTestInShortMode(t)

	helper := runGrafana(t)
	ctx := context.Background()

	originalName := "original-repo"
	// Create instance sync repository first
	originalRepo := TestRepo{
		Name:               originalName,
		Target:             "instance",
		Copies:             map[string]string{}, // No files needed for this test
		ExpectedDashboards: 0,
		ExpectedFolders:    0,
	}
	helper.CreateRepo(t, originalRepo)

	t.Run("folder sync is rejected when instance sync exists", func(t *testing.T) {
		folderRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
			"Name":        "folder-blocked-by-instance",
			"SyncEnabled": true,
			"SyncTarget":  "folder",
		})

		_, err := helper.Repositories.Resource.Create(ctx, folderRepo, metav1.CreateOptions{FieldValidation: "Strict"})
		require.Error(t, err, "folder sync repository should be rejected when instance sync exists")

		// Verify the error message mentions the existing instance repository
		statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity)
		require.Contains(t, statusError.Message, "Cannot create folder repository when instance repository exists: "+originalName)
	})

	t.Run("change between folder and instance sync for the same repository if no previous sync happened", func(t *testing.T) {
		repo, err := helper.Repositories.Resource.Get(ctx, originalName, metav1.GetOptions{})
		require.NoError(t, err, "failed to get repository")
		err = unstructured.SetNestedField(repo.Object, "folder", "spec", "sync", "target")
		require.NoError(t, err, "failed to set syncTarget to folder")
		_, err = helper.Repositories.Resource.Update(ctx, repo, metav1.UpdateOptions{FieldValidation: "Strict"})
		require.NoError(t, err, "failed to update repository to folder sync")

		// Verify that the repository is now a folder sync
		// We verify with the listing APIs because it may take some time for the update to propagate
		require.Eventually(t, func() bool {
			repos, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{})
			if err != nil {
				return false
			}

			for _, repo := range repos.Items {
				if repo.GetName() == originalName {
					syncTarget, found, err := unstructured.NestedString(repo.Object, "spec", "sync", "target")
					if err != nil || !found {
						return false
					}

					return syncTarget == "folder"
				}
			}

			return false
		}, time.Second*10, time.Millisecond*100, "failed to verify that sync target is folder")
	})

	t.Run("instance sync rejected when any other repository exists", func(t *testing.T) {
		instanceRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
			"Name":        "instance-repo-blocked",
			"SyncEnabled": true,
			"SyncTarget":  "instance",
		})

		_, err := helper.Repositories.Resource.Create(ctx, instanceRepo, metav1.CreateOptions{FieldValidation: "Strict"})
		require.Error(t, err, "instance sync repository should be rejected when any other repository exists")

		statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity)
		require.Contains(t, statusError.Message, "Instance repository can only be created when no other repositories exist. Found: "+originalName)
	})

	t.Run("repository limit validation of 10 for folder syncs repositories", func(t *testing.T) {
		for i := 2; i <= 10; i++ {
			repoName := fmt.Sprintf("limit-test-repo-%d", i)
			limitTestRepo := TestRepo{
				Name:               repoName,
				Target:             "folder",
				Copies:             map[string]string{}, // No files needed for this test
				ExpectedDashboards: 0,
				ExpectedFolders:    i, // Each repository creates a folder, so total = i
			}
			helper.CreateRepo(t, limitTestRepo)
		}

		// Try to create the 11th repository - should fail due to limit
		eleventhRepoName := "limit-test-repo-11"
		eleventhRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
			"Name":        eleventhRepoName,
			"SyncEnabled": true,
			"SyncTarget":  "folder",
		})

		_, err := helper.Repositories.Resource.Create(ctx, eleventhRepo, metav1.CreateOptions{FieldValidation: "Strict"})
		require.Error(t, err, "11th repository should be rejected due to limit")

		statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity)
		require.Contains(t, statusError.Message, "Maximum number of 10 repositories reached")
	})
}

func TestIntegrationProvisioning_RunLocalRepository(t *testing.T) {
	testutil.SkipIntegrationTestInShortMode(t)

	helper := runGrafana(t)
	ctx := context.Background()

	const allPanels = "n1jR8vnnz"
	const repo = "local-local-examples"
	const targetPath = "all-panels.json"

	// Set up the repository.
	helper.CreateRepo(t, TestRepo{Name: repo})

	// Write a file -- this will create it *both* in the local file system, and in grafana
	t.Run("write all panels", func(t *testing.T) {
		code := 0

		// Check that we can not (yet) UPDATE the target path
		result := helper.AdminREST.Put().
			Namespace("default").
			Resource("repositories").
			Name(repo).
			SubResource("files", targetPath).
			Body(helper.LoadFile("testdata/all-panels.json")).
			SetHeader("Content-Type", "application/json").
			Do(ctx).StatusCode(&code)
		require.Equal(t, http.StatusNotFound, code)
		require.True(t, apierrors.IsNotFound(result.Error()))

		// Now try again with POST (as an editor)
		result = helper.EditorREST.Post().
			Namespace("default").
			Resource("repositories").
			Name(repo).
			SubResource("files", targetPath).
			Body(helper.LoadFile("testdata/all-panels.json")).
			SetHeader("Content-Type", "application/json").
			Do(ctx).StatusCode(&code)
		require.NoError(t, result.Error(), "expecting to be able to create file")
		wrapper := &provisioning.ResourceWrapper{}
		raw, err := result.Raw()
		require.NoError(t, err)
		err = json.Unmarshal(raw, wrapper)
		require.NoError(t, err)
		require.Equal(t, 200, code, "expected 200 response")
		require.Equal(t, provisioning.ClassicDashboard, wrapper.Resource.Type.Classic)
		name, _, _ := unstructured.NestedString(wrapper.Resource.File.Object, "metadata", "name")
		require.Equal(t, allPanels, name, "name from classic UID")
		name, _, _ = unstructured.NestedString(wrapper.Resource.Upsert.Object, "metadata", "name")
		require.Equal(t, allPanels, name, "save the name from the request")

		// Get the file from the grafana database
		obj, err := helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{})
		require.NoError(t, err, "the value should be saved in grafana")
		val, _, _ := unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyManagerKind)
		require.Equal(t, string(utils.ManagerKindRepo), val, "should have repo annotations")
		val, _, _ = unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyManagerIdentity)
		require.Equal(t, repo, val, "should have repo annotations")

		// Read the file we wrote
		wrapObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", targetPath)
		require.NoError(t, err, "read value")

		wrap := &unstructured.Unstructured{}
		wrap.Object, _, err = unstructured.NestedMap(wrapObj.Object, "resource", "dryRun")
		require.NoError(t, err)
		meta, err := utils.MetaAccessor(wrap)
		require.NoError(t, err)
		require.Equal(t, allPanels, meta.GetName(), "read the name out of the saved file")

		// Check that an admin can update
		meta.SetAnnotation("test", "from-provisioning")
		body, err := json.Marshal(wrap.Object)
		require.NoError(t, err)
		result = helper.AdminREST.Put().
			Namespace("default").
			Resource("repositories").
			Name(repo).
			SubResource("files", targetPath).
			Body(body).
			SetHeader("Content-Type", "application/json").
			Do(ctx).StatusCode(&code)
		require.Equal(t, 200, code)
		require.NoError(t, result.Error(), "update as admin value")
		raw, err = result.Raw()
		require.NoError(t, err)
		err = json.Unmarshal(raw, wrapper)
		require.NoError(t, err)
		anno, _, _ := unstructured.NestedString(wrapper.Resource.File.Object, "metadata", "annotations", "test")
		require.Equal(t, "from-provisioning", anno, "should set the annotation")

		// But a viewer can not
		result = helper.ViewerREST.Put().
			Namespace("default").
			Resource("repositories").
			Name(repo).
			SubResource("files", targetPath).
			Body(body).
			SetHeader("Content-Type", "application/json").
			Do(ctx).StatusCode(&code)
		require.Equal(t, 403, code)
		require.True(t, apierrors.IsForbidden(result.Error()), code)
	})

	t.Run("fail using invalid paths", func(t *testing.T) {
		result := helper.AdminREST.Post().
			Namespace("default").
			Resource("repositories").
			Name(repo).
			SubResource("files", "test", "..", "..", "all-panels.json"). // UNSAFE PATH
			Body(helper.LoadFile("testdata/all-panels.json")).
			SetHeader("Content-Type", "application/json").
			Do(ctx)
		require.Error(t, result.Error(), "invalid path should return error")

		// Read a file with a bad path
		_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "../../all-panels.json")
		require.Error(t, err, "invalid path should error")
	})

	t.Run("require name or generateName", func(t *testing.T) {
		code := 0
		result := helper.AdminREST.Post().
			Namespace("default").
			Resource("repositories").
			Name(repo).
			SubResource("files", "example.json").
			Body([]byte(`apiVersion: dashboard.grafana.app/v0alpha1
kind: Dashboard
spec:
  title: Test dashboard
`)).Do(ctx).StatusCode(&code)
		require.Error(t, result.Error(), "missing name")

		result = helper.AdminREST.Post().
			Namespace("default").
			Resource("repositories").
			Name(repo).
			SubResource("files", "example.json").
			Body([]byte(`apiVersion: dashboard.grafana.app/v0alpha1
kind: Dashboard
metadata:
  generateName: prefix-
spec:
  title: Test dashboard
`)).Do(ctx).StatusCode(&code)
		require.NoError(t, result.Error(), "should create name")
		require.Equal(t, 200, code, "expect OK result")

		raw, err := result.Raw()
		require.NoError(t, err)

		obj := &unstructured.Unstructured{}
		err = json.Unmarshal(raw, obj)
		require.NoError(t, err)

		name, _, _ := unstructured.NestedString(obj.Object, "resource", "upsert", "metadata", "name")
		require.True(t, strings.HasPrefix(name, "prefix-"), "should generate name")
	})
}

func TestIntegrationProvisioning_ImportAllPanelsFromLocalRepository(t *testing.T) {
	testutil.SkipIntegrationTestInShortMode(t)

	helper := runGrafana(t)
	ctx := context.Background()

	// The dashboard shouldn't exist yet
	const allPanels = "n1jR8vnnz"
	_, err := helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{})
	require.Error(t, err, "no all-panels dashboard should exist")
	require.True(t, apierrors.IsNotFound(err))

	const repo = "local-tmp"
	// Set up the repository and the file to import.
	testRepo := TestRepo{
		Name:               repo,
		Target:             "instance",
		Copies:             map[string]string{"testdata/all-panels.json": "all-panels.json"},
		ExpectedDashboards: 1,
		ExpectedFolders:    0,
	}
	// We create the repository
	helper.CreateRepo(t, testRepo)

	// Now, we import it, such that it may exist
	// The sync may not be necessary as the sync may have happened automatically at this point
	helper.SyncAndWait(t, repo, nil)

	// Make sure the repo can read and validate the file
	obj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "all-panels.json")
	require.NoError(t, err, "valid path should be fine")

	resource, _, err := unstructured.NestedMap(obj.Object, "resource")
	require.NoError(t, err, "missing resource")
	require.NoError(t, err, "invalid action")
	require.NotNil(t, resource["file"], "the raw file")
	require.NotNil(t, resource["dryRun"], "dryRun result")

	action, _, err := unstructured.NestedString(resource, "action")
	require.NoError(t, err, "invalid action")
	// FIXME: there is no point in in returning action for a read / get request.
	require.Equal(t, "update", action)

	_, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
	require.NoError(t, err, "can list values")

	obj, err = helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{})
	require.NoError(t, err, "all-panels dashboard should exist")
	require.Equal(t, repo, obj.GetAnnotations()[utils.AnnoKeyManagerIdentity])

	// Try writing the value directly
	err = unstructured.SetNestedField(obj.Object, []any{"aaa", "bbb"}, "spec", "tags")
	require.NoError(t, err, "set tags")
	obj, err = helper.DashboardsV1.Resource.Update(ctx, obj, metav1.UpdateOptions{})
	require.NoError(t, err)
	v, _, _ := unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyUpdatedBy)
	require.Equal(t, "access-policy:provisioning", v)

	// Should not be able to directly delete the managed resource
	err = helper.DashboardsV1.Resource.Delete(ctx, allPanels, metav1.DeleteOptions{})
	require.NoError(t, err, "user can delete")

	_, err = helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{})
	require.Error(t, err, "should delete the internal resource")
	require.True(t, apierrors.IsNotFound(err))
}

func TestIntegrationProvisioning_DeleteRepositoryAndReleaseResources(t *testing.T) {
	testutil.SkipIntegrationTestInShortMode(t)

	helper := runGrafana(t)
	ctx := context.Background()

	const repo = "gh-repo"
	testRepo := TestRepo{
		Name:               repo,
		Template:           "testdata/github-readonly.json.tmpl",
		Target:             "folder",
		ExpectedDashboards: 3,
		ExpectedFolders:    3,
	}
	helper.CreateRepo(t, testRepo)

	// Checking resources are there and are managed
	foundFolders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{})
	require.NoError(t, err, "can list folders")
	for _, v := range foundFolders.Items {
		assert.Contains(t, v.GetAnnotations(), utils.AnnoKeyManagerKind)
		assert.Contains(t, v.GetAnnotations(), utils.AnnoKeyManagerIdentity)
	}

	foundDashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
	require.NoError(t, err, "can list dashboards")
	for _, v := range foundDashboards.Items {
		assert.Contains(t, v.GetAnnotations(), utils.AnnoKeyManagerKind)
		assert.Contains(t, v.GetAnnotations(), utils.AnnoKeyManagerIdentity)
		assert.Contains(t, v.GetAnnotations(), utils.AnnoKeySourcePath)
		assert.Contains(t, v.GetAnnotations(), utils.AnnoKeySourceChecksum)
	}

	_, err = helper.Repositories.Resource.Patch(ctx, repo, types.JSONPatchType, []byte(`[
		{
			"op": "replace",
			"path": "/metadata/finalizers",
			"value": ["cleanup", "release-orphan-resources"]
		}
	]`), metav1.PatchOptions{})
	require.NoError(t, err, "should successfully patch finalizers")

	err = helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{})
	require.NoError(t, err, "should delete repository")

	require.EventuallyWithT(t, func(collect *assert.CollectT) {
		_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{})
		assert.True(collect, apierrors.IsNotFound(err), "repository should be deleted")
	}, time.Second*10, time.Millisecond*50, "repository should be deleted")

	require.EventuallyWithT(t, func(collect *assert.CollectT) {
		foundDashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
		assert.NoError(t, err, "can list values")
		for _, v := range foundDashboards.Items {
			assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeyManagerKind)
			assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeyManagerIdentity)
			assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeySourcePath)
			assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeySourceChecksum)
		}
	}, time.Second*20, time.Millisecond*10, "Expected dashboards to be released")

	require.EventuallyWithT(t, func(collect *assert.CollectT) {
		foundFolders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{})
		assert.NoError(t, err, "can list values")
		for _, v := range foundFolders.Items {
			assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeyManagerKind)
			assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeyManagerIdentity)
			assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeySourcePath)
			assert.NotContains(t, v.GetAnnotations(), utils.AnnoKeySourceChecksum)
		}
	}, time.Second*20, time.Millisecond*10, "Expected folders to be released")
}
