package provisioning

import (
	"context"
	"encoding/json"
	"fmt"
	"io/fs"
	"net/http"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
	"text/template"
	"time"

	"github.com/google/go-github/v82/github"
	ghmock "github.com/migueleliasweb/go-github-mock/src/mock"
	"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/schema"
	"k8s.io/client-go/rest"

	dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
	dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
	dashboardsV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
	dashboardsV2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1"
	folder "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
	provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
	githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
	grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
	"github.com/grafana/grafana/pkg/extensions"
	"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
	"github.com/grafana/grafana/pkg/services/featuremgmt"
	"github.com/grafana/grafana/pkg/setting"
	"github.com/grafana/grafana/pkg/tests/apis"
	"github.com/grafana/grafana/pkg/tests/testinfra"
	"github.com/grafana/grafana/pkg/tests/testsuite"
)

const (
	waitTimeoutDefault  = 30 * time.Second
	waitIntervalDefault = 100 * time.Millisecond
)

func TestMain(m *testing.M) {
	testsuite.Run(m)
}

type provisioningTestHelper struct {
	*apis.K8sTestHelper
	ProvisioningPath string

	Repositories       *apis.K8sResourceClient
	Connections        *apis.K8sResourceClient
	Jobs               *apis.K8sResourceClient
	Folders            *apis.K8sResourceClient
	DashboardsV0       *apis.K8sResourceClient
	DashboardsV1       *apis.K8sResourceClient
	DashboardsV2alpha1 *apis.K8sResourceClient
	DashboardsV2beta1  *apis.K8sResourceClient
	AdminREST          *rest.RESTClient
	EditorREST         *rest.RESTClient
	ViewerREST         *rest.RESTClient
}

func (h *provisioningTestHelper) SyncAndWait(t *testing.T, repo string, options *provisioning.SyncJobOptions) {
	t.Helper()

	if options == nil {
		options = &provisioning.SyncJobOptions{}
	}
	body := asJSON(&provisioning.JobSpec{
		Action: provisioning.JobActionPull,
		Pull:   options,
	})

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

	if apierrors.IsAlreadyExists(result.Error()) {
		// Wait for all jobs to finish as we don't have the name.
		h.AwaitJobs(t, repo)
		return
	}

	obj, err := result.Get()
	require.NoError(t, err, "expecting to be able to sync repository")

	unstruct, ok := obj.(*unstructured.Unstructured)
	require.True(t, ok, "expecting unstructured object, but got %T", obj)

	name := unstruct.GetName()
	require.NotEmpty(t, name, "expecting name to be set")
	h.AwaitJobs(t, repo)
}

func (h *provisioningTestHelper) TriggerJobAndWaitForSuccess(t *testing.T, repo string, spec provisioning.JobSpec) {
	t.Helper()

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

	if apierrors.IsAlreadyExists(result.Error()) {
		// Wait for all jobs to finish as we don't have the name.
		h.AwaitJobs(t, repo)
		return
	}

	obj, err := result.Get()
	require.NoError(t, err, "expecting to be able to sync repository")

	unstruct, ok := obj.(*unstructured.Unstructured)
	require.True(t, ok, "expecting unstructured object, but got %T", obj)

	name := unstruct.GetName()
	require.NotEmpty(t, name, "expecting name to be set")
	h.AwaitJobSuccess(t, t.Context(), unstruct)
}

func (h *provisioningTestHelper) TriggerJobAndWaitForComplete(t *testing.T, repo string, spec provisioning.JobSpec) *unstructured.Unstructured {
	t.Helper()

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

	if apierrors.IsAlreadyExists(result.Error()) {
		// A job is already in-flight. Wait and return the latest historic job.
		t.Logf("job already running for repo %q; waiting for it to complete", repo)
		return h.AwaitLatestHistoricJob(t, repo)
	}

	obj, err := result.Get()
	require.NoError(t, err, "expecting to be able to sync repository")

	unstruct, ok := obj.(*unstructured.Unstructured)
	require.True(t, ok, "expecting unstructured object, but got %T", obj)

	name := unstruct.GetName()
	require.NotEmpty(t, name, "expecting name to be set")

	return h.AwaitJob(t, t.Context(), unstruct)
}

// AwaitLatestHistoricJob waits for the repo's queue to empty and returns the most recent historic job.
func (h *provisioningTestHelper) AwaitLatestHistoricJob(t *testing.T, repo string) *unstructured.Unstructured {
	t.Helper()
	// Wait until no active jobs for this repo
	require.EventuallyWithT(t, func(collect *assert.CollectT) {
		list, err := h.Jobs.Resource.List(context.Background(), metav1.ListOptions{})
		if !assert.NoError(collect, err, "failed to list active jobs") {
			return
		}
		for _, elem := range list.Items {
			r, _, err := unstructured.NestedString(elem.Object, "spec", "repository")
			if !assert.NoError(collect, err) {
				return
			}
			if r == repo {
				collect.Errorf("still have active job %q for repo %q", elem.GetName(), repo)
				return
			}
		}
	}, waitTimeoutDefault, waitIntervalDefault, "job queue must be empty before reading historic jobs")

	// Fetch historic jobs and pick the newest by creationTimestamp
	result, err := h.Repositories.Resource.Get(context.Background(), repo, metav1.GetOptions{}, "jobs")
	require.NoError(t, err, "failed to list historic jobs")
	list, err := result.ToList()
	require.NoError(t, err, "results should be a list")
	require.NotEmpty(t, list.Items, "expect at least one historic job")

	latest := list.Items[0]
	for i := 1; i < len(list.Items); i++ {
		if list.Items[i].GetCreationTimestamp().After(latest.GetCreationTimestamp().Time) {
			latest = list.Items[i]
		}
	}
	return latest.DeepCopy()
}

