/*
SPDX-FileCopyrightText: Red Hat

SPDX-License-Identifier: Apache-2.0
*/

/*
Assisted-by: Cursor/claude-4-sonnet
*/

/*
Test Cases Overview for provisioningrequest_clusterinstall_test.go:

1. handleRenderClusterInstance Tests:
   - Tests successful rendering and validation of ClusterInstance with dry-run
   - Verifies disable-auto-import annotation behavior for ManagedCluster:
     * Added when cluster provisioning has not started
     * Not added when cluster provisioning has completed
   - Tests failure scenarios with invalid input data (empty clusterName)
   - Validates status condition updates for ClusterInstanceRendered

2. handleClusterInstallation Tests:
   - Validates that ClusterInstance is applied with correct parameters
   - Tests that ClusterDetails.Name is set in status when applying ClusterInstance

3. TestExtractNodeDetails Tests:
   - Tests extraction of node details from existing ClusterInstance unstructured data
   - Covers various scenarios:
     * Valid input with complete node information (hostname, BMC, MAC addresses)
     * Missing hostname (nodes should be skipped)
     * Missing BMC address and boot MAC address
     * Missing BMC credentials name or nested name field
     * Missing node network interfaces
     * Invalid interface data (missing name or MAC address fields)
   - Validates proper handling of nodeInfo structure population

4. TestAssignNodeDetails Tests:
   - Tests assignment of node details to rendered ClusterInstance
   - Covers scenarios:
     * Valid assignment of BMC details, MAC addresses, and credentials
     * Missing spec.nodes section (should not panic)
     * Nodes with missing hostname (should be skipped)
     * Missing interface MAC address information in nodeInfo
     * Proper setting of hostRef fields (name and namespace)
   - Validates that unstructured.Unstructured objects are modified correctly
*/

package controllers

import (
	"context"
	"reflect"
	"testing"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"

	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/meta"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"

	hwmgmtv1alpha1 "github.com/openshift-kni/oran-o2ims/api/hardwaremanagement/v1alpha1"
	provisioningv1alpha1 "github.com/openshift-kni/oran-o2ims/api/provisioning/v1alpha1"
	"github.com/openshift-kni/oran-o2ims/internal/constants"
	"github.com/openshift-kni/oran-o2ims/internal/controllers/utils"
	hwmgrcontroller "github.com/openshift-kni/oran-o2ims/internal/hardwaremanager/controller"
	"github.com/openshift-kni/oran-o2ims/test/fakeclient"
	testutils "github.com/openshift-kni/oran-o2ims/test/utils"
)

// getClusterInstanceDefaultsConfigMap returns a ConfigMap with ClusterInstance defaults
func getClusterInstanceDefaultsConfigMap(name, namespace string) *corev1.ConfigMap {
	return &corev1.ConfigMap{
		ObjectMeta: metav1.ObjectMeta{
			Name:      name,
			Namespace: namespace,
		},
		Data: map[string]string{
			utils.ClusterInstanceTemplateDefaultsConfigmapKey: `
clusterImageSetNameRef: "4.15"
pullSecretRef:
  name: "pull-secret"
holdInstallation: false
templateRefs:
  - name: "ai-cluster-templates-v1"
    namespace: "siteconfig-operator"
nodes:
- hostname: "node1"
  role: master
  ironicInspect: ""
  nodeNetwork:
    interfaces:
    - name: eno1
      label: boot-interface
    - name: eth0
      label: base-interface
    - name: eth1
      label: data-interface
  templateRefs:
    - name: "ai-node-templates-v1"
      namespace: "siteconfig-operator"`,
		},
	}
}

