package resource

import (
	"context"
	"errors"
	"fmt"
	"iter"
	"sync"
	"testing"
	"time"

	"github.com/Masterminds/semver"
	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	"github.com/grafana/authlib/types"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"

	dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
	"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)

var _ ResourceIndex = &MockResourceIndex{}

// Mock implementations
type MockResourceIndex struct {
	mock.Mock

	updateIndexError error

	updateIndexMu    sync.Mutex
	updateIndexCalls int

	buildInfo IndexBuildInfo
}

func (m *MockResourceIndex) BuildInfo() (IndexBuildInfo, error) {
	return m.buildInfo, nil
}

func (m *MockResourceIndex) BulkIndex(req *BulkIndexRequest) error {
	args := m.Called(req)
	return args.Error(0)
}

func (m *MockResourceIndex) Search(ctx context.Context, access types.AccessClient, req *resourcepb.ResourceSearchRequest, federate []ResourceIndex, stats *SearchStats) (*resourcepb.ResourceSearchResponse, error) {
	args := m.Called(ctx, access, req, federate)
	return args.Get(0).(*resourcepb.ResourceSearchResponse), args.Error(1)
}

func (m *MockResourceIndex) CountManagedObjects(ctx context.Context, stats *SearchStats) ([]*resourcepb.CountManagedObjectsResponse_ResourceCount, error) {
	args := m.Called(ctx)
	return args.Get(0).([]*resourcepb.CountManagedObjectsResponse_ResourceCount), args.Error(1)
}

func (m *MockResourceIndex) DocCount(ctx context.Context, folder string, stats *SearchStats) (int64, error) {
	args := m.Called(ctx, folder)
	return args.Get(0).(int64), args.Error(1)
}

func (m *MockResourceIndex) ListManagedObjects(ctx context.Context, req *resourcepb.ListManagedObjectsRequest, stats *SearchStats) (*resourcepb.ListManagedObjectsResponse, error) {
	args := m.Called(ctx, req)
	return args.Get(0).(*resourcepb.ListManagedObjectsResponse), args.Error(1)
}

func (m *MockResourceIndex) UpdateIndex(_ context.Context) (int64, error) {
	m.updateIndexMu.Lock()
	defer m.updateIndexMu.Unlock()

	m.updateIndexCalls++
	return 0, m.updateIndexError
}

var _ DocumentBuilder = &MockDocumentBuilder{}

type MockDocumentBuilder struct {
	mock.Mock
}

func (m *MockDocumentBuilder) BuildDocument(ctx context.Context, key *resourcepb.ResourceKey, resourceVersion int64, value []byte) (*IndexableDocument, error) {
	args := m.Called(ctx, key, resourceVersion, value)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*IndexableDocument), nil
}

// mockStorageBackend implements StorageBackend for testing
type mockStorageBackend struct {
	resourceStats   []ResourceStats
	lastImportTimes []ResourceLastImportTime
}

func (m *mockStorageBackend) GetResourceStats(ctx context.Context, nsr NamespacedResource, minCount int) ([]ResourceStats, error) {
	var result []ResourceStats
	for _, stat := range m.resourceStats {
		// Apply the minCount filter like the real implementation does
		if stat.Count > int64(minCount) {
			result = append(result, stat)
		}
	}
	return result, nil
}