func (h *provisioningTestHelper) AwaitJobSuccess(t *testing.T, ctx context.Context, job *unstructured.Unstructured) {
	t.Helper()
	job = h.AwaitJob(t, ctx, job)
	lastErrors := mustNestedStringSlice(job.Object, "status", "errors")
	lastState := mustNestedString(job.Object, "status", "state")

	repo := job.GetLabels()[jobs.LabelRepository]

	// Debug state if job failed
	if len(lastErrors) > 0 || lastState != string(provisioning.JobStateSuccess) {
		h.DebugState(t, repo, fmt.Sprintf("JOB FAILED: %s", job.GetName()))
	}

	require.Empty(t, lastErrors, "historic job '%s' has errors: %v", job.GetName(), lastErrors)
	require.Equal(t, string(provisioning.JobStateSuccess), lastState,
		"historic job '%s' was not successful", job.GetName())
}

func (h *provisioningTestHelper) AwaitJob(t *testing.T, ctx context.Context, job *unstructured.Unstructured) *unstructured.Unstructured {
	t.Helper()

	repo := job.GetLabels()[jobs.LabelRepository]
	require.NotEmpty(t, repo)

	var lastResult *unstructured.Unstructured
	require.EventuallyWithT(t, func(collect *assert.CollectT) {
		result, err := h.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{},
			"jobs", string(job.GetUID()))

		if !assert.False(collect, apierrors.IsNotFound(err)) {
			collect.Errorf("job '%s' not found, still waiting for it to complete", job.GetName())
			return
		}

		assert.NoError(collect, err, "failed to get job '%s' to be found", job.GetName())
		if err != nil {
			return
		}

		lastResult = result
	}, waitTimeoutDefault, waitIntervalDefault)
	require.NotNil(t, lastResult, "expected job result to be non-nil")

	return lastResult
}

func (h *provisioningTestHelper) AwaitJobs(t *testing.T, repoName string) {
	t.Helper()

	// First, we wait for all current jobs for the repository to disappear (i.e. complete/fail).
	j, err := h.Jobs.Resource.List(context.Background(), metav1.ListOptions{})
	require.NoError(t, err, "failed to list active jobs")

	waitUntilComplete := map[string]bool{}
	for _, item := range j.Items {
		annotations := item.GetLabels()
		if annotations[jobs.LabelRepository] == repoName {
			waitUntilComplete[item.GetName()] = false
		}
	}

	// if no active jobs for this repo, queue a pull job as a failsafe to try to ensure we are up to date as much as possible
	if len(waitUntilComplete) == 0 {
		body := asJSON(&provisioning.JobSpec{
			Action: provisioning.JobActionPull,
			Pull:   &provisioning.SyncJobOptions{},
		})

		h.AdminREST.Post().
			Namespace("default").
			Resource("repositories").
			Name(repoName).
			SubResource("jobs").
			Body(body).
			SetHeader("Content-Type", "application/json").
			Do(t.Context())

		j, err = h.Jobs.Resource.List(context.Background(), metav1.ListOptions{})
		require.NoError(t, err, "failed to list active jobs")

		for _, item := range j.Items {
			annotations := item.GetLabels()
			if annotations[jobs.LabelRepository] == repoName {
				waitUntilComplete[item.GetName()] = false
			}
		}
	}

	require.EventuallyWithT(t, func(collect *assert.CollectT) {
		for elem := range waitUntilComplete {
			_, err := h.Jobs.Resource.Get(context.Background(), elem, metav1.GetOptions{})
			switch {
			case err == nil:
				collect.Errorf("job(%s) for repo %s still exists", elem, repoName)
				return
			case apierrors.IsNotFound(err):
				// yay
				waitUntilComplete[elem] = true
			default:
				collect.Errorf("get(%s) for repo %s: %v", elem, repoName, err)
				return
			}
		}
		for elem, isComplete := range waitUntilComplete {
			if !isComplete {
				collect.Errorf("job(%s) for repo %s still exists", elem, repoName)
				return
			}
		}
	}, waitTimeoutDefault, waitIntervalDefault, "jobs for %s should finish. status: %v", repoName, waitUntilComplete)

	// Then wait for them to be listed as historic jobs
	var list *unstructured.UnstructuredList
	require.EventuallyWithT(t, func(collect *assert.CollectT) {
		result, err := h.Repositories.Resource.Get(context.Background(), repoName, metav1.GetOptions{}, "jobs")
		if !assert.NoError(collect, err, "failed to list historic jobs") {
			return
		}
		list, err = result.ToList()
		if !assert.NoError(collect, err, "results should be a list") {
			return
		}
		if !assert.NotEmpty(collect, list.Items, "expect at least one job") {
			return
		}
	}, waitTimeoutDefault, waitIntervalDefault, "failed to list historic jobs")

	// finally check that all the jobs are successful
	successCount := 0
	for _, elem := range list.Items {
		require.Equal(t, repoName, elem.GetLabels()[jobs.LabelRepository], "should have repo label")

		// historic jobs will have a suffix of -<hash>, trim that to see if the job is one we were waiting on
		if _, ok := waitUntilComplete[getNameBeforeLastDash(elem.GetName())]; ok && (mustNestedString(elem.Object, "status", "state") != string(provisioning.JobStateError)) {
			successCount++
		}
	}
	// can be greater if a pull job was queued by a background task
	require.GreaterOrEqual(t, successCount, len(waitUntilComplete), "should have all original jobs we were waiting on successful. got: %v. expected: %v", list.Items, waitUntilComplete)
}

