// Copyright 2026 Microsoft Corporation
//
// 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 version

import (
	"context"
	"errors"
	"strings"
	"testing"
	"time"

	"github.com/blang/semver/v4"
	"github.com/go-logr/logr"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.uber.org/mock/gomock"

	apimeta "k8s.io/apimachinery/pkg/api/meta"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	clocktesting "k8s.io/utils/clock/testing"
	"k8s.io/utils/ptr"

	azcorearm "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"

	configv1 "github.com/openshift/api/config/v1"
	cvocincinnati "github.com/openshift/cluster-version-operator/pkg/cincinnati"

	"github.com/Azure/ARO-HCP/backend/pkg/utils/controllerutils"
	"github.com/Azure/ARO-HCP/internal/api/coreapi"
	"github.com/Azure/ARO-HCP/internal/api/metadataapi"
	"github.com/Azure/ARO-HCP/internal/cincinnati"
	"github.com/Azure/ARO-HCP/internal/database/cosmosstorage/cosmosstorageutils"
	"github.com/Azure/ARO-HCP/internal/database/cosmosstoragetesting/corecosmosstoragetesting"
	"github.com/Azure/ARO-HCP/internal/database/listers/corelisters"
	"github.com/Azure/ARO-HCP/internal/database/listertesting/corelistertesting"
	"github.com/Azure/ARO-HCP/internal/ocm"
	"github.com/Azure/ARO-HCP/internal/utils"
)

// channelExistence maps channel group -> minor -> whether GraphClient.ChannelExists returns true.
type channelExistence map[string]map[string]bool

func mockGraphClient(ctrl *gomock.Controller, existence channelExistence) *cincinnati.MockGraphClient {
	mc := cincinnati.NewMockGraphClient(ctrl)
	for channelGroup, minors := range existence {
		for minor, exists := range minors {
			mc.EXPECT().ChannelExists(gomock.Any(), channelGroup, minor).Return(exists, nil)
		}
	}
	return mc
}