var _ = Describe("handleRenderClusterInstance", func() {
	var (
		ctx          context.Context
		c            client.Client
		reconciler   *ProvisioningRequestReconciler
		task         *provisioningRequestReconcilerTask
		cr           *provisioningv1alpha1.ProvisioningRequest
		ciDefaultsCm = "clusterinstance-defaults-v1"
		tName        = "clustertemplate-a"
		tVersion     = "v1.0.0"
		ctNamespace  = "clustertemplate-a-v4-16"
		crName       = "cluster-1"
	)

	BeforeEach(func() {
		ctx = context.Background()
		// Define the provisioning request.
		cr = &provisioningv1alpha1.ProvisioningRequest{
			ObjectMeta: metav1.ObjectMeta{
				Name: crName,
			},
			Spec: provisioningv1alpha1.ProvisioningRequestSpec{
				TemplateName:    tName,
				TemplateVersion: tVersion,
				TemplateParameters: runtime.RawExtension{
					Raw: []byte(testutils.TestFullTemplateParameters),
				},
			},
		}

		// Define the cluster template.
		ct := &provisioningv1alpha1.ClusterTemplate{
			ObjectMeta: metav1.ObjectMeta{
				Name:      GetClusterTemplateRefName(tName, tVersion),
				Namespace: ctNamespace,
			},
			Spec: provisioningv1alpha1.ClusterTemplateSpec{
				TemplateDefaults: provisioningv1alpha1.TemplateDefaults{
					ClusterInstanceDefaults: ciDefaultsCm,
				},
			},
		}
		// Use helper function to get ConfigMap with ClusterInstance defaults
		cm := getClusterInstanceDefaultsConfigMap(ciDefaultsCm, ctNamespace)

		c = fakeclient.GetFakeClientFromObjects([]client.Object{cr, ct, cm}...)
		reconciler = &ProvisioningRequestReconciler{
			Client: c,
			Logger: logger,
		}
		task = &provisioningRequestReconcilerTask{
			logger:       reconciler.Logger,
			client:       reconciler.Client,
			object:       cr,
			clusterInput: &clusterInput{},
			ctDetails: &clusterTemplateDetails{
				namespace: ctNamespace,
			},
		}

		clusterInstanceInputParams, err := provisioningv1alpha1.ExtractMatchingInput(
			cr.Spec.TemplateParameters.Raw, constants.TemplateParamClusterInstance)
		Expect(err).ToNot(HaveOccurred())
		mergedClusterInstanceData, err := task.getMergedClusterInputData(
			ctx, ciDefaultsCm, clusterInstanceInputParams.(map[string]any), constants.TemplateParamClusterInstance)
		Expect(err).ToNot(HaveOccurred())
		task.clusterInput.clusterInstanceData = mergedClusterInstanceData
	})

	It("should successfully render and validate ClusterInstance with dry-run", func() {
		renderedClusterInstance, err := task.handleRenderClusterInstance(ctx)
		Expect(err).ToNot(HaveOccurred())
		Expect(renderedClusterInstance).ToNot(BeNil())

		// Verify the disable-auto-import annotation is added to the ManagedCluster
		// when cluster provisioning has not started yet.
		Expect(renderedClusterInstance.Spec.ExtraAnnotations).To(HaveKey("ManagedCluster"))
		Expect(renderedClusterInstance.Spec.ExtraAnnotations["ManagedCluster"]).To(HaveKey(disableAutoImportAnnotation))

		// Check if status condition was updated correctly
		cond := meta.FindStatusCondition(task.object.Status.Conditions,
			string(provisioningv1alpha1.PRconditionTypes.ClusterInstanceRendered))
		Expect(cond).ToNot(BeNil())
		testutils.VerifyStatusCondition(*cond, metav1.Condition{
			Type:    string(provisioningv1alpha1.PRconditionTypes.ClusterInstanceRendered),
			Status:  metav1.ConditionTrue,
			Reason:  string(provisioningv1alpha1.CRconditionReasons.Completed),
			Message: "ClusterInstance rendered and passed dry-run validation",
		})
	})

	It("should not contain disable-auto-import annotation for ManagedCluster in the "+
		"rendered ClusterInstance if cluster provisioning has completed", func() {
		// Simulate that the ClusterInstance has started provisioning
		task.object.Status.Conditions = []metav1.Condition{
			{
				Type:    string(provisioningv1alpha1.PRconditionTypes.ClusterProvisioned),
				Status:  metav1.ConditionTrue,
				Reason:  string(provisioningv1alpha1.CRconditionReasons.Completed),
				Message: "Provisioned cluster",
			},
		}
		renderedClusterInstance, err := task.handleRenderClusterInstance(ctx)
		Expect(err).ToNot(HaveOccurred())
		Expect(renderedClusterInstance).ToNot(BeNil())

		// Verify the disable-auto-import annotation is not added to the ManagedCluster
		// when cluster provisioning has completed.
		Expect(renderedClusterInstance.Spec.ExtraAnnotations).ToNot(HaveKey("ManagedCluster"))

		// Check if status condition was updated correctly
		cond := meta.FindStatusCondition(task.object.Status.Conditions,
			string(provisioningv1alpha1.PRconditionTypes.ClusterInstanceRendered))
		Expect(cond).ToNot(BeNil())
		testutils.VerifyStatusCondition(*cond, metav1.Condition{
			Type:    string(provisioningv1alpha1.PRconditionTypes.ClusterInstanceRendered),
			Status:  metav1.ConditionTrue,
			Reason:  string(provisioningv1alpha1.CRconditionReasons.Completed),
			Message: "ClusterInstance rendered and passed dry-run validation",
		})
	})

	It("should fail to render ClusterInstance due to invalid input", func() {
		// Modify input data to be invalid
		task.clusterInput.clusterInstanceData["clusterName"] = ""
		_, err := task.handleRenderClusterInstance(ctx)
		Expect(err).To(HaveOccurred())

		// Check if status condition was updated correctly
		cond := meta.FindStatusCondition(task.object.Status.Conditions,
			string(provisioningv1alpha1.PRconditionTypes.ClusterInstanceRendered))
		Expect(cond).ToNot(BeNil())
		testutils.VerifyStatusCondition(*cond, metav1.Condition{
			Type:    string(provisioningv1alpha1.PRconditionTypes.ClusterInstanceRendered),
			Status:  metav1.ConditionFalse,
			Reason:  string(provisioningv1alpha1.CRconditionReasons.Failed),
			Message: "spec.clusterName cannot be empty",
		})
	})

})

