package action

import (
	"context"
	"errors"
	"testing"

	"github.com/samber/lo"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
	apiv1 "github.com/tidbcloud/cluster-service-ng/api/cluster-service-ng/v1beta"
	"github.com/tidbcloud/cluster-service-ng/config"
	"github.com/tidbcloud/cluster-service-ng/domain/entity"
	"github.com/tidbcloud/cluster-service-ng/domain/workflow"
	"github.com/tidbcloud/cluster-service-ng/pkg/autoscale/resolve"
	"github.com/tidbcloud/cluster-service-ng/pkg/metricsprovider"
	"github.com/tidbcloud/cluster-service-ng/pkg/runtime/actions"
	schemav1 "github.com/tidbcloud/cluster-service-ng/pkg/storage/etcd/schema/v1"
	repomocks "github.com/tidbcloud/cluster-service-ng/pkg/storage/repo/mocks"
	"google.golang.org/protobuf/encoding/protojson"
	autoscalingv2 "k8s.io/api/autoscaling/v2"
	"k8s.io/apimachinery/pkg/api/resource"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	"sigs.k8s.io/controller-runtime/pkg/client/fake"
)

func TestGetHPAName(t *testing.T) {
	tests := []struct {
		name          string
		componentType apiv1.ComponentType
		componentId   string
		expected      string
	}{
		{
			name:          "TIDB component",
			componentType: apiv1.ComponentType_TIDB,
			componentId:   "cluster-1",
			expected:      "tidbgroup-db-cluster-1",
		},
		{
			name:          "TIDB_WORKER component",
			componentType: apiv1.ComponentType_TIDB_WORKER,
			componentId:   "cluster-1",
			expected:      "tidbgroup-db-cluster-1-worker",
		},
		{
			name:          "TIKV_WORKER component",
			componentType: apiv1.ComponentType_TIKV_WORKER,
			componentId:   "cluster-1",
			expected:      "tikvworker-db-cluster-1-default",
		},
		{
			name:          "COPROCESSOR_WORKER component",
			componentType: apiv1.ComponentType_COPROCESSOR_WORKER,
			componentId:   "cluster-1",
			expected:      "tikvworker-db-cluster-1-coprocessor",
		},
		{
			name:          "TIPROXY component",
			componentType: apiv1.ComponentType_TIPROXY,
			componentId:   "cluster-1",
			expected:      "tiproxygroup-db-cluster-1",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			comp := &entity.Component{
				Id:            tt.componentId,
				ComponentType: tt.componentType,
			}
			result := getHPAName(comp)
			assert.Equal(t, tt.expected, result)
		})
	}
}

func TestApplyComponentHpaActionName(t *testing.T) {
	action := &ApplyComponentHpaAction{}
	assert.Equal(t, ApplyComponentHpaActionName, action.Name())
	assert.Equal(t, "apply-component-hpa", action.Name())
}

func TestApplyComponentHpaActionSkipsWithoutMutationTarget(t *testing.T) {
	sharedPoolRepo := repomocks.NewSharedPoolRepo(t)
	action := &ApplyComponentHpaAction{
		sharedPoolRepo: sharedPoolRepo,
		bizConfig:      &config.BizConfig{EnableHpa: true},
	}

	err := action.Invoke(actions.ActionArgs{
		workflow.SkipComponentHPAApplyKey: actions.ActionArgString(workflow.SkipComponentHPAApplyValue),
	})

	require.NoError(t, err)
	sharedPoolRepo.AssertNotCalled(t, "Load", mock.Anything, mock.Anything, mock.Anything)
}

func TestReconcileExistingHPA_TiDBEnablesScaleInGate(t *testing.T) {
	hpa := &autoscalingv2.HorizontalPodAutoscaler{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "tidbgroup-db-cluster-1",
			Namespace: "tidb123",
		},
		Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
			MinReplicas: ptrInt32(1),
			MaxReplicas: 3,
			Metrics: []autoscalingv2.MetricSpec{
				{
					Type: autoscalingv2.ResourceMetricSourceType,
					Resource: &autoscalingv2.ResourceMetricSource{
						Name: "cpu",
						Target: autoscalingv2.MetricTarget{
							Type:               autoscalingv2.UtilizationMetricType,
							AverageUtilization: ptrInt32(70),
						},
					},
				},
			},
			Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
				ScaleDown: &autoscalingv2.HPAScalingRules{
					SelectPolicy: ptrPolicy(autoscalingv2.DisabledPolicySelect),
				},
			},
		},
	}

	changed := reconcileExistingHPA(hpa, &entity.Component{
		Id:            "cluster-1",
		ComponentType: apiv1.ComponentType_TIDB,
	}, 2, 5, autoscalingv2.MaxChangePolicySelect, autoscalingv2.MaxChangePolicySelect, true)

	require.True(t, changed)
	require.NotNil(t, hpa.Spec.MinReplicas)
	assert.Equal(t, int32(2), *hpa.Spec.MinReplicas)
	assert.Equal(t, int32(5), hpa.Spec.MaxReplicas)
	require.NotNil(t, hpa.Spec.Behavior)
	require.NotNil(t, hpa.Spec.Behavior.ScaleDown)
	require.NotNil(t, hpa.Spec.Behavior.ScaleDown.SelectPolicy)
	assert.Equal(t, autoscalingv2.DisabledPolicySelect, *hpa.Spec.Behavior.ScaleDown.SelectPolicy)
	// Gate metric and threshold annotation are now added unconditionally for TIDB
	require.NotNil(t, hpa.Annotations)
	assert.Equal(t, "30", hpa.Annotations[metricsprovider.AnnotationTiDBCPUScaleInThreshold])
	require.Len(t, hpa.Spec.Metrics, 2)
	assert.Equal(t, metricsprovider.MetricNameExtTiDBCPUScaleInGate, hpa.Spec.Metrics[0].Object.Metric.Name)
	assert.Equal(t, autoscalingv2.ResourceMetricSourceType, hpa.Spec.Metrics[1].Type)
}