func TestDesiredControlPlaneZVersion_ZStreamManagedUpgrade(t *testing.T) {
	tests := []struct {
		name                  string
		activeVersions        []coreapi.HCPClusterActiveVersion
		customerDesiredMinor  string
		channelGroup          string
		channelExistence      channelExistence
		mockSetup             func(*cincinnati.MockClient)
		expectedVersion       *semver.Version
		expectedError         bool
		expectedErrorContains string
	}{
		{
			name:                 "Z-stream upgrade - finds latest gateway",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.15")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.19",
			channelGroup:         "stable",
			channelExistence:     channelExistence{"stable": {"4.20": true}},
			mockSetup: func(mc *cincinnati.MockClient) {
				// Cincinnati may return versions from other minors which should be filtered out
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.19", semver.MustParse("4.19.15")).Return(
					configv1.Release{Version: "4.19.15"},
					[]configv1.Release{{Version: "4.19.18"}, {Version: "4.19.22"}, {Version: "4.20.5"}}, // 4.20.5 should be filtered out
					[]configv1.ConditionalUpdate{},
					nil,
				)
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.20", semver.MustParse("4.19.22")).Return(
					configv1.Release{Version: "4.19.22"},
					[]configv1.Release{{Version: "4.20.5"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(semver.MustParse("4.19.22")),
			expectedError:   false,
		},
		{
			name:                 "Z-stream upgrade - already at latest",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.22")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.19",
			channelGroup:         "stable",
			mockSetup: func(mc *cincinnati.MockClient) {
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.19", semver.MustParse("4.19.22")).Return(
					configv1.Release{Version: "4.19.22"},
					[]configv1.Release{}, // No newer versions
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: nil,
			expectedError:   false,
		},
		{
			name:                 "Z-stream upgrade - actual has edge to next minor, no gateway in candidates",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.15")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.19",
			channelGroup:         "stable",
			channelExistence:     channelExistence{"stable": {"4.20": true}},
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.19 versions from 4.19.15
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.19", semver.MustParse("4.19.15")).Return(
					configv1.Release{Version: "4.19.15"},
					[]configv1.Release{{Version: "4.19.20"}}, // Latest, but no gateway to 4.20
					[]configv1.ConditionalUpdate{},
					nil,
				)

				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.20", semver.MustParse("4.19.20")).Return(
					configv1.Release{Version: "4.19.20"},
					[]configv1.Release{}, // No path to 4.20
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: nil, // Z-stream: preserve upgradeability, don't select version without gateway
			expectedError:   false,
		},
		{
			name:                 "Z-stream upgrade - multiple active versions, only common candidates considered",
			customerDesiredMinor: "4.19",
			channelGroup:         "stable",
			channelExistence:     channelExistence{"stable": {"4.20": true}},
			activeVersions: []coreapi.HCPClusterActiveVersion{
				{Version: ptr.To(semver.MustParse("4.19.12")), State: configv1.CompletedUpdate}, // Most recent
				{Version: ptr.To(semver.MustParse("4.19.10")), State: configv1.CompletedUpdate}, // Older active version
			},
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.19 versions from 4.19.12 (most recent active version)
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.19", semver.MustParse("4.19.12")).Return(
					configv1.Release{Version: "4.19.12"},
					[]configv1.Release{{Version: "4.19.15"}, {Version: "4.19.18"}, {Version: "4.19.22"}}, // Reachable from 4.19.12
					[]configv1.ConditionalUpdate{},
					nil,
				)

				// Query for 4.19 versions from 4.19.10 (older active version)
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.19", semver.MustParse("4.19.10")).Return(
					configv1.Release{Version: "4.19.10"},
					[]configv1.Release{{Version: "4.19.15"}, {Version: "4.19.18"}}, // Reachable from 4.19.10. Note: 4.19.22 is NOT reachable
					[]configv1.ConditionalUpdate{},
					nil,
				)

				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.20", semver.MustParse("4.19.18")).Return(
					configv1.Release{Version: "4.19.18"},
					[]configv1.Release{{Version: "4.20.5"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(semver.MustParse("4.19.18")), // Latest common candidate that's a gateway
			expectedError:   false,
		},
		{
			name:                 "Z-stream upgrade - actual has NO edge to next minor, no gateway in candidates",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.10")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.19",
			channelGroup:         "stable",
			channelExistence:     channelExistence{"stable": {"4.20": false}},
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.19 versions from 4.19.10
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.19", semver.MustParse("4.19.10")).Return(
					configv1.Release{Version: "4.19.10"},
					[]configv1.Release{{Version: "4.19.18"}}, // Latest, but no gateway to 4.20
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(semver.MustParse("4.19.18")), // Safe to upgrade - no existing path to break
			expectedError:   false,
		},
		{
			name:                 "Z-stream upgrade - Cincinnati query error",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.15")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.19",
			channelGroup:         "stable",
			mockSetup: func(mc *cincinnati.MockClient) {
				// Mock Cincinnati returning an error
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.19", semver.MustParse("4.19.15")).Return(
					configv1.Release{},
					nil,
					nil,
					&cvocincinnati.Error{Message: "example error message"},
				)
			},
			expectedVersion:       nil,
			expectedError:         true,
			expectedErrorContains: "example error message",
		},
		{
			name:                 "Z-stream upgrade - no desired minor version specified",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.15")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "",
			channelGroup:         "stable",
			mockSetup:            func(mc *cincinnati.MockClient) {},
			expectedVersion:      nil,
			expectedError:        false,
		},
		{
			name:                 "Z-stream upgrade - no channel group specified",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.15")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.19",
			channelGroup:         "",
			mockSetup:            func(mc *cincinnati.MockClient) {},
			expectedVersion:      nil,
			expectedError:        false,
		},
		{
			name:                 "Z-stream upgrade - candidate channel, customer desired full version (4.20.15) normalized to same minor",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.20.10")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.20.15",
			channelGroup:         "candidate",
			channelExistence:     channelExistence{"candidate": {"4.21": true}},
			mockSetup: func(mc *cincinnati.MockClient) {
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("candidate")), "multi", "multi", "candidate-4.20", semver.MustParse("4.20.10")).Return(
					configv1.Release{Version: "4.20.10"},
					[]configv1.Release{{Version: "4.20.15"}, {Version: "4.20.12"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
				// isGatewayToNextMinor(4.20.15) - has path to 4.21, so 4.20.15 is selected
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("candidate")), "multi", "multi", "candidate-4.21", semver.MustParse("4.20.15")).Return(
					configv1.Release{Version: "4.20.15"},
					[]configv1.Release{{Version: "4.21.0"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(semver.MustParse("4.20.15")),
			expectedError:   false,
		},
		{
			name:                 "Z-stream upgrade - nightly channel, customer desired full version (4.19.0-0.nightly-multi-...) normalized to same minor",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(metadataapi.Must(semver.ParseTolerant("4.19.0-0.nightly-multi-2026-01-10-204154"))), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.19.0-0.nightly-multi-2026-01-12-061259",
			channelGroup:         "nightly",
			channelExistence:     channelExistence{"nightly": {"4.20": false}},
			mockSetup: func(mc *cincinnati.MockClient) {
				activeVer := metadataapi.Must(semver.ParseTolerant("4.19.0-0.nightly-multi-2026-01-10-204154"))
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("nightly")), "multi", "multi", "nightly-4.19", activeVer).Return(
					configv1.Release{Version: "4.19.0-0.nightly-multi-2026-01-10-204154"},
					[]configv1.Release{{Version: "4.19.0-0.nightly-multi-2026-01-12-061259"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(metadataapi.Must(semver.ParseTolerant("4.19.0-0.nightly-multi-2026-01-12-061259"))),
			expectedError:   false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)

			mockCincinnatiClient := cincinnati.NewMockClient(ctrl)
			tt.mockSetup(mockCincinnatiClient)

			mockResourcesDBClient := corecosmosstoragetesting.NewMockResourcesDBClient()
			syncer := &controlPlaneDesiredVersionSyncer{
				resourcesDBClient:             mockResourcesDBClient,
				graphClient:                   mockGraphClient(ctrl, tt.channelExistence),
				serviceProviderClusterLister:  &corelistertesting.DBServiceProviderClusterLister{ResourcesDBClient: mockResourcesDBClient},
				serviceProviderNodePoolLister: &corelistertesting.DBServiceProviderNodePoolLister{ResourcesDBClient: mockResourcesDBClient},
			}

			ctx := context.Background()
			result, err := syncer.desiredControlPlaneZVersion(ctx, mockCincinnatiClient, metadataapi.Must(coreapi.ToClusterResourceID("6b690bec-0c16-4ecb-8f67-781caf40bba7", "test-rg", "test-cluster")), tt.customerDesiredMinor, tt.channelGroup, tt.activeVersions, false)

			assertVersionResult(t, result, err, tt.expectedVersion, tt.expectedError, tt.expectedErrorContains)
		})
	}
}

func TestDesiredControlPlaneZVersion_NextYStreamUpgrade(t *testing.T) {
	tests := []struct {
		name                  string
		activeVersions        []coreapi.HCPClusterActiveVersion
		customerDesiredMinor  string
		channelGroup          string
		channelExistence      channelExistence
		mockSetup             func(*cincinnati.MockClient)
		cosmosResources       []any
		expectedVersion       *semver.Version
		expectedError         bool
		expectedErrorContains string
	}{
		{
			name:                 "Y-stream upgrade - direct path available returns latest version with gateway to next minor",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.22")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.20",
			channelGroup:         "stable",
			channelExistence:     channelExistence{"stable": {"4.21": true}},
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.20 versions from 4.19.22
				// Cincinnati may return versions from other minors which should be filtered out
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.20", semver.MustParse("4.19.22")).Return(
					configv1.Release{Version: "4.19.22"},
					[]configv1.Release{{Version: "4.20.15"}, {Version: "4.20.10"}, {Version: "4.19.25"}}, // 4.19.25 should be filtered out
					[]configv1.ConditionalUpdate{},
					nil,
				)

				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.21", semver.MustParse("4.20.15")).Return(
					configv1.Release{Version: "4.20.15"},
					[]configv1.Release{}, // No path to 4.21
					[]configv1.ConditionalUpdate{},
					nil,
				).Times(1)

				// Check if 4.20.10 has gateway to 4.21 - it does
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.21", semver.MustParse("4.20.10")).Return(
					configv1.Release{Version: "4.20.10"},
					[]configv1.Release{{Version: "4.21.0"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(semver.MustParse("4.20.10")),
			expectedError:   false,
		},
		{
			name:                 "Y-stream upgrade - succeeds with node pool within skew versus desired minor",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.22")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.20",
			channelGroup:         "stable",
			channelExistence:     channelExistence{"stable": {"4.21": true}},
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.20 versions from 4.19.22
				// Cincinnati may return versions from other minors which should be filtered out
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.20", semver.MustParse("4.19.22")).Return(
					configv1.Release{Version: "4.19.22"},
					[]configv1.Release{{Version: "4.20.15"}, {Version: "4.20.10"}, {Version: "4.19.25"}}, // 4.19.25 should be filtered out
					[]configv1.ConditionalUpdate{},
					nil,
				)

				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.21", semver.MustParse("4.20.15")).Return(
					configv1.Release{Version: "4.20.15"},
					[]configv1.Release{}, // No path to 4.21
					[]configv1.ConditionalUpdate{},
					nil,
				).Times(1)

				// Check if 4.20.10 has gateway to 4.21 - it does
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.21", semver.MustParse("4.20.10")).Return(
					configv1.Release{Version: "4.20.10"},
					[]configv1.Release{{Version: "4.21.0"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			cosmosResources: testCosmosClusterWithWorkersNodePoolAtVersion("4.18.0"),
			expectedVersion: ptr.To(semver.MustParse("4.20.10")),
			expectedError:   false,
		},
		{
			name:                 "Y-stream upgrade - no direct path, falls back to Z-stream",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.15")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.20",
			channelGroup:         "stable",
			channelExistence:     channelExistence{"stable": {"4.20": true}},
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.20 versions from 4.19.15 - no path
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.20", semver.MustParse("4.19.15")).Return(
					configv1.Release{Version: "4.19.15"},
					[]configv1.Release{}, // No direct path to 4.20
					[]configv1.ConditionalUpdate{},
					nil,
				)

				// Fallback to Z-stream in actual minor (4.19)
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.19", semver.MustParse("4.19.15")).Return(
					configv1.Release{Version: "4.19.15"},
					[]configv1.Release{{Version: "4.19.22"}, {Version: "4.19.18"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)

				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.20", semver.MustParse("4.19.22")).Return(
					configv1.Release{Version: "4.19.22"},
					[]configv1.Release{{Version: "4.20.5"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(semver.MustParse("4.19.22")),
			expectedError:   false,
		},
		{
			name:                 "Y-stream upgrade - multiple active versions, only common candidates considered",
			customerDesiredMinor: "4.20",
			channelGroup:         "stable",
			channelExistence:     channelExistence{"stable": {"4.21": true}},
			activeVersions: []coreapi.HCPClusterActiveVersion{
				{Version: ptr.To(semver.MustParse("4.19.18")), State: configv1.CompletedUpdate}, // Most recent
				{Version: ptr.To(semver.MustParse("4.19.15")), State: configv1.CompletedUpdate}, // Older active version
			},
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.20 versions from 4.19.18 (most recent active version)
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.20", semver.MustParse("4.19.18")).Return(
					configv1.Release{Version: "4.19.18"},
					[]configv1.Release{{Version: "4.20.8"}, {Version: "4.20.12"}, {Version: "4.20.15"}}, // Reachable from 4.19.18
					[]configv1.ConditionalUpdate{},
					nil,
				)

				// Query for 4.20 versions from 4.19.15 (older active version)
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.20", semver.MustParse("4.19.15")).Return(
					configv1.Release{Version: "4.19.15"},
					[]configv1.Release{{Version: "4.20.8"}, {Version: "4.20.12"}}, // Reachable from 4.19.15. Note: 4.20.15 is NOT reachable
					[]configv1.ConditionalUpdate{},
					nil,
				)

				// For Y-stream upgrades, actualMinor != targetMinor, so uses latest candidate
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.21", semver.MustParse("4.20.12")).Return(
					configv1.Release{Version: "4.20.12"},
					[]configv1.Release{{Version: "4.21.3"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(semver.MustParse("4.20.12")), // Latest common candidate with gateway to 4.21
			expectedError:   false,
		},
		{
			name:                 "Y-stream upgrade - no gateway found but returns latest anyway",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.15")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.20",
			channelGroup:         "stable",
			channelExistence:     channelExistence{"stable": {"4.21": false}},
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.20 versions from 4.19.15
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.20", semver.MustParse("4.19.15")).Return(
					configv1.Release{Version: "4.19.15"},
					[]configv1.Release{{Version: "4.20.12"}}, // Latest in 4.20, but no gateway to 4.21
					[]configv1.ConditionalUpdate{},
					nil,
				)

			},
			expectedVersion: ptr.To(semver.MustParse("4.20.12")), // Returns latest even without gateway - user wants to be on 4.20
			expectedError:   false,
		},
		{
			name:                 "Y-stream upgrade - next minor exists but no gateway, returns latest for security",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.60")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.20",
			channelGroup:         "candidate",
			channelExistence:     channelExistence{"candidate": {"4.21": true}},
			cosmosResources:      testCosmosClusterWithWorkersNodePoolAtVersion("4.19.60"),
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.20 versions from 4.19.60 - 4.20.50 is reachable
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("candidate")), "multi", "multi", "candidate-4.20", semver.MustParse("4.19.60")).Return(
					configv1.Release{Version: "4.19.60"},
					[]configv1.Release{{Version: "4.20.50"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)

				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("candidate")), "multi", "multi", "candidate-4.21", semver.MustParse("4.20.50")).Return(
					configv1.Release{Version: "4.20.50"},
					[]configv1.Release{}, // No path to 4.21
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(semver.MustParse("4.20.50")), // Prioritize security over y-stream path
			expectedError:   false,
		},
		{
			name:                 "Y-stream upgrade - Cincinnati query error",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.22")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.20",
			channelGroup:         "stable",
			mockSetup: func(mc *cincinnati.MockClient) {
				// Mock Cincinnati returning an error
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.20", semver.MustParse("4.19.22")).Return(
					configv1.Release{},
					nil,
					nil,
					&cvocincinnati.Error{Message: "example error message"},
				)
			},
			expectedVersion:       nil,
			expectedError:         true,
			expectedErrorContains: "example error message",
		},
		{
			name:                 "Y-stream upgrade - no path in target minor and already at latest in current minor",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.20.22")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.21",
			channelGroup:         "candidate",
			cosmosResources:      testCosmosClusterWithWorkersNodePoolAtVersion("4.20.22"),
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.21 versions from 4.20.22 - no candidates reachable
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("candidate")), "multi", "multi", "candidate-4.21", semver.MustParse("4.20.22")).Return(
					configv1.Release{Version: "4.20.22"},
					[]configv1.Release{},
					[]configv1.ConditionalUpdate{},
					nil,
				)

				// Fallback to current minor (4.20) - already at latest, no candidates
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("candidate")), "multi", "multi", "candidate-4.20", semver.MustParse("4.20.22")).Return(
					configv1.Release{Version: "4.20.22"},
					[]configv1.Release{},
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion:       nil,
			expectedError:         true,
			expectedErrorContains: "no upgrade path found from 4.20.22 to 4.21.0",
		},
		{
			name:                 "Y-stream upgrade - candidates exist but no gateway to next minor, returns latest for security",
			activeVersions:       []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.20.22")), State: configv1.CompletedUpdate}},
			customerDesiredMinor: "4.21",
			channelGroup:         "candidate",
			channelExistence:     channelExistence{"candidate": {"4.22": true}},
			cosmosResources:      testCosmosClusterWithWorkersNodePoolAtVersion("4.20.22"),
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.21 versions from 4.20.22 - 4.21.15 reachable
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("candidate")), "multi", "multi", "candidate-4.21", semver.MustParse("4.20.22")).Return(
					configv1.Release{Version: "4.20.22"},
					[]configv1.Release{{Version: "4.21.15"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)

				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("candidate")), "multi", "multi", "candidate-4.22", semver.MustParse("4.21.15")).Return(
					configv1.Release{Version: "4.21.15"},
					[]configv1.Release{},
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(semver.MustParse("4.21.15")), // Prioritize security over y-stream path
			expectedError:   false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)

			mockCincinnatiClient := cincinnati.NewMockClient(ctrl)
			tt.mockSetup(mockCincinnatiClient)

			ctx := context.Background()
			mockResourcesDBClient, err := corecosmosstoragetesting.NewMockResourcesDBClientWithResources(ctx, tt.cosmosResources)
			require.NoError(t, err)
			syncer := &controlPlaneDesiredVersionSyncer{
				resourcesDBClient:             mockResourcesDBClient,
				graphClient:                   mockGraphClient(ctrl, tt.channelExistence),
				serviceProviderClusterLister:  &corelistertesting.DBServiceProviderClusterLister{ResourcesDBClient: mockResourcesDBClient},
				serviceProviderNodePoolLister: &corelistertesting.DBServiceProviderNodePoolLister{ResourcesDBClient: mockResourcesDBClient},
			}

			result, err := syncer.desiredControlPlaneZVersion(ctx, mockCincinnatiClient, metadataapi.Must(coreapi.ToClusterResourceID("6b690bec-0c16-4ecb-8f67-781caf40bba7", "test-rg", "test-cluster")), tt.customerDesiredMinor, tt.channelGroup, tt.activeVersions, false)

			assertVersionResult(t, result, err, tt.expectedVersion, tt.expectedError, tt.expectedErrorContains)
		})
	}
}

