package controller

import (
	"context"
	"encoding/json"
	"errors"
	"strings"
	"testing"
	"time"

	"dario.cat/mergo"
	cachemocks "github.com/argoproj/argo-cd/gitops-engine/pkg/cache/mocks"
	"github.com/argoproj/argo-cd/gitops-engine/pkg/health"
	synccommon "github.com/argoproj/argo-cd/gitops-engine/pkg/sync/common"
	"github.com/argoproj/argo-cd/gitops-engine/pkg/utils/kube"
	. "github.com/argoproj/argo-cd/gitops-engine/pkg/utils/testing"
	"github.com/sirupsen/logrus"
	logrustest "github.com/sirupsen/logrus/hooks/test"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	networkingv1 "k8s.io/api/networking/v1"
	rbacv1 "k8s.io/api/rbac/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"

	"github.com/argoproj/argo-cd/v3/common"
	"github.com/argoproj/argo-cd/v3/controller/testdata"
	"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
	"github.com/argoproj/argo-cd/v3/reposerver/apiclient"
	"github.com/argoproj/argo-cd/v3/test"
)

// TestCompareAppStateEmpty tests comparison when both git and live have no objects
func TestCompareAppStateEmpty(t *testing.T) {
	t.Parallel()

	app := newFakeApp()
	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.NotNil(t, compRes.syncStatus)
	assert.Equal(t, v1alpha1.SyncStatusCodeSynced, compRes.syncStatus.Status)
	assert.Empty(t, compRes.resources)
	assert.Empty(t, compRes.managedResources)
	assert.Empty(t, app.Status.Conditions)
}

// TestCompareAppStateRepoError tests the case when CompareAppState notices a repo error
func TestCompareAppStateRepoError(t *testing.T) {
	app := newFakeApp()
	ctrl := newFakeController(t.Context(), &fakeData{manifestResponses: make([]*apiclient.ManifestResponse, 3)}, errors.New("test repo error"))
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	assert.Nil(t, compRes)
	require.EqualError(t, err, ErrCompareStateRepo.Error())

	// expect to still get compare state error to as inside grace period
	compRes, err = ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	assert.Nil(t, compRes)
	require.EqualError(t, err, ErrCompareStateRepo.Error())

	time.Sleep(10 * time.Second)
	// expect to not get error as outside of grace period, but status should be unknown
	compRes, err = ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	assert.NotNil(t, compRes)
	require.NoError(t, err)
	assert.Equal(t, v1alpha1.SyncStatusCodeUnknown, compRes.syncStatus.Status)
}

// TestCompareAppStateNamespaceMetadataDiffers tests comparison when managed namespace metadata differs
func TestCompareAppStateNamespaceMetadataDiffers(t *testing.T) {
	app := newFakeApp()
	app.Spec.SyncPolicy.ManagedNamespaceMetadata = &v1alpha1.ManagedNamespaceMetadata{
		Labels: map[string]string{
			"foo": "bar",
		},
		Annotations: map[string]string{
			"foo": "bar",
		},
	}
	app.Status.OperationState = &v1alpha1.OperationState{
		SyncResult: &v1alpha1.SyncOperationResult{},
	}

	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.NotNil(t, compRes.syncStatus)
	assert.Equal(t, v1alpha1.SyncStatusCodeOutOfSync, compRes.syncStatus.Status)
	assert.Empty(t, compRes.resources)
	assert.Empty(t, compRes.managedResources)
	assert.Empty(t, app.Status.Conditions)
}

// TestCompareAppStateNamespaceMetadataDiffers tests comparison when managed namespace metadata differs to live and manifest ns
func TestCompareAppStateNamespaceMetadataDiffersToManifest(t *testing.T) {
	ns := NewNamespace()
	ns.SetName(test.FakeDestNamespace)
	ns.SetNamespace(test.FakeDestNamespace)
	ns.SetAnnotations(map[string]string{"bar": "bat"})

	app := newFakeApp()
	app.Spec.SyncPolicy.ManagedNamespaceMetadata = &v1alpha1.ManagedNamespaceMetadata{
		Labels: map[string]string{
			"foo": "bar",
		},
		Annotations: map[string]string{
			"foo": "bar",
		},
	}
	app.Status.OperationState = &v1alpha1.OperationState{
		SyncResult: &v1alpha1.SyncOperationResult{},
	}

	liveNs := ns.DeepCopy()
	liveNs.SetAnnotations(nil)

	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{toJSON(t, liveNs)},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
			kube.GetResourceKey(ns): ns,
		},
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.NotNil(t, compRes.syncStatus)
	assert.Equal(t, v1alpha1.SyncStatusCodeOutOfSync, compRes.syncStatus.Status)
	assert.Len(t, compRes.resources, 1)
	assert.Len(t, compRes.managedResources, 1)
	assert.NotNil(t, compRes.diffResultList)
	assert.Len(t, compRes.diffResultList.Diffs, 1)

	result := NewNamespace()
	require.NoError(t, json.Unmarshal(compRes.diffResultList.Diffs[0].PredictedLive, result))

	labels := result.GetLabels()
	delete(labels, "kubernetes.io/metadata.name")

	assert.Equal(t, map[string]string{}, labels)
	// Manifests override definitions in managedNamespaceMetadata
	assert.Equal(t, map[string]string{"bar": "bat"}, result.GetAnnotations())
	assert.Empty(t, app.Status.Conditions)
}

// TestCompareAppStateNamespaceMetadata tests comparison when managed namespace metadata differs to live
func TestCompareAppStateNamespaceMetadata(t *testing.T) {
	ns := NewNamespace()
	ns.SetName(test.FakeDestNamespace)
	ns.SetNamespace(test.FakeDestNamespace)
	ns.SetAnnotations(map[string]string{"bar": "bat"})

	app := newFakeApp()
	app.Spec.SyncPolicy.ManagedNamespaceMetadata = &v1alpha1.ManagedNamespaceMetadata{
		Labels: map[string]string{
			"foo": "bar",
		},
		Annotations: map[string]string{
			"foo": "bar",
		},
	}
	app.Status.OperationState = &v1alpha1.OperationState{
		SyncResult: &v1alpha1.SyncOperationResult{},
	}

	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
			kube.GetResourceKey(ns): ns,
		},
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.NotNil(t, compRes.syncStatus)
	assert.Equal(t, v1alpha1.SyncStatusCodeOutOfSync, compRes.syncStatus.Status)
	assert.Len(t, compRes.resources, 1)
	assert.Len(t, compRes.managedResources, 1)
	assert.NotNil(t, compRes.diffResultList)
	assert.Len(t, compRes.diffResultList.Diffs, 1)

	result := NewNamespace()
	require.NoError(t, json.Unmarshal(compRes.diffResultList.Diffs[0].PredictedLive, result))

	labels := result.GetLabels()
	delete(labels, "kubernetes.io/metadata.name")

	assert.Equal(t, map[string]string{"foo": "bar"}, labels)
	assert.Equal(t, map[string]string{"argocd.argoproj.io/sync-options": "ServerSideApply=true", "bar": "bat", "foo": "bar"}, result.GetAnnotations())
	assert.Empty(t, app.Status.Conditions)
}

// TestCompareAppStateNamespaceMetadataIsTheSame tests comparison when managed namespace metadata is the same
func TestCompareAppStateNamespaceMetadataIsTheSame(t *testing.T) {
	app := newFakeApp()
	app.Spec.SyncPolicy.ManagedNamespaceMetadata = &v1alpha1.ManagedNamespaceMetadata{
		Labels: map[string]string{
			"foo": "bar",
		},
		Annotations: map[string]string{
			"foo": "bar",
		},
	}
	app.Status.OperationState = &v1alpha1.OperationState{
		SyncResult: &v1alpha1.SyncOperationResult{
			ManagedNamespaceMetadata: &v1alpha1.ManagedNamespaceMetadata{
				Labels: map[string]string{
					"foo": "bar",
				},
				Annotations: map[string]string{
					"foo": "bar",
				},
			},
		},
	}

	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.NotNil(t, compRes.syncStatus)
	assert.Equal(t, v1alpha1.SyncStatusCodeSynced, compRes.syncStatus.Status)
	assert.Empty(t, compRes.resources)
	assert.Empty(t, compRes.managedResources)
	assert.Empty(t, app.Status.Conditions)
}

// TestCompareAppStateMissing tests when there is a manifest defined in the repo which doesn't exist in live
func TestCompareAppStateMissing(t *testing.T) {
	app := newFakeApp()
	data := fakeData{
		apps: []runtime.Object{app},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{PodManifest},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.NotNil(t, compRes.syncStatus)
	assert.Equal(t, v1alpha1.SyncStatusCodeOutOfSync, compRes.syncStatus.Status)
	assert.Len(t, compRes.resources, 1)
	assert.Len(t, compRes.managedResources, 1)
	assert.Empty(t, app.Status.Conditions)
}

// TestCompareAppStateExtra tests when there is an extra object in live but not defined in git
func TestCompareAppStateExtra(t *testing.T) {
	pod := NewPod()
	pod.SetNamespace(test.FakeDestNamespace)
	app := newFakeApp()
	key := kube.ResourceKey{Group: "", Kind: "Pod", Namespace: test.FakeDestNamespace, Name: app.Name}
	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
			key: pod,
		},
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.Equal(t, v1alpha1.SyncStatusCodeOutOfSync, compRes.syncStatus.Status)
	assert.Len(t, compRes.resources, 1)
	assert.Len(t, compRes.managedResources, 1)
	assert.Empty(t, app.Status.Conditions)
}

