package controller

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strconv"
	"testing"
	"time"

	clustercache "github.com/argoproj/gitops-engine/pkg/cache"
	"github.com/argoproj/gitops-engine/pkg/health"
	"github.com/argoproj/gitops-engine/pkg/utils/kube/kubetest"
	"github.com/sirupsen/logrus"
	"github.com/stretchr/testify/require"
	"k8s.io/apimachinery/pkg/api/resource"
	"k8s.io/apimachinery/pkg/labels"
	"k8s.io/apimachinery/pkg/util/wait"
	"k8s.io/client-go/rest"
	"k8s.io/utils/ptr"

	"github.com/argoproj/argo-cd/v3/common"
	statecache "github.com/argoproj/argo-cd/v3/controller/cache"
	"github.com/argoproj/argo-cd/v3/controller/sharding"

	"github.com/argoproj/gitops-engine/pkg/cache/mocks"
	synccommon "github.com/argoproj/gitops-engine/pkg/sync/common"
	"github.com/argoproj/gitops-engine/pkg/utils/kube"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/client-go/kubernetes/fake"
	kubetesting "k8s.io/client-go/testing"
	"k8s.io/client-go/tools/cache"
	"sigs.k8s.io/yaml"

	dbmocks "github.com/argoproj/argo-cd/v3/util/db/mocks"

	mockcommitclient "github.com/argoproj/argo-cd/v3/commitserver/apiclient/mocks"
	mockstatecache "github.com/argoproj/argo-cd/v3/controller/cache/mocks"
	"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
	appclientset "github.com/argoproj/argo-cd/v3/pkg/client/clientset/versioned/fake"
	"github.com/argoproj/argo-cd/v3/reposerver/apiclient"
	mockrepoclient "github.com/argoproj/argo-cd/v3/reposerver/apiclient/mocks"
	"github.com/argoproj/argo-cd/v3/test"
	"github.com/argoproj/argo-cd/v3/util/argo"
	"github.com/argoproj/argo-cd/v3/util/argo/normalizers"
	cacheutil "github.com/argoproj/argo-cd/v3/util/cache"
	appstatecache "github.com/argoproj/argo-cd/v3/util/cache/appstate"
	"github.com/argoproj/argo-cd/v3/util/settings"
	utilTest "github.com/argoproj/argo-cd/v3/util/test"
)

var testEnableEventList []string = argo.DefaultEnableEventList()

type namespacedResource struct {
	v1alpha1.ResourceNode
	AppName string
}

type fakeData struct {
	apps                           []runtime.Object
	manifestResponse               *apiclient.ManifestResponse
	manifestResponses              []*apiclient.ManifestResponse
	managedLiveObjs                map[kube.ResourceKey]*unstructured.Unstructured
	namespacedResources            map[kube.ResourceKey]namespacedResource
	configMapData                  map[string]string
	metricsCacheExpiration         time.Duration
	applicationNamespaces          []string
	updateRevisionForPathsResponse *apiclient.UpdateRevisionForPathsResponse
	additionalObjs                 []runtime.Object
}

type MockKubectl struct {
	kube.Kubectl

	DeletedResources []kube.ResourceKey
	CreatedResources []*unstructured.Unstructured
}

func (m *MockKubectl) CreateResource(ctx context.Context, config *rest.Config, gvk schema.GroupVersionKind, name string, namespace string, obj *unstructured.Unstructured, createOptions metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) {
	m.CreatedResources = append(m.CreatedResources, obj)
	return m.Kubectl.CreateResource(ctx, config, gvk, name, namespace, obj, createOptions, subresources...)
}

func (m *MockKubectl) DeleteResource(ctx context.Context, config *rest.Config, gvk schema.GroupVersionKind, name string, namespace string, deleteOptions metav1.DeleteOptions) error {
	m.DeletedResources = append(m.DeletedResources, kube.NewResourceKey(gvk.Group, gvk.Kind, namespace, name))
	return m.Kubectl.DeleteResource(ctx, config, gvk, name, namespace, deleteOptions)
}

func newFakeController(data *fakeData, repoErr error) *ApplicationController {
	return newFakeControllerWithResync(data, time.Minute, repoErr, nil)
}

func newFakeControllerWithResync(data *fakeData, appResyncPeriod time.Duration, repoErr, revisionPathsErr error) *ApplicationController {
	var clust corev1.Secret
	err := yaml.Unmarshal([]byte(fakeCluster), &clust)
	if err != nil {
		panic(err)
	}

	// Mock out call to GenerateManifest
	mockRepoClient := mockrepoclient.RepoServerServiceClient{}

	if len(data.manifestResponses) > 0 {
		for _, response := range data.manifestResponses {
			if repoErr != nil {
				mockRepoClient.On("GenerateManifest", mock.Anything, mock.Anything).Return(response, repoErr).Once()
			} else {
				mockRepoClient.On("GenerateManifest", mock.Anything, mock.Anything).Return(response, nil).Once()
			}
		}
	} else {
		if repoErr != nil {
			mockRepoClient.On("GenerateManifest", mock.Anything, mock.Anything).Return(data.manifestResponse, repoErr).Once()
		} else {
			mockRepoClient.On("GenerateManifest", mock.Anything, mock.Anything).Return(data.manifestResponse, nil).Once()
		}
	}

	if revisionPathsErr != nil {
		mockRepoClient.On("UpdateRevisionForPaths", mock.Anything, mock.Anything).Return(nil, revisionPathsErr)
	} else {
		mockRepoClient.On("UpdateRevisionForPaths", mock.Anything, mock.Anything).Return(data.updateRevisionForPathsResponse, nil)
	}

	mockRepoClientset := mockrepoclient.Clientset{RepoServerServiceClient: &mockRepoClient}

	mockCommitClientset := mockcommitclient.Clientset{}

	secret := corev1.Secret{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "argocd-secret",
			Namespace: test.FakeArgoCDNamespace,
		},
		Data: map[string][]byte{
			"admin.password":   []byte("test"),
			"server.secretkey": []byte("test"),
		},
	}
	cm := corev1.ConfigMap{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "argocd-cm",
			Namespace: test.FakeArgoCDNamespace,
			Labels: map[string]string{
				"app.kubernetes.io/part-of": "argocd",
			},
		},
		Data: data.configMapData,
	}
	runtimeObjs := []runtime.Object{&clust, &secret, &cm}
	runtimeObjs = append(runtimeObjs, data.additionalObjs...)
	kubeClient := fake.NewClientset(runtimeObjs...)
	settingsMgr := settings.NewSettingsManager(context.Background(), kubeClient, test.FakeArgoCDNamespace)
	kubectl := &MockKubectl{Kubectl: &kubetest.MockKubectlCmd{}}
	ctrl, err := NewApplicationController(
		test.FakeArgoCDNamespace,
		settingsMgr,
		kubeClient,
		appclientset.NewSimpleClientset(data.apps...),
		&mockRepoClientset,
		&mockCommitClientset,
		appstatecache.NewCache(
			cacheutil.NewCache(cacheutil.NewInMemoryCache(1*time.Minute)),
			1*time.Minute,
		),
		kubectl,
		appResyncPeriod,
		time.Hour,
		time.Second,
		time.Minute,
		nil,
		time.Minute,
		0,
		time.Second*10,
		common.DefaultPortArgoCDMetrics,
		data.metricsCacheExpiration,
		[]string{},
		[]string{},
		[]string{},
		0,
		true,
		nil,
		data.applicationNamespaces,
		nil,
		false,
		false,
		normalizers.IgnoreNormalizerOpts{},
		testEnableEventList,
		false,
	)
	db := &dbmocks.ArgoDB{}
	db.On("GetApplicationControllerReplicas").Return(1)
	// Setting a default sharding algorithm for the tests where we cannot set it.
	ctrl.clusterSharding = sharding.NewClusterSharding(db, 0, 1, common.DefaultShardingAlgorithm)
	if err != nil {
		panic(err)
	}
	cancelProj := test.StartInformer(ctrl.projInformer)
	defer cancelProj()
	cancelApp := test.StartInformer(ctrl.appInformer)
	defer cancelApp()
	clusterCacheMock := mocks.ClusterCache{}
	clusterCacheMock.On("IsNamespaced", mock.Anything).Return(true, nil)
	clusterCacheMock.On("GetOpenAPISchema").Return(nil, nil)
	clusterCacheMock.On("GetGVKParser").Return(nil)

	mockStateCache := mockstatecache.LiveStateCache{}
	ctrl.appStateManager.(*appStateManager).liveStateCache = &mockStateCache
	ctrl.stateCache = &mockStateCache
	mockStateCache.On("IsNamespaced", mock.Anything, mock.Anything).Return(true, nil)
	mockStateCache.On("GetManagedLiveObjs", mock.Anything, mock.Anything, mock.Anything).Return(data.managedLiveObjs, nil)
	mockStateCache.On("GetVersionsInfo", mock.Anything).Return("v1.2.3", nil, nil)
	response := make(map[kube.ResourceKey]v1alpha1.ResourceNode)
	for k, v := range data.namespacedResources {
		response[k] = v.ResourceNode
	}
	mockStateCache.On("GetNamespaceTopLevelResources", mock.Anything, mock.Anything).Return(response, nil)
	mockStateCache.On("IterateResources", mock.Anything, mock.Anything).Return(nil)
	mockStateCache.On("GetClusterCache", mock.Anything).Return(&clusterCacheMock, nil)
	mockStateCache.On("IterateHierarchyV2", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
		keys := args[1].([]kube.ResourceKey)
		action := args[2].(func(child v1alpha1.ResourceNode, appName string) bool)
		for _, key := range keys {
			appName := ""
			if res, ok := data.namespacedResources[key]; ok {
				appName = res.AppName
			}
			_ = action(v1alpha1.ResourceNode{ResourceRef: v1alpha1.ResourceRef{Kind: key.Kind, Group: key.Group, Namespace: key.Namespace, Name: key.Name}}, appName)
		}
	}).Return(nil)
	return ctrl
}

var fakeCluster = `
apiVersion: v1
data:
  # {"bearerToken":"fake","tlsClientConfig":{"insecure":true},"awsAuthConfig":null}
  config: eyJiZWFyZXJUb2tlbiI6ImZha2UiLCJ0bHNDbGllbnRDb25maWciOnsiaW5zZWN1cmUiOnRydWV9LCJhd3NBdXRoQ29uZmlnIjpudWxsfQ==
  # minikube
  name: bWluaWt1YmU=
  # https://localhost:6443
  server: aHR0cHM6Ly9sb2NhbGhvc3Q6NjQ0Mw==
kind: Secret
metadata:
  labels:
    argocd.argoproj.io/secret-type: cluster
  name: some-secret
  namespace: ` + test.FakeArgoCDNamespace + `
type: Opaque
`

var fakeApp = `
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  uid: "123"
  name: my-app
  namespace: ` + test.FakeArgoCDNamespace + `
spec:
  destination:
    namespace: ` + test.FakeDestNamespace + `
    server: https://localhost:6443
  project: default
  source:
    path: some/path
    repoURL: https://github.com/argoproj/argocd-example-apps.git
  syncPolicy:
    automated: {}
status:
  operationState:
    finishedAt: 2018-09-21T23:50:29Z
    message: successfully synced
    operation:
      sync:
        revision: HEAD
    phase: Succeeded
    startedAt: 2018-09-21T23:50:25Z
    syncResult:
      resources:
      - kind: RoleBinding
        message: |-
          rolebinding.rbac.authorization.k8s.io/always-outofsync reconciled
          rolebinding.rbac.authorization.k8s.io/always-outofsync configured
        name: always-outofsync
        namespace: default
        status: Synced
      revision: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
      source:
        path: some/path
        repoURL: https://github.com/argoproj/argocd-example-apps.git
`

var fakeMultiSourceApp = `
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  uid: "123"
  name: my-app
  namespace: ` + test.FakeArgoCDNamespace + `
spec:
  destination:
    namespace: ` + test.FakeDestNamespace + `
    server: https://localhost:6443
  project: default
  sources:
  - path: some/path
    helm:
      valueFiles:
      - $values_test/values.yaml
    repoURL: https://github.com/argoproj/argocd-example-apps.git
  - path: some/other/path
    repoURL: https://github.com/argoproj/argocd-example-apps-fake.git
  - ref: values_test
    repoURL: https://github.com/argoproj/argocd-example-apps-fake-ref.git
  syncPolicy:
    automated: {}
status:
  operationState:
    finishedAt: 2018-09-21T23:50:29Z
    message: successfully synced
    operation:
      sync:
        revisions:
        - HEAD
        - HEAD
        - HEAD
    phase: Succeeded
    startedAt: 2018-09-21T23:50:25Z
    syncResult:
      resources:
      - kind: RoleBinding
        message: |-
          rolebinding.rbac.authorization.k8s.io/always-outofsync reconciled
          rolebinding.rbac.authorization.k8s.io/always-outofsync configured
        name: always-outofsync
        namespace: default
        status: Synced
      revisions:
      - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
      - bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
      - cccccccccccccccccccccccccccccccccccccccc
      sources:
      - path: some/path
        helm:
          valueFiles:
          - $values_test/values.yaml
        repoURL: https://github.com/argoproj/argocd-example-apps.git
      - path: some/other/path
        repoURL: https://github.com/argoproj/argocd-example-apps-fake.git
      - ref: values_test
        repoURL: https://github.com/argoproj/argocd-example-apps-fake-ref.git
`

var fakeAppWithDestName = `
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  uid: "123"
  name: my-app
  namespace: ` + test.FakeArgoCDNamespace + `
spec:
  destination:
    namespace: ` + test.FakeDestNamespace + `
    name: minikube
  project: default
  source:
    path: some/path
    repoURL: https://github.com/argoproj/argocd-example-apps.git
  syncPolicy:
    automated: {}
`

var fakeAppWithDestMismatch = `
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  uid: "123"
  name: my-app
  namespace: ` + test.FakeArgoCDNamespace + `
spec:
  destination:
    namespace: ` + test.FakeDestNamespace + `
    name: another-cluster
    server: https://localhost:6443
  project: default
  source:
    path: some/path
    repoURL: https://github.com/argoproj/argocd-example-apps.git
  syncPolicy:
    automated: {}
`