func getNameBeforeLastDash(name string) string {
	lastDashIndex := strings.LastIndex(name, "-")
	if lastDashIndex == -1 {
		return name
	}
	return name[:lastDashIndex]
}

// RenderObject reads the filePath and renders it as a template with the given values.
// The template is expected to be a YAML or JSON file.
//
// The values object is mutated to also include the helper property as `h`.
func (h *provisioningTestHelper) RenderObject(t *testing.T, filePath string, values map[string]any) *unstructured.Unstructured {
	t.Helper()
	file := h.LoadFile(filePath)

	if values == nil {
		values = make(map[string]any)
	}
	values["h"] = h

	tmpl, err := template.New(filePath).Parse(string(file))
	require.NoError(t, err, "failed to parse template")

	var buf strings.Builder
	err = tmpl.Execute(&buf, values)
	require.NoError(t, err, "failed to execute template")

	return h.LoadYAMLOrJSON(buf.String())
}

// CopyToProvisioningPath copies a file to the provisioning path.
// The from path is relative to test file's directory.
func (h *provisioningTestHelper) CopyToProvisioningPath(t *testing.T, from, to string) {
	fullPath := path.Join(h.ProvisioningPath, to)
	t.Logf("Copying file from '%s' to provisioning path '%s'", from, fullPath)
	err := os.MkdirAll(path.Dir(fullPath), 0o750)
	require.NoError(t, err, "failed to create directories for provisioning path")

	file := h.LoadFile(from)
	err = os.WriteFile(fullPath, file, 0o600)
	require.NoError(t, err, "failed to write file to provisioning path")
}

// DebugState logs the current state of filesystem, repository, and Grafana resources for debugging
func (h *provisioningTestHelper) DebugState(t *testing.T, repo string, label string) {
	t.Helper()
	t.Logf("=== DEBUG STATE: %s ===", label)

	ctx := context.Background()

	// Log filesystem contents using existing tree function
	printFileTree(t, h.ProvisioningPath)

	// Log all repositories first
	t.Logf("All repositories:")
	repos, err := h.Repositories.Resource.List(ctx, metav1.ListOptions{})
	if err != nil {
		t.Logf("  ERROR listing repositories: %v", err)
	} else {
		t.Logf("  Total repositories: %d", len(repos.Items))
		for i, repository := range repos.Items {
			t.Logf("  Repository %d: name=%s", i+1, repository.GetName())
		}
	}

	// Log repository files for the specific repo
	t.Logf("Repository '%s' files:", repo)
	h.logRepositoryFiles(t, ctx, repo, "  ")

	// Log files for all other repositories too
	if repos != nil && len(repos.Items) > 1 {
		t.Logf("Files in other repositories:")
		for _, repository := range repos.Items {
			if repository.GetName() != repo {
				t.Logf("  Repository '%s' files:", repository.GetName())
				h.logRepositoryFiles(t, ctx, repository.GetName(), "    ")
			}
		}
	}

	// Log Grafana dashboards
	t.Logf("Grafana dashboards:")
	dashboards, err := h.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
	if err != nil {
		t.Logf("  ERROR listing dashboards: %v", err)
	} else {
		t.Logf("  Total dashboards: %d", len(dashboards.Items))
		for i, dashboard := range dashboards.Items {
			t.Logf("  Dashboard %d: name=%s, UID=%s", i+1, dashboard.GetName(), dashboard.GetUID())
		}
	}

	// Log Grafana folders
	t.Logf("Grafana folders:")
	folders, err := h.Folders.Resource.List(ctx, metav1.ListOptions{})
	if err != nil {
		t.Logf("  ERROR listing folders: %v", err)
	} else {
		t.Logf("  Total folders: %d", len(folders.Items))
		for i, folder := range folders.Items {
			t.Logf("  Folder %d: name=%s", i+1, folder.GetName())
		}
	}

	t.Logf("=== END DEBUG STATE ===")
}

