/*
Copyright 2024 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package framework

import (
	"reflect"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"

	v1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/resource"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/util/version"
	utilfeature "k8s.io/apiserver/pkg/util/feature"
	featuregatetesting "k8s.io/component-base/featuregate/testing"
	fwk "k8s.io/kube-scheduler/framework"
	"k8s.io/kubernetes/pkg/features"
	st "k8s.io/kubernetes/pkg/scheduler/testing"
	"k8s.io/utils/ptr"
)

func TestNodeAllocatableChange(t *testing.T) {
	newQuantity := func(value int64) resource.Quantity {
		return *resource.NewQuantity(value, resource.BinarySI)
	}
	for _, test := range []struct {
		name string
		// changed is true if it's expected that the function detects the change and returns event.
		changed        bool
		oldAllocatable v1.ResourceList
		newAllocatable v1.ResourceList
	}{
		{
			name:           "no allocatable resources changed",
			changed:        false,
			oldAllocatable: v1.ResourceList{v1.ResourceMemory: newQuantity(1024)},
			newAllocatable: v1.ResourceList{v1.ResourceMemory: newQuantity(1024)},
		},
		{
			name:           "new node has more allocatable resources",
			changed:        true,
			oldAllocatable: v1.ResourceList{v1.ResourceMemory: newQuantity(1024)},
			newAllocatable: v1.ResourceList{v1.ResourceMemory: newQuantity(1024), v1.ResourceStorage: newQuantity(1024)},
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			oldNode := &v1.Node{Status: v1.NodeStatus{Allocatable: test.oldAllocatable}}
			newNode := &v1.Node{Status: v1.NodeStatus{Allocatable: test.newAllocatable}}
			changed := extractNodeAllocatableChange(newNode, oldNode) != fwk.None
			if changed != test.changed {
				t.Errorf("nodeAllocatableChanged should be %t, got %t", test.changed, changed)
			}
		})
	}
}

func TestNodeLabelsChange(t *testing.T) {
	for _, test := range []struct {
		name string
		// changed is true if it's expected that the function detects the change and returns event.
		changed   bool
		oldLabels map[string]string
		newLabels map[string]string
	}{
		{
			name:      "no labels changed",
			changed:   false,
			oldLabels: map[string]string{"foo": "bar"},
			newLabels: map[string]string{"foo": "bar"},
		},
		// Labels changed.
		{
			name:      "new object has more labels",
			changed:   true,
			oldLabels: map[string]string{"foo": "bar"},
			newLabels: map[string]string{"foo": "bar", "test": "value"},
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			oldNode := &v1.Node{ObjectMeta: metav1.ObjectMeta{Labels: test.oldLabels}}
			newNode := &v1.Node{ObjectMeta: metav1.ObjectMeta{Labels: test.newLabels}}
			changed := extractNodeLabelsChange(newNode, oldNode) != fwk.None
			if changed != test.changed {
				t.Errorf("Test case %q failed: should be %t, got %t", test.name, test.changed, changed)
			}
		})
	}
}

func TestNodeTaintsChange(t *testing.T) {
	for _, test := range []struct {
		name string
		// changed is true if it's expected that the function detects the change and returns event.
		changed   bool
		oldTaints []v1.Taint
		newTaints []v1.Taint
	}{
		{
			name:      "no taint changed",
			changed:   false,
			oldTaints: []v1.Taint{{Key: "key", Value: "value"}},
			newTaints: []v1.Taint{{Key: "key", Value: "value"}},
		},
		{
			name:      "taint value changed",
			changed:   true,
			oldTaints: []v1.Taint{{Key: "key", Value: "value1"}},
			newTaints: []v1.Taint{{Key: "key", Value: "value2"}},
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			oldNode := &v1.Node{Spec: v1.NodeSpec{Taints: test.oldTaints}}
			newNode := &v1.Node{Spec: v1.NodeSpec{Taints: test.newTaints}}
			changed := extractNodeTaintsChange(newNode, oldNode) != fwk.None
			if changed != test.changed {
				t.Errorf("Test case %q failed: should be %t, not %t", test.name, test.changed, changed)
			}
		})
	}
}

func TestNodeConditionsChange(t *testing.T) {
	nodeConditionType := reflect.TypeOf(v1.NodeCondition{})
	if nodeConditionType.NumField() != 6 {
		t.Errorf("NodeCondition type has changed. The nodeConditionsChange() function must be reevaluated.")
	}

	for _, test := range []struct {
		name string
		// changed is true if it's expected that the function detects the change and returns event.
		changed       bool
		oldConditions []v1.NodeCondition
		newConditions []v1.NodeCondition
	}{
		{
			name:          "no condition changed",
			changed:       false,
			oldConditions: []v1.NodeCondition{{Type: v1.NodeDiskPressure, Status: v1.ConditionTrue}},
			newConditions: []v1.NodeCondition{{Type: v1.NodeDiskPressure, Status: v1.ConditionTrue}},
		},
		{
			name:          "only LastHeartbeatTime changed",
			changed:       false,
			oldConditions: []v1.NodeCondition{{Type: v1.NodeDiskPressure, Status: v1.ConditionTrue, LastHeartbeatTime: metav1.Unix(1, 0)}},
			newConditions: []v1.NodeCondition{{Type: v1.NodeDiskPressure, Status: v1.ConditionTrue, LastHeartbeatTime: metav1.Unix(2, 0)}},
		},
		{
			name:          "new node has more healthy conditions",
			changed:       true,
			oldConditions: []v1.NodeCondition{},
			newConditions: []v1.NodeCondition{{Type: v1.NodeReady, Status: v1.ConditionTrue}},
		},
		{
			name:          "new node has less unhealthy conditions",
			changed:       true,
			oldConditions: []v1.NodeCondition{{Type: v1.NodeDiskPressure, Status: v1.ConditionTrue}},
			newConditions: []v1.NodeCondition{},
		},
		{
			name:          "condition status changed",
			changed:       true,
			oldConditions: []v1.NodeCondition{{Type: v1.NodeReady, Status: v1.ConditionFalse}},
			newConditions: []v1.NodeCondition{{Type: v1.NodeReady, Status: v1.ConditionTrue}},
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			oldNode := &v1.Node{Status: v1.NodeStatus{Conditions: test.oldConditions}}
			newNode := &v1.Node{Status: v1.NodeStatus{Conditions: test.newConditions}}
			changed := extractNodeConditionsChange(newNode, oldNode) != fwk.None
			if changed != test.changed {
				t.Errorf("Test case %q failed: should be %t, got %t", test.name, test.changed, changed)
			}
		})
	}
}

func TestNodeSchedulingPropertiesChange(t *testing.T) {
	testCases := []struct {
		name       string
		newNode    *v1.Node
		oldNode    *v1.Node
		wantEvents []fwk.ClusterEvent
	}{
		{
			name:       "no specific changed applied",
			newNode:    st.MakeNode().Unschedulable(false).Obj(),
			oldNode:    st.MakeNode().Unschedulable(false).Obj(),
			wantEvents: nil,
		},
		{
			name:       "only node spec unavailable changed",
			newNode:    st.MakeNode().Unschedulable(false).Obj(),
			oldNode:    st.MakeNode().Unschedulable(true).Obj(),
			wantEvents: []fwk.ClusterEvent{{Resource: fwk.Node, ActionType: fwk.UpdateNodeTaint}},
		},
		{
			name: "only node allocatable changed",
			newNode: st.MakeNode().Capacity(map[v1.ResourceName]string{
				v1.ResourceCPU:                     "1000m",
				v1.ResourceMemory:                  "100m",
				v1.ResourceName("example.com/foo"): "1"},
			).Obj(),
			oldNode: st.MakeNode().Capacity(map[v1.ResourceName]string{
				v1.ResourceCPU:                     "1000m",
				v1.ResourceMemory:                  "100m",
				v1.ResourceName("example.com/foo"): "2"},
			).Obj(),
			wantEvents: []fwk.ClusterEvent{{Resource: fwk.Node, ActionType: fwk.UpdateNodeAllocatable}},
		},
		{
			name:       "only node label changed",
			newNode:    st.MakeNode().Label("foo", "bar").Obj(),
			oldNode:    st.MakeNode().Label("foo", "fuz").Obj(),
			wantEvents: []fwk.ClusterEvent{{Resource: fwk.Node, ActionType: fwk.UpdateNodeLabel}},
		},
		{
			name: "only node taint changed",
			newNode: st.MakeNode().Taints([]v1.Taint{
				{Key: v1.TaintNodeUnschedulable, Value: "", Effect: v1.TaintEffectNoSchedule},
			}).Obj(),
			oldNode: st.MakeNode().Taints([]v1.Taint{
				{Key: v1.TaintNodeUnschedulable, Value: "foo", Effect: v1.TaintEffectNoSchedule},
			}).Obj(),
			wantEvents: []fwk.ClusterEvent{{Resource: fwk.Node, ActionType: fwk.UpdateNodeTaint}},
		},
		{
			name:       "only node annotation changed",
			newNode:    st.MakeNode().Annotation("foo", "bar").Obj(),
			oldNode:    st.MakeNode().Annotation("foo", "fuz").Obj(),
			wantEvents: []fwk.ClusterEvent{{Resource: fwk.Node, ActionType: fwk.UpdateNodeAnnotation}},
		},
		{
			name:    "only node condition changed",
			newNode: st.MakeNode().Obj(),
			oldNode: st.MakeNode().Condition(
				v1.NodeReady,
				v1.ConditionTrue,
				"Ready",
				"Ready",
			).Obj(),
			wantEvents: []fwk.ClusterEvent{{Resource: fwk.Node, ActionType: fwk.UpdateNodeCondition}},
		},
		{
			name: "both node label and node taint changed",
			newNode: st.MakeNode().
				Label("foo", "bar").
				Taints([]v1.Taint{
					{Key: v1.TaintNodeUnschedulable, Value: "", Effect: v1.TaintEffectNoSchedule},
				}).Obj(),
			oldNode: st.MakeNode().Taints([]v1.Taint{
				{Key: v1.TaintNodeUnschedulable, Value: "foo", Effect: v1.TaintEffectNoSchedule},
			}).Obj(),
			wantEvents: []fwk.ClusterEvent{{Resource: fwk.Node, ActionType: fwk.UpdateNodeLabel}, {Resource: fwk.Node, ActionType: fwk.UpdateNodeTaint}},
		},
	}

	for _, tc := range testCases {
		gotEvents := NodeSchedulingPropertiesChange(tc.newNode, tc.oldNode)
		if diff := cmp.Diff(tc.wantEvents, gotEvents, cmpopts.EquateComparable(fwk.ClusterEvent{})); diff != "" {
			t.Errorf("unexpected event (-want, +got):\n%s", diff)
		}
	}
}

func TestExtractNodeFeaturesChange(t *testing.T) {
	testCases := []struct {
		name    string
		oldNode *v1.Node
		newNode *v1.Node
		want    fwk.ActionType
	}{
		{
			name:    "no features changed",
			oldNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: []string{"featA", "featB"}}},
			newNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: []string{"featA", "featB"}}},
			want:    fwk.None,
		},
		{
			name:    "features added",
			oldNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: []string{"featA"}}},
			newNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: []string{"featA", "featB"}}},
			want:    fwk.UpdateNodeDeclaredFeature,
		},
		{
			name:    "features removed",
			oldNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: []string{"featA", "featB"}}},
			newNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: []string{"featA"}}},
			want:    fwk.UpdateNodeDeclaredFeature,
		},
		{
			name:    "features different",
			oldNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: []string{"featA"}}},
			newNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: []string{"featC"}}},
			want:    fwk.UpdateNodeDeclaredFeature,
		},
		{
			name:    "old features nil, new has features",
			oldNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: nil}},
			newNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: []string{"featA"}}},
			want:    fwk.UpdateNodeDeclaredFeature,
		},
		{
			name:    "old features empty, new has features",
			oldNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: []string{}}},
			newNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: []string{"featA"}}},
			want:    fwk.UpdateNodeDeclaredFeature,
		},
		{
			name:    "old has features, new is nil",
			oldNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: []string{"featA"}}},
			newNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: nil}},
			want:    fwk.UpdateNodeDeclaredFeature,
		},
		{
			name:    "both nil",
			oldNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: nil}},
			newNode: &v1.Node{Status: v1.NodeStatus{DeclaredFeatures: nil}},
			want:    fwk.None,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			got := extractNodeFeaturesChange(tc.newNode, tc.oldNode)
			if got != tc.want {
				t.Errorf("extractNodeFeaturesChange() = %v, want %v", got, tc.want)
			}
		})
	}
}

func Test_podSchedulingPropertiesChange(t *testing.T) {
	podWithBigRequest := &v1.Pod{
		Spec: v1.PodSpec{
			Containers: []v1.Container{
				{
					Name: "app",
					Resources: v1.ResourceRequirements{
						Requests: v1.ResourceList{v1.ResourceCPU: resource.MustParse("101m")},
					},
				},
			},
		},
		Status: v1.PodStatus{
			ContainerStatuses: []v1.ContainerStatus{
				{
					Name:               "app",
					AllocatedResources: v1.ResourceList{v1.ResourceCPU: resource.MustParse("101m")},
				},
			},
		},
	}
	podWithSmallRequestAndLabel := &v1.Pod{
		ObjectMeta: metav1.ObjectMeta{
			Labels: map[string]string{"foo": "bar"},
		},
		Spec: v1.PodSpec{
			Containers: []v1.Container{
				{
					Name: "app",
					Resources: v1.ResourceRequirements{
						Requests: v1.ResourceList{v1.ResourceCPU: resource.MustParse("100m")},
					},
				},
			},
		},
		Status: v1.PodStatus{
			ContainerStatuses: []v1.ContainerStatus{
				{
					Name:               "app",
					AllocatedResources: v1.ResourceList{v1.ResourceCPU: resource.MustParse("100m")},
				},
			},
		},
	}
	podWithSmallRequest := &v1.Pod{
		Spec: v1.PodSpec{
			Containers: []v1.Container{
				{
					Name: "app",
					Resources: v1.ResourceRequirements{
						Requests: v1.ResourceList{v1.ResourceCPU: resource.MustParse("100m")},
					},
				},
			},
		},
		Status: v1.PodStatus{
			ContainerStatuses: []v1.ContainerStatus{
				{
					Name:               "app",
					AllocatedResources: v1.ResourceList{v1.ResourceCPU: resource.MustParse("100m")},
				},
			},
		},
	}
	claimStatusA := v1.PodResourceClaimStatus{
		Name:              "my-claim",
		ResourceClaimName: ptr.To("claim"),
	}
	claimStatusB := v1.PodResourceClaimStatus{
		Name:              "my-claim-2",
		ResourceClaimName: ptr.To("claim-2"),
	}
	extendedClaimStatusA := &v1.PodExtendedResourceClaimStatus{
		RequestMappings: []v1.ContainerExtendedResourceRequest{
			{RequestName: "request", ContainerName: "c", ResourceName: "example.com/gpu"},
		},
		ResourceClaimName: "claim",
	}
	extendedClaimStatusB := &v1.PodExtendedResourceClaimStatus{
		RequestMappings: []v1.ContainerExtendedResourceRequest{
			{RequestName: "request", ContainerName: "c", ResourceName: "example.com/gpu"},
		},
		ResourceClaimName: "claim-2",
	}
	tests := []struct {
		name        string
		newPod      *v1.Pod
		oldPod      *v1.Pod
		draDisabled bool
		want        []fwk.ClusterEvent
	}{
		{
			name:   "assigned pod is updated",
			newPod: st.MakePod().Label("foo", "bar").Node("node").Obj(),
			oldPod: st.MakePod().Label("foo", "bar2").Node("node").Obj(),
			want:   []fwk.ClusterEvent{{Resource: assignedPod, ActionType: fwk.UpdatePodLabel}},
		},
		{
			name:   "only label is updated",
			newPod: st.MakePod().Label("foo", "bar").Obj(),
			oldPod: st.MakePod().Label("foo", "bar2").Obj(),
			want:   []fwk.ClusterEvent{{Resource: unschedulablePod, ActionType: fwk.UpdatePodLabel}},
		},
		{
			name:   "pod's resource request is scaled down",
			oldPod: podWithBigRequest,
			newPod: podWithSmallRequest,
			want:   []fwk.ClusterEvent{{Resource: unschedulablePod, ActionType: fwk.UpdatePodScaleDown}},
		},
		{
			name:   "pod's resource request is scaled up",
			oldPod: podWithSmallRequest,
			newPod: podWithBigRequest,
			want:   []fwk.ClusterEvent{{Resource: unschedulablePod, ActionType: fwk.Update}},
		},
		{
			name:   "both pod's resource request and label are updated",
			oldPod: podWithBigRequest,
			newPod: podWithSmallRequestAndLabel,
			want: []fwk.ClusterEvent{
				{Resource: unschedulablePod, ActionType: fwk.UpdatePodLabel},
				{Resource: unschedulablePod, ActionType: fwk.UpdatePodScaleDown},
			},
		},
		{
			name:   "untracked properties of pod is updated",
			newPod: st.MakePod().Annotation("foo", "bar").Obj(),
			oldPod: st.MakePod().Annotation("foo", "bar2").Obj(),
			want:   []fwk.ClusterEvent{{Resource: unschedulablePod, ActionType: fwk.Update}},
		},
		{
			name:   "scheduling gate is eliminated",
			newPod: st.MakePod().SchedulingGates([]string{}).Obj(),
			oldPod: st.MakePod().SchedulingGates([]string{"foo"}).Obj(),
			want:   []fwk.ClusterEvent{{Resource: unschedulablePod, ActionType: fwk.UpdatePodSchedulingGatesEliminated}},
		},
		{
			name:   "scheduling gate is removed, but not completely eliminated",
			newPod: st.MakePod().SchedulingGates([]string{"foo"}).Obj(),
			oldPod: st.MakePod().SchedulingGates([]string{"foo", "bar"}).Obj(),
			want:   []fwk.ClusterEvent{{Resource: unschedulablePod, ActionType: fwk.Update}},
		},
		{
			name:   "pod's tolerations are updated",
			newPod: st.MakePod().Toleration("key").Toleration("key2").Obj(),
			oldPod: st.MakePod().Toleration("key").Obj(),
			want:   []fwk.ClusterEvent{{Resource: unschedulablePod, ActionType: fwk.UpdatePodToleration}},
		},
		{
			name:        "pod claim statuses change, feature disabled",
			draDisabled: true,
			newPod:      st.MakePod().ResourceClaimStatuses(claimStatusA).Obj(),
			oldPod:      st.MakePod().Obj(),
			want:        []fwk.ClusterEvent{{Resource: unschedulablePod, ActionType: fwk.Update}},
		},
		{
			name:   "pod claim statuses change, feature enabled",
			newPod: st.MakePod().ResourceClaimStatuses(claimStatusA).Obj(),
			oldPod: st.MakePod().Obj(),
			want:   []fwk.ClusterEvent{{Resource: unschedulablePod, ActionType: fwk.UpdatePodGeneratedResourceClaim}},
		},
		{
			name:   "pod claim statuses swapped",
			newPod: st.MakePod().ResourceClaimStatuses(claimStatusA, claimStatusB).Obj(),
			oldPod: st.MakePod().ResourceClaimStatuses(claimStatusB, claimStatusA).Obj(),
			want:   []fwk.ClusterEvent{{Resource: unschedulablePod, ActionType: fwk.UpdatePodGeneratedResourceClaim}},
		},
		{
			name:        "pod extended resource claim status change, feature disabled",
			draDisabled: true,
			newPod:      st.MakePod().ExtendedResourceClaimStatus(extendedClaimStatusA).Obj(),
			oldPod:      st.MakePod().Obj(),
			want:        []fwk.ClusterEvent{{Resource: unschedulablePod, ActionType: fwk.Update}},
		},
		{
			name:   "pod extended resource claim status change, feature enabled",
			newPod: st.MakePod().ExtendedResourceClaimStatus(extendedClaimStatusA).Obj(),
			oldPod: st.MakePod().Obj(),
			want:   []fwk.ClusterEvent{{Resource: unschedulablePod, ActionType: fwk.UpdatePodGeneratedResourceClaim}},
		},
		{
			name:   "pod extended resource claim status swapped",
			newPod: st.MakePod().ExtendedResourceClaimStatus(extendedClaimStatusB).Obj(),
			oldPod: st.MakePod().ExtendedResourceClaimStatus(extendedClaimStatusA).Obj(),
			want:   []fwk.ClusterEvent{{Resource: unschedulablePod, ActionType: fwk.UpdatePodGeneratedResourceClaim}},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if tt.draDisabled {
				featuregatetesting.SetFeatureGateEmulationVersionDuringTest(t, utilfeature.DefaultFeatureGate, version.MustParse("1.34"))
			}
			featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DynamicResourceAllocation, !tt.draDisabled)
			got := PodSchedulingPropertiesChange(tt.newPod, tt.oldPod)
			if diff := cmp.Diff(tt.want, got, cmpopts.EquateComparable(fwk.ClusterEvent{})); diff != "" {
				t.Errorf("unexpected event is returned from podSchedulingPropertiesChange (-want, +got):\n%s", diff)
			}
		})
	}
}