var fakeStrayResource = `
apiVersion: v1
kind: ConfigMap
metadata:
  name: test-cm
  namespace: invalid
  labels:
    app.kubernetes.io/instance: my-app
data:
`

var fakePostDeleteHook = `
{
  "apiVersion": "batch/v1",
  "kind": "Job",
  "metadata": {
    "name": "post-delete-hook",
    "namespace": "default",
    "labels": {
      "app.kubernetes.io/instance": "my-app"
    },
    "annotations": {
      "argocd.argoproj.io/hook": "PostDelete",
      "argocd.argoproj.io/hook-delete-policy": "HookSucceeded"
    }
  },
  "spec": {
    "template": {
      "metadata": {
        "name": "post-delete-hook"
      },
      "spec": {
        "containers": [
          {
            "name": "post-delete-hook",
            "image": "busybox",
            "command": [
              "/bin/sh",
              "-c",
              "sleep 5 && echo hello from the post-delete-hook job"
            ]
          }
        ],
        "restartPolicy": "Never"
      }
    }
  }
}
`

var fakeServiceAccount = `
{
  "apiVersion": "v1",
  "kind": "ServiceAccount",
  "metadata": {
    "name": "hook-serviceaccount",
    "namespace": "default",
    "annotations": {
      "argocd.argoproj.io/hook": "PostDelete",
      "argocd.argoproj.io/hook-delete-policy": "BeforeHookCreation,HookSucceeded"
    }
  }
}
`

var fakeRole = `
{
  "apiVersion": "rbac.authorization.k8s.io/v1",
  "kind": "Role",
  "metadata": {
    "name": "hook-role",
    "namespace": "default",
    "annotations": {
      "argocd.argoproj.io/hook": "PostDelete",
      "argocd.argoproj.io/hook-delete-policy": "BeforeHookCreation,HookSucceeded"
    }
  },
  "rules": [
    {
      "apiGroups": [""],
      "resources": ["secrets"],
      "verbs": ["get", "delete", "list"]
    }
  ]
}
`

var fakeRoleBinding = `
{
  "apiVersion": "rbac.authorization.k8s.io/v1",
  "kind": "RoleBinding",
  "metadata": {
    "name": "hook-rolebinding",
    "namespace": "default",
    "annotations": {
      "argocd.argoproj.io/hook": "PostDelete",
      "argocd.argoproj.io/hook-delete-policy": "BeforeHookCreation,HookSucceeded"
    }
  },
  "roleRef": {
    "apiGroup": "rbac.authorization.k8s.io",
    "kind": "Role",
    "name": "hook-role"
  },
  "subjects": [
    {
      "kind": "ServiceAccount",
      "name": "hook-serviceaccount",
      "namespace": "default"
    }
  ]
}
`

func newFakeApp() *v1alpha1.Application {
	return createFakeApp(fakeApp)
}

func newFakeAppWithHealthAndTime(status health.HealthStatusCode, timestamp metav1.Time) *v1alpha1.Application {
	return createFakeAppWithHealthAndTime(fakeApp, status, timestamp)
}

func newFakeMultiSourceApp() *v1alpha1.Application {
	return createFakeApp(fakeMultiSourceApp)
}

func createFakeAppWithHealthAndTime(testApp string, status health.HealthStatusCode, timestamp metav1.Time) *v1alpha1.Application {
	app := createFakeApp(testApp)
	app.Status.Health = v1alpha1.AppHealthStatus{
		Status:             status,
		LastTransitionTime: &timestamp,
	}
	return app
}

func newFakeAppWithDestMismatch() *v1alpha1.Application {
	return createFakeApp(fakeAppWithDestMismatch)
}

func newFakeAppWithDestName() *v1alpha1.Application {
	return createFakeApp(fakeAppWithDestName)
}

func createFakeApp(testApp string) *v1alpha1.Application {
	var app v1alpha1.Application
	err := yaml.Unmarshal([]byte(testApp), &app)
	if err != nil {
		panic(err)
	}
	return &app
}

func newFakeCM() map[string]any {
	var cm map[string]any
	err := yaml.Unmarshal([]byte(fakeStrayResource), &cm)
	if err != nil {
		panic(err)
	}
	return cm
}

func newFakePostDeleteHook() map[string]any {
	var hook map[string]any
	err := yaml.Unmarshal([]byte(fakePostDeleteHook), &hook)
	if err != nil {
		panic(err)
	}
	return hook
}

func newFakeRoleBinding() map[string]any {
	var roleBinding map[string]any
	err := yaml.Unmarshal([]byte(fakeRoleBinding), &roleBinding)
	if err != nil {
		panic(err)
	}
	return roleBinding
}

func newFakeRole() map[string]any {
	var role map[string]any
	err := yaml.Unmarshal([]byte(fakeRole), &role)
	if err != nil {
		panic(err)
	}
	return role
}

func newFakeServiceAccount() map[string]any {
	var serviceAccount map[string]any
	err := yaml.Unmarshal([]byte(fakeServiceAccount), &serviceAccount)
	if err != nil {
		panic(err)
	}
	return serviceAccount
}

func TestAutoSync(t *testing.T) {
	app := newFakeApp()
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
	syncStatus := v1alpha1.SyncStatus{
		Status:   v1alpha1.SyncStatusCodeOutOfSync,
		Revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
	}
	cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{{Name: "guestbook", Kind: kube.DeploymentKind, Status: v1alpha1.SyncStatusCodeOutOfSync}}, true)
	assert.Nil(t, cond)
	app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
	require.NoError(t, err)
	assert.NotNil(t, app.Operation)
	assert.NotNil(t, app.Operation.Sync)
	assert.False(t, app.Operation.Sync.Prune)
}

func TestAutoSyncEnabledSetToTrue(t *testing.T) {
	app := newFakeApp()
	enable := true
	app.Spec.SyncPolicy.Automated = &v1alpha1.SyncPolicyAutomated{Enabled: &enable}
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
	syncStatus := v1alpha1.SyncStatus{
		Status:   v1alpha1.SyncStatusCodeOutOfSync,
		Revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
	}
	cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{{Name: "guestbook", Kind: kube.DeploymentKind, Status: v1alpha1.SyncStatusCodeOutOfSync}}, true)
	assert.Nil(t, cond)
	app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
	require.NoError(t, err)
	assert.NotNil(t, app.Operation)
	assert.NotNil(t, app.Operation.Sync)
	assert.False(t, app.Operation.Sync.Prune)
}

func TestAutoSyncMultiSourceWithoutSelfHeal(t *testing.T) {
	// Simulate OutOfSync caused by object change in cluster
	// So our Sync Revisions and SyncStatus Revisions should deep equal
	t.Run("ClusterObjectChangeShouldNotTriggerAutoSync", func(t *testing.T) {
		app := newFakeMultiSourceApp()
		app.Spec.SyncPolicy.Automated.SelfHeal = false
		app.Status.OperationState.SyncResult.Revisions = []string{"z", "x", "v"}
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
		syncStatus := v1alpha1.SyncStatus{
			Status:    v1alpha1.SyncStatusCodeOutOfSync,
			Revisions: []string{"z", "x", "v"},
		}
		cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{{Name: "guestbook-1", Kind: kube.DeploymentKind, Status: v1alpha1.SyncStatusCodeOutOfSync}}, true)
		assert.Nil(t, cond)
		app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
		require.NoError(t, err)
		assert.Nil(t, app.Operation)
	})
	t.Run("NewRevisionChangeShouldTriggerAutoSync", func(t *testing.T) {
		app := newFakeMultiSourceApp()
		app.Spec.SyncPolicy.Automated.SelfHeal = false
		app.Status.OperationState.SyncResult.Revisions = []string{"z", "x", "v"}
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
		syncStatus := v1alpha1.SyncStatus{
			Status:    v1alpha1.SyncStatusCodeOutOfSync,
			Revisions: []string{"a", "b", "c"},
		}
		cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{{Name: "guestbook-1", Kind: kube.DeploymentKind, Status: v1alpha1.SyncStatusCodeOutOfSync}}, true)
		assert.Nil(t, cond)
		app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
		require.NoError(t, err)
		assert.NotNil(t, app.Operation)
	})
}

func TestAutoSyncNotAllowEmpty(t *testing.T) {
	app := newFakeApp()
	app.Spec.SyncPolicy.Automated.Prune = true
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
	syncStatus := v1alpha1.SyncStatus{
		Status:   v1alpha1.SyncStatusCodeOutOfSync,
		Revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
	}
	cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{}, true)
	assert.NotNil(t, cond)
}

func TestAutoSyncAllowEmpty(t *testing.T) {
	app := newFakeApp()
	app.Spec.SyncPolicy.Automated.Prune = true
	app.Spec.SyncPolicy.Automated.AllowEmpty = true
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
	syncStatus := v1alpha1.SyncStatus{
		Status:   v1alpha1.SyncStatusCodeOutOfSync,
		Revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
	}
	cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{}, true)
	assert.Nil(t, cond)
}

func TestSkipAutoSync(t *testing.T) {
	// Verify we skip when we previously synced to it in our most recent history
	// Set current to 'aaaaa', desired to 'aaaa' and mark system OutOfSync
	t.Run("PreviouslySyncedToRevision", func(t *testing.T) {
		app := newFakeApp()
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
		syncStatus := v1alpha1.SyncStatus{
			Status:   v1alpha1.SyncStatusCodeOutOfSync,
			Revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
		}
		cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{}, true)
		assert.Nil(t, cond)
		app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
		require.NoError(t, err)
		assert.Nil(t, app.Operation)
	})

	// Verify we skip when we are already Synced (even if revision is different)
	t.Run("AlreadyInSyncedState", func(t *testing.T) {
		app := newFakeApp()
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
		syncStatus := v1alpha1.SyncStatus{
			Status:   v1alpha1.SyncStatusCodeSynced,
			Revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
		}
		cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{}, true)
		assert.Nil(t, cond)
		app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
		require.NoError(t, err)
		assert.Nil(t, app.Operation)
	})

	// Verify we skip when auto-sync is disabled
	t.Run("AutoSyncIsDisabled", func(t *testing.T) {
		app := newFakeApp()
		app.Spec.SyncPolicy = nil
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
		syncStatus := v1alpha1.SyncStatus{
			Status:   v1alpha1.SyncStatusCodeOutOfSync,
			Revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
		}
		cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{}, true)
		assert.Nil(t, cond)
		app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
		require.NoError(t, err)
		assert.Nil(t, app.Operation)
	})

	// Verify we skip when auto-sync is disabled
	t.Run("AutoSyncEnableFieldIsSetFalse", func(t *testing.T) {
		app := newFakeApp()
		enable := false
		app.Spec.SyncPolicy.Automated = &v1alpha1.SyncPolicyAutomated{Enabled: &enable}
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
		syncStatus := v1alpha1.SyncStatus{
			Status:   v1alpha1.SyncStatusCodeOutOfSync,
			Revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
		}
		cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{}, true)
		assert.Nil(t, cond)
		app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
		require.NoError(t, err)
		assert.Nil(t, app.Operation)
	})

	// Verify we skip when application is marked for deletion
	t.Run("ApplicationIsMarkedForDeletion", func(t *testing.T) {
		app := newFakeApp()
		now := metav1.Now()
		app.DeletionTimestamp = &now
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
		syncStatus := v1alpha1.SyncStatus{
			Status:   v1alpha1.SyncStatusCodeOutOfSync,
			Revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
		}
		cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{}, true)
		assert.Nil(t, cond)
		app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
		require.NoError(t, err)
		assert.Nil(t, app.Operation)
	})

	// Verify we skip when previous sync attempt failed and return error condition
	// Set current to 'aaaaa', desired to 'bbbbb' and add 'bbbbb' to failure history
	t.Run("PreviousSyncAttemptFailed", func(t *testing.T) {
		app := newFakeApp()
		app.Status.OperationState = &v1alpha1.OperationState{
			Operation: v1alpha1.Operation{
				Sync: &v1alpha1.SyncOperation{},
			},
			Phase: synccommon.OperationFailed,
			SyncResult: &v1alpha1.SyncOperationResult{
				Revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
				Source:   *app.Spec.Source.DeepCopy(),
			},
		}
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
		syncStatus := v1alpha1.SyncStatus{
			Status:   v1alpha1.SyncStatusCodeOutOfSync,
			Revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
		}
		cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{{Name: "guestbook", Kind: kube.DeploymentKind, Status: v1alpha1.SyncStatusCodeOutOfSync}}, true)
		assert.NotNil(t, cond)
		app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
		require.NoError(t, err)
		assert.Nil(t, app.Operation)
	})

	t.Run("PreviousSyncAttemptError", func(t *testing.T) {
		app := newFakeApp()
		app.Status.OperationState = &v1alpha1.OperationState{
			Operation: v1alpha1.Operation{
				Sync: &v1alpha1.SyncOperation{},
			},
			Phase: synccommon.OperationError,
			SyncResult: &v1alpha1.SyncOperationResult{
				Revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
				Source:   *app.Spec.Source.DeepCopy(),
			},
		}
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
		syncStatus := v1alpha1.SyncStatus{
			Status:   v1alpha1.SyncStatusCodeOutOfSync,
			Revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
		}
		cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{{Name: "guestbook", Kind: kube.DeploymentKind, Status: v1alpha1.SyncStatusCodeOutOfSync}}, true)
		assert.NotNil(t, cond)
		app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
		require.NoError(t, err)
		assert.Nil(t, app.Operation)
	})

	t.Run("NeedsToPruneResourcesOnlyButAutomatedPruneDisabled", func(t *testing.T) {
		app := newFakeApp()
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
		syncStatus := v1alpha1.SyncStatus{
			Status:   v1alpha1.SyncStatusCodeOutOfSync,
			Revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
		}
		cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{
			{Name: "guestbook", Kind: kube.DeploymentKind, Status: v1alpha1.SyncStatusCodeOutOfSync, RequiresPruning: true},
		}, true)
		assert.Nil(t, cond)
		app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
		require.NoError(t, err)
		assert.Nil(t, app.Operation)
	})
}