// TestCompareAppStateHook checks that hooks are detected during manifest generation, and not
// considered as part of resources when assessing Synced status
func TestCompareAppStateHook(t *testing.T) {
	pod := NewPod()
	pod.SetAnnotations(map[string]string{synccommon.AnnotationKeyHook: "PreSync"})
	podBytes, _ := json.Marshal(pod)
	app := newFakeApp()
	data := fakeData{
		apps: []runtime.Object{app},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{string(podBytes)},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.Equal(t, v1alpha1.SyncStatusCodeSynced, compRes.syncStatus.Status)
	assert.Empty(t, compRes.resources)
	assert.Empty(t, compRes.managedResources)
	assert.Len(t, compRes.reconciliationResult.Hooks, 1)
	assert.Empty(t, app.Status.Conditions)
}

// TestCompareAppStateSkipHook checks that skipped resources are detected during manifest generation, and not
// considered as part of resources when assessing Synced status
func TestCompareAppStateSkipHook(t *testing.T) {
	pod := NewPod()
	pod.SetAnnotations(map[string]string{synccommon.AnnotationKeyHook: "Skip"})
	podBytes, _ := json.Marshal(pod)
	app := newFakeApp()
	data := fakeData{
		apps: []runtime.Object{app},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{string(podBytes)},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.Equal(t, v1alpha1.SyncStatusCodeSynced, compRes.syncStatus.Status)
	assert.Len(t, compRes.resources, 1)
	assert.Len(t, compRes.managedResources, 1)
	assert.Empty(t, compRes.reconciliationResult.Hooks)
	assert.Empty(t, app.Status.Conditions)
}

// TestCompareAppStateSyncHookSyncWave tests that Sync hooks display correct SyncWave
// This is the specific case from issue #26208
func TestCompareAppStateSyncHookSyncWave(t *testing.T) {
	tests := []struct {
		name             string
		hookType         string
		syncWave         string
		expectedSyncWave int64
	}{
		{
			name:             "Sync hook with wave 2",
			hookType:         "Sync",
			syncWave:         "2",
			expectedSyncWave: 2,
		},
		{
			name:             "PreSync hook with wave 1",
			hookType:         "PreSync",
			syncWave:         "1",
			expectedSyncWave: 1,
		},
		{
			name:             "PostSync hook with negative wave",
			hookType:         "PostSync",
			syncWave:         "-1",
			expectedSyncWave: -1,
		},
		{
			name:             "Sync hook without explicit wave",
			hookType:         "Sync",
			syncWave:         "",
			expectedSyncWave: 0, // default
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			app := newFakeApp()

			// Create hook pod with annotations
			hookPod := NewPod()
			hookPod.SetNamespace(test.FakeDestNamespace)
			annot := map[string]string{
				synccommon.AnnotationKeyHook: tt.hookType,
			}
			if tt.syncWave != "" {
				annot[synccommon.AnnotationSyncWave] = tt.syncWave
			}
			hookPod.SetAnnotations(annot)

			// The hook exists in live state (already created by previous sync)
			livePod := hookPod.DeepCopy()

			data := fakeData{
				apps: []runtime.Object{app},
				manifestResponse: &apiclient.ManifestResponse{
					Manifests: []string{toJSON(t, hookPod)},
					Namespace: test.FakeDestNamespace,
					Server:    test.FakeClusterURL,
					Revision:  "abc123",
				},
				managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
					kube.GetResourceKey(livePod): livePod,
				},
			}

			ctrl := newFakeController(t.Context(), &data, nil)
			sources := []v1alpha1.ApplicationSource{app.Spec.GetSource()}
			revisions := []string{""}

			compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
			require.NoError(t, err)
			require.NotNil(t, compRes)

			// For hooks, they go into reconciliationResult.Hooks, not resources
			// But we should also check resources if the hook appears there
			for _, res := range compRes.resources {
				if res.Hook {
					assert.Equal(t, tt.expectedSyncWave, res.SyncWave,
						"Hook SyncWave should be %d but got %d", tt.expectedSyncWave, res.SyncWave)
				}
			}
		})
	}
}

func TestCompareAppStateRequireDeletion(t *testing.T) {
	obj1 := NewPod()
	obj1.SetName("my-pod-1")
	obj1.SetAnnotations(map[string]string{"argocd.argoproj.io/sync-options": "Delete=confirm"})
	obj2 := NewPod()
	obj2.SetName("my-pod-2")
	obj2.SetAnnotations(map[string]string{"argocd.argoproj.io/sync-options": "Prune=confirm"})
	obj3 := NewPod()
	obj3.SetName("my-pod-3")

	app := newFakeApp()
	data := fakeData{
		apps: []runtime.Object{app},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{toJSON(t, obj1), toJSON(t, obj2), toJSON(t, obj3)},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
			kube.GetResourceKey(obj1): obj1,
			kube.GetResourceKey(obj2): obj2,
			kube.GetResourceKey(obj3): obj3,
		},
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)

	assert.NotNil(t, compRes)
	assert.NotNil(t, compRes.syncStatus)
	assert.Equal(t, v1alpha1.SyncStatusCodeOutOfSync, compRes.syncStatus.Status)
	assert.Len(t, compRes.resources, 3)
	assert.Len(t, compRes.managedResources, 3)
	assert.Empty(t, app.Status.Conditions)

	countRequireDeletion := 0
	for _, res := range compRes.resources {
		if res.RequiresDeletionConfirmation {
			countRequireDeletion++
		}
	}
	assert.Equal(t, 2, countRequireDeletion)
}

// checks that ignore resources are detected, but excluded from status
func TestCompareAppStateCompareOptionIgnoreExtraneous(t *testing.T) {
	pod := NewPod()
	pod.SetAnnotations(map[string]string{common.AnnotationCompareOptions: "IgnoreExtraneous"})
	app := newFakeApp()
	data := fakeData{
		apps: []runtime.Object{app},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}
	ctrl := newFakeController(t.Context(), &data, nil)

	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)

	assert.NotNil(t, compRes)
	assert.Equal(t, v1alpha1.SyncStatusCodeSynced, compRes.syncStatus.Status)
	assert.Empty(t, compRes.resources)
	assert.Empty(t, compRes.managedResources)
	assert.Empty(t, app.Status.Conditions)
}

// TestCompareAppStateExtraHook tests when there is an extra _hook_ object in live but not defined in git
func TestCompareAppStateExtraHook(t *testing.T) {
	pod := NewPod()
	pod.SetAnnotations(map[string]string{synccommon.AnnotationKeyHook: "PreSync"})
	pod.SetNamespace(test.FakeDestNamespace)
	app := newFakeApp()
	key := kube.ResourceKey{Group: "", Kind: "Pod", Namespace: test.FakeDestNamespace, Name: app.Name}
	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
			key: pod,
		},
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)

	assert.NotNil(t, compRes)
	assert.Equal(t, v1alpha1.SyncStatusCodeSynced, compRes.syncStatus.Status)
	assert.Len(t, compRes.resources, 1)
	assert.Len(t, compRes.managedResources, 1)
	assert.Empty(t, compRes.reconciliationResult.Hooks)
	assert.Empty(t, app.Status.Conditions)
}

// TestAppRevisions tests that revisions are properly propagated for a single source app
func TestAppRevisionsSingleSource(t *testing.T) {
	obj1 := NewPod()
	obj1.SetNamespace(test.FakeDestNamespace)
	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{toJSON(t, obj1)},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}
	ctrl := newFakeController(t.Context(), &data, nil)

	app := newFakeApp()
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, app.Spec.GetSources(), false, false, nil, app.Spec.HasMultipleSources())
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.NotNil(t, compRes.syncStatus)
	assert.NotEmpty(t, compRes.syncStatus.Revision)
	assert.Empty(t, compRes.syncStatus.Revisions)
}

// TestAppRevisions tests that revisions are properly propagated for a multi source app
func TestAppRevisionsMultiSource(t *testing.T) {
	obj1 := NewPod()
	obj1.SetNamespace(test.FakeDestNamespace)
	data := fakeData{
		manifestResponses: []*apiclient.ManifestResponse{
			{
				Manifests: []string{toJSON(t, obj1)},
				Namespace: test.FakeDestNamespace,
				Server:    test.FakeClusterURL,
				Revision:  "abc123",
			},
			{
				Manifests: []string{toJSON(t, obj1)},
				Namespace: test.FakeDestNamespace,
				Server:    test.FakeClusterURL,
				Revision:  "def456",
			},
			{
				Manifests: []string{},
				Namespace: test.FakeDestNamespace,
				Server:    test.FakeClusterURL,
				Revision:  "ghi789",
			},
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}
	ctrl := newFakeController(t.Context(), &data, nil)

	app := newFakeMultiSourceApp()
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, app.Spec.GetSources(), false, false, nil, app.Spec.HasMultipleSources())
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.NotNil(t, compRes.syncStatus)
	assert.Empty(t, compRes.syncStatus.Revision)
	assert.Len(t, compRes.syncStatus.Revisions, 3)
	assert.Equal(t, "abc123", compRes.syncStatus.Revisions[0])
	assert.Equal(t, "def456", compRes.syncStatus.Revisions[1])
	assert.Equal(t, "ghi789", compRes.syncStatus.Revisions[2])
}

func toJSON(t *testing.T, obj *unstructured.Unstructured) string {
	t.Helper()
	data, err := json.Marshal(obj)
	require.NoError(t, err)
	return string(data)
}

func TestCompareAppStateDuplicatedNamespacedResources(t *testing.T) {
	obj1 := NewPod()
	obj1.SetNamespace(test.FakeDestNamespace)
	obj2 := NewPod()
	obj3 := NewPod()
	obj3.SetNamespace("kube-system")
	obj4 := NewPod()
	obj4.SetGenerateName("my-pod")
	obj4.SetName("")
	obj5 := NewPod()
	obj5.SetName("")
	obj5.SetGenerateName("my-pod")

	app := newFakeApp()
	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{toJSON(t, obj1), toJSON(t, obj2), toJSON(t, obj3), toJSON(t, obj4), toJSON(t, obj5)},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
			kube.GetResourceKey(obj1): obj1,
			kube.GetResourceKey(obj3): obj3,
		},
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)

	assert.NotNil(t, compRes)
	assert.Len(t, app.Status.Conditions, 1)
	assert.NotNil(t, app.Status.Conditions[0].LastTransitionTime)
	assert.Equal(t, v1alpha1.ApplicationConditionRepeatedResourceWarning, app.Status.Conditions[0].Type)
	assert.Equal(t, "Resource /Pod/fake-dest-ns/my-pod appeared 2 times among application resources.", app.Status.Conditions[0].Message)
	assert.Len(t, compRes.resources, 4)
}

func TestCompareAppStateManagedNamespaceMetadataWithLiveNsDoesNotGetPruned(t *testing.T) {
	app := newFakeApp()
	app.Spec.SyncPolicy = &v1alpha1.SyncPolicy{
		ManagedNamespaceMetadata: &v1alpha1.ManagedNamespaceMetadata{
			Labels:      nil,
			Annotations: nil,
		},
	}

	ns := NewNamespace()
	ns.SetName(test.FakeDestNamespace)
	ns.SetNamespace(test.FakeDestNamespace)
	ns.SetAnnotations(map[string]string{"argocd.argoproj.io/sync-options": "ServerSideApply=true"})

	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
			kube.GetResourceKey(ns): ns,
		},
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, []string{}, app.Spec.Sources, false, false, nil, false)
	require.NoError(t, err)

	assert.NotNil(t, compRes)
	assert.Empty(t, app.Status.Conditions)
	assert.NotNil(t, compRes)
	assert.NotNil(t, compRes.syncStatus)
	// Ensure that ns does not get pruned
	assert.NotNil(t, compRes.reconciliationResult.Target[0])
	assert.Equal(t, compRes.reconciliationResult.Target[0].GetName(), ns.GetName())
	assert.Equal(t, compRes.reconciliationResult.Target[0].GetAnnotations(), ns.GetAnnotations())
	assert.Equal(t, compRes.reconciliationResult.Target[0].GetLabels(), ns.GetLabels())
	assert.Len(t, compRes.resources, 1)
	assert.Len(t, compRes.managedResources, 1)
}

