package dashboards

import (
	"context"
	"encoding/json"
	"fmt"
	"io/fs"
	"math"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	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/dynamic"
	k8srest "k8s.io/client-go/rest"

	dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
	"github.com/grafana/grafana/pkg/apimachinery/identity"
	"github.com/grafana/grafana/pkg/apiserver/rest"
	"github.com/grafana/grafana/pkg/components/simplejson"
	"github.com/grafana/grafana/pkg/services/dashboards"
	"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/util/testutil"
)

func TestIntegrationSearchDevDashboards(t *testing.T) {
	testutil.SkipIntegrationTestInShortMode(t)
	ctx := context.Background()

	helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
		AppModeProduction:    true,
		DisableAnonymous:     true,
		APIServerStorageType: "unified",
		UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
			"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5},
			"folders.folder.grafana.app":       {DualWriterMode: rest.Mode5},
		},
	})
	defer helper.Shutdown()

	// Create devenv dashboards from legacy API
	cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig())
	cfg.GroupVersion = &dashboardV0.GroupVersion
	adminClient, err := k8srest.RESTClientFor(cfg)
	require.NoError(t, err)
	adminClient.Get()

	fileCount := 0
	devenv := "../../../../devenv/dev-dashboards/panel-timeseries"
	err = filepath.WalkDir(devenv, func(p string, d fs.DirEntry, e error) error {
		require.NoError(t, err)
		if d.IsDir() || filepath.Ext(d.Name()) != ".json" {
			return nil
		}

		// use the filename as UID
		uid := strings.TrimSuffix(d.Name(), ".json")
		if len(uid) > 40 {
			uid = uid[:40] // avoid uid too long, max 40 characters
		}

		// nolint:gosec
		data, err := os.ReadFile(p)
		require.NoError(t, err)

		cmd := dashboards.SaveDashboardCommand{
			Dashboard: &simplejson.Json{},
			Overwrite: true,
		}
		err = cmd.Dashboard.FromDB(data)
		require.NoError(t, err)
		cmd.Dashboard.Set("id", nil)
		cmd.Dashboard.Set("uid", uid)
		data, err = json.Marshal(cmd)
		require.NoError(t, err)

		var statusCode int
		result := adminClient.Post().AbsPath("api", "dashboards", "db").
			Body(data).
			SetHeader("Content-type", "application/json").
			Do(ctx).
			StatusCode(&statusCode)
		require.NoError(t, result.Error(), "file: [%d] %s [status:%d]", fileCount, d.Name(), statusCode)
		require.Equal(t, int(http.StatusOK), statusCode)
		fileCount++
		return nil
	})
	require.NoError(t, err)
	require.Equal(t, 17, fileCount, "file count from %s", devenv)

	// Helper to call search
	callSearch := func(user apis.User, params map[string]string) dashboardV0.SearchResults {
		require.NotNil(t, user)
		ns := user.Identity.GetNamespace()
		cfg := dynamic.ConfigFor(user.NewRestConfig())
		cfg.GroupVersion = &dashboardV0.GroupVersion
		restClient, err := k8srest.RESTClientFor(cfg)
		require.NoError(t, err)

		var statusCode int
		req := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search").
			//Param("explain", "true") // helpful to understand which field made things match
			Param("limit", "1000").
			Param("type", "dashboard") // Only search dashboards

		for k, v := range params {
			req = req.Param(k, v)
		}
		res := req.Do(ctx).StatusCode(&statusCode)
		require.NoError(t, res.Error())
		require.Equal(t, int(http.StatusOK), statusCode)
		var sr dashboardV0.SearchResults
		raw, err := res.Raw()
		require.NoError(t, err)
		require.NoError(t, json.Unmarshal(raw, &sr))

		// Normalize scores and query cost for snapshot comparison
		sr.QueryCost = 0 // this depends on the hardware
		sr.MaxScore = roundTo(sr.MaxScore, 3)
		for i := range sr.Hits {
			sr.Hits[i].Score = roundTo(sr.Hits[i].Score, 3) // 0.6250571494814442 -> 0.625
			if sr.Hits[i].Explain != nil {
				roundExplainValues(sr.Hits[i].Explain.Object, 3)
			}
		}
		return sr
	}

	// debug helper
	testCase := ""

	// Compare a results to snapshots
	testCases := []struct {
		name   string
		user   apis.User
		params map[string]string
	}{
		{
			name: "all",
			user: helper.Org1.Admin,
		},
		{
			name: "query-single-word",
			user: helper.Org1.Admin,
			params: map[string]string{
				"query": "stacking",
			},
		},
		{
			name: "query-multiple-words",
			user: helper.Org1.Admin,
			params: map[string]string{
				"query": "graph softMin", // must match ALL terms
			},
		},
		{
			name: "with-text-panel",
			user: helper.Org1.Admin,
			params: map[string]string{
				"field":     "panel_types", // return panel types
				"panelType": "text",
			},
		},
		{
			name: "title-ngram-prefix",
			user: helper.Org1.Admin,
			params: map[string]string{
				"query": "zer", // should match "Zero Decimals Y Ticks"
			},
		},
		{
			name: "title-ngram-middle-word",
			user: helper.Org1.Admin,
			params: map[string]string{
				"query": "decim", // should match "Zero Decimals Y Ticks"
			},
		},
		{
			name: "panel-title-orange",
			user: helper.Org1.Admin,
			params: map[string]string{
				"query":            "orange",
				"panelTitleSearch": "true",
				"explain":          "true",
			},
		},
	}
	for i, tc := range testCases {
		if testCase != "" && testCase != tc.name {
			continue
		}
		t.Run(tc.name, func(t *testing.T) {
			res := callSearch(tc.user, tc.params)
			jj, err := json.MarshalIndent(res, "", "  ")
			require.NoError(t, err)

			fname := fmt.Sprintf("testdata/searchV0/t%02d-%s.json", i, tc.name)
			// nolint:gosec
			snapshot, err := os.ReadFile(fname)
			if err != nil {
				assert.Failf(t, "Failed to read snapshot", "file: %s", fname)
				err = os.WriteFile(fname, jj, 0o644)
				require.NoErrorf(t, err, "Failed to write snapshot file %s", fname)
				return
			}

			if !assert.JSONEq(t, string(snapshot), string(jj)) {
				err = os.WriteFile(fname, jj, 0o644)
				require.NoErrorf(t, err, "Failed to write snapshot file %s", fname)
			}
		})
	}
}