func (m *mockStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (int64, error) {
	return 0, nil
}

func (m *mockStorageBackend) ReadResource(ctx context.Context, req *resourcepb.ReadRequest) *BackendReadResponse {
	return nil
}

func (m *mockStorageBackend) WatchWriteEvents(ctx context.Context) (<-chan *WrittenEvent, error) {
	return nil, nil
}

func (m *mockStorageBackend) ListIterator(ctx context.Context, req *resourcepb.ListRequest, callback func(ListIterator) error) (int64, error) {
	return 0, nil
}

func (m *mockStorageBackend) ListHistory(ctx context.Context, req *resourcepb.ListRequest, callback func(ListIterator) error) (int64, error) {
	return 0, nil
}

func (m *mockStorageBackend) ListModifiedSince(ctx context.Context, key NamespacedResource, sinceRv int64) (int64, iter.Seq2[*ModifiedResource, error]) {
	return 0, func(yield func(*ModifiedResource, error) bool) {
		yield(nil, errors.New("not implemented"))
	}
}

func (m *mockStorageBackend) GetResourceLastImportTimes(ctx context.Context) iter.Seq2[ResourceLastImportTime, error] {
	return func(yield func(ResourceLastImportTime, error) bool) {
		for _, ti := range m.lastImportTimes {
			if !yield(ti, nil) {
				return
			}
		}
	}
}

// mockSearchBackend implements SearchBackend for testing with tracking capabilities
type mockSearchBackend struct {
	openIndexes []NamespacedResource

	mu              sync.Mutex
	buildIndexCalls []buildIndexCall
	cache           map[NamespacedResource]ResourceIndex
}

type buildIndexCall struct {
	key    NamespacedResource
	size   int64
	fields SearchableDocumentFields
}

func (m *mockSearchBackend) GetIndex(key NamespacedResource) ResourceIndex {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.cache[key]
}

func (m *mockSearchBackend) BuildIndex(ctx context.Context, key NamespacedResource, size int64, fields SearchableDocumentFields, reason string, builder BuildFn, updater UpdateFn, rebuild bool) (ResourceIndex, error) {
	index := &MockResourceIndex{}
	index.On("BulkIndex", mock.Anything).Return(nil).Maybe()
	index.On("DocCount", mock.Anything, mock.Anything).Return(int64(0), nil).Maybe()

	// Call the builder function (required by the contract)
	_, err := builder(index)
	if err != nil {
		return nil, err
	}

	m.mu.Lock()
	defer m.mu.Unlock()

	if m.cache == nil {
		m.cache = make(map[NamespacedResource]ResourceIndex)
	}
	m.cache[key] = index

	// Determine if this is an empty index based on size
	// Empty indexes are characterized by size == 0
	m.buildIndexCalls = append(m.buildIndexCalls, buildIndexCall{
		key:    key,
		size:   size,
		fields: fields,
	})

	return index, nil
}

func (m *mockSearchBackend) TotalDocs() int64 {
	return 0
}

func (m *mockSearchBackend) GetOpenIndexes() []NamespacedResource {
	return m.openIndexes
}

func TestSearchGetOrCreateIndex(t *testing.T) {
	// Setup mock implementations
	storage := &mockStorageBackend{
		resourceStats: []ResourceStats{
			{NamespacedResource: NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, Count: 50, ResourceVersion: 11111111},
		},
	}
	search := &mockSearchBackend{}
	supplier := &TestDocumentBuilderSupplier{
		GroupsResources: map[string]string{
			"group": "resource",
		},
	}

	opts := SearchOptions{
		Backend:      search,
		Resources:    supplier,
		InitMinCount: 1, // set min count to default for this test
	}

	support, err := newSearchSupport(opts, storage, nil, nil, nil, nil)
	require.NoError(t, err)
	require.NotNil(t, support)

	start := make(chan struct{})

	const concurrency = 100
	wg := sync.WaitGroup{}
	for i := 0; i < concurrency; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			<-start
			_, _ = support.getOrCreateIndex(context.Background(), nil, NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, "test")
		}()
	}

	// Wait a bit for goroutines to start (hopefully)
	time.Sleep(10 * time.Millisecond)
	// Unblock all goroutines.
	close(start)
	wg.Wait()

	require.NotEmpty(t, search.buildIndexCalls)
	require.Less(t, len(search.buildIndexCalls), concurrency, "Should not have built index more than a few times (ideally once)")
	require.Equal(t, int64(50), search.buildIndexCalls[0].size)
}