var defaultProj = v1alpha1.AppProject{
	ObjectMeta: metav1.ObjectMeta{
		Name:      "default",
		Namespace: test.FakeArgoCDNamespace,
	},
	Spec: v1alpha1.AppProjectSpec{
		SourceRepos: []string{"*"},
		Destinations: []v1alpha1.ApplicationDestination{
			{
				Server:    "*",
				Namespace: "*",
			},
		},
	},
}

// TestCompareAppStateWithManifestGeneratePath tests that it compares revisions when the manifest-generate-path annotation is set.
func TestCompareAppStateWithManifestGeneratePath(t *testing.T) {
	app := newFakeApp()
	app.SetAnnotations(map[string]string{v1alpha1.AnnotationKeyManifestGeneratePaths: "."})
	app.Status.Sync = v1alpha1.SyncStatus{
		Revision: "abc123",
		Status:   v1alpha1.SyncStatusCodeSynced,
	}

	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		updateRevisionForPathsResponse: &apiclient.UpdateRevisionForPathsResponse{},
	}

	ctrl := newFakeController(t.Context(), &data, nil)
	revisions := make([]string, 0)
	revisions = append(revisions, "abc123")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, app.Spec.GetSources(), false, false, nil, false)
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.Equal(t, v1alpha1.SyncStatusCodeSynced, compRes.syncStatus.Status)
	assert.Equal(t, "abc123", compRes.syncStatus.Revision)
}

func TestSetHealth(t *testing.T) {
	app := newFakeApp()
	deployment := kube.MustToUnstructured(&appsv1.Deployment{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "apps/v1",
			Kind:       "Deployment",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      "demo",
			Namespace: "default",
		},
	})
	ctrl := newFakeController(t.Context(), &fakeData{
		apps: []runtime.Object{app, &defaultProj},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
			kube.GetResourceKey(deployment): deployment,
		},
	}, nil)

	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)

	assert.Equal(t, health.HealthStatusHealthy, compRes.healthStatus)
}

func TestPreserveStatusTimestamp(t *testing.T) {
	timestamp := metav1.Now()
	app := newFakeAppWithHealthAndTime(health.HealthStatusHealthy, timestamp)
	deployment := kube.MustToUnstructured(&appsv1.Deployment{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "apps/v1",
			Kind:       "Deployment",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      "demo",
			Namespace: "default",
		},
	})
	ctrl := newFakeController(t.Context(), &fakeData{
		apps: []runtime.Object{app, &defaultProj},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
			kube.GetResourceKey(deployment): deployment,
		},
	}, nil)

	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)

	assert.Equal(t, health.HealthStatusHealthy, compRes.healthStatus)
}

func TestSetHealthSelfReferencedApp(t *testing.T) {
	app := newFakeApp()
	unstructuredApp := kube.MustToUnstructured(app)
	deployment := kube.MustToUnstructured(&appsv1.Deployment{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "apps/v1",
			Kind:       "Deployment",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      "demo",
			Namespace: "default",
		},
	})
	ctrl := newFakeController(t.Context(), &fakeData{
		apps: []runtime.Object{app, &defaultProj},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
			kube.GetResourceKey(deployment):      deployment,
			kube.GetResourceKey(unstructuredApp): unstructuredApp,
		},
	}, nil)

	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)

	assert.Equal(t, health.HealthStatusHealthy, compRes.healthStatus)
}

func TestSetManagedResourcesWithOrphanedResources(t *testing.T) {
	proj := defaultProj.DeepCopy()
	proj.Spec.OrphanedResources = &v1alpha1.OrphanedResourcesMonitorSettings{}

	app := newFakeApp()
	ctrl := newFakeController(t.Context(), &fakeData{
		apps: []runtime.Object{app, proj},
		namespacedResources: map[kube.ResourceKey]namespacedResource{
			kube.NewResourceKey("apps", kube.DeploymentKind, app.Namespace, "guestbook"): {
				ResourceNode: v1alpha1.ResourceNode{
					ResourceRef: v1alpha1.ResourceRef{Kind: kube.DeploymentKind, Name: "guestbook", Namespace: app.Namespace},
				},
				AppName: "",
			},
		},
	}, nil)

	tree, err := ctrl.setAppManagedResources(&v1alpha1.Cluster{Server: "test", Name: "test"}, app, &comparisonResult{managedResources: make([]managedResource, 0)})

	require.NoError(t, err)
	assert.Len(t, tree.OrphanedNodes, 1)
	assert.Equal(t, "guestbook", tree.OrphanedNodes[0].Name)
	assert.Equal(t, app.Namespace, tree.OrphanedNodes[0].Namespace)
}

func TestSetManagedResourcesWithResourcesOfAnotherApp(t *testing.T) {
	proj := defaultProj.DeepCopy()
	proj.Spec.OrphanedResources = &v1alpha1.OrphanedResourcesMonitorSettings{}

	app1 := newFakeApp()
	app1.Name = "app1"
	app2 := newFakeApp()
	app2.Name = "app2"

	ctrl := newFakeController(t.Context(), &fakeData{
		apps: []runtime.Object{app1, app2, proj},
		namespacedResources: map[kube.ResourceKey]namespacedResource{
			kube.NewResourceKey("apps", kube.DeploymentKind, app2.Namespace, "guestbook"): {
				ResourceNode: v1alpha1.ResourceNode{
					ResourceRef: v1alpha1.ResourceRef{Kind: kube.DeploymentKind, Name: "guestbook", Namespace: app2.Namespace},
				},
				AppName: "app2",
			},
		},
	}, nil)

	tree, err := ctrl.setAppManagedResources(&v1alpha1.Cluster{Server: "test", Name: "test"}, app1, &comparisonResult{managedResources: make([]managedResource, 0)})

	require.NoError(t, err)
	assert.Empty(t, tree.OrphanedNodes)
}

func TestReturnUnknownComparisonStateOnSettingLoadError(t *testing.T) {
	proj := defaultProj.DeepCopy()
	proj.Spec.OrphanedResources = &v1alpha1.OrphanedResourcesMonitorSettings{}

	app := newFakeApp()

	ctrl := newFakeController(t.Context(), &fakeData{
		apps: []runtime.Object{app, proj},
		configMapData: map[string]string{
			"resource.customizations": "invalid setting",
		},
	}, nil)

	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)

	assert.Equal(t, health.HealthStatusUnknown, compRes.healthStatus)
	assert.Equal(t, v1alpha1.SyncStatusCodeUnknown, compRes.syncStatus.Status)
}

func TestSetManagedResourcesKnownOrphanedResourceExceptions(t *testing.T) {
	proj := defaultProj.DeepCopy()
	proj.Spec.OrphanedResources = &v1alpha1.OrphanedResourcesMonitorSettings{}
	proj.Spec.SourceNamespaces = []string{"default"}

	app := newFakeApp()
	app.Namespace = "default"

	ctrl := newFakeController(t.Context(), &fakeData{
		apps: []runtime.Object{app, proj},
		namespacedResources: map[kube.ResourceKey]namespacedResource{
			kube.NewResourceKey("apps", kube.DeploymentKind, app.Namespace, "guestbook"): {
				ResourceNode: v1alpha1.ResourceNode{ResourceRef: v1alpha1.ResourceRef{Group: "apps", Kind: kube.DeploymentKind, Name: "guestbook", Namespace: app.Namespace}},
			},
			kube.NewResourceKey("", kube.ServiceAccountKind, app.Namespace, "default"): {
				ResourceNode: v1alpha1.ResourceNode{ResourceRef: v1alpha1.ResourceRef{Kind: kube.ServiceAccountKind, Name: "default", Namespace: app.Namespace}},
			},
			kube.NewResourceKey("", kube.ServiceKind, app.Namespace, "kubernetes"): {
				ResourceNode: v1alpha1.ResourceNode{ResourceRef: v1alpha1.ResourceRef{Kind: kube.ServiceAccountKind, Name: "kubernetes", Namespace: app.Namespace}},
			},
		},
	}, nil)

	tree, err := ctrl.setAppManagedResources(&v1alpha1.Cluster{Server: "test", Name: "test"}, app, &comparisonResult{managedResources: make([]managedResource, 0)})

	require.NoError(t, err)
	assert.Len(t, tree.OrphanedNodes, 1)
	assert.Equal(t, "guestbook", tree.OrphanedNodes[0].Name)
}

func Test_appStateManager_persistRevisionHistory(t *testing.T) {
	app := newFakeApp()
	ctrl := newFakeController(t.Context(), &fakeData{
		apps: []runtime.Object{app},
	}, nil)
	manager := ctrl.appStateManager.(*appStateManager)
	setRevisionHistoryLimit := func(value int) {
		if value < 0 {
			value = 0
		}
		i := int64(value)
		app.Spec.RevisionHistoryLimit = &i
	}
	addHistory := func() {
		err := manager.persistRevisionHistory(app, "my-revision", v1alpha1.ApplicationSource{}, []string{}, []v1alpha1.ApplicationSource{}, false, metav1.Time{}, v1alpha1.OperationInitiator{})
		require.NoError(t, err)
	}
	addHistory()
	assert.Len(t, app.Status.History, 1)
	addHistory()
	assert.Len(t, app.Status.History, 2)
	addHistory()
	assert.Len(t, app.Status.History, 3)
	addHistory()
	assert.Len(t, app.Status.History, 4)
	addHistory()
	assert.Len(t, app.Status.History, 5)
	addHistory()
	assert.Len(t, app.Status.History, 6)
	addHistory()
	assert.Len(t, app.Status.History, 7)
	addHistory()
	assert.Len(t, app.Status.History, 8)
	addHistory()
	assert.Len(t, app.Status.History, 9)
	addHistory()
	assert.Len(t, app.Status.History, 10)
	// default limit is 10
	addHistory()
	assert.Len(t, app.Status.History, 10)
	// increase limit
	setRevisionHistoryLimit(11)
	addHistory()
	assert.Len(t, app.Status.History, 11)
	// decrease limit
	setRevisionHistoryLimit(9)
	addHistory()
	assert.Len(t, app.Status.History, 9)

	metav1NowTime := metav1.NewTime(time.Now())
	err := manager.persistRevisionHistory(app, "my-revision", v1alpha1.ApplicationSource{}, []string{}, []v1alpha1.ApplicationSource{}, false, metav1NowTime, v1alpha1.OperationInitiator{})
	require.NoError(t, err)
	assert.Equal(t, app.Status.History.LastRevisionHistory().DeployStartedAt, &metav1NowTime)

	// negative limit to 0
	setRevisionHistoryLimit(-1)
	addHistory()
	assert.Empty(t, app.Status.History)
}