var _ = Describe("handleClusterInstallation", func() {
	var (
		ctx          context.Context
		c            client.Client
		reconciler   *ProvisioningRequestReconciler
		task         *provisioningRequestReconcilerTask
		cr           *provisioningv1alpha1.ProvisioningRequest
		ciDefaultsCm = "clusterinstance-defaults-v1"
		tName        = "clustertemplate-a"
		tVersion     = "v1.0.0"
		ctNamespace  = "clustertemplate-a-v4-16"
		crName       = "cluster-1"
	)

	BeforeEach(func() {
		ctx = context.Background()
		// Define the provisioning request.
		cr = &provisioningv1alpha1.ProvisioningRequest{
			ObjectMeta: metav1.ObjectMeta{
				Name: crName,
			},
			Spec: provisioningv1alpha1.ProvisioningRequestSpec{
				TemplateName:    tName,
				TemplateVersion: tVersion,
				TemplateParameters: runtime.RawExtension{
					Raw: []byte(testutils.TestFullTemplateParameters),
				},
			},
		}

		// Define the cluster template.
		ct := &provisioningv1alpha1.ClusterTemplate{
			ObjectMeta: metav1.ObjectMeta{
				Name:      GetClusterTemplateRefName(tName, tVersion),
				Namespace: ctNamespace,
			},
			Spec: provisioningv1alpha1.ClusterTemplateSpec{
				TemplateDefaults: provisioningv1alpha1.TemplateDefaults{
					ClusterInstanceDefaults: ciDefaultsCm,
				},
			},
		}
		// Use helper function to get ConfigMap with ClusterInstance defaults
		cm := getClusterInstanceDefaultsConfigMap(ciDefaultsCm, ctNamespace)

		c = fakeclient.GetFakeClientFromObjects([]client.Object{cr, ct, cm}...)
		reconciler = &ProvisioningRequestReconciler{
			Client: c,
			Logger: logger,
		}
		task = &provisioningRequestReconcilerTask{
			logger:       reconciler.Logger,
			client:       reconciler.Client,
			object:       cr,
			clusterInput: &clusterInput{},
			ctDetails: &clusterTemplateDetails{
				namespace: ctNamespace,
			},
		}

		clusterInstanceInputParams, err := provisioningv1alpha1.ExtractMatchingInput(
			cr.Spec.TemplateParameters.Raw, constants.TemplateParamClusterInstance)
		Expect(err).ToNot(HaveOccurred())
		mergedClusterInstanceData, err := task.getMergedClusterInputData(
			ctx, ciDefaultsCm, clusterInstanceInputParams.(map[string]any), constants.TemplateParamClusterInstance)
		Expect(err).ToNot(HaveOccurred())
		task.clusterInput.clusterInstanceData = mergedClusterInstanceData
	})

	It("should apply ClusterInstance with correct parameters", func() {
		// Build unstructured ClusterInstance
		renderedCIUnstructured, err := task.buildClusterInstanceUnstructured()
		Expect(err).ToNot(HaveOccurred())
		Expect(renderedCIUnstructured).ToNot(BeNil())

		clusterName := renderedCIUnstructured.GetName()

		// Apply the ClusterInstance
		_ = task.handleClusterInstallation(ctx, renderedCIUnstructured)

		// Verify ClusterInstance was applied by checking if it exists
		appliedCI := &unstructured.Unstructured{}
		appliedCI.SetGroupVersionKind(renderedCIUnstructured.GroupVersionKind())
		err = c.Get(ctx, client.ObjectKey{Name: clusterName, Namespace: clusterName}, appliedCI)
		Expect(err).ToNot(HaveOccurred())
		Expect(appliedCI.GetName()).To(Equal(clusterName))
	})

	It("should set ClusterDetails.Name in status when applying ClusterInstance", func() {
		// Build unstructured ClusterInstance
		renderedCIUnstructured, err := task.buildClusterInstanceUnstructured()
		Expect(err).ToNot(HaveOccurred())
		Expect(renderedCIUnstructured).ToNot(BeNil())

		clusterName := task.clusterInput.clusterInstanceData["clusterName"].(string)

		// Verify ClusterDetails is not set before applying
		Expect(task.object.Status.Extensions.ClusterDetails).To(BeNil())

		// Apply the ClusterInstance (this will set ClusterDetails)
		_ = task.handleClusterInstallation(ctx, renderedCIUnstructured)

		// Fetch the ProvisioningRequest from the client and verify ClusterDetails.Name is set
		provisioningRequest := &provisioningv1alpha1.ProvisioningRequest{}
		err = c.Get(ctx, client.ObjectKey{Name: crName}, provisioningRequest)
		Expect(err).ToNot(HaveOccurred())
		Expect(provisioningRequest.Status.Extensions.ClusterDetails).ToNot(BeNil())
		Expect(provisioningRequest.Status.Extensions.ClusterDetails.Name).To(Equal(clusterName))
	})
})

// ptr is a helper function to create pointers
func ptr[T any](v T) *T {
	return &v
}