// testCosmosClusterWithWorkersNodePoolAtVersion returns a cluster and workers node pool for the subscription, resource group,
// and cluster name shared by desiredControlPlaneZVersion tests. nodePoolVersionId is properties.version.id on the pool.
// Also seeds an empty ServiceProviderCluster and an empty ServiceProviderNodePool the
// way the production creator controllers would have populated them.
func testCosmosClusterWithWorkersNodePoolAtVersion(nodePoolVersionId string) []any {
	clusterResourceId, cluster := testCosmosClusterResource()
	workersNodePool := testCosmosNodePool(clusterResourceId, "workers", nodePoolVersionId, false)
	return []any{
		cluster,
		workersNodePool,
		testCosmosServiceProviderNodePool(workersNodePool.ResourceID),
	}
}

func testCosmosClusterResource() (*azcorearm.ResourceID, *coreapi.HCPOpenShiftCluster) {
	clusterResourceId := metadataapi.Must(coreapi.ToClusterResourceID("6b690bec-0c16-4ecb-8f67-781caf40bba7", "test-rg", "test-cluster"))
	cluster := &coreapi.HCPOpenShiftCluster{
		CosmosMetadata: coreapi.CosmosMetadata{
			ResourceID:   clusterResourceId,
			PartitionKey: strings.ToLower(clusterResourceId.SubscriptionID),
		},
		TrackedResource: coreapi.TrackedResource{
			Resource: coreapi.Resource{
				ID:   clusterResourceId,
				Name: clusterResourceId.Name,
				Type: clusterResourceId.ResourceType.String(),
			},
		},
		ServiceProviderProperties: coreapi.HCPOpenShiftClusterServiceProviderProperties{
			ClusterServiceID: metadataapi.Ptr(metadataapi.Must(metadataapi.NewInternalID("/api/clusters_mgmt/v1/clusters/test-cluster"))),
		},
	}
	return clusterResourceId, cluster
}

// testCosmosServiceProviderNodePool returns an empty ServiceProviderNodePool nested under
// the given node pool, the way CreateServiceProviderNodePool would have populated it in production.
func testCosmosServiceProviderNodePool(nodePoolResourceId *azcorearm.ResourceID) *coreapi.ServiceProviderNodePool {
	spnpResourceId := metadataapi.Must(azcorearm.ParseResourceID(
		nodePoolResourceId.String() + "/serviceProviderNodePools/" + coreapi.ServiceProviderNodePoolResourceName,
	))
	return &coreapi.ServiceProviderNodePool{
		CosmosMetadata: coreapi.CosmosMetadata{
			ResourceID:   spnpResourceId,
			PartitionKey: strings.ToLower(spnpResourceId.SubscriptionID),
		},
	}
}

func testCosmosNodePool(clusterResourceId *azcorearm.ResourceID, name, nodePoolVersionId string, deleting bool) *coreapi.HCPOpenShiftClusterNodePool {
	nodePoolResourceId := metadataapi.Must(azcorearm.ParseResourceID(clusterResourceId.String() + "/nodePools/" + name))
	nodePool := &coreapi.HCPOpenShiftClusterNodePool{
		CosmosMetadata: coreapi.CosmosMetadata{
			ResourceID:   nodePoolResourceId,
			PartitionKey: strings.ToLower(nodePoolResourceId.SubscriptionID),
		},
		TrackedResource: coreapi.NewTrackedResource(nodePoolResourceId, "eastus"),
		Properties: coreapi.HCPOpenShiftClusterNodePoolProperties{
			Version: coreapi.NodePoolVersionProfile{ID: nodePoolVersionId},
		},
		ServiceProviderProperties: coreapi.HCPOpenShiftClusterNodePoolServiceProviderProperties{
			ClusterServiceID: metadataapi.Ptr(metadataapi.Must(metadataapi.NewInternalID("/api/clusters_mgmt/v1/clusters/test-cluster/node_pools/" + name))),
		},
	}
	if deleting {
		nodePool.ServiceProviderProperties.DeletionTimestamp = ptr.To(metav1.Now())
	}
	return nodePool
}