var projWithSourceIntegrity = v1alpha1.AppProject{
	ObjectMeta: metav1.ObjectMeta{
		Name:      "default",
		Namespace: test.FakeArgoCDNamespace,
	},
	Spec: v1alpha1.AppProjectSpec{
		SourceRepos: []string{"*"},
		Destinations: []v1alpha1.ApplicationDestination{
			{
				Server:    "*",
				Namespace: "*",
			},
		},
		SourceIntegrity: &v1alpha1.SourceIntegrity{
			Git: &v1alpha1.SourceIntegrityGit{
				Policies: []*v1alpha1.SourceIntegrityGitPolicy{{
					GPG: &v1alpha1.SourceIntegrityGitPolicyGPG{
						Mode: v1alpha1.SourceIntegrityGitPolicyGPGModeStrict,
						Keys: []string{"4AEE18F83AFDEB23"},
					},
				}},
			},
		},
	},
}

func TestNoSourceIntegrity(t *testing.T) {
	t.Setenv("ARGOCD_GPG_ENABLED", "true")

	{
		app := newFakeApp()
		data := fakeData{
			manifestResponse: &apiclient.ManifestResponse{
				Manifests:             []string{},
				Namespace:             test.FakeDestNamespace,
				Server:                test.FakeClusterURL,
				Revision:              "abc123",
				SourceIntegrityResult: nil, // No verification requested
				VerifyResult:          "",
			},
			managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
		}
		ctrl := newFakeController(t.Context(), &data, nil)
		sources := make([]v1alpha1.ApplicationSource, 0)
		sources = append(sources, app.Spec.GetSource())
		revisions := make([]string, 0)
		revisions = append(revisions, "")
		compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
		require.NoError(t, err)
		assert.NotNil(t, compRes)
		assert.NotNil(t, compRes.syncStatus)
		assert.Equal(t, v1alpha1.SyncStatusCodeSynced, compRes.syncStatus.Status)
		assert.Empty(t, compRes.resources)
		assert.Empty(t, compRes.managedResources)
		assert.Empty(t, app.Status.Conditions)
	}
}

func TestValidSourceIntegrity(t *testing.T) {
	t.Setenv("ARGOCD_GPG_ENABLED", "true")

	// Source integrity required, and it is valid - sync!
	{
		app := newFakeApp()
		data := fakeData{
			manifestResponse: &apiclient.ManifestResponse{
				Manifests: []string{},
				Namespace: test.FakeDestNamespace,
				Server:    test.FakeClusterURL,
				Revision:  "abc123",
				SourceIntegrityResult: &v1alpha1.SourceIntegrityCheckResult{Checks: []v1alpha1.SourceIntegrityCheckResultItem{{
					Name:     "Some/check",
					Problems: []string{}, // Valid
				}}},
			},
			managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
		}
		ctrl := newFakeController(t.Context(), &data, nil)
		sources := make([]v1alpha1.ApplicationSource, 0)
		sources = append(sources, app.Spec.GetSource())
		revisions := make([]string, 0)
		revisions = append(revisions, "")
		compRes, err := ctrl.appStateManager.CompareAppState(app, &projWithSourceIntegrity, revisions, sources, false, false, nil, false)
		require.NoError(t, err)
		assert.NotNil(t, compRes)
		assert.NotNil(t, compRes.syncStatus)
		assert.Equal(t, v1alpha1.SyncStatusCodeSynced, compRes.syncStatus.Status)
		assert.Empty(t, compRes.resources)
		assert.Empty(t, compRes.managedResources)
		assert.Empty(t, app.Status.Conditions)
	}
	// Source integrity required, not valid - do not sync
	{
		app := newFakeApp()
		data := fakeData{
			manifestResponse: &apiclient.ManifestResponse{
				Manifests: []string{},
				Namespace: test.FakeDestNamespace,
				Server:    test.FakeClusterURL,
				Revision:  "abc123",
				SourceIntegrityResult: &v1alpha1.SourceIntegrityCheckResult{Checks: []v1alpha1.SourceIntegrityCheckResultItem{{
					Name:     "Some/check",
					Problems: []string{"The thing have failed to validate!"},
				}}},
			},
			managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
		}
		ctrl := newFakeController(t.Context(), &data, nil)
		sources := make([]v1alpha1.ApplicationSource, 0)
		sources = append(sources, app.Spec.GetSource())
		revisions := make([]string, 0)
		revisions = append(revisions, "abc123")
		compRes, err := ctrl.appStateManager.CompareAppState(app, &projWithSourceIntegrity, revisions, sources, false, false, nil, false)
		require.NoError(t, err)
		assert.NotNil(t, compRes)
		assert.NotNil(t, compRes.syncStatus)
		assert.Equal(t, v1alpha1.SyncStatusCodeSynced, compRes.syncStatus.Status)
		assert.Empty(t, compRes.resources)
		assert.Empty(t, compRes.managedResources)
		require.Len(t, app.Status.Conditions, 1)
		assert.Contains(t, app.Status.Conditions[0].Message, "Some/check: The thing have failed to validate!")
	}

	// Source integrity required, unknown key - do not sync
	{
		app := newFakeApp()
		data := fakeData{
			manifestResponse: &apiclient.ManifestResponse{
				Manifests: []string{},
				Namespace: test.FakeDestNamespace,
				Server:    test.FakeClusterURL,
				Revision:  "abc123",
				SourceIntegrityResult: &v1alpha1.SourceIntegrityCheckResult{Checks: []v1alpha1.SourceIntegrityCheckResultItem{{
					Name:     "Some/check",
					Problems: []string{"The thing have failed to validate!"},
				}}},
			},
			managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
		}
		ctrl := newFakeController(t.Context(), &data, nil)
		sources := make([]v1alpha1.ApplicationSource, 0)
		sources = append(sources, app.Spec.GetSource())
		revisions := make([]string, 0)
		revisions = append(revisions, "abc123")
		compRes, err := ctrl.appStateManager.CompareAppState(app, &projWithSourceIntegrity, revisions, sources, false, false, nil, false)
		require.NoError(t, err)
		assert.NotNil(t, compRes)
		assert.NotNil(t, compRes.syncStatus)
		assert.Equal(t, v1alpha1.SyncStatusCodeSynced, compRes.syncStatus.Status)
		assert.Empty(t, compRes.resources)
		assert.Empty(t, compRes.managedResources)
		require.Len(t, app.Status.Conditions, 1)
		assert.Contains(t, app.Status.Conditions[0].Message, "The thing have failed to validate!")
	}

	t.Setenv("ARGOCD_GPG_ENABLED", "false")

	// Signature required and local manifests supplied and GPG subsystem is disabled - sync
	{
		app := newFakeApp()
		data := fakeData{
			manifestResponse: &apiclient.ManifestResponse{
				Manifests: []string{},
				Namespace: test.FakeDestNamespace,
				Server:    test.FakeClusterURL,
				Revision:  "abc123",
				SourceIntegrityResult: &v1alpha1.SourceIntegrityCheckResult{Checks: []v1alpha1.SourceIntegrityCheckResultItem{{
					Name:     "Some/check",
					Problems: []string{"The thing have failed to validate!"},
				}}},
			},
			managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
		}
		// it doesn't matter for our test whether local manifests are valid
		localManifests := []string{""}
		ctrl := newFakeController(t.Context(), &data, nil)
		sources := make([]v1alpha1.ApplicationSource, 0)
		sources = append(sources, app.Spec.GetSource())
		revisions := make([]string, 0)
		revisions = append(revisions, "abc123")
		compRes, err := ctrl.appStateManager.CompareAppState(app, &projWithSourceIntegrity, revisions, sources, false, false, localManifests, false)
		require.NoError(t, err)
		assert.NotNil(t, compRes)
		assert.NotNil(t, compRes.syncStatus)
		assert.Equal(t, v1alpha1.SyncStatusCodeSynced, compRes.syncStatus.Status)
		assert.Empty(t, compRes.resources)
		assert.Empty(t, compRes.managedResources)
		assert.Empty(t, app.Status.Conditions)
	}
}

func TestComparisonResult_GetHealthStatus(t *testing.T) {
	status := health.HealthStatusMissing
	res := comparisonResult{
		healthStatus: status,
	}

	assert.Equal(t, status, res.GetHealthStatus())
}

func TestComparisonResult_GetSyncStatus(t *testing.T) {
	status := &v1alpha1.SyncStatus{Status: v1alpha1.SyncStatusCodeOutOfSync}
	res := comparisonResult{
		syncStatus: status,
	}

	assert.Equal(t, status, res.GetSyncStatus())
}