func TestExtractNodeDetails(t *testing.T) {
	tests := []struct {
		name       string
		existingCI *unstructured.Unstructured
		expected   map[string]nodeInfo
	}{
		{
			name: "Valid Input - Extract Node Details",
			existingCI: &unstructured.Unstructured{
				Object: map[string]any{
					"spec": map[string]any{
						"nodes": []any{
							map[string]any{
								"hostName":       "node1",
								"bmcAddress":     "192.168.1.1",
								"bootMACAddress": "AA:BB:CC:DD:EE:FF",
								"bmcCredentialsName": map[string]any{
									"name": "bmc-secret",
								},
								"nodeNetwork": map[string]any{
									"interfaces": []any{
										map[string]any{
											"name":       "eth0",
											"macAddress": "00:11:22:33:44:55",
										},
										map[string]any{
											"name":       "eth1",
											"macAddress": "00:11:22:33:44:66",
										},
									},
								},
							},
						},
					},
				},
			},
			expected: map[string]nodeInfo{
				"node1": {
					bmcAddress:         ptr("192.168.1.1"),
					bootMACAddress:     ptr("AA:BB:CC:DD:EE:FF"),
					bmcCredentialsName: ptr("bmc-secret"),
					interfaceMACAddress: map[string]string{
						"eth0": "00:11:22:33:44:55",
						"eth1": "00:11:22:33:44:66",
					},
				},
			},
		},
		{
			name: "Missing hostName - Should be skipped",
			existingCI: &unstructured.Unstructured{
				Object: map[string]any{
					"spec": map[string]any{
						"nodes": []any{
							map[string]any{ // No hostName
								"bmcAddress": "192.168.1.1",
							},
						},
					},
				},
			},
			expected: map[string]nodeInfo{}, // No nodes extracted
		},
		{
			name: "Missing bmcAddress and bootMACAddress",
			existingCI: &unstructured.Unstructured{
				Object: map[string]any{
					"spec": map[string]any{
						"nodes": []any{
							map[string]any{
								"hostName": "node1",
								"bmcCredentialsName": map[string]any{
									"name": "bmc-secret",
								},
								"nodeNetwork": map[string]any{
									"interfaces": []any{
										map[string]any{
											"name":       "eth0",
											"macAddress": "00:11:22:33:44:55",
										},
										map[string]any{
											"name":       "eth1",
											"macAddress": "00:11:22:33:44:66",
										},
									},
								},
							},
						},
					},
				},
			},
			expected: map[string]nodeInfo{
				"node1": {
					bmcCredentialsName: ptr("bmc-secret"),
					interfaceMACAddress: map[string]string{
						"eth0": "00:11:22:33:44:55",
						"eth1": "00:11:22:33:44:66",
					},
				},
			},
		},
		{
			name: "Missing bmcCredentialsName or bmcCredentialsName.name",
			existingCI: &unstructured.Unstructured{
				Object: map[string]any{
					"spec": map[string]any{
						"nodes": []any{
							map[string]any{
								"hostName":           "node1",
								"bmcCredentialsName": map[string]any{
									// "name" key is missing
								},
							},
						},
					},
				},
			},
			expected: map[string]nodeInfo{
				"node1": {},
			},
		},
		{
			name: "Missing nodeNetwork.interfaces",
			existingCI: &unstructured.Unstructured{
				Object: map[string]any{
					"spec": map[string]any{
						"nodes": []any{
							map[string]any{
								"hostName":       "node1",
								"bmcAddress":     "192.168.1.1",
								"bootMACAddress": "AA:BB:CC:DD:EE:FF",
								"bmcCredentialsName": map[string]any{
									"name": "bmc-secret",
								},
								"nodeNetwork": map[string]any{
									// "interfaces" key is missing
								},
							},
						},
					},
				},
			},
			expected: map[string]nodeInfo{
				"node1": {
					bmcAddress:         ptr("192.168.1.1"),
					bootMACAddress:     ptr("AA:BB:CC:DD:EE:FF"),
					bmcCredentialsName: ptr("bmc-secret"),
				},
			},
		},
		{
			name: "Invalid Interface Data (missing name or macAddress)",
			existingCI: &unstructured.Unstructured{
				Object: map[string]any{
					"spec": map[string]any{
						"nodes": []any{
							map[string]any{
								"hostName": "node1",
								"nodeNetwork": map[string]any{
									"interfaces": []any{
										map[string]any{ // Missing name
											"macAddress": "00:11:22:33:44:55",
										},
										map[string]any{ // Missing macAddress
											"name": "eth1",
										},
										map[string]any{ // Valid entry
											"name":       "eth2",
											"macAddress": "00:11:22:33:44:77",
										},
									},
								},
							},
						},
					},
				},
			},
			expected: map[string]nodeInfo{
				"node1": {
					interfaceMACAddress: map[string]string{
						"eth2": "00:11:22:33:44:77", // Only the valid entry should be included
					},
				},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := extractNodeDetails(tt.existingCI)

			// Compare the extracted result with expected output
			if !reflect.DeepEqual(tt.expected, result) {
				t.Errorf("expected %v, got %v", tt.expected, result)
			}
		})
	}
}