func TestIntegrationSearchOwnerReferences(t *testing.T) {
	testutil.SkipIntegrationTestInShortMode(t)
	ctx := context.Background()

	helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
		AppModeProduction:    true,
		DisableAnonymous:     true,
		APIServerStorageType: "unified",
		UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
			"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5},
			"folders.folder.grafana.app":       {DualWriterMode: rest.Mode5},
		},
	})
	defer helper.Shutdown()

	ns := helper.Org1.Admin.Identity.GetNamespace()
	gvr := schema.GroupVersionResource{
		Group:    dashboardV0.GROUP,
		Version:  dashboardV0.VERSION,
		Resource: "dashboards",
	}
	client := helper.GetResourceClient(apis.ResourceClientArgs{
		User: helper.Org1.Admin,
		GVR:  gvr,
	})

	// Create a dashboard with multiple owner references
	dashboardUID := "owner-ref-test-dash"
	ownerRefs := []metav1.OwnerReference{
		{
			APIVersion: "iam.grafana.app/v0alpha1",
			Kind:       "Team",
			Name:       "test-team",
			UID:        "test-team-uid",
		},
		{
			APIVersion: "iam.grafana.app/v0alpha1",
			Kind:       "User",
			Name:       "test-user",
			UID:        "test-user-uid",
		},
	}

	obj := &unstructured.Unstructured{
		Object: map[string]interface{}{
			"spec": map[string]any{
				"title":         "Dashboard with owner references",
				"schemaVersion": 42,
			},
		},
	}
	obj.SetName(dashboardUID)
	obj.SetAPIVersion(gvr.GroupVersion().String())
	obj.SetKind("Dashboard")
	obj.SetOwnerReferences(ownerRefs)

	_, err := client.Resource.Create(ctx, obj, metav1.CreateOptions{})
	require.NoError(t, err)

	// Search for the dashboard by one of the owner references and verify all refs are returned
	cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig())
	cfg.GroupVersion = &dashboardV0.GroupVersion
	restClient, err := k8srest.RESTClientFor(cfg)
	require.NoError(t, err)

	var statusCode int
	req := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search").
		Param("limit", "1000").
		Param("type", "dashboard").
		Param("ownerReference", "iam.grafana.app/Team/test-team") // Search by one owner reference

	res := req.Do(ctx).StatusCode(&statusCode)
	require.NoError(t, res.Error())
	require.Equal(t, http.StatusOK, statusCode)

	var sr dashboardV0.SearchResults
	raw, err := res.Raw()
	require.NoError(t, err)
	require.NoError(t, json.Unmarshal(raw, &sr))

	// Find our dashboard in the results
	var foundHit *dashboardV0.DashboardHit
	for i := range sr.Hits {
		if sr.Hits[i].Name == dashboardUID {
			foundHit = &sr.Hits[i]
			break
		}
	}

	require.NotNil(t, foundHit, "Dashboard should be found in search results when filtering by owner reference")
	require.Len(t, foundHit.OwnerReferences, 2, "Dashboard should have two owner references")
	// Owner references are stored in format {Group}/{Kind}/{Name}
	assert.Contains(t, foundHit.OwnerReferences, "iam.grafana.app/Team/test-team")
	assert.Contains(t, foundHit.OwnerReferences, "iam.grafana.app/User/test-user")
}