func TestIsLiveResourceManaged(t *testing.T) {
	t.Parallel()

	managedObj := kube.MustToUnstructured(&corev1.ConfigMap{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "v1",
			Kind:       "ConfigMap",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      "configmap1",
			Namespace: "default",
			Annotations: map[string]string{
				common.AnnotationKeyAppInstance: "guestbook:/ConfigMap:default/configmap1",
			},
		},
	})
	managedObjWithLabel := kube.MustToUnstructured(&corev1.ConfigMap{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "v1",
			Kind:       "ConfigMap",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      "configmap1",
			Namespace: "default",
			Labels: map[string]string{
				common.LabelKeyAppInstance: "guestbook",
			},
		},
	})
	unmanagedObjWrongName := kube.MustToUnstructured(&corev1.ConfigMap{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "v1",
			Kind:       "ConfigMap",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      "configmap2",
			Namespace: "default",
			Annotations: map[string]string{
				common.AnnotationKeyAppInstance: "guestbook:/ConfigMap:default/configmap1",
			},
		},
	})
	unmanagedObjWrongKind := kube.MustToUnstructured(&corev1.ConfigMap{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "v1",
			Kind:       "ConfigMap",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      "configmap2",
			Namespace: "default",
			Annotations: map[string]string{
				common.AnnotationKeyAppInstance: "guestbook:/Service:default/configmap2",
			},
		},
	})
	unmanagedObjWrongGroup := kube.MustToUnstructured(&corev1.ConfigMap{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "v1",
			Kind:       "ConfigMap",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      "configmap2",
			Namespace: "default",
			Annotations: map[string]string{
				common.AnnotationKeyAppInstance: "guestbook:apps/ConfigMap:default/configmap2",
			},
		},
	})
	unmanagedObjWrongNamespace := kube.MustToUnstructured(&corev1.ConfigMap{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "v1",
			Kind:       "ConfigMap",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      "configmap2",
			Namespace: "default",
			Annotations: map[string]string{
				common.AnnotationKeyAppInstance: "guestbook:/ConfigMap:fakens/configmap2",
			},
		},
	})
	managedWrongAPIGroup := kube.MustToUnstructured(&networkingv1.Ingress{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "networking.k8s.io/v1",
			Kind:       "Ingress",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      "some-ingress",
			Namespace: "default",
			Annotations: map[string]string{
				common.AnnotationKeyAppInstance: "guestbook:extensions/Ingress:default/some-ingress",
			},
		},
	})
	ctrl := newFakeController(t.Context(), &fakeData{
		apps: []runtime.Object{app, &defaultProj},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
			kube.GetResourceKey(managedObj):                 managedObj,
			kube.GetResourceKey(unmanagedObjWrongName):      unmanagedObjWrongName,
			kube.GetResourceKey(unmanagedObjWrongKind):      unmanagedObjWrongKind,
			kube.GetResourceKey(unmanagedObjWrongGroup):     unmanagedObjWrongGroup,
			kube.GetResourceKey(unmanagedObjWrongNamespace): unmanagedObjWrongNamespace,
		},
	}, nil)

	manager := ctrl.appStateManager.(*appStateManager)
	appName := "guestbook"

	t.Run("will return true if trackingid matches the resource", func(t *testing.T) {
		// given
		t.Parallel()
		configObj := managedObj.DeepCopy()

		// then
		assert.True(t, manager.isSelfReferencedObj(managedObj, configObj, appName, v1alpha1.TrackingMethodLabel, ""))
		assert.True(t, manager.isSelfReferencedObj(managedObj, configObj, appName, v1alpha1.TrackingMethodAnnotation, ""))
	})
	t.Run("will return true if tracked with label", func(t *testing.T) {
		// given
		t.Parallel()
		configObj := managedObjWithLabel.DeepCopy()

		// then
		assert.True(t, manager.isSelfReferencedObj(managedObjWithLabel, configObj, appName, v1alpha1.TrackingMethodLabel, ""))
	})
	t.Run("will handle if trackingId has wrong resource name and config is nil", func(t *testing.T) {
		// given
		t.Parallel()

		// then
		assert.True(t, manager.isSelfReferencedObj(unmanagedObjWrongName, nil, appName, v1alpha1.TrackingMethodLabel, ""))
		assert.False(t, manager.isSelfReferencedObj(unmanagedObjWrongName, nil, appName, v1alpha1.TrackingMethodAnnotation, ""))
	})
	t.Run("will handle if trackingId has wrong resource group and config is nil", func(t *testing.T) {
		// given
		t.Parallel()

		// then
		assert.True(t, manager.isSelfReferencedObj(unmanagedObjWrongGroup, nil, appName, v1alpha1.TrackingMethodLabel, ""))
		assert.False(t, manager.isSelfReferencedObj(unmanagedObjWrongGroup, nil, appName, v1alpha1.TrackingMethodAnnotation, ""))
	})
	t.Run("will handle if trackingId has wrong kind and config is nil", func(t *testing.T) {
		// given
		t.Parallel()

		// then
		assert.True(t, manager.isSelfReferencedObj(unmanagedObjWrongKind, nil, appName, v1alpha1.TrackingMethodLabel, ""))
		assert.False(t, manager.isSelfReferencedObj(unmanagedObjWrongKind, nil, appName, v1alpha1.TrackingMethodAnnotation, ""))
	})
	t.Run("will handle if trackingId has wrong namespace and config is nil", func(t *testing.T) {
		// given
		t.Parallel()

		// then
		assert.True(t, manager.isSelfReferencedObj(unmanagedObjWrongNamespace, nil, appName, v1alpha1.TrackingMethodLabel, ""))
		assert.False(t, manager.isSelfReferencedObj(unmanagedObjWrongNamespace, nil, appName, v1alpha1.TrackingMethodAnnotationAndLabel, ""))
	})
	t.Run("will return true if live is nil", func(t *testing.T) {
		t.Parallel()
		assert.True(t, manager.isSelfReferencedObj(nil, nil, appName, v1alpha1.TrackingMethodAnnotation, ""))
	})

	t.Run("will handle upgrade in desired state APIGroup", func(t *testing.T) {
		// given
		t.Parallel()
		config := managedWrongAPIGroup.DeepCopy()
		delete(config.GetAnnotations(), common.AnnotationKeyAppInstance)

		// then
		assert.True(t, manager.isSelfReferencedObj(managedWrongAPIGroup, config, appName, v1alpha1.TrackingMethodAnnotation, ""))
	})
}

func TestUseDiffCache(t *testing.T) {
	t.Parallel()
	type fixture struct {
		testName             string
		noCache              bool
		manifestInfos        []*apiclient.ManifestResponse
		sources              []v1alpha1.ApplicationSource
		app                  *v1alpha1.Application
		manifestRevisions    []string
		statusRefreshTimeout time.Duration
		expectedUseCache     bool
		serverSideDiff       bool
	}
	manifestInfos := func(revision string) []*apiclient.ManifestResponse {
		return []*apiclient.ManifestResponse{
			{
				Manifests: []string{
					"{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"labels\":{\"app.kubernetes.io/instance\":\"httpbin\"},\"name\":\"httpbin-svc\",\"namespace\":\"httpbin\"},\"spec\":{\"ports\":[{\"name\":\"http-port\",\"port\":7777,\"targetPort\":80},{\"name\":\"test\",\"port\":333}],\"selector\":{\"app\":\"httpbin\"}}}",
					"{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"labels\":{\"app.kubernetes.io/instance\":\"httpbin\"},\"name\":\"httpbin-deployment\",\"namespace\":\"httpbin\"},\"spec\":{\"replicas\":2,\"selector\":{\"matchLabels\":{\"app\":\"httpbin\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"httpbin\"}},\"spec\":{\"containers\":[{\"image\":\"kennethreitz/httpbin\",\"imagePullPolicy\":\"Always\",\"name\":\"httpbin\",\"ports\":[{\"containerPort\":80}]}]}}}}",
				},
				Namespace:  "",
				Server:     "",
				Revision:   revision,
				SourceType: "Kustomize",
			},
		}
	}
	source := func() v1alpha1.ApplicationSource {
		return v1alpha1.ApplicationSource{
			RepoURL:        "https://some-repo.com",
			Path:           "argocd/httpbin",
			TargetRevision: "HEAD",
		}
	}
	sources := func() []v1alpha1.ApplicationSource {
		return []v1alpha1.ApplicationSource{source()}
	}

	app := func(namespace string, revision string, refresh bool, a *v1alpha1.Application) *v1alpha1.Application {
		app := &v1alpha1.Application{
			ObjectMeta: metav1.ObjectMeta{
				Name:      "httpbin",
				Namespace: namespace,
			},
			Spec: v1alpha1.ApplicationSpec{
				Source: new(source()),
				Destination: v1alpha1.ApplicationDestination{
					Server:    "https://kubernetes.default.svc",
					Namespace: "httpbin",
				},
				Project: "default",
				SyncPolicy: &v1alpha1.SyncPolicy{
					SyncOptions: []string{
						"CreateNamespace=true",
						"ServerSideApply=true",
					},
				},
			},
			Status: v1alpha1.ApplicationStatus{
				Resources: []v1alpha1.ResourceStatus{},
				Sync: v1alpha1.SyncStatus{
					Status: v1alpha1.SyncStatusCodeSynced,
					ComparedTo: v1alpha1.ComparedTo{
						Source: source(),
						Destination: v1alpha1.ApplicationDestination{
							Server:    "https://kubernetes.default.svc",
							Namespace: "httpbin",
						},
					},
					Revision:  revision,
					Revisions: []string{},
				},
				ReconciledAt: &metav1.Time{
					Time: time.Now().Add(-time.Hour),
				},
			},
		}
		if refresh {
			annotations := make(map[string]string)
			annotations[v1alpha1.AnnotationKeyRefresh] = string(v1alpha1.RefreshTypeNormal)
			app.SetAnnotations(annotations)
		}
		if a != nil {
			err := mergo.Merge(app, a, mergo.WithOverride, mergo.WithOverwriteWithEmptyValue)
			require.NoErrorf(t, err, "error merging app")
		}
		return app
	}
	cases := []fixture{
		{
			testName:             "will use diff cache",
			noCache:              false,
			manifestInfos:        manifestInfos("rev1"),
			sources:              sources(),
			app:                  app("httpbin", "rev1", false, nil),
			manifestRevisions:    []string{"rev1"},
			statusRefreshTimeout: time.Hour * 24,
			expectedUseCache:     true,
			serverSideDiff:       false,
		},
		{
			testName:             "will use diff cache with sync policy",
			noCache:              false,
			manifestInfos:        manifestInfos("rev1"),
			sources:              []v1alpha1.ApplicationSource{test.YamlToApplication(testdata.DiffCacheYaml).Status.Sync.ComparedTo.Source},
			app:                  test.YamlToApplication(testdata.DiffCacheYaml),
			manifestRevisions:    []string{"rev1"},
			statusRefreshTimeout: time.Hour * 24,
			expectedUseCache:     true,
			serverSideDiff:       true,
		},
		{
			testName:      "will use diff cache for multisource",
			noCache:       false,
			manifestInfos: append(manifestInfos("rev1"), manifestInfos("rev2")...),
			sources: v1alpha1.ApplicationSources{
				{
					RepoURL: "multisource repo1",
				},
				{
					RepoURL: "multisource repo2",
				},
			},
			app: app("httpbin", "", false, &v1alpha1.Application{
				Spec: v1alpha1.ApplicationSpec{
					Source: nil,
					Sources: v1alpha1.ApplicationSources{
						{
							RepoURL: "multisource repo1",
						},
						{
							RepoURL: "multisource repo2",
						},
					},
				},
				Status: v1alpha1.ApplicationStatus{
					Resources: []v1alpha1.ResourceStatus{},
					Sync: v1alpha1.SyncStatus{
						Status: v1alpha1.SyncStatusCodeSynced,
						ComparedTo: v1alpha1.ComparedTo{
							Source: v1alpha1.ApplicationSource{},
							Sources: v1alpha1.ApplicationSources{
								{
									RepoURL: "multisource repo1",
								},
								{
									RepoURL: "multisource repo2",
								},
							},
						},
						Revisions: []string{"rev1", "rev2"},
					},
					ReconciledAt: &metav1.Time{
						Time: time.Now().Add(-time.Hour),
					},
				},
			}),
			manifestRevisions:    []string{"rev1", "rev2"},
			statusRefreshTimeout: time.Hour * 24,
			expectedUseCache:     true,
			serverSideDiff:       false,
		},
		{
			testName:             "will return false if nocache is true",
			noCache:              true,
			manifestInfos:        manifestInfos("rev1"),
			sources:              sources(),
			app:                  app("httpbin", "rev1", false, nil),
			manifestRevisions:    []string{"rev1"},
			statusRefreshTimeout: time.Hour * 24,
			expectedUseCache:     false,
			serverSideDiff:       false,
		},
		{
			testName:             "will return false if requested refresh",
			noCache:              false,
			manifestInfos:        manifestInfos("rev1"),
			sources:              sources(),
			app:                  app("httpbin", "rev1", true, nil),
			manifestRevisions:    []string{"rev1"},
			statusRefreshTimeout: time.Hour * 24,
			expectedUseCache:     false,
			serverSideDiff:       false,
		},
		{
			testName:             "will return false if status expired",
			noCache:              false,
			manifestInfos:        manifestInfos("rev1"),
			sources:              sources(),
			app:                  app("httpbin", "rev1", false, nil),
			manifestRevisions:    []string{"rev1"},
			statusRefreshTimeout: time.Minute,
			expectedUseCache:     false,
			serverSideDiff:       false,
		},
		{
			testName:             "will return true if status expired and server-side diff",
			noCache:              false,
			manifestInfos:        manifestInfos("rev1"),
			sources:              sources(),
			app:                  app("httpbin", "rev1", false, nil),
			manifestRevisions:    []string{"rev1"},
			statusRefreshTimeout: time.Minute,
			expectedUseCache:     true,
			serverSideDiff:       true,
		},
		{
			testName:             "will return false if there is a new revision",
			noCache:              false,
			manifestInfos:        manifestInfos("rev1"),
			sources:              sources(),
			app:                  app("httpbin", "rev1", false, nil),
			manifestRevisions:    []string{"rev2"},
			statusRefreshTimeout: time.Hour * 24,
			expectedUseCache:     false,
			serverSideDiff:       false,
		},
		{
			testName:      "will return false if app spec repo changed",
			noCache:       false,
			manifestInfos: manifestInfos("rev1"),
			sources:       sources(),
			app: app("httpbin", "rev1", false, &v1alpha1.Application{
				Spec: v1alpha1.ApplicationSpec{
					Source: &v1alpha1.ApplicationSource{
						RepoURL: "new-repo",
					},
				},
			}),
			manifestRevisions:    []string{"rev1"},
			statusRefreshTimeout: time.Hour * 24,
			expectedUseCache:     false,
			serverSideDiff:       false,
		},
		{
			testName:      "will return false if app spec IgnoreDifferences changed",
			noCache:       false,
			manifestInfos: manifestInfos("rev1"),
			sources:       sources(),
			app: app("httpbin", "rev1", false, &v1alpha1.Application{
				Spec: v1alpha1.ApplicationSpec{
					IgnoreDifferences: []v1alpha1.ResourceIgnoreDifferences{
						{
							Group:             "app/v1",
							Kind:              "application",
							Name:              "httpbin",
							Namespace:         "httpbin",
							JQPathExpressions: []string{"."},
						},
					},
				},
			}),
			manifestRevisions:    []string{"rev1"},
			statusRefreshTimeout: time.Hour * 24,
			expectedUseCache:     false,
			serverSideDiff:       false,
		},
	}

	for _, tc := range cases {
		t.Run(tc.testName, func(t *testing.T) {
			// Given
			t.Parallel()
			logger, _ := logrustest.NewNullLogger()
			log := logrus.NewEntry(logger)
			// When
			useDiffCache := useDiffCache(tc.noCache, tc.manifestInfos, tc.sources, tc.app, tc.manifestRevisions, tc.statusRefreshTimeout, tc.serverSideDiff, log)
			// Then
			assert.Equal(t, tc.expectedUseCache, useDiffCache)
		})
	}
}