func TestSearchGetOrCreateIndexWithIndexUpdate(t *testing.T) {
	// Setup mock implementations
	storage := &mockStorageBackend{
		resourceStats: []ResourceStats{
			{NamespacedResource: NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, Count: 50, ResourceVersion: 11111111},
		},
	}
	failedErr := fmt.Errorf("failed to update index")
	search := &mockSearchBackend{
		cache: map[NamespacedResource]ResourceIndex{
			{Namespace: "ns", Group: "group", Resource: "bad"}: &MockResourceIndex{
				updateIndexError: failedErr,
			},
		},
	}
	supplier := &TestDocumentBuilderSupplier{
		GroupsResources: map[string]string{
			"group": "resource",
		},
	}

	opts := SearchOptions{
		Backend:      search,
		Resources:    supplier,
		InitMinCount: 1, // set min count to default for this test
	}

	// Enable searchAfterWrite
	support, err := newSearchSupport(opts, storage, nil, nil, nil, nil)
	require.NoError(t, err)
	require.NotNil(t, support)

	idx, err := support.getOrCreateIndex(context.Background(), nil, NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, "initial call")
	require.NoError(t, err)
	require.NotNil(t, idx)
	checkMockIndexUpdateCalls(t, idx, 1)

	idx, err = support.getOrCreateIndex(context.Background(), nil, NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, "second call")
	require.NoError(t, err)
	require.NotNil(t, idx)
	checkMockIndexUpdateCalls(t, idx, 2)

	idx, err = support.getOrCreateIndex(context.Background(), nil, NamespacedResource{Namespace: "ns", Group: "group", Resource: "bad"}, "call to bad index")
	require.ErrorIs(t, err, failedErr)
	require.Nil(t, idx)
}

func checkMockIndexUpdateCalls(t *testing.T, idx ResourceIndex, calls int) {
	mi, ok := idx.(*MockResourceIndex)
	require.True(t, ok)
	mi.updateIndexMu.Lock()
	defer mi.updateIndexMu.Unlock()
	require.Equal(t, calls, mi.updateIndexCalls)
}

func TestSearchGetOrCreateIndexWithCancellation(t *testing.T) {
	// Setup mock implementations
	storage := &mockStorageBackend{
		resourceStats: []ResourceStats{
			{NamespacedResource: NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, Count: 50, ResourceVersion: 11111111},
		},
	}
	search := &slowSearchBackendWithCache{
		mockSearchBackend: mockSearchBackend{},
	}
	supplier := &TestDocumentBuilderSupplier{
		GroupsResources: map[string]string{
			"group": "resource",
		},
	}

	opts := SearchOptions{
		Backend:      search,
		Resources:    supplier,
		InitMinCount: 1, // set min count to default for this test
	}

	support, err := newSearchSupport(opts, storage, nil, nil, nil, nil)
	require.NoError(t, err)
	require.NotNil(t, support)

	key := NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}

	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
	defer cancel()

	_, err = support.getOrCreateIndex(ctx, nil, key, "test")
	// Make sure we get context deadline error
	require.ErrorIs(t, err, context.DeadlineExceeded)

	// Wait until indexing is finished.
	search.wg.Wait()

	require.NotEmpty(t, search.buildIndexCalls)

	// Wait until new index is put into cache.
	require.Eventually(t, func() bool {
		idx := support.search.GetIndex(key)
		return idx != nil
	}, 1*time.Second, 100*time.Millisecond, "Indexing finishes despite context cancellation")

	// Second call to getOrCreateIndex returns index immediately, even if context is canceled, as the index is now ready and cached.
	_, err = support.getOrCreateIndex(ctx, nil, key, "test")
	require.NoError(t, err)
}

type slowSearchBackendWithCache struct {
	mockSearchBackend
	wg sync.WaitGroup
}

func (m *slowSearchBackendWithCache) GetIndex(key NamespacedResource) ResourceIndex {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.cache[key]
}