// TestAutoSyncIndicateError verifies we skip auto-sync and return error condition if previous sync failed
func TestAutoSyncIndicateError(t *testing.T) {
	app := newFakeApp()
	app.Spec.Source.Helm = &v1alpha1.ApplicationSourceHelm{
		Parameters: []v1alpha1.HelmParameter{
			{
				Name:  "a",
				Value: "1",
			},
		},
	}
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
	syncStatus := v1alpha1.SyncStatus{
		Status:   v1alpha1.SyncStatusCodeOutOfSync,
		Revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
	}
	app.Status.OperationState = &v1alpha1.OperationState{
		Operation: v1alpha1.Operation{
			Sync: &v1alpha1.SyncOperation{
				Source: app.Spec.Source.DeepCopy(),
			},
		},
		Phase: synccommon.OperationFailed,
		SyncResult: &v1alpha1.SyncOperationResult{
			Revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
			Source:   *app.Spec.Source.DeepCopy(),
		},
	}
	cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{{Name: "guestbook", Kind: kube.DeploymentKind, Status: v1alpha1.SyncStatusCodeOutOfSync}}, true)
	assert.NotNil(t, cond)
	app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
	require.NoError(t, err)
	assert.Nil(t, app.Operation)
}

// TestAutoSyncParameterOverrides verifies we auto-sync if revision is same but parameter overrides are different
func TestAutoSyncParameterOverrides(t *testing.T) {
	t.Run("Single source", func(t *testing.T) {
		app := newFakeApp()
		app.Spec.Source.Helm = &v1alpha1.ApplicationSourceHelm{
			Parameters: []v1alpha1.HelmParameter{
				{
					Name:  "a",
					Value: "1",
				},
			},
		}
		app.Status.OperationState = &v1alpha1.OperationState{
			Operation: v1alpha1.Operation{
				Sync: &v1alpha1.SyncOperation{
					Source: &v1alpha1.ApplicationSource{
						Helm: &v1alpha1.ApplicationSourceHelm{
							Parameters: []v1alpha1.HelmParameter{
								{
									Name:  "a",
									Value: "2", // this value changed
								},
							},
						},
					},
				},
			},
			Phase: synccommon.OperationFailed,
			SyncResult: &v1alpha1.SyncOperationResult{
				Revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
			},
		}
		syncStatus := v1alpha1.SyncStatus{
			Status:   v1alpha1.SyncStatusCodeOutOfSync,
			Revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
		}
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
		cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{{Name: "guestbook", Kind: kube.DeploymentKind, Status: v1alpha1.SyncStatusCodeOutOfSync}}, true)
		assert.Nil(t, cond)
		app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
		require.NoError(t, err)
		assert.NotNil(t, app.Operation)
	})

	t.Run("Multi sources", func(t *testing.T) {
		app := newFakeMultiSourceApp()
		app.Spec.Sources[0].Helm = &v1alpha1.ApplicationSourceHelm{
			Parameters: []v1alpha1.HelmParameter{
				{
					Name:  "a",
					Value: "1",
				},
			},
		}
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
		app.Status.OperationState.SyncResult.Revisions = []string{"z", "x", "v"}
		app.Status.OperationState.SyncResult.Sources[0].Helm = &v1alpha1.ApplicationSourceHelm{
			Parameters: []v1alpha1.HelmParameter{
				{
					Name:  "a",
					Value: "2", // this value changed
				},
			},
		}
		syncStatus := v1alpha1.SyncStatus{
			Status:    v1alpha1.SyncStatusCodeOutOfSync,
			Revisions: []string{"z", "x", "v"},
		}
		cond, _ := ctrl.autoSync(app, &syncStatus, []v1alpha1.ResourceStatus{{Name: "guestbook", Kind: kube.DeploymentKind, Status: v1alpha1.SyncStatusCodeOutOfSync}}, true)
		assert.Nil(t, cond)
		app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(test.FakeArgoCDNamespace).Get(t.Context(), "my-app", metav1.GetOptions{})
		require.NoError(t, err)
		assert.NotNil(t, app.Operation)
	})
}

// TestFinalizeAppDeletion verifies application deletion
func TestFinalizeAppDeletion(t *testing.T) {
	now := metav1.Now()
	defaultProj := v1alpha1.AppProject{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "default",
			Namespace: test.FakeArgoCDNamespace,
		},
		Spec: v1alpha1.AppProjectSpec{
			SourceRepos: []string{"*"},
			Destinations: []v1alpha1.ApplicationDestination{
				{
					Server:    "*",
					Namespace: "*",
				},
			},
		},
	}

	// Ensure app can be deleted cascading
	t.Run("CascadingDelete", func(t *testing.T) {
		app := newFakeApp()
		app.SetCascadedDeletion(v1alpha1.ResourcesFinalizerName)
		app.DeletionTimestamp = &now
		app.Spec.Destination.Namespace = test.FakeArgoCDNamespace
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app, &defaultProj}, managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{}}, nil)
		patched := false
		fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
		defaultReactor := fakeAppCs.ReactionChain[0]
		fakeAppCs.ReactionChain = nil
		fakeAppCs.AddReactor("get", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			return defaultReactor.React(action)
		})
		fakeAppCs.AddReactor("patch", "*", func(_ kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			patched = true
			return true, &v1alpha1.Application{}, nil
		})
		err := ctrl.finalizeApplicationDeletion(app, func(_ string) ([]*v1alpha1.Cluster, error) {
			return []*v1alpha1.Cluster{}, nil
		})
		require.NoError(t, err)
		assert.True(t, patched)
	})

	// Ensure any stray resources irregularly labeled with instance label of app are not deleted upon deleting,
	// when app project restriction is in place
	t.Run("ProjectRestrictionEnforced", func(t *testing.T) {
		restrictedProj := v1alpha1.AppProject{
			ObjectMeta: metav1.ObjectMeta{
				Name:      "restricted",
				Namespace: test.FakeArgoCDNamespace,
			},
			Spec: v1alpha1.AppProjectSpec{
				SourceRepos: []string{"*"},
				Destinations: []v1alpha1.ApplicationDestination{
					{
						Server:    "*",
						Namespace: "my-app",
					},
				},
			},
		}
		app := newFakeApp()
		app.SetCascadedDeletion(v1alpha1.ResourcesFinalizerName)
		app.DeletionTimestamp = &now
		app.Spec.Destination.Namespace = test.FakeArgoCDNamespace
		app.Spec.Project = "restricted"
		appObj := kube.MustToUnstructured(&app)
		cm := newFakeCM()
		strayObj := kube.MustToUnstructured(&cm)
		ctrl := newFakeController(&fakeData{
			apps: []runtime.Object{app, &defaultProj, &restrictedProj},
			managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
				kube.GetResourceKey(appObj):   appObj,
				kube.GetResourceKey(strayObj): strayObj,
			},
		}, nil)

		patched := false
		fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
		defaultReactor := fakeAppCs.ReactionChain[0]
		fakeAppCs.ReactionChain = nil
		fakeAppCs.AddReactor("get", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			return defaultReactor.React(action)
		})
		fakeAppCs.AddReactor("patch", "*", func(_ kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			patched = true
			return true, &v1alpha1.Application{}, nil
		})
		err := ctrl.finalizeApplicationDeletion(app, func(_ string) ([]*v1alpha1.Cluster, error) {
			return []*v1alpha1.Cluster{}, nil
		})
		require.NoError(t, err)
		assert.True(t, patched)
		objsMap, err := ctrl.stateCache.GetManagedLiveObjs(&v1alpha1.Cluster{Server: "test", Name: "test"}, app, []*unstructured.Unstructured{})
		if err != nil {
			require.NoError(t, err)
		}
		// Managed objects must be empty
		assert.Empty(t, objsMap)

		// Loop through all deleted objects, ensure that test-cm is none of them
		for _, o := range ctrl.kubectl.(*MockKubectl).DeletedResources {
			assert.NotEqual(t, "test-cm", o.Name)
		}
	})

	t.Run("DeleteWithDestinationClusterName", func(t *testing.T) {
		app := newFakeAppWithDestName()
		app.SetCascadedDeletion(v1alpha1.ResourcesFinalizerName)
		app.DeletionTimestamp = &now
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app, &defaultProj}, managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{}}, nil)
		patched := false
		fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
		defaultReactor := fakeAppCs.ReactionChain[0]
		fakeAppCs.ReactionChain = nil
		fakeAppCs.AddReactor("get", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			return defaultReactor.React(action)
		})
		fakeAppCs.AddReactor("patch", "*", func(_ kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			patched = true
			return true, &v1alpha1.Application{}, nil
		})
		err := ctrl.finalizeApplicationDeletion(app, func(_ string) ([]*v1alpha1.Cluster, error) {
			return []*v1alpha1.Cluster{}, nil
		})
		require.NoError(t, err)
		assert.True(t, patched)
	})

	// Create an Application with a cluster that doesn't exist
	// Ensure it can be deleted.
	t.Run("DeleteWithInvalidClusterName", func(t *testing.T) {
		appTemplate := newFakeAppWithDestName()

		testShouldDelete := func(app *v1alpha1.Application) {
			appObj := kube.MustToUnstructured(&app)
			ctrl := newFakeController(&fakeData{apps: []runtime.Object{app, &defaultProj}, managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
				kube.GetResourceKey(appObj): appObj,
			}}, nil)

			fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
			defaultReactor := fakeAppCs.ReactionChain[0]
			fakeAppCs.ReactionChain = nil
			fakeAppCs.AddReactor("get", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
				return defaultReactor.React(action)
			})
			err := ctrl.finalizeApplicationDeletion(app, func(_ string) ([]*v1alpha1.Cluster, error) {
				return []*v1alpha1.Cluster{}, nil
			})
			require.NoError(t, err)
		}

		app1 := appTemplate.DeepCopy()
		app1.Spec.Destination.Server = "https://invalid"
		testShouldDelete(app1)

		app2 := appTemplate.DeepCopy()
		app2.Spec.Destination.Name = "invalid"
		testShouldDelete(app2)

		app3 := appTemplate.DeepCopy()
		app3.Spec.Destination.Name = "invalid"
		app3.Spec.Destination.Server = "https://invalid"
		testShouldDelete(app3)
	})

	t.Run("PostDelete_HookIsCreated", func(t *testing.T) {
		app := newFakeApp()
		app.SetPostDeleteFinalizer()
		app.Spec.Destination.Namespace = test.FakeArgoCDNamespace
		ctrl := newFakeController(&fakeData{
			manifestResponses: []*apiclient.ManifestResponse{{
				Manifests: []string{fakePostDeleteHook},
			}},
			apps:            []runtime.Object{app, &defaultProj},
			managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{},
		}, nil)

		patched := false
		fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
		defaultReactor := fakeAppCs.ReactionChain[0]
		fakeAppCs.ReactionChain = nil
		fakeAppCs.AddReactor("get", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			return defaultReactor.React(action)
		})
		fakeAppCs.AddReactor("patch", "*", func(_ kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			patched = true
			return true, &v1alpha1.Application{}, nil
		})
		err := ctrl.finalizeApplicationDeletion(app, func(_ string) ([]*v1alpha1.Cluster, error) {
			return []*v1alpha1.Cluster{}, nil
		})
		require.NoError(t, err)
		// finalizer is not deleted
		assert.False(t, patched)
		// post-delete hook is created
		require.Len(t, ctrl.kubectl.(*MockKubectl).CreatedResources, 1)
		require.Equal(t, "post-delete-hook", ctrl.kubectl.(*MockKubectl).CreatedResources[0].GetName())
	})

	t.Run("PostDelete_HookIsExecuted", func(t *testing.T) {
		app := newFakeApp()
		app.SetPostDeleteFinalizer()
		app.Spec.Destination.Namespace = test.FakeArgoCDNamespace
		liveHook := &unstructured.Unstructured{Object: newFakePostDeleteHook()}
		conditions := []any{
			map[string]any{
				"type":   "Complete",
				"status": "True",
			},
		}
		require.NoError(t, unstructured.SetNestedField(liveHook.Object, conditions, "status", "conditions"))
		ctrl := newFakeController(&fakeData{
			manifestResponses: []*apiclient.ManifestResponse{{
				Manifests: []string{fakePostDeleteHook},
			}},
			apps: []runtime.Object{app, &defaultProj},
			managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
				kube.GetResourceKey(liveHook): liveHook,
			},
		}, nil)

		patched := false
		fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
		defaultReactor := fakeAppCs.ReactionChain[0]
		fakeAppCs.ReactionChain = nil
		fakeAppCs.AddReactor("get", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			return defaultReactor.React(action)
		})
		fakeAppCs.AddReactor("patch", "*", func(_ kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			patched = true
			return true, &v1alpha1.Application{}, nil
		})
		err := ctrl.finalizeApplicationDeletion(app, func(_ string) ([]*v1alpha1.Cluster, error) {
			return []*v1alpha1.Cluster{}, nil
		})
		require.NoError(t, err)
		// finalizer is removed
		assert.True(t, patched)
	})

	t.Run("PostDelete_HookIsDeleted", func(t *testing.T) {
		app := newFakeApp()
		app.SetPostDeleteFinalizer("cleanup")
		app.Spec.Destination.Namespace = test.FakeArgoCDNamespace
		liveRoleBinding := &unstructured.Unstructured{Object: newFakeRoleBinding()}
		liveRole := &unstructured.Unstructured{Object: newFakeRole()}
		liveServiceAccount := &unstructured.Unstructured{Object: newFakeServiceAccount()}
		liveHook := &unstructured.Unstructured{Object: newFakePostDeleteHook()}
		conditions := []any{
			map[string]any{
				"type":   "Complete",
				"status": "True",
			},
		}
		require.NoError(t, unstructured.SetNestedField(liveHook.Object, conditions, "status", "conditions"))
		ctrl := newFakeController(&fakeData{
			manifestResponses: []*apiclient.ManifestResponse{{
				Manifests: []string{fakeRoleBinding, fakeRole, fakeServiceAccount, fakePostDeleteHook},
			}},
			apps: []runtime.Object{app, &defaultProj},
			managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{
				kube.GetResourceKey(liveRoleBinding):    liveRoleBinding,
				kube.GetResourceKey(liveRole):           liveRole,
				kube.GetResourceKey(liveServiceAccount): liveServiceAccount,
				kube.GetResourceKey(liveHook):           liveHook,
			},
		}, nil)

		patched := false
		fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
		defaultReactor := fakeAppCs.ReactionChain[0]
		fakeAppCs.ReactionChain = nil
		fakeAppCs.AddReactor("get", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			return defaultReactor.React(action)
		})
		fakeAppCs.AddReactor("patch", "*", func(_ kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			patched = true
			return true, &v1alpha1.Application{}, nil
		})
		err := ctrl.finalizeApplicationDeletion(app, func(_ string) ([]*v1alpha1.Cluster, error) {
			return []*v1alpha1.Cluster{}, nil
		})
		require.NoError(t, err)
		// post-delete hooks are deleted
		require.Len(t, ctrl.kubectl.(*MockKubectl).DeletedResources, 4)
		deletedResources := []string{}
		for _, res := range ctrl.kubectl.(*MockKubectl).DeletedResources {
			deletedResources = append(deletedResources, res.Name)
		}
		expectedNames := []string{"hook-rolebinding", "hook-role", "hook-serviceaccount", "post-delete-hook"}
		require.ElementsMatch(t, expectedNames, deletedResources, "Deleted resources should match expected names")
		// finalizer is not removed
		assert.False(t, patched)
	})
}