func TestCompareAppStateDefaultRevisionUpdated(t *testing.T) {
	app := newFakeApp()
	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.NotNil(t, compRes.syncStatus)
	assert.True(t, compRes.revisionsMayHaveChanges)
}

func TestCompareAppStateRevisionUpdatedWithHelmSource(t *testing.T) {
	app := newFakeMultiSourceApp()
	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}
	ctrl := newFakeController(t.Context(), &data, nil)
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, app.Spec.GetSource())
	revisions := make([]string, 0)
	revisions = append(revisions, "")
	compRes, err := ctrl.appStateManager.CompareAppState(app, &defaultProj, revisions, sources, false, false, nil, false)
	require.NoError(t, err)
	assert.NotNil(t, compRes)
	assert.NotNil(t, compRes.syncStatus)
	assert.True(t, compRes.revisionsMayHaveChanges)
}

func Test_NormalizeTargetObjects_ClusterScopeTracking(t *testing.T) {
	obj := kube.MustToUnstructured(&rbacv1.ClusterRole{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "test",
			Namespace: "test",
		},
	})
	c := &cachemocks.ClusterCache{}
	c.EXPECT().IsNamespaced(mock.Anything).Return(false, nil)
	var called bool
	_, _, err := NormalizeTargetObjects(test.FakeDestNamespace, []*unstructured.Unstructured{obj}, &resourceInfoProviderStub{}, func(u *unstructured.Unstructured) error {
		// We expect that the normalization function will call this callback with an obj that has had the namespace set
		// to empty.
		called = true
		assert.Empty(t, u.GetNamespace())
		return nil
	})
	require.NoError(t, err)
	require.True(t, called, "normalization function should have called the callback function")
}

func Test_NormalizeTargetObjects_Deduplication(t *testing.T) {
	// Create three cluster-scoped objects with the same Group/Kind/Name
	// Using cluster-scoped to work with resourceInfoProviderStub
	obj1 := kube.MustToUnstructured(&rbacv1.ClusterRole{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "rbac.authorization.k8s.io/v1",
			Kind:       "ClusterRole",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name: "my-cluster-role",
		},
		Rules: []rbacv1.PolicyRule{
			{Verbs: []string{"get"}, Resources: []string{"pods"}},
		},
	})
	obj2 := kube.MustToUnstructured(&rbacv1.ClusterRole{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "rbac.authorization.k8s.io/v1",
			Kind:       "ClusterRole",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name: "my-cluster-role",
		},
		Rules: []rbacv1.PolicyRule{
			{Verbs: []string{"list"}, Resources: []string{"pods"}},
		},
	})
	obj3 := kube.MustToUnstructured(&rbacv1.ClusterRole{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "rbac.authorization.k8s.io/v1",
			Kind:       "ClusterRole",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name: "my-cluster-role",
		},
		Rules: []rbacv1.PolicyRule{
			{Verbs: []string{"watch"}, Resources: []string{"pods"}},
		},
	})

	result, conditions, err := NormalizeTargetObjects(
		test.FakeDestNamespace,
		[]*unstructured.Unstructured{obj1, obj2, obj3},
		&resourceInfoProviderStub{},
		func(_ *unstructured.Unstructured) error {
			return nil
		},
	)

	require.NoError(t, err)
	// Should only keep the last object
	assert.Len(t, result, 1, "should deduplicate to one object")
	// Verify it's the last object by checking the rules
	rules, found, err := unstructured.NestedSlice(result[0].Object, "rules")
	require.NoError(t, err)
	require.True(t, found)
	require.Len(t, rules, 1)
	rule := rules[0].(map[string]any)
	verbs := rule["verbs"].([]any)
	assert.Equal(t, "watch", verbs[0], "should keep the last duplicate")

	// Should have one condition warning about duplication
	require.Len(t, conditions, 1, "should have one duplication warning")
	assert.Equal(t, v1alpha1.ApplicationConditionRepeatedResourceWarning, conditions[0].Type)
	assert.Contains(t, conditions[0].Message, "appeared 3 times")
	assert.Contains(t, conditions[0].Message, "rbac.authorization.k8s.io/ClusterRole//my-cluster-role")
}

func Test_NormalizeTargetObjects_GenerateName(t *testing.T) {
	// Create two objects with the same generateName
	obj1 := kube.MustToUnstructured(&corev1.Pod{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "v1",
			Kind:       "Pod",
		},
		ObjectMeta: metav1.ObjectMeta{
			GenerateName: "test-pod-",
			Namespace:    "default",
		},
	})
	obj2 := kube.MustToUnstructured(&corev1.Pod{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "v1",
			Kind:       "Pod",
		},
		ObjectMeta: metav1.ObjectMeta{
			GenerateName: "test-pod-",
			Namespace:    "default",
		},
	})

	result, conditions, err := NormalizeTargetObjects(
		test.FakeDestNamespace,
		[]*unstructured.Unstructured{obj1, obj2},
		&resourceInfoProviderStub{},
		func(_ *unstructured.Unstructured) error {
			return nil
		},
	)

	require.NoError(t, err)
	// Both objects should be present because they get synthetic names (test-pod-0, test-pod-1)
	assert.Len(t, result, 2, "should keep all objects with different synthetic names")
	assert.Empty(t, conditions, "should have no duplication warnings for generateName objects")
}

func Test_NormalizeTargetObjects_NamespacedResourceWithTracking(t *testing.T) {
	// Create a namespaced resource without namespace set
	obj := kube.MustToUnstructured(&corev1.ConfigMap{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "v1",
			Kind:       "ConfigMap",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name: "my-config",
			// No namespace specified
		},
		Data: map[string]string{"key": "value"},
	})

	trackingCalled := false
	expectedNamespace := "my-app-namespace"

	// Custom info provider that reports ConfigMaps as namespaced
	namespacedProvider := &namespacedResourceInfoProvider{namespaced: true}

	result, conditions, err := NormalizeTargetObjects(
		expectedNamespace,
		[]*unstructured.Unstructured{obj},
		namespacedProvider,
		func(u *unstructured.Unstructured) error {
			// Verify namespace was set before tracking callback
			assert.Equal(t, expectedNamespace, u.GetNamespace(), "namespace should be set before tracking callback")
			trackingCalled = true
			return nil
		},
	)

	require.NoError(t, err)
	assert.Empty(t, conditions, "should have no conditions")
	require.Len(t, result, 1, "should have one result")

	// Verify namespace was set
	assert.Equal(t, expectedNamespace, result[0].GetNamespace(), "namespace should be set on result")

	// Verify tracking callback was called
	assert.True(t, trackingCalled, "tracking callback should have been called")
}

// namespacedResourceInfoProvider is a test helper that returns a fixed namespaced status
type namespacedResourceInfoProvider struct {
	namespaced bool
}