func (m *slowSearchBackendWithCache) BuildIndex(ctx context.Context, key NamespacedResource, size int64, fields SearchableDocumentFields, reason string, builder BuildFn, updater UpdateFn, rebuild bool) (ResourceIndex, error) {
	m.wg.Add(1)
	defer m.wg.Done()

	time.Sleep(1 * time.Second)

	// Simulate erroring out when context is cancelled.
	if ctx.Err() != nil {
		return nil, ctx.Err()
	}
	idx, err := m.mockSearchBackend.BuildIndex(ctx, key, size, fields, reason, builder, updater, rebuild)
	if err != nil {
		return nil, err
	}
	return idx, nil
}

func TestCombineBuildRequests(t *testing.T) {
	type testcase struct {
		a, b  rebuildRequest
		exp   rebuildRequest
		expOK bool
	}

	now := time.Now()
	for name, tc := range map[string]testcase{
		"mismatched resource": {
			a:     rebuildRequest{NamespacedResource: NamespacedResource{Namespace: "a", Group: "a", Resource: "a"}},
			b:     rebuildRequest{NamespacedResource: NamespacedResource{Namespace: "b", Group: "b", Resource: "b"}},
			expOK: false,
		},
		"equal values": {
			a:     rebuildRequest{minBuildTime: now, minBuildVersion: semver.MustParse("10.15.20")},
			b:     rebuildRequest{minBuildTime: now, minBuildVersion: semver.MustParse("10.15.20")},
			expOK: true,
			exp:   rebuildRequest{minBuildTime: now, minBuildVersion: semver.MustParse("10.15.20")},
		},
		"empty field": {
			a:     rebuildRequest{minBuildTime: now},
			b:     rebuildRequest{minBuildVersion: semver.MustParse("10.15.20")},
			expOK: true,
			exp:   rebuildRequest{minBuildTime: now, minBuildVersion: semver.MustParse("10.15.20")},
		},
		"use max build time": {
			a:     rebuildRequest{minBuildTime: now.Add(2 * time.Hour)},
			b:     rebuildRequest{minBuildTime: now.Add(-time.Hour)},
			expOK: true,
			exp:   rebuildRequest{minBuildTime: now.Add(2 * time.Hour)},
		},
		"use max version": {
			a:     rebuildRequest{minBuildVersion: semver.MustParse("12.10.99")},
			b:     rebuildRequest{minBuildVersion: semver.MustParse("10.15.20")},
			expOK: true,
			exp:   rebuildRequest{minBuildVersion: semver.MustParse("12.10.99")},
		},
		"both fields": {
			a:     rebuildRequest{minBuildTime: now.Add(2 * time.Hour), minBuildVersion: semver.MustParse("12.10.99")},
			b:     rebuildRequest{minBuildTime: now.Add(-time.Hour), minBuildVersion: semver.MustParse("10.15.20")},
			expOK: true,
			exp:   rebuildRequest{minBuildTime: now.Add(2 * time.Hour), minBuildVersion: semver.MustParse("12.10.99")},
		},
	} {
		t.Run(name, func(t *testing.T) {
			res1, ok := combineRebuildRequests(tc.a, tc.b)
			require.Equal(t, tc.expOK, ok)
			if ok {
				require.Equal(t, tc.exp, res1)
			}

			// commutativity
			res2, ok := combineRebuildRequests(tc.b, tc.a)
			require.Equal(t, tc.expOK, ok)
			if ok {
				require.Equal(t, tc.exp, res2)
			}
		})
	}
}