func TestFinalizeAppDeletionWithImpersonation(t *testing.T) {
	type fixture struct {
		application *v1alpha1.Application
		controller  *ApplicationController
	}

	setup := func(destinationNamespace, serviceAccountName string) *fixture {
		app := newFakeApp()
		app.Status.OperationState = nil
		app.Status.History = nil
		now := metav1.Now()
		app.DeletionTimestamp = &now

		project := &v1alpha1.AppProject{
			ObjectMeta: metav1.ObjectMeta{
				Namespace: test.FakeArgoCDNamespace,
				Name:      "default",
			},
			Spec: v1alpha1.AppProjectSpec{
				SourceRepos: []string{"*"},
				Destinations: []v1alpha1.ApplicationDestination{
					{
						Server:    "*",
						Namespace: "*",
					},
				},
				DestinationServiceAccounts: []v1alpha1.ApplicationDestinationServiceAccount{
					{
						Server:                "https://localhost:6443",
						Namespace:             destinationNamespace,
						DefaultServiceAccount: serviceAccountName,
					},
				},
			},
		}

		additionalObjs := []runtime.Object{}
		if serviceAccountName != "" {
			syncServiceAccount := &corev1.ServiceAccount{
				ObjectMeta: metav1.ObjectMeta{
					Name:      serviceAccountName,
					Namespace: test.FakeDestNamespace,
				},
			}
			additionalObjs = append(additionalObjs, syncServiceAccount)
		}

		data := fakeData{
			apps: []runtime.Object{app, project},
			manifestResponse: &apiclient.ManifestResponse{
				Manifests: []string{},
				Namespace: test.FakeDestNamespace,
				Server:    "https://localhost:6443",
				Revision:  "abc123",
			},
			managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{},
			configMapData: map[string]string{
				"application.sync.impersonation.enabled": strconv.FormatBool(true),
			},
			additionalObjs: additionalObjs,
		}
		ctrl := newFakeController(&data, nil)
		return &fixture{
			application: app,
			controller:  ctrl,
		}
	}

	t.Run("no matching impersonation service account is configured", func(t *testing.T) {
		// given impersonation is enabled but no matching service account exists
		f := setup(test.FakeDestNamespace, "")

		// when
		err := f.controller.finalizeApplicationDeletion(f.application, func(_ string) ([]*v1alpha1.Cluster, error) {
			return []*v1alpha1.Cluster{}, nil
		})

		// then deletion should fail due to impersonation error
		require.Error(t, err)
		assert.Contains(t, err.Error(), "error deriving service account to impersonate")
	})

	t.Run("valid impersonation service account is configured", func(t *testing.T) {
		// given impersonation is enabled with valid service account
		f := setup(test.FakeDestNamespace, "test-sa")

		// when
		err := f.controller.finalizeApplicationDeletion(f.application, func(_ string) ([]*v1alpha1.Cluster, error) {
			return []*v1alpha1.Cluster{}, nil
		})

		// then deletion should succeed
		require.NoError(t, err)
	})

	t.Run("invalid application destination cluster", func(t *testing.T) {
		// given impersonation is enabled but destination cluster does not exist
		f := setup(test.FakeDestNamespace, "test-sa")
		f.application.Spec.Destination.Server = "https://invalid-cluster:6443"
		f.application.Spec.Destination.Name = "invalid"

		// when
		err := f.controller.finalizeApplicationDeletion(f.application, func(_ string) ([]*v1alpha1.Cluster, error) {
			return []*v1alpha1.Cluster{}, nil
		})

		// then deletion should still succeed by removing finalizers
		require.NoError(t, err)
	})
}

// TestNormalizeApplication verifies we normalize an application during reconciliation
func TestNormalizeApplication(t *testing.T) {
	defaultProj := v1alpha1.AppProject{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "default",
			Namespace: test.FakeArgoCDNamespace,
		},
		Spec: v1alpha1.AppProjectSpec{
			SourceRepos: []string{"*"},
			Destinations: []v1alpha1.ApplicationDestination{
				{
					Server:    "*",
					Namespace: "*",
				},
			},
		},
	}
	app := newFakeApp()
	app.Spec.Project = ""
	app.Spec.Source.Kustomize = &v1alpha1.ApplicationSourceKustomize{NamePrefix: "foo-"}
	data := fakeData{
		apps: []runtime.Object{app, &defaultProj},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}

	{
		// Verify we normalize the app because project is missing
		ctrl := newFakeController(&data, nil)
		key, _ := cache.MetaNamespaceKeyFunc(app)
		ctrl.appRefreshQueue.AddRateLimited(key)
		fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
		fakeAppCs.ReactionChain = nil
		normalized := false
		fakeAppCs.AddReactor("patch", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			if patchAction, ok := action.(kubetesting.PatchAction); ok {
				if string(patchAction.GetPatch()) == `{"spec":{"project":"default"}}` {
					normalized = true
				}
			}
			return true, &v1alpha1.Application{}, nil
		})
		ctrl.processAppRefreshQueueItem()
		assert.True(t, normalized)
	}

	{
		// Verify we don't unnecessarily normalize app when project is set
		app.Spec.Project = "default"
		data.apps[0] = app
		ctrl := newFakeController(&data, nil)
		key, _ := cache.MetaNamespaceKeyFunc(app)
		ctrl.appRefreshQueue.AddRateLimited(key)
		fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
		fakeAppCs.ReactionChain = nil
		normalized := false
		fakeAppCs.AddReactor("patch", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			if patchAction, ok := action.(kubetesting.PatchAction); ok {
				if string(patchAction.GetPatch()) == `{"spec":{"project":"default"},"status":{"sync":{"comparedTo":{"destination":{},"source":{"repoURL":""}}}}}` {
					normalized = true
				}
			}
			return true, &v1alpha1.Application{}, nil
		})
		ctrl.processAppRefreshQueueItem()
		assert.False(t, normalized)
	}
}

func TestHandleAppUpdated(t *testing.T) {
	app := newFakeApp()
	app.Spec.Destination.Namespace = test.FakeArgoCDNamespace
	app.Spec.Destination.Server = v1alpha1.KubernetesInternalAPIServerAddr
	proj := defaultProj.DeepCopy()
	proj.Spec.SourceNamespaces = []string{test.FakeArgoCDNamespace}
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app, proj}}, nil)

	ctrl.handleObjectUpdated(map[string]bool{app.InstanceName(ctrl.namespace): true}, kube.GetObjectRef(kube.MustToUnstructured(app)))
	isRequested, level := ctrl.isRefreshRequested(app.QualifiedName())
	assert.False(t, isRequested)
	assert.Equal(t, ComparisonWithNothing, level)

	ctrl.handleObjectUpdated(map[string]bool{app.InstanceName(ctrl.namespace): true}, corev1.ObjectReference{UID: "test", Kind: kube.DeploymentKind, Name: "test", Namespace: "default"})
	isRequested, level = ctrl.isRefreshRequested(app.QualifiedName())
	assert.True(t, isRequested)
	assert.Equal(t, CompareWithRecent, level)
}

func TestHandleOrphanedResourceUpdated(t *testing.T) {
	app1 := newFakeApp()
	app1.Name = "app1"
	app1.Spec.Destination.Namespace = test.FakeArgoCDNamespace
	app1.Spec.Destination.Server = v1alpha1.KubernetesInternalAPIServerAddr

	app2 := newFakeApp()
	app2.Name = "app2"
	app2.Spec.Destination.Namespace = test.FakeArgoCDNamespace
	app2.Spec.Destination.Server = v1alpha1.KubernetesInternalAPIServerAddr

	proj := defaultProj.DeepCopy()
	proj.Spec.OrphanedResources = &v1alpha1.OrphanedResourcesMonitorSettings{}

	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app1, app2, proj}}, nil)

	ctrl.handleObjectUpdated(map[string]bool{}, corev1.ObjectReference{UID: "test", Kind: kube.DeploymentKind, Name: "test", Namespace: test.FakeArgoCDNamespace})

	isRequested, level := ctrl.isRefreshRequested(app1.QualifiedName())
	assert.True(t, isRequested)
	assert.Equal(t, CompareWithRecent, level)

	isRequested, level = ctrl.isRefreshRequested(app2.QualifiedName())
	assert.True(t, isRequested)
	assert.Equal(t, CompareWithRecent, level)
}

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

	managedDeploy := v1alpha1.ResourceNode{
		ResourceRef: v1alpha1.ResourceRef{Group: "apps", Kind: "Deployment", Namespace: "default", Name: "nginx-deployment", Version: "v1"},
		Health: &v1alpha1.HealthStatus{
			Status: health.HealthStatusMissing,
		},
	}
	orphanedDeploy1 := v1alpha1.ResourceNode{
		ResourceRef: v1alpha1.ResourceRef{Group: "apps", Kind: "Deployment", Namespace: "default", Name: "deploy1"},
	}
	orphanedDeploy2 := v1alpha1.ResourceNode{
		ResourceRef: v1alpha1.ResourceRef{Group: "apps", Kind: "Deployment", Namespace: "default", Name: "deploy2"},
	}

	ctrl := newFakeController(&fakeData{
		apps: []runtime.Object{app, proj},
		namespacedResources: map[kube.ResourceKey]namespacedResource{
			kube.NewResourceKey("apps", "Deployment", "default", "nginx-deployment"): {ResourceNode: managedDeploy},
			kube.NewResourceKey("apps", "Deployment", "default", "deploy1"):          {ResourceNode: orphanedDeploy1},
			kube.NewResourceKey("apps", "Deployment", "default", "deploy2"):          {ResourceNode: orphanedDeploy2},
		},
	}, nil)
	tree, err := ctrl.getResourceTree(&v1alpha1.Cluster{Server: "https://localhost:6443", Name: "fake-cluster"}, app, []*v1alpha1.ResourceDiff{{
		Namespace:   "default",
		Name:        "nginx-deployment",
		Kind:        "Deployment",
		Group:       "apps",
		LiveState:   "null",
		TargetState: test.DeploymentManifest,
	}})

	require.NoError(t, err)
	assert.Equal(t, []v1alpha1.ResourceNode{managedDeploy}, tree.Nodes)
	assert.Equal(t, []v1alpha1.ResourceNode{orphanedDeploy1, orphanedDeploy2}, tree.OrphanedNodes)
}

func TestSetOperationStateOnDeletedApp(t *testing.T) {
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{}}, nil)
	fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
	fakeAppCs.ReactionChain = nil
	patched := false
	fakeAppCs.AddReactor("patch", "*", func(_ kubetesting.Action) (handled bool, ret runtime.Object, err error) {
		patched = true
		return true, &v1alpha1.Application{}, apierrors.NewNotFound(schema.GroupResource{}, "my-app")
	})
	ctrl.setOperationState(newFakeApp(), &v1alpha1.OperationState{Phase: synccommon.OperationSucceeded})
	assert.True(t, patched)
}

func TestSetOperationStateLogRetries(t *testing.T) {
	hook := utilTest.LogHook{}
	logrus.AddHook(&hook)
	t.Cleanup(func() {
		logrus.StandardLogger().ReplaceHooks(logrus.LevelHooks{})
	})
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{}}, nil)
	fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
	fakeAppCs.ReactionChain = nil
	patched := false
	fakeAppCs.AddReactor("patch", "*", func(_ kubetesting.Action) (handled bool, ret runtime.Object, err error) {
		if !patched {
			patched = true
			return true, &v1alpha1.Application{}, errors.New("fake error")
		}
		return true, &v1alpha1.Application{}, nil
	})
	ctrl.setOperationState(newFakeApp(), &v1alpha1.OperationState{Phase: synccommon.OperationSucceeded})
	assert.True(t, patched)
	require.GreaterOrEqual(t, len(hook.Entries), 1)
	entry := hook.Entries[0]
	require.Contains(t, entry.Data, "error")
	errorVal, ok := entry.Data["error"].(error)
	require.True(t, ok, "error field should be of type error")
	assert.Contains(t, errorVal.Error(), "fake error")
}