func (n *namespacedResourceInfoProvider) IsNamespaced(_ schema.GroupKind) (bool, error) {
	return n.namespaced, nil
}

func TestCompareAppState_CallUpdateRevisionForPaths_ForOCI(t *testing.T) {
	app := newFakeApp()
	// Enable the manifest-generate-paths annotation and set a synced revision
	app.SetAnnotations(map[string]string{v1alpha1.AnnotationKeyManifestGeneratePaths: "."})
	app.Status.Sync = v1alpha1.SyncStatus{
		Revision: "abc123",
		Status:   v1alpha1.SyncStatusCodeSynced,
	}

	data := fakeData{
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		updateRevisionForPathsResponse: &apiclient.UpdateRevisionForPathsResponse{
			Changes:  false,
			Revision: "abc123",
		},
	}
	ctrl := newFakeController(t.Context(), &data, nil)

	source := app.Spec.GetSource()
	source.RepoURL = "oci://example.com/argo/argo-cd"
	sources := make([]v1alpha1.ApplicationSource, 0)
	sources = append(sources, source)

	_, _, revisionsMayHaveChanges, err := ctrl.appStateManager.GetRepoObjs(t.Context(), app, sources, "abc123", []string{"123456"}, false, false, defaultProj.EffectiveSourceIntegrity(), &defaultProj, false)
	require.NoError(t, err)
	require.False(t, revisionsMayHaveChanges)
}

func TestGetRepoObjs_CallUpdateRevisionForPaths_ForMultiSource(t *testing.T) {
	app := newFakeApp()
	// Enable the manifest-generate-paths annotation and set a synced revision
	app.SetAnnotations(map[string]string{v1alpha1.AnnotationKeyManifestGeneratePaths: "."})
	app.Status.Sync = v1alpha1.SyncStatus{
		Revision:  "abc123",
		Status:    v1alpha1.SyncStatusCodeSynced,
		Revisions: []string{"0.0.1", "resolved-abc123", "resolved-main"},
	}

	app.Spec.Sources = v1alpha1.ApplicationSources{
		{RepoURL: "oci://example.com/argo/argo-cd", TargetRevision: "0.0.1", Helm: &v1alpha1.ApplicationSourceHelm{ValueFiles: []string{"$values/my-path"}}},
		{Ref: "values", RepoURL: "https://git.test.com", TargetRevision: "abc123"},
		{TargetRevision: "main", RepoURL: "https://git.test.com", Path: "path/to/chart"},
	}

	data := fakeData{
		manifestResponses: []*apiclient.ManifestResponse{
			{
				Manifests: []string{},
				Namespace: test.FakeDestNamespace,
				Server:    test.FakeClusterURL,
				Revision:  "0.0.1",
			},
			{
				Manifests: []string{},
				Namespace: test.FakeDestNamespace,
				Server:    test.FakeClusterURL,
				Revision:  "abc123",
			},
			{
				Manifests: []string{},
				Namespace: test.FakeDestNamespace,
				Server:    test.FakeClusterURL,
				Revision:  "main",
			},
		},
		updateRevisionForPathsResponses: []*apiclient.UpdateRevisionForPathsResponse{
			{Changes: false, Revision: "0.0.1"},
			{Changes: false, Revision: "resolved-main"},
		},
	}
	ctrl := newFakeController(t.Context(), &data, nil)

	revisions := make([]string, 0)
	revisions = append(revisions, "0.0.1", "abc123", "main")

	sources := app.Spec.Sources

	_, _, revisionsMayHaveChanges, err := ctrl.appStateManager.GetRepoObjs(t.Context(), app, sources, "0.0.1", revisions, false, false, defaultProj.EffectiveSourceIntegrity(), &defaultProj, false)
	require.NoError(t, err)
	require.False(t, revisionsMayHaveChanges)
}

func Test_GetRepoObjs_HydrateToAppPathNotExist(t *testing.T) {
	t.Parallel()
	t.Run("with hydrateTo: appends waiting message", func(t *testing.T) {
		t.Parallel()

		app := newFakeApp()
		app.Spec.Source = nil
		app.Spec.SourceHydrator = &v1alpha1.SourceHydrator{
			DrySource: v1alpha1.DrySource{
				RepoURL:        "https://github.com/example/repo",
				TargetRevision: "main",
				Path:           "apps/my-app",
			},
			SyncSource: v1alpha1.SyncSource{
				TargetBranch: "env/prod",
				Path:         "env/prod/my-app",
			},
			HydrateTo: &v1alpha1.HydrateTo{
				TargetBranch: "env/prod-next",
			},
		}

		ctrl := newFakeController(t.Context(), &fakeData{manifestResponse: &apiclient.ManifestResponse{}}, errors.New("env/prod/my-app: app path does not exist"))
		source := app.Spec.GetSource()

		_, _, _, err := ctrl.appStateManager.GetRepoObjs(t.Context(), app, []v1alpha1.ApplicationSource{source}, "app", []string{""}, true, false, nil, &defaultProj, false)
		require.ErrorContains(t, err, "app path does not exist")
		require.ErrorContains(t, err, "waiting for an external process to update env/prod from env/prod-next")
	})
	t.Run("without hydrateTo: no waiting message appended", func(t *testing.T) {
		t.Parallel()

		app := newFakeApp()
		app.Spec.Source = nil
		app.Spec.SourceHydrator = &v1alpha1.SourceHydrator{
			DrySource: v1alpha1.DrySource{
				RepoURL:        "https://github.com/example/repo",
				TargetRevision: "main",
				Path:           "apps/my-app",
			},
			SyncSource: v1alpha1.SyncSource{
				TargetBranch: "env/prod",
				Path:         "env/prod/my-app",
			},
		}

		ctrl := newFakeController(t.Context(), &fakeData{manifestResponse: &apiclient.ManifestResponse{}}, errors.New("env/prod/my-app: app path does not exist"))
		source := app.Spec.GetSource()

		_, _, _, err := ctrl.appStateManager.GetRepoObjs(t.Context(), app, []v1alpha1.ApplicationSource{source}, "app", []string{""}, true, false, nil, &defaultProj, false)
		require.ErrorContains(t, err, "app path does not exist")
		require.NotContains(t, err.Error(), "waiting for an external process")
	})
}

func Test_isObjRequiresDeletionConfirmation(t *testing.T) {
	for _, tt := range []struct {
		name                string
		resourceSyncOptions []string
		appSyncOptions      []string
		expected            bool
	}{
		{
			name:     "default",
			expected: false,
		},
		{
			name:                "confirm delete resource",
			resourceSyncOptions: []string{"Delete=confirm"},
			expected:            true,
		},
		{
			name:           "confirm delete app",
			appSyncOptions: []string{"Delete=confirm"},
			expected:       true,
		},
		{
			name:           "confirm prune resource",
			appSyncOptions: []string{"Prune=confirm"},
			expected:       true,
		},
		{
			name:                "confirm app & resource delete",
			appSyncOptions:      []string{"Delete=confirm"},
			resourceSyncOptions: []string{"Delete=confirm"},
			expected:            true,
		},
		{
			name:                "confirm app & resource override",
			appSyncOptions:      []string{"Delete=confirm"},
			resourceSyncOptions: []string{"Delete=foo"},
			expected:            false,
		},
		{
			name:                "confirm app & resource mixed delete and prune",
			appSyncOptions:      []string{"Prune=confirm"},
			resourceSyncOptions: []string{"Delete=confirm"},
			expected:            true,
		},
		{
			name:                "override prune resource",
			appSyncOptions:      []string{"Prune=confirm"},
			resourceSyncOptions: []string{"Prune=foo"},
			expected:            false,
		},
		{
			name:                "override delete resource and additional delete confirm",
			appSyncOptions:      []string{"Delete=confirm", "Prune=confirm"},
			resourceSyncOptions: []string{"Delete=foo"},
			expected:            true,
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			obj := NewPod()
			obj.SetAnnotations(map[string]string{"argocd.argoproj.io/sync-options": strings.Join(tt.resourceSyncOptions, ",")})

			app := newFakeApp()
			app.Spec.SyncPolicy.SyncOptions = tt.appSyncOptions

			require.Equal(t, tt.expected, isObjRequiresDeletionConfirmation(obj, app))
		})
	}
}

