Ctrl K
ComunidadGuías…

pkg/gitops/operations_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package gitops

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"reflect"
	"slices"
	"strings"
	"testing"
	"time"

	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/dynamic/fake"
	clienttesting "k8s.io/client-go/testing"

	pkgauth "github.com/skyhook-io/radar/pkg/auth"
)

// argoAppForTest returns a minimal Argo Application as an unstructured object
// for use with the fake dynamic client. status fields default to absent;
// callers set them via the optional mutator.
func argoAppForTest(namespace, name string, mutate func(map[string]any)) *unstructured.Unstructured {
	app := &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "argoproj.io/v1alpha1",
		"kind":       "Application",
		"metadata": map[string]any{
			"namespace": namespace,
			"name":      name,
		},
		"spec":   map[string]any{"project": "default"},
		"status": map[string]any{},
	}}
	if mutate != nil {
		mutate(app.Object)
	}
	return app
}

func newFakeArgo(objs ...runtime.Object) *fake.FakeDynamicClient {
	scheme := runtime.NewScheme()
	// Register the Application list kind so List/Get/Patch on the GVR work.
	scheme.AddKnownTypeWithName(argoAppGVR.GroupVersion().WithKind("Application"), &unstructured.Unstructured{})
	scheme.AddKnownTypeWithName(argoAppGVR.GroupVersion().WithKind("ApplicationList"), &unstructured.UnstructuredList{})
	// Pull in the core scheme so non-Argo objects don't break the client init.
	_ = corev1.AddToScheme(scheme)
	return fake.NewSimpleDynamicClient(scheme, objs...)
}

// captureLastPatch returns the body of the most recent merge-patch action,
// decoded into a map. Fails the test if no patch was issued.
func captureLastPatch(t *testing.T, client *fake.FakeDynamicClient) map[string]any {
	t.Helper()
	for i := len(client.Actions()) - 1; i >= 0; i-- {
		if pa, ok := client.Actions()[i].(clienttesting.PatchAction); ok {
			var body map[string]any
			if err := json.Unmarshal(pa.GetPatch(), &body); err != nil {
				t.Fatalf("patch body not JSON: %v", err)
			}
			return body
		}
	}
	t.Fatalf("no patch action recorded; actions=%v", client.Actions())
	return nil
}

// nestedMap is a small helper that returns a sub-map by walking keys; missing
// keys return nil so test assertions stay declarative.
func nestedMap(m map[string]any, keys ...string) map[string]any {
	cur := m
	for _, k := range keys {
		next, ok := cur[k].(map[string]any)
		if !ok {
			return nil
		}
		cur = next
	}
	return cur
}

// TestSyncArgoAppSyncStrategy pins the wire format: Force without ApplyOnly
// must encode as syncStrategy.hook.force, not syncStrategy.apply.force,
// otherwise Argo silently skips PreSync / PostSync / SyncFail hooks.
func TestSyncArgoAppSyncStrategy(t *testing.T) {
	tr := true
	fa := false
	cases := []struct {
		name         string
		opts         ArgoSyncOptions
		wantStrategy map[string]any // nil = no syncStrategy in patch
	}{
		{
			name:         "no flags → no syncStrategy",
			opts:         ArgoSyncOptions{},
			wantStrategy: nil,
		},
		{
			name:         "Force only → hook strategy with force",
			opts:         ArgoSyncOptions{Force: &tr},
			wantStrategy: map[string]any{"hook": map[string]any{"force": true}},
		},
		{
			name:         "ApplyOnly only → apply strategy without force",
			opts:         ArgoSyncOptions{ApplyOnly: &tr},
			wantStrategy: map[string]any{"apply": map[string]any{}},
		},
		{
			name:         "Force + ApplyOnly → apply strategy with force",
			opts:         ArgoSyncOptions{Force: &tr, ApplyOnly: &tr},
			wantStrategy: map[string]any{"apply": map[string]any{"force": true}},
		},
		{
			name:         "Force=&false (explicit off) → no syncStrategy",
			opts:         ArgoSyncOptions{Force: &fa},
			wantStrategy: nil,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			client := newFakeArgo(argoAppForTest("argocd", "demo", nil))
			if _, err := SyncArgoApp(context.Background(), client, "argocd", "demo", tc.opts); err != nil {
				t.Fatalf("SyncArgoApp: %v", err)
			}
			body := captureLastPatch(t, client)
			sync := nestedMap(body, "operation", "sync")
			if sync == nil {
				t.Fatalf("patch missing operation.sync: %#v", body)
			}
			gotStrategy, _ := sync["syncStrategy"].(map[string]any)
			if tc.wantStrategy == nil {
				if gotStrategy != nil {
					t.Fatalf("expected no syncStrategy, got %#v", gotStrategy)
				}
				return
			}
			if !equalMap(gotStrategy, tc.wantStrategy) {
				t.Fatalf("syncStrategy = %#v, want %#v", gotStrategy, tc.wantStrategy)
			}
		})
	}
}

// equalMap is a shallow value comparison for map[string]any with map values —
// reflect.DeepEqual would also work but produces noisier failure output.
func equalMap(a, b map[string]any) bool {
	if len(a) != len(b) {
		return false
	}
	for k, va := range a {
		vb, ok := b[k]
		if !ok {
			return false
		}
		switch va := va.(type) {
		case map[string]any:
			vbMap, ok := vb.(map[string]any)
			if !ok || !equalMap(va, vbMap) {
				return false
			}
		default:
			if va != vb {
				return false
			}
		}
	}
	return true
}

// Prune is always written and is off unless the caller asks for it, as in
// Argo CD's UI and CLI: a sync that omits it must not delete resources that
// were removed from Git.
func TestSyncArgoAppPruneAlwaysWrittenAndDefaultsOff(t *testing.T) {
	tr := true
	fa := false
	cases := []struct {
		name      string
		opts      ArgoSyncOptions
		wantPrune any
	}{
		{name: "nil prune defaults to false", opts: ArgoSyncOptions{}, wantPrune: false},
		{name: "explicit true", opts: ArgoSyncOptions{Prune: &tr}, wantPrune: true},
		{name: "explicit false reaches the wire", opts: ArgoSyncOptions{Prune: &fa}, wantPrune: false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			client := newFakeArgo(argoAppForTest("argocd", "demo", nil))
			if _, err := SyncArgoApp(context.Background(), client, "argocd", "demo", tc.opts); err != nil {
				t.Fatalf("SyncArgoApp: %v", err)
			}
			body := captureLastPatch(t, client)
			sync := nestedMap(body, "operation", "sync")
			if sync["prune"] != tc.wantPrune {
				t.Fatalf("prune = %#v, want %#v", sync["prune"], tc.wantPrune)
			}
		})
	}
}

// TestRollbackArgoAppHistoryIDVerification pins both type-assertion paths
// for status.history[].id (int64 from structured deep-copy, float64 from
// JSON unmarshal). Dropping either half silently breaks rollback for the
// other source.
func TestRollbackArgoAppHistoryIDVerification(t *testing.T) {
	makeAppWithHistory := func(historyID any) *unstructured.Unstructured {
		return argoAppForTest("argocd", "demo", func(obj map[string]any) {
			status, _ := obj["status"].(map[string]any)
			status["history"] = []any{
				map[string]any{"id": historyID, "revision": "abc123", "source": map[string]any{"repoURL": "https://example.com/repo", "path": "app"}},
			}
		})
	}

	t.Run("matches int64 history id", func(t *testing.T) {
		client := newFakeArgo(makeAppWithHistory(int64(7)))
		_, err := RollbackArgoApp(context.Background(), client, "argocd", "demo", ArgoRollbackOptions{ID: 7})
		if err != nil {
			t.Fatalf("expected success for int64 id=7, got %v", err)
		}
		body := captureLastPatch(t, client)
		if got := nestedMap(body, "operation", "sync")["revision"]; got != "abc123" {
			t.Fatalf("operation.sync.revision = %#v, want abc123", got)
		}
	})

	t.Run("matches float64 history id (the realistic JSON case)", func(t *testing.T) {
		client := newFakeArgo(makeAppWithHistory(float64(42)))
		_, err := RollbackArgoApp(context.Background(), client, "argocd", "demo", ArgoRollbackOptions{ID: 42})
		if err != nil {
			t.Fatalf("expected success for float64 id=42, got %v", err)
		}
	})

	// Argo's controller numbers history from 0, so the first deploy is id 0.
	t.Run("id 0 is the first history entry", func(t *testing.T) {
		client := newFakeArgo(makeAppWithHistory(int64(0)))
		if _, err := RollbackArgoApp(context.Background(), client, "argocd", "demo", ArgoRollbackOptions{ID: 0}); err != nil {
			t.Fatalf("expected success for id=0, got %v", err)
		}
	})

	t.Run("missing id rejected with sentinel error", func(t *testing.T) {
		client := newFakeArgo(makeAppWithHistory(int64(7)))
		_, err := RollbackArgoApp(context.Background(), client, "argocd", "demo", ArgoRollbackOptions{ID: 999})
		if err == nil {
			t.Fatal("expected error for unknown history id, got nil")
		}
		if !errors.Is(err, ErrHistoryEntryNotFound) {
			t.Fatalf("expected ErrHistoryEntryNotFound, got %v", err)
		}
		assertNoPatch(t, client)
	})

	t.Run("negative id rejected upfront", func(t *testing.T) {
		client := newFakeArgo(makeAppWithHistory(int64(7)))
		_, err := RollbackArgoApp(context.Background(), client, "argocd", "demo", ArgoRollbackOptions{ID: -1})
		if err == nil {
			t.Fatal("expected error for id=-1")
		}
	})

	t.Run("running operation rejects rollback with sentinel error", func(t *testing.T) {
		app := argoAppForTest("argocd", "demo", func(obj map[string]any) {
			status, _ := obj["status"].(map[string]any)
			status["operationState"] = map[string]any{"phase": "Running"}
			status["history"] = []any{map[string]any{"id": int64(1)}}
		})
		client := newFakeArgo(app)
		_, err := RollbackArgoApp(context.Background(), client, "argocd", "demo", ArgoRollbackOptions{ID: 1})
		if err == nil {
			t.Fatal("expected error during running operation")
		}
		if !errors.Is(err, ErrOperationInProgress) {
			t.Fatalf("expected ErrOperationInProgress, got %v", err)
		}
	})

	t.Run("pending operation rejects rollback with sentinel error", func(t *testing.T) {
		client := newFakeArgo(argoAppForTest("argocd", "demo", func(obj map[string]any) {
			obj["operation"] = map[string]any{"sync": map[string]any{"revision": "abc123"}}
			status, _ := obj["status"].(map[string]any)
			status["history"] = []any{map[string]any{"id": int64(1), "revision": "abc123", "source": map[string]any{"repoURL": "https://example.com/repo"}}}
		}))
		_, err := RollbackArgoApp(context.Background(), client, "argocd", "demo", ArgoRollbackOptions{ID: 1})
		if !errors.Is(err, ErrOperationInProgress) {
			t.Fatalf("expected ErrOperationInProgress, got %v", err)
		}
		assertNoPatch(t, client)
	})
}

func assertNoPatch(t *testing.T, client *fake.FakeDynamicClient) {
	t.Helper()
	for _, action := range client.Actions() {
		if _, ok := action.(clienttesting.PatchAction); ok {
			t.Fatalf("expected no patch; actions=%v", client.Actions())
		}
	}
}

// The Application CRD has no operation.rollback: the API server prunes it and
// Argo's controller fails the empty operation. Rollback must be written the
// way argocd-server writes it — a sync pinned to the history entry.
func TestRollbackArgoAppWritesSyncOperation(t *testing.T) {
	ctx := context.Background()
	trueValue := true

	t.Run("single source", func(t *testing.T) {
		client := newFakeArgo(argoAppForTest("argocd", "demo", func(obj map[string]any) {
			obj["spec"] = map[string]any{
				"project":    "default",
				"source":     map[string]any{"repoURL": "https://example.com/repo", "path": "current", "targetRevision": "main"},
				"syncPolicy": map[string]any{"syncOptions": []any{"CreateNamespace=true"}},
			}
			status, _ := obj["status"].(map[string]any)
			status["history"] = []any{
				map[string]any{"id": int64(0), "revision": "rev-old", "source": map[string]any{"repoURL": "https://example.com/repo", "path": "old", "targetRevision": "main"}},
				map[string]any{"id": int64(1), "revision": "rev-new", "source": map[string]any{"repoURL": "https://example.com/repo", "path": "current", "targetRevision": "main"}},
			}
		}))
		if _, err := RollbackArgoApp(ctx, client, "argocd", "demo", ArgoRollbackOptions{ID: 0}); err != nil {
			t.Fatalf("RollbackArgoApp: %v", err)
		}
		body := captureLastPatch(t, client)
		operation := nestedMap(body, "operation")
		if _, ok := operation["rollback"]; ok {
			t.Fatalf("patch still writes operation.rollback: %v", operation)
		}
		if got := nestedMap(operation, "initiatedBy")["username"]; got != "radar" {
			t.Fatalf("initiatedBy.username = %#v, want radar", got)
		}
		if !argoOperationHasInfo(operation, argoRollbackInfoName, "true") {
			t.Fatalf("rollback operation lacks its marker: %#v", operation)
		}
		want := map[string]any{
			"revision":     "rev-old",
			"source":       map[string]any{"repoURL": "https://example.com/repo", "path": "old", "targetRevision": "main"},
			"prune":        false,
			"syncStrategy": map[string]any{"apply": map[string]any{}},
			"syncOptions":  []any{"CreateNamespace=true"},
		}
		if got := nestedMap(operation, "sync"); !reflect.DeepEqual(got, want) {
			t.Fatalf("operation.sync = %#v\nwant %#v", got, want)
		}
	})

	t.Run("multi source", func(t *testing.T) {
		sources := []any{
			map[string]any{"repoURL": "https://example.com/a", "path": "app", "targetRevision": "main"},
			map[string]any{"repoURL": "https://example.com/b", "path": "values", "targetRevision": "v2", "ref": "values"},
		}
		client := newFakeArgo(argoAppForTest("argocd", "demo", func(obj map[string]any) {
			status, _ := obj["status"].(map[string]any)
			status["history"] = []any{map[string]any{
				"id":        int64(3),
				"revisions": []any{"rev-a", "rev-b"},
				"sources":   sources,
				// Argo serializes the unused single source of a multi-source
				// entry as an all-empty object.
				"source": map[string]any{"repoURL": ""},
			}}
		}))
		if _, err := RollbackArgoApp(ctx, client, "argocd", "demo", ArgoRollbackOptions{ID: 3, Prune: &trueValue, DryRun: &trueValue}); err != nil {
			t.Fatalf("RollbackArgoApp: %v", err)
		}
		want := map[string]any{
			"revisions":    []any{"rev-a", "rev-b"},
			"sources":      sources,
			"prune":        true,
			"dryRun":       true,
			"syncStrategy": map[string]any{"apply": map[string]any{}},
		}
		if got := nestedMap(captureLastPatch(t, client), "operation", "sync"); !reflect.DeepEqual(got, want) {
			t.Fatalf("operation.sync = %#v\nwant %#v", got, want)
		}
	})

	t.Run("entry without a source is refused", func(t *testing.T) {
		client := newFakeArgo(argoAppForTest("argocd", "demo", func(obj map[string]any) {
			status, _ := obj["status"].(map[string]any)
			status["history"] = []any{map[string]any{"id": int64(1), "revision": "abc123"}}
		}))
		_, err := RollbackArgoApp(ctx, client, "argocd", "demo", ArgoRollbackOptions{ID: 1})
		if !errors.Is(err, ErrHistoryEntryNotRollbackable) {
			t.Fatalf("expected ErrHistoryEntryNotRollbackable, got %v", err)
		}
		assertNoPatch(t, client)
	})
}

