package jobs

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

	"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"

	provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
	"github.com/grafana/grafana/pkg/tests/apis/provisioning/common"
)

func TestIntegrationProvisioning_MoveJob(t *testing.T) {
	helper := sharedHelper(t)
	ctx := context.Background()
	const repo = "move-test-repo"
	testRepo := common.TestRepo{
		Name:   repo,
		Target: "folder",
		Copies: map[string]string{
			"../testdata/all-panels.json":    "dashboard1.json",
			"../testdata/text-options.json":  "dashboard2.json",
			"../testdata/timeline-demo.json": "folder/dashboard3.json",
		},
		ExpectedDashboards: 3,
		ExpectedFolders:    2, // folder sync creates a folder for the repo + one nested folder
	}
	helper.CreateRepo(t, testRepo)

	t.Run("move single file", func(t *testing.T) {
		helper.DebugState(t, repo, "BEFORE MOVE SINGLE FILE")

		spec := provisioning.JobSpec{
			Action: provisioning.JobActionMove,
			Move: &provisioning.MoveJobOptions{
				Paths:      []string{"dashboard1.json"},
				TargetPath: "moved/",
			},
		}
		helper.TriggerJobAndWaitForSuccess(t, repo, spec)

		helper.DebugState(t, repo, "AFTER MOVE SINGLE FILE")
		// TODO: This additional sync should not be necessary - the move job should handle sync properly
		helper.SyncAndWait(t, repo, nil)

		// FIXME: use the helpers for assertions
		// Verify file is moved in repository
		_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "dashboard1.json")
		require.NoError(t, err, "file should exist at new location in repository")

		// Verify original file is gone from repository
		_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard1.json")
		require.Error(t, err, "original file should be gone from repository")
		require.True(t, apierrors.IsNotFound(err), "should be not found error")

		// Verify other files still exist at original locations
		_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard2.json")
		require.NoError(t, err, "other files should still exist")
		_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard3.json")
		require.NoError(t, err, "nested files should still exist")

		// Verify dashboard still exists in Grafana after sync
		dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
		require.NoError(t, err)
		require.Len(t, dashboards.Items, 3, "should still have 3 dashboards after move")
		// Verify that dashboards have the correct source paths
		foundPaths := make(map[string]bool)
		for _, dashboard := range dashboards.Items {
			sourcePath := dashboard.GetAnnotations()["grafana.app/sourcePath"]
			foundPaths[sourcePath] = true
		}

		require.True(t, foundPaths["moved/dashboard1.json"], "should have dashboard with moved source path")
		require.True(t, foundPaths["dashboard2.json"], "should have dashboard2 in original location")
		require.True(t, foundPaths["folder/dashboard3.json"], "should have dashboard3 in original nested location")
	})

	t.Run("move multiple files and folder", func(t *testing.T) {
		helper.DebugState(t, repo, "BEFORE MOVE MULTIPLE FILES")

		// Create move job for multiple files including a folder
		spec := provisioning.JobSpec{
			Action: provisioning.JobActionMove,
			Move: &provisioning.MoveJobOptions{
				Paths:      []string{"dashboard2.json", "folder/"},
				TargetPath: "archived/",
			},
		}
		helper.TriggerJobAndWaitForSuccess(t, repo, spec)

		helper.DebugState(t, repo, "AFTER MOVE MULTIPLE FILES")
		// TODO: This additional sync should not be necessary - the move job should handle sync properly
		helper.SyncAndWait(t, repo, nil)

		// Verify files are moved in repository
		_, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "archived", "dashboard2.json")
		require.NoError(t, err, "dashboard2.json should exist at new location")
		_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "archived", "folder", "dashboard3.json")
		require.NoError(t, err, "folder/dashboard3.json should exist at new nested location")

		// Verify original files are gone from repository
		_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard2.json")
		require.Error(t, err, "dashboard2.json should be gone from original location")
		require.True(t, apierrors.IsNotFound(err))

		_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard3.json")
		require.Error(t, err, "folder should be gone from original location")
		require.True(t, apierrors.IsNotFound(err), err.Error())

		// Verify dashboards still exist in Grafana after sync
		// Note: Since dashboard1.json was moved in the previous test, we now expect all 3 dashboards
		// to be accessible from their moved locations (dashboard1 from moved/, dashboard2 and dashboard3 from archived/)
		dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
		require.NoError(t, err)
		require.Len(t, dashboards.Items, 3, "should still have 3 dashboards after move")

		// Verify that dashboards have the correct source paths after cumulative moves
		foundPaths := make(map[string]bool)
		for _, dashboard := range dashboards.Items {
			sourcePath := dashboard.GetAnnotations()["grafana.app/sourcePath"]
			foundPaths[sourcePath] = true
		}

		require.True(t, foundPaths["moved/dashboard1.json"], "should have dashboard1 from first move")
		require.True(t, foundPaths["archived/dashboard2.json"], "should have dashboard2 in archived location")
		require.True(t, foundPaths["archived/folder/dashboard3.json"], "should have dashboard3 in archived nested location")
	})

	t.Run("move non-existent file is rejected", func(t *testing.T) {
		body := common.AsJSON(provisioning.JobSpec{
			Action: provisioning.JobActionMove,
			Move: &provisioning.MoveJobOptions{
				Paths:      []string{"non-existent.json"},
				TargetPath: "moved/",
			},
		})

		result := helper.AdminREST.Post().
			Namespace("default").
			Resource("repositories").
			Name(repo).
			SubResource("jobs").
			Body(body).
			SetHeader("Content-Type", "application/json").
			Do(ctx)

		require.Error(t, result.Error(), "move job for non-existent file should be rejected at creation")
	})

	t.Run("move non-existent uid", func(t *testing.T) {
		spec := provisioning.JobSpec{
			Action: provisioning.JobActionMove,
			Move: &provisioning.MoveJobOptions{
				TargetPath: "moved-nonexistent/",
				Resources: []provisioning.ResourceRef{
					{
						Name:  "non-existent-move-uid",
						Kind:  "Dashboard",
						Group: "dashboard.grafana.app",
					},
				},
			},
		}

		job := helper.TriggerJobAndWaitForComplete(t, repo, spec)
		state := common.MustNestedString(job.Object, "status", "state")
		require.Equal(t, "error", state, "move job should have failed due to non-existent uid")
	})

	t.Run("move without target path", func(t *testing.T) {
		// Create move job without target path (should fail validation at creation time)
		spec := provisioning.JobSpec{
			Action: provisioning.JobActionMove,
			Move: &provisioning.MoveJobOptions{
				Paths: []string{"moved/dashboard1.json"},
				// TargetPath intentionally omitted
			},
		}

		// The job should be rejected by the admission controller with validation error
		body := common.AsJSON(&spec)
		result := helper.AdminREST.Post().
			Namespace("default").
			Resource("repositories").
			Name(repo).
			SubResource("jobs").
			Body(body).
			SetHeader("Content-Type", "application/json").
			Do(ctx)

		require.Error(t, result.Error(), "move job without target path should fail validation")
		statusError := helper.RequireApiErrorStatus(result.Error(), metav1.StatusReasonInvalid, 422)
		require.Contains(t, statusError.Message, "spec.move.targetPath", "error should mention missing target path")
	})

	t.Run("move by resource reference", func(t *testing.T) {
		// Delete the existing repository to avoid conflicts with validation rules
		err := helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{})
		require.NoError(t, err)

		// Wait for repository to be fully deleted
		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*5, time.Millisecond*50, "repository should be deleted before creating new one")

		// Create modified test files with unique UIDs for ResourceRef testing
		allPanelsContent := helper.LoadFile("../testdata/all-panels.json")
		textOptionsContent := helper.LoadFile("../testdata/text-options.json")
		timelineDemoContent := helper.LoadFile("../testdata/timeline-demo.json")

		// Modify UIDs to be unique for ResourceRef tests
		allPanelsModified := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "moveref1"`, 1)
		textOptionsModified := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "moveref2"`, 1)
		timelineDemoModified := strings.Replace(string(timelineDemoContent), `"uid": "mIJjFy8Kz"`, `"uid": "moveref3"`, 1)

		// Create temporary files and copy them to the provisioning path
		tmpDir := t.TempDir()
		tmpFile1 := filepath.Join(tmpDir, "move-ref-test-1.json")
		tmpFile2 := filepath.Join(tmpDir, "move-ref-test-2.json")
		tmpFile3 := filepath.Join(tmpDir, "move-ref-test-3.json")

		require.NoError(t, os.WriteFile(tmpFile1, []byte(allPanelsModified), 0644))
		require.NoError(t, os.WriteFile(tmpFile2, []byte(textOptionsModified), 0644))
		require.NoError(t, os.WriteFile(tmpFile3, []byte(timelineDemoModified), 0644))

		// Copy files to provisioning path to set up test - use refRepo's path
		helper.CopyToProvisioningPath(t, tmpFile1, "move-source-1.json")
		helper.CopyToProvisioningPath(t, tmpFile2, "move-source-2.json")
		helper.CopyToProvisioningPath(t, tmpFile3, "move-source-3.json")

		// Create a unique repository for resource reference testing to avoid contamination
		const refRepo = "move-ref-test-repo"
		helper.CreateRepo(t, common.TestRepo{
			Name:                   refRepo,
			Target:                 "folder",
			SkipResourceAssertions: true, // HACK: I am not sure why sometimes it's 6 or 3 dashbaords.
		})

		t.Run("move single dashboard by resource reference", func(t *testing.T) {
			spec := provisioning.JobSpec{
				Action: provisioning.JobActionMove,
				Move: &provisioning.MoveJobOptions{
					TargetPath: "moved-by-ref/",
					Resources: []provisioning.ResourceRef{
						{
							Name:  "moveref1", // UID from modified all-panels.json
							Kind:  "Dashboard",
							Group: "dashboard.grafana.app",
						},
					},
				},
			}

			helper.TriggerJobAndWaitForSuccess(t, refRepo, spec)
			// Verify corresponding file is moved in repository
			_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "moved-by-ref", "move-source-1.json")
			require.NoError(t, err, "file should be moved to new location in repository")

			// Verify original file is deleted from repository
			_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-1.json")
			require.Error(t, err, "original file should be deleted from repository")
			require.True(t, apierrors.IsNotFound(err), "should be not found error")

			// Verify dashboard still exists in Grafana (should be updated after sync)
			helper.SyncAndWait(t, refRepo, nil)
			_, err = helper.DashboardsV1.Resource.Get(ctx, "moveref1", metav1.GetOptions{})
			require.NoError(t, err, "dashboard should still exist in Grafana after move")

			// Verify other resource still exists in original location
			_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-2.json")
			require.NoError(t, err, "other files should still exist in original location")
		})

		t.Run("move multiple resources by reference", func(t *testing.T) {
			spec := provisioning.JobSpec{
				Action: provisioning.JobActionMove,
				Move: &provisioning.MoveJobOptions{
					TargetPath: "archived-by-ref/",
					Resources: []provisioning.ResourceRef{
						{
							Name:  "moveref2", // UID from modified text-options.json
							Kind:  "Dashboard",
							Group: "dashboard.grafana.app",
						},
					},
				},
			}
			helper.TriggerJobAndWaitForSuccess(t, refRepo, spec)

			// Verify file is moved in repository
			_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "archived-by-ref", "move-source-2.json")
			require.NoError(t, err, "file should be moved to new location")

			// Verify original file is deleted from repository
			_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-2.json")
			require.Error(t, err, "original file should be deleted from repository")
			require.True(t, apierrors.IsNotFound(err), "should be not found error")

			// Verify dashboard still exists in Grafana after sync
			helper.SyncAndWait(t, refRepo, nil)
			_, err = helper.DashboardsV1.Resource.Get(ctx, "moveref2", metav1.GetOptions{})
			require.NoError(t, err, "dashboard should still exist in Grafana after move")
		})

		t.Run("move resource from nested folder to root path", func(t *testing.T) {
			// Reproduces https://github.com/grafana/git-ui-sync-project/issues/919
			// Moving a resource from an inner folder to the base folder ("/") should
			// relocate the file to root, not delete it.
			rootMoveContent := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "movetoroot1"`, 1)
			tmpRootMove := filepath.Join(tmpDir, "move-to-root-test.json")
			require.NoError(t, os.WriteFile(tmpRootMove, []byte(rootMoveContent), 0644))
			helper.CopyToProvisioningPath(t, tmpRootMove, "inner-folder/move-to-root.json")

			helper.SyncAndWait(t, refRepo, nil)

			// Verify dashboard exists before the move
			_, err = helper.DashboardsV1.Resource.Get(ctx, "movetoroot1", metav1.GetOptions{})
			require.NoError(t, err, "dashboard should exist before move")
			_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "inner-folder", "move-to-root.json")
			require.NoError(t, err, "file should exist in nested folder before move")

			spec := provisioning.JobSpec{
				Action: provisioning.JobActionMove,
				Move: &provisioning.MoveJobOptions{
					TargetPath: "/",
					Resources: []provisioning.ResourceRef{
						{
							Name:  "movetoroot1",
							Kind:  "Dashboard",
							Group: "dashboard.grafana.app",
						},
					},
				},
			}

			helper.TriggerJobAndWaitForSuccess(t, refRepo, spec)
			helper.SyncAndWait(t, refRepo, nil)

			// The dashboard must NOT be deleted — it should still exist in Grafana
			_, err = helper.DashboardsV1.Resource.Get(ctx, "movetoroot1", metav1.GetOptions{})
			require.NoError(t, err, "dashboard should NOT be deleted when moving to root path '/'")

			// The file should now live at the repository root
			_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-to-root.json")
			require.NoError(t, err, "file should exist at root level after move to '/'")

			// The file should be gone from the nested folder
			_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "inner-folder", "move-to-root.json")
			require.Error(t, err, "file should be gone from nested folder after move")
			require.True(t, apierrors.IsNotFound(err), "should be not found error")
		})

		t.Run("mixed move - paths and resources", func(t *testing.T) {
			// Setup fresh resources for mixed test
			tmpMixed1 := filepath.Join(tmpDir, "mixed-move-1.json")
			tmpMixed2 := filepath.Join(tmpDir, "mixed-move-2.json")

			allPanelsMixed := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "mixedmove1"`, 1)
			textOptionsMixed := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "mixedmove2"`, 1)

			require.NoError(t, os.WriteFile(tmpMixed1, []byte(allPanelsMixed), 0644))
			require.NoError(t, os.WriteFile(tmpMixed2, []byte(textOptionsMixed), 0644))

			helper.CopyToProvisioningPath(t, tmpMixed1, "mixed-move-1.json") // UID: mixedmove1
			helper.CopyToProvisioningPath(t, tmpMixed2, "mixed-move-2.json") // UID: mixedmove2

			helper.SyncAndWait(t, refRepo, nil)

			// Create move job that combines both paths and resource references
			spec := provisioning.JobSpec{
				Action: provisioning.JobActionMove,
				Move: &provisioning.MoveJobOptions{
					TargetPath: "mixed-target/",
					Paths:      []string{"mixed-move-1.json"}, // Move by path
					Resources: []provisioning.ResourceRef{
						{
							Name:  "mixedmove2", // Move by resource reference
							Kind:  "Dashboard",
							Group: "dashboard.grafana.app",
						},
					},
				},
			}
			helper.TriggerJobAndWaitForSuccess(t, refRepo, spec)

			// Verify both targeted resources are moved in repository
			_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-target", "mixed-move-1.json")
			require.NoError(t, err, "file moved by path should exist at new location")

			_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-target", "mixed-move-2.json")
			require.NoError(t, err, "file moved by resource ref should exist at new location")

			// Verify files are deleted from original locations
			_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-move-1.json")
			require.Error(t, err, "file moved by path should be deleted from original location")

			_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-move-2.json")
			require.Error(t, err, "file moved by resource ref should be deleted from original location")

			// Verify dashboards still exist in Grafana after sync
			helper.SyncAndWait(t, refRepo, nil)
			_, err = helper.DashboardsV1.Resource.Get(ctx, "mixedmove1", metav1.GetOptions{})
			require.NoError(t, err, "dashboard moved by path should still exist in Grafana")

			_, err = helper.DashboardsV1.Resource.Get(ctx, "mixedmove2", metav1.GetOptions{})
			require.NoError(t, err, "dashboard moved by resource ref should still exist in Grafana")
		})
	})
}