func TestReconcileExistingHPA_TiDBPreservesConfiguredScaleInThreshold(t *testing.T) {
	hpa := &autoscalingv2.HorizontalPodAutoscaler{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "tidbgroup-db-cluster-1",
			Namespace: "tidb123",
			Annotations: map[string]string{
				metricsprovider.AnnotationTiDBCPUScaleInThreshold: "25",
			},
		},
		Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
			MinReplicas: ptrInt32(1),
			MaxReplicas: 3,
			Metrics: []autoscalingv2.MetricSpec{
				{
					Type: autoscalingv2.ResourceMetricSourceType,
					Resource: &autoscalingv2.ResourceMetricSource{
						Name: "cpu",
						Target: autoscalingv2.MetricTarget{
							Type:               autoscalingv2.UtilizationMetricType,
							AverageUtilization: ptrInt32(70),
						},
					},
				},
			},
			Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
				ScaleDown: &autoscalingv2.HPAScalingRules{
					SelectPolicy: ptrPolicy(autoscalingv2.MinChangePolicySelect),
				},
			},
		},
	}

	changed := reconcileExistingHPA(hpa, &entity.Component{
		Id:            "cluster-1",
		ComponentType: apiv1.ComponentType_TIDB,
	}, 1, 3, autoscalingv2.MaxChangePolicySelect, autoscalingv2.MaxChangePolicySelect, true)

	require.True(t, changed)
	require.NotNil(t, hpa.Annotations)
	assert.Equal(t, "25", hpa.Annotations[metricsprovider.AnnotationTiDBCPUScaleInThreshold])
	require.NotEmpty(t, hpa.Spec.Metrics)
	require.NotNil(t, hpa.Spec.Metrics[0].Object)
	assert.Equal(t, metricsprovider.MetricNameExtTiDBCPUScaleInGate, hpa.Spec.Metrics[0].Object.Metric.Name)
}

func TestReconcileExistingHPA_TiProxyEnablesScaleInGate(t *testing.T) {
	hpa := &autoscalingv2.HorizontalPodAutoscaler{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "tiproxygroup-db-tiproxy",
			Namespace: "tidb123",
		},
		Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
			MinReplicas: ptrInt32(1),
			MaxReplicas: 3,
			Metrics: []autoscalingv2.MetricSpec{
				{
					Type: autoscalingv2.ResourceMetricSourceType,
					Resource: &autoscalingv2.ResourceMetricSource{
						Name: "cpu",
						Target: autoscalingv2.MetricTarget{
							Type:               autoscalingv2.UtilizationMetricType,
							AverageUtilization: ptrInt32(70),
						},
					},
				},
			},
			Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
				ScaleDown: &autoscalingv2.HPAScalingRules{
					SelectPolicy: ptrPolicy(autoscalingv2.DisabledPolicySelect),
				},
			},
		},
	}

	changed := reconcileExistingHPA(hpa, &entity.Component{
		Id:            "tiproxy-1",
		ComponentType: apiv1.ComponentType_TIPROXY,
	}, 2, 5, autoscalingv2.MaxChangePolicySelect, autoscalingv2.MaxChangePolicySelect, true)

	require.True(t, changed)
	require.NotNil(t, hpa.Spec.MinReplicas)
	assert.Equal(t, int32(2), *hpa.Spec.MinReplicas)
	assert.Equal(t, int32(5), hpa.Spec.MaxReplicas)
	require.NotNil(t, hpa.Annotations)
	assert.Equal(t, "30", hpa.Annotations[metricsprovider.AnnotationTiProxyCPUScaleInThreshold])
	require.Len(t, hpa.Spec.Metrics, 2)
	assert.Equal(t, metricsprovider.MetricNameExtTiProxyCPUScaleInGate, hpa.Spec.Metrics[0].Object.Metric.Name)
	assert.Equal(t, autoscalingv2.ResourceMetricSourceType, hpa.Spec.Metrics[1].Type)
	assert.Equal(t, int32(70), *hpa.Spec.Metrics[1].Resource.Target.AverageUtilization)
}

func TestReconcileExistingHPA_TiProxyRepairsDriftedGateMetric(t *testing.T) {
	hpa := &autoscalingv2.HorizontalPodAutoscaler{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "tiproxygroup-db-tiproxy",
			Namespace: "tidb123",
			Annotations: map[string]string{
				metricsprovider.AnnotationTiProxyCPUScaleInThreshold: "30",
			},
		},
		Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
			MinReplicas: ptrInt32(1),
			MaxReplicas: 3,
			Metrics: []autoscalingv2.MetricSpec{{
				Type: autoscalingv2.ObjectMetricSourceType,
				Object: &autoscalingv2.ObjectMetricSource{
					Metric: autoscalingv2.MetricIdentifier{Name: metricsprovider.MetricNameExtTiProxyCPUScaleInGate},
					DescribedObject: autoscalingv2.CrossVersionObjectReference{
						APIVersion: "autoscaling/v1",
						Kind:       "Deployment",
						Name:       "wrong-name",
					},
					Target: autoscalingv2.MetricTarget{
						Type:  autoscalingv2.UtilizationMetricType,
						Value: resource.NewQuantity(99, resource.DecimalSI),
					},
				},
			}},
		},
	}

	changed := reconcileExistingHPA(hpa, &entity.Component{
		Id:            "tiproxy-1",
		ComponentType: apiv1.ComponentType_TIPROXY,
	}, 1, 3, autoscalingv2.MaxChangePolicySelect, autoscalingv2.MaxChangePolicySelect, true)

	require.True(t, changed)
	assert.Equal(t, autoscalingv2.SchemeGroupVersion.String(), hpa.Spec.Metrics[0].Object.DescribedObject.APIVersion)
	assert.Equal(t, "HorizontalPodAutoscaler", hpa.Spec.Metrics[0].Object.DescribedObject.Kind)
	assert.Equal(t, "tiproxygroup-db-tiproxy", hpa.Spec.Metrics[0].Object.DescribedObject.Name)
	assert.Equal(t, autoscalingv2.ValueMetricType, hpa.Spec.Metrics[0].Object.Target.Type)
	assert.Equal(t, int64(1), hpa.Spec.Metrics[0].Object.Target.Value.Value())
	require.False(t, reconcileExistingHPA(hpa, &entity.Component{
		Id:            "tiproxy-1",
		ComponentType: apiv1.ComponentType_TIPROXY,
	}, 1, 3, autoscalingv2.MaxChangePolicySelect, autoscalingv2.MaxChangePolicySelect, true))
}