// testCosmosClusterWithActiveAndDeletingNodePools returns a cluster with one active node pool and one
// node pool marked for deletion. Skew validation should consider only the active pool.
// The active pool is also seeded with an empty ServiceProviderNodePool so that
// listClusterAdmissionNodePools (which Gets the SPNP for every non-deleting pool)
// returns a complete result.
func testCosmosClusterWithActiveAndDeletingNodePools(activeNodePoolName, activeVersion, deletingNodePoolName, deletingVersion string) []any {
	clusterResourceId, cluster := testCosmosClusterResource()
	activeNodePool := testCosmosNodePool(clusterResourceId, activeNodePoolName, activeVersion, false)
	deletingNodePool := testCosmosNodePool(clusterResourceId, deletingNodePoolName, deletingVersion, true)
	return []any{
		cluster,
		activeNodePool,
		deletingNodePool,
		testCosmosServiceProviderNodePool(activeNodePool.ResourceID),
	}
}

func TestDesiredControlPlaneZVersion_Validations(t *testing.T) {
	tests := []struct {
		name                        string
		activeVersions              []coreapi.HCPClusterActiveVersion
		customerDesiredMinor        string
		channelGroup                string
		mockSetup                   func(*cincinnati.MockClient)
		cosmosResources             []any
		experimentalReleaseFeatures bool
		expectedVersion             *semver.Version
		expectedError               bool
		expectedErrorContains       string
	}{
		{
			name:                  "Validation - downgrade not allowed (4.20 -> 4.19)",
			activeVersions:        []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.20.15")), State: configv1.CompletedUpdate}},
			customerDesiredMinor:  "4.19",
			channelGroup:          "stable",
			mockSetup:             func(mc *cincinnati.MockClient) {},
			expectedVersion:       nil,
			expectedError:         true,
			expectedErrorContains: "only upgrades to the next minor version are allowed, no downgrades",
		},
		{
			name:                  "Validation - OpenShift 5.x requires AFEC (4.20 -> 5.0)",
			activeVersions:        []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.20.15")), State: configv1.CompletedUpdate}},
			customerDesiredMinor:  "5.0",
			channelGroup:          "stable",
			mockSetup:             func(mc *cincinnati.MockClient) {},
			expectedVersion:       nil,
			expectedError:         true,
			expectedErrorContains: "OpenShift v5 and above is not supported",
		},
		{
			name:                        "Validation - unsupported cross-major (4.20 -> 5.0, not a supported 4→5 landing) when AFEC registered",
			activeVersions:              []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.20.15")), State: configv1.CompletedUpdate}},
			customerDesiredMinor:        "5.0",
			channelGroup:                "stable",
			mockSetup:                   func(mc *cincinnati.MockClient) {},
			experimentalReleaseFeatures: true,
			expectedVersion:             nil,
			expectedError:               true,
			expectedErrorContains:       "cross-major upgrade from 4.20 is only allowed to",
		},
		{
			name:                  "Validation - skip minor version not allowed (4.19 -> 4.21)",
			activeVersions:        []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.19.22")), State: configv1.CompletedUpdate}},
			customerDesiredMinor:  "4.21",
			channelGroup:          "stable",
			mockSetup:             func(mc *cincinnati.MockClient) {},
			expectedVersion:       nil,
			expectedError:         true,
			expectedErrorContains: "only upgrade to the next minor is allowed",
		},
		{
			name:                  "Validation - major version downgrade not allowed (5.1 -> 4.20)",
			activeVersions:        []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("5.1.5")), State: configv1.CompletedUpdate}},
			customerDesiredMinor:  "4.20",
			channelGroup:          "stable",
			mockSetup:             func(mc *cincinnati.MockClient) {},
			expectedVersion:       nil,
			expectedError:         true,
			expectedErrorContains: "only upgrades to the next minor version are allowed, no downgrades",
		},
		{
			name:                        "Validation - node pool minor skew blocks supported cross-major desired minor",
			activeVersions:              []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.22.0")), State: configv1.CompletedUpdate}},
			customerDesiredMinor:        "5.0",
			channelGroup:                "stable",
			mockSetup:                   func(mc *cincinnati.MockClient) {},
			cosmosResources:             testCosmosClusterWithWorkersNodePoolAtVersion("4.20.0"),
			experimentalReleaseFeatures: true,
			expectedVersion:             nil,
			expectedError:               true,
			expectedErrorContains:       "incompatible with node pool",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)

			mockCincinnatiClient := cincinnati.NewMockClient(ctrl)
			tt.mockSetup(mockCincinnatiClient)

			ctx := context.Background()
			mockResourcesDBClient, err := corecosmosstoragetesting.NewMockResourcesDBClientWithResources(ctx, tt.cosmosResources)
			require.NoError(t, err)
			syncer := &controlPlaneDesiredVersionSyncer{
				resourcesDBClient:             mockResourcesDBClient,
				serviceProviderClusterLister:  &corelistertesting.DBServiceProviderClusterLister{ResourcesDBClient: mockResourcesDBClient},
				serviceProviderNodePoolLister: &corelistertesting.DBServiceProviderNodePoolLister{ResourcesDBClient: mockResourcesDBClient},
			}

			result, err := syncer.desiredControlPlaneZVersion(ctx, mockCincinnatiClient, metadataapi.Must(coreapi.ToClusterResourceID("6b690bec-0c16-4ecb-8f67-781caf40bba7", "test-rg", "test-cluster")), tt.customerDesiredMinor, tt.channelGroup, tt.activeVersions, tt.experimentalReleaseFeatures)

			assertVersionResult(t, result, err, tt.expectedVersion, tt.expectedError, tt.expectedErrorContains)
		})
	}
}