func TestAssignNodeDetails(t *testing.T) {
	tests := []struct {
		name       string
		renderedCI *unstructured.Unstructured
		nodesInfo  map[string]nodeInfo
		expected   map[string]any
	}{
		{
			name: "Valid Input - Assign Node Details",
			renderedCI: &unstructured.Unstructured{
				Object: map[string]any{
					"spec": map[string]any{
						"nodes": []any{
							map[string]any{
								"hostName": "node1",
								"nodeNetwork": map[string]any{
									"interfaces": []any{
										map[string]any{
											"name":       "eth0",
											"macAddress": "old-mac",
										},
									},
								},
							},
						},
					},
				},
			},
			nodesInfo: map[string]nodeInfo{
				"node1": {
					bmcAddress:         ptr("192.168.1.1"),
					bootMACAddress:     ptr("AA:BB:CC:DD:EE:FF"),
					bmcCredentialsName: ptr("bmc-secret"),
					interfaceMACAddress: map[string]string{
						"eth0": "00:11:22:33:44:55",
					},
				},
			},
			expected: map[string]any{
				"spec": map[string]any{
					"nodes": []any{
						map[string]any{
							"hostName":       "node1",
							"bmcAddress":     "192.168.1.1",
							"bootMACAddress": "AA:BB:CC:DD:EE:FF",
							"bmcCredentialsName": map[string]any{
								"name": "bmc-secret",
							},
							"nodeNetwork": map[string]any{
								"interfaces": []any{
									map[string]any{
										"name":       "eth0",
										"macAddress": "00:11:22:33:44:55", // Updated MAC
									},
								},
							},
						},
					},
				},
			},
		},
		{
			name: "Missing spec.nodes - Should not panic",
			renderedCI: &unstructured.Unstructured{
				Object: map[string]any{
					"spec": map[string]any{},
				},
			},
			nodesInfo: map[string]nodeInfo{
				"node1": {
					bmcAddress:         ptr("192.168.1.1"),
					bootMACAddress:     ptr("AA:BB:CC:DD:EE:FF"),
					bmcCredentialsName: ptr("bmc-secret"),
					interfaceMACAddress: map[string]string{
						"eth0": "00:11:22:33:44:55",
					},
				},
			},
			expected: map[string]any{
				"spec": map[string]any{}, // No changes
			},
		},
		{
			name: "Node with missing hostName - Should be skipped",
			renderedCI: &unstructured.Unstructured{
				Object: map[string]any{
					"spec": map[string]any{
						"nodes": []any{
							map[string]any{ // No hostName
								"nodeNetwork": map[string]any{
									"interfaces": []any{
										map[string]any{
											"name":       "eth0",
											"macAddress": "old-mac",
										},
									},
								},
							},
						},
					},
				},
			},
			nodesInfo: map[string]nodeInfo{
				"node1": {
					bmcAddress:         ptr("192.168.1.1"),
					bootMACAddress:     ptr("AA:BB:CC:DD:EE:FF"),
					bmcCredentialsName: ptr("bmc-secret"),
					interfaceMACAddress: map[string]string{
						"eth0": "00:11:22:33:44:55",
					},
				},
			},
			expected: map[string]any{
				"spec": map[string]any{
					"nodes": []any{ // No changes, since hostName is missing
						map[string]any{
							"nodeNetwork": map[string]any{
								"interfaces": []any{
									map[string]any{
										"name":       "eth0",
										"macAddress": "old-mac",
									},
								},
							},
						},
					},
				},
			},
		},
		{
			name: "Missing interfacesMACAddress in the nodeInfo",
			renderedCI: &unstructured.Unstructured{
				Object: map[string]any{
					"spec": map[string]any{
						"nodes": []any{
							map[string]any{
								"hostName": "node1",
							},
						},
					},
				},
			},
			nodesInfo: map[string]nodeInfo{
				"node1": {
					bmcCredentialsName: ptr("bmc-secret"),
					bootMACAddress:     ptr("AA:BB:CC:DD:EE:FF"),
					bmcAddress:         ptr("192.168.1.1"),
					HwMgrNodeId:        "test",
					HwMgrNodeNs:        "test",
				},
			},
			expected: map[string]any{
				"spec": map[string]any{
					"nodes": []any{
						map[string]any{
							"hostName":       "node1",
							"bootMACAddress": "AA:BB:CC:DD:EE:FF",
							"bmcAddress":     "192.168.1.1",
							"bmcCredentialsName": map[string]any{
								"name": "bmc-secret",
							},
							"hostRef": map[string]any{
								"name":      "test",
								"namespace": "test",
							},
						},
					},
				},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assignNodeDetails(tt.renderedCI, tt.nodesInfo)

			// Compare modified renderedCI with expected output
			if !reflect.DeepEqual(tt.expected, tt.renderedCI.Object) {
				t.Errorf("expected %v, got %v", tt.expected, tt.renderedCI.Object)
			}
		})
	}
}