func TestOriginalScaleDownPolicy(t *testing.T) {
	// OriginalScaleDownPolicy is the hardcoded baseline. FeatureFlags
	// do not change these defaults; the disabled-mode TIPROXY overlay
	// is applyTiProxyScaleInGate (see TestApplyTiProxyScaleInGate).
	tests := []struct {
		name     string
		input    apiv1.ComponentType
		expected autoscalingv2.ScalingPolicySelect
	}{
		{"TIDB → Min", apiv1.ComponentType_TIDB, autoscalingv2.MinChangePolicySelect},
		{"TIDB_WORKER → Max", apiv1.ComponentType_TIDB_WORKER, autoscalingv2.MaxChangePolicySelect},
		{"TIKV_WORKER → Max", apiv1.ComponentType_TIKV_WORKER, autoscalingv2.MaxChangePolicySelect},
		{"COPROCESSOR_WORKER → Min", apiv1.ComponentType_COPROCESSOR_WORKER, autoscalingv2.MinChangePolicySelect},
		{"TIPROXY → Disabled", apiv1.ComponentType_TIPROXY, autoscalingv2.DisabledPolicySelect},
		{"TIKV → Disabled", apiv1.ComponentType_TIKV, autoscalingv2.DisabledPolicySelect},
		{"Unknown → Disabled", apiv1.ComponentType_PD, autoscalingv2.DisabledPolicySelect},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assert.Equal(t, tt.expected, resolve.OriginalScaleDownPolicy(tt.input))
		})
	}
}

func TestDefaultHPAPolicySelection(t *testing.T) {
	tests := []struct {
		component apiv1.ComponentType
		scaleDown autoscalingv2.ScalingPolicySelect
	}{
		{apiv1.ComponentType_TIDB, autoscalingv2.MinChangePolicySelect},
		{apiv1.ComponentType_TIDB_WORKER, autoscalingv2.MaxChangePolicySelect},
		{apiv1.ComponentType_TIKV_WORKER, autoscalingv2.MaxChangePolicySelect},
		{apiv1.ComponentType_COPROCESSOR_WORKER, autoscalingv2.MinChangePolicySelect},
		{apiv1.ComponentType_TIPROXY, autoscalingv2.DisabledPolicySelect},
		{apiv1.ComponentType_PD, autoscalingv2.DisabledPolicySelect},
	}

	for _, tt := range tests {
		t.Run(tt.component.String(), func(t *testing.T) {
			selection := defaultHPAPolicySelection(tt.component)
			require.Equal(t, autoscalingv2.MaxChangePolicySelect, selection.scaleUp)
			require.Equal(t, tt.scaleDown, selection.scaleDown)
			require.False(t, selection.reconcileScaleUp)
		})
	}
}