func TestDesiredControlPlaneZVersion_CrossMajorUpgrade(t *testing.T) {
	tests := []struct {
		name                        string
		activeVersions              []coreapi.HCPClusterActiveVersion
		customerDesiredMinor        string
		channelGroup                string
		channelExistence            channelExistence
		mockSetup                   func(*cincinnati.MockClient)
		cosmosResources             []any
		experimentalReleaseFeatures bool
		expectedVersion             *semver.Version
		expectedError               bool
		expectedErrorContains       string
	}{
		{
			name:                        "Cross-major allowed — 4.22 to 5.0 with experimental release features and compatible node pools",
			activeVersions:              []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.22.0")), State: configv1.CompletedUpdate}},
			customerDesiredMinor:        "5.0",
			channelGroup:                "stable",
			channelExistence:            channelExistence{"stable": {"5.1": true}},
			cosmosResources:             testCosmosClusterWithWorkersNodePoolAtVersion("4.22.0"),
			experimentalReleaseFeatures: true,
			mockSetup: func(mc *cincinnati.MockClient) {
				stableURI := metadataapi.Must(cincinnati.GetCincinnatiURI("stable"))
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), stableURI, "multi", "multi", "stable-5.0", semver.MustParse("4.22.0")).Return(
					configv1.Release{Version: "4.22.0"},
					[]configv1.Release{{Version: "5.0.15"}, {Version: "5.0.10"}, {Version: "4.22.5"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), stableURI, "multi", "multi", "stable-5.1", semver.MustParse("5.0.15")).Return(
					configv1.Release{Version: "5.0.15"},
					[]configv1.Release{},
					[]configv1.ConditionalUpdate{},
					nil,
				)
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), stableURI, "multi", "multi", "stable-5.1", semver.MustParse("5.0.10")).Return(
					configv1.Release{Version: "5.0.10"},
					[]configv1.Release{{Version: "5.1.0"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(semver.MustParse("5.0.10")),
			expectedError:   false,
		},
		{
			name:                        "Cross-major allowed when incompatible node pool is being deleted",
			activeVersions:              []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.22.0")), State: configv1.CompletedUpdate}},
			customerDesiredMinor:        "5.0",
			channelGroup:                "stable",
			channelExistence:            channelExistence{"stable": {"5.1": true}},
			cosmosResources:             testCosmosClusterWithActiveAndDeletingNodePools("infra", "4.22.0", "workers", "4.20.0"),
			experimentalReleaseFeatures: true,
			mockSetup: func(mc *cincinnati.MockClient) {
				stableURI := metadataapi.Must(cincinnati.GetCincinnatiURI("stable"))
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), stableURI, "multi", "multi", "stable-5.0", semver.MustParse("4.22.0")).Return(
					configv1.Release{Version: "4.22.0"},
					[]configv1.Release{{Version: "5.0.15"}, {Version: "5.0.10"}, {Version: "4.22.5"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), stableURI, "multi", "multi", "stable-5.1", semver.MustParse("5.0.15")).Return(
					configv1.Release{Version: "5.0.15"},
					[]configv1.Release{},
					[]configv1.ConditionalUpdate{},
					nil,
				)
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), stableURI, "multi", "multi", "stable-5.1", semver.MustParse("5.0.10")).Return(
					configv1.Release{Version: "5.0.10"},
					[]configv1.Release{{Version: "5.1.0"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(semver.MustParse("5.0.10")),
			expectedError:   false,
		},
		{
			name:                        "Cross-major not allowed — 4.22 to 5.0 without experimental release features",
			activeVersions:              []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.22.0")), State: configv1.CompletedUpdate}},
			customerDesiredMinor:        "5.0",
			channelGroup:                "stable",
			mockSetup:                   func(mc *cincinnati.MockClient) {},
			experimentalReleaseFeatures: false,
			expectedVersion:             nil,
			expectedError:               true,
			expectedErrorContains:       "OpenShift v5 and above is not supported",
		},
		{
			name:                        "Cross-major not allowed — 4.21 to 5.0 is not a supported landing even with experimental release features",
			activeVersions:              []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.21.10")), State: configv1.CompletedUpdate}},
			customerDesiredMinor:        "5.0",
			channelGroup:                "stable",
			cosmosResources:             testCosmosClusterWithWorkersNodePoolAtVersion("4.21.0"),
			mockSetup:                   func(mc *cincinnati.MockClient) {},
			experimentalReleaseFeatures: true,
			expectedVersion:             nil,
			expectedError:               true,
			expectedErrorContains:       "cross-major upgrade from 4.21 is only allowed to",
		},
		{
			name:                        "Cross-major not allowed — 4.22 to 5.1 skips the supported 4.22 to 5.0 path",
			activeVersions:              []coreapi.HCPClusterActiveVersion{{Version: ptr.To(semver.MustParse("4.22.0")), State: configv1.CompletedUpdate}},
			customerDesiredMinor:        "5.1",
			channelGroup:                "stable",
			cosmosResources:             testCosmosClusterWithWorkersNodePoolAtVersion("4.22.0"),
			mockSetup:                   func(mc *cincinnati.MockClient) {},
			experimentalReleaseFeatures: true,
			expectedVersion:             nil,
			expectedError:               true,
			expectedErrorContains:       "cross-major upgrade from 4.22 is only allowed to",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)

			mockCincinnatiClient := cincinnati.NewMockClient(ctrl)
			tt.mockSetup(mockCincinnatiClient)

			ctx := context.Background()
			mockResourcesDBClient, err := corecosmosstoragetesting.NewMockResourcesDBClientWithResources(ctx, tt.cosmosResources)
			require.NoError(t, err)
			syncer := &controlPlaneDesiredVersionSyncer{
				resourcesDBClient:             mockResourcesDBClient,
				graphClient:                   mockGraphClient(ctrl, tt.channelExistence),
				serviceProviderClusterLister:  &corelistertesting.DBServiceProviderClusterLister{ResourcesDBClient: mockResourcesDBClient},
				serviceProviderNodePoolLister: &corelistertesting.DBServiceProviderNodePoolLister{ResourcesDBClient: mockResourcesDBClient},
			}

			result, err := syncer.desiredControlPlaneZVersion(ctx, mockCincinnatiClient, metadataapi.Must(coreapi.ToClusterResourceID("6b690bec-0c16-4ecb-8f67-781caf40bba7", "test-rg", "test-cluster")), tt.customerDesiredMinor, tt.channelGroup, tt.activeVersions, tt.experimentalReleaseFeatures)

			assertVersionResult(t, result, err, tt.expectedVersion, tt.expectedError, tt.expectedErrorContains)
		})
	}
}

func TestDesiredControlPlaneZVersion_InitialVersionSelection(t *testing.T) {
	tests := []struct {
		name                  string
		customerDesiredMinor  string
		channelGroup          string
		channelExistence      channelExistence
		mockSetup             func(*cincinnati.MockClient)
		expectedVersion       *semver.Version
		expectedError         bool
		expectedErrorContains string
	}{
		{
			name:                 "Initial version - prefers gateway over absolute latest",
			customerDesiredMinor: "4.19",
			channelGroup:         "stable",
			channelExistence:     channelExistence{"stable": {"4.20": true}},
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.19 versions from seedVersion (4.19.0)
				// Cincinnati may return versions from other minors which should be filtered out
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.19", semver.MustParse("4.19.0")).Return(
					configv1.Release{Version: "4.19.0"},
					[]configv1.Release{{Version: "4.19.15"}, {Version: "4.19.22"}, {Version: "4.20.5"}}, // 4.20.5 should be filtered out
					[]configv1.ConditionalUpdate{},
					nil,
				)

				// Returns empty list - 4.19.22 is NOT a gateway to 4.20
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.20", semver.MustParse("4.19.22")).Return(
					configv1.Release{Version: "4.19.22"},
					[]configv1.Release{}, // No path to 4.20
					[]configv1.ConditionalUpdate{},
					nil,
				)

				// Check if 4.19.15 is a gateway to 4.20 - it is
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.20", semver.MustParse("4.19.15")).Return(
					configv1.Release{Version: "4.19.15"},
					[]configv1.Release{{Version: "4.20.5"}},
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(semver.MustParse("4.19.15")), // Prefers gateway version over absolute latest
			expectedError:   false,
		},
		{
			name:                 "Initial version - no updates available, falls back to seedVersion",
			customerDesiredMinor: "4.19",
			channelGroup:         "stable",
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.19 versions from seedVersion (4.19.0)
				// No updates available - Cincinnati returns empty list
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.19", semver.MustParse("4.19.0")).Return(
					configv1.Release{Version: "4.19.0"},
					[]configv1.Release{}, // No newer versions available
					[]configv1.ConditionalUpdate{},
					nil,
				)
			},
			expectedVersion: ptr.To(semver.MustParse("4.19.0")), // Falls back to seedVersion
			expectedError:   false,
		},
		{
			name:                 "Initial version - next minor doesn't exist yet, returns latest",
			customerDesiredMinor: "4.19",
			channelGroup:         "stable",
			channelExistence:     channelExistence{"stable": {"4.20": false}},
			mockSetup: func(mc *cincinnati.MockClient) {
				// Query for 4.19 versions from seedVersion (4.19.0)
				// Cincinnati may return versions from other minors which should be filtered out
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.19", semver.MustParse("4.19.0")).Return(
					configv1.Release{Version: "4.19.0"},
					[]configv1.Release{{Version: "4.19.15"}, {Version: "4.19.22"}, {Version: "4.20.0"}}, // 4.20.0 should be filtered out
					[]configv1.ConditionalUpdate{},
					nil,
				)

				// Since next minor doesn't exist, return latest candidate
			},
			expectedVersion: ptr.To(semver.MustParse("4.19.22")), // Returns latest - no next minor to preserve path to
			expectedError:   false,
		},
		{
			name:                 "Initial version - Cincinnati query error",
			customerDesiredMinor: "4.19",
			channelGroup:         "stable",
			mockSetup: func(mc *cincinnati.MockClient) {
				// Mock Cincinnati returning an error
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.19", semver.MustParse("4.19.0")).Return(
					configv1.Release{},
					nil,
					nil,
					&cvocincinnati.Error{Message: "example error message"},
				)
			},
			expectedVersion:       nil,
			expectedError:         true,
			expectedErrorContains: "example error message",
		},
		{
			name:                 "Initial version - Cincinnati version not found",
			customerDesiredMinor: "4.19",
			channelGroup:         "stable",
			mockSetup: func(mc *cincinnati.MockClient) {
				// Mock Cincinnati returning a VersionNotFound error
				mc.EXPECT().GetUpdates(gomock.AssignableToTypeOf(context.Background()), metadataapi.Must(cincinnati.GetCincinnatiURI("stable")), "multi", "multi", "stable-4.19", semver.MustParse("4.19.0")).Return(
					configv1.Release{},
					nil,
					nil,
					&cvocincinnati.Error{Reason: "VersionNotFound"},
				)
			},
			expectedVersion:       nil,
			expectedError:         true,
			expectedErrorContains: "VersionNotFound",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)

			mockCincinnatiClient := cincinnati.NewMockClient(ctrl)
			tt.mockSetup(mockCincinnatiClient)

			mockResourcesDBClient := corecosmosstoragetesting.NewMockResourcesDBClient()
			syncer := &controlPlaneDesiredVersionSyncer{
				resourcesDBClient:             mockResourcesDBClient,
				graphClient:                   mockGraphClient(ctrl, tt.channelExistence),
				serviceProviderClusterLister:  &corelistertesting.DBServiceProviderClusterLister{ResourcesDBClient: mockResourcesDBClient},
				serviceProviderNodePoolLister: &corelistertesting.DBServiceProviderNodePoolLister{ResourcesDBClient: mockResourcesDBClient},
			}

			// Empty active versions - simulating a new cluster
			activeVersions := []coreapi.HCPClusterActiveVersion{}

			ctx := context.Background()
			result, err := syncer.desiredControlPlaneZVersion(ctx, mockCincinnatiClient, metadataapi.Must(coreapi.ToClusterResourceID("6b690bec-0c16-4ecb-8f67-781caf40bba7", "test-rg", "test-cluster")), tt.customerDesiredMinor, tt.channelGroup, activeVersions, false)

			assertVersionResult(t, result, err, tt.expectedVersion, tt.expectedError, tt.expectedErrorContains)
		})
	}
}