// Argo CD refuses a rollback while automated sync is on; Argo CD 3.x can keep
// the automated block with enabled: false, which is off.
func TestRollbackArgoAppAutoSync(t *testing.T) {
	cases := []struct {
		name      string
		automated map[string]any
		wantErr   bool
	}{
		{name: "automated block present", automated: map[string]any{"prune": true, "selfHeal": true}, wantErr: true},
		{name: "empty automated block", automated: map[string]any{}, wantErr: true},
		{name: "enabled: true", automated: map[string]any{"enabled": true}, wantErr: true},
		{name: "enabled: false", automated: map[string]any{"enabled": false, "prune": true}, wantErr: false},
		{name: "no automated block", automated: nil, wantErr: false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			client := newFakeArgo(argoAppForTest("argocd", "demo", func(obj map[string]any) {
				spec, _ := obj["spec"].(map[string]any)
				if tc.automated != nil {
					spec["syncPolicy"] = map[string]any{"automated": tc.automated}
				}
				status, _ := obj["status"].(map[string]any)
				status["history"] = []any{map[string]any{"id": int64(0), "revision": "abc123", "source": map[string]any{"repoURL": "https://example.com/repo"}}}
			}))
			_, err := RollbackArgoApp(context.Background(), client, "argocd", "demo", ArgoRollbackOptions{ID: 0})
			if tc.wantErr {
				if !errors.Is(err, ErrAutoSyncEnabled) {
					t.Fatalf("expected ErrAutoSyncEnabled, got %v", err)
				}
				assertNoPatch(t, client)
				return
			}
			if err != nil {
				t.Fatalf("expected rollback to start, got %v", err)
			}
		})
	}
}

func TestArgoHistoryEntryRollbackable(t *testing.T) {
	cases := []struct {
		name  string
		entry map[string]any
		want  bool
	}{
		{name: "single source", entry: map[string]any{"id": int64(0), "source": map[string]any{"repoURL": "https://example.com/repo"}}, want: true},
		{name: "multi source", entry: map[string]any{"id": float64(2), "sources": []any{map[string]any{"repoURL": "https://example.com/repo"}}, "source": map[string]any{"repoURL": ""}}, want: true},
		{name: "empty source object", entry: map[string]any{"id": int64(1), "revision": "abc", "source": map[string]any{"repoURL": ""}}, want: false},
		{name: "no source at all", entry: map[string]any{"id": int64(1), "revision": "abc"}, want: false},
		{name: "no id", entry: map[string]any{"source": map[string]any{"repoURL": "https://example.com/repo"}}, want: false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := ArgoHistoryEntryRollbackable(tc.entry); got != tc.want {
				t.Fatalf("ArgoHistoryEntryRollbackable = %v, want %v", got, tc.want)
			}
		})
	}
}

// Sanity: the rollback-collision sentinel maps the same way in tests as it
// does in production — used to verify the handler-level HTTP mapping doesn't
// drift from the operation layer.
func TestSentinelErrorsAreDistinct(t *testing.T) {
	// Each sentinel must be uniquely identifiable so handler error mapping
	// doesn't accidentally collapse them to the same status code.
	if errors.Is(ErrOperationInProgress, ErrNoOperationInProgress) ||
		errors.Is(ErrNoOperationInProgress, ErrHistoryEntryNotFound) ||
		errors.Is(ErrOperationInProgress, ErrHistoryEntryNotFound) {
		t.Fatal("sentinel errors should not match each other under errors.Is")
	}
}

// TestOperationsRefuseTerminatingResource pins that mutating ops refuse a
// resource with metadata.deletionTimestamp set, returning ErrResourceTerminating.
// Refresh and Terminate are intentionally excluded because both remain useful
// when an in-flight operation is blocking deletion.
func TestOperationsRefuseTerminatingResource(t *testing.T) {
	ctx := context.Background()
	terminatingApp := func() *unstructured.Unstructured {
		return argoAppForTest("argocd", "demo", func(obj map[string]any) {
			md, _ := obj["metadata"].(map[string]any)
			md["deletionTimestamp"] = "2026-04-13T13:14:42Z"
			md["finalizers"] = []any{"resources-finalizer.argocd.argoproj.io"}
			// Add some history so RollbackArgoApp's history-id check
			// would otherwise pass — we want to confirm the terminating
			// guard fires *before* the history check.
			status, _ := obj["status"].(map[string]any)
			status["history"] = []any{map[string]any{"id": int64(1), "revision": "abc"}}
		})
	}
	cases := []struct {
		name string
		fn   func(client *fake.FakeDynamicClient) error
	}{
		{"SyncArgoApp", func(c *fake.FakeDynamicClient) error {
			_, err := SyncArgoApp(ctx, c, "argocd", "demo", ArgoSyncOptions{})
			return err
		}},
		{"SetArgoAutoSync(suspend)", func(c *fake.FakeDynamicClient) error {
			_, err := SetArgoAutoSync(ctx, c, "argocd", "demo", false)
			return err
		}},
		{"SetArgoAutoSync(resume)", func(c *fake.FakeDynamicClient) error {
			_, err := SetArgoAutoSync(ctx, c, "argocd", "demo", true)
			return err
		}},
		{"RollbackArgoApp", func(c *fake.FakeDynamicClient) error {
			_, err := RollbackArgoApp(ctx, c, "argocd", "demo", ArgoRollbackOptions{ID: 1})
			return err
		}},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			client := newFakeArgo(terminatingApp())
			err := tc.fn(client)
			if err == nil {
				t.Fatalf("expected error, got nil")
			}
			if !errors.Is(err, ErrResourceTerminating) {
				t.Fatalf("expected ErrResourceTerminating, got %v", err)
			}
			// The error message must name the finalizer — otherwise the
			// user sees "resource is pending deletion" with no path
			// forward. Naming the finalizer points them at the owning
			// controller to investigate.
			if !strings.Contains(err.Error(), "resources-finalizer.argocd.argoproj.io") {
				t.Fatalf("expected error to name finalizer; got: %v", err)
			}
			// Verify no patch was issued — the entire point of this
			// guard is that we don't touch a resource being torn down.
			for _, action := range client.Actions() {
				if _, ok := action.(clienttesting.PatchAction); ok {
					t.Fatalf("operation issued a patch despite Terminating; actions=%v", client.Actions())
				}
			}
		})
	}
}

// TestOperationsAllowReadVerbsOnTerminatingResource asserts the carve-out
// for Refresh and Terminate. These verbs are useful on a Terminating
// resource (refresh re-reads Git; terminate clears a stuck op record)
// so they intentionally don't fire the assertNotTerminating guard.
func TestOperationsAllowReadVerbsOnTerminatingResource(t *testing.T) {
	ctx := context.Background()
	terminatingApp := argoAppForTest("argocd", "demo", func(obj map[string]any) {
		md, _ := obj["metadata"].(map[string]any)
		md["deletionTimestamp"] = "2026-04-13T13:14:42Z"
		md["finalizers"] = []any{"resources-finalizer.argocd.argoproj.io"}
		status, _ := obj["status"].(map[string]any)
		status["operationState"] = map[string]any{"phase": "Running"}
	})

	// Each subtest asserts the *guard* doesn't fire — the operation may
	// still error for unrelated reasons (the fake dynamic client's
	// JSON-Patch support is incomplete) but it must not be
	// ErrResourceTerminating. That's the contract: read-style verbs
	// don't gate on Terminating.
	t.Run("Refresh does not gate on Terminating", func(t *testing.T) {
		client := newFakeArgo(terminatingApp)
		_, err := RefreshArgoApp(ctx, client, "argocd", "demo", "normal")
		if errors.Is(err, ErrResourceTerminating) {
			t.Fatalf("Refresh should not return ErrResourceTerminating; got %v", err)
		}
	})
	t.Run("Terminate does not gate on Terminating", func(t *testing.T) {
		client := newFakeArgo(terminatingApp)
		_, err := TerminateArgoSync(ctx, client, "argocd", "demo")
		if errors.Is(err, ErrResourceTerminating) {
			t.Fatalf("Terminate should not return ErrResourceTerminating; got %v", err)
		}
	})
}

// TestOperationsPreserveNotFoundChain pins the error-wrapping contract that
// the HTTP layer relies on. Argo/Flux operation funcs wrap K8s NotFound
// errors with %w so writeGitOpsError's apierrors.IsNotFound check matches
// via errors.Is, mapping to 404. Stripping the wrap (returning a plain
// fmt.Errorf("...not found", ...)) silently downgrades 404 to 500 because
// the K8s status reason is gone — a real bug we shipped and reverted.
func TestOperationsPreserveNotFoundChain(t *testing.T) {
	ctx := context.Background()
	cases := []struct {
		name string
		fn   func(client *fake.FakeDynamicClient) error
	}{
		{"SyncArgoApp", func(c *fake.FakeDynamicClient) error {
			_, err := SyncArgoApp(ctx, c, "argocd", "missing", ArgoSyncOptions{})
			return err
		}},
		{"SetArgoAutoSync(suspend)", func(c *fake.FakeDynamicClient) error {
			_, err := SetArgoAutoSync(ctx, c, "argocd", "missing", false)
			return err
		}},
		{"RefreshArgoApp", func(c *fake.FakeDynamicClient) error {
			_, err := RefreshArgoApp(ctx, c, "argocd", "missing", "normal")
			return err
		}},
		{"TerminateArgoSync", func(c *fake.FakeDynamicClient) error {
			_, err := TerminateArgoSync(ctx, c, "argocd", "missing")
			return err
		}},
		{"RollbackArgoApp", func(c *fake.FakeDynamicClient) error {
			_, err := RollbackArgoApp(ctx, c, "argocd", "missing", ArgoRollbackOptions{ID: 1})
			return err
		}},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			client := newFakeArgo()
			err := tc.fn(client)
			if err == nil {
				t.Fatalf("expected error from missing Application, got nil")
			}
			if !apierrors.IsNotFound(err) {
				t.Fatalf("expected apierrors.IsNotFound to match, got %v", err)
			}
		})
	}
}

// TestSyncArgoAppSelectiveResources covers the selective-sync wire shape.
func TestSyncArgoAppSelectiveResources(t *testing.T) {
	cases := []struct {
		name      string
		resources []ArgoSyncResource
		want      []map[string]any // nil = sync.resources field absent
	}{
		{
			name:      "empty slice → no resources field",
			resources: nil,
			want:      nil,
		},
		{
			name: "single valid entry survives",
			resources: []ArgoSyncResource{
				{Group: "apps", Kind: "Deployment", Namespace: "demo", Name: "web"},
			},
			want: []map[string]any{
				{"group": "apps", "kind": "Deployment", "namespace": "demo", "name": "web"},
			},
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			client := newFakeArgo(argoAppForTest("argocd", "demo", nil))
			if _, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{Resources: tc.resources}); err != nil {
				t.Fatalf("SyncArgoApp: %v", err)
			}
			body := captureLastPatch(t, client)
			sync := nestedMap(body, "operation", "sync")
			got, _ := sync["resources"].([]any)
			if tc.want == nil {
				if got != nil {
					t.Fatalf("expected no resources field, got %#v", got)
				}
				return
			}
			if len(got) != len(tc.want) {
				t.Fatalf("resources length = %d, want %d (%#v)", len(got), len(tc.want), got)
			}
			for i, raw := range got {
				m, ok := raw.(map[string]any)
				if !ok {
					t.Fatalf("resources[%d] not a map: %T", i, raw)
				}
				if !equalMap(m, tc.want[i]) {
					t.Fatalf("resources[%d] = %#v, want %#v", i, m, tc.want[i])
				}
			}
		})
	}
}

func TestSyncArgoAppRejectsIncompleteSelectiveResource(t *testing.T) {
	resources := []ArgoSyncResource{
		{Group: "apps", Kind: "Deployment", Namespace: "demo", Name: "web"},
		{Kind: "Service"},
	}
	client := newFakeArgo(argoAppForTest("argocd", "demo", nil))
	_, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{Resources: resources})
	if !errors.Is(err, ErrInvalidResourceSelection) {
		t.Fatalf("SyncArgoApp error = %v, want ErrInvalidResourceSelection", err)
	}
	for _, action := range client.Actions() {
		if _, ok := action.(clienttesting.PatchAction); ok {
			t.Fatalf("invalid selective sync issued a patch; actions=%v", client.Actions())
		}
	}
}

func TestSyncArgoAppDryRunDoesNotRequestHardRefresh(t *testing.T) {
	dryRun := true
	client := newFakeArgo(argoAppForTest("argocd", "demo", nil))
	if _, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{DryRun: &dryRun}); err != nil {
		t.Fatalf("SyncArgoApp: %v", err)
	}
	body := captureLastPatch(t, client)
	if annotations := nestedMap(body, "metadata", "annotations"); annotations != nil {
		t.Fatalf("dry-run patch must not request a refresh, got annotations %#v", annotations)
	}
}

func TestArgoOperationPatchesUseResourceVersion(t *testing.T) {
	app := argoAppForTest("argocd", "demo", func(obj map[string]any) {
		metadata, _ := obj["metadata"].(map[string]any)
		metadata["resourceVersion"] = "17"
		status, _ := obj["status"].(map[string]any)
		status["history"] = []any{map[string]any{"id": int64(1), "source": map[string]any{"repoURL": "https://example.com/repo"}}}
	})

	t.Run("sync", func(t *testing.T) {
		client := newFakeArgo(app.DeepCopy())
		if _, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{}); err != nil {
			t.Fatalf("SyncArgoApp: %v", err)
		}
		metadata := nestedMap(captureLastPatch(t, client), "metadata")
		if metadata["resourceVersion"] != "17" {
			t.Fatalf("sync resourceVersion = %#v, want 17", metadata["resourceVersion"])
		}
	})

	t.Run("rollback", func(t *testing.T) {
		client := newFakeArgo(app.DeepCopy())
		if _, err := RollbackArgoApp(context.Background(), client, "argocd", "demo", ArgoRollbackOptions{ID: 1}); err != nil {
			t.Fatalf("RollbackArgoApp: %v", err)
		}
		metadata := nestedMap(captureLastPatch(t, client), "metadata")
		if metadata["resourceVersion"] != "17" {
			t.Fatalf("rollback resourceVersion = %#v, want 17", metadata["resourceVersion"])
		}
	})
}