func TestShouldRebuildIndex(t *testing.T) {
	type testcase struct {
		buildInfo       IndexBuildInfo
		minTime         time.Time
		lastImportTime  time.Time
		minBuildVersion *semver.Version

		expected bool
	}

	now := time.Now()

	for name, tc := range map[string]testcase{
		"empty build info, with no rebuild conditions": {
			buildInfo: IndexBuildInfo{},
			expected:  false,
		},
		"empty build info, with minTime": {
			buildInfo: IndexBuildInfo{},
			minTime:   now,
			expected:  true,
		},
		"empty build info, with lastImportTime": {
			buildInfo:      IndexBuildInfo{},
			lastImportTime: now,
			expected:       true,
		},
		"empty build info, with minVersion": {
			buildInfo:       IndexBuildInfo{},
			minBuildVersion: semver.MustParse("10.15.20"),
			expected:        true,
		},
		"build time before min time": {
			buildInfo: IndexBuildInfo{BuildTime: now.Add(-2 * time.Hour)},
			minTime:   now,
			expected:  true,
		},
		"build time after min time": {
			buildInfo: IndexBuildInfo{BuildTime: now.Add(2 * time.Hour)},
			minTime:   now,
			expected:  false,
		},
		"build time before last import time": {
			buildInfo:      IndexBuildInfo{BuildTime: now.Add(-2 * time.Hour)},
			lastImportTime: now,
			expected:       true,
		},
		"build time after last import time": {
			buildInfo:      IndexBuildInfo{BuildTime: now.Add(2 * time.Hour)},
			lastImportTime: now,
			expected:       false,
		},
		"build version before min version": {
			buildInfo:       IndexBuildInfo{BuildVersion: semver.MustParse("10.15.19")},
			minBuildVersion: semver.MustParse("10.15.20"),
			expected:        true,
		},
		"build version after min version": {
			buildInfo:       IndexBuildInfo{BuildVersion: semver.MustParse("11.0.0")},
			minBuildVersion: semver.MustParse("10.15.20"),
			expected:        false,
		},
	} {
		t.Run(name, func(t *testing.T) {
			res := shouldRebuildIndex(tc.buildInfo, tc.minBuildVersion, tc.minTime, tc.lastImportTime, nil)
			require.Equal(t, tc.expected, res)
		})
	}
}