// assertVersionResult is a helper function that validates the result of desiredControlPlaneZVersion
func assertVersionResult(t *testing.T, result *semver.Version, err error, expectedVersion *semver.Version, expectedError bool, expectedErrorContains string) {
	if expectedError {
		assert.Error(t, err)
		assert.NotEmpty(t, expectedErrorContains)
		assert.ErrorContains(t, err, expectedErrorContains)
	} else {
		assert.NoError(t, err)
		if expectedVersion == nil {
			assert.Nil(t, result)
		} else {
			assert.NotNil(t, result)
			assert.True(t, result.EQ(*expectedVersion), "Expected version %q, got %q", expectedVersion.String(), result.String())
		}
	}
}

func createTestHCPClusterWithCustomerVersion(t *testing.T, ctx context.Context, mockResourcesDBClient *corecosmosstoragetesting.MockResourcesDBClient, customerVersionID, channelGroup string) {
	t.Helper()
	createTestSubscription(t, ctx, mockResourcesDBClient)
	clusterResourceID := metadataapi.Must(azcorearm.ParseResourceID("/subscriptions/" + testSubscriptionID +
		"/resourceGroups/" + testResourceGroupName +
		"/providers/Microsoft.RedHatOpenShift/hcpOpenShiftClusters/" + testClusterName))
	clusterInternalID, err := metadataapi.NewInternalID(testCSClusterIDStr)
	require.NoError(t, err)
	cluster := &coreapi.HCPOpenShiftCluster{
		CosmosMetadata: coreapi.CosmosMetadata{
			ResourceID:   clusterResourceID,
			PartitionKey: strings.ToLower(clusterResourceID.SubscriptionID),
		},
		TrackedResource: coreapi.TrackedResource{
			Resource: coreapi.Resource{
				ID:   clusterResourceID,
				Name: testClusterName,
				Type: coreapi.ClusterResourceType.String(),
			},
			Location: "eastus",
		},
		CustomerProperties: coreapi.HCPOpenShiftClusterCustomerProperties{
			Version: coreapi.VersionProfile{
				ID:           customerVersionID,
				ChannelGroup: channelGroup,
			},
		},
		ServiceProviderProperties: coreapi.HCPOpenShiftClusterServiceProviderProperties{
			ProvisioningState: coreapi.ProvisioningStateSucceeded,
			ClusterServiceID:  &clusterInternalID,
		},
	}
	_, err = mockResourcesDBClient.HCPClusters(testSubscriptionID, testResourceGroupName).Create(ctx, cluster, nil)
	require.NoError(t, err)
}

func TestControlPlaneDesiredVersionSyncer_SyncOnce(t *testing.T) {
	clusterKey := controllerutils.HCPClusterKey{
		SubscriptionID:    testSubscriptionID,
		ResourceGroupName: testResourceGroupName,
		HCPClusterName:    testClusterName,
	}
	subResourceID := metadataapi.Must(azcorearm.ParseResourceID("/subscriptions/" + testSubscriptionID))
	subscriptionLister := &corelistertesting.SliceSubscriptionLister{
		Subscriptions: []*coreapi.Subscription{{
			CosmosMetadata: coreapi.CosmosMetadata{ResourceID: subResourceID, PartitionKey: strings.ToLower(subResourceID.SubscriptionID)},
			ResourceID:     subResourceID,
			Properties:     &coreapi.SubscriptionProperties{},
		}},
	}

	stableURI := metadataapi.Must(cincinnati.GetCincinnatiURI("stable"))
	const testChannelGroup = "stable"

	tests := []struct {
		name                   string
		customerVersion        string
		controlPlaneVersion    string
		previousDesiredVersion *semver.Version
		channelExistence       channelExistence
		setupCincinnati        func(mc *cincinnati.MockClient)
		wantSyncErr            bool
		wantErrContains        string
		wantDesiredVersion     *semver.Version
		wantIntentFailed       *metav1.Condition
	}{
		{
			name:                "successful resolution persists desired version and sets IntentFailed False",
			customerVersion:     "4.19",
			controlPlaneVersion: "4.19.15",
			channelExistence:    channelExistence{"stable": {"4.20": false}},
			setupCincinnati: func(mc *cincinnati.MockClient) {
				mc.EXPECT().GetUpdates(gomock.Any(), stableURI, "multi", "multi", "stable-4.19", semver.MustParse("4.19.15")).Return(
					configv1.Release{},
					[]configv1.Release{{Version: "4.19.22"}},
					nil,
					nil,
				).Times(1)
			},
			wantDesiredVersion: ptr.To(semver.MustParse("4.19.22")),
			wantIntentFailed: &metav1.Condition{
				Type:   coreapi.ControllerConditionTypeIntentFailed,
				Status: metav1.ConditionFalse,
				Reason: coreapi.ControllerConditionReasonAsExpected,
			},
		},
		{
			name:                   "lower resolved desired does not replace higher previously selected desired",
			customerVersion:        "4.19",
			controlPlaneVersion:    "4.19.15",
			previousDesiredVersion: ptr.To(semver.MustParse("4.19.22")),
			channelExistence:       channelExistence{"stable": {"4.20": false}},
			setupCincinnati: func(mc *cincinnati.MockClient) {
				mc.EXPECT().GetUpdates(gomock.Any(), stableURI, "multi", "multi", "stable-4.19", semver.MustParse("4.19.15")).Return(
					configv1.Release{},
					[]configv1.Release{{Version: "4.19.18"}},
					nil,
					nil,
				).Times(1)
			},
			wantDesiredVersion: ptr.To(semver.MustParse("4.19.22")),
			wantIntentFailed: &metav1.Condition{
				Type:   coreapi.ControllerConditionTypeIntentFailed,
				Status: metav1.ConditionFalse,
				Reason: coreapi.ControllerConditionReasonAsExpected,
			},
		},
		{
			name:                "validation error persists IntentFailed and does not set desired version",
			customerVersion:     "4.19",
			controlPlaneVersion: "4.20.15",
			setupCincinnati:     func(mc *cincinnati.MockClient) {},
			wantDesiredVersion:  nil,
			wantIntentFailed: &metav1.Condition{
				Type:    coreapi.ControllerConditionTypeIntentFailed,
				Status:  metav1.ConditionTrue,
				Reason:  coreapi.VersionUpgradeNotAcceptedReason,
				Message: "invalid next y-stream upgrade path from 4.20.0 to 4.19.0: only upgrades to the next minor version are allowed, no downgrades",
			},
		},
		{
			name:                "Cincinnati upstream error does not persist IntentFailed or desired version",
			customerVersion:     "4.19",
			controlPlaneVersion: "4.19.15",
			setupCincinnati: func(mc *cincinnati.MockClient) {
				mc.EXPECT().GetUpdates(gomock.Any(), stableURI, "multi", "multi", "stable-4.19", semver.MustParse("4.19.15")).Return(
					configv1.Release{}, nil, nil, &cvocincinnati.Error{Reason: "ServiceUnavailable", Message: "503 Service Unavailable"},
				).Times(1)
			},
			wantSyncErr:        true,
			wantErrContains:    "503 Service Unavailable",
			wantDesiredVersion: nil,
			wantIntentFailed:   nil,
		},
		{
			name:                "Cincinnati VersionNotFound persists IntentFailed and does not set desired version",
			customerVersion:     "4.19",
			controlPlaneVersion: "4.19.15",
			setupCincinnati: func(mc *cincinnati.MockClient) {
				mc.EXPECT().GetUpdates(gomock.Any(), stableURI, "multi", "multi", "stable-4.19", semver.MustParse("4.19.15")).Return(
					configv1.Release{}, nil, nil, &cvocincinnati.Error{Reason: "VersionNotFound"},
				).Times(1)
			},
			wantDesiredVersion: nil,
			wantIntentFailed: &metav1.Condition{
				Type:    coreapi.ControllerConditionTypeIntentFailed,
				Status:  metav1.ConditionTrue,
				Reason:  coreapi.VersionUpgradeNotAcceptedReason,
				Message: "VersionNotFound: ",
			},
		},
	}

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

			ctx := utils.ContextWithLogger(context.Background(), logr.Discard())
			ctrl := gomock.NewController(t)
			mockResourcesDBClient := corecosmosstoragetesting.NewMockResourcesDBClient()
			mockCS := ocm.NewMockClusterServiceClientSpec(ctrl)

			createTestHCPClusterWithCustomerVersion(t, ctx, mockResourcesDBClient, tt.customerVersion, testChannelGroup)
			createServiceProviderClusterWithActiveAndDesiredVersion(t, ctx, mockResourcesDBClient, semver.MustParse(tt.controlPlaneVersion), tt.previousDesiredVersion)

			mockCincinnati := cincinnati.NewMockClient(ctrl)
			tt.setupCincinnati(mockCincinnati)

			mockClientCache := cincinnati.NewMockClientCache(ctrl)
			mockClientCache.EXPECT().GetOrCreateClient(gomock.Any()).Return(mockCincinnati).AnyTimes()

			syncer := &controlPlaneDesiredVersionSyncer{
				readDesireLister:              newValidHostedClusterReadDesireLister(t),
				resourcesDBClient:             mockResourcesDBClient,
				clusterServiceClient:          mockCS,
				subscriptionLister:            subscriptionLister,
				cincinnatiClientCache:         mockClientCache,
				graphClient:                   mockGraphClient(ctrl, tt.channelExistence),
				serviceProviderClusterLister:  &corelistertesting.DBServiceProviderClusterLister{ResourcesDBClient: mockResourcesDBClient},
				serviceProviderNodePoolLister: &corelistertesting.DBServiceProviderNodePoolLister{ResourcesDBClient: mockResourcesDBClient},
			}

			err := syncer.SyncOnce(ctx, clusterKey)
			if tt.wantSyncErr {
				require.Error(t, err)
				require.NotEmpty(t, tt.wantErrContains, "when wantSyncErr is true, wantErrContains must be set to a substring of the expected error")
				assert.ErrorContains(t, err, tt.wantErrContains)
			} else {
				require.NoError(t, err)
				assert.Empty(t, tt.wantErrContains, "when wantSyncErr is false, wantErrContains must be empty")
			}

			serviceProviderCluster, getServiceProviderClusterErr := mockResourcesDBClient.ServiceProviderClusters(testSubscriptionID, testResourceGroupName, testClusterName).Get(ctx, coreapi.ServiceProviderClusterResourceName)
			require.NoError(t, getServiceProviderClusterErr)
			gotDesired := serviceProviderCluster.Spec.ControlPlaneVersion.DesiredVersion
			if tt.wantDesiredVersion != nil {
				require.NotNil(t, gotDesired)
				assert.True(t, gotDesired.EQ(*tt.wantDesiredVersion), "wanted desired version %s, got %s", tt.wantDesiredVersion.String(), gotDesired.String())
			} else {
				assert.Nil(t, gotDesired)
			}

			controlPlaneDesiredVersionControllerDoc, getControllerDocErr := mockResourcesDBClient.HCPClusters(testSubscriptionID, testResourceGroupName).
				Controllers(testClusterName).Get(ctx, controlPlaneDesiredVersionControllerName)
			if tt.wantIntentFailed != nil {
				require.NoError(t, getControllerDocErr)
				require.NotNil(t, controlPlaneDesiredVersionControllerDoc)
				intentFailedCondition := apimeta.FindStatusCondition(controlPlaneDesiredVersionControllerDoc.Status.Conditions,
					coreapi.ControllerConditionTypeIntentFailed)
				require.NotNil(t, intentFailedCondition)
				assert.Equal(t, tt.wantIntentFailed.Type, intentFailedCondition.Type)
				assert.Equal(t, tt.wantIntentFailed.Status, intentFailedCondition.Status)
				assert.Equal(t, tt.wantIntentFailed.Reason, intentFailedCondition.Reason)
				if tt.wantIntentFailed.Status == metav1.ConditionTrue {
					require.NotEmpty(t, tt.wantIntentFailed.Message, "set wantIntentFailed.Message to the exact persisted IntentFailed message")
					assert.Equal(t, tt.wantIntentFailed.Message, intentFailedCondition.Message)
				} else {
					assert.Empty(t, intentFailedCondition.Message, "when wantIntentFailed.Status is false, intentFailedCondition.Message must be empty")
				}
			}
		})
	}
}