func TestArgoOperationPatchConflictMapsToOperationInProgress(t *testing.T) {
	app := argoAppForTest("argocd", "demo", func(obj map[string]any) {
		status, _ := obj["status"].(map[string]any)
		status["history"] = []any{map[string]any{"id": int64(1), "source": map[string]any{"repoURL": "https://example.com/repo"}}}
	})
	cases := []struct {
		name string
		run  func(*fake.FakeDynamicClient) error
	}{
		{name: "sync", run: func(client *fake.FakeDynamicClient) error {
			_, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{})
			return err
		}},
		{name: "rollback", run: func(client *fake.FakeDynamicClient) error {
			_, err := RollbackArgoApp(context.Background(), client, "argocd", "demo", ArgoRollbackOptions{ID: 1})
			return err
		}},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			client := newFakeArgo(app.DeepCopy())
			client.PrependReactor("patch", "applications", func(action clienttesting.Action) (bool, runtime.Object, error) {
				return true, nil, apierrors.NewConflict(argoAppGVR.GroupResource(), "demo", errors.New("changed"))
			})
			if err := tc.run(client); !errors.Is(err, ErrOperationInProgress) {
				t.Fatalf("error = %v, want ErrOperationInProgress", err)
			}
		})
	}
}

func TestValidateArgoResourceRejectsQueuedOperation(t *testing.T) {
	app := argoAppForTest("argocd", "demo", func(obj map[string]any) {
		obj["operation"] = map[string]any{"sync": map[string]any{}}
	})
	client := newFakeArgo(app)
	_, err := ValidateArgoResource(context.Background(), client, "argocd", "demo", ArgoSyncResource{Kind: "Service", Name: "api"}, ArgoSyncOptions{})
	if !errors.Is(err, ErrOperationInProgress) {
		t.Fatalf("ValidateArgoResource error = %v, want ErrOperationInProgress", err)
	}
	for _, action := range client.Actions() {
		if _, ok := action.(clienttesting.PatchAction); ok {
			t.Fatalf("validation overwrote a queued operation; actions=%v", client.Actions())
		}
	}
}

func TestValidateArgoResourceStartsSafeSelectiveDryRun(t *testing.T) {
	client := newFakeArgo(argoAppForTest("argocd", "demo", nil))
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()
	target := ArgoSyncResource{Group: "apps", Kind: "Deployment", Namespace: "demo", Name: "api"}
	_, err := ValidateArgoResource(ctx, client, "argocd", "demo", target, ArgoSyncOptions{})
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("ValidateArgoResource error = %v, want context deadline", err)
	}
	body := captureLastPatch(t, client)
	if annotations := nestedMap(body, "metadata", "annotations"); annotations != nil {
		t.Fatalf("validation patch must not request a refresh, got annotations %#v", annotations)
	}
	sync := nestedMap(body, "operation", "sync")
	if sync["dryRun"] != true || sync["prune"] != false {
		t.Fatalf("validation flags = dryRun:%#v prune:%#v", sync["dryRun"], sync["prune"])
	}
	resources, ok := sync["resources"].([]any)
	if !ok || len(resources) != 1 {
		t.Fatalf("validation resources = %#v, want one exact selector", sync["resources"])
	}
	info, ok := nestedMap(body, "operation")["info"].([]any)
	if !ok || len(info) != 2 {
		t.Fatalf("validation info = %#v, want the provenance and one correlation entry", nestedMap(body, "operation")["info"])
	}
	if !argoOperationHasInfo(nestedMap(body, "operation"), ArgoInitiatedViaInfo, ArgoInitiatedViaRadar) {
		t.Fatalf("validation info lacks the Radar provenance: %#v", info)
	}
	if _, ok := nestedMap(body, "operation")["retry"]; ok {
		t.Fatalf("validation dry-run must not carry a retry strategy: %#v", nestedMap(body, "operation"))
	}
}

func TestArgoResourceValidationResultCorrelatesAndReturnsTarget(t *testing.T) {
	target := ArgoSyncResource{Group: "karpenter.sh", Kind: "NodePool", Namespace: "default", Name: "default"}
	makeApp := func(id, phase string, resources []any) *unstructured.Unstructured {
		return argoAppForTest("argocd", "demo", func(obj map[string]any) {
			obj["status"] = map[string]any{
				"operationState": map[string]any{
					"phase": phase,
					"operation": map[string]any{
						"info": []any{map[string]any{"name": "radar-validation-id", "value": id}},
					},
					"syncResult": map[string]any{"resources": resources},
				},
			}
		})
	}
	matchingResource := map[string]any{
		"group": "karpenter.sh", "kind": "NodePool", "namespace": "default", "name": "default",
		"status": "Synced", "message": "nodepool.karpenter.sh/default configured (dry run)",
	}

	if _, done := argoResourceValidationResult(makeApp("old", "Succeeded", []any{matchingResource}), "current", target); done {
		t.Fatal("a terminal result from a previous request must not complete the current validation")
	}
	if _, done := argoResourceValidationResult(makeApp("current", "Running", []any{matchingResource}), "current", target); done {
		t.Fatal("a matching operation that is still running must keep waiting")
	}
	result, done := argoResourceValidationResult(makeApp("current", "Succeeded", []any{matchingResource}), "current", target)
	if !done || result.Outcome != "succeeded" || result.Resource == nil {
		t.Fatalf("result = %#v, done=%v; want succeeded target result", result, done)
	}
	if !strings.Contains(result.Message, "API admission can still reject") {
		t.Fatalf("success message = %q, want dry-run limitation", result.Message)
	}
	if result.Resource.Status != "Synced" || !strings.Contains(result.Resource.Message, "dry run") {
		t.Fatalf("resource result = %#v, want exact Argo status and message", result.Resource)
	}
	targetWithoutNamespace := target
	targetWithoutNamespace.Namespace = ""
	result, done = argoResourceValidationResult(makeApp("current", "Succeeded", []any{matchingResource}), "current", targetWithoutNamespace)
	if !done || result.Outcome != "succeeded" {
		t.Fatalf("namespace-omitted selector result = %#v, done=%v; want succeeded", result, done)
	}
	targetInAnotherNamespace := target
	targetInAnotherNamespace.Namespace = "other"
	if argoResourceResultMatches(matchingResource, targetInAnotherNamespace) {
		t.Fatal("an explicit selector namespace must match the result namespace")
	}
	result, done = argoResourceValidationResult(makeApp("current", "Succeeded", nil), "current", target)
	if !done || result.Outcome != "inconclusive" {
		t.Fatalf("missing target result = %#v, done=%v; want inconclusive", result, done)
	}
}

// TestSetArgoAutoSyncResumeRestoresSettings pins the legacy / current
// annotation lookup on resume. Older Radar builds wrote skyhook.io/* keys;
// the current writer uses radarhq.io/*. Resume must read either, prefer
// the current key when both are present, and fall back to defaults when
// neither is (prune and selfHeal off) — and clear all the keys regardless.
func TestSetArgoAutoSyncResumeRestoresSettings(t *testing.T) {
	cases := []struct {
		name         string
		annotations  map[string]any
		wantPrune    bool
		wantSelfHeal bool
	}{
		{
			name: "current keys only",
			annotations: map[string]any{
				ArgoSuspendedPruneAnnotation:    "false",
				ArgoSuspendedSelfHealAnnotation: "true",
			},
			wantPrune:    false,
			wantSelfHeal: true,
		},
		{
			name: "legacy keys only",
			annotations: map[string]any{
				legacyArgoSuspendedPruneAnnotation:    "true",
				legacyArgoSuspendedSelfHealAnnotation: "false",
			},
			wantPrune:    true,
			wantSelfHeal: false,
		},
		{
			name: "current and legacy both present → current wins",
			annotations: map[string]any{
				ArgoSuspendedPruneAnnotation:          "true",
				legacyArgoSuspendedPruneAnnotation:    "false",
				ArgoSuspendedSelfHealAnnotation:       "false",
				legacyArgoSuspendedSelfHealAnnotation: "true",
			},
			wantPrune:    true,
			wantSelfHeal: false,
		},
		{
			name:         "never suspended → prune and selfHeal off, as in Argo CD",
			annotations:  map[string]any{},
			wantPrune:    false,
			wantSelfHeal: false,
		},
		{
			name: "only a prune key → selfHeal on, the suspending Radar's fallback",
			annotations: map[string]any{
				ArgoSuspendedPruneAnnotation: "true",
			},
			wantPrune:    true,
			wantSelfHeal: true,
		},
		{
			name: "only the legacy prune key → selfHeal on",
			annotations: map[string]any{
				legacyArgoSuspendedPruneAnnotation: "false",
			},
			wantPrune:    false,
			wantSelfHeal: true,
		},
		{
			name: "only the legacy selfHeal key → prune off",
			annotations: map[string]any{
				legacyArgoSuspendedSelfHealAnnotation: "false",
			},
			wantPrune:    false,
			wantSelfHeal: false,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			app := argoAppForTest("argocd", "demo", func(obj map[string]any) {
				md, _ := obj["metadata"].(map[string]any)
				md["annotations"] = tc.annotations
			})
			client := newFakeArgo(app)
			if _, err := SetArgoAutoSync(context.Background(), client, "argocd", "demo", true); err != nil {
				t.Fatalf("SetArgoAutoSync(resume): %v", err)
			}
			body := captureLastPatch(t, client)
			automated := nestedMap(body, "spec", "syncPolicy", "automated")
			if automated == nil {
				t.Fatalf("patch missing spec.syncPolicy.automated: %#v", body)
			}
			if got, _ := automated["prune"].(bool); got != tc.wantPrune {
				t.Fatalf("prune = %v, want %v", got, tc.wantPrune)
			}
			if got, _ := automated["selfHeal"].(bool); got != tc.wantSelfHeal {
				t.Fatalf("selfHeal = %v, want %v", got, tc.wantSelfHeal)
			}
			// All four annotation keys must be cleared (set to nil) so a
			// future suspend cycle starts clean. The patch encodes "delete"
			// as a nil-valued annotations map entry.
			ann := nestedMap(body, "metadata", "annotations")
			if ann == nil {
				t.Fatalf("patch missing metadata.annotations: %#v", body)
			}
			for _, key := range []string{
				ArgoSuspendedAutomatedAnnotation,
				ArgoSuspendedPruneAnnotation,
				ArgoSuspendedSelfHealAnnotation,
				legacyArgoSuspendedPruneAnnotation,
				legacyArgoSuspendedSelfHealAnnotation,
			} {
				v, present := ann[key]
				if !present {
					t.Fatalf("annotation %q not present in patch (must be set to nil to clear)", key)
				}
				if v != nil {
					t.Fatalf("annotation %q = %v, want nil (delete)", key, v)
				}
			}
		})
	}
}

// Argo CD 3.x can switch automation off in place with automated.enabled:
// false. Resume must clear that flag, or Argo stays manual while Radar reports
// auto-sync resumed, and it keeps the block's own prune/selfHeal settings.
func TestSetArgoAutoSyncResumeKeepsTheBlocksSettings(t *testing.T) {
	for name, automated := range map[string]map[string]any{
		"switched off in place": {"enabled": false, "prune": false, "selfHeal": true},
		"already on":            {"prune": false, "selfHeal": true},
	} {
		t.Run(name, func(t *testing.T) {
			client := newFakeArgo(argoAppForTest("argocd", "demo", func(obj map[string]any) {
				spec, _ := obj["spec"].(map[string]any)
				spec["syncPolicy"] = map[string]any{"automated": automated}
			}))
			// A retried or duplicated resume must not fall back to the defaults.
			for call := 1; call <= 2; call++ {
				if _, err := SetArgoAutoSync(context.Background(), client, "argocd", "demo", true); err != nil {
					t.Fatalf("SetArgoAutoSync(resume) #%d: %v", call, err)
				}
				app, err := client.Resource(argoAppGVR).Namespace("argocd").Get(context.Background(), "demo", metav1.GetOptions{})
				if err != nil {
					t.Fatalf("get app: %v", err)
				}
				if !ArgoAutoSyncEnabled(app) {
					t.Fatalf("auto-sync off after resume #%d: %#v", call, app.Object["spec"])
				}
				got, _, _ := unstructured.NestedMap(app.Object, "spec", "syncPolicy", "automated")
				if got["prune"] != false || got["selfHeal"] != true {
					t.Fatalf("resume #%d changed the block's settings: %#v", call, got)
				}
			}
		})
	}
}

// TestFluxOperationsRefuseTerminatingResource extends the Argo coverage in
// TestOperationsRefuseTerminatingResource to the Flux operation surface,
// so a refactor that drops assertNotTerminating from any Flux verb is
// caught at the unit level, not when it hits prod.
func TestFluxOperationsRefuseTerminatingResource(t *testing.T) {
	ctx := context.Background()
	scheme := newFluxScheme(t)
	terminatingKustomization := func() *unstructured.Unstructured {
		return &unstructured.Unstructured{Object: map[string]any{
			"apiVersion": "kustomize.toolkit.fluxcd.io/v1",
			"kind":       "Kustomization",
			"metadata": map[string]any{
				"namespace":         "flux-system",
				"name":              "demo",
				"deletionTimestamp": "2026-04-13T13:14:42Z",
				"finalizers":        []any{"finalizers.fluxcd.io"},
			},
			"spec": map[string]any{
				"sourceRef": map[string]any{
					"kind":      "GitRepository",
					"name":      "source",
					"namespace": "flux-system",
				},
			},
		}}
	}
	kustomizationEntry, err := ResolveFluxKind("Kustomization")
	if err != nil {
		t.Fatalf("ResolveFluxKind: %v", err)
	}
	cases := []struct {
		name string
		fn   func(client *fake.FakeDynamicClient) error
	}{
		{"ReconcileFlux", func(c *fake.FakeDynamicClient) error {
			_, err := ReconcileFlux(ctx, c, kustomizationEntry, "flux-system", "demo")
			return err
		}},
		{"SetFluxSuspend(suspend)", func(c *fake.FakeDynamicClient) error {
			_, err := SetFluxSuspend(ctx, c, kustomizationEntry, "flux-system", "demo", true)
			return err
		}},
		{"SetFluxSuspend(resume)", func(c *fake.FakeDynamicClient) error {
			_, err := SetFluxSuspend(ctx, c, kustomizationEntry, "flux-system", "demo", false)
			return err
		}},
		{"SyncFluxWithSource", func(c *fake.FakeDynamicClient) error {
			_, err := SyncFluxWithSource(ctx, c, "Kustomization", "flux-system", "demo", nil)
			return err
		}},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			client := fake.NewSimpleDynamicClient(scheme, terminatingKustomization())
			err := tc.fn(client)
			if err == nil {
				t.Fatalf("expected error, got nil")
			}
			if !errors.Is(err, ErrResourceTerminating) {
				t.Fatalf("expected ErrResourceTerminating, got %v", err)
			}
			for _, action := range client.Actions() {
				if _, ok := action.(clienttesting.PatchAction); ok {
					t.Fatalf("operation issued a patch despite Terminating; actions=%v", client.Actions())
				}
			}
		})
	}
}