// logRepositoryFiles logs repository file structure using the files API
func (h *provisioningTestHelper) logRepositoryFiles(t *testing.T, ctx context.Context, repoName string, prefix string) {
	t.Helper()

	// Try to list files at root level
	files, err := h.Repositories.Resource.Get(ctx, repoName, metav1.GetOptions{}, "files")
	if err != nil {
		t.Logf("%sERROR getting repository files: %v", prefix, err)
		return
	}

	// The API returns a structured response, we need to extract the actual file data
	if files.Object != nil {
		h.logRepositoryObject(t, files.Object, prefix, "")
	} else {
		t.Logf("%s(empty repository)", prefix)
	}
}

// logRepositoryObject recursively logs repository file structure from API response
func (h *provisioningTestHelper) logRepositoryObject(t *testing.T, obj map[string]interface{}, prefix string, path string) {
	t.Helper()

	if obj == nil {
		return
	}

	// Skip metadata fields and focus on actual content
	for key, value := range obj {
		// Skip Kubernetes metadata fields
		if key == "kind" || key == "apiVersion" || key == "metadata" {
			continue
		}

		// Calculate new path for nested objects
		var newPath string
		if path != "" {
			newPath = path + "/" + key
		} else {
			newPath = key
		}

		switch v := value.(type) {
		case map[string]interface{}:
			t.Logf("%s├── %s/", prefix, key)
			h.logRepositoryObject(t, v, prefix+"  ", newPath)
		case []interface{}:
			// Handle lists (like items array)
			if key == "items" && len(v) > 0 {
				t.Logf("%s%d items:", prefix, len(v))
				for i, item := range v {
					if itemMap, ok := item.(map[string]interface{}); ok {
						// Try to get the actual file path from the item
						if pathVal, exists := itemMap["path"]; exists {
							t.Logf("%s├── %v", prefix, pathVal)
						} else {
							t.Logf("%s├── item %d:", prefix, i+1)
						}
						h.logRepositoryObject(t, itemMap, prefix+"  ", newPath)
					}
				}
			}
		default:
			// This could be file content or metadata
			// Skip common metadata fields that are not useful for debugging
			if key != "kind" && key != "apiVersion" && key != "path" && key != "size" && key != "hash" {
				t.Logf("%s├── %s: %v", prefix, key, value)
			}
		}
	}
}

// validateManagedDashboardsFolderMetadata validates the folder metadata
// of the managed dashboards.
// If folder is nested, folder annotations should not be empty.
// Also checks that the managerId property exists.
func (h *provisioningTestHelper) validateManagedDashboardsFolderMetadata(t *testing.T,
	ctx context.Context, repoName string, dashboards []unstructured.Unstructured) {
	t.Helper()

	// Check if folder is nested or not.
	// If not, folder annotations should be empty as we have an "instance" sync target
	for _, d := range dashboards {
		sourcePath, _, _ := unstructured.NestedString(d.Object, "metadata", "annotations", "grafana.app/sourcePath")
		isNested := strings.Contains(sourcePath, "/")

		folder, found, _ := unstructured.NestedString(d.Object, "metadata", "annotations", "grafana.app/folder")
		if isNested {
			require.True(t, found, "dashboard should have a folder annotation")
			require.NotEmpty(t, folder, "dashboard should be in a non-empty folder")
		} else {
			require.False(t, found, "dashboard should not have a folder annotation")
		}

		managerID, _, _ := unstructured.NestedString(d.Object, "metadata", "annotations", "grafana.app/managerId")
		// require.Equal(t, repoName, managerID, "dashboard should be managed by gitsync repo")
		require.Equal(t, repoName, managerID, "dashboard should be managed by gitsync repo")
	}
}

type TestRepo struct {
	Name                   string
	Target                 string
	Path                   string
	Values                 map[string]any
	Copies                 map[string]string
	ExpectedDashboards     int
	ExpectedFolders        int
	SkipSync               bool
	SkipResourceAssertions bool
	Template               string
}