func createServiceProviderClusterWithActiveAndDesiredVersion(t *testing.T, ctx context.Context, mockResourcesDBClient *corecosmosstoragetesting.MockResourcesDBClient, activeVersion semver.Version, desiredVersion *semver.Version) {
	t.Helper()

	serviceProviderCluster := &coreapi.ServiceProviderCluster{
		CosmosMetadata: coreapi.CosmosMetadata{
			ResourceID: metadataapi.Must(azcorearm.ParseResourceID(
				coreapi.ToServiceProviderClusterResourceIDString(testSubscriptionID, testResourceGroupName, testClusterName),
			)),
		},
		Spec: coreapi.ServiceProviderClusterSpec{
			ControlPlaneVersion: coreapi.ServiceProviderClusterSpecVersion{
				DesiredVersion: desiredVersion,
			},
		},
		Status: coreapi.ServiceProviderClusterStatus{
			ControlPlaneVersion: coreapi.ServiceProviderClusterStatusVersion{
				ActiveVersions: []coreapi.HCPClusterActiveVersion{
					{Version: ptr.To(activeVersion), State: configv1.CompletedUpdate},
				},
			},
		},
	}
	serviceProviderCluster.SetPartitionKey(testSubscriptionID)
	_, err := mockResourcesDBClient.ServiceProviderClusters(testSubscriptionID, testResourceGroupName, testClusterName).Create(ctx, serviceProviderCluster, nil)
	require.NoError(t, err)
}

// boomActiveOperationLister is a test double that returns the configured
// error from ListActiveOperationsForCluster. It exists so the gating helper
// can exercise its error-propagation branch without a misbehaving mock DB.
type boomActiveOperationLister struct {
	corelisters.ActiveOperationLister
	err error
}

func (b *boomActiveOperationLister) Get(_ context.Context, _, _ string) (*coreapi.Operation, error) {
	return nil, b.err
}

func (b *boomActiveOperationLister) ListActiveOperationsForCluster(_ context.Context, _, _, _ string) ([]*coreapi.Operation, error) {
	return nil, b.err
}

// seedClusterCreateOperation seeds an active Create operation rooted at the
// given ExternalID into the mock DB so the DB-backed active operation lister
// can find it.
func seedClusterCreateOperation(t *testing.T, ctx context.Context, mockDB *corecosmosstoragetesting.MockResourcesDBClient, externalID *azcorearm.ResourceID, opName string) {
	t.Helper()
	opResourceID := metadataapi.Must(azcorearm.ParseResourceID(coreapi.ToOperationResourceIDString(externalID.SubscriptionID, opName)))
	operationID := metadataapi.Must(azcorearm.ParseResourceID(
		"/subscriptions/" + externalID.SubscriptionID +
			"/providers/Microsoft.RedHatOpenShift/locations/eastus/hcpOperationStatuses/" + opName,
	))
	op := &coreapi.Operation{
		CosmosMetadata: coreapi.CosmosMetadata{
			ResourceID:   opResourceID,
			PartitionKey: strings.ToLower(externalID.SubscriptionID),
		},
		Status:      coreapi.ProvisioningStateAccepted,
		Request:     cosmosstorageutils.OperationRequestCreate,
		ExternalID:  externalID,
		OperationID: operationID,
	}
	_, err := mockDB.Operations(externalID.SubscriptionID).Create(ctx, op, nil)
	require.NoError(t, err)
}