func TestSelectHPAPolicyGate(t *testing.T) {
	ctx := context.Background()
	taggedSharedPool := func(tenantID string) *entity.SharedPool {
		return &entity.SharedPool{SharedPool: apiv1.SharedPool{Provider: &apiv1.Provider{
			Tags: map[string]string{schemav1.LabelUserTenantKey: tenantID},
		}}}
	}

	t.Run("disabled does not read bundle and uses conservative TiDB min scale-down", func(t *testing.T) {
		bundleRepo := repomocks.NewAutoscalePolicyBundleRepo(t)
		selection, err := selectHPAPolicy(ctx, &config.BizConfig{}, bundleRepo, "", nil, apiv1.ComponentType_TIDB)

		require.NoError(t, err)
		require.Equal(t, autoscalingv2.MaxChangePolicySelect, selection.scaleUp)
		require.Equal(t, autoscalingv2.MinChangePolicySelect, selection.scaleDown)
		require.False(t, selection.reconcileScaleUp)
	})

	t.Run("enforce resolves persisted tenant when task omits tenant", func(t *testing.T) {
		bundleRepo := repomocks.NewAutoscalePolicyBundleRepo(t)
		bundleRepo.EXPECT().
			GetBundle(ctx, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, "__default__").
			Return(nil, nil)
		bundleRepo.EXPECT().
			GetBundle(ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1").
			Return(nil, nil)
		bizConfig := &config.BizConfig{AutoscalePolicy: config.AutoscalePolicyConfig{
			Mode:            config.AutoscalePolicyModeEnforce,
			AllowAllTenants: true,
		}}

		selection, err := selectHPAPolicy(
			ctx, bizConfig, bundleRepo, "", taggedSharedPool("tenant-1"), apiv1.ComponentType_TIDB)

		require.NoError(t, err)
		require.Equal(t, autoscalingv2.MaxChangePolicySelect, selection.scaleUp)
		require.Equal(t, autoscalingv2.MinChangePolicySelect, selection.scaleDown)
		require.True(t, selection.reconcileScaleUp)
	})

	t.Run("enforce without authoritative tenant fails closed to legacy", func(t *testing.T) {
		bundleRepo := repomocks.NewAutoscalePolicyBundleRepo(t)
		bizConfig := &config.BizConfig{AutoscalePolicy: config.AutoscalePolicyConfig{
			Mode:            config.AutoscalePolicyModeEnforce,
			AllowAllTenants: true,
		}}

		selection, err := selectHPAPolicy(ctx, bizConfig, bundleRepo, "", &entity.SharedPool{}, apiv1.ComponentType_TIDB)

		require.ErrorContains(t, err, "tenant id is unavailable while policy is enforced")
		require.Equal(t, autoscalingv2.MinChangePolicySelect, selection.scaleDown)
		require.False(t, selection.reconcileScaleUp)
	})

	t.Run("enforce rejects task and persisted tenant mismatch", func(t *testing.T) {
		bundleRepo := repomocks.NewAutoscalePolicyBundleRepo(t)
		bizConfig := &config.BizConfig{AutoscalePolicy: config.AutoscalePolicyConfig{
			Mode:            config.AutoscalePolicyModeEnforce,
			AllowAllTenants: true,
		}}

		selection, err := selectHPAPolicy(
			ctx, bizConfig, bundleRepo, "tenant-2", taggedSharedPool("tenant-1"), apiv1.ComponentType_TIDB)

		require.EqualError(t, err, `task tenant id "tenant-2" does not match shared pool tenant id "tenant-1"`)
		require.Equal(t, autoscalingv2.MinChangePolicySelect, selection.scaleDown)
		require.False(t, selection.reconcileScaleUp)
	})

	t.Run("enforce leaves multi-tenant shared pool on legacy profile", func(t *testing.T) {
		bundleRepo := repomocks.NewAutoscalePolicyBundleRepo(t)
		bizConfig := &config.BizConfig{AutoscalePolicy: config.AutoscalePolicyConfig{
			Mode:            config.AutoscalePolicyModeEnforce,
			AllowAllTenants: true,
		}}

		selection, err := selectHPAPolicy(
			ctx, bizConfig, bundleRepo, "", taggedSharedPool(schemav1.MultiTenantTag), apiv1.ComponentType_TIDB)

		require.NoError(t, err)
		require.Equal(t, autoscalingv2.MaxChangePolicySelect, selection.scaleUp)
		require.Equal(t, autoscalingv2.MinChangePolicySelect, selection.scaleDown)
		require.False(t, selection.reconcileScaleUp)
	})
}

func TestApplyTiProxyScaleInGate(t *testing.T) {
	ctx := context.Background()
	baseline := defaultHPAPolicySelection(apiv1.ComponentType_TIPROXY)
	require.Equal(t, autoscalingv2.DisabledPolicySelect, baseline.scaleDown)

	t.Run("nil repo keeps Disabled", func(t *testing.T) {
		got := applyTiProxyScaleInGate(ctx, nil, "sp-1", baseline)
		assert.Equal(t, autoscalingv2.DisabledPolicySelect, got.scaleDown)
	})

	t.Run("pool explicit true flips Disabled to MinChange even when it equals YAML", func(t *testing.T) {
		enabled := true
		mockRC := repomocks.NewRuConfigRepo(t)
		mockRC.EXPECT().GetRuConfigWithFound(ctx, "sp-1", apiv1.RuConfig_SHARED_POOL).Return(&entity.RuConfig{
			RuConfig: apiv1.RuConfig{FeatureFlags: &apiv1.RuConfig_FeatureFlags{
				EnableAutoscaleTiproxyScaleIn: &enabled,
			}},
		}, true, nil)
		got := applyTiProxyScaleInGate(ctx, mockRC, "sp-1", baseline)
		assert.Equal(t, autoscalingv2.MinChangePolicySelect, got.scaleDown)
	})

	t.Run("flag false keeps Disabled", func(t *testing.T) {
		enabled := false
		mockRC := repomocks.NewRuConfigRepo(t)
		mockRC.EXPECT().GetRuConfigWithFound(ctx, "sp-1", apiv1.RuConfig_SHARED_POOL).Return(&entity.RuConfig{
			RuConfig: apiv1.RuConfig{FeatureFlags: &apiv1.RuConfig_FeatureFlags{
				EnableAutoscaleTiproxyScaleIn: &enabled,
			}},
		}, true, nil)
		got := applyTiProxyScaleInGate(ctx, mockRC, "sp-1", baseline)
		assert.Equal(t, autoscalingv2.DisabledPolicySelect, got.scaleDown)
	})

	t.Run("nil FeatureFlags keeps Disabled", func(t *testing.T) {
		mockRC := repomocks.NewRuConfigRepo(t)
		mockRC.EXPECT().GetRuConfigWithFound(ctx, "sp-1", apiv1.RuConfig_SHARED_POOL).Return(&entity.RuConfig{}, true, nil)
		got := applyTiProxyScaleInGate(ctx, mockRC, "sp-1", baseline)
		assert.Equal(t, autoscalingv2.DisabledPolicySelect, got.scaleDown)
	})

	t.Run("missing pool row keeps Disabled", func(t *testing.T) {
		mockRC := repomocks.NewRuConfigRepo(t)
		mockRC.EXPECT().GetRuConfigWithFound(ctx, "sp-1", apiv1.RuConfig_SHARED_POOL).Return(&entity.RuConfig{}, false, nil)
		got := applyTiProxyScaleInGate(ctx, mockRC, "sp-1", baseline)
		assert.Equal(t, autoscalingv2.DisabledPolicySelect, got.scaleDown)
	})

	t.Run("GetRuConfig error keeps Disabled", func(t *testing.T) {
		mockRC := repomocks.NewRuConfigRepo(t)
		mockRC.EXPECT().GetRuConfigWithFound(ctx, "sp-1", apiv1.RuConfig_SHARED_POOL).Return(nil, false, errors.New("db down"))
		got := applyTiProxyScaleInGate(ctx, mockRC, "sp-1", baseline)
		assert.Equal(t, autoscalingv2.DisabledPolicySelect, got.scaleDown)
	})

}

func TestReconcileExistingHPA_TiProxyGatePreservesExistingScaleDown(t *testing.T) {
	tiproxy := &entity.Component{ComponentType: apiv1.ComponentType_TIPROXY}
	tidb := &entity.Component{ComponentType: apiv1.ComponentType_TIDB}
	minReplicas := int32(1)
	newHPA := func(name string, down autoscalingv2.ScalingPolicySelect, gateCfg metricsprovider.CPUScaleInGateConfig) *autoscalingv2.HorizontalPodAutoscaler {
		hpa := &autoscalingv2.HorizontalPodAutoscaler{
			ObjectMeta: metav1.ObjectMeta{Name: name},
			Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
				MinReplicas: &minReplicas,
				MaxReplicas: 3,
				Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
					ScaleDown: &autoscalingv2.HPAScalingRules{
						SelectPolicy:               ptrPolicy(down),
						StabilizationWindowSeconds: lo.ToPtr(int32(180)),
					},
				},
			},
		}
		ensureCPUScaleInGateForHPA(hpa, gateCfg)
		return hpa
	}

	t.Run("disabled-mode flag off preserves existing MinChange", func(t *testing.T) {
		hpa := newHPA("tiproxygroup-db-tiproxy", autoscalingv2.MinChangePolicySelect, metricsprovider.TiProxyCPUScaleInGateConfig)
		changed := reconcileExistingHPA(hpa, tiproxy, 1, 3,
			autoscalingv2.DisabledPolicySelect, autoscalingv2.MaxChangePolicySelect, false)
		require.False(t, changed)
		assert.Equal(t, autoscalingv2.MinChangePolicySelect, *hpa.Spec.Behavior.ScaleDown.SelectPolicy)
		assert.Equal(t, int32(180), *hpa.Spec.Behavior.ScaleDown.StabilizationWindowSeconds,
			"reconcile must not touch stabilization window")
	})

	t.Run("disabled-mode flag on preserves existing Disabled", func(t *testing.T) {
		hpa := newHPA("tiproxygroup-db-tiproxy", autoscalingv2.DisabledPolicySelect, metricsprovider.TiProxyCPUScaleInGateConfig)
		changed := reconcileExistingHPA(hpa, tiproxy, 1, 3,
			autoscalingv2.MinChangePolicySelect, autoscalingv2.MaxChangePolicySelect, false)
		require.False(t, changed)
		assert.Equal(t, autoscalingv2.DisabledPolicySelect, *hpa.Spec.Behavior.ScaleDown.SelectPolicy)
	})

	t.Run("disabled-mode matching desired is a no-op", func(t *testing.T) {
		hpa := newHPA("tiproxygroup-db-tiproxy", autoscalingv2.DisabledPolicySelect, metricsprovider.TiProxyCPUScaleInGateConfig)
		changed := reconcileExistingHPA(hpa, tiproxy, 1, 3,
			autoscalingv2.DisabledPolicySelect, autoscalingv2.MaxChangePolicySelect, false)
		require.False(t, changed)
		assert.Equal(t, autoscalingv2.DisabledPolicySelect, *hpa.Spec.Behavior.ScaleDown.SelectPolicy)
	})

	t.Run("enforce mode still fill-nil-only", func(t *testing.T) {
		hpa := newHPA("tiproxygroup-db-tiproxy", autoscalingv2.MinChangePolicySelect, metricsprovider.TiProxyCPUScaleInGateConfig)
		_ = reconcileExistingHPA(hpa, tiproxy, 1, 3,
			autoscalingv2.DisabledPolicySelect, autoscalingv2.MaxChangePolicySelect, true)
		assert.Equal(t, autoscalingv2.MinChangePolicySelect, *hpa.Spec.Behavior.ScaleDown.SelectPolicy,
			"enforce mode must not overwrite a non-nil TIPROXY ScaleDown")
	})

	t.Run("other components stay fill-nil-only in disabled mode", func(t *testing.T) {
		hpa := newHPA("tidbgroup-db-cluster-1", autoscalingv2.MinChangePolicySelect, metricsprovider.TiDBCPUScaleInGateConfig)
		changed := reconcileExistingHPA(hpa, tidb, 1, 3,
			autoscalingv2.DisabledPolicySelect, autoscalingv2.MaxChangePolicySelect, false)
		require.False(t, changed)
		assert.Equal(t, autoscalingv2.MinChangePolicySelect, *hpa.Spec.Behavior.ScaleDown.SelectPolicy)
	})
}