func (h *provisioningTestHelper) CreateRepo(t *testing.T, repo TestRepo) {
	if repo.Target == "" {
		repo.Target = "instance"
	}

	// Use custom path if provided, otherwise use default provisioning path
	repoPath := h.ProvisioningPath
	if repo.Path != "" {
		repoPath = repo.Path
		// Ensure the directory exists
		err := os.MkdirAll(repoPath, 0o750)
		require.NoError(t, err, "should be able to create repository path")
	}

	templateVars := map[string]any{
		"Name":        repo.Name,
		"SyncEnabled": !repo.SkipSync,
		"SyncTarget":  repo.Target,
	}
	if repo.Path != "" {
		templateVars["Path"] = repoPath
	}
	// Add custom values from TestRepo
	for key, value := range repo.Values {
		templateVars[key] = value
	}

	tmpl := "testdata/local-write.json.tmpl"
	if repo.Template != "" {
		tmpl = repo.Template
	}
	localTmp := h.RenderObject(t, tmpl, templateVars)

	_, err := h.Repositories.Resource.Create(t.Context(), localTmp, metav1.CreateOptions{})
	require.NoError(t, err)
	h.WaitForHealthyRepository(t, repo.Name)

	for from, to := range repo.Copies {
		if repo.Path != "" {
			// Copy to custom path
			fullPath := path.Join(repoPath, to)
			err := os.MkdirAll(path.Dir(fullPath), 0o750)
			require.NoError(t, err, "failed to create directories for custom path")
			file := h.LoadFile(from)
			err = os.WriteFile(fullPath, file, 0o600)
			require.NoError(t, err, "failed to write file to custom path")
		} else {
			h.CopyToProvisioningPath(t, from, to)
		}
	}

	if !repo.SkipSync {
		// Trigger and wait for initial sync to populate resources
		h.SyncAndWait(t, repo.Name, nil)
		h.DebugState(t, repo.Name, "AFTER INITIAL SYNC")
	} else {
		h.DebugState(t, repo.Name, "AFTER REPO CREATION")
	}

	// Verify initial state
	if !repo.SkipResourceAssertions {
		require.EventuallyWithT(t, func(collect *assert.CollectT) {
			dashboards, err := h.DashboardsV1.Resource.List(t.Context(), metav1.ListOptions{})
			if err != nil {
				collect.Errorf("could not list dashboards error: %s", err.Error())
				return
			}
			if len(dashboards.Items) != repo.ExpectedDashboards {
				collect.Errorf("should have the expected dashboards after sync. got: %d. expected: %d", len(dashboards.Items), repo.ExpectedDashboards)
				return
			}
			folders, err := h.Folders.Resource.List(t.Context(), metav1.ListOptions{})
			if err != nil {
				collect.Errorf("could not list folders: error: %s", err.Error())
				return
			}
			if len(folders.Items) != repo.ExpectedFolders {
				collect.Errorf("should have the expected folders after sync. got: %d. expected: %d", len(folders.Items), repo.ExpectedFolders)
				return
			}
			assert.Len(collect, dashboards.Items, repo.ExpectedDashboards)
			assert.Len(collect, folders.Items, repo.ExpectedFolders)
		}, waitTimeoutDefault, waitIntervalDefault, "should have the expected dashboards and folders after sync")
	}
}

// WaitForHealthyRepository waits for a repository to become healthy.
func (h *provisioningTestHelper) WaitForHealthyRepository(t *testing.T, name string) {
	require.EventuallyWithT(t, func(collect *assert.CollectT) {
		repoStatus, err := h.Repositories.Resource.Get(t.Context(), name, metav1.GetOptions{})
		if !assert.NoError(collect, err, "failed to get repository status") {
			return
		}
		errType := mustNestedString(repoStatus.Object, "status", "health", "error")
		assert.Empty(collect, errType, "repository %s has health error: %s", name, errType)
		msgs := mustNestedStringSlice(repoStatus.Object, "status", "health", "message")
		assert.Empty(collect, msgs, "repository %s has health messages: %v", name, msgs)
		status, found := mustNestedBool(repoStatus.Object, "status", "health", "healthy")
		assert.True(collect, found, "repository %s does not have health status", name)
		assert.True(collect, status, "repository %s is not healthy yet", name)
	}, waitTimeoutDefault, waitIntervalDefault, "repository %s should become healthy", name)
}

type grafanaOption func(opts *testinfra.GrafanaOpts)

// Useful for debugging a test in development.
//
//lint:ignore U1000 This is used when needed while debugging.
//nolint:golint,unused
func withLogs(opts *testinfra.GrafanaOpts) {
	opts.EnableLog = true
}