func TestNeedRefreshAppStatus(t *testing.T) {
	testCases := []struct {
		name string
		app  *v1alpha1.Application
	}{
		{
			name: "single-source app",
			app:  newFakeApp(),
		},
		{
			name: "multi-source app",
			app:  newFakeMultiSourceApp(),
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			app := tc.app
			now := metav1.Now()
			app.Status.ReconciledAt = &now

			app.Status.Sync = v1alpha1.SyncStatus{
				Status: v1alpha1.SyncStatusCodeSynced,
				ComparedTo: v1alpha1.ComparedTo{
					Destination:       app.Spec.Destination,
					IgnoreDifferences: app.Spec.IgnoreDifferences,
				},
			}

			if app.Spec.HasMultipleSources() {
				app.Status.Sync.ComparedTo.Sources = app.Spec.Sources
			} else {
				app.Status.Sync.ComparedTo.Source = app.Spec.GetSource()
			}

			ctrl := newFakeController(&fakeData{apps: []runtime.Object{}}, nil)

			t.Run("no need to refresh just reconciled application", func(t *testing.T) {
				needRefresh, _, _ := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.False(t, needRefresh)
			})

			t.Run("requested refresh is respected", func(t *testing.T) {
				needRefresh, _, _ := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.False(t, needRefresh)

				// use a one-off controller so other tests don't have a manual refresh request
				ctrl := newFakeController(&fakeData{apps: []runtime.Object{}}, nil)

				// refresh app using the 'deepest' requested comparison level
				ctrl.requestAppRefresh(app.Name, CompareWithRecent.Pointer(), nil)
				ctrl.requestAppRefresh(app.Name, ComparisonWithNothing.Pointer(), nil)

				needRefresh, refreshType, compareWith := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.True(t, needRefresh)
				assert.Equal(t, v1alpha1.RefreshTypeNormal, refreshType)
				assert.Equal(t, CompareWithRecent, compareWith)
			})

			t.Run("requesting refresh with delay gives correct compression level", func(t *testing.T) {
				needRefresh, _, _ := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.False(t, needRefresh)

				// use a one-off controller so other tests don't have a manual refresh request
				ctrl := newFakeController(&fakeData{apps: []runtime.Object{}}, nil)

				// refresh app with a non-nil delay
				// use zero-second delay to test the add later logic without waiting in the test
				delay := time.Duration(0)
				ctrl.requestAppRefresh(app.Name, CompareWithRecent.Pointer(), &delay)

				ctrl.processAppComparisonTypeQueueItem()
				needRefresh, refreshType, compareWith := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.True(t, needRefresh)
				assert.Equal(t, v1alpha1.RefreshTypeNormal, refreshType)
				assert.Equal(t, CompareWithRecent, compareWith)
			})

			t.Run("refresh application which status is not reconciled using latest commit", func(t *testing.T) {
				app := app.DeepCopy()
				needRefresh, _, _ := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.False(t, needRefresh)
				app.Status.Sync = v1alpha1.SyncStatus{Status: v1alpha1.SyncStatusCodeUnknown}

				needRefresh, refreshType, compareWith := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.True(t, needRefresh)
				assert.Equal(t, v1alpha1.RefreshTypeNormal, refreshType)
				assert.Equal(t, CompareWithLatestForceResolve, compareWith)
			})

			t.Run("refresh app using the 'latest' level if comparison expired", func(t *testing.T) {
				app := app.DeepCopy()

				// use a one-off controller so other tests don't have a manual refresh request
				ctrl := newFakeController(&fakeData{apps: []runtime.Object{}}, nil)

				needRefresh, _, _ := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.False(t, needRefresh)

				ctrl.requestAppRefresh(app.Name, CompareWithRecent.Pointer(), nil)
				reconciledAt := metav1.NewTime(time.Now().UTC().Add(-1 * time.Hour))
				app.Status.ReconciledAt = &reconciledAt
				needRefresh, refreshType, compareWith := ctrl.needRefreshAppStatus(app, 1*time.Minute, 2*time.Hour)
				assert.True(t, needRefresh)
				assert.Equal(t, v1alpha1.RefreshTypeNormal, refreshType)
				assert.Equal(t, CompareWithLatest, compareWith)
			})

			t.Run("refresh app using the 'latest' level if comparison expired for hard refresh", func(t *testing.T) {
				app := app.DeepCopy()
				app.Status.Sync = v1alpha1.SyncStatus{
					Status: v1alpha1.SyncStatusCodeSynced,
					ComparedTo: v1alpha1.ComparedTo{
						Destination:       app.Spec.Destination,
						IgnoreDifferences: app.Spec.IgnoreDifferences,
					},
				}
				if app.Spec.HasMultipleSources() {
					app.Status.Sync.ComparedTo.Sources = app.Spec.Sources
				} else {
					app.Status.Sync.ComparedTo.Source = app.Spec.GetSource()
				}

				// use a one-off controller so other tests don't have a manual refresh request
				ctrl := newFakeController(&fakeData{apps: []runtime.Object{}}, nil)

				needRefresh, _, _ := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.False(t, needRefresh)
				ctrl.requestAppRefresh(app.Name, CompareWithRecent.Pointer(), nil)
				reconciledAt := metav1.NewTime(time.Now().UTC().Add(-1 * time.Hour))
				app.Status.ReconciledAt = &reconciledAt
				needRefresh, refreshType, compareWith := ctrl.needRefreshAppStatus(app, 2*time.Hour, 1*time.Minute)
				assert.True(t, needRefresh)
				assert.Equal(t, v1alpha1.RefreshTypeHard, refreshType)
				assert.Equal(t, CompareWithLatest, compareWith)
			})

			t.Run("execute hard refresh if app has refresh annotation", func(t *testing.T) {
				app := app.DeepCopy()
				needRefresh, _, _ := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.False(t, needRefresh)
				reconciledAt := metav1.NewTime(time.Now().UTC().Add(-1 * time.Hour))
				app.Status.ReconciledAt = &reconciledAt
				app.Annotations = map[string]string{
					v1alpha1.AnnotationKeyRefresh: string(v1alpha1.RefreshTypeHard),
				}
				needRefresh, refreshType, compareWith := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.True(t, needRefresh)
				assert.Equal(t, v1alpha1.RefreshTypeHard, refreshType)
				assert.Equal(t, CompareWithLatestForceResolve, compareWith)
			})

			t.Run("ensure that CompareWithLatest level is used if application source has changed", func(t *testing.T) {
				app := app.DeepCopy()
				needRefresh, _, _ := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.False(t, needRefresh)
				// sample app source change
				if app.Spec.HasMultipleSources() {
					app.Spec.Sources[0].Helm = &v1alpha1.ApplicationSourceHelm{
						Parameters: []v1alpha1.HelmParameter{{
							Name:  "foo",
							Value: "bar",
						}},
					}
				} else {
					app.Spec.Source.Helm = &v1alpha1.ApplicationSourceHelm{
						Parameters: []v1alpha1.HelmParameter{{
							Name:  "foo",
							Value: "bar",
						}},
					}
				}

				needRefresh, refreshType, compareWith := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.True(t, needRefresh)
				assert.Equal(t, v1alpha1.RefreshTypeNormal, refreshType)
				assert.Equal(t, CompareWithLatestForceResolve, compareWith)
			})

			t.Run("ensure that CompareWithLatest level is used if ignored differences change", func(t *testing.T) {
				app := app.DeepCopy()
				needRefresh, _, _ := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.False(t, needRefresh)

				app.Spec.IgnoreDifferences = []v1alpha1.ResourceIgnoreDifferences{
					{
						Group: "apps",
						Kind:  "Deployment",
						JSONPointers: []string{
							"/spec/template/spec/containers/0/image",
						},
					},
				}

				needRefresh, refreshType, compareWith := ctrl.needRefreshAppStatus(app, 1*time.Hour, 2*time.Hour)
				assert.True(t, needRefresh)
				assert.Equal(t, v1alpha1.RefreshTypeNormal, refreshType)
				assert.Equal(t, CompareWithLatest, compareWith)
			})
		})
	}
}

func TestUpdatedManagedNamespaceMetadata(t *testing.T) {
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{}}, nil)
	app := newFakeApp()
	app.Spec.SyncPolicy.ManagedNamespaceMetadata = &v1alpha1.ManagedNamespaceMetadata{
		Labels: map[string]string{
			"foo": "bar",
		},
		Annotations: map[string]string{
			"foo": "bar",
		},
	}
	app.Status.Sync.ComparedTo.Source = app.Spec.GetSource()
	app.Status.Sync.ComparedTo.Destination = app.Spec.Destination

	// Ensure that hard/soft refresh isn't triggered due to reconciledAt being expired
	reconciledAt := metav1.NewTime(time.Now().UTC().Add(15 * time.Minute))
	app.Status.ReconciledAt = &reconciledAt
	needRefresh, refreshType, compareWith := ctrl.needRefreshAppStatus(app, 30*time.Minute, 2*time.Hour)

	assert.True(t, needRefresh)
	assert.Equal(t, v1alpha1.RefreshTypeNormal, refreshType)
	assert.Equal(t, CompareWithLatest, compareWith)
}

func TestUnchangedManagedNamespaceMetadata(t *testing.T) {
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{}}, nil)
	app := newFakeApp()
	app.Spec.SyncPolicy.ManagedNamespaceMetadata = &v1alpha1.ManagedNamespaceMetadata{
		Labels: map[string]string{
			"foo": "bar",
		},
		Annotations: map[string]string{
			"foo": "bar",
		},
	}
	app.Status.Sync.ComparedTo.Source = app.Spec.GetSource()
	app.Status.Sync.ComparedTo.Destination = app.Spec.Destination
	app.Status.OperationState.SyncResult.ManagedNamespaceMetadata = app.Spec.SyncPolicy.ManagedNamespaceMetadata

	// Ensure that hard/soft refresh isn't triggered due to reconciledAt being expired
	reconciledAt := metav1.NewTime(time.Now().UTC().Add(15 * time.Minute))
	app.Status.ReconciledAt = &reconciledAt
	needRefresh, refreshType, compareWith := ctrl.needRefreshAppStatus(app, 30*time.Minute, 2*time.Hour)

	assert.False(t, needRefresh)
	assert.Equal(t, v1alpha1.RefreshTypeNormal, refreshType)
	assert.Equal(t, CompareWithLatest, compareWith)
}

func TestRefreshAppConditions(t *testing.T) {
	defaultProj := v1alpha1.AppProject{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "default",
			Namespace: test.FakeArgoCDNamespace,
		},
		Spec: v1alpha1.AppProjectSpec{
			SourceRepos: []string{"*"},
			Destinations: []v1alpha1.ApplicationDestination{
				{
					Server:    "*",
					Namespace: "*",
				},
			},
		},
	}

	t.Run("NoErrorConditions", func(t *testing.T) {
		app := newFakeApp()
		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app, &defaultProj}}, nil)

		_, hasErrors := ctrl.refreshAppConditions(app)
		assert.False(t, hasErrors)
		assert.Empty(t, app.Status.Conditions)
	})

	t.Run("PreserveExistingWarningCondition", func(t *testing.T) {
		app := newFakeApp()
		app.Status.SetConditions([]v1alpha1.ApplicationCondition{{Type: v1alpha1.ApplicationConditionExcludedResourceWarning}}, nil)

		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app, &defaultProj}}, nil)

		_, hasErrors := ctrl.refreshAppConditions(app)
		assert.False(t, hasErrors)
		assert.Len(t, app.Status.Conditions, 1)
		assert.Equal(t, v1alpha1.ApplicationConditionExcludedResourceWarning, app.Status.Conditions[0].Type)
	})

	t.Run("ReplacesSpecErrorCondition", func(t *testing.T) {
		app := newFakeApp()
		app.Spec.Project = "wrong project"
		app.Status.SetConditions([]v1alpha1.ApplicationCondition{{Type: v1alpha1.ApplicationConditionInvalidSpecError, Message: "old message"}}, nil)

		ctrl := newFakeController(&fakeData{apps: []runtime.Object{app, &defaultProj}}, nil)

		_, hasErrors := ctrl.refreshAppConditions(app)
		assert.True(t, hasErrors)
		assert.Len(t, app.Status.Conditions, 1)
		assert.Equal(t, v1alpha1.ApplicationConditionInvalidSpecError, app.Status.Conditions[0].Type)
		assert.Equal(t, "Application referencing project wrong project which does not exist", app.Status.Conditions[0].Message)
	})
}

func TestUpdateReconciledAt(t *testing.T) {
	app := newFakeApp()
	reconciledAt := metav1.NewTime(time.Now().Add(-1 * time.Second))
	app.Status = v1alpha1.ApplicationStatus{ReconciledAt: &reconciledAt}
	app.Status.Sync = v1alpha1.SyncStatus{ComparedTo: v1alpha1.ComparedTo{Source: app.Spec.GetSource(), Destination: app.Spec.Destination, IgnoreDifferences: app.Spec.IgnoreDifferences}}
	ctrl := newFakeController(&fakeData{
		apps: []runtime.Object{app, &defaultProj},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}, nil)
	key, _ := cache.MetaNamespaceKeyFunc(app)
	fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
	fakeAppCs.ReactionChain = nil
	receivedPatch := map[string]any{}
	fakeAppCs.AddReactor("patch", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
		if patchAction, ok := action.(kubetesting.PatchAction); ok {
			require.NoError(t, json.Unmarshal(patchAction.GetPatch(), &receivedPatch))
		}
		return true, &v1alpha1.Application{}, nil
	})

	t.Run("UpdatedOnFullReconciliation", func(t *testing.T) {
		receivedPatch = map[string]any{}
		ctrl.requestAppRefresh(app.Name, CompareWithLatest.Pointer(), nil)
		ctrl.appRefreshQueue.AddRateLimited(key)

		ctrl.processAppRefreshQueueItem()

		_, updated, err := unstructured.NestedString(receivedPatch, "status", "reconciledAt")
		require.NoError(t, err)
		assert.True(t, updated)

		_, updated, err = unstructured.NestedString(receivedPatch, "status", "observedAt")
		require.NoError(t, err)
		assert.False(t, updated)
	})

	t.Run("NotUpdatedOnPartialReconciliation", func(t *testing.T) {
		receivedPatch = map[string]any{}
		ctrl.appRefreshQueue.AddRateLimited(key)
		ctrl.requestAppRefresh(app.Name, CompareWithRecent.Pointer(), nil)

		ctrl.processAppRefreshQueueItem()

		_, updated, err := unstructured.NestedString(receivedPatch, "status", "reconciledAt")
		require.NoError(t, err)
		assert.False(t, updated)

		_, updated, err = unstructured.NestedString(receivedPatch, "status", "observedAt")
		require.NoError(t, err)
		assert.False(t, updated)
	})
}