func TestApplyComponentHpaActionEnforceResolutionErrorIsFatal(t *testing.T) {
	const (
		sharedPoolID = "sp-1"
		componentID  = "tidb-1"
	)
	component := &apiv1.Component{
		Id:            componentID,
		ComponentType: apiv1.ComponentType_TIDB,
		Phase:         apiv1.Component_MODIFYING,
	}
	persistedSharedPool := &entity.SharedPool{SharedPool: apiv1.SharedPool{
		Id: sharedPoolID,
		Provider: &apiv1.Provider{Tags: map[string]string{
			schemav1.LabelUserTenantKey: "tenant-1",
		}},
	}}
	taskSharedPool := &entity.SharedPool{SharedPool: apiv1.SharedPool{
		Id:              sharedPoolID,
		ComponentGroups: []*apiv1.Component{component},
	}}
	taskSharedPoolJSON, err := protojson.Marshal(&taskSharedPool.SharedPool)
	require.NoError(t, err)

	sharedPoolRepo := repomocks.NewSharedPoolRepo(t)
	sharedPoolRepo.EXPECT().
		Load(mock.Anything, sharedPoolID, false).
		Return(persistedSharedPool, nil)
	action := &ApplyComponentHpaAction{
		sharedPoolRepo: sharedPoolRepo,
		bizConfig: &config.BizConfig{
			EnableHpa: true,
			AutoscalePolicy: config.AutoscalePolicyConfig{
				Mode:            config.AutoscalePolicyModeEnforce,
				AllowAllTenants: true,
			},
		},
		// A missing repository models any enforced policy-resolution failure.
		autoscalePolicyBundleRepo: nil,
	}

	err = action.Invoke(actions.ActionArgs{
		workflow.ComponentIdsKey: actions.ActionArgString(componentID),
		workflow.SharedPoolIdKey: actions.ActionArgString(sharedPoolID),
		workflow.SharedPoolKey:   actions.ActionArgString(string(taskSharedPoolJSON)),
	})

	require.ErrorContains(t, err, "failed to resolve HPA behavior while autoscale policy is enforced")
	require.ErrorContains(t, err, "autoscale policy bundle repository is unavailable")
}