func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper {
	provisioningPath := t.TempDir()
	opts := testinfra.GrafanaOpts{
		EnableFeatureToggles: []string{
			featuremgmt.FlagProvisioning,
		},
		// Provisioning requires resources to be fully migrated to unified storage.
		// Mode5 ensures reads/writes go to unified storage, and EnableMigration
		// enables the data migration at startup to migrate legacy data.
		UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
			"dashboards.dashboard.grafana.app": {
				DualWriterMode:  grafanarest.Mode5,
				EnableMigration: true,
			},
			"folders.folder.grafana.app": {
				DualWriterMode:  grafanarest.Mode5,
				EnableMigration: true,
			},
		},
		PermittedProvisioningPaths: ".|" + provisioningPath,
		// Allow both folder and instance sync targets for tests
		// (instance is needed for export jobs, folder for most operations)
		ProvisioningAllowedTargets: []string{"folder", "instance"},
	}

	if extensions.IsEnterprise {
		opts.ProvisioningRepositoryTypes = []string{"local", "github", "gitlab", "bitbucket"}
	}

	for _, o := range options {
		o(&opts)
	}
	helper := apis.NewK8sTestHelper(t, opts)

	// FIXME: keeping these lines here to keep the dependency around until we have tests which use this again.
	helper.GetEnv().GithubRepoFactory.Client = ghmock.NewMockedHTTPClient()

	repositories := helper.GetResourceClient(apis.ResourceClientArgs{
		User:      helper.Org1.Admin,
		Namespace: "default", // actually org1
		GVR:       provisioning.RepositoryResourceInfo.GroupVersionResource(),
	})
	connections := helper.GetResourceClient(apis.ResourceClientArgs{
		User:      helper.Org1.Admin,
		Namespace: "default", // actually org1
		GVR:       provisioning.ConnectionResourceInfo.GroupVersionResource(),
	})
	jobs := helper.GetResourceClient(apis.ResourceClientArgs{
		User:      helper.Org1.Admin,
		Namespace: "default", // actually org1
		GVR:       provisioning.JobResourceInfo.GroupVersionResource(),
	})
	folders := helper.GetResourceClient(apis.ResourceClientArgs{
		User:      helper.Org1.Admin,
		Namespace: "default", // actually org1
		GVR:       folder.FolderResourceInfo.GroupVersionResource(),
	})
	dashboardsV0 := helper.GetResourceClient(apis.ResourceClientArgs{
		User:      helper.Org1.Admin,
		Namespace: "default", // actually org1
		GVR:       dashboardV0.DashboardResourceInfo.GroupVersionResource(),
	})
	dashboardsV1 := helper.GetResourceClient(apis.ResourceClientArgs{
		User:      helper.Org1.Admin,
		Namespace: "default", // actually org1
		GVR:       dashboardV1.DashboardResourceInfo.GroupVersionResource(),
	})
	dashboardsV2alpha1 := helper.GetResourceClient(apis.ResourceClientArgs{
		User:      helper.Org1.Admin,
		Namespace: "default", // actually org1
		GVR:       dashboardsV2alpha1.DashboardResourceInfo.GroupVersionResource(),
	})
	dashboardsV2beta1 := helper.GetResourceClient(apis.ResourceClientArgs{
		User:      helper.Org1.Admin,
		Namespace: "default", // actually org1
		GVR:       dashboardsV2beta1.DashboardResourceInfo.GroupVersionResource(),
	})

	// Repo client, but less guard rails. Useful for subresources. We'll need this later...
	gv := &schema.GroupVersion{Group: "provisioning.grafana.app", Version: "v0alpha1"}
	adminClient := helper.Org1.Admin.RESTClient(t, gv)
	editorClient := helper.Org1.Editor.RESTClient(t, gv)
	viewerClient := helper.Org1.Viewer.RESTClient(t, gv)

	deleteAll := func(client *apis.K8sResourceClient) error {
		ctx := context.Background()
		list, err := client.Resource.List(ctx, metav1.ListOptions{})
		if err != nil {
			return err
		}
		for _, resource := range list.Items {
			if err := client.Resource.Delete(ctx, resource.GetName(), metav1.DeleteOptions{}); err != nil {
				return err
			}
		}
		return nil
	}

	require.NoError(t, deleteAll(dashboardsV1), "deleting all dashboards") // v0+v1+v2
	require.NoError(t, deleteAll(folders), "deleting all folders")
	require.NoError(t, deleteAll(repositories), "deleting all repositories")

	return &provisioningTestHelper{
		ProvisioningPath: provisioningPath,
		K8sTestHelper:    helper,

		Repositories:       repositories,
		Connections:        connections,
		AdminREST:          adminClient,
		EditorREST:         editorClient,
		ViewerREST:         viewerClient,
		Jobs:               jobs,
		Folders:            folders,
		DashboardsV0:       dashboardsV0,
		DashboardsV1:       dashboardsV1,
		DashboardsV2alpha1: dashboardsV2alpha1,
		DashboardsV2beta1:  dashboardsV2beta1,
	}
}

func mustNestedString(obj map[string]interface{}, fields ...string) string {
	v, _, err := unstructured.NestedString(obj, fields...)
	if err != nil {
		panic(err)
	}
	return v
}

func mustNestedBool(obj map[string]interface{}, fields ...string) (bool, bool) {
	v, found, err := unstructured.NestedBool(obj, fields...)
	if err != nil {
		panic(err)
	}

	return v, found
}

func mustNestedStringSlice(obj map[string]interface{}, fields ...string) []string {
	v, _, err := unstructured.NestedStringSlice(obj, fields...)
	if err != nil {
		panic(err)
	}
	return v
}

func asJSON(obj any) []byte {
	jj, _ := json.Marshal(obj)
	return jj
}

func unstructuredToRepository(t *testing.T, obj *unstructured.Unstructured) *provisioning.Repository {
	bytes, err := obj.MarshalJSON()
	require.NoError(t, err)

	repo := &provisioning.Repository{}
	err = json.Unmarshal(bytes, repo)
	require.NoError(t, err)

	return repo
}

func repositoryToUnstructured(t *testing.T, obj *provisioning.Repository) *unstructured.Unstructured {
	bytes, err := json.Marshal(obj)
	require.NoError(t, err)

	res := &unstructured.Unstructured{}
	err = res.UnmarshalJSON(bytes)
	require.NoError(t, err)

	return res
}

func unstructuredToConnection(t *testing.T, obj *unstructured.Unstructured) *provisioning.Connection {
	bytes, err := obj.MarshalJSON()
	require.NoError(t, err)

	c := &provisioning.Connection{}
	err = json.Unmarshal(bytes, c)
	require.NoError(t, err)

	return c
}