// TestSyncFluxWithSourceCases covers happy-path + the three bad-input shapes
// the real implementation has to navigate: missing sourceRef, unsupported
// outer kind, and the zombie-namespace 404 wrapping.
func TestSyncFluxWithSourceCases(t *testing.T) {
	ctx := context.Background()
	scheme := newFluxScheme(t)

	t.Run("Kustomization happy path patches source then target", func(t *testing.T) {
		client := fake.NewSimpleDynamicClient(scheme,
			fluxGitRepository("flux-system", "source"),
			fluxKustomization("flux-system", "demo", map[string]any{
				"kind": "GitRepository", "name": "source", "namespace": "flux-system",
			}),
		)
		res, err := SyncFluxWithSource(ctx, client, "Kustomization", "flux-system", "demo", nil)
		if err != nil {
			t.Fatalf("SyncFluxWithSource: %v", err)
		}
		if res.Source == nil || res.Source.Kind != "GitRepository" || res.Source.Name != "source" {
			t.Fatalf("unexpected Source ref: %#v", res.Source)
		}
		patches := patchActionsByResource(client)
		if patches["gitrepositories"] != 1 {
			t.Fatalf("expected 1 patch on gitrepositories, got %d", patches["gitrepositories"])
		}
		if patches["kustomizations"] != 1 {
			t.Fatalf("expected 1 patch on kustomizations, got %d", patches["kustomizations"])
		}
	})

	source := func(kind, namespace, name string) *unstructured.Unstructured {
		return &unstructured.Unstructured{Object: map[string]any{
			"apiVersion": "source.toolkit.fluxcd.io/v1",
			"kind":       kind,
			"metadata":   map[string]any{"namespace": namespace, "name": name},
		}}
	}
	release := func(spec, status map[string]any) *unstructured.Unstructured {
		obj := map[string]any{
			"apiVersion": "helm.toolkit.fluxcd.io/v2",
			"kind":       "HelmRelease",
			"metadata":   map[string]any{"namespace": "team", "name": "demo"},
			"spec":       runtime.DeepCopyJSON(spec),
		}
		if status != nil {
			obj["status"] = status
		}
		return &unstructured.Unstructured{Object: obj}
	}
	inline := map[string]any{"chart": map[string]any{"spec": map[string]any{"sourceRef": map[string]any{"kind": "HelmRepository", "name": "repo", "namespace": "flux-system"}}}}

	chart := func(namespace, name string, sourceRef map[string]any) *unstructured.Unstructured {
		obj := source("HelmChart", namespace, name)
		obj.Object["spec"] = map[string]any{"sourceRef": sourceRef}
		return obj
	}
	repoRef := map[string]any{"kind": "HelmRepository", "name": "repo"}

	// Matches `flux reconcile hr --with-source`: the chart is reconciled with
	// its own source first, so a new chart version in the repository resolves.
	for _, tc := range []struct {
		name    string
		hr      *unstructured.Unstructured
		objects []runtime.Object
		source  string
		want    []string
	}{
		{"inline release reconciles repository, generated chart, release", release(inline, nil),
			[]runtime.Object{source("HelmRepository", "flux-system", "repo"), chart("flux-system", "team-demo", repoRef)},
			"HelmChart flux-system/team-demo", []string{"helmrepositories flux-system/repo", "helmcharts flux-system/team-demo", "helmreleases team/demo"}},
		{"inline release follows the current spec, not a stale recorded chart", release(inline, map[string]any{"helmChart": "old-charts/team-demo"}),
			[]runtime.Object{source("HelmRepository", "flux-system", "repo"), chart("flux-system", "team-demo", repoRef), chart("old-charts", "team-demo", repoRef)},
			"HelmChart flux-system/team-demo", []string{"helmrepositories flux-system/repo", "helmcharts flux-system/team-demo", "helmreleases team/demo"}},
		{"chartRef to an OCIRepository", release(map[string]any{"chartRef": map[string]any{"kind": "OCIRepository", "name": "chart"}}, nil),
			[]runtime.Object{source("OCIRepository", "team", "chart")},
			"OCIRepository team/chart", []string{"ocirepositories team/chart", "helmreleases team/demo"}},
		{"chartRef to a HelmChart in another namespace reconciles the chart's source first", release(map[string]any{"chartRef": map[string]any{"kind": "HelmChart", "name": "shared", "namespace": "charts"}}, nil),
			[]runtime.Object{source("GitRepository", "charts", "git"), chart("charts", "shared", map[string]any{"kind": "GitRepository", "name": "git"})},
			"HelmChart charts/shared", []string{"gitrepositories charts/git", "helmcharts charts/shared", "helmreleases team/demo"}},
	} {
		t.Run(tc.name, func(t *testing.T) {
			client := fake.NewSimpleDynamicClient(scheme, append(tc.objects, tc.hr)...)
			res, err := SyncFluxWithSource(ctx, client, "HelmRelease", "team", "demo", nil)
			if err != nil {
				t.Fatalf("SyncFluxWithSource: %v", err)
			}
			if res.Source == nil || res.Source.Kind+" "+res.Source.Namespace+"/"+res.Source.Name != tc.source {
				t.Fatalf("source = %#v, want %s", res.Source, tc.source)
			}
			if got := patchSequence(client); !slices.Equal(got, tc.want) {
				t.Fatalf("patches = %v, want %v", got, tc.want)
			}
		})
	}

	t.Run("a generated chart that doesn't exist yet fails before any patch", func(t *testing.T) {
		client := fake.NewSimpleDynamicClient(scheme, source("HelmRepository", "flux-system", "repo"), release(inline, nil))
		_, err := SyncFluxWithSource(ctx, client, "HelmRelease", "team", "demo", nil)
		if !apierrors.IsNotFound(err) {
			t.Fatalf("want NotFound, got %v", err)
		}
		if got := patchSequence(client); len(got) != 0 {
			t.Fatalf("patches = %v", got)
		}
	})

	suspended := func(obj *unstructured.Unstructured) *unstructured.Unstructured {
		spec, _ := obj.Object["spec"].(map[string]any)
		if spec == nil {
			spec = map[string]any{}
		}
		spec["suspend"] = true
		obj.Object["spec"] = spec
		return obj
	}
	for _, tc := range []struct {
		name    string
		objects []runtime.Object
	}{
		{"suspended release", []runtime.Object{source("HelmRepository", "flux-system", "repo"), chart("flux-system", "team-demo", repoRef), suspended(release(inline, nil))}},
		{"suspended generated chart", []runtime.Object{source("HelmRepository", "flux-system", "repo"), suspended(chart("flux-system", "team-demo", repoRef)), release(inline, nil)}},
		{"suspended repository behind the chart", []runtime.Object{suspended(source("HelmRepository", "flux-system", "repo")), chart("flux-system", "team-demo", repoRef), release(inline, nil)}},
	} {
		t.Run(tc.name+" is refused before any patch", func(t *testing.T) {
			client := fake.NewSimpleDynamicClient(scheme, tc.objects...)
			_, err := SyncFluxWithSource(ctx, client, "HelmRelease", "team", "demo", nil)
			if !errors.Is(err, ErrResourceSuspended) {
				t.Fatalf("want ErrResourceSuspended, got %v", err)
			}
			if got := patchSequence(client); len(got) != 0 {
				t.Fatalf("patches = %v", got)
			}
		})
	}

	for _, tc := range []struct {
		name, resource string
		err            error
	}{
		{"an unreadable generated chart", "helmcharts", apierrors.NewForbidden(schema.GroupResource{Group: "source.toolkit.fluxcd.io", Resource: "helmcharts"}, "team-demo", errors.New("denied"))},
		{"an unreadable repository behind the chart", "helmrepositories", apierrors.NewForbidden(schema.GroupResource{Group: "source.toolkit.fluxcd.io", Resource: "helmrepositories"}, "repo", errors.New("denied"))},
		{"a source read that times out", "helmrepositories", apierrors.NewTimeoutError("slow", 1)},
	} {
		t.Run(tc.name+" fails before any patch", func(t *testing.T) {
			client := fake.NewSimpleDynamicClient(scheme, suspended(source("HelmRepository", "flux-system", "repo")), chart("flux-system", "team-demo", repoRef), release(inline, nil))
			client.PrependReactor("get", tc.resource, func(clienttesting.Action) (bool, runtime.Object, error) { return true, nil, tc.err })
			_, err := SyncFluxWithSource(ctx, client, "HelmRelease", "team", "demo", nil)
			if err == nil || errors.Is(err, ErrResourceSuspended) || apierrors.ReasonForError(err) != apierrors.ReasonForError(tc.err) {
				t.Fatalf("want the read error %v, got %v", tc.err, err)
			}
			if got := patchSequence(client); len(got) != 0 {
				t.Fatalf("patches = %v", got)
			}
		})
	}

	t.Run("callers without a resolver keep the static versions", func(t *testing.T) {
		client := fake.NewSimpleDynamicClient(scheme, fluxGitRepository("flux-system", "source"),
			fluxKustomization("flux-system", "demo", map[string]any{"kind": "GitRepository", "name": "source"}))
		if _, err := SyncFluxWithSource(ctx, client, "Kustomization", "flux-system", "demo"); err != nil {
			t.Fatalf("SyncFluxWithSource: %v", err)
		}
	})

	t.Run("sources are patched at the version the cluster serves", func(t *testing.T) {
		older := source("OCIRepository", "team", "chart")
		older.SetAPIVersion("source.toolkit.fluxcd.io/v1beta2")
		hr := release(map[string]any{"chartRef": map[string]any{"kind": "OCIRepository", "name": "chart"}}, nil)
		served := func(group, kind string) (schema.GroupVersionResource, bool) {
			if kind == "OCIRepository" {
				return schema.GroupVersionResource{Group: group, Version: "v1beta2", Resource: "ocirepositories"}, true
			}
			return schema.GroupVersionResource{}, false
		}
		if _, err := SyncFluxWithSource(ctx, fake.NewSimpleDynamicClient(scheme, older, hr.DeepCopy()), "HelmRelease", "team", "demo", nil); !apierrors.IsNotFound(err) {
			t.Fatalf("static v1 against a v1beta2-only cluster: want NotFound, got %v", err)
		}
		client := fake.NewSimpleDynamicClient(scheme, older, hr)
		if _, err := SyncFluxWithSource(ctx, client, "HelmRelease", "team", "demo", served); err != nil {
			t.Fatalf("SyncFluxWithSource: %v", err)
		}
		if got := patchSequence(client); !slices.Equal(got, []string{"ocirepositories team/chart", "helmreleases team/demo"}) {
			t.Fatalf("patches = %v", got)
		}
	})

	t.Run("chartRef to an ExternalArtifact reconciles only the release", func(t *testing.T) {
		client := fake.NewSimpleDynamicClient(scheme, release(map[string]any{"chartRef": map[string]any{"kind": "ExternalArtifact", "name": "generated"}}, nil))
		res, err := SyncFluxWithSource(ctx, client, "HelmRelease", "team", "demo", nil)
		if err != nil {
			t.Fatalf("SyncFluxWithSource: %v", err)
		}
		if res.Source != nil || !strings.Contains(res.Message, "produced by another controller") {
			t.Fatalf("result = %#v", res)
		}
		if patches := patchActionsByResource(client); len(patches) != 1 || patches["helmreleases"] != 1 {
			t.Fatalf("patches = %v", patches)
		}
	})

	t.Run("Kustomization from a Bucket", func(t *testing.T) {
		client := fake.NewSimpleDynamicClient(scheme, source("Bucket", "flux-system", "manifests"),
			fluxKustomization("flux-system", "demo", map[string]any{"kind": "Bucket", "name": "manifests"}))
		res, err := SyncFluxWithSource(ctx, client, "Kustomization", "flux-system", "demo", nil)
		if err != nil || res.Source == nil || res.Source.Kind != "Bucket" {
			t.Fatalf("result = %#v err = %v", res, err)
		}
	})

	t.Run("missing sourceRef returns an explicit error before any patch", func(t *testing.T) {
		client := fake.NewSimpleDynamicClient(scheme, fluxKustomization("flux-system", "demo", nil))
		_, err := SyncFluxWithSource(ctx, client, "Kustomization", "flux-system", "demo", nil)
		if err == nil {
			t.Fatal("expected error for missing sourceRef")
		}
		if !strings.Contains(err.Error(), "no source reference") {
			t.Fatalf("error should name the missing source ref; got %v", err)
		}
		for _, action := range client.Actions() {
			if _, ok := action.(clienttesting.PatchAction); ok {
				t.Fatal("no patch should fire when sourceRef is missing")
			}
		}
	})

	t.Run("unsupported kind rejected without API call", func(t *testing.T) {
		// GitRepository is a valid Flux kind but sync-with-source isn't
		// defined for it (it's already a source). Use a non-empty spec so
		// the spec-shape guard passes and we exercise the kind-switch default.
		repo := &unstructured.Unstructured{Object: map[string]any{
			"apiVersion": "source.toolkit.fluxcd.io/v1",
			"kind":       "GitRepository",
			"metadata":   map[string]any{"namespace": "flux-system", "name": "source"},
			"spec":       map[string]any{"interval": "1m", "url": "https://example.com"},
		}}
		client := fake.NewSimpleDynamicClient(scheme, repo)
		_, err := SyncFluxWithSource(ctx, client, "GitRepository", "flux-system", "source", nil)
		if err == nil {
			t.Fatal("expected error for unsupported kind")
		}
		if !strings.Contains(err.Error(), "only supported for Kustomization and HelmRelease") {
			t.Fatalf("error should name the supported kinds; got %v", err)
		}
	})

	t.Run("source-not-found wraps with finalizer-zombie context and preserves NotFound chain", func(t *testing.T) {
		// Kustomization references a source that doesn't exist in the
		// fake client — patch returns NotFound. The wrapped error must
		// (a) mention the zombie scenario for operator clarity and
		// (b) errors.Is-match apierrors.IsNotFound so HTTP layer maps to 404.
		client := fake.NewSimpleDynamicClient(scheme,
			fluxKustomization("flux-system", "demo", map[string]any{
				"kind": "GitRepository", "name": "ghost", "namespace": "flux-system",
			}),
		)
		_, err := SyncFluxWithSource(ctx, client, "Kustomization", "flux-system", "demo", nil)
		if err == nil {
			t.Fatal("expected NotFound error from missing source")
		}
		if !apierrors.IsNotFound(err) {
			t.Fatalf("expected apierrors.IsNotFound to match (HTTP layer relies on this); got %v", err)
		}
		if !strings.Contains(err.Error(), "finalizer-stuck zombie") {
			t.Fatalf("error should include the zombie hint to disambiguate cause; got %v", err)
		}
	})
}

// --- Flux test helpers ---