func TestNewApplyComponentHpaAction(t *testing.T) {
	action := NewApplyComponentHpaAction(nil, nil, nil, nil, nil, nil)
	require.NotNil(t, action)
	assert.Equal(t, ApplyComponentHpaActionName, action.Name())
}

func ptrInt32(v int32) *int32 {
	return &v
}

func ptrPolicy(p autoscalingv2.ScalingPolicySelect) *autoscalingv2.ScalingPolicySelect {
	return &p
}

func TestEnsureTiDBHPAHasScaleInGate_InjectsGate(t *testing.T) {
	scheme := runtime.NewScheme()
	require.NoError(t, autoscalingv2.AddToScheme(scheme))

	hpa := &autoscalingv2.HorizontalPodAutoscaler{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "tidbgroup-db-test-cluster",
			Namespace: "tidbtest-pool",
		},
		Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
			MinReplicas: ptrInt32(1),
			MaxReplicas: 3,
			Metrics: []autoscalingv2.MetricSpec{
				{
					Type: autoscalingv2.ResourceMetricSourceType,
					Resource: &autoscalingv2.ResourceMetricSource{
						Name: "cpu",
						Target: autoscalingv2.MetricTarget{
							Type:               autoscalingv2.UtilizationMetricType,
							AverageUtilization: ptrInt32(70),
						},
					},
				},
			},
		},
	}

	cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(hpa).Build()

	comp := &entity.Component{
		Id:            "test-cluster",
		ComponentType: apiv1.ComponentType_TIDB,
		NodeQuota: &apiv1.NodeQuota{
			MinNodeCount: 1,
			MaxNodeCount: 3,
		},
	}

	ensureHPAHasScaleInGate(context.Background(), cli, comp,
		"tidbtest-pool", "tidbgroup-db-test-cluster", "test-pool",
		autoscalingv2.MaxChangePolicySelect, autoscalingv2.DisabledPolicySelect, true)

	updatedHPA := &autoscalingv2.HorizontalPodAutoscaler{}
	err := cli.Get(context.Background(),
		types.NamespacedName{Namespace: "tidbtest-pool", Name: "tidbgroup-db-test-cluster"},
		updatedHPA)
	require.NoError(t, err)
	require.NotNil(t, updatedHPA.Annotations)
	assert.Equal(t, "30", updatedHPA.Annotations[metricsprovider.AnnotationTiDBCPUScaleInThreshold])
	require.Len(t, updatedHPA.Spec.Metrics, 2)
	assert.Equal(t, metricsprovider.MetricNameExtTiDBCPUScaleInGate, updatedHPA.Spec.Metrics[0].Object.Metric.Name)
}

func TestEnsureTiDBHPAHasScaleInGate_AlreadyPresent(t *testing.T) {
	scheme := runtime.NewScheme()
	require.NoError(t, autoscalingv2.AddToScheme(scheme))

	hpa := &autoscalingv2.HorizontalPodAutoscaler{
		ObjectMeta: metav1.ObjectMeta{
			Name:      "tidbgroup-db-test-cluster",
			Namespace: "tidbtest-pool",
			Annotations: map[string]string{
				metricsprovider.AnnotationTiDBCPUScaleInThreshold: "25",
			},
		},
		Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
			MinReplicas: ptrInt32(1),
			MaxReplicas: 3,
			Metrics: []autoscalingv2.MetricSpec{
				{
					Type: autoscalingv2.ObjectMetricSourceType,
					Object: &autoscalingv2.ObjectMetricSource{
						Metric: autoscalingv2.MetricIdentifier{
							Name: metricsprovider.MetricNameExtTiDBCPUScaleInGate,
						},
						DescribedObject: autoscalingv2.CrossVersionObjectReference{
							APIVersion: autoscalingv2.SchemeGroupVersion.String(),
							Kind:       "HorizontalPodAutoscaler",
							Name:       "tidbgroup-db-test-cluster",
						},
						Target: autoscalingv2.MetricTarget{
							Type:  autoscalingv2.ValueMetricType,
							Value: resource.NewQuantity(1, resource.DecimalSI),
						},
					},
				},
				{
					Type: autoscalingv2.ResourceMetricSourceType,
					Resource: &autoscalingv2.ResourceMetricSource{
						Name: "cpu",
						Target: autoscalingv2.MetricTarget{
							Type:               autoscalingv2.UtilizationMetricType,
							AverageUtilization: ptrInt32(70),
						},
					},
				},
			},
		},
	}

	cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(hpa).Build()

	comp := &entity.Component{
		Id:            "test-cluster",
		ComponentType: apiv1.ComponentType_TIDB,
		NodeQuota: &apiv1.NodeQuota{
			MinNodeCount: 1,
			MaxNodeCount: 3,
		},
	}

	ensureHPAHasScaleInGate(context.Background(), cli, comp,
		"tidbtest-pool", "tidbgroup-db-test-cluster", "test-pool",
		autoscalingv2.MaxChangePolicySelect, autoscalingv2.DisabledPolicySelect, true)

	updatedHPA := &autoscalingv2.HorizontalPodAutoscaler{}
	err := cli.Get(context.Background(),
		types.NamespacedName{Namespace: "tidbtest-pool", Name: "tidbgroup-db-test-cluster"},
		updatedHPA)
	require.NoError(t, err)
	assert.Equal(t, "25", updatedHPA.Annotations[metricsprovider.AnnotationTiDBCPUScaleInThreshold])
	require.Len(t, updatedHPA.Spec.Metrics, 2)
}