func TestIntegrationSearchCreatedBy(t *testing.T) {
	testutil.SkipIntegrationTestInShortMode(t)
	ctx := context.Background()

	helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
		AppModeProduction:    true,
		DisableAnonymous:     true,
		APIServerStorageType: "unified",
		UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
			"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5},
			"folders.folder.grafana.app":       {DualWriterMode: rest.Mode5},
		},
	})
	defer helper.Shutdown()

	ns := helper.Org1.Admin.Identity.GetNamespace()
	gvr := schema.GroupVersionResource{
		Group:    dashboardV0.GROUP,
		Version:  dashboardV0.VERSION,
		Resource: "dashboards",
	}
	client := helper.GetResourceClient(apis.ResourceClientArgs{
		User: helper.Org1.Admin,
		GVR:  gvr,
	})

	// Create a dashboard as admin user
	dashboardUID := "created-by-test-dash"
	obj := &unstructured.Unstructured{
		Object: map[string]interface{}{
			"spec": map[string]any{
				"title":         "Dashboard created by admin",
				"schemaVersion": 42,
			},
		},
	}
	obj.SetName(dashboardUID)
	obj.SetAPIVersion(gvr.GroupVersion().String())
	obj.SetKind("Dashboard")

	_, err := client.Resource.Create(ctx, obj, metav1.CreateOptions{})
	require.NoError(t, err)

	// The createdBy annotation is set automatically by the apistore (prepare.go)
	// to the identity UID of the user who created the resource.
	createdByUID := helper.Org1.Admin.Identity.GetUID()

	// Search for dashboards filtered by createdBy
	cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig())
	cfg.GroupVersion = &dashboardV0.GroupVersion
	restClient, err := k8srest.RESTClientFor(cfg)
	require.NoError(t, err)

	var statusCode int
	req := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search").
		Param("limit", "1000").
		Param("type", "dashboard").
		Param("createdBy", createdByUID)

	res := req.Do(ctx).StatusCode(&statusCode)
	require.NoError(t, res.Error())
	require.Equal(t, http.StatusOK, statusCode)

	var sr dashboardV0.SearchResults
	raw, err := res.Raw()
	require.NoError(t, err)
	require.NoError(t, json.Unmarshal(raw, &sr))

	// Find our dashboard in the results
	var foundHit *dashboardV0.DashboardHit
	for i := range sr.Hits {
		if sr.Hits[i].Name == dashboardUID {
			foundHit = &sr.Hits[i]
			break
		}
	}
	require.NotNil(t, foundHit, "Dashboard should be found when filtering by createdBy")
	assert.Equal(t, "Dashboard created by admin", foundHit.Title)

	// Verify that searching with a non-matching createdBy returns no match for this dashboard
	req2 := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search").
		Param("limit", "1000").
		Param("type", "dashboard").
		Param("createdBy", "user:nonexistent-uid")

	res2 := req2.Do(ctx).StatusCode(&statusCode)
	require.NoError(t, res2.Error())
	require.Equal(t, http.StatusOK, statusCode)

	var sr2 dashboardV0.SearchResults
	raw2, err := res2.Raw()
	require.NoError(t, err)
	require.NoError(t, json.Unmarshal(raw2, &sr2))

	// The dashboard should not appear with a different createdBy filter
	for _, hit := range sr2.Hits {
		assert.NotEqual(t, dashboardUID, hit.Name, "Dashboard should not be found when filtering by a different createdBy")
	}
}

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

	// Only run for Unified Storage modes that support search (Mode4+)
	modes := []rest.DualWriterMode{rest.Mode5}
	for _, mode := range modes {
		runSearchPermissionTest(t, mode)
	}
}