func TestUpdateHealthStatusTransitionTime(t *testing.T) {
	deployment := kube.MustToUnstructured(&appsv1.Deployment{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "apps/v1",
			Kind:       "Deployment",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      "demo",
			Namespace: "default",
		},
	})
	testCases := []struct {
		name           string
		app            *v1alpha1.Application
		configMapData  map[string]string
		expectedStatus health.HealthStatusCode
	}{
		{
			name: "Degraded to Missing",
			app:  newFakeAppWithHealthAndTime(health.HealthStatusDegraded, testTimestamp),
			configMapData: map[string]string{
				"resource.customizations": `
apps/Deployment:
  health.lua: |
    hs = {}
    hs.status = "Missing"
    hs.message = ""
    return hs`,
			},
			expectedStatus: health.HealthStatusMissing,
		},
		{
			name: "Missing to Progressing",
			app:  newFakeAppWithHealthAndTime(health.HealthStatusMissing, testTimestamp),
			configMapData: map[string]string{
				"resource.customizations": `
apps/Deployment:
  health.lua: |
    hs = {}
    hs.status = "Progressing"
    hs.message = ""
    return hs`,
			},
			expectedStatus: health.HealthStatusProgressing,
		},
		{
			name: "Progressing to Healthy",
			app:  newFakeAppWithHealthAndTime(health.HealthStatusProgressing, testTimestamp),
			configMapData: map[string]string{
				"resource.customizations": `
apps/Deployment:
  health.lua: |
    hs = {}
    hs.status = "Healthy"
    hs.message = ""
    return hs`,
			},
			expectedStatus: health.HealthStatusHealthy,
		},
		{
			name: "Healthy  to Degraded",
			app:  newFakeAppWithHealthAndTime(health.HealthStatusHealthy, testTimestamp),
			configMapData: map[string]string{
				"resource.customizations": `
apps/Deployment:
  health.lua: |
    hs = {}
    hs.status = "Degraded"
    hs.message = ""
    return hs`,
			},
			expectedStatus: health.HealthStatusDegraded,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			ctrl := newFakeController(&fakeData{
				apps: []runtime.Object{tc.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,
				},
				configMapData: tc.configMapData,
			}, nil)

			ctrl.processAppRefreshQueueItem()
			apps, err := ctrl.appLister.List(labels.Everything())
			require.NoError(t, err)
			assert.NotEmpty(t, apps)
			assert.Equal(t, tc.expectedStatus, apps[0].Status.Health.Status)
			assert.NotEqual(t, testTimestamp, *apps[0].Status.Health.LastTransitionTime)
		})
	}
}

func TestUpdateHealthStatusProgression(t *testing.T) {
	app := newFakeAppWithHealthAndTime(health.HealthStatusDegraded, testTimestamp)
	deployment := kube.MustToUnstructured(&appsv1.Deployment{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "apps/v1",
			Kind:       "Deployment",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      "demo",
			Namespace: "default",
		},
		Status: appsv1.DeploymentStatus{
			ObservedGeneration: 0,
		},
	})
	configMapData := map[string]string{
		"resource.customizations": `
apps/Deployment:
  health.lua: |
    hs = {}
    hs.status = ""
    hs.message = ""

    if obj.metadata ~= nil then
      if obj.metadata.labels ~= nil then
        current_status = obj.metadata.labels["status"]
        if current_status == "Degraded" then
          hs.status = "Missing"
        elseif current_status == "Missing" then
          hs.status = "Progressing"
        elseif current_status == "Progressing" then
          hs.status = "Healthy"
        elseif current_status == "Healthy" then
          hs.status = "Degraded"
        end
      end
    end

    return hs`,
	}
	ctrl := newFakeControllerWithResync(&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,
		},
		configMapData: configMapData,
		manifestResponses: []*apiclient.ManifestResponse{
			{},
			{},
			{},
			{},
		},
	}, time.Millisecond*10, nil, nil)

	testCases := []struct {
		name           string
		initialStatus  string
		expectedStatus health.HealthStatusCode
	}{
		{
			name:           "Degraded to Missing",
			initialStatus:  "Degraded",
			expectedStatus: health.HealthStatusMissing,
		},
		{
			name:           "Missing to Progressing",
			initialStatus:  "Missing",
			expectedStatus: health.HealthStatusProgressing,
		},
		{
			name:           "Progressing to Healthy",
			initialStatus:  "Progressing",
			expectedStatus: health.HealthStatusHealthy,
		},
		{
			name:           "Healthy to Degraded",
			initialStatus:  "Healthy",
			expectedStatus: health.HealthStatusDegraded,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			deployment.SetLabels(map[string]string{"status": tc.initialStatus})
			ctrl.processAppRefreshQueueItem()
			apps, err := ctrl.appLister.List(labels.Everything())
			require.NoError(t, err)
			if assert.NotEmpty(t, apps) {
				assert.Equal(t, tc.expectedStatus, apps[0].Status.Health.Status)
				assert.NotEqual(t, testTimestamp, *apps[0].Status.Health.LastTransitionTime)
			}

			ctrl.requestAppRefresh(app.Name, nil, nil)
			time.Sleep(time.Millisecond * 15)
		})
	}
}

func TestProjectErrorToCondition(t *testing.T) {
	app := newFakeApp()
	app.Spec.Project = "wrong project"
	ctrl := newFakeController(&fakeData{
		apps: []runtime.Object{app, &defaultProj},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
	}, nil)
	key, _ := cache.MetaNamespaceKeyFunc(app)
	ctrl.appRefreshQueue.AddRateLimited(key)
	ctrl.requestAppRefresh(app.Name, CompareWithRecent.Pointer(), nil)

	ctrl.processAppRefreshQueueItem()

	obj, ok, err := ctrl.appInformer.GetIndexer().GetByKey(key)
	assert.True(t, ok)
	require.NoError(t, err)
	updatedApp := obj.(*v1alpha1.Application)
	assert.Equal(t, v1alpha1.ApplicationConditionInvalidSpecError, updatedApp.Status.Conditions[0].Type)
	assert.Equal(t, "Application referencing project wrong project which does not exist", updatedApp.Status.Conditions[0].Message)
	assert.Equal(t, v1alpha1.ApplicationConditionInvalidSpecError, updatedApp.Status.Conditions[0].Type)
}

func TestFinalizeProjectDeletion_HasApplications(t *testing.T) {
	app := newFakeApp()
	proj := &v1alpha1.AppProject{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: test.FakeArgoCDNamespace}}
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app, proj}}, nil)

	fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
	patched := false
	fakeAppCs.PrependReactor("patch", "*", func(_ kubetesting.Action) (handled bool, ret runtime.Object, err error) {
		patched = true
		return true, &v1alpha1.Application{}, nil
	})

	err := ctrl.finalizeProjectDeletion(proj)
	require.NoError(t, err)
	assert.False(t, patched)
}

func TestFinalizeProjectDeletion_DoesNotHaveApplications(t *testing.T) {
	proj := &v1alpha1.AppProject{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: test.FakeArgoCDNamespace}}
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{&defaultProj}}, nil)

	fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
	receivedPatch := map[string]any{}
	fakeAppCs.PrependReactor("patch", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
		if patchAction, ok := action.(kubetesting.PatchAction); ok {
			require.NoError(t, json.Unmarshal(patchAction.GetPatch(), &receivedPatch))
		}
		return true, &v1alpha1.AppProject{}, nil
	})

	err := ctrl.finalizeProjectDeletion(proj)
	require.NoError(t, err)
	assert.Equal(t, map[string]any{
		"metadata": map[string]any{
			"finalizers": nil,
		},
	}, receivedPatch)
}

func TestProcessRequestedAppOperation_FailedNoRetries(t *testing.T) {
	app := newFakeApp()
	app.Spec.Project = "default"
	app.Operation = &v1alpha1.Operation{
		Sync: &v1alpha1.SyncOperation{},
	}
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
	fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
	receivedPatch := map[string]any{}
	fakeAppCs.PrependReactor("patch", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
		if patchAction, ok := action.(kubetesting.PatchAction); ok {
			require.NoError(t, json.Unmarshal(patchAction.GetPatch(), &receivedPatch))
		}
		return true, &v1alpha1.Application{}, nil
	})

	ctrl.processRequestedAppOperation(app)

	phase, _, _ := unstructured.NestedString(receivedPatch, "status", "operationState", "phase")
	message, _, _ := unstructured.NestedString(receivedPatch, "status", "operationState", "message")
	assert.Equal(t, string(synccommon.OperationError), phase)
	assert.Equal(t, "Failed to load application project: error getting app project \"default\": appproject.argoproj.io \"default\" not found", message)
}

func TestProcessRequestedAppOperation_InvalidDestination(t *testing.T) {
	app := newFakeAppWithDestMismatch()
	app.Spec.Project = "test-project"
	app.Operation = &v1alpha1.Operation{
		Sync: &v1alpha1.SyncOperation{},
	}
	proj := defaultProj
	proj.Name = "test-project"
	proj.Spec.SourceNamespaces = []string{test.FakeArgoCDNamespace}
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app, &proj}}, nil)
	fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
	receivedPatch := map[string]any{}
	func() {
		fakeAppCs.Lock()
		defer fakeAppCs.Unlock()
		fakeAppCs.PrependReactor("patch", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
			if patchAction, ok := action.(kubetesting.PatchAction); ok {
				require.NoError(t, json.Unmarshal(patchAction.GetPatch(), &receivedPatch))
			}
			return true, &v1alpha1.Application{}, nil
		})
	}()

	ctrl.processRequestedAppOperation(app)

	phase, _, _ := unstructured.NestedString(receivedPatch, "status", "operationState", "phase")
	message, _, _ := unstructured.NestedString(receivedPatch, "status", "operationState", "message")
	assert.Equal(t, string(synccommon.OperationError), phase)
	assert.Contains(t, message, "application destination can't have both name and server defined: another-cluster https://localhost:6443")
}

func TestProcessRequestedAppOperation_FailedHasRetries(t *testing.T) {
	app := newFakeApp()
	app.Spec.Project = "invalid-project"
	app.Operation = &v1alpha1.Operation{
		Sync:  &v1alpha1.SyncOperation{},
		Retry: v1alpha1.RetryStrategy{Limit: 1},
	}
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
	fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
	receivedPatch := map[string]any{}
	fakeAppCs.PrependReactor("patch", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
		if patchAction, ok := action.(kubetesting.PatchAction); ok {
			require.NoError(t, json.Unmarshal(patchAction.GetPatch(), &receivedPatch))
		}
		return true, &v1alpha1.Application{}, nil
	})

	ctrl.processRequestedAppOperation(app)

	phase, _, _ := unstructured.NestedString(receivedPatch, "status", "operationState", "phase")
	message, _, _ := unstructured.NestedString(receivedPatch, "status", "operationState", "message")
	retryCount, _, _ := unstructured.NestedFloat64(receivedPatch, "status", "operationState", "retryCount")
	assert.Equal(t, string(synccommon.OperationRunning), phase)
	assert.Contains(t, message, "Failed to load application project: error getting app project \"invalid-project\": appproject.argoproj.io \"invalid-project\" not found. Retrying attempt #1")
	assert.InEpsilon(t, float64(1), retryCount, 0.0001)
}

func TestProcessRequestedAppOperation_RunningPreviouslyFailed(t *testing.T) {
	failedAttemptFinisedAt := time.Now().Add(-time.Minute * 5)
	app := newFakeApp()
	app.Operation = &v1alpha1.Operation{
		Sync:  &v1alpha1.SyncOperation{},
		Retry: v1alpha1.RetryStrategy{Limit: 1},
	}
	app.Status.OperationState.Operation = *app.Operation
	app.Status.OperationState.Phase = synccommon.OperationRunning
	app.Status.OperationState.RetryCount = 1
	app.Status.OperationState.FinishedAt = &metav1.Time{Time: failedAttemptFinisedAt}
	app.Status.OperationState.SyncResult.Resources = []*v1alpha1.ResourceResult{{
		Name:   "guestbook",
		Kind:   "Deployment",
		Group:  "apps",
		Status: synccommon.ResultCodeSyncFailed,
	}}

	data := &fakeData{
		apps: []runtime.Object{app, &defaultProj},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
	}
	ctrl := newFakeController(data, nil)
	fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
	receivedPatch := map[string]any{}
	fakeAppCs.PrependReactor("patch", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
		if patchAction, ok := action.(kubetesting.PatchAction); ok {
			require.NoError(t, json.Unmarshal(patchAction.GetPatch(), &receivedPatch))
		}
		return true, &v1alpha1.Application{}, nil
	})

	ctrl.processRequestedAppOperation(app)

	phase, _, _ := unstructured.NestedString(receivedPatch, "status", "operationState", "phase")
	message, _, _ := unstructured.NestedString(receivedPatch, "status", "operationState", "message")
	finishedAtStr, _, _ := unstructured.NestedString(receivedPatch, "status", "operationState", "finishedAt")
	finishedAt, err := time.Parse(time.RFC3339, finishedAtStr)
	require.NoError(t, err)
	assert.Equal(t, string(synccommon.OperationSucceeded), phase)
	assert.Equal(t, "successfully synced (no more tasks)", message)
	assert.Truef(t, finishedAt.After(failedAttemptFinisedAt), "finishedAt was expected to be updated. The retry was not performed.")
}

func TestProcessRequestedAppOperation_RunningPreviouslyFailedBackoff(t *testing.T) {
	failedAttemptFinisedAt := time.Now().Add(-time.Second)
	app := newFakeApp()
	app.Operation = &v1alpha1.Operation{
		Sync: &v1alpha1.SyncOperation{},
		Retry: v1alpha1.RetryStrategy{
			Limit: 1,
			Backoff: &v1alpha1.Backoff{
				Duration:    "1h",
				Factor:      ptr.To(int64(100)),
				MaxDuration: "1h",
			},
		},
	}
	app.Status.OperationState.Operation = *app.Operation
	app.Status.OperationState.Phase = synccommon.OperationRunning
	app.Status.OperationState.Message = "pending retry"
	app.Status.OperationState.RetryCount = 1
	app.Status.OperationState.FinishedAt = &metav1.Time{Time: failedAttemptFinisedAt}
	app.Status.OperationState.SyncResult.Resources = []*v1alpha1.ResourceResult{{
		Name:   "guestbook",
		Kind:   "Deployment",
		Group:  "apps",
		Status: synccommon.ResultCodeSyncFailed,
	}}

	data := &fakeData{
		apps: []runtime.Object{app, &defaultProj},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
	}
	ctrl := newFakeController(data, nil)
	fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
	fakeAppCs.PrependReactor("patch", "*", func(_ kubetesting.Action) (handled bool, ret runtime.Object, err error) {
		require.FailNow(t, "A patch should not have been called if the backoff has not passed")
		return true, &v1alpha1.Application{}, nil
	})

	ctrl.processRequestedAppOperation(app)
}