// postFilesRequest performs a direct HTTP POST request to the files API.
// This bypasses Kubernetes REST client limitations with '/' characters in subresource names.
type filesPostOptions struct {
	targetPath   string // The target file/directory path
	originalPath string // Source path for move operations (optional)
	message      string // Commit message (optional)
	body         string // Request body content (optional)
	ref          string // Git ref/branch (optional)
}

func (h *provisioningTestHelper) postFilesRequest(t *testing.T, repo string, opts filesPostOptions) *http.Response {
	addr := h.GetEnv().Server.HTTPServer.Listener.Addr().String()
	baseUrl := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/%s",
		addr, repo, opts.targetPath)

	// Build the URL with proper query parameter encoding
	parsedUrl, err := url.Parse(baseUrl)
	require.NoError(t, err)
	params := parsedUrl.Query()

	if opts.originalPath != "" {
		params.Set("originalPath", opts.originalPath)
	}
	if opts.message != "" {
		params.Set("message", opts.message)
	}
	if opts.ref != "" {
		params.Set("ref", opts.ref)
	}
	parsedUrl.RawQuery = params.Encode()

	req, err := http.NewRequest(http.MethodPost, parsedUrl.String(), strings.NewReader(opts.body))
	require.NoError(t, err)
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)

	return resp
}

// printFileTree prints the directory structure as a tree for debugging purposes
func printFileTree(t *testing.T, rootPath string) {
	t.Helper()
	t.Logf("File tree for %s:", rootPath)

	err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}

		relPath, err := filepath.Rel(rootPath, path)
		if err != nil {
			return err
		}

		if relPath == "." {
			return nil
		}

		depth := strings.Count(relPath, string(filepath.Separator))
		indent := strings.Repeat("  ", depth)

		if d.IsDir() {
			t.Logf("%s├── %s/", indent, d.Name())
		} else {
			info, err := d.Info()
			if err != nil {
				t.Logf("%s├── %s (error reading info)", indent, d.Name())
			} else {
				t.Logf("%s├── %s (%d bytes)", indent, d.Name(), info.Size())
			}
		}

		return nil
	})
	if err != nil {
		t.Logf("Error walking directory: %v", err)
	}
}

// Helper function to count files in a directory recursively
func countFilesInDir(rootPath string) (int, error) {
	count := 0
	err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() {
			count++
		}
		return nil
	})
	return count, err
}

// CleanupAllRepos deletes all repositories and waits for them to be fully removed
func (h *provisioningTestHelper) CleanupAllRepos(t *testing.T) {
	t.Helper()
	ctx := context.Background()

	// First, get all repositories that exist
	list, err := h.Repositories.Resource.List(ctx, metav1.ListOptions{})
	if err != nil || len(list.Items) == 0 {
		return // Nothing to clean up
	}

	// Wait for any active jobs to complete before deleting repositories
	require.EventuallyWithT(t, func(collect *assert.CollectT) {
		activeJobs, err := h.Jobs.Resource.List(ctx, metav1.ListOptions{})
		if !assert.NoError(collect, err, "failed to list active jobs") {
			return
		}
		assert.Equal(collect, 0, len(activeJobs.Items), "all active jobs should complete before cleanup")
	}, waitTimeoutDefault, waitIntervalDefault, "active jobs should complete before cleanup")

	// Now delete all repositories with retries
	require.EventuallyWithT(t, func(collect *assert.CollectT) {
		list, err := h.Repositories.Resource.List(ctx, metav1.ListOptions{})
		if !assert.NoError(collect, err) {
			return
		}

		for _, repo := range list.Items {
			err := h.Repositories.Resource.Delete(ctx, repo.GetName(), metav1.DeleteOptions{})
			// Don't fail if already deleted (404 is OK)
			if err != nil {
				assert.True(collect, apierrors.IsNotFound(err), "Should be able to delete repository %s (or it should already be deleted)", repo.GetName())
			}
		}
	}, waitTimeoutDefault, waitIntervalDefault, "should be able to delete all repositories")

	// Then wait for repositories to be fully deleted to ensure clean state
	require.EventuallyWithT(t, func(collect *assert.CollectT) {
		list, err := h.Repositories.Resource.List(ctx, metav1.ListOptions{})
		if !assert.NoError(collect, err) {
			return
		}
		assert.Equal(collect, 0, len(list.Items), "repositories should be cleaned up")
	}, waitTimeoutDefault, waitIntervalDefault, "repositories should be cleaned up between subtests")
}

func (h *provisioningTestHelper) CreateGithubConnection(
	t *testing.T,
	ctx context.Context,
	connection *unstructured.Unstructured,
) (*unstructured.Unstructured, error) {
	t.Helper()

	err := h.setGithubClient(t, connection)
	if err != nil {
		return nil, err
	}

	return h.Connections.Resource.Create(ctx, connection, metav1.CreateOptions{FieldValidation: "Strict"})
}

func (h *provisioningTestHelper) UpdateGithubConnection(
	t *testing.T,
	ctx context.Context,
	connection *unstructured.Unstructured,
) (*unstructured.Unstructured, error) {
	t.Helper()

	err := h.setGithubClient(t, connection)
	if err != nil {
		return nil, err
	}

	return h.Connections.Resource.Update(ctx, connection, metav1.UpdateOptions{FieldValidation: "Strict"})
}