var _ = Describe("validateScaleWorkerOnly", func() {
	makeCI := func(nodes []map[string]any) *unstructured.Unstructured {
		ci := &unstructured.Unstructured{}
		ci.Object = map[string]any{
			"spec": map[string]any{
				"nodes": func() []any {
					result := make([]any, len(nodes))
					for i, n := range nodes {
						result[i] = n
					}
					return result
				}(),
			},
		}
		return ci
	}

	It("should allow adding worker nodes", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
			{"hostName": "worker2", "role": "worker"},
		})
		Expect(validateScaleWorkerOnly(existingCI, renderedCI)).To(Succeed())
	})

	It("should allow removing worker nodes", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
			{"hostName": "worker2", "role": "worker"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		Expect(validateScaleWorkerOnly(existingCI, renderedCI)).To(Succeed())
	})

	It("should allow replacing a worker node (simultaneous add and remove)", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker2", "role": "worker"},
		})
		Expect(validateScaleWorkerOnly(existingCI, renderedCI)).To(Succeed())
	})

	It("should reject adding master nodes", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "master2", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		err := validateScaleWorkerOnly(existingCI, renderedCI)
		Expect(err).To(HaveOccurred())
		Expect(err.Error()).To(ContainSubstring("cannot add master node"))
	})

	It("should reject removing master nodes", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "master2", "role": "master"},
			{"hostName": "master3", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "master2", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		err := validateScaleWorkerOnly(existingCI, renderedCI)
		Expect(err).To(HaveOccurred())
		Expect(err.Error()).To(ContainSubstring("cannot remove master node"))
		Expect(err.Error()).To(ContainSubstring("master3"))
	})

	It("should reject scaling on single-node clusters", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		err := validateScaleWorkerOnly(existingCI, renderedCI)
		Expect(err).To(HaveOccurred())
		Expect(err.Error()).To(ContainSubstring("not supported on single-node clusters"))
	})

	It("should pass when no scaling occurred", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		Expect(validateScaleWorkerOnly(existingCI, renderedCI)).To(Succeed())
	})

	It("should reject in-place role change from worker to master", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "master"},
		})
		err := validateScaleWorkerOnly(existingCI, renderedCI)
		Expect(err).To(HaveOccurred())
		Expect(err.Error()).To(ContainSubstring("in-place role change is not supported"))
		Expect(err.Error()).To(ContainSubstring("worker1"))
	})

	It("should reject in-place role change from master to worker", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "worker"},
			{"hostName": "worker1", "role": "worker"},
		})
		err := validateScaleWorkerOnly(existingCI, renderedCI)
		Expect(err).To(HaveOccurred())
		Expect(err.Error()).To(ContainSubstring("in-place role change is not supported"))
		Expect(err.Error()).To(ContainSubstring("master1"))
	})
})

var _ = Describe("scale-out detection via getNodeRolesByHostname", func() {
	makeCI := func(nodes []map[string]any) *unstructured.Unstructured {
		ci := &unstructured.Unstructured{}
		ci.Object = map[string]any{
			"spec": map[string]any{
				"nodes": func() []any {
					result := make([]any, len(nodes))
					for i, n := range nodes {
						result[i] = n
					}
					return result
				}(),
			},
		}
		return ci
	}

	It("should detect scale-out when rendered CI has more nodes than existing CI", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "master2", "role": "master"},
			{"hostName": "master3", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "master2", "role": "master"},
			{"hostName": "master3", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
			{"hostName": "worker2", "role": "worker"},
		})
		existingCount := len(getNodeRolesByHostname(existingCI))
		renderedCount := len(getNodeRolesByHostname(renderedCI))
		Expect(renderedCount).To(BeNumerically(">", existingCount))
	})

	It("should not detect scale-out when node counts are equal", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		existingCount := len(getNodeRolesByHostname(existingCI))
		renderedCount := len(getNodeRolesByHostname(renderedCI))
		Expect(renderedCount).To(Equal(existingCount))
	})
})