func newFluxScheme(t *testing.T) *runtime.Scheme {
	t.Helper()
	scheme := runtime.NewScheme()
	for kind, gv := range map[string]schema.GroupVersion{
		"Kustomization":  {Group: "kustomize.toolkit.fluxcd.io", Version: "v1"},
		"HelmRelease":    {Group: "helm.toolkit.fluxcd.io", Version: "v2"},
		"GitRepository":  {Group: "source.toolkit.fluxcd.io", Version: "v1"},
		"HelmRepository": {Group: "source.toolkit.fluxcd.io", Version: "v1"},
		"OCIRepository":  {Group: "source.toolkit.fluxcd.io", Version: "v1"},
		"OCIRepository.": {Group: "source.toolkit.fluxcd.io", Version: "v1beta2"},
		"HelmChart":      {Group: "source.toolkit.fluxcd.io", Version: "v1"},
		"Bucket":         {Group: "source.toolkit.fluxcd.io", Version: "v1"},
	} {
		kind = strings.TrimSuffix(kind, ".")
		scheme.AddKnownTypeWithName(gv.WithKind(kind), &unstructured.Unstructured{})
		scheme.AddKnownTypeWithName(gv.WithKind(kind+"List"), &unstructured.UnstructuredList{})
	}
	_ = corev1.AddToScheme(scheme)
	return scheme
}

func fluxKustomization(namespace, name string, sourceRef map[string]any) *unstructured.Unstructured {
	spec := map[string]any{}
	if sourceRef != nil {
		spec["sourceRef"] = sourceRef
	}
	return &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "kustomize.toolkit.fluxcd.io/v1",
		"kind":       "Kustomization",
		"metadata":   map[string]any{"namespace": namespace, "name": name},
		"spec":       spec,
	}}
}

func fluxGitRepository(namespace, name string) *unstructured.Unstructured {
	return &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "source.toolkit.fluxcd.io/v1",
		"kind":       "GitRepository",
		"metadata":   map[string]any{"namespace": namespace, "name": name},
	}}
}

// patchSequence lists patches in order as "<resource> <namespace>/<name>".
func patchSequence(client *fake.FakeDynamicClient) []string {
	var out []string
	for _, action := range client.Actions() {
		if pa, ok := action.(clienttesting.PatchAction); ok {
			out = append(out, pa.GetResource().Resource+" "+pa.GetNamespace()+"/"+pa.GetName())
		}
	}
	return out
}

func patchActionsByResource(client *fake.FakeDynamicClient) map[string]int {
	out := map[string]int{}
	for _, action := range client.Actions() {
		if pa, ok := action.(clienttesting.PatchAction); ok {
			out[pa.GetResource().Resource]++
		}
	}
	return out
}

func TestTerminateArgoSyncJSONPatchRaceMapsToSentinel(t *testing.T) {
	app := argoAppForTest("argocd", "demo", func(obj map[string]any) {
		status, _ := obj["status"].(map[string]any)
		status["operationState"] = map[string]any{"phase": "Running"}
	})
	client := newFakeArgo(app)
	client.PrependReactor("patch", "applications", func(action clienttesting.Action) (bool, runtime.Object, error) {
		// Mimic the K8s API server's JSON-Patch error when /operation is
		// missing: an Invalid StatusError. apierrors.IsInvalid must match.
		return true, nil, apierrors.NewInvalid(
			schema.GroupKind{Group: "argoproj.io", Kind: "Application"},
			"demo",
			nil,
		)
	})
	_, err := TerminateArgoSync(context.Background(), client, "argocd", "demo")
	if err == nil {
		t.Fatal("expected error from racing terminate")
	}
	if !errors.Is(err, ErrNoOperationInProgress) {
		t.Fatalf("expected ErrNoOperationInProgress, got %v", err)
	}
}

func TestTerminateArgoSyncRequestsControllerTermination(t *testing.T) {
	app := argoAppForTest("argocd", "demo", func(obj map[string]any) {
		metadata, _ := obj["metadata"].(map[string]any)
		metadata["resourceVersion"] = "23"
		obj["operation"] = map[string]any{"sync": map[string]any{"revision": "abc123"}}
		status, _ := obj["status"].(map[string]any)
		status["operationState"] = map[string]any{"phase": "Running"}
	})
	client := newFakeArgo(app)

	result, err := TerminateArgoSync(context.Background(), client, "argocd", "demo")
	if err != nil {
		t.Fatalf("TerminateArgoSync: %v", err)
	}
	if result.Message != "Termination requested" {
		t.Fatalf("message = %q, want termination request", result.Message)
	}
	updated, err := client.Resource(argoAppGVR).Namespace("argocd").Get(context.Background(), "demo", metav1.GetOptions{})
	if err != nil {
		t.Fatalf("get updated Application: %v", err)
	}
	phase, _, _ := unstructured.NestedString(updated.Object, "status", "operationState", "phase")
	if phase != "Terminating" {
		t.Fatalf("operation phase = %q, want Terminating", phase)
	}
	if operation, found, _ := unstructured.NestedFieldNoCopy(updated.Object, "operation"); !found || operation == nil {
		t.Fatal("terminate removed spec.operation instead of leaving it for the Argo controller")
	}
	for _, action := range client.Actions() {
		patchAction, ok := action.(clienttesting.PatchAction)
		if !ok {
			continue
		}
		var operations []map[string]any
		if err := json.Unmarshal(patchAction.GetPatch(), &operations); err != nil {
			t.Fatalf("decode terminate patch: %v", err)
		}
		for _, operation := range operations {
			if operation["path"] == "/metadata/resourceVersion" {
				t.Fatal("terminate must not reject a live operation because unrelated status updates changed resourceVersion")
			}
		}
	}
}

func TestTerminateArgoSyncReportsAlreadyTerminatingAsNoChange(t *testing.T) {
	app := argoAppForTest("argocd", "demo", func(obj map[string]any) {
		obj["operation"] = map[string]any{"sync": map[string]any{"revision": "abc123"}}
		status, _ := obj["status"].(map[string]any)
		status["operationState"] = map[string]any{"phase": "Terminating"}
	})
	client := newFakeArgo(app)

	result, err := TerminateArgoSync(context.Background(), client, "argocd", "demo")
	if err != nil {
		t.Fatalf("TerminateArgoSync: %v", err)
	}
	if !result.NoChange || result.Message != "Termination already in progress" {
		t.Fatalf("result = %+v, want explicit no-change termination", result)
	}
	for _, action := range client.Actions() {
		if _, ok := action.(clienttesting.PatchAction); ok {
			t.Fatalf("already-terminating application was patched: %+v", client.Actions())
		}
	}
}

func TestTerminateArgoSyncRepairsStaleRunningStatus(t *testing.T) {
	app := argoAppForTest("argocd", "demo", func(obj map[string]any) {
		status, _ := obj["status"].(map[string]any)
		status["operationState"] = map[string]any{"phase": "Running", "message": "waiting for hook"}
	})
	client := newFakeArgo(app)

	result, err := TerminateArgoSync(context.Background(), client, "argocd", "demo")
	if err != nil {
		t.Fatalf("TerminateArgoSync: %v", err)
	}
	if result.Message != "Stale operation status cleared" {
		t.Fatalf("message = %q, want stale-status recovery", result.Message)
	}
	updated, err := client.Resource(argoAppGVR).Namespace("argocd").Get(context.Background(), "demo", metav1.GetOptions{})
	if err != nil {
		t.Fatalf("get updated Application: %v", err)
	}
	phase, _, _ := unstructured.NestedString(updated.Object, "status", "operationState", "phase")
	if phase != "Error" {
		t.Fatalf("operation phase = %q, want Error", phase)
	}
	finishedAt, _, _ := unstructured.NestedString(updated.Object, "status", "operationState", "finishedAt")
	if _, err := time.Parse(time.RFC3339Nano, finishedAt); err != nil {
		t.Fatalf("finishedAt = %q, want RFC3339 timestamp: %v", finishedAt, err)
	}
}

// Suppress the unused-metav1 lint when this file is compiled alone.
var _ = metav1.ObjectMeta{}

// completedAutomatedState is what Argo CD leaves in status.operationState after
// an automated sync that needed retries.
func completedAutomatedState() map[string]any {
	return map[string]any{
		"phase":      "Succeeded",
		"message":    "successfully synced (all tasks run)",
		"retryCount": int64(2),
		"startedAt":  "2026-10-10T10:00:00Z",
		"finishedAt": "2026-10-10T10:00:05Z",
		"operation": map[string]any{
			"initiatedBy": map[string]any{"automated": true},
			"retry":       map[string]any{"limit": int64(5)},
			"sync":        map[string]any{"revision": "old", "prune": true},
		},
		"syncResult": map[string]any{"revision": "old", "resources": []any{}},
	}
}

func appWithHistoryAndState(state map[string]any) *unstructured.Unstructured {
	return argoAppForTest("argocd", "demo", func(obj map[string]any) {
		status, _ := obj["status"].(map[string]any)
		status["history"] = []any{map[string]any{"id": int64(1), "revision": "abc", "source": map[string]any{"repoURL": "https://example.com/repo"}}}
		if state != nil {
			status["operationState"] = state
		}
	})
}

// A new operation replaces a completed status.operationState in the same
// write, as Argo CD's SetAppOperation does. Otherwise the controller's merge
// patch keeps the old state's initiatedBy.automated, retryCount and
// syncResult, and its sync-window check reads the old automated state.
func TestArgoOperationsClearCompletedOperationState(t *testing.T) {
	run := map[string]func(*fake.FakeDynamicClient) error{
		"sync": func(client *fake.FakeDynamicClient) error {
			_, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{})
			return err
		},
		"rollback": func(client *fake.FakeDynamicClient) error {
			_, err := RollbackArgoApp(context.Background(), client, "argocd", "demo", ArgoRollbackOptions{ID: 1})
			return err
		},
	}
	for name, start := range run {
		for stateName, completedState := range map[string]map[string]any{
			"automated": completedAutomatedState(),
			"rollback": {
				"phase":      "Succeeded",
				"operation":  map[string]any{"info": []any{map[string]any{"name": argoRollbackInfoName, "value": "true"}}},
				"syncResult": map[string]any{"revision": "abc123"},
			},
		} {
			t.Run(name+" clears a completed "+stateName+" state", func(t *testing.T) {
				client := newFakeArgo(appWithHistoryAndState(completedState))
				if err := start(client); err != nil {
					t.Fatalf("%s: %v", name, err)
				}
				body := captureLastPatch(t, client)
				status, ok := body["status"].(map[string]any)
				if !ok {
					t.Fatalf("patch does not touch status: %#v", body)
				}
				if state, present := status["operationState"]; !present || state != nil {
					t.Fatalf("status.operationState = %#v (present %v), want null", state, present)
				}
				if nestedMap(body, "operation", "sync") == nil {
					t.Fatalf("patch does not install the operation: %#v", body)
				}
				app, err := client.Resource(argoAppGVR).Namespace("argocd").Get(context.Background(), "demo", metav1.GetOptions{})
				if err != nil {
					t.Fatalf("get app: %v", err)
				}
				if _, found, _ := unstructured.NestedFieldNoCopy(app.Object, "status", "operationState"); found {
					t.Fatalf("stored Application still has the old operationState: %#v", app.Object["status"])
				}
				if _, found, _ := unstructured.NestedSlice(app.Object, "status", "history"); !found {
					t.Fatalf("clearing the operation state dropped status.history: %#v", app.Object["status"])
				}
			})
		}
		t.Run(name+" leaves status alone when there is no state", func(t *testing.T) {
			client := newFakeArgo(appWithHistoryAndState(nil))
			if err := start(client); err != nil {
				t.Fatalf("%s: %v", name, err)
			}
			if _, ok := captureLastPatch(t, client)["status"]; ok {
				t.Fatalf("patch touches status with no operationState to clear: %#v", captureLastPatch(t, client))
			}
		})
	}
}

// An omitted syncOptions inherits the Application's own, as argocd-server
// does; an explicit list, even an empty one, replaces them. A sync also
// carries the Application's retry strategy, while a rollback never does
// (argocd-server's Rollback doesn't) and still inherits the sync options.
func TestArgoOperationsInheritSyncOptionsAndRetry(t *testing.T) {
	appSyncOptions := []any{"CreateNamespace=true", "ServerSideApply=true"}
	appRetry := map[string]any{"limit": float64(3), "backoff": map[string]any{"duration": "5s"}}
	withPolicy := func() *unstructured.Unstructured {
		app := appWithHistoryAndState(nil)
		spec, _ := app.Object["spec"].(map[string]any)
		spec["syncPolicy"] = map[string]any{"syncOptions": appSyncOptions, "retry": appRetry}
		return app
	}
	syncWith := func(t *testing.T, app *unstructured.Unstructured, opts ArgoSyncOptions) map[string]any {
		t.Helper()
		client := newFakeArgo(app)
		if _, err := SyncArgoApp(context.Background(), client, "argocd", "demo", opts); err != nil {
			t.Fatalf("SyncArgoApp: %v", err)
		}
		return nestedMap(captureLastPatch(t, client), "operation")
	}

	t.Run("omitted options inherit the Application's options and retry", func(t *testing.T) {
		operation := syncWith(t, withPolicy(), ArgoSyncOptions{})
		if got := nestedMap(operation, "sync")["syncOptions"]; !reflect.DeepEqual(got, appSyncOptions) {
			t.Fatalf("syncOptions = %#v, want %#v", got, appSyncOptions)
		}
		if got := nestedMap(operation, "retry"); !reflect.DeepEqual(got, appRetry) {
			t.Fatalf("retry = %#v, want %#v", got, appRetry)
		}
	})
	t.Run("explicit options replace the Application's", func(t *testing.T) {
		operation := syncWith(t, withPolicy(), ArgoSyncOptions{SyncOptions: []string{"Replace=true"}})
		if got := nestedMap(operation, "sync")["syncOptions"]; !reflect.DeepEqual(got, []any{"Replace=true"}) {
			t.Fatalf("syncOptions = %#v, want only Replace=true", got)
		}
	})
	t.Run("an explicit empty list syncs with no options", func(t *testing.T) {
		operation := syncWith(t, withPolicy(), ArgoSyncOptions{SyncOptions: []string{}})
		if got, ok := nestedMap(operation, "sync")["syncOptions"]; ok {
			t.Fatalf("syncOptions = %#v, want none", got)
		}
	})
	t.Run("an Application without a policy adds neither", func(t *testing.T) {
		operation := syncWith(t, appWithHistoryAndState(nil), ArgoSyncOptions{})
		if got, ok := nestedMap(operation, "sync")["syncOptions"]; ok {
			t.Fatalf("syncOptions = %#v, want none", got)
		}
		if got, ok := operation["retry"]; ok {
			t.Fatalf("retry = %#v, want none", got)
		}
	})
	t.Run("rollback inherits options but not retry", func(t *testing.T) {
		client := newFakeArgo(withPolicy())
		if _, err := RollbackArgoApp(context.Background(), client, "argocd", "demo", ArgoRollbackOptions{ID: 1}); err != nil {
			t.Fatalf("RollbackArgoApp: %v", err)
		}
		operation := nestedMap(captureLastPatch(t, client), "operation")
		if got := nestedMap(operation, "sync")["syncOptions"]; !reflect.DeepEqual(got, appSyncOptions) {
			t.Fatalf("rollback syncOptions = %#v, want %#v", got, appSyncOptions)
		}
		if got, ok := operation["retry"]; ok {
			t.Fatalf("rollback retry = %#v, want none", got)
		}
	})
}