func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) {
	t.Run(fmt.Sprintf("search permission filtering with dual writer mode %d", mode), func(t *testing.T) {
		ctx := context.Background()

		helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
			AppModeProduction:    true,
			DisableAnonymous:     true,
			APIServerStorageType: "unified",
			UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
				"dashboards.dashboard.grafana.app": {DualWriterMode: mode},
				"folders.folder.grafana.app":       {DualWriterMode: mode},
			},
		})
		defer helper.Shutdown()

		// Create a folder via legacy API using Admin
		folderUID := "perm-test-folder"
		{
			cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig())
			cfg.GroupVersion = &schema.GroupVersion{Group: "folder.grafana.app", Version: "v1beta1"}
			restClient, err := k8srest.RESTClientFor(cfg)
			require.NoError(t, err)

			var statusCode int
			body := []byte(fmt.Sprintf(`{"uid":"%s","title":"Permission Test Folder"}`, folderUID))
			result := restClient.Post().AbsPath("api", "folders").
				Body(body).
				SetHeader("Content-type", "application/json").
				Do(ctx).
				StatusCode(&statusCode)
			require.NoError(t, result.Error())
			require.Equal(t, int(http.StatusOK), statusCode)
		}

		// Set permissions: Viewer gets View, Editor gets Edit
		viewerID, err := identity.UserIdentifier(helper.Org1.Viewer.Identity.GetID())
		require.NoError(t, err)
		editorID, err := identity.UserIdentifier(helper.Org1.Editor.Identity.GetID())
		require.NoError(t, err)

		permissions := []ResourcePermissionSetting{
			{UserID: &viewerID, Level: ResourcePermissionLevelView},
			{UserID: &editorID, Level: ResourcePermissionLevelEdit},
		}
		setFolderPermissions(t, helper, helper.Org1.Admin, folderUID, permissions)

		// Helper to call search
		callSearch := func(user apis.User, params string) dashboardV0.SearchResults {
			ns := user.Identity.GetNamespace()
			cfg := dynamic.ConfigFor(user.NewRestConfig())
			cfg.GroupVersion = &schema.GroupVersion{Group: "dashboard.grafana.app", Version: "v0alpha1"}
			restClient, err := k8srest.RESTClientFor(cfg)
			require.NoError(t, err)

			var statusCode int
			req := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search").
				Param("limit", "1000").
				Param("type", "folder") // Only search folders

			for _, kv := range strings.Split(params, "&") {
				if kv == "" {
					continue
				}
				parts := strings.SplitN(kv, "=", 2)
				if len(parts) == 2 {
					req = req.Param(parts[0], parts[1])
				}
			}
			res := req.Do(ctx).StatusCode(&statusCode)
			require.NoError(t, res.Error())
			require.Equal(t, int(http.StatusOK), statusCode)
			var sr dashboardV0.SearchResults
			raw, err := res.Raw()
			require.NoError(t, err)
			require.NoError(t, json.Unmarshal(raw, &sr))
			return sr
		}

		// 1. Viewer searching without permission parameter should find it (has View access)
		{
			res := callSearch(helper.Org1.Viewer, "")
			found := false
			for _, h := range res.Hits {
				if h.Name == folderUID { // Verify it's our folder
					found = true
					break
				}
			}
			require.True(t, found, "Viewer should find folder without permission parameter")
		}

		// 2. Viewer searching with permission=View should find it
		{
			res := callSearch(helper.Org1.Viewer, "permission=view")
			found := false
			for _, h := range res.Hits {
				if h.Name == folderUID { // Verify it's our folder
					found = true
					break
				}
			}
			require.True(t, found, "Viewer should find folder with permission=view")
		}

		// 3. Viewer searching with permission=Edit should NOT find it
		{
			res := callSearch(helper.Org1.Viewer, "permission=edit")
			found := false
			for _, h := range res.Hits {
				if h.Name == folderUID { // Verify it's our folder
					found = true
					break
				}
			}
			require.False(t, found, "Viewer should NOT find folder with permission=edit")
		}

		// 4. Editor searching with permission=Edit should find it
		{
			res := callSearch(helper.Org1.Editor, "permission=edit")
			found := false
			for _, h := range res.Hits {
				if h.Name == folderUID { // Verify it's our folder
					found = true
					break
				}
			}
			require.True(t, found, "Editor should find folder with permission=edit")
		}

		// 5. Editor searching with permission=View should find it (Edit permission includes View)
		{
			res := callSearch(helper.Org1.Editor, "permission=view")
			found := false
			for _, h := range res.Hits {
				if h.Name == folderUID { // Verify it's our folder
					found = true
					break
				}
			}
			require.True(t, found, "Editor should find folder with permission=view (Edit includes View)")
		}

		// 6. Editor searching without permission parameter should find it (has Edit access)
		{
			res := callSearch(helper.Org1.Editor, "")
			found := false
			for _, h := range res.Hits {
				if h.Name == folderUID { // Verify it's our folder
					found = true
					break
				}
			}
			require.True(t, found, "Editor should find folder without permission parameter")
		}

		// 7. Admin searching with permission=View should find it (Admin has full access)
		{
			res := callSearch(helper.Org1.Admin, "permission=view")
			found := false
			for _, h := range res.Hits {
				if h.Name == folderUID { // Verify it's our folder
					found = true
					break
				}
			}
			require.True(t, found, "Admin should find folder with permission=view")
		}

		// 8. Admin searching with permission=Edit should find it (Admin has full access)
		{
			res := callSearch(helper.Org1.Admin, "permission=edit")
			found := false
			for _, h := range res.Hits {
				if h.Name == folderUID { // Verify it's our folder
					found = true
					break
				}
			}
			require.True(t, found, "Admin should find folder with permission=edit")
		}

		// 9. Admin searching with permission=Admin should find it (Admin has full access)
		{
			res := callSearch(helper.Org1.Admin, "permission=admin")
			found := false
			for _, h := range res.Hits {
				if h.Name == folderUID { // Verify it's our folder
					found = true
					break
				}
			}
			require.True(t, found, "Admin should find folder with permission=admin")
		}

		// 10. Admin searching without permission parameter should find it (has Admin access)
		{
			res := callSearch(helper.Org1.Admin, "")
			found := false
			for _, h := range res.Hits {
				if h.Name == folderUID { // Verify it's our folder
					found = true
					break
				}
			}
			require.True(t, found, "Admin should find folder without permission parameter")
		}
	})
}