func TestProcessRequestedAppOperation_HasRetriesTerminated(t *testing.T) {
	app := newFakeApp()
	app.Operation = &v1alpha1.Operation{
		Sync:  &v1alpha1.SyncOperation{},
		Retry: v1alpha1.RetryStrategy{Limit: 10},
	}
	app.Status.OperationState.Operation = *app.Operation
	app.Status.OperationState.Phase = synccommon.OperationTerminating

	data := &fakeData{
		apps: []runtime.Object{app, &defaultProj},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
	}
	ctrl := newFakeController(data, nil)
	fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
	receivedPatch := map[string]any{}
	fakeAppCs.PrependReactor("patch", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
		if patchAction, ok := action.(kubetesting.PatchAction); ok {
			require.NoError(t, json.Unmarshal(patchAction.GetPatch(), &receivedPatch))
		}
		return true, &v1alpha1.Application{}, nil
	})

	ctrl.processRequestedAppOperation(app)

	phase, _, _ := unstructured.NestedString(receivedPatch, "status", "operationState", "phase")
	message, _, _ := unstructured.NestedString(receivedPatch, "status", "operationState", "message")
	assert.Equal(t, string(synccommon.OperationFailed), phase)
	assert.Equal(t, "Operation terminated", message)
}

func TestProcessRequestedAppOperation_Successful(t *testing.T) {
	app := newFakeApp()
	app.Spec.Project = "default"
	app.Operation = &v1alpha1.Operation{
		Sync: &v1alpha1.SyncOperation{},
	}
	ctrl := newFakeController(&fakeData{
		apps: []runtime.Object{app, &defaultProj},
		manifestResponses: []*apiclient.ManifestResponse{{
			Manifests: []string{},
		}},
	}, nil)
	fakeAppCs := ctrl.applicationClientset.(*appclientset.Clientset)
	receivedPatch := map[string]any{}
	fakeAppCs.PrependReactor("patch", "*", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) {
		if patchAction, ok := action.(kubetesting.PatchAction); ok {
			require.NoError(t, json.Unmarshal(patchAction.GetPatch(), &receivedPatch))
		}
		return true, &v1alpha1.Application{}, nil
	})

	ctrl.processRequestedAppOperation(app)

	phase, _, _ := unstructured.NestedString(receivedPatch, "status", "operationState", "phase")
	message, _, _ := unstructured.NestedString(receivedPatch, "status", "operationState", "message")
	assert.Equal(t, string(synccommon.OperationSucceeded), phase)
	assert.Equal(t, "successfully synced (no more tasks)", message)
	ok, level := ctrl.isRefreshRequested(ctrl.toAppKey(app.Name))
	assert.True(t, ok)
	assert.Equal(t, CompareWithLatestForceResolve, level)
}

func TestProcessRequestedAppOperation_SyncTimeout(t *testing.T) {
	testCases := []struct {
		name            string
		startedSince    time.Duration
		syncTimeout     time.Duration
		retryAttempt    int
		currentPhase    synccommon.OperationPhase
		expectedPhase   synccommon.OperationPhase
		expectedMessage string
	}{{
		name:            "Continue when running operation has not exceeded timeout",
		syncTimeout:     time.Minute,
		startedSince:    30 * time.Second,
		currentPhase:    synccommon.OperationRunning,
		expectedPhase:   synccommon.OperationSucceeded,
		expectedMessage: "successfully synced (no more tasks)",
	}, {
		name:            "Continue when terminating operation has exceeded timeout",
		syncTimeout:     time.Minute,
		startedSince:    2 * time.Minute,
		currentPhase:    synccommon.OperationTerminating,
		expectedPhase:   synccommon.OperationFailed,
		expectedMessage: "Operation terminated",
	}, {
		name:            "Terminate when running operation exceeded timeout",
		syncTimeout:     time.Minute,
		startedSince:    2 * time.Minute,
		currentPhase:    synccommon.OperationRunning,
		expectedPhase:   synccommon.OperationFailed,
		expectedMessage: "Operation terminated, triggered by controller sync timeout",
	}, {
		name:            "Terminate when retried operation exceeded timeout",
		syncTimeout:     time.Minute,
		startedSince:    15 * time.Minute,
		currentPhase:    synccommon.OperationRunning,
		retryAttempt:    1,
		expectedPhase:   synccommon.OperationFailed,
		expectedMessage: "Operation terminated, triggered by controller sync timeout (retried 1 times).",
	}}
	for i := range testCases {
		tc := testCases[i]
		t.Run(fmt.Sprintf("case %d: %s", i, tc.name), func(t *testing.T) {
			app := newFakeApp()
			app.Spec.Project = "default"
			app.Operation = &v1alpha1.Operation{
				Sync: &v1alpha1.SyncOperation{
					Revision: "HEAD",
				},
			}
			ctrl := newFakeController(&fakeData{
				apps: []runtime.Object{app, &defaultProj},
				manifestResponses: []*apiclient.ManifestResponse{{
					Manifests: []string{},
				}},
			}, nil)

			ctrl.syncTimeout = tc.syncTimeout
			app.Status.OperationState = &v1alpha1.OperationState{
				Operation: *app.Operation,
				Phase:     tc.currentPhase,
				StartedAt: metav1.NewTime(time.Now().Add(-tc.startedSince)),
			}
			if tc.retryAttempt > 0 {
				app.Status.OperationState.FinishedAt = ptr.To(metav1.NewTime(time.Now().Add(-tc.startedSince)))
				app.Status.OperationState.RetryCount = int64(tc.retryAttempt)
			}

			ctrl.processRequestedAppOperation(app)

			app, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(app.ObjectMeta.Namespace).Get(t.Context(), app.Name, metav1.GetOptions{})
			require.NoError(t, err)
			assert.Equal(t, tc.expectedPhase, app.Status.OperationState.Phase)
			assert.Equal(t, tc.expectedMessage, app.Status.OperationState.Message)
		})
	}
}

func TestGetAppHosts(t *testing.T) {
	app := newFakeApp()
	data := &fakeData{
		apps: []runtime.Object{app, &defaultProj},
		manifestResponse: &apiclient.ManifestResponse{
			Manifests: []string{},
			Namespace: test.FakeDestNamespace,
			Server:    test.FakeClusterURL,
			Revision:  "abc123",
		},
		configMapData: map[string]string{
			"application.allowedNodeLabels": "label1,label2",
		},
	}
	ctrl := newFakeController(data, nil)
	mockStateCache := &mockstatecache.LiveStateCache{}
	mockStateCache.On("IterateResources", mock.Anything, mock.MatchedBy(func(callback func(res *clustercache.Resource, info *statecache.ResourceInfo)) bool {
		// node resource
		callback(&clustercache.Resource{
			Ref: corev1.ObjectReference{Name: "minikube", Kind: "Node", APIVersion: "v1"},
		}, &statecache.ResourceInfo{NodeInfo: &statecache.NodeInfo{
			Name:       "minikube",
			SystemInfo: corev1.NodeSystemInfo{OSImage: "debian"},
			Capacity:   map[corev1.ResourceName]resource.Quantity{corev1.ResourceCPU: resource.MustParse("5")},
			Labels:     map[string]string{"label1": "value1", "label2": "value2"},
		}})

		// app pod
		callback(&clustercache.Resource{
			Ref: corev1.ObjectReference{Name: "pod1", Kind: kube.PodKind, APIVersion: "v1", Namespace: "default"},
		}, &statecache.ResourceInfo{PodInfo: &statecache.PodInfo{
			NodeName:         "minikube",
			ResourceRequests: map[corev1.ResourceName]resource.Quantity{corev1.ResourceCPU: resource.MustParse("1")},
		}})
		// neighbor pod
		callback(&clustercache.Resource{
			Ref: corev1.ObjectReference{Name: "pod2", Kind: kube.PodKind, APIVersion: "v1", Namespace: "default"},
		}, &statecache.ResourceInfo{PodInfo: &statecache.PodInfo{
			NodeName:         "minikube",
			ResourceRequests: map[corev1.ResourceName]resource.Quantity{corev1.ResourceCPU: resource.MustParse("2")},
		}})
		return true
	})).Return(nil)
	ctrl.stateCache = mockStateCache

	hosts, err := ctrl.getAppHosts(&v1alpha1.Cluster{Server: "test", Name: "test"}, app, []v1alpha1.ResourceNode{{
		ResourceRef: v1alpha1.ResourceRef{Name: "pod1", Namespace: "default", Kind: kube.PodKind},
		Info: []v1alpha1.InfoItem{{
			Name:  "Host",
			Value: "Minikube",
		}},
	}})

	require.NoError(t, err)
	assert.Equal(t, []v1alpha1.HostInfo{{
		Name:       "minikube",
		SystemInfo: corev1.NodeSystemInfo{OSImage: "debian"},
		ResourcesInfo: []v1alpha1.HostResourceInfo{
			{
				ResourceName: corev1.ResourceCPU, Capacity: 5000, RequestedByApp: 1000, RequestedByNeighbors: 2000,
			},
		},
		Labels: map[string]string{"label1": "value1", "label2": "value2"},
	}}, hosts)
}

func TestMetricsExpiration(t *testing.T) {
	app := newFakeApp()
	// Check expiration is disabled by default
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
	assert.False(t, ctrl.metricsServer.HasExpiration())
	// Check expiration is enabled if set
	ctrl = newFakeController(&fakeData{apps: []runtime.Object{app}, metricsCacheExpiration: 10 * time.Second}, nil)
	assert.True(t, ctrl.metricsServer.HasExpiration())
}

func TestToAppKey(t *testing.T) {
	ctrl := newFakeController(&fakeData{}, nil)
	tests := []struct {
		name     string
		input    string
		expected string
	}{
		{"From instance name", "foo_bar", "foo/bar"},
		{"From qualified name", "foo/bar", "foo/bar"},
		{"From unqualified name", "bar", ctrl.namespace + "/bar"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assert.Equal(t, tt.expected, ctrl.toAppKey(tt.input))
		})
	}
}

func Test_canProcessApp(t *testing.T) {
	app := newFakeApp()
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
	ctrl.applicationNamespaces = []string{"good"}
	t.Run("without cluster filter, good namespace", func(t *testing.T) {
		app.Namespace = "good"
		canProcess := ctrl.canProcessApp(app)
		assert.True(t, canProcess)
	})
	t.Run("without cluster filter, bad namespace", func(t *testing.T) {
		app.Namespace = "bad"
		canProcess := ctrl.canProcessApp(app)
		assert.False(t, canProcess)
	})
	t.Run("with cluster filter, good namespace", func(t *testing.T) {
		app.Namespace = "good"
		canProcess := ctrl.canProcessApp(app)
		assert.True(t, canProcess)
	})
	t.Run("with cluster filter, bad namespace", func(t *testing.T) {
		app.Namespace = "bad"
		canProcess := ctrl.canProcessApp(app)
		assert.False(t, canProcess)
	})
}

func Test_canProcessAppSkipReconcileAnnotation(t *testing.T) {
	appSkipReconcileInvalid := newFakeApp()
	appSkipReconcileInvalid.Annotations = map[string]string{common.AnnotationKeyAppSkipReconcile: "invalid-value"}
	appSkipReconcileFalse := newFakeApp()
	appSkipReconcileFalse.Annotations = map[string]string{common.AnnotationKeyAppSkipReconcile: "false"}
	appSkipReconcileTrue := newFakeApp()
	appSkipReconcileTrue.Annotations = map[string]string{common.AnnotationKeyAppSkipReconcile: "true"}
	ctrl := newFakeController(&fakeData{}, nil)
	tests := []struct {
		name     string
		input    any
		expected bool
	}{
		{"No skip reconcile annotation", newFakeApp(), true},
		{"Contains skip reconcile annotation ", appSkipReconcileInvalid, true},
		{"Contains skip reconcile annotation value false", appSkipReconcileFalse, true},
		{"Contains skip reconcile annotation value true", appSkipReconcileTrue, false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assert.Equal(t, tt.expected, ctrl.canProcessApp(tt.input))
		})
	}
}

func Test_syncDeleteOption(t *testing.T) {
	app := newFakeApp()
	ctrl := newFakeController(&fakeData{apps: []runtime.Object{app}}, nil)
	cm := newFakeCM()
	t.Run("without delete option object is deleted", func(t *testing.T) {
		cmObj := kube.MustToUnstructured(&cm)
		assert.True(t, ctrl.shouldBeDeleted(app, cmObj))
	})
	t.Run("with delete set to false object is retained", func(t *testing.T) {
		cmObj := kube.MustToUnstructured(&cm)
		cmObj.SetAnnotations(map[string]string{"argocd.argoproj.io/sync-options": "Delete=false"})
		assert.False(t, ctrl.shouldBeDeleted(app, cmObj))
	})
	t.Run("with delete set to false object is retained", func(t *testing.T) {
		cmObj := kube.MustToUnstructured(&cm)
		cmObj.SetAnnotations(map[string]string{"helm.sh/resource-policy": "keep"})
		assert.False(t, ctrl.shouldBeDeleted(app, cmObj))
	})
}

func TestAddControllerNamespace(t *testing.T) {
	t.Run("set controllerNamespace when the app is in the controller namespace", func(t *testing.T) {
		app := newFakeApp()
		ctrl := newFakeController(&fakeData{
			apps:             []runtime.Object{app, &defaultProj},
			manifestResponse: &apiclient.ManifestResponse{},
		}, nil)

		ctrl.processAppRefreshQueueItem()

		updatedApp, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(ctrl.namespace).Get(t.Context(), app.Name, metav1.GetOptions{})
		require.NoError(t, err)
		assert.Equal(t, test.FakeArgoCDNamespace, updatedApp.Status.ControllerNamespace)
	})
	t.Run("set controllerNamespace when the app is in another namespace than the controller", func(t *testing.T) {
		appNamespace := "app-namespace"

		app := newFakeApp()
		app.Namespace = appNamespace
		proj := defaultProj
		proj.Spec.SourceNamespaces = []string{appNamespace}
		ctrl := newFakeController(&fakeData{
			apps:                  []runtime.Object{app, &proj},
			manifestResponse:      &apiclient.ManifestResponse{},
			applicationNamespaces: []string{appNamespace},
		}, nil)

		ctrl.processAppRefreshQueueItem()

		updatedApp, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(appNamespace).Get(t.Context(), app.Name, metav1.GetOptions{})
		require.NoError(t, err)
		assert.Equal(t, test.FakeArgoCDNamespace, updatedApp.Status.ControllerNamespace)
	})
}