func TestSyncArgoAppRevision(t *testing.T) {
	for _, sourceCount := range []int{0, 1, 2} {
		for _, revision := range []string{"", "release-1"} {
			for _, dryRun := range []bool{false, true} {
				t.Run(fmt.Sprintf("sources=%d/revision=%s/dryRun=%t", sourceCount, revision, dryRun), func(t *testing.T) {
					app := argoAppForTest("argocd", "demo", func(obj map[string]any) {
						sources := make([]any, sourceCount)
						for i := range sources {
							sources[i] = map[string]any{"repoURL": "https://example.com/repo", "targetRevision": "main"}
						}
						obj["spec"].(map[string]any)["sources"] = sources
					})
					client := newFakeArgo(app)
					_, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{Revision: revision, DryRun: &dryRun})
					if sourceCount > 0 && revision != "" {
						if !errors.Is(err, ErrInvalidRevision) || !strings.Contains(err.Error(), "multi-source") {
							t.Fatalf("error = %v, want invalid revision for a multi-source Application", err)
						}
						for _, action := range client.Actions() {
							if action.GetVerb() != "get" {
								t.Fatalf("rejected sync wrote to the Application: %v", action)
							}
						}
						return
					}
					if err != nil {
						t.Fatalf("SyncArgoApp: %v", err)
					}
					if got := nestedMap(captureLastPatch(t, client), "operation", "sync")["revision"]; got != revision {
						t.Fatalf("revision = %v, want %q", got, revision)
					}
				})
			}
		}
	}
}

func TestSyncArgoAppAutoSyncRevision(t *testing.T) {
	for _, tc := range []struct {
		name         string
		target       string
		hydrated     bool
		scalarTarget string
		revision     string
		dryRun       bool
		enabled      any
		wantRefusal  bool
	}{
		{name: "different revision", target: "main", revision: "old-sha", wantRefusal: true},
		{name: "explicitly enabled", target: "main", revision: "old-sha", enabled: true, wantRefusal: true},
		{name: "same target", target: "main", revision: "main"},
		{name: "empty revision", target: "main"},
		{name: "default HEAD", revision: "HEAD"},
		{name: "different from default HEAD", revision: "old-sha", wantRefusal: true},
		{name: "dry run", target: "main", revision: "old-sha", dryRun: true},
		{name: "explicitly disabled", target: "main", revision: "old-sha", enabled: false},
		{name: "hydrated same target", hydrated: true, target: "environments/prod", revision: "environments/prod"},
		{name: "hydrated different revision", hydrated: true, target: "environments/prod", revision: "old-sha", wantRefusal: true},
		{name: "hydrated HEAD differs", hydrated: true, target: "environments/prod", revision: "HEAD", wantRefusal: true},
		{name: "hydrated explicit HEAD target", hydrated: true, target: "HEAD", revision: "HEAD"},
		{name: "hydrated empty revision", hydrated: true, target: "environments/prod"},
		{name: "hydrated dry run", hydrated: true, target: "environments/prod", revision: "old-sha", dryRun: true},
		{name: "hydrated explicitly disabled", hydrated: true, target: "environments/prod", revision: "old-sha", enabled: false},
		{name: "hydrated target takes precedence", hydrated: true, target: "environments/prod", scalarTarget: "main", revision: "environments/prod"},
		{name: "hydrated scalar target refused", hydrated: true, target: "environments/prod", scalarTarget: "main", revision: "main", wantRefusal: true},
		{name: "hydrated missing target refuses HEAD", hydrated: true, revision: "HEAD", wantRefusal: true},
		{name: "hydrated missing target ignores scalar target", hydrated: true, scalarTarget: "main", revision: "main", wantRefusal: true},
		{name: "hydrated missing target empty revision", hydrated: true},
		{name: "hydrated missing target dry run", hydrated: true, revision: "HEAD", dryRun: true},
	} {
		t.Run(tc.name, func(t *testing.T) {
			app := argoAppForTest("argocd", "demo", func(obj map[string]any) {
				automated := map[string]any{}
				if tc.enabled != nil {
					automated["enabled"] = tc.enabled
				}
				spec := obj["spec"].(map[string]any)
				if tc.hydrated {
					spec["sourceHydrator"] = map[string]any{
						"drySource":  map[string]any{"repoURL": "https://example.com/repo", "path": "app", "targetRevision": "main"},
						"syncSource": map[string]any{"targetBranch": tc.target, "path": "rendered"},
					}
					if tc.scalarTarget != "" {
						spec["source"] = map[string]any{"targetRevision": tc.scalarTarget}
					}
				} else {
					spec["source"] = map[string]any{"targetRevision": tc.target}
				}
				spec["syncPolicy"] = map[string]any{"automated": automated}
			})
			client := newFakeArgo(app)
			_, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{Revision: tc.revision, DryRun: &tc.dryRun})
			if tc.wantRefusal {
				if !errors.Is(err, ErrAutoSyncEnabled) || !strings.Contains(err.Error(), "disable auto-sync first") {
					t.Fatalf("error = %v, want ErrAutoSyncEnabled and suspend guidance", err)
				}
				if tc.hydrated && tc.target == "" && !strings.Contains(err.Error(), "hydrated sync target branch is not configured") {
					t.Fatalf("error = %v, want missing hydrated target guidance", err)
				}
				for _, action := range client.Actions() {
					if action.GetVerb() != "get" {
						t.Fatalf("refused sync wrote: %v", action)
					}
				}
				return
			}
			if err != nil {
				t.Fatalf("SyncArgoApp: %v", err)
			}
			if got := nestedMap(captureLastPatch(t, client), "operation", "sync")["revision"]; got != tc.revision {
				t.Fatalf("revision = %v, want %q", got, tc.revision)
			}
		})
	}
}

func TestSyncArgoAppRechecksAutoSyncRevisionAfterConflict(t *testing.T) {
	client := newFakeArgo(argoAppForTest("argocd", "demo", func(obj map[string]any) {
		obj["spec"].(map[string]any)["source"] = map[string]any{"targetRevision": "main"}
	}))
	patches := conflictOnce(t, client, func(app *unstructured.Unstructured) {
		if err := unstructured.SetNestedMap(app.Object, map[string]any{}, "spec", "syncPolicy", "automated"); err != nil {
			t.Fatal(err)
		}
	})
	_, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{Revision: "old-sha"})
	if !errors.Is(err, ErrAutoSyncEnabled) || *patches != 1 {
		t.Fatalf("error = %v, patches = %d; want ErrAutoSyncEnabled without a second patch", err, *patches)
	}
}

func TestArgoSyncOperationPinsComparedRevisions(t *testing.T) {
	source := map[string]any{"repoURL": "https://example.com/repo", "path": "app", "targetRevision": "main"}
	otherSource := map[string]any{"repoURL": "https://example.com/values", "ref": "values", "targetRevision": "main"}
	sources := []any{source, otherSource}
	single := map[string]any{"source": source}
	multi := map[string]any{"sources": sources}
	singleComparison := map[string]any{"revision": "compared-sha", "comparedTo": single}
	multiComparison := map[string]any{"revisions": []any{"app-sha", "values-sha"}, "comparedTo": multi}
	dryRun := true
	tests := []struct {
		name          string
		spec          map[string]any
		comparison    map[string]any
		opts          ArgoSyncOptions
		validationID  string
		wantRevision  string
		wantRevisions []string
	}{
		{name: "single source", spec: single, comparison: singleComparison, wantRevision: "compared-sha"},
		{name: "multiple sources", spec: multi, comparison: multiComparison, wantRevisions: []string{"app-sha", "values-sha"}},
		{name: "one plural source", spec: map[string]any{"sources": []any{source}}, comparison: map[string]any{"revisions": []any{"app-sha"}, "comparedTo": map[string]any{"sources": []any{source}}}, wantRevisions: []string{"app-sha"}},
		{name: "single source changed", spec: single, comparison: map[string]any{"revision": "old-sha", "comparedTo": map[string]any{"source": otherSource}}},
		{name: "multiple sources changed", spec: multi, comparison: map[string]any{"revisions": []any{"app-sha", "values-sha"}, "comparedTo": map[string]any{"sources": []any{otherSource, source}}}},
		{name: "incomplete revisions", spec: multi, comparison: map[string]any{"revisions": []any{"app-sha"}, "comparedTo": multi}},
		{name: "too many revisions", spec: multi, comparison: map[string]any{"revisions": []any{"app-sha", "values-sha", "extra"}, "comparedTo": multi}},
		{name: "missing comparison", spec: single},
		{name: "missing single revision", spec: single, comparison: map[string]any{"comparedTo": single}},
		{name: "missing plural revisions", spec: multi, comparison: map[string]any{"comparedTo": multi}},
		{name: "explicit branch", spec: single, comparison: singleComparison, opts: ArgoSyncOptions{Revision: "main"}, wantRevision: "main"},
		{name: "explicit commit", spec: single, comparison: singleComparison, opts: ArgoSyncOptions{Revision: "requested-sha"}, wantRevision: "requested-sha"},
		{name: "user dry-run", spec: single, comparison: singleComparison, opts: ArgoSyncOptions{DryRun: &dryRun}, wantRevision: "compared-sha"},
		// Validation checks what the following sync deploys.
		{name: "single validation dry-run", spec: single, comparison: singleComparison, opts: ArgoSyncOptions{DryRun: &dryRun}, validationID: "validation", wantRevision: "compared-sha"},
		{name: "plural validation dry-run", spec: multi, comparison: multiComparison, opts: ArgoSyncOptions{DryRun: &dryRun}, validationID: "validation", wantRevisions: []string{"app-sha", "values-sha"}},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			for _, refresh := range []bool{false, true} {
				app := argoAppForTest("argocd", "demo", nil)
				if err := unstructured.SetNestedMap(app.Object, tc.spec, "spec"); err != nil {
					t.Fatal(err)
				}
				if tc.comparison != nil {
					if err := unstructured.SetNestedMap(app.Object, tc.comparison, "status", "sync"); err != nil {
						t.Fatal(err)
					}
				}
				retry := map[string]any{"limit": int64(3), "refresh": refresh, "backoff": map[string]any{"duration": "5s"}}
				if err := unstructured.SetNestedMap(app.Object, retry, "spec", "syncPolicy", "retry"); err != nil {
					t.Fatal(err)
				}
				before := app.DeepCopy()
				operation := argoSyncOperation(context.Background(), app, tc.opts, tc.validationID)
				sync := nestedMap(operation, "sync")
				if got := sync["revision"]; got != tc.wantRevision {
					t.Fatalf("revision = %#v, want %q", got, tc.wantRevision)
				}
				if got := sync["revisions"]; tc.wantRevisions != nil && !reflect.DeepEqual(got, tc.wantRevisions) || tc.wantRevisions == nil && got != nil {
					t.Fatalf("revisions = %#v, want %#v", got, tc.wantRevisions)
				}
				if !reflect.DeepEqual(argoSyncSources(sync), argoSyncSources(tc.spec)) {
					t.Fatalf("sync sources = %#v, want %#v", argoSyncSources(sync), argoSyncSources(tc.spec))
				}
				if tc.validationID != "" {
					if _, ok := operation["retry"]; ok {
						t.Fatal("validation must not inherit retry")
					}
				} else if !reflect.DeepEqual(operation["retry"], retry) {
					t.Fatalf("retry = %#v, want %#v", operation["retry"], retry)
				}
				if !reflect.DeepEqual(app, before) {
					t.Fatal("building the operation changed the Application")
				}
			}
		})
	}
}

// The sync fixes the Application's source(s) when it is requested, as
// argocd-server does, so a sync waiting behind a window deploys what was
// there when it was asked for.
func TestSyncArgoAppCapturesSources(t *testing.T) {
	single := map[string]any{"repoURL": "https://example.com/repo", "path": "app", "targetRevision": "HEAD"}
	multi := []any{
		map[string]any{"repoURL": "https://example.com/a", "path": "a", "targetRevision": "main"},
		map[string]any{"repoURL": "https://example.com/b", "path": "b", "targetRevision": "v1"},
	}
	t.Run("single source", func(t *testing.T) {
		client := newFakeArgo(argoAppForTest("argocd", "demo", func(obj map[string]any) {
			obj["spec"].(map[string]any)["source"] = single
		}))
		if _, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{}); err != nil {
			t.Fatalf("SyncArgoApp: %v", err)
		}
		sync := nestedMap(captureLastPatch(t, client), "operation", "sync")
		if !reflect.DeepEqual(sync["source"], single) || sync["sources"] != nil {
			t.Fatalf("sync source = %#v sources = %#v, want the spec's source only", sync["source"], sync["sources"])
		}
	})
	t.Run("multiple sources", func(t *testing.T) {
		client := newFakeArgo(argoAppForTest("argocd", "demo", func(obj map[string]any) {
			obj["spec"].(map[string]any)["sources"] = multi
		}))
		if _, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{}); err != nil {
			t.Fatalf("SyncArgoApp: %v", err)
		}
		sync := nestedMap(captureLastPatch(t, client), "operation", "sync")
		if !reflect.DeepEqual(sync["sources"], multi) || sync["source"] != nil {
			t.Fatalf("sync sources = %#v source = %#v, want the spec's sources only", sync["sources"], sync["source"])
		}
	})
}