func TestEnsureTiDBHPAHasScaleInGate_NotFound(t *testing.T) {
	scheme := runtime.NewScheme()
	require.NoError(t, autoscalingv2.AddToScheme(scheme))

	cli := fake.NewClientBuilder().WithScheme(scheme).Build()

	comp := &entity.Component{
		Id:            "test-cluster",
		ComponentType: apiv1.ComponentType_TIDB,
		NodeQuota: &apiv1.NodeQuota{
			MinNodeCount: 1,
			MaxNodeCount: 3,
		},
	}

	ensureHPAHasScaleInGate(context.Background(), cli, comp,
		"tidbtest-pool", "tidbgroup-db-nonexistent", "test-pool",
		autoscalingv2.MaxChangePolicySelect, autoscalingv2.MaxChangePolicySelect, true)
}

// TestApplyComponentHpaAction_RuConfigLoadErrorIsFatal pins the
// contract added in PR #2856 follow-up (review issuecomment
// 4823890716, item 1): a transient `ruConfigRepo.GetRuConfig`
// failure is FATAL and returns the error, instead of silently
// falling through to a `Disabled` HPA fallback.
//
// Pre-#2856 the code logged a warning and set
// `sharedPoolRC = nil`; both `DesiredScale{Down,Up}PolicyFromRuConfig`
// return `Disabled` for a nil config, so the fallback got
// written to `Behavior.Scale{Up,Down}.SelectPolicy` on:
//   - first-time HPA create
//   - deleted-HPA recreate
//   - existing HPA with nil `selectPolicy` (reconcileExistingHPA
//     intentionally preserves non-nil values, so the
//     transient fallback would become the persistent source
//     of truth)
//
// Once the HPA materializes, HPA behavior → autoscale_policies would
// publish the disabled state and scale-out would be silently
// disabled until someone manually repaired the HPA behavior.
//
// This test asserts:
//   - Invoke returns the error (not nil) when GetRuConfig fails
//   - No template render / HPA apply is attempted downstream