func TestHelmValuesObjectHasReplaceStrategy(t *testing.T) {
	app := v1alpha1.Application{
		Status: v1alpha1.ApplicationStatus{Sync: v1alpha1.SyncStatus{ComparedTo: v1alpha1.ComparedTo{
			Source: v1alpha1.ApplicationSource{
				Helm: &v1alpha1.ApplicationSourceHelm{
					ValuesObject: &runtime.RawExtension{
						Object: &unstructured.Unstructured{Object: map[string]any{"key": []string{"value"}}},
					},
				},
			},
		}}},
	}

	appModified := v1alpha1.Application{
		Status: v1alpha1.ApplicationStatus{Sync: v1alpha1.SyncStatus{ComparedTo: v1alpha1.ComparedTo{
			Source: v1alpha1.ApplicationSource{
				Helm: &v1alpha1.ApplicationSourceHelm{
					ValuesObject: &runtime.RawExtension{
						Object: &unstructured.Unstructured{Object: map[string]any{"key": []string{"value-modified1"}}},
					},
				},
			},
		}}},
	}

	patch, _, err := createMergePatch(
		app,
		appModified)
	require.NoError(t, err)
	assert.JSONEq(t, `{"status":{"sync":{"comparedTo":{"source":{"helm":{"valuesObject":{"key":["value-modified1"]}}}}}}}`, string(patch))
}

func TestAppStatusIsReplaced(t *testing.T) {
	original := &v1alpha1.ApplicationStatus{Sync: v1alpha1.SyncStatus{
		ComparedTo: v1alpha1.ComparedTo{
			Destination: v1alpha1.ApplicationDestination{
				Server: "https://mycluster",
			},
		},
	}}

	updated := &v1alpha1.ApplicationStatus{Sync: v1alpha1.SyncStatus{
		ComparedTo: v1alpha1.ComparedTo{
			Destination: v1alpha1.ApplicationDestination{
				Name: "mycluster",
			},
		},
	}}

	patchData, ok, err := createMergePatch(original, updated)

	require.NoError(t, err)
	require.True(t, ok)
	patchObj := map[string]any{}
	require.NoError(t, json.Unmarshal(patchData, &patchObj))

	val, has, err := unstructured.NestedFieldNoCopy(patchObj, "sync", "comparedTo", "destination", "server")
	require.NoError(t, err)
	require.True(t, has)
	require.Nil(t, val)
}

func TestAlreadyAttemptSync(t *testing.T) {
	app := newFakeApp()
	defaultRevision := app.Status.OperationState.SyncResult.Revision

	t.Run("no operation state", func(t *testing.T) {
		app := app.DeepCopy()
		app.Status.OperationState = nil
		attempted, _, _ := alreadyAttemptedSync(app, []string{defaultRevision}, true)
		assert.False(t, attempted)
	})

	t.Run("no sync result for running sync", func(t *testing.T) {
		app := app.DeepCopy()
		app.Status.OperationState.SyncResult = nil
		app.Status.OperationState.Phase = synccommon.OperationRunning
		attempted, _, _ := alreadyAttemptedSync(app, []string{defaultRevision}, true)
		assert.False(t, attempted)
	})

	t.Run("no sync result for completed sync", func(t *testing.T) {
		app := app.DeepCopy()
		app.Status.OperationState.SyncResult = nil
		app.Status.OperationState.Phase = synccommon.OperationError
		attempted, _, _ := alreadyAttemptedSync(app, []string{defaultRevision}, true)
		assert.True(t, attempted)
	})

	t.Run("single source", func(t *testing.T) {
		t.Run("no revision", func(t *testing.T) {
			attempted, _, _ := alreadyAttemptedSync(app, []string{}, true)
			assert.False(t, attempted)
		})

		t.Run("empty revision", func(t *testing.T) {
			attempted, _, _ := alreadyAttemptedSync(app, []string{""}, true)
			assert.False(t, attempted)
		})

		t.Run("too many revision", func(t *testing.T) {
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Revision = "sha"
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha", "sha2"}, true)
			assert.False(t, attempted)
		})

		t.Run("same manifest, same SHA with changes", func(t *testing.T) {
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Revision = "sha"
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha"}, true)
			assert.True(t, attempted)
		})

		t.Run("same manifest, different SHA with changes", func(t *testing.T) {
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Revision = "sha1"
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha2"}, true)
			assert.False(t, attempted)
		})

		t.Run("same manifest, different SHA without changes", func(t *testing.T) {
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Revision = "sha1"
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha2"}, false)
			assert.True(t, attempted)
		})

		t.Run("different manifest, same SHA with changes", func(t *testing.T) {
			// This test represents the case where the user changed a source's target revision to a new branch, but it
			// points to the same revision as the old branch. We currently do not consider this as having been "already
			// attempted." In the future we may want to short-circuit the auto-sync in these cases.
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Source = v1alpha1.ApplicationSource{TargetRevision: "branch1"}
			app.Spec.Source = &v1alpha1.ApplicationSource{TargetRevision: "branch2"}
			app.Status.OperationState.SyncResult.Revision = "sha"
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha"}, true)
			assert.False(t, attempted)
		})

		t.Run("different manifest, different SHA with changes", func(t *testing.T) {
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Source = v1alpha1.ApplicationSource{Path: "folder1"}
			app.Spec.Source = &v1alpha1.ApplicationSource{Path: "folder2"}
			app.Status.OperationState.SyncResult.Revision = "sha1"
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha2"}, true)
			assert.False(t, attempted)
		})

		t.Run("different manifest, different SHA without changes", func(t *testing.T) {
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Source = v1alpha1.ApplicationSource{Path: "folder1"}
			app.Spec.Source = &v1alpha1.ApplicationSource{Path: "folder2"}
			app.Status.OperationState.SyncResult.Revision = "sha1"
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha2"}, false)
			assert.False(t, attempted)
		})

		t.Run("different manifest, same SHA without changes", func(t *testing.T) {
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Source = v1alpha1.ApplicationSource{Path: "folder1"}
			app.Spec.Source = &v1alpha1.ApplicationSource{Path: "folder2"}
			app.Status.OperationState.SyncResult.Revision = "sha"
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha"}, false)
			assert.False(t, attempted)
		})
	})

	t.Run("multi-source", func(t *testing.T) {
		app := app.DeepCopy()
		app.Status.OperationState.SyncResult.Sources = []v1alpha1.ApplicationSource{{Path: "folder1"}, {Path: "folder2"}}
		app.Spec.Sources = []v1alpha1.ApplicationSource{{Path: "folder1"}, {Path: "folder2"}}

		t.Run("same manifest, same SHAs with changes", func(t *testing.T) {
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Revisions = []string{"sha_a", "sha_b"}
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha_a", "sha_b"}, true)
			assert.True(t, attempted)
		})

		t.Run("same manifest, different SHAs with changes", func(t *testing.T) {
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Revisions = []string{"sha_a_=", "sha_b_1"}
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha_a_2", "sha_b_2"}, true)
			assert.False(t, attempted)
		})

		t.Run("same manifest, different SHA without changes", func(t *testing.T) {
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Revisions = []string{"sha_a_=", "sha_b_1"}
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha_a_2", "sha_b_2"}, false)
			assert.True(t, attempted)
		})

		t.Run("different manifest, same SHA with changes", func(t *testing.T) {
			// This test represents the case where the user changed a source's target revision to a new branch, but it
			// points to the same revision as the old branch. We currently do not consider this as having been "already
			// attempted." In the future we may want to short-circuit the auto-sync in these cases.
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Sources = []v1alpha1.ApplicationSource{{TargetRevision: "branch1"}, {TargetRevision: "branch2"}}
			app.Spec.Sources = []v1alpha1.ApplicationSource{{TargetRevision: "branch1"}, {TargetRevision: "branch3"}}
			app.Status.OperationState.SyncResult.Revisions = []string{"sha_a_2", "sha_b_2"}
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha_a_2", "sha_b_2"}, false)
			assert.False(t, attempted)
		})

		t.Run("different manifest, different SHA with changes", func(t *testing.T) {
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Sources = []v1alpha1.ApplicationSource{{Path: "folder1"}, {Path: "folder2"}}
			app.Spec.Sources = []v1alpha1.ApplicationSource{{Path: "folder1"}, {Path: "folder3"}}
			app.Status.OperationState.SyncResult.Revisions = []string{"sha_a", "sha_b"}
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha_a", "sha_b_2"}, true)
			assert.False(t, attempted)
		})

		t.Run("different manifest, different SHA without changes", func(t *testing.T) {
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Sources = []v1alpha1.ApplicationSource{{Path: "folder1"}, {Path: "folder2"}}
			app.Spec.Sources = []v1alpha1.ApplicationSource{{Path: "folder1"}, {Path: "folder3"}}
			app.Status.OperationState.SyncResult.Revisions = []string{"sha_a", "sha_b"}
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha_a", "sha_b_2"}, false)
			assert.False(t, attempted)
		})

		t.Run("different manifest, same SHA without changes", func(t *testing.T) {
			app := app.DeepCopy()
			app.Status.OperationState.SyncResult.Sources = []v1alpha1.ApplicationSource{{Path: "folder1"}, {Path: "folder2"}}
			app.Spec.Sources = []v1alpha1.ApplicationSource{{Path: "folder1"}, {Path: "folder3"}}
			app.Status.OperationState.SyncResult.Revisions = []string{"sha_a", "sha_b"}
			attempted, _, _ := alreadyAttemptedSync(app, []string{"sha_a", "sha_b"}, false)
			assert.False(t, attempted)
		})
	})
}

func assertDurationAround(t *testing.T, expected time.Duration, actual time.Duration) {
	t.Helper()
	delta := time.Second / 2
	assert.GreaterOrEqual(t, expected, actual-delta)
	assert.LessOrEqual(t, expected, actual+delta)
}

func TestSelfHealRemainingBackoff(t *testing.T) {
	ctrl := newFakeController(&fakeData{}, nil)
	ctrl.selfHealBackoff = &wait.Backoff{
		Factor:   3,
		Duration: 2 * time.Second,
		Cap:      2 * time.Minute,
	}
	app := &v1alpha1.Application{
		Status: v1alpha1.ApplicationStatus{
			OperationState: &v1alpha1.OperationState{
				Operation: v1alpha1.Operation{
					Sync: &v1alpha1.SyncOperation{},
				},
			},
		},
	}

	testCases := []struct {
		attempts         int
		finishedAt       *metav1.Time
		expectedDuration time.Duration
		shouldSelfHeal   bool
	}{{
		attempts:         0,
		finishedAt:       ptr.To(metav1.Now()),
		expectedDuration: 0,
		shouldSelfHeal:   true,
	}, {
		attempts:         1,
		finishedAt:       ptr.To(metav1.Now()),
		expectedDuration: 2 * time.Second,
		shouldSelfHeal:   false,
	}, {
		attempts:         2,
		finishedAt:       ptr.To(metav1.Now()),
		expectedDuration: 6 * time.Second,
		shouldSelfHeal:   false,
	}, {
		attempts:         3,
		finishedAt:       nil,
		expectedDuration: 18 * time.Second,
		shouldSelfHeal:   false,
	}, {
		attempts:         4,
		finishedAt:       nil,
		expectedDuration: 54 * time.Second,
		shouldSelfHeal:   false,
	}, {
		attempts:         5,
		finishedAt:       nil,
		expectedDuration: 120 * time.Second,
		shouldSelfHeal:   false,
	}, {
		attempts:         6,
		finishedAt:       nil,
		expectedDuration: 120 * time.Second,
		shouldSelfHeal:   false,
	}, {
		attempts:         6,
		finishedAt:       ptr.To(metav1.Now()),
		expectedDuration: 120 * time.Second,
		shouldSelfHeal:   false,
	}, {
		attempts:         40,
		finishedAt:       &metav1.Time{Time: time.Now().Add(-1 * time.Minute)},
		expectedDuration: 60 * time.Second,
		shouldSelfHeal:   false,
	}}

	for i := range testCases {
		tc := testCases[i]
		t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) {
			app.Status.OperationState.FinishedAt = tc.finishedAt
			duration := ctrl.selfHealRemainingBackoff(app, tc.attempts)
			shouldSelfHeal := duration <= 0
			require.Equal(t, tc.shouldSelfHeal, shouldSelfHeal)
			assertDurationAround(t, tc.expectedDuration, duration)
		})
	}
}

func TestSelfHealBackoffCooldownElapsed(t *testing.T) {
	cooldown := time.Second * 30
	ctrl := newFakeController(&fakeData{}, nil)
	ctrl.selfHealBackoffCooldown = cooldown

	app := &v1alpha1.Application{
		Status: v1alpha1.ApplicationStatus{
			OperationState: &v1alpha1.OperationState{
				Phase: synccommon.OperationSucceeded,
			},
		},
	}

	t.Run("operation not completed", func(t *testing.T) {
		app := app.DeepCopy()
		app.Status.OperationState.FinishedAt = nil
		elapsed := ctrl.selfHealBackoffCooldownElapsed(app)
		assert.True(t, elapsed)
	})

	t.Run("successful operation finised after cooldown", func(t *testing.T) {
		app := app.DeepCopy()
		app.Status.OperationState.FinishedAt = &metav1.Time{Time: time.Now().Add(-cooldown)}
		elapsed := ctrl.selfHealBackoffCooldownElapsed(app)
		assert.True(t, elapsed)
	})

	t.Run("unsuccessful operation finised after cooldown", func(t *testing.T) {
		app := app.DeepCopy()
		app.Status.OperationState.Phase = synccommon.OperationFailed
		app.Status.OperationState.FinishedAt = &metav1.Time{Time: time.Now().Add(-cooldown)}
		elapsed := ctrl.selfHealBackoffCooldownElapsed(app)
		assert.False(t, elapsed)
	})

	t.Run("successful operation finised before cooldown", func(t *testing.T) {
		app := app.DeepCopy()
		app.Status.OperationState.FinishedAt = &metav1.Time{Time: time.Now()}
		elapsed := ctrl.selfHealBackoffCooldownElapsed(app)
		assert.False(t, elapsed)
	})
}