func TestArgoOperationsAttributeInitiator(t *testing.T) {
	alice := pkgauth.ContextWithUser(context.Background(), &pkgauth.User{Username: "alice@example.com", Groups: []string{"team"}})
	cases := []struct {
		name string
		ctx  context.Context
		want string
	}{
		{name: "authenticated user", ctx: alice, want: "alice@example.com"},
		{name: "cloud email", ctx: pkgauth.ContextWithUser(context.Background(), &pkgauth.User{Username: "user_01ABC", Groups: []string{"radar:member", "radar:idp:radar:email:other@example.com", "radar:email:alice@example.com"}}), want: "alice@example.com"},
		{name: "cloud without email", ctx: pkgauth.ContextWithUser(context.Background(), &pkgauth.User{Username: "user_01ABC", Groups: []string{"radar:member"}}), want: "user_01ABC"},
		{name: "user-token Cloud email", ctx: pkgauth.ContextWithUser(context.Background(), &pkgauth.User{Username: "kube-user", Groups: []string{"system:authenticated"}, HubGroups: []string{"radar:member", "radar:email:alice@example.com"}}), want: "alice@example.com"},
		{name: "empty email group", ctx: pkgauth.ContextWithUser(context.Background(), &pkgauth.User{Username: "user_01ABC", Groups: []string{"radar:email:"}}), want: "user_01ABC"},
		{name: "no per-user auth", ctx: context.Background(), want: "radar"},
		{name: "user without a name", ctx: pkgauth.ContextWithUser(context.Background(), &pkgauth.User{}), want: "radar"},
	}
	for _, tc := range cases {
		for verb, start := range map[string]func(context.Context, *fake.FakeDynamicClient) error{
			"sync": func(ctx context.Context, client *fake.FakeDynamicClient) error {
				_, err := SyncArgoApp(ctx, client, "argocd", "demo", ArgoSyncOptions{})
				return err
			},
			"rollback": func(ctx context.Context, client *fake.FakeDynamicClient) error {
				_, err := RollbackArgoApp(ctx, client, "argocd", "demo", ArgoRollbackOptions{ID: 1})
				return err
			},
		} {
			t.Run(tc.name+"/"+verb, func(t *testing.T) {
				user := pkgauth.UserFromContext(tc.ctx)
				var originalUser pkgauth.User
				if user != nil {
					originalUser = *user
					originalUser.Groups = append([]string(nil), user.Groups...)
				}
				client := newFakeArgo(appWithHistoryAndState(nil))
				if err := start(tc.ctx, client); err != nil {
					t.Fatalf("%s: %v", verb, err)
				}
				if user != nil && !reflect.DeepEqual(*user, originalUser) {
					t.Fatalf("operation changed the authenticated identity: got %#v, want %#v", *user, originalUser)
				}
				operation := nestedMap(captureLastPatch(t, client), "operation")
				if got := nestedMap(operation, "initiatedBy"); !reflect.DeepEqual(got, map[string]any{"username": tc.want}) {
					t.Fatalf("initiatedBy = %#v, want username %q only", got, tc.want)
				}
				if !argoOperationHasInfo(operation, ArgoInitiatedViaInfo, ArgoInitiatedViaRadar) {
					t.Fatalf("operation.info = %#v, want the Radar provenance", operation["info"])
				}
				if got := argoOperationHasInfo(operation, argoRollbackInfoName, "true"); got != (verb == "rollback") {
					t.Fatalf("rollback marker present = %v for %s; info = %#v", got, verb, operation["info"])
				}
			})
		}
	}
}

// conflictOnce makes the first patch fail with a conflict after applying
// change to the stored Application, as a concurrent writer would.
func conflictOnce(t *testing.T, client *fake.FakeDynamicClient, change func(app *unstructured.Unstructured)) *int {
	t.Helper()
	patches := 0
	client.PrependReactor("patch", "applications", func(action clienttesting.Action) (bool, runtime.Object, error) {
		patches++
		if patches > 1 {
			return false, nil, nil
		}
		obj, err := client.Tracker().Get(argoAppGVR, "argocd", "demo")
		if err != nil {
			t.Fatalf("tracker get: %v", err)
		}
		app := obj.(*unstructured.Unstructured).DeepCopy()
		change(app)
		app.SetResourceVersion("18")
		if err := client.Tracker().Update(argoAppGVR, app, "argocd"); err != nil {
			t.Fatalf("tracker update: %v", err)
		}
		return true, nil, apierrors.NewConflict(argoAppGVR.GroupResource(), "demo", errors.New("changed"))
	})
	return &patches
}

// A conflicting write is retried only from a fresh read that is validated
// again: an operation another writer installed in between is refused, and
// a changed Application is written from its new state and resourceVersion.
func TestArgoOperationConflictRereadsAndRevalidates(t *testing.T) {
	seed := func() *unstructured.Unstructured {
		app := appWithHistoryAndState(nil)
		app.SetResourceVersion("17")
		return app
	}
	starts := map[string]func(*fake.FakeDynamicClient) error{
		"sync": func(client *fake.FakeDynamicClient) error {
			_, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{})
			return err
		},
		"rollback": func(client *fake.FakeDynamicClient) error {
			_, err := RollbackArgoApp(context.Background(), client, "argocd", "demo", ArgoRollbackOptions{ID: 1})
			return err
		},
	}
	for verb, start := range starts {
		t.Run(verb+": an operation installed meanwhile is refused", func(t *testing.T) {
			client := newFakeArgo(seed())
			patches := conflictOnce(t, client, func(app *unstructured.Unstructured) {
				app.Object["operation"] = map[string]any{"sync": map[string]any{"revision": "theirs"}}
			})
			if err := start(client); !errors.Is(err, ErrOperationInProgress) {
				t.Fatalf("error = %v, want ErrOperationInProgress", err)
			}
			if *patches != 1 {
				t.Fatalf("patches = %d, want only the conflicting one", *patches)
			}
			app, _ := client.Resource(argoAppGVR).Namespace("argocd").Get(context.Background(), "demo", metav1.GetOptions{})
			if revision, _, _ := unstructured.NestedString(app.Object, "operation", "sync", "revision"); revision != "theirs" {
				t.Fatalf("the other writer's operation was replaced: %#v", app.Object["operation"])
			}
		})
		t.Run(verb+": a changed Application is written from the new read", func(t *testing.T) {
			client := newFakeArgo(seed())
			patches := conflictOnce(t, client, func(app *unstructured.Unstructured) {
				spec, _ := app.Object["spec"].(map[string]any)
				spec["syncPolicy"] = map[string]any{"syncOptions": []any{"PruneLast=true"}}
			})
			if err := start(client); err != nil {
				t.Fatalf("%s: %v", verb, err)
			}
			if *patches != 2 {
				t.Fatalf("patches = %d, want the conflict and one retry", *patches)
			}
			body := captureLastPatch(t, client)
			if rv := nestedMap(body, "metadata")["resourceVersion"]; rv != "18" {
				t.Fatalf("retry resourceVersion = %#v, want the reread 18", rv)
			}
			if got := nestedMap(body, "operation", "sync")["syncOptions"]; !reflect.DeepEqual(got, []any{"PruneLast=true"}) {
				t.Fatalf("retry syncOptions = %#v, want the reread Application's", got)
			}
		})
	}
	t.Run("a conflict on every attempt gives up with 409", func(t *testing.T) {
		client := newFakeArgo(seed())
		patches := 0
		client.PrependReactor("patch", "applications", func(action clienttesting.Action) (bool, runtime.Object, error) {
			patches++
			return true, nil, apierrors.NewConflict(argoAppGVR.GroupResource(), "demo", errors.New("changed"))
		})
		if _, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{}); !errors.Is(err, ErrOperationInProgress) {
			t.Fatalf("error = %v, want ErrOperationInProgress", err)
		}
		if patches != argoOperationAttempts {
			t.Fatalf("patches = %d, want %d", patches, argoOperationAttempts)
		}
	})
}

func getTestApp(t *testing.T, client *fake.FakeDynamicClient) *unstructured.Unstructured {
	t.Helper()
	app, err := client.Resource(argoAppGVR).Namespace("argocd").Get(context.Background(), "demo", metav1.GetOptions{})
	if err != nil {
		t.Fatalf("get app: %v", err)
	}
	return app
}

func setAutoSync(t *testing.T, client *fake.FakeDynamicClient, enable bool) OperationResult {
	t.Helper()
	result, err := SetArgoAutoSync(context.Background(), client, "argocd", "demo", enable)
	if err != nil {
		t.Fatalf("SetArgoAutoSync(%v): %v", enable, err)
	}
	return result
}

func automatedOf(app *unstructured.Unstructured) (map[string]any, bool) {
	automated, found, _ := unstructured.NestedMap(app.Object, "spec", "syncPolicy", "automated")
	return automated, found
}

func appWithAutomated(automated map[string]any, annotations map[string]any) *unstructured.Unstructured {
	return argoAppForTest("argocd", "demo", func(obj map[string]any) {
		if automated != nil {
			obj["spec"].(map[string]any)["syncPolicy"] = map[string]any{"automated": automated}
		}
		if annotations != nil {
			obj["metadata"].(map[string]any)["annotations"] = annotations
		}
	})
}

// Suspend and resume keep the whole automated block, defined for repeated
// suspends and for edits made outside Radar while suspended.
func TestSetArgoAutoSyncLifecycle(t *testing.T) {
	original := map[string]any{"prune": true, "selfHeal": false, "allowEmpty": true}

	t.Run("resume restores every field suspend removed", func(t *testing.T) {
		client := newFakeArgo(appWithAutomated(original, nil))
		setAutoSync(t, client, false)
		suspended := getTestApp(t, client)
		if _, found := automatedOf(suspended); found {
			t.Fatalf("suspend left automated in place: %#v", suspended.Object["spec"])
		}
		annotations := suspended.GetAnnotations()
		if annotations[ArgoSuspendedPruneAnnotation] != "true" || annotations[ArgoSuspendedSelfHealAnnotation] != "false" {
			t.Fatalf("suspend dropped the per-field keys older Radar builds resume from: %#v", annotations)
		}
		setAutoSync(t, client, true)
		resumed := getTestApp(t, client)
		if automated, _ := automatedOf(resumed); !reflect.DeepEqual(automated, original) {
			t.Fatalf("resumed automated = %#v, want %#v", automated, original)
		}
		if len(resumed.GetAnnotations()) != 0 {
			t.Fatalf("resume left suspension annotations: %#v", resumed.GetAnnotations())
		}
	})

	t.Run("a second suspend changes nothing", func(t *testing.T) {
		client := newFakeArgo(appWithAutomated(original, nil))
		setAutoSync(t, client, false)
		before := getTestApp(t, client).GetAnnotations()
		if result := setAutoSync(t, client, false); !result.NoChange {
			t.Fatalf("second suspend = %#v, want NoChange", result)
		}
		if after := getTestApp(t, client).GetAnnotations(); !reflect.DeepEqual(after, before) {
			t.Fatalf("second suspend rewrote the saved settings: %#v, want %#v", after, before)
		}
		setAutoSync(t, client, true)
		if automated, _ := automatedOf(getTestApp(t, client)); !reflect.DeepEqual(automated, original) {
			t.Fatalf("resume after two suspends = %#v, want %#v", automated, original)
		}
	})

	t.Run("suspending an Application without auto-sync writes nothing", func(t *testing.T) {
		client := newFakeArgo(appWithAutomated(nil, nil))
		if result := setAutoSync(t, client, false); !result.NoChange {
			t.Fatalf("suspend = %#v, want NoChange", result)
		}
		assertNoPatch(t, client)
	})

	t.Run("auto-sync re-enabled outside Radar keeps its block", func(t *testing.T) {
		client := newFakeArgo(appWithAutomated(original, nil))
		setAutoSync(t, client, false)
		app := getTestApp(t, client)
		external := map[string]any{"prune": false, "selfHeal": true}
		app.Object["spec"].(map[string]any)["syncPolicy"] = map[string]any{"automated": external}
		if err := client.Tracker().Update(argoAppGVR, app, "argocd"); err != nil {
			t.Fatalf("external edit: %v", err)
		}
		if result := setAutoSync(t, client, true); !result.NoChange {
			t.Fatalf("resume = %#v, want NoChange", result)
		}
		resumed := getTestApp(t, client)
		if automated, _ := automatedOf(resumed); !reflect.DeepEqual(automated, external) {
			t.Fatalf("resume replaced the external block: %#v, want %#v", automated, external)
		}
		if len(resumed.GetAnnotations()) != 0 {
			t.Fatalf("resume left Radar's saved copy: %#v", resumed.GetAnnotations())
		}
	})

	t.Run("a block switched off in place while suspended wins over the saved one", func(t *testing.T) {
		client := newFakeArgo(appWithAutomated(original, nil))
		setAutoSync(t, client, false)
		app := getTestApp(t, client)
		app.Object["spec"].(map[string]any)["syncPolicy"] = map[string]any{"automated": map[string]any{"enabled": false, "prune": false, "allowEmpty": true}}
		if err := client.Tracker().Update(argoAppGVR, app, "argocd"); err != nil {
			t.Fatalf("external edit: %v", err)
		}
		setAutoSync(t, client, true)
		resumed := getTestApp(t, client)
		if automated, _ := automatedOf(resumed); !reflect.DeepEqual(automated, map[string]any{"prune": false, "allowEmpty": true}) {
			t.Fatalf("resumed automated = %#v, want the in-place block without enabled", automated)
		}
		if !ArgoAutoSyncEnabled(resumed) || len(resumed.GetAnnotations()) != 0 {
			t.Fatalf("resume left auto-sync off or annotations behind: %#v", resumed.Object)
		}
	})

	t.Run("a saved block an older Radar's suspend made stale is not used", func(t *testing.T) {
		client := newFakeArgo(appWithAutomated(nil, map[string]any{
			ArgoSuspendedAutomatedAnnotation: `{"prune":true,"selfHeal":true,"allowEmpty":true}`,
			ArgoSuspendedPruneAnnotation:     "false",
			ArgoSuspendedSelfHealAnnotation:  "true",
		}))
		setAutoSync(t, client, true)
		if automated, _ := automatedOf(getTestApp(t, client)); !reflect.DeepEqual(automated, map[string]any{"prune": false, "selfHeal": true}) {
			t.Fatalf("resumed automated = %#v, want the per-field keys", automated)
		}
	})
}

// "Enable Auto-Sync" on an Application Radar never suspended has no saved
// settings to restore. It must not turn pruning or self-heal on: Argo CD's own
// UI and CLI enable auto-sync with both off, and a block that exists keeps its
// own values.
func TestSetArgoAutoSyncEnableNeverAutomatedLeavesPruneAndSelfHealOff(t *testing.T) {
	client := newFakeArgo(appWithAutomated(nil, nil))
	setAutoSync(t, client, true)
	app := getTestApp(t, client)
	if !ArgoAutoSyncEnabled(app) {
		t.Fatalf("auto-sync still off: %#v", app.Object["spec"])
	}
	if automated, _ := automatedOf(app); !reflect.DeepEqual(automated, map[string]any{"prune": false, "selfHeal": false}) {
		t.Fatalf("enabled automated = %#v, want prune and selfHeal off", automated)
	}

	saved := newFakeArgo(appWithAutomated(nil, map[string]any{
		ArgoSuspendedPruneAnnotation:    "true",
		ArgoSuspendedSelfHealAnnotation: "false",
	}))
	setAutoSync(t, saved, true)
	if automated, _ := automatedOf(getTestApp(t, saved)); !reflect.DeepEqual(automated, map[string]any{"prune": true, "selfHeal": false}) {
		t.Fatalf("resumed automated = %#v, want the saved prune on and selfHeal off", automated)
	}
}

// A Go caller encoding ArgoSyncOptions for the HTTP API must keep the
// difference between "none" ([]) and "the Application's own" (nil).
func TestArgoSyncOptionsJSONKeepsEmptyList(t *testing.T) {
	for name, tc := range map[string]struct {
		in      []string
		wantNil bool
	}{
		"none":     {in: []string{}},
		"inherit":  {in: nil, wantNil: true},
		"explicit": {in: []string{"Replace=true"}},
	} {
		t.Run(name, func(t *testing.T) {
			data, err := json.Marshal(ArgoSyncOptions{SyncOptions: tc.in})
			if err != nil {
				t.Fatal(err)
			}
			var out ArgoSyncOptions
			if err := json.Unmarshal(data, &out); err != nil {
				t.Fatal(err)
			}
			if (out.SyncOptions == nil) != tc.wantNil || len(out.SyncOptions) != len(tc.in) {
				t.Fatalf("%s round-tripped to %#v", data, out.SyncOptions)
			}
		})
	}
}