func TestFindIndexesForRebuild(t *testing.T) {
	storage := &mockStorageBackend{
		resourceStats: []ResourceStats{
			{NamespacedResource: NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, Count: 50, ResourceVersion: 11111111},
		},
	}

	now := time.Now().UTC()

	search := &mockSearchBackend{
		openIndexes: []NamespacedResource{
			{Namespace: "resource-2h-v5", Group: "group", Resource: "folder"},
			{Namespace: "resource-2h-v6", Group: "group", Resource: "folder"},
			{Namespace: "resource-10h-v5", Group: "group", Resource: "folder"},
			{Namespace: "resource-10h-v6", Group: "group", Resource: "folder"},
			{Namespace: "resource-v5", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE},
			{Namespace: "resource-v6", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE},
			{Namespace: "resource-2h-v5", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE},
			{Namespace: "resource-2h-v6", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE},
			{Namespace: "resource-recently-imported", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE},

			// We report this index as open, but it's really not. This can happen if index expires between the call
			// to GetOpenIndexes and the call to GetIndex.
			{Namespace: "ns", Group: "group", Resource: "missing"},
		},

		cache: map[NamespacedResource]ResourceIndex{
			// To be rebuilt because of minVersion
			{Namespace: "resource-2h-v5", Group: "group", Resource: "folder"}: &MockResourceIndex{
				buildInfo: IndexBuildInfo{BuildTime: now.Add(-2 * time.Hour), BuildVersion: semver.MustParse("5.0.0")},
			},

			// Not rebuilt
			{Namespace: "resource-2h-v6", Group: "group", Resource: "folder"}: &MockResourceIndex{
				buildInfo: IndexBuildInfo{BuildTime: now.Add(-2 * time.Hour), BuildVersion: semver.MustParse("6.0.0")},
			},

			// To be rebuilt because of minTime
			{Namespace: "resource-10h-v5", Group: "group", Resource: "folder"}: &MockResourceIndex{
				buildInfo: IndexBuildInfo{BuildTime: now.Add(-10 * time.Hour), BuildVersion: semver.MustParse("5.0.0")},
			},

			// To be rebuilt because of minTime
			{Namespace: "resource-10h-v6", Group: "group", Resource: "folder"}: &MockResourceIndex{
				buildInfo: IndexBuildInfo{BuildTime: now.Add(-10 * time.Hour), BuildVersion: semver.MustParse("6.0.0")},
			},

			// To be rebuilt because of minVersion
			{Namespace: "resource-v5", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}: &MockResourceIndex{
				buildInfo: IndexBuildInfo{BuildTime: now, BuildVersion: semver.MustParse("5.0.0")},
			},

			// Not rebuilt
			{Namespace: "resource-v6", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}: &MockResourceIndex{
				buildInfo: IndexBuildInfo{BuildTime: now, BuildVersion: semver.MustParse("6.0.0")},
			},

			// To be rebuilt because of minTime (1h for dashboards)
			{Namespace: "resource-2h-v5", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}: &MockResourceIndex{
				buildInfo: IndexBuildInfo{BuildTime: now.Add(-2 * time.Hour), BuildVersion: semver.MustParse("5.0.0")},
			},

			// To be rebuilt because of minTime (1h for dashboards)
			{Namespace: "resource-2h-v6", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}: &MockResourceIndex{
				buildInfo: IndexBuildInfo{BuildTime: now.Add(-2 * time.Hour), BuildVersion: semver.MustParse("6.0.0")},
			},

			// Built recently, to be rebuilt because of last import time
			{Namespace: "resource-recently-imported", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}: &MockResourceIndex{
				buildInfo: IndexBuildInfo{BuildTime: now.Add(-30 * time.Minute), BuildVersion: semver.MustParse("6.0.0")},
			},
		},
	}

	supplier := &TestDocumentBuilderSupplier{
		GroupsResources: map[string]string{
			"group": "resource",
		},
	}

	opts := SearchOptions{
		Backend:   search,
		Resources: supplier,

		DashboardIndexMaxAge: 1 * time.Hour,
		MaxIndexAge:          5 * time.Hour,
		MinBuildVersion:      semver.MustParse("5.5.5"),
	}

	support, err := newSearchSupport(opts, storage, nil, nil, nil, nil)
	require.NoError(t, err)
	require.NotNil(t, support)

	lastImportTime := now.Add(-10 * time.Minute)
	importTimes := map[NamespacedResource]time.Time{
		{Namespace: "resource-recently-imported", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}: lastImportTime,

		// This index was "just" built, and should not be rebuilt.
		{Namespace: "resource-v6", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}: lastImportTime,
	}

	support.findIndexesToRebuild(importTimes, nil, now)
	require.Equal(t, 7, support.rebuildQueue.Len())

	now5m := now.Add(5 * time.Minute)

	// Running findIndexesToRebuild again should not add any new indexes to the rebuild queue, and all existing
	// ones should be "combined" with new ones (this will "bump" minBuildTime)
	support.findIndexesToRebuild(importTimes, nil, now5m)
	require.Equal(t, 7, support.rebuildQueue.Len())

	// Values that we expect to find in rebuild requests.
	minBuildVersion := semver.MustParse("5.5.5")
	minBuildTime := now5m.Add(-5 * time.Hour)
	minBuildTimeDashboard := now5m.Add(-1 * time.Hour)

	vals := support.rebuildQueue.Elements()
	expected := []rebuildRequest{
		{NamespacedResource: NamespacedResource{Namespace: "resource-2h-v5", Group: "group", Resource: "folder"}, minBuildVersion: minBuildVersion, minBuildTime: minBuildTime},
		{NamespacedResource: NamespacedResource{Namespace: "resource-10h-v5", Group: "group", Resource: "folder"}, minBuildVersion: minBuildVersion, minBuildTime: minBuildTime},
		{NamespacedResource: NamespacedResource{Namespace: "resource-10h-v6", Group: "group", Resource: "folder"}, minBuildVersion: minBuildVersion, minBuildTime: minBuildTime},

		{NamespacedResource: NamespacedResource{Namespace: "resource-v5", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}, minBuildVersion: minBuildVersion, minBuildTime: minBuildTimeDashboard},
		{NamespacedResource: NamespacedResource{Namespace: "resource-2h-v5", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}, minBuildVersion: minBuildVersion, minBuildTime: minBuildTimeDashboard},
		{NamespacedResource: NamespacedResource{Namespace: "resource-2h-v6", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}, minBuildVersion: minBuildVersion, minBuildTime: minBuildTimeDashboard},

		{NamespacedResource: NamespacedResource{Namespace: "resource-recently-imported", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}, minBuildVersion: minBuildVersion, minBuildTime: minBuildTimeDashboard, lastImportTime: lastImportTime},
	}
	if diff := cmp.Diff(expected, vals, cmpopts.IgnoreFields(rebuildRequest{}, "completeChannels"), cmp.AllowUnexported(rebuildRequest{})); diff != "" {
		t.Errorf("rebuildQueue mismatch (-want +got):\n%s", diff)
	}
}