var _ = Describe("buildIntermediateCIWithPruneManifests", func() {
	makeCI := func(nodes []map[string]any) *unstructured.Unstructured {
		ci := &unstructured.Unstructured{}
		ci.Object = map[string]any{
			"spec": map[string]any{
				"nodes": func() []any {
					result := make([]any, len(nodes))
					for i, n := range nodes {
						result[i] = n
					}
					return result
				}(),
			},
		}
		return ci
	}

	It("should add removed node entries with pruneManifests", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master", "bmcAddress": "redfish://1.2.3.4"},
			{"hostName": "worker1", "role": "worker", "bmcAddress": "redfish://1.2.3.5"},
			{"hostName": "worker2", "role": "worker", "bmcAddress": "redfish://1.2.3.6"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master", "bmcAddress": "redfish://1.2.3.4"},
			{"hostName": "worker1", "role": "worker", "bmcAddress": "redfish://1.2.3.5"},
		})

		result := buildIntermediateCIWithPruneManifests(renderedCI, existingCI, []string{"worker2"})

		spec := result.Object["spec"].(map[string]any)
		nodes := spec["nodes"].([]any)
		Expect(nodes).To(HaveLen(3))

		// The third node (worker2) should have pruneManifests
		worker2 := nodes[2].(map[string]any)
		Expect(worker2["hostName"]).To(Equal("worker2"))
		Expect(worker2["pruneManifests"]).ToNot(BeNil())

		prune := worker2["pruneManifests"].([]any)
		Expect(prune).To(HaveLen(3))
		Expect(prune[0].(map[string]any)["kind"]).To(Equal("InfraEnv"))
		Expect(prune[1].(map[string]any)["kind"]).To(Equal("NMStateConfig"))
		Expect(prune[2].(map[string]any)["kind"]).To(Equal("BareMetalHost"))
	})

	It("should not modify the original rendered CI", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
		})

		result := buildIntermediateCIWithPruneManifests(renderedCI, existingCI, []string{"worker1"})

		// Original rendered CI should still have 1 node
		origSpec := renderedCI.Object["spec"].(map[string]any)
		origNodes := origSpec["nodes"].([]any)
		Expect(origNodes).To(HaveLen(1))

		// Result should have 2 nodes
		resultSpec := result.Object["spec"].(map[string]any)
		resultNodes := resultSpec["nodes"].([]any)
		Expect(resultNodes).To(HaveLen(2))
	})

	It("should handle multiple removed nodes", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
			{"hostName": "worker2", "role": "worker"},
			{"hostName": "worker3", "role": "worker"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})

		result := buildIntermediateCIWithPruneManifests(renderedCI, existingCI, []string{"worker2", "worker3"})

		spec := result.Object["spec"].(map[string]any)
		nodes := spec["nodes"].([]any)
		Expect(nodes).To(HaveLen(4))

		// Both removed nodes should have pruneManifests
		for _, node := range nodes[2:] {
			nodeMap := node.(map[string]any)
			Expect(nodeMap["pruneManifests"]).ToNot(BeNil())
		}
	})

	It("should exclude new nodes not in existing CI (swap case)", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
			{"hostName": "worker2", "role": "worker"},
		})
		// Swap: remove worker2, add worker3
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
			{"hostName": "worker3", "role": "worker"},
		})

		result := buildIntermediateCIWithPruneManifests(renderedCI, existingCI, []string{"worker2"})

		spec := result.Object["spec"].(map[string]any)
		nodes := spec["nodes"].([]any)
		// Should have: master1, worker1 (kept), worker2 (pruned) = 3 nodes
		// worker3 should be excluded (not in existing CI)
		Expect(nodes).To(HaveLen(3))

		hostnames := make([]string, len(nodes))
		for i, n := range nodes {
			hostnames[i] = n.(map[string]any)["hostName"].(string)
		}
		Expect(hostnames).To(ContainElements("master1", "worker1", "worker2"))
		Expect(hostnames).ToNot(ContainElement("worker3"))

		// worker2 should have pruneManifests
		worker2 := nodes[2].(map[string]any)
		Expect(worker2["hostName"]).To(Equal("worker2"))
		Expect(worker2["pruneManifests"]).ToNot(BeNil())
	})

	It("should include BMH in pruneManifests to clear stale manifestsRendered", func() {
		existingCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
			{"hostName": "worker1", "role": "worker"},
		})
		renderedCI := makeCI([]map[string]any{
			{"hostName": "master1", "role": "master"},
		})

		result := buildIntermediateCIWithPruneManifests(renderedCI, existingCI, []string{"worker1"})

		spec := result.Object["spec"].(map[string]any)
		nodes := spec["nodes"].([]any)
		worker1 := nodes[1].(map[string]any)
		prune := worker1["pruneManifests"].([]any)

		Expect(prune).To(HaveLen(3))
		kinds := make([]string, len(prune))
		for i, entry := range prune {
			kinds[i] = entry.(map[string]any)["kind"].(string)
		}
		Expect(kinds).To(ContainElements("InfraEnv", "NMStateConfig", "BareMetalHost"))
	})
})

var _ = Describe("getAgentHostname", func() {
	It("should extract hostname from spec.hostname", func() {
		agent := &unstructured.Unstructured{
			Object: map[string]any{
				"spec": map[string]any{
					"hostname": "worker1.cluster.example.com",
				},
			},
		}
		Expect(getAgentHostname(agent)).To(Equal("worker1.cluster.example.com"))
	})

	It("should fall back to status.inventory.hostname", func() {
		agent := &unstructured.Unstructured{
			Object: map[string]any{
				"spec": map[string]any{},
				"status": map[string]any{
					"inventory": map[string]any{
						"hostname": "worker2.cluster.example.com",
					},
				},
			},
		}
		Expect(getAgentHostname(agent)).To(Equal("worker2.cluster.example.com"))
	})

	It("should prefer spec.hostname over status.inventory.hostname", func() {
		agent := &unstructured.Unstructured{
			Object: map[string]any{
				"spec": map[string]any{
					"hostname": "from-spec",
				},
				"status": map[string]any{
					"inventory": map[string]any{
						"hostname": "from-status",
					},
				},
			},
		}
		Expect(getAgentHostname(agent)).To(Equal("from-spec"))
	})

	It("should return empty string when no hostname is found", func() {
		agent := &unstructured.Unstructured{
			Object: map[string]any{
				"spec": map[string]any{},
			},
		}
		Expect(getAgentHostname(agent)).To(BeEmpty())
	})
})