func TestArgoRollbackRevisionMismatch(t *testing.T) {
	for _, tc := range []struct {
		name                             string
		target, result                   []string
		multi, dryRun, unmarked, pending bool
		phase                            string
		wantTarget, wantResult           string
	}{
		{name: "single mismatch", target: []string{"old"}, result: []string{"new"}, wantTarget: "old", wantResult: "new"},
		{name: "single match", target: []string{"old"}, result: []string{"old"}},
		{name: "multi mismatch", multi: true, target: []string{"old-a", "old-b"}, result: []string{"new-a", "new-b"}, wantTarget: "old-a, old-b", wantResult: "new-a, new-b"},
		{name: "multi partial mismatch", multi: true, target: []string{"old-a", "old-b"}, result: []string{"old-a", "new-b"}, wantTarget: "old-a, old-b", wantResult: "old-a, new-b"},
		{name: "multi match", multi: true, target: []string{"a", "b"}, result: []string{"a", "b"}},
		{name: "dry run", dryRun: true, target: []string{"old"}, result: []string{"new"}},
		{name: "multi dry run", multi: true, dryRun: true, target: []string{"old"}, result: []string{"new"}},
		{name: "unmarked sync", unmarked: true, target: []string{"old"}, result: []string{"new"}},
		{name: "failed", phase: "Failed", target: []string{"old"}, result: []string{"new"}},
		{name: "running", phase: "Running", target: []string{"old"}, result: []string{"new"}},
		{name: "pending operation", pending: true, target: []string{"old"}, result: []string{"new"}},
		{name: "no result", target: []string{"old"}, wantTarget: "old"},
		{name: "no target", result: []string{"new"}},
	} {
		t.Run(tc.name, func(t *testing.T) {
			app := argoAppForTest("argocd", "demo", nil)
			sync, result := map[string]any{"dryRun": tc.dryRun}, map[string]any{}
			if tc.multi {
				_ = unstructured.SetNestedStringSlice(sync, tc.target, "revisions")
				_ = unstructured.SetNestedStringSlice(result, tc.result, "revisions")
			} else {
				if len(tc.target) > 0 {
					sync["revision"] = tc.target[0]
				}
				if len(tc.result) > 0 {
					result["revision"] = tc.result[0]
				}
			}
			operation := map[string]any{"sync": sync, "initiatedBy": map[string]any{"username": "alice"}}
			if !tc.unmarked {
				operation["info"] = []any{map[string]any{"name": argoRollbackInfoName, "value": "true"}}
			}
			phase := tc.phase
			if phase == "" {
				phase = "Succeeded"
			}
			_ = unstructured.SetNestedMap(app.Object, map[string]any{"phase": phase, "operation": operation, "syncResult": result}, "status", "operationState")
			if tc.pending {
				app.Object["operation"] = map[string]any{"sync": map[string]any{}}
			}
			target, applied := ArgoRollbackRevisionMismatch(app)
			if target != tc.wantTarget || applied != tc.wantResult {
				t.Fatalf("mismatch = (%q, %q), want (%q, %q)", target, applied, tc.wantTarget, tc.wantResult)
			}
			if tc.wantTarget != "" {
				if revision, _ := ArgoSuccessfulRollback(app); revision != "" {
					t.Fatalf("mismatch reported as successful rollback: %q", revision)
				}
			}
		})
	}
}

func TestArgoRollbackSourceComparison(t *testing.T) {
	source := map[string]any{
		"repoURL": "https://example.com/repo", "path": "historical", "targetRevision": "main",
		"helm": map[string]any{"parameters": []any{map[string]any{"name": "replicas", "value": "2"}}},
	}
	for _, multi := range []bool{false, true} {
		for _, tc := range []struct {
			name                       string
			field                      string
			value                      any
			missingResult, placeholder bool
			mismatch                   bool
		}{
			{name: "identical"},
			{name: "path differs at same revision", field: "path", value: "current", mismatch: true},
			{name: "repo differs", field: "repoURL", value: "https://example.com/other", mismatch: true},
			{name: "chart differs", field: "chart", value: "other", mismatch: true},
			{name: "target revision differs", field: "targetRevision", value: "other", mismatch: true},
			{name: "helm values differ", field: "helm", value: map[string]any{"parameters": []any{map[string]any{"name": "replicas", "value": "3"}}}, mismatch: true},
			{name: "kustomize differs", field: "kustomize", value: map[string]any{"namePrefix": "other"}, mismatch: true},
			{name: "directory differs", field: "directory", value: map[string]any{"recurse": true}, mismatch: true},
			{name: "plugin differs", field: "plugin", value: map[string]any{"name": "other"}, mismatch: true},
			{name: "missing result sources", missingResult: true},
			{name: "zero single source placeholder", placeholder: true},
			{name: "missing plural result with placeholder", missingResult: true, placeholder: true},
			{name: "placeholder does not hide plural mismatch", placeholder: true, field: "path", value: "current", mismatch: true},
		} {
			t.Run(fmt.Sprintf("multi=%v/%s", multi, tc.name), func(t *testing.T) {
				sync := map[string]any{"revision": "same"}
				result := map[string]any{"revision": "same"}
				appliedSource := (&unstructured.Unstructured{Object: source}).DeepCopy().Object
				if tc.field != "" {
					appliedSource[tc.field] = tc.value
				}
				if multi {
					sync = map[string]any{"revisions": []any{"same", "values"}, "sources": []any{source, map[string]any{"repoURL": "https://example.com/values", "ref": "values"}}}
					result = map[string]any{"revisions": []any{"same", "values"}}
					if !tc.missingResult {
						result["sources"] = []any{appliedSource, map[string]any{"repoURL": "https://example.com/values", "ref": "values"}}
					}
					if tc.placeholder {
						sync["source"] = map[string]any{}
						result["source"] = map[string]any{"repoURL": ""}
					}
				} else {
					sync["source"] = source
					if !tc.missingResult {
						result["source"] = appliedSource
					}
				}
				app := argoAppForTest("argocd", "demo", nil)
				_ = unstructured.SetNestedMap(app.Object, map[string]any{
					"phase":      "Succeeded",
					"operation":  map[string]any{"sync": sync, "info": []any{map[string]any{"name": argoRollbackInfoName, "value": "true"}}},
					"syncResult": result,
				}, "status", "operationState")
				target, applied := ArgoRollbackRevisionMismatch(app)
				if got := target != "" && applied != ""; got != tc.mismatch {
					t.Errorf("mismatch = (%q, %q), want mismatch %v", target, applied, tc.mismatch)
				}
				revision, _ := ArgoSuccessfulRollback(app)
				if got := revision != ""; got == tc.mismatch {
					t.Errorf("successful rollback = %q, want success %v", revision, !tc.mismatch)
				}
			})
		}
	}
}

func TestArgoRollbackRevisionShapes(t *testing.T) {
	for _, tc := range []struct {
		name                   string
		target, result         map[string]any
		wantTarget, wantResult string
	}{
		{"plural target scalar result", map[string]any{"revisions": []any{"old-a", "old-b"}}, map[string]any{"revision": "new"}, "old-a, old-b", "new"},
		{"scalar target plural result", map[string]any{"revision": "old"}, map[string]any{"revisions": []any{"new-a", "new-b"}}, "old", "new-a, new-b"},
		{"plural target absent result", map[string]any{"revisions": []any{"old-a", "old-b"}}, map[string]any{}, "old-a, old-b", ""},
		{"scalar target absent result", map[string]any{"revision": "old"}, map[string]any{}, "old", ""},
		{"normalized equal", map[string]any{"revision": "old"}, map[string]any{"revisions": []any{"old"}}, "", ""},
	} {
		t.Run(tc.name, func(t *testing.T) {
			app := argoAppForTest("argocd", "demo", nil)
			_ = unstructured.SetNestedMap(app.Object, map[string]any{
				"phase":      "Succeeded",
				"operation":  map[string]any{"info": []any{map[string]any{"name": argoRollbackInfoName, "value": "true"}}, "sync": tc.target},
				"syncResult": tc.result,
			}, "status", "operationState")
			target, applied := ArgoRollbackRevisionMismatch(app)
			if target != tc.wantTarget || applied != tc.wantResult {
				t.Fatalf("mismatch = (%q, %q), want (%q, %q)", target, applied, tc.wantTarget, tc.wantResult)
			}
			revision, _ := ArgoSuccessfulRollback(app)
			if (revision != "") != (tc.wantTarget == "") {
				t.Fatalf("successful rollback = %q, mismatch = %q", revision, tc.wantTarget)
			}
		})
	}
}

func TestArgoAppliedCurrentComparison(t *testing.T) {
	source := map[string]any{"repoURL": "repo", "path": "app", "targetRevision": "main"}
	other := map[string]any{"repoURL": "repo", "path": "other", "targetRevision": "main"}
	for _, tc := range []struct {
		name               string
		result, comparison map[string]any
		want               bool
	}{
		{"single match", map[string]any{"revision": "a", "source": source}, map[string]any{"revision": "a", "comparedTo": map[string]any{"source": source}}, true},
		{"revision differs", map[string]any{"revision": "old", "source": source}, map[string]any{"revision": "new", "comparedTo": map[string]any{"source": source}}, false},
		{"source differs", map[string]any{"revision": "a", "source": source}, map[string]any{"revision": "a", "comparedTo": map[string]any{"source": other}}, false},
		{"plural match", map[string]any{"revisions": []any{"a", "b"}, "sources": []any{source, other}}, map[string]any{"revisions": []any{"a", "b"}, "comparedTo": map[string]any{"sources": []any{source, other}}}, true},
		{"plural revision differs", map[string]any{"revisions": []any{"a", "old"}, "sources": []any{source, other}}, map[string]any{"revisions": []any{"a", "new"}, "comparedTo": map[string]any{"sources": []any{source, other}}}, false},
		{"plural source order differs", map[string]any{"revisions": []any{"a", "b"}, "sources": []any{source, other}}, map[string]any{"revisions": []any{"a", "b"}, "comparedTo": map[string]any{"sources": []any{other, source}}}, false},
		{"missing applied evidence", map[string]any{}, map[string]any{"revision": "a", "comparedTo": map[string]any{"source": source}}, false},
		{"missing comparison evidence", map[string]any{"revision": "a", "source": source}, map[string]any{}, false},
	} {
		t.Run(tc.name, func(t *testing.T) {
			app := argoAppForTest("argocd", "demo", nil)
			_ = unstructured.SetNestedMap(app.Object, tc.result, "status", "operationState", "syncResult")
			_ = unstructured.SetNestedMap(app.Object, tc.comparison, "status", "sync")
			if got := ArgoAppliedCurrentComparison(app); got != tc.want {
				t.Fatalf("matches = %v, want %v", got, tc.want)
			}
		})
	}
}

// Argo CD prefers spec.sourceHydrator over a spec.source or spec.sources left
// beside it. A sync names the hydrated sync source: with no operation source
// the controller would copy leftover spec.sources and sync those instead.
func TestArgoSyncOperationUsesHydratedSyncSource(t *testing.T) {
	hydrator := map[string]any{
		"drySource":  map[string]any{"repoURL": "https://example.com/repo", "path": "app", "targetRevision": "main"},
		"syncSource": map[string]any{"targetBranch": "environments/prod", "path": "rendered"},
	}
	leftover := map[string]any{"repoURL": "https://example.com/old", "path": "old", "targetRevision": "main"}
	for name, spec := range map[string]map[string]any{
		"hydrator only":           {"sourceHydrator": hydrator},
		"leftover scalar source":  {"sourceHydrator": hydrator, "source": leftover},
		"leftover plural sources": {"sourceHydrator": hydrator, "sources": []any{leftover}},
	} {
		t.Run(name, func(t *testing.T) {
			app := argoAppForTest("argocd", "demo", nil)
			if err := unstructured.SetNestedMap(app.Object, spec, "spec"); err != nil {
				t.Fatal(err)
			}
			comparedTo := map[string]any{"source": leftover, "sources": []any{leftover}}
			if err := unstructured.SetNestedMap(app.Object, map[string]any{"revision": "old-sha", "revisions": []any{"old-sha"}, "comparedTo": comparedTo}, "status", "sync"); err != nil {
				t.Fatal(err)
			}
			client := newFakeArgo(app)
			if _, err := SyncArgoApp(context.Background(), client, "argocd", "demo", ArgoSyncOptions{}); err != nil {
				t.Fatalf("SyncArgoApp: %v", err)
			}
			sync := nestedMap(captureLastPatch(t, client), "operation", "sync")
			want := map[string]any{"repoURL": "https://example.com/repo", "path": "rendered", "targetRevision": "environments/prod"}
			if !reflect.DeepEqual(sync["source"], want) {
				t.Fatalf("sync.source = %v, want the hydrated sync source %v", sync["source"], want)
			}
			for _, key := range []string{"sources", "revisions"} {
				if _, ok := sync[key]; ok {
					t.Fatalf("sync.%s = %v, want none: the controller would sync leftover sources", key, sync[key])
				}
			}
			if sync["revision"] != "" {
				t.Fatalf("sync.revision = %v, want none", sync["revision"])
			}
		})
	}

	// An explicit sync repository wins over the dry one, as in GetSyncSource.
	explicit := map[string]any{
		"drySource":  map[string]any{"repoURL": "https://example.com/dry", "path": "app"},
		"syncSource": map[string]any{"repoURL": "https://example.com/rendered", "targetBranch": "env/prod", "path": "out"},
	}
	app := argoAppForTest("argocd", "demo", nil)
	if err := unstructured.SetNestedMap(app.Object, map[string]any{"sourceHydrator": explicit}, "spec"); err != nil {
		t.Fatal(err)
	}
	got := nestedMap(argoSyncOperation(context.Background(), app, ArgoSyncOptions{}, ""), "sync")["source"]
	if want := map[string]any{"repoURL": "https://example.com/rendered", "path": "out", "targetRevision": "env/prod"}; !reflect.DeepEqual(got, want) {
		t.Fatalf("sync.source = %v, want %v", got, want)
	}

	// Leftover spec.sources do not make a hydrated Application multi-source.
	app = argoAppForTest("argocd", "demo", nil)
	if err := unstructured.SetNestedMap(app.Object, map[string]any{"sourceHydrator": hydrator, "sources": []any{leftover}}, "spec"); err != nil {
		t.Fatal(err)
	}
	if _, err := SyncArgoApp(context.Background(), newFakeArgo(app), "argocd", "demo", ArgoSyncOptions{Revision: "environments/prod"}); err != nil {
		t.Fatalf("revision on a hydrated Application with leftover sources: %v", err)
	}
}