// TestReconcileExistingHPA_LeavesStabilizationWindowAlone pins the
// contract: reconcile does NOT touch StabilizationWindowSeconds,
// and does NOT overwrite existing non-nil SelectPolicy values.
// Both fields are owned by the HPA template (first create) and the
// SetHPAScaleBehavior API (operator override) — letting reconcile
// write nil → default would silently clobber template defaults
// and erase explicit operator overrides. Reconcile only fills nil
// SelectPolicy slots (first apply / template-generated HPA without
// explicit behavior).
func TestReconcileExistingHPA_LeavesStabilizationWindowAlone(t *testing.T) {
	// Case 1: existing stabilization windows of any value must
	// be preserved. Existing non-nil SelectPolicy is never overwritten.
	t.Run("existing stabilization window preserved", func(t *testing.T) {
		minReplicas := int32(1)
		hpa := &autoscalingv2.HorizontalPodAutoscaler{
			Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
				MinReplicas: &minReplicas,
				MaxReplicas: 3,
				Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
					ScaleDown: &autoscalingv2.HPAScalingRules{
						SelectPolicy:               ptrPolicy(autoscalingv2.MinChangePolicySelect),
						StabilizationWindowSeconds: lo.ToPtr(int32(3600)), // TIDB template value
					},
					ScaleUp: &autoscalingv2.HPAScalingRules{
						SelectPolicy:               ptrPolicy(autoscalingv2.MaxChangePolicySelect),
						StabilizationWindowSeconds: lo.ToPtr(int32(0)),
					},
				},
			},
		}
		comp := &entity.Component{ComponentType: apiv1.ComponentType_TIDB_WORKER}
		changed := reconcileExistingHPA(hpa, comp, 1, 3,
			autoscalingv2.MaxChangePolicySelect, autoscalingv2.MaxChangePolicySelect, true)
		assert.False(t, changed, "non-nil SelectPolicy is preserved — reconcile should not overwrite")
		// Existing SelectPolicy preserved as-is (not overwritten by desired).
		assert.Equal(t, autoscalingv2.MinChangePolicySelect, *hpa.Spec.Behavior.ScaleDown.SelectPolicy)
		assert.Equal(t, autoscalingv2.MaxChangePolicySelect, *hpa.Spec.Behavior.ScaleUp.SelectPolicy)
		// Stabilization windows preserved — reconcile must not touch them.
		assert.Equal(t, int32(3600), *hpa.Spec.Behavior.ScaleDown.StabilizationWindowSeconds,
			"existing scaleDown stabilization window must be preserved")
		assert.Equal(t, int32(0), *hpa.Spec.Behavior.ScaleUp.StabilizationWindowSeconds,
			"existing scaleUp stabilization window must be preserved")
	})

	// Case 2: nil stabilization must NOT be filled in by reconcile.
	// The HPA template owns first-create defaults; reconcile must
	// not synthesize its own.
	t.Run("nil stabilization window left nil", func(t *testing.T) {
		minReplicas := int32(1)
		hpa := &autoscalingv2.HorizontalPodAutoscaler{
			Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
				MinReplicas: &minReplicas,
				MaxReplicas: 3,
				Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
					ScaleDown: &autoscalingv2.HPAScalingRules{
						SelectPolicy:               ptrPolicy(autoscalingv2.DisabledPolicySelect),
						StabilizationWindowSeconds: nil,
					},
					ScaleUp: &autoscalingv2.HPAScalingRules{
						SelectPolicy:               ptrPolicy(autoscalingv2.MaxChangePolicySelect),
						StabilizationWindowSeconds: nil,
					},
				},
			},
		}
		comp := &entity.Component{ComponentType: apiv1.ComponentType_TIDB_WORKER}
		changed := reconcileExistingHPA(hpa, comp, 1, 3,
			autoscalingv2.MaxChangePolicySelect, autoscalingv2.MaxChangePolicySelect, true)
		assert.False(t, changed, "non-nil SelectPolicy preserved; reconcile should not overwrite")
		// Reconcile must NOT fabricate stabilization windows.
		assert.Nil(t, hpa.Spec.Behavior.ScaleDown.StabilizationWindowSeconds,
			"reconcile must NOT set nil scaleDown stabilization window")
		assert.Nil(t, hpa.Spec.Behavior.ScaleUp.StabilizationWindowSeconds,
			"reconcile must NOT set nil scaleUp stabilization window")
		// SelectPolicy preserved — NOT overwritten by reconcile.
		assert.Equal(t, autoscalingv2.DisabledPolicySelect, *hpa.Spec.Behavior.ScaleDown.SelectPolicy)
		assert.Equal(t, autoscalingv2.MaxChangePolicySelect, *hpa.Spec.Behavior.ScaleUp.SelectPolicy)
	})

	// Case 3: first apply — Behavior is nil, SelectPolicy set,
	// stabilization stays nil (template owns first-create values).
	t.Run("first apply sets select policy only", func(t *testing.T) {
		minReplicas := int32(1)
		hpa := &autoscalingv2.HorizontalPodAutoscaler{
			Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
				MinReplicas: &minReplicas,
				MaxReplicas: 3,
				Behavior:    nil, // not created yet
			},
		}
		comp := &entity.Component{ComponentType: apiv1.ComponentType_TIDB_WORKER}
		changed := reconcileExistingHPA(hpa, comp, 1, 3,
			autoscalingv2.MaxChangePolicySelect, autoscalingv2.MaxChangePolicySelect, true)
		assert.True(t, changed)
		// SelectPolicy set by reconcile.
		assert.Equal(t, autoscalingv2.MaxChangePolicySelect, *hpa.Spec.Behavior.ScaleDown.SelectPolicy)
		assert.Equal(t, autoscalingv2.MaxChangePolicySelect, *hpa.Spec.Behavior.ScaleUp.SelectPolicy)
		// StabilizationWindowSeconds NOT touched by reconcile.
		assert.Nil(t, hpa.Spec.Behavior.ScaleDown.StabilizationWindowSeconds,
			"reconcile must NOT set first-create stabilization window")
		assert.Nil(t, hpa.Spec.Behavior.ScaleUp.StabilizationWindowSeconds,
			"reconcile must NOT set first-create stabilization window")
	})

	// Case 4: disable and re-enable — stabilization preserved,
	// SelectPolicy unchanged (matches desired).
	t.Run("disable re-enable preserves stabilization and select policy", func(t *testing.T) {
		minReplicas := int32(1)
		hpa := &autoscalingv2.HorizontalPodAutoscaler{
			Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
				MinReplicas: &minReplicas,
				MaxReplicas: 3,
				Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
					ScaleDown: &autoscalingv2.HPAScalingRules{
						SelectPolicy:               ptrPolicy(autoscalingv2.DisabledPolicySelect),
						StabilizationWindowSeconds: lo.ToPtr(int32(180)),
					},
					ScaleUp: &autoscalingv2.HPAScalingRules{
						SelectPolicy:               ptrPolicy(autoscalingv2.DisabledPolicySelect),
						StabilizationWindowSeconds: lo.ToPtr(int32(0)),
					},
				},
			},
		}
		comp := &entity.Component{ComponentType: apiv1.ComponentType_TIDB_WORKER}
		changed := reconcileExistingHPA(hpa, comp, 1, 3,
			autoscalingv2.DisabledPolicySelect, autoscalingv2.DisabledPolicySelect, true)
		assert.False(t, changed, "nothing to change when disabled and select policy matches")
		// Stabilization window preserved.
		assert.Equal(t, int32(180), *hpa.Spec.Behavior.ScaleDown.StabilizationWindowSeconds)
		assert.Equal(t, int32(0), *hpa.Spec.Behavior.ScaleUp.StabilizationWindowSeconds)
	})
}

func TestReconcileExistingHPADisabledPreservesNilScaleUpPolicy(t *testing.T) {
	minReplicas := int32(1)
	hpa := &autoscalingv2.HorizontalPodAutoscaler{
		Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
			MinReplicas: &minReplicas,
			MaxReplicas: 3,
		},
	}
	comp := &entity.Component{ComponentType: apiv1.ComponentType_TIDB_WORKER}

	changed := reconcileExistingHPA(
		hpa,
		comp,
		1,
		3,
		autoscalingv2.MaxChangePolicySelect,
		autoscalingv2.MaxChangePolicySelect,
		false,
	)

	require.True(t, changed)
	require.NotNil(t, hpa.Spec.Behavior)
	require.NotNil(t, hpa.Spec.Behavior.ScaleDown)
	require.Equal(t, autoscalingv2.MaxChangePolicySelect, *hpa.Spec.Behavior.ScaleDown.SelectPolicy)
	require.Nil(t, hpa.Spec.Behavior.ScaleUp)
}