var _ = Describe("handleScaleInAbort", func() {
	var (
		ctx  context.Context
		task *provisioningRequestReconcilerTask
		nar  *hwmgmtv1alpha1.NodeAllocationRequest
	)

	BeforeEach(func() {
		ctx = context.Background()
		nar = &hwmgmtv1alpha1.NodeAllocationRequest{
			ObjectMeta: metav1.ObjectMeta{
				Name:      "test-pr",
				Namespace: "oran-o2ims",
			},
		}
	})

	It("should remove scale-in annotation from NAR when aborting", func() {
		nar.Annotations = map[string]string{
			hwmgrcontroller.ScaleInNodesAnnotation: "node-1",
		}

		pr := &provisioningv1alpha1.ProvisioningRequest{
			ObjectMeta: metav1.ObjectMeta{
				Name: "test-pr",
			},
			Status: provisioningv1alpha1.ProvisioningRequestStatus{
				Extensions: provisioningv1alpha1.Extensions{
					ClusterDetails: &provisioningv1alpha1.ClusterDetails{
						Name: "test-cluster",
					},
				},
			},
		}

		c := fakeclient.GetFakeClientFromObjects(pr, nar)
		task = &provisioningRequestReconcilerTask{
			logger: logger,
			client: c,
			object: pr,
		}

		Expect(task.handleScaleInAbort(ctx)).To(Succeed())

		// Verify annotation was removed
		updatedNAR := &hwmgmtv1alpha1.NodeAllocationRequest{}
		Expect(c.Get(ctx, client.ObjectKeyFromObject(nar), updatedNAR)).To(Succeed())
		Expect(updatedNAR.GetAnnotations()).ToNot(HaveKey(hwmgrcontroller.ScaleInNodesAnnotation))
	})

	It("should be a no-op when no annotation is present", func() {
		pr := &provisioningv1alpha1.ProvisioningRequest{
			ObjectMeta: metav1.ObjectMeta{
				Name: "test-pr",
			},
			Status: provisioningv1alpha1.ProvisioningRequestStatus{
				Extensions: provisioningv1alpha1.Extensions{
					ClusterDetails: &provisioningv1alpha1.ClusterDetails{
						Name: "test-cluster",
					},
				},
			},
		}

		c := fakeclient.GetFakeClientFromObjects(pr, nar)
		task = &provisioningRequestReconcilerTask{
			logger: logger,
			client: c,
			object: pr,
		}

		Expect(task.handleScaleInAbort(ctx)).To(Succeed())
	})

	It("should be a no-op when NAR does not exist", func() {
		pr := &provisioningv1alpha1.ProvisioningRequest{
			ObjectMeta: metav1.ObjectMeta{
				Name: "test-pr",
			},
			Status: provisioningv1alpha1.ProvisioningRequestStatus{
				Extensions: provisioningv1alpha1.Extensions{
					ClusterDetails: &provisioningv1alpha1.ClusterDetails{
						Name: "test-cluster",
					},
				},
			},
		}

		c := fakeclient.GetFakeClientFromObjects(pr)
		task = &provisioningRequestReconcilerTask{
			logger: logger,
			client: c,
			object: pr,
		}

		Expect(task.handleScaleInAbort(ctx)).To(Succeed())
	})

	It("should remove annotation when hostmap is populated but spoke is gone", func() {
		nar.Annotations = map[string]string{
			hwmgrcontroller.ScaleInNodesAnnotation: "node-1,node-2",
		}

		pr := &provisioningv1alpha1.ProvisioningRequest{
			ObjectMeta: metav1.ObjectMeta{
				Name: "test-pr",
			},
			Status: provisioningv1alpha1.ProvisioningRequestStatus{
				Extensions: provisioningv1alpha1.Extensions{
					ClusterDetails: &provisioningv1alpha1.ClusterDetails{
						Name: "test-cluster",
					},
					AllocatedNodeHostMap: map[string]string{
						"node-1": "worker-1.example.com",
						"node-2": "worker-2.example.com",
					},
				},
			},
		}

		// No kubeconfig secret — spoke cluster is gone
		c := fakeclient.GetFakeClientFromObjects(pr, nar)
		task = &provisioningRequestReconcilerTask{
			logger: logger,
			client: c,
			object: pr,
		}

		Expect(task.handleScaleInAbort(ctx)).To(Succeed())

		// Verify annotation was still removed even though spoke is unavailable
		updatedNAR := &hwmgmtv1alpha1.NodeAllocationRequest{}
		Expect(c.Get(ctx, client.ObjectKeyFromObject(nar), updatedNAR)).To(Succeed())
		Expect(updatedNAR.GetAnnotations()).ToNot(HaveKey(hwmgrcontroller.ScaleInNodesAnnotation))
	})
})
