// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0

package predicates_test

import (
	"testing"

	admissionv1 "k8s.io/api/admissionregistration/v1"
	corev1 "k8s.io/api/core/v1"
	networkingv1 "k8s.io/api/networking/v1"
	rbacv1 "k8s.io/api/rbac/v1"
	"k8s.io/apimachinery/pkg/api/resource"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/event"

	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
	"github.com/projectcapsule/capsule/pkg/runtime/predicates"
)

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

	p := predicates.ObjectMetadataChangedPredicate{}
	oldPod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Labels: map[string]string{"a": "b"}}}
	statusOnly := oldPod.DeepCopy()
	statusOnly.Status.Phase = corev1.PodRunning

	if p.Update(event.UpdateEvent{ObjectOld: oldPod, ObjectNew: statusOnly}) {
		t.Fatal("status-only update must be filtered")
	}

	metadataChanged := oldPod.DeepCopy()
	metadataChanged.Labels["a"] = "c"
	if !p.Update(event.UpdateEvent{ObjectOld: oldPod, ObjectNew: metadataChanged}) {
		t.Fatal("metadata update must be admitted")
	}

	if !p.Create(event.CreateEvent{Object: oldPod}) {
		t.Fatal("create must be admitted")
	}

	if p.Delete(event.DeleteEvent{Object: oldPod}) {
		t.Fatal("delete must be filtered")
	}
}

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

	p := predicates.TenantManagedResourceChangedPredicate{}

	tests := []struct {
		name string
		old  client.Object
		new  client.Object
		want bool
	}{
		{
			name: "limitrange spec drift without generation change",
			old:  &corev1.LimitRange{},
			new: &corev1.LimitRange{Spec: corev1.LimitRangeSpec{Limits: []corev1.LimitRangeItem{{
				Type: corev1.LimitTypeContainer,
			}}}},
			want: true,
		},
		{
			name: "resourcequota spec drift without generation change",
			old:  &corev1.ResourceQuota{},
			new: &corev1.ResourceQuota{Spec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{
				corev1.ResourceLimitsCPU: resource.MustParse("1"),
			}}},
			want: true,
		},
		{
			name: "resourcequota usage status is filtered",
			old:  &corev1.ResourceQuota{},
			new: &corev1.ResourceQuota{Status: corev1.ResourceQuotaStatus{Used: corev1.ResourceList{
				corev1.ResourceLimitsCPU: resource.MustParse("1"),
			}}},
			want: false,
		},
		{
			name: "networkpolicy spec drift",
			old:  &networkingv1.NetworkPolicy{},
			new: &networkingv1.NetworkPolicy{Spec: networkingv1.NetworkPolicySpec{
				PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress},
			}},
			want: true,
		},
		{
			name: "rolebinding subjects drift",
			old:  &rbacv1.RoleBinding{},
			new: &rbacv1.RoleBinding{Subjects: []rbacv1.Subject{{
				Kind: rbacv1.UserKind,
				Name: "alice",
			}}},
			want: true,
		},
		{
			name: "managed owner reference drift",
			old: &corev1.LimitRange{ObjectMeta: metav1.ObjectMeta{OwnerReferences: []metav1.OwnerReference{{
				Kind: "Tenant", Name: "tenant",
			}}}},
			new:  &corev1.LimitRange{},
			want: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := p.Update(event.UpdateEvent{ObjectOld: tt.old, ObjectNew: tt.new}); got != tt.want {
				t.Fatalf("Update() = %t, want %t", got, tt.want)
			}
		})
	}
}

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

	p := predicates.ValidatingAdmissionConfigurationChangedPredicate{}
	withoutHash := &admissionv1.ValidatingWebhookConfiguration{
		ObjectMeta: metav1.ObjectMeta{Name: "capsule"},
		Webhooks:   []admissionv1.ValidatingWebhook{{Name: "namespaces.projectcapsule.dev"}},
	}
	obj := withoutHash.DeepCopy()
	obj.Annotations = map[string]string{
		predicates.AdmissionStateHashAnnotation: predicates.ValidatingAdmissionStateHash(obj),
	}
	if p.Update(event.UpdateEvent{ObjectOld: withoutHash, ObjectNew: obj}) {
		t.Fatal("controller hash update must not enqueue itself")
	}

	unchanged := obj.DeepCopy()
	unchanged.ResourceVersion = "2"
	if p.Update(event.UpdateEvent{ObjectOld: obj, ObjectNew: unchanged}) {
		t.Fatal("controller-authored state must not enqueue itself")
	}

	drifted := unchanged.DeepCopy()
	drifted.Webhooks[0].Name = "changed.projectcapsule.dev"
	if !p.Update(event.UpdateEvent{ObjectOld: unchanged, ObjectNew: drifted}) {
		t.Fatal("webhook drift must be admitted")
	}

	driftedWithHash := unchanged.DeepCopy()
	driftedWithHash.Webhooks[0].Name = "changed-with-hash.projectcapsule.dev"
	driftedWithHash.Annotations[predicates.AdmissionStateHashAnnotation] = "changed"
	if !p.Update(event.UpdateEvent{ObjectOld: unchanged, ObjectNew: driftedWithHash}) {
		t.Fatal("webhook drift combined with a hash annotation update must be admitted")
	}

	caOnly := unchanged.DeepCopy()
	caOnly.Webhooks[0].ClientConfig.CABundle = []byte("rotated")
	if p.Update(event.UpdateEvent{ObjectOld: unchanged, ObjectNew: caOnly}) {
		t.Fatal("CA rotation is owned by the TLS controller and must be filtered")
	}
}

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

	p := predicates.MutatingAdmissionConfigurationChangedPredicate{}
	withoutHash := &admissionv1.MutatingWebhookConfiguration{
		ObjectMeta: metav1.ObjectMeta{Name: "capsule"},
		Webhooks:   []admissionv1.MutatingWebhook{{Name: "pods.projectcapsule.dev"}},
	}
	obj := withoutHash.DeepCopy()
	obj.Annotations = map[string]string{
		predicates.AdmissionStateHashAnnotation: predicates.MutatingAdmissionStateHash(obj),
	}
	if p.Update(event.UpdateEvent{ObjectOld: withoutHash, ObjectNew: obj}) {
		t.Fatal("controller hash update must not enqueue itself")
	}

	driftedWithHash := obj.DeepCopy()
	driftedWithHash.Webhooks[0].Name = "changed.projectcapsule.dev"
	driftedWithHash.Annotations[predicates.AdmissionStateHashAnnotation] = "changed"
	if !p.Update(event.UpdateEvent{ObjectOld: obj, ObjectNew: driftedWithHash}) {
		t.Fatal("webhook drift combined with a hash annotation update must be admitted")
	}
}

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

	p := predicates.DependencyStateChangedPredicate{}
	obj := &capsulev1beta2.TenantResource{}

	for _, e := range []event.UpdateEvent{
		{ObjectNew: obj},
		{ObjectOld: obj},
		{},
	} {
		if p.Update(e) {
			t.Fatal("update with a nil object must be filtered")
		}
	}
}

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

	p := predicates.QuantityLedgerWorkChangedPredicate{}
	if p.Create(event.CreateEvent{Object: &capsulev1beta2.QuantityLedger{}}) {
		t.Fatal("settled ledger creation must be filtered; the owning quota create already reconciles it")
	}

	createdWithWork := &capsulev1beta2.QuantityLedger{
		Status: capsulev1beta2.QuantityLedgerStatus{
			Reservations: []capsulev1beta2.QuantityLedgerReservation{{ID: "request"}},
		},
	}
	if !p.Create(event.CreateEvent{Object: createdWithWork}) {
		t.Fatal("ledger first observed with work must trigger reconciliation")
	}

	oldLedger := &capsulev1beta2.QuantityLedger{}
	derived := oldLedger.DeepCopy()
	derived.Status.Allocated = resource.MustParse("1")
	if p.Update(event.UpdateEvent{ObjectOld: oldLedger, ObjectNew: derived}) {
		t.Fatal("derived allocation update must be filtered")
	}

	work := oldLedger.DeepCopy()
	work.Status.Reservations = append(work.Status.Reservations, capsulev1beta2.QuantityLedgerReservation{ID: "request"})
	if !p.Update(event.UpdateEvent{ObjectOld: oldLedger, ObjectNew: work}) {
		t.Fatal("reservation update must be admitted")
	}

	moreWork := work.DeepCopy()
	moreWork.Status.Reservations = append(
		moreWork.Status.Reservations,
		capsulev1beta2.QuantityLedgerReservation{ID: "request-2"},
	)
	if !p.Update(event.UpdateEvent{ObjectOld: work, ObjectNew: moreWork}) {
		t.Fatal("additional work must be admitted in case the informer coalesced an intermediate settled state")
	}

	replacedWork := oldLedger.DeepCopy()
	replacedWork.Status.PendingDeletes = []capsulev1beta2.QuantityLedgerPendingDelete{{
		ID: "delete-request",
	}}
	if !p.Update(event.UpdateEvent{ObjectOld: work, ObjectNew: replacedWork}) {
		t.Fatal("replacement work must be admitted when an intermediate settled state is not observed")
	}

	settled := moreWork.DeepCopy()
	settled.Status.Reservations = nil
	if p.Update(event.UpdateEvent{ObjectOld: moreWork, ObjectNew: settled}) {
		t.Fatal("controller settlement updates must be filtered")
	}

	partiallySettled := moreWork.DeepCopy()
	partiallySettled.Status.Reservations = partiallySettled.Status.Reservations[:1]
	if p.Update(event.UpdateEvent{ObjectOld: moreWork, ObjectNew: partiallySettled}) {
		t.Fatal("partial controller settlement updates must be filtered")
	}

	unchangedWork := work.DeepCopy()
	unchangedWork.Status.Allocated = resource.MustParse("1")
	if p.Update(event.UpdateEvent{ObjectOld: work, ObjectNew: unchangedWork}) {
		t.Fatal("derived updates with unchanged work must be filtered")
	}
}

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

	p := predicates.ResourcePoolNamespacesChangedPredicate{}
	oldPool := &capsulev1beta2.ResourcePool{}
	statusOnly := oldPool.DeepCopy()
	statusOnly.Status.ClaimSize = 1
	if p.Update(event.UpdateEvent{ObjectOld: oldPool, ObjectNew: statusOnly}) {
		t.Fatal("unrelated pool status update must be filtered")
	}

	namespacesChanged := oldPool.DeepCopy()
	namespacesChanged.Status.Namespaces = []string{"tenant-a"}
	if !p.Update(event.UpdateEvent{ObjectOld: oldPool, ObjectNew: namespacesChanged}) {
		t.Fatal("namespace allocation update must be admitted")
	}

	deleting := oldPool.DeepCopy()
	now := metav1.Now()
	deleting.DeletionTimestamp = &now
	if !p.Update(event.UpdateEvent{ObjectOld: oldPool, ObjectNew: deleting}) {
		t.Fatal("deletion transition must be admitted")
	}
}

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

	p := predicates.ResourceQuotaUsageChangedPredicate{}
	oldQuota := &corev1.ResourceQuota{}

	hardOnly := oldQuota.DeepCopy()
	hardOnly.Status.Hard = corev1.ResourceList{
		corev1.ResourceLimitsCPU: resource.MustParse("2"),
	}
	if p.Update(event.UpdateEvent{ObjectOld: oldQuota, ObjectNew: hardOnly}) {
		t.Fatal("status.hard-only update must be filtered")
	}

	used := hardOnly.DeepCopy()
	used.Status.Used = corev1.ResourceList{
		corev1.ResourceLimitsCPU: resource.MustParse("1"),
	}
	if !p.Update(event.UpdateEvent{ObjectOld: hardOnly, ObjectNew: used}) {
		t.Fatal("status.used update must be admitted")
	}

	usageReleased := used.DeepCopy()
	usageReleased.Status.Used[corev1.ResourceLimitsCPU] = resource.MustParse("0")
	if !p.Update(event.UpdateEvent{ObjectOld: used, ObjectNew: usageReleased}) {
		t.Fatal("released usage update must be admitted")
	}

	if p.Update(event.UpdateEvent{ObjectOld: oldQuota, ObjectNew: &corev1.ConfigMap{}}) {
		t.Fatal("non-ResourceQuota update must be filtered")
	}
}

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

	p := predicates.DeletionChangedPredicate{}
	oldObject := &corev1.ConfigMap{}
	statusOnly := oldObject.DeepCopy()
	if p.Update(event.UpdateEvent{ObjectOld: oldObject, ObjectNew: statusOnly}) {
		t.Fatal("ordinary update must be filtered")
	}

	deleting := oldObject.DeepCopy()
	now := metav1.Now()
	deleting.DeletionTimestamp = &now
	if !p.Update(event.UpdateEvent{ObjectOld: oldObject, ObjectNew: deleting}) {
		t.Fatal("deletion timestamp transition must be admitted")
	}
}

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

	p := predicates.ClassChanged()
	oldTenant := &capsulev1beta2.Tenant{}
	deleting := oldTenant.DeepCopy()
	now := metav1.Now()
	deleting.DeletionTimestamp = &now

	if !p.Update(event.UpdateEvent{ObjectOld: oldTenant, ObjectNew: deleting}) {
		t.Fatal("controller primary predicate must admit deletion transition")
	}
}