// Types and helpers for permission setting

type ResourcePermissionLevel int

const (
	ResourcePermissionLevelView  ResourcePermissionLevel = 1
	ResourcePermissionLevelEdit  ResourcePermissionLevel = 2
	ResourcePermissionLevelAdmin ResourcePermissionLevel = 4
)

type ResourcePermissionSetting struct {
	UserID *int64                  `json:"userId,omitempty"`
	TeamID *int64                  `json:"teamId,omitempty"`
	Role   *string                 `json:"role,omitempty"`
	Level  ResourcePermissionLevel `json:"permission"`
}

type permissionRequest struct {
	Items []ResourcePermissionSetting `json:"items"`
}

func setFolderPermissions(t *testing.T, helper *apis.K8sTestHelper, actingUser apis.User, folderUID string, permissions []ResourcePermissionSetting) {
	reqBody := permissionRequest{
		Items: permissions,
	}

	jsonBody, err := json.Marshal(reqBody)
	require.NoError(t, err, "Failed to marshal permissions to JSON")

	path := fmt.Sprintf("/api/folders/%s/permissions", folderUID)

	resp := apis.DoRequest(helper, apis.RequestParams{
		User:        actingUser,
		Method:      http.MethodPost,
		Path:        path,
		Body:        jsonBody,
		ContentType: "application/json",
	}, &struct{}{})

	require.Equal(t, http.StatusOK, resp.Response.StatusCode, "Failed to set permissions for folder %s", folderUID)
}

func roundExplainValues(obj map[string]any, decimals uint32) {
	for k, val := range obj {
		switch k {
		case "value":
			v, ok := val.(float64)
			if ok {
				obj[k] = roundTo(v, decimals)
			}
		case "children":
			children, ok := val.([]any)
			if ok {
				for _, child := range children {
					if v, ok := child.(map[string]any); ok {
						roundExplainValues(v, decimals)
					}
				}
			}
		}
	}
}

// roundTo rounds a float64 to a specified number of decimal places.
func roundTo(n float64, decimals uint32) float64 {
	// Calculate the power of 10 for the desired number of decimals
	scale := math.Pow(10, float64(decimals))
	// Multiply, round to the nearest integer, and then divide back
	return math.Round(n*scale) / scale
}