func Test_EvaluateAppRevisionsChanges(t *testing.T) {
	t.Parallel()

	testCases := []struct {
		name                      string
		app                       *v1alpha1.Application
		sources                   []v1alpha1.ApplicationSource
		revisions                 []string
		data                      fakeData
		sendRuntimeState          bool
		expectedHasChanges        bool
		expectedResolvedRevisions []string
	}{
		{
			name: "single source with changes (no annotation)",
			app: func() *v1alpha1.Application {
				app := newFakeApp()
				app.Status.Sync.Revision = "abc123"
				return app
			}(),
			sources: func() []v1alpha1.ApplicationSource {
				app := newFakeApp()
				return []v1alpha1.ApplicationSource{app.Spec.GetSource()}
			}(),
			revisions:                 []string{"def456"},
			data:                      fakeData{},
			sendRuntimeState:          false,
			expectedHasChanges:        true,
			expectedResolvedRevisions: []string{"def456"},
		},
		{
			name: "multiple sources with changes (no annotation)",
			app: func() *v1alpha1.Application {
				app := newFakeMultiSourceApp()
				app.Status.Sync.Revisions = []string{"abc123", "xyz789"}
				return app
			}(),
			sources: []v1alpha1.ApplicationSource{
				{RepoURL: "https://github.com/test/repo1", Path: "path1", TargetRevision: "main"},
				{RepoURL: "https://github.com/test/repo2", Path: "path2", TargetRevision: "main"},
			},
			revisions:                 []string{"abc123", "new-sha"},
			data:                      fakeData{},
			sendRuntimeState:          false,
			expectedHasChanges:        true,
			expectedResolvedRevisions: []string{"abc123", "new-sha"},
		},
		{
			name: "single source without changes (no annotation)",
			app: func() *v1alpha1.Application {
				app := newFakeApp()
				app.Status.Sync.Revision = "abc123"
				return app
			}(),
			sources: func() []v1alpha1.ApplicationSource {
				app := newFakeApp()
				return []v1alpha1.ApplicationSource{app.Spec.GetSource()}
			}(),
			revisions:                 []string{"def456"},
			data:                      fakeData{},
			sendRuntimeState:          false,
			expectedHasChanges:        true,
			expectedResolvedRevisions: []string{"def456"},
		},
		{
			name: "single source without changes (with annotation)",
			app: func() *v1alpha1.Application {
				app := newFakeApp()
				app.Annotations = map[string]string{
					v1alpha1.AnnotationKeyManifestGeneratePaths: ".",
				}
				app.Status.Sync.Revision = "abc123"
				return app
			}(),
			sources: func() []v1alpha1.ApplicationSource {
				app := newFakeApp()
				return []v1alpha1.ApplicationSource{app.Spec.GetSource()}
			}(),
			revisions: []string{"def456"},
			data: fakeData{
				updateRevisionForPathsResponse: &apiclient.UpdateRevisionForPathsResponse{
					Revision: "def456",
					Changes:  false,
				},
			},
			sendRuntimeState:          false,
			expectedHasChanges:        false,
			expectedResolvedRevisions: []string{"def456"},
		},
		{
			name: "with send runtime state",
			app: func() *v1alpha1.Application {
				app := newFakeApp()
				app.Status.Sync.Revision = "abc123"
				return app
			}(),
			sources: func() []v1alpha1.ApplicationSource {
				app := newFakeApp()
				return []v1alpha1.ApplicationSource{app.Spec.GetSource()}
			}(),
			revisions:                 []string{"def456"},
			data:                      fakeData{},
			sendRuntimeState:          true,
			expectedHasChanges:        true,
			expectedResolvedRevisions: []string{"def456"},
		},
		{
			name: "dry source with annotation uses UpdateRevisionForPaths",
			app: func() *v1alpha1.Application {
				app := newFakeApp()
				app.Annotations = map[string]string{
					v1alpha1.AnnotationKeyManifestGeneratePaths: ".",
				}
				app.Spec.SourceHydrator = &v1alpha1.SourceHydrator{
					DrySource: v1alpha1.DrySource{
						RepoURL:        app.Spec.Source.RepoURL,
						TargetRevision: app.Spec.Source.TargetRevision,
						Path:           app.Spec.Source.Path,
					},
					SyncSource: v1alpha1.SyncSource{
						TargetBranch: "hydrated",
						Path:         "hydrated/path",
					},
				}
				app.Status.SourceHydrator.LastComparedDryRevision = "old-dry-sha"
				app.Status.Sync.Revision = "should-not-be-used"
				return app
			}(),
			sources: func() []v1alpha1.ApplicationSource {
				app := newFakeApp()
				app.Spec.SourceHydrator = &v1alpha1.SourceHydrator{
					DrySource: v1alpha1.DrySource{
						RepoURL:        app.Spec.Source.RepoURL,
						TargetRevision: app.Spec.Source.TargetRevision,
						Path:           app.Spec.Source.Path,
					},
				}
				drySource := app.Spec.SourceHydrator.GetDrySource()
				return []v1alpha1.ApplicationSource{drySource}
			}(),
			revisions: []string{"new-dry-sha"},
			data: fakeData{
				updateRevisionForPathsResponse: &apiclient.UpdateRevisionForPathsResponse{
					Revision: "new-dry-sha",
					Changes:  true,
				},
			},
			sendRuntimeState:          false,
			expectedHasChanges:        true,
			expectedResolvedRevisions: []string{"new-dry-sha"},
		},
		{
			name: "dry source without annotation uses ResolveRevision",
			app: func() *v1alpha1.Application {
				app := newFakeApp()
				// No annotation set
				app.Spec.SourceHydrator = &v1alpha1.SourceHydrator{
					DrySource: v1alpha1.DrySource{
						RepoURL:        app.Spec.Source.RepoURL,
						TargetRevision: "HEAD",
						Path:           app.Spec.Source.Path,
					},
					SyncSource: v1alpha1.SyncSource{
						TargetBranch: "hydrated",
						Path:         "hydrated/path",
					},
				}
				app.Status.SourceHydrator.LastComparedDryRevision = "old-dry-sha"
				return app
			}(),
			sources: func() []v1alpha1.ApplicationSource {
				app := newFakeApp()
				app.Spec.SourceHydrator = &v1alpha1.SourceHydrator{
					DrySource: v1alpha1.DrySource{
						RepoURL:        app.Spec.Source.RepoURL,
						TargetRevision: "HEAD",
						Path:           app.Spec.Source.Path,
					},
				}
				drySource := app.Spec.SourceHydrator.GetDrySource()
				return []v1alpha1.ApplicationSource{drySource}
			}(),
			revisions: []string{"HEAD"},
			data: fakeData{
				resolveRevisionResponses: []*apiclient.ResolveRevisionResponse{
					{
						Revision:          "old-dry-sha",
						AmbiguousRevision: "HEAD",
					},
				},
			},
			sendRuntimeState:          false,
			expectedHasChanges:        false,
			expectedResolvedRevisions: []string{"old-dry-sha"},
		},
		{
			name: "ref source always returns false",
			app:  newFakeApp(),
			sources: []v1alpha1.ApplicationSource{
				{Ref: "ref-name"},
			},
			revisions:                 []string{"any-revision"},
			data:                      fakeData{},
			sendRuntimeState:          false,
			expectedHasChanges:        false,
			expectedResolvedRevisions: []string{"any-revision"},
		},
		{
			name: "same revision no ref sources returns false",
			app: func() *v1alpha1.Application {
				app := newFakeApp()
				app.Status.Sync.Revision = "abc123"
				return app
			}(),
			sources: func() []v1alpha1.ApplicationSource {
				app := newFakeApp()
				return []v1alpha1.ApplicationSource{app.Spec.GetSource()}
			}(),
			revisions:                 []string{"abc123"},
			data:                      fakeData{},
			sendRuntimeState:          false,
			expectedHasChanges:        false,
			expectedResolvedRevisions: []string{"abc123"},
		},
		{
			name: "dry source with same SHA returns false",
			app: func() *v1alpha1.Application {
				app := newFakeApp()
				app.Spec.SourceHydrator = &v1alpha1.SourceHydrator{
					DrySource: v1alpha1.DrySource{
						RepoURL:        app.Spec.Source.RepoURL,
						TargetRevision: "HEAD",
						Path:           "dry-path",
					},
					SyncSource: v1alpha1.SyncSource{
						TargetBranch: "hydrated",
						Path:         "sync-path",
					},
				}
				app.Status.SourceHydrator.LastComparedDryRevision = "same-sha"
				return app
			}(),
			sources: func() []v1alpha1.ApplicationSource {
				app := newFakeApp()
				app.Spec.SourceHydrator = &v1alpha1.SourceHydrator{
					DrySource: v1alpha1.DrySource{
						RepoURL:        app.Spec.Source.RepoURL,
						TargetRevision: "HEAD",
						Path:           "dry-path",
					},
				}
				drySource := app.Spec.SourceHydrator.GetDrySource()
				return []v1alpha1.ApplicationSource{drySource}
			}(),
			revisions:                 []string{"same-sha"},
			data:                      fakeData{},
			sendRuntimeState:          false,
			expectedHasChanges:        false,
			expectedResolvedRevisions: []string{"same-sha"},
		},
		{
			name: "dry source with annotation and changes detected",
			app: func() *v1alpha1.Application {
				app := newFakeApp()
				app.Annotations = map[string]string{
					v1alpha1.AnnotationKeyManifestGeneratePaths: ".",
				}
				app.Spec.SourceHydrator = &v1alpha1.SourceHydrator{
					DrySource: v1alpha1.DrySource{
						RepoURL:        app.Spec.Source.RepoURL,
						TargetRevision: "HEAD",
						Path:           "dry-path",
					},
					SyncSource: v1alpha1.SyncSource{
						TargetBranch: "hydrated",
						Path:         "sync-path",
					},
				}
				app.Status.SourceHydrator.LastComparedDryRevision = "old-dry-sha"
				return app
			}(),
			sources: func() []v1alpha1.ApplicationSource {
				app := newFakeApp()
				app.Spec.SourceHydrator = &v1alpha1.SourceHydrator{
					DrySource: v1alpha1.DrySource{
						RepoURL:        app.Spec.Source.RepoURL,
						TargetRevision: "HEAD",
						Path:           "dry-path",
					},
				}
				drySource := app.Spec.SourceHydrator.GetDrySource()
				return []v1alpha1.ApplicationSource{drySource}
			}(),
			revisions: []string{"HEAD"},
			data: fakeData{
				updateRevisionForPathsResponse: &apiclient.UpdateRevisionForPathsResponse{
					Revision: "new-dry-sha-resolved",
					Changes:  true,
				},
			},
			sendRuntimeState:          false,
			expectedHasChanges:        true,
			expectedResolvedRevisions: []string{"new-dry-sha-resolved"},
		},
		{
			name: "dry source with annotation but no changes",
			app: func() *v1alpha1.Application {
				app := newFakeApp()
				app.Annotations = map[string]string{
					v1alpha1.AnnotationKeyManifestGeneratePaths: ".",
				}
				app.Spec.SourceHydrator = &v1alpha1.SourceHydrator{
					DrySource: v1alpha1.DrySource{
						RepoURL:        app.Spec.Source.RepoURL,
						TargetRevision: "HEAD",
						Path:           "dry-path",
					},
					SyncSource: v1alpha1.SyncSource{
						TargetBranch: "hydrated",
						Path:         "sync-path",
					},
				}
				app.Status.SourceHydrator.LastComparedDryRevision = "old-dry-sha"
				return app
			}(),
			sources: func() []v1alpha1.ApplicationSource {
				app := newFakeApp()
				app.Spec.SourceHydrator = &v1alpha1.SourceHydrator{
					DrySource: v1alpha1.DrySource{
						RepoURL:        app.Spec.Source.RepoURL,
						TargetRevision: "HEAD",
						Path:           "dry-path",
					},
				}
				drySource := app.Spec.SourceHydrator.GetDrySource()
				return []v1alpha1.ApplicationSource{drySource}
			}(),
			revisions: []string{"HEAD"},
			data: fakeData{
				updateRevisionForPathsResponse: &apiclient.UpdateRevisionForPathsResponse{
					Revision: "new-dry-sha-resolved",
					Changes:  false,
				},
			},
			sendRuntimeState:          false,
			expectedHasChanges:        false,
			expectedResolvedRevisions: []string{"new-dry-sha-resolved"},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			ctrl := newFakeController(t.Context(), &tc.data, nil)

			hasChanges, resolvedRevisions, err := ctrl.appStateManager.EvaluateAppRevisionsChanges(
				context.Background(),
				tc.app,
				tc.sources,
				tc.revisions,
				&defaultProj,
				tc.sendRuntimeState,
				false,
			)

			require.NoError(t, err)
			assert.Equal(t, tc.expectedHasChanges, hasChanges)
			require.Len(t, resolvedRevisions, len(tc.sources))
			assert.Equal(t, tc.expectedResolvedRevisions, resolvedRevisions)
		})
	}
}