func (h *provisioningTestHelper) setGithubClient(t *testing.T, connection *unstructured.Unstructured) error {
	t.Helper()

	objectSpec := connection.Object["spec"].(map[string]interface{})
	githubObj := objectSpec["github"].(map[string]interface{})
	appID := githubObj["appID"].(string)
	id, err := strconv.ParseInt(appID, 10, 64)
	if err != nil {
		return err
	}

	appSlug := "someSlug"
	connectionFactory := h.GetEnv().GithubConnectionFactory.(*githubConnection.Factory)
	// Setup mock repositories for the ListRepos endpoint
	expectedRepos := []*github.Repository{
		{
			Name: github.Ptr("test-repo-1"),
			Owner: &github.User{
				Login: github.Ptr("test-owner-1"),
			},
			HTMLURL: github.Ptr("https://github.com/test-owner-1/test-repo-1"),
		},
		{
			Name: github.Ptr("test-repo-2"),
			Owner: &github.User{
				Login: github.Ptr("test-owner-2"),
			},
			HTMLURL: github.Ptr("https://github.com/test-owner-2/test-repo-2"),
		},
		{
			Name: github.Ptr("test-repo-3"),
			Owner: &github.User{
				Login: github.Ptr("test-owner-3"),
			},
			HTMLURL: github.Ptr("https://github.com/test-owner-3/test-repo-3"),
		},
	}

	connectionFactory.Client = ghmock.NewMockedHTTPClient(
		ghmock.WithRequestMatchHandler(
			ghmock.GetApp,
			http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				w.WriteHeader(http.StatusOK)
				app := github.App{
					ID:   &id,
					Slug: &appSlug,
					Permissions: &github.InstallationPermissions{
						Contents:        github.Ptr("write"),
						Metadata:        github.Ptr("read"),
						PullRequests:    github.Ptr("write"),
						RepositoryHooks: github.Ptr("write"),
					},
				}
				_, _ = w.Write(ghmock.MustMarshal(app))
			}),
		),
		ghmock.WithRequestMatchHandler(
			ghmock.GetAppInstallationsByInstallationId,
			http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				id := r.URL.Query().Get("installation_id")
				idInt, _ := strconv.ParseInt(id, 10, 64)
				w.WriteHeader(http.StatusOK)
				installation := github.Installation{
					ID: &idInt,
				}
				_, _ = w.Write(ghmock.MustMarshal(installation))
			}),
		),
		ghmock.WithRequestMatchHandler(
			ghmock.PostAppInstallationsAccessTokensByInstallationId,
			http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				w.WriteHeader(http.StatusOK)
				installation := github.InstallationToken{
					Token:     github.Ptr("someToken"),
					ExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour * 2)},
				}
				_, _ = w.Write(ghmock.MustMarshal(installation))
			}),
		),
		ghmock.WithRequestMatchHandler(
			ghmock.GetInstallationRepositories,
			http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				w.WriteHeader(http.StatusOK)
				reposResponse := &github.ListRepositories{
					Repositories: expectedRepos,
					TotalCount:   github.Ptr(len(expectedRepos)),
				}
				_, _ = w.Write(ghmock.MustMarshal(reposResponse))
			}),
		),
	)
	h.SetGithubConnectionFactory(connectionFactory)

	return nil
}

func postHelper(t *testing.T, helper apis.K8sTestHelper, path string, body interface{}, user apis.User) (map[string]interface{}, int, error) {
	return requestHelper(t, helper, http.MethodPost, path, body, user)
}

func patchHelper(t *testing.T, helper apis.K8sTestHelper, path string, body interface{}, user apis.User) (map[string]interface{}, int, error) {
	return requestHelper(t, helper, http.MethodPatch, path, body, user)
}

func requestHelper(
	t *testing.T,
	helper apis.K8sTestHelper,
	method string,
	path string,
	body interface{},
	user apis.User,
) (map[string]interface{}, int, error) {
	bodyJSON, err := json.Marshal(body)
	require.NoError(t, err)

	resp := apis.DoRequest(&helper, apis.RequestParams{
		User:        user,
		Method:      method,
		Path:        path,
		Body:        bodyJSON,
		ContentType: "application/json",
	}, &struct{}{})

	if resp.Response.StatusCode != http.StatusOK {
		res := map[string]interface{}{}
		err := json.Unmarshal(resp.Body, &res)
		if err != nil {
			return nil, 0, fmt.Errorf("failed to unmarshal response JSON: %v", err)
		}

		return res, resp.Response.StatusCode, fmt.Errorf("failure when making request: %s", resp.Response.Status)
	}

	var result map[string]interface{}
	err = json.Unmarshal(resp.Body, &result)
	if err != nil {
		return nil, 0, fmt.Errorf("failed to unmarshal response JSON: %v", err)
	}

	return result, resp.Response.StatusCode, nil
}

// findCondition finds a condition by type in the conditions list
func findCondition(conditions []metav1.Condition, conditionType string) *metav1.Condition {
	for i := range conditions {
		if conditions[i].Type == conditionType {
			return &conditions[i]
		}
	}
	return nil
}