func TestRebuildIndexes(t *testing.T) {
	storage := &mockStorageBackend{}

	now := time.Now()

	search := &mockSearchBackend{
		cache: map[NamespacedResource]ResourceIndex{
			{Namespace: "idx1", Group: "group", Resource: "res"}: &MockResourceIndex{
				buildInfo: IndexBuildInfo{BuildVersion: semver.MustParse("5.0.0")},
			},

			{Namespace: "idx2", Group: "group", Resource: "res"}: &MockResourceIndex{
				buildInfo: IndexBuildInfo{BuildTime: now.Add(-2 * time.Hour)},
			},

			{Namespace: "idx3", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}: &MockResourceIndex{},
		},
	}

	supplier := &TestDocumentBuilderSupplier{
		GroupsResources: map[string]string{
			"group": "resource",
		},
	}

	opts := SearchOptions{
		Backend:   search,
		Resources: supplier,
	}

	support, err := newSearchSupport(opts, storage, nil, nil, nil, nil)
	require.NoError(t, err)
	require.NotNil(t, support)

	// Note: we can only rebuild each index once, after that it "loses" it's build info.

	t.Run("Don't rebuild if min build version is old", func(t *testing.T) {
		checkRebuildIndex(t, support, rebuildRequest{
			NamespacedResource: NamespacedResource{Namespace: "idx1", Group: "group", Resource: "res"},
			minBuildVersion:    semver.MustParse("4.5"),
		}, true, false)
	})

	t.Run("Rebuild if min build version is more recent", func(t *testing.T) {
		checkRebuildIndex(t, support, rebuildRequest{
			NamespacedResource: NamespacedResource{Namespace: "idx1", Group: "group", Resource: "res"},
			minBuildVersion:    semver.MustParse("5.5.5"),
		}, true, true)
	})

	t.Run("Don't rebuild if min build time is very old", func(t *testing.T) {
		checkRebuildIndex(t, support, rebuildRequest{
			NamespacedResource: NamespacedResource{Namespace: "idx2", Group: "group", Resource: "res"},
			minBuildTime:       now.Add(-5 * time.Hour),
		}, true, false)
	})

	t.Run("Rebuild if min build time is more recent", func(t *testing.T) {
		checkRebuildIndex(t, support, rebuildRequest{
			NamespacedResource: NamespacedResource{Namespace: "idx2", Group: "group", Resource: "res"},
			minBuildTime:       now.Add(-1 * time.Hour),
		}, true, true)
	})

	t.Run("Don't rebuild if index doesn't exist.", func(t *testing.T) {
		checkRebuildIndex(t, support, rebuildRequest{
			NamespacedResource: NamespacedResource{Namespace: "unknown", Group: "group", Resource: "res"},
			minBuildTime:       now.Add(-5 * time.Hour),
		}, false, true)
	})

	t.Run("Rebuild dashboard index (it has no build info), verify that builders cache was emptied.", func(t *testing.T) {
		dashKey := NamespacedResource{Namespace: "idx3", Group: "group", Resource: dashboardv1.DASHBOARD_RESOURCE}

		support.builders.ns.Add(dashKey, &MockDocumentBuilder{})
		_, ok := support.builders.ns.Get(dashKey)
		require.True(t, ok)

		checkRebuildIndex(t, support, rebuildRequest{
			NamespacedResource: dashKey,
			minBuildTime:       now,
		}, true, true)

		// Verify that builders cache was emptied.
		_, ok = support.builders.ns.Get(dashKey)
		require.False(t, ok)
	})
}