func TestControlPlaneDesiredVersionSyncer_ShouldDetermineDesiredVersion(t *testing.T) {
	clusterResourceID := metadataapi.Must(coreapi.ToClusterResourceID(testSubscriptionID, testResourceGroupName, testClusterName))
	now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
	listerBoom := errors.New("active operation lister exploded")

	newCluster := func(createdAt *time.Time, activeOperationID string) *coreapi.HCPOpenShiftCluster {
		c := &coreapi.HCPOpenShiftCluster{
			CosmosMetadata: coreapi.CosmosMetadata{
				ResourceID: clusterResourceID,
			},
			TrackedResource: coreapi.TrackedResource{
				Resource: coreapi.Resource{
					ID:   clusterResourceID,
					Name: testClusterName,
					Type: coreapi.ClusterResourceType.String(),
				},
			},
			ServiceProviderProperties: coreapi.HCPOpenShiftClusterServiceProviderProperties{
				ActiveOperationID: activeOperationID,
			},
		}
		if createdAt != nil {
			c.SystemData = &coreapi.SystemData{CreatedAt: createdAt}
		}
		return c
	}
	newSPC := func(desired *semver.Version) *coreapi.ServiceProviderCluster {
		return &coreapi.ServiceProviderCluster{
			Spec: coreapi.ServiceProviderClusterSpec{
				ControlPlaneVersion: coreapi.ServiceProviderClusterSpecVersion{DesiredVersion: desired},
			},
		}
	}

	tests := []struct {
		name           string
		cluster        *coreapi.HCPOpenShiftCluster
		spc            *coreapi.ServiceProviderCluster
		seedOperation  bool
		opLister       func(mockDB *corecosmosstoragetesting.MockResourcesDBClient) corelisters.ActiveOperationLister
		wantShouldRun  bool
		wantErrContain string
	}{
		{
			name:          "empty DesiredVersion runs even when create is in flight (gate 1)",
			cluster:       newCluster(ptr.To(now.Add(-5*time.Minute)), "op-create-1"),
			spc:           newSPC(nil),
			seedOperation: true,
			wantShouldRun: true,
		},
		{
			name:          "cluster older than grace period runs even with active create (gate 2)",
			cluster:       newCluster(ptr.To(now.Add(-3*time.Hour)), "op-create-1"),
			spc:           newSPC(ptr.To(semver.MustParse("4.19.15"))),
			seedOperation: true,
			wantShouldRun: true,
		},
		{
			name:          "cluster with no SystemData.CreatedAt runs (treated as old enough)",
			cluster:       newCluster(nil, "op-create-1"),
			spc:           newSPC(ptr.To(semver.MustParse("4.19.15"))),
			seedOperation: true,
			wantShouldRun: true,
		},
		{
			name:          "cluster younger than grace period with no active create runs (gate 3)",
			cluster:       newCluster(ptr.To(now.Add(-5*time.Minute)), ""),
			spc:           newSPC(ptr.To(semver.MustParse("4.19.15"))),
			seedOperation: false,
			wantShouldRun: true,
		},
		{
			name:          "young cluster + DesiredVersion set + active create skips",
			cluster:       newCluster(ptr.To(now.Add(-5*time.Minute)), "op-create-1"),
			spc:           newSPC(ptr.To(semver.MustParse("4.19.15"))),
			seedOperation: true,
			wantShouldRun: false,
		},
		{
			name:    "cluster exactly at grace period boundary still skips (boundary is strict >)",
			cluster: newCluster(ptr.To(now.Add(-clusterCreateGracePeriod)), "op-create-1"),
			spc:     newSPC(ptr.To(semver.MustParse("4.19.15"))),
			// active create present so without the boundary-is-strict gate, the
			// cluster's age would have to push us through.
			seedOperation: true,
			wantShouldRun: false,
		},
		{
			// Fail open: if we can't tell whether a Create is in flight we
			// surface the error to the caller but still report shouldRun=true
			// so a flaky lister doesn't pin the controller in skip-forever
			// mode for the rest of the grace window.
			name:          "active operation lister error is propagated and fails open to shouldRun=true",
			cluster:       newCluster(ptr.To(now.Add(-5*time.Minute)), "op-broken"),
			spc:           newSPC(ptr.To(semver.MustParse("4.19.15"))),
			seedOperation: false,
			opLister: func(_ *corecosmosstoragetesting.MockResourcesDBClient) corelisters.ActiveOperationLister {
				return &boomActiveOperationLister{err: listerBoom}
			},
			wantShouldRun:  true,
			wantErrContain: "failed to get operations",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			ctx := utils.ContextWithLogger(context.Background(), logr.Discard())
			mockDB := corecosmosstoragetesting.NewMockResourcesDBClient()
			if tt.seedOperation {
				seedClusterCreateOperation(t, ctx, mockDB, clusterResourceID, "op-create-1")
			}
			var opLister corelisters.ActiveOperationLister
			if tt.opLister != nil {
				opLister = tt.opLister(mockDB)
			} else {
				opLister = &corelistertesting.DBActiveOperationLister{ResourcesDBClient: mockDB}
			}
			syncer := &controlPlaneDesiredVersionSyncer{
				clock:                 clocktesting.NewFakePassiveClock(now),
				resourcesDBClient:     mockDB,
				activeOperationLister: opLister,
			}

			gotShouldRun, err := syncer.shouldDetermineDesiredVersion(ctx, tt.cluster, tt.spc)
			if tt.wantErrContain != "" {
				require.Error(t, err)
				assert.ErrorContains(t, err, tt.wantErrContain)
			} else {
				require.NoError(t, err)
			}
			assert.Equal(t, tt.wantShouldRun, gotShouldRun)
		})
	}
}

// TestControlPlaneDesiredVersionSyncer_SyncOnceSkipsWhenGated verifies the
// end-to-end skip behaviour: when shouldDetermineDesiredVersion returns false
// SyncOnce returns nil without touching the SPC DesiredVersion or writing a
// controller doc, so the cluster create can finish without an upgrade
// recomputation racing it.
func TestControlPlaneDesiredVersionSyncer_SyncOnceSkipsWhenGated(t *testing.T) {
	clusterKey := controllerutils.HCPClusterKey{
		SubscriptionID:    testSubscriptionID,
		ResourceGroupName: testResourceGroupName,
		HCPClusterName:    testClusterName,
	}
	ctx := utils.ContextWithLogger(context.Background(), logr.Discard())
	mockDB := corecosmosstoragetesting.NewMockResourcesDBClient()
	now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)

	// Cluster is 5 minutes old.
	createTestHCPClusterWithCustomerVersion(t, ctx, mockDB, "4.19", "stable")
	clusterCRUD := mockDB.HCPClusters(testSubscriptionID, testResourceGroupName)
	existing, err := clusterCRUD.Get(ctx, testClusterName)
	require.NoError(t, err)
	updated := existing.DeepCopy()
	createdAt := now.Add(-5 * time.Minute)
	updated.SystemData = &coreapi.SystemData{CreatedAt: &createdAt}
	updated.ServiceProviderProperties.ActiveOperationID = "op-create-1"
	_, err = clusterCRUD.Replace(ctx, updated, nil)
	require.NoError(t, err)

	// SPC already has a desired version — gate 1 will not fire.
	createServiceProviderClusterWithActiveAndDesiredVersion(t, ctx, mockDB, semver.MustParse("4.19.15"), ptr.To(semver.MustParse("4.19.22")))

	// Active Create operation pinned to the cluster itself.
	clusterResourceID := metadataapi.Must(coreapi.ToClusterResourceID(testSubscriptionID, testResourceGroupName, testClusterName))
	seedClusterCreateOperation(t, ctx, mockDB, clusterResourceID, "op-create-1")

	ctrl := gomock.NewController(t)
	mockClientCache := cincinnati.NewMockClientCache(ctrl)
	// Cincinnati must not be consulted on the skip path.
	mockClientCache.EXPECT().GetOrCreateClient(gomock.Any()).Times(0)

	syncer := &controlPlaneDesiredVersionSyncer{
		clock:                        clocktesting.NewFakePassiveClock(now),
		readDesireLister:             newValidHostedClusterReadDesireLister(t),
		resourcesDBClient:            mockDB,
		serviceProviderClusterLister: &corelistertesting.DBServiceProviderClusterLister{ResourcesDBClient: mockDB},
		clusterServiceClient:         ocm.NewMockClusterServiceClientSpec(ctrl),
		subscriptionLister: &corelistertesting.SliceSubscriptionLister{Subscriptions: []*coreapi.Subscription{{
			ResourceID: metadataapi.Must(azcorearm.ParseResourceID("/subscriptions/" + testSubscriptionID)),
			Properties: &coreapi.SubscriptionProperties{},
		}}},
		activeOperationLister: &corelistertesting.DBActiveOperationLister{ResourcesDBClient: mockDB},
		cincinnatiClientCache: mockClientCache,
	}

	require.NoError(t, syncer.SyncOnce(ctx, clusterKey))

	// DesiredVersion is untouched.
	spc, err := mockDB.ServiceProviderClusters(testSubscriptionID, testResourceGroupName, testClusterName).Get(ctx, coreapi.ServiceProviderClusterResourceName)
	require.NoError(t, err)
	require.NotNil(t, spc.Spec.ControlPlaneVersion.DesiredVersion)
	assert.True(t, spc.Spec.ControlPlaneVersion.DesiredVersion.EQ(semver.MustParse("4.19.22")), "DesiredVersion must not change on the skip path")

	// Controller doc was never written, since we returned before WriteController.
	_, getControllerDocErr := mockDB.HCPClusters(testSubscriptionID, testResourceGroupName).
		Controllers(testClusterName).Get(ctx, controlPlaneDesiredVersionControllerName)
	assert.True(t, cosmosstorageutils.IsNotFoundError(getControllerDocErr), "controller doc must not be written on the skip path, got err=%v", getControllerDocErr)
}