func checkRebuildIndex(t *testing.T, support *searchSupport, req rebuildRequest, indexExists, expectedRebuild bool) {
	ctx := context.Background()

	idxBefore := support.search.GetIndex(req.NamespacedResource)
	if indexExists {
		require.NotNil(t, idxBefore, "index should exist before rebuildIndex")
	} else {
		require.Nil(t, idxBefore, "index should not exist before rebuildIndex")
	}

	support.rebuildIndex(ctx, req)

	idxAfter := support.search.GetIndex(req.NamespacedResource)

	if indexExists {
		require.NotNil(t, idxAfter, "index should exist after rebuildIndex")
		if expectedRebuild {
			require.NotSame(t, idxBefore, idxAfter, "index should be rebuilt")
		} else {
			require.Same(t, idxBefore, idxAfter, "index should not be rebuilt")
		}
	} else {
		require.Nil(t, idxAfter, "index should not exist after rebuildIndex")
	}
}

func TestRebuildIndexesForResource(t *testing.T) {
	key := NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}

	storage := &mockStorageBackend{
		resourceStats: []ResourceStats{
			{NamespacedResource: key, Count: 50, ResourceVersion: 11111111},
		},
		lastImportTimes: []ResourceLastImportTime{{
			NamespacedResource: key,
			LastImportTime:     time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
		}},
	}

	search := &mockSearchBackend{}
	supplier := &TestDocumentBuilderSupplier{
		GroupsResources: map[string]string{
			"group": "resource",
		},
	}

	opts := SearchOptions{
		Backend:      search,
		Resources:    supplier,
		InitMinCount: 1,
	}

	support, err := newSearchSupport(opts, storage, nil, nil, nil, nil)
	require.NoError(t, err)
	require.NotNil(t, support)

	err = support.init(t.Context())
	require.NoError(t, err)

	require.Equal(t, 0, support.rebuildQueue.Len())

	// invalid request
	rebuildReq := &resourcepb.RebuildIndexesRequest{
		Namespace: "some-other-namespace",
		Keys: []*resourcepb.ResourceKey{{
			Namespace: key.Namespace,
			Group:     key.Group,
			Resource:  key.Resource,
		}}}
	rsp, err := support.RebuildIndexes(t.Context(), rebuildReq)
	require.NoError(t, err)
	require.Equal(t, "key namespace does not match request namespace", rsp.Error.Message)

	rebuildReq.Namespace = key.Namespace

	// cached index info
	search.cache[key] = &MockResourceIndex{
		buildInfo: IndexBuildInfo{BuildVersion: semver.MustParse("5.0.0"), BuildTime: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)},
	}

	// old import time will not be rebuilt
	storage.lastImportTimes = []ResourceLastImportTime{{
		NamespacedResource: key,
		LastImportTime:     time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
	}}
	rsp, err = support.RebuildIndexes(t.Context(), rebuildReq)
	require.NoError(t, err)
	require.Equal(t, int64(0), rsp.RebuildCount)
	require.Equal(t, 0, support.rebuildQueue.Len())

	// recent import time gets added to rebuild queue and processed
	storage.lastImportTimes = []ResourceLastImportTime{{
		NamespacedResource: key,
		LastImportTime:     time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
	}}

	rsp, err = support.RebuildIndexes(t.Context(), rebuildReq)
	require.NoError(t, err)
	require.Equal(t, int64(1), rsp.RebuildCount)

	// rebuild waited for rebuild queue to process
	require.Equal(t, 0, support.rebuildQueue.Len())
}
