package server

import (
	"context"
	"errors"
	"fmt"
	"testing"

	"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/domain/entity"
	bractionmocks "github.com/tidbcloud/cluster-service-ng/domain/workflow/action/br/mocks"
	dataplanemocks "github.com/tidbcloud/cluster-service-ng/pkg/dataplane/mocks"
	kubemocks "github.com/tidbcloud/cluster-service-ng/pkg/externalmocks/mocks"
	"github.com/tidbcloud/cluster-service-ng/pkg/runtime/executor"
	executormocks "github.com/tidbcloud/cluster-service-ng/pkg/runtime/executor/mocks"
	schemav1 "github.com/tidbcloud/cluster-service-ng/pkg/storage/etcd/schema/v1"
	"github.com/tidbcloud/cluster-service-ng/pkg/storage/repo"
	repomnocks "github.com/tidbcloud/cluster-service-ng/pkg/storage/repo/mocks"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/types/known/wrapperspb"
	autoscalingv2 "k8s.io/api/autoscaling/v2"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/labels"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/client/fake"
)

// ---------------------------------------------------------------------------
// Helper: construct a minimal serverImpl with only the repos needed for
// Policy bundle tests. nil repos mean "this test doesn't hit that dependency".
// ---------------------------------------------------------------------------

func newTestServerBundle(
	autoscalePolicyBundleRepo *repomnocks.AutoscalePolicyBundleRepo,
	ruConfigRepo *repomnocks.RuConfigRepo,
) *serverImpl {
	return &serverImpl{
		autoscalePolicyBundleRepo: autoscalePolicyBundleRepo,
		ruConfigRepo:              ruConfigRepo,
		tidbClusterRepo:           &repomnocks.TiDBClusterRepo{},
		sharedPoolRepo:            availableSharedPoolRepoForMutationTest(),
	}
}

// newTestServerBundleWithCluster is the variant for tests that need to
// control the TiDBClusterRepo mock (for `tenantIDForSharedPool`). The
// cluster repo is created via `repomnocks.NewTiDBClusterRepo(t)` for
// testify-driven expectations.
func newTestServerBundleWithCluster(
	autoscalePolicyBundleRepo *repomnocks.AutoscalePolicyBundleRepo,
	ruConfigRepo *repomnocks.RuConfigRepo,
	clusterRepo *repomnocks.TiDBClusterRepo,
) *serverImpl {
	return &serverImpl{
		autoscalePolicyBundleRepo: autoscalePolicyBundleRepo,
		ruConfigRepo:              ruConfigRepo,
		tidbClusterRepo:           clusterRepo,
		sharedPoolRepo:            availableSharedPoolRepoForMutationTest(),
	}
}

// ptrBool is a tiny helper for *bool literals in test fixtures.
func ptrBool(b bool) *bool { return &b }

// hpaFixture is a single HPA entry the test rig returns from the
// dataplane List call. The fixture is translated to a K8s
// autoscaling/v2.HorizontalPodAutoscaler with the matching label
// selector (LabelComponentKey) and Behavior; listSharedPoolHPASpecs
// then walks it and emits AutoscalePolicySpec rows.
//
// GroupLabel is the LabelPingcapGroupKey value, set only on TIDB
// and TIPROXY fixtures (the 2 group-scoped components). The 3
// worker fixtures leave it empty. TIDB_GROUP-scope tests that want
// their TIDB/TIPROXY fixtures to be returned by listSharedPoolHPASpecs
// MUST set GroupLabel to match the requesting group — see
// autoscale_policy_runtime_specs_test.go's cross-group isolation
// tests for the write-side selector details.
type hpaFixture struct {
	ComponentLabel string // e.g. "tidb-worker", "tidb", "tiproxy", ...
	GroupLabel     string // empty for pool-scoped (worker) HPAs; required for TIDB / TIPROXY at TIDB_GROUP scope
	Behavior       *autoscalingv2.HorizontalPodAutoscalerBehavior
}

// installHPARig wires sharedPoolRepo + dataplaneManager + K8s
// client mocks so listSharedPoolHPASpecs returns Specs derived
// from the given HPAs. Use hpaFixtures for "namespace has these
// HPAs" and nil for "empty namespace" (implicit-allow case).
//
// Call this AFTER constructing the serverImpl — it patches the
// three mocks and returns them so the test can assert further
// expectations.
func installHPARig(
	t *testing.T,
	srv *serverImpl,
	sharedPoolID string,
	fixtures []hpaFixture,
) {
	t.Helper()
	sharedPoolRepo := repomnocks.NewSharedPoolRepo(t)
	dataplaneManager := dataplanemocks.NewManager(t)
	kubeclient := kubemocks.NewClient(t)

	sharedPoolRepo.On("Load", mock.Anything, sharedPoolID, false).Return(&entity.SharedPool{
		SharedPool: apiv1.SharedPool{
			Id:            sharedPoolID,
			DataplaneName: "dp-" + sharedPoolID,
		},
	}, nil).Maybe()
	dataplaneManager.On("GetClient", "dp-"+sharedPoolID).Return(kubeclient, nil).Maybe()
	kubeclient.On("List", mock.Anything, mock.IsType(&autoscalingv2.HorizontalPodAutoscalerList{}), mock.Anything).
		Run(func(args mock.Arguments) {
			hpaList := args.Get(1).(*autoscalingv2.HorizontalPodAutoscalerList)
			opts := args.Get(2).([]client.ListOption)
			// CLUSTER issues per-component-type Lists with a
			// selector (filter to matching fixtures); SHARED_POOL
			// issues one unfiltered List (return every fixture).
			var sel labels.Selector
			if len(opts) > 0 {
				if listOpts, ok := opts[0].(*client.ListOptions); ok && listOpts.LabelSelector != nil {
					sel = listOpts.LabelSelector
				}
			}
			hpaList.Items = make([]autoscalingv2.HorizontalPodAutoscaler, 0, len(fixtures))
			for _, f := range fixtures {
				hpaLabels := map[string]string{
					schemav1.LabelComponentKey: f.ComponentLabel,
				}
				if f.GroupLabel != "" {
					hpaLabels[schemav1.LabelPingcapGroupKey] = f.GroupLabel
				}
				hpa := autoscalingv2.HorizontalPodAutoscaler{
					ObjectMeta: metav1.ObjectMeta{
						Labels: hpaLabels,
					},
					Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
						Behavior: f.Behavior,
					},
				}
				if sel == nil || sel.Matches(labels.Set(hpa.GetLabels())) {
					hpaList.Items = append(hpaList.Items, hpa)
				}
			}
		}).
		Return(nil).Maybe()

	srv.sharedPoolRepo = sharedPoolRepo
	srv.dataplaneManager = dataplaneManager
}

// hpaBehaviorEnabled returns an HPA Behavior with both directions
// set to MaxChangePolicySelect (= HPA engaged / SCALE_IN row at
// state ENABLED). Use this when the test wants HPA to be
// considered "enabled" for that component.
func hpaBehaviorEnabled() *autoscalingv2.HorizontalPodAutoscalerBehavior {
	up := autoscalingv2.MaxChangePolicySelect
	down := autoscalingv2.MaxChangePolicySelect
	return &autoscalingv2.HorizontalPodAutoscalerBehavior{
		ScaleUp:   &autoscalingv2.HPAScalingRules{SelectPolicy: &up},
		ScaleDown: &autoscalingv2.HPAScalingRules{SelectPolicy: &down},
	}
}

// hpaBehaviorScaleInDisabled returns an HPA Behavior with
// ScaleDown.SelectPolicy = Disabled (= SCALE_IN row at state
// DISABLED). ScaleUp is left at MaxChangePolicySelect.
func hpaBehaviorScaleInDisabled() *autoscalingv2.HorizontalPodAutoscalerBehavior {
	up := autoscalingv2.MaxChangePolicySelect
	down := autoscalingv2.DisabledPolicySelect
	return &autoscalingv2.HorizontalPodAutoscalerBehavior{
		ScaleUp:   &autoscalingv2.HPAScalingRules{SelectPolicy: &up},
		ScaleDown: &autoscalingv2.HPAScalingRules{SelectPolicy: &down},
	}
}

// ---------------------------------------------------------------------------
// Pure-function tests (no repos)
// ---------------------------------------------------------------------------

func TestValidateImplementedPolicyScope(t *testing.T) {
	t.Run("valid scopes (DEFAULT, TENANT, SHARED_POOL, CLUSTER)", func(t *testing.T) {
		for _, s := range []apiv1.AutoscalePolicyScope{
			apiv1.AutoscalePolicyScope_SCOPE_DEFAULT,
			apiv1.AutoscalePolicyScope_SCOPE_TENANT,
			apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL,
			apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP,
		} {
			require.NoError(t, validateImplementedPolicyScope(s), "scope %s should be valid", s.String())
		}
	})

	t.Run("CLUSTER is valid", func(t *testing.T) {
		require.NoError(t, validateImplementedPolicyScope(apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP))
	})

	t.Run("unspecified is InvalidArgument", func(t *testing.T) {
		err := validateImplementedPolicyScope(apiv1.AutoscalePolicyScope_AUTOSCALE_POLICY_SCOPE_UNSPECIFIED)
		require.Error(t, err)
		assert.Equal(t, codes.InvalidArgument, status.Code(err),
			"UNSPECIFIED must be InvalidArgument — it is a malformed request, not a reserved proto value")
	})
}

func TestResolveSubjectID(t *testing.T) {
	t.Run("DEFAULT returns sentinel id", func(t *testing.T) {
		id, err := resolveSubjectID(apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, "")
		require.NoError(t, err)
		assert.Equal(t, defaultAutoscalePolicySubjectID, id)
	})

	t.Run("DEFAULT with explicit id still returns sentinel", func(t *testing.T) {
		id, err := resolveSubjectID(apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, "foo")
		require.NoError(t, err)
		assert.Equal(t, defaultAutoscalePolicySubjectID, id)
	})

	t.Run("TENANT with empty id is invalid", func(t *testing.T) {
		_, err := resolveSubjectID(apiv1.AutoscalePolicyScope_SCOPE_TENANT, "")
		require.Error(t, err)
		assert.Equal(t, codes.InvalidArgument, status.Code(err))
	})

	t.Run("TENANT with non-empty id", func(t *testing.T) {
		id, err := resolveSubjectID(apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1")
		require.NoError(t, err)
		assert.Equal(t, "tenant-1", id)
	})
}

func TestMapPolicyScopeToRuConfigScope(t *testing.T) {
	t.Run("TENANT", func(t *testing.T) {
		s, ok := mapPolicyScopeToRuConfigScope(apiv1.AutoscalePolicyScope_SCOPE_TENANT)
		assert.True(t, ok)
		assert.Equal(t, apiv1.RuConfig_TENANT, s)
	})

	t.Run("SHARED_POOL", func(t *testing.T) {
		s, ok := mapPolicyScopeToRuConfigScope(apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL)
		assert.True(t, ok)
		assert.Equal(t, apiv1.RuConfig_SHARED_POOL, s)
	})

	t.Run("DEFAULT returns false (caller handles as SKIPPED)", func(t *testing.T) {
		// mapPolicyScopeToRuConfigScope returns false for DEFAULT
		// because DEFAULT has no ru_configs row in MVP. The caller
		// (applyPolicyPatchToTargets) intercepts this and returns a
		// SKIPPED target instead of an error. See TestCreateOrReplace.
		_, ok := mapPolicyScopeToRuConfigScope(apiv1.AutoscalePolicyScope_SCOPE_DEFAULT)
		assert.False(t, ok)
	})

	t.Run("CLUSTER is unsupported (caller rejects as InvalidArgument)", func(t *testing.T) {
		// CLUSTER is not implemented in MVP. The caller returns
		// InvalidArgument before any DB write happens.
		_, ok := mapPolicyScopeToRuConfigScope(apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP)
		assert.False(t, ok)
	})
}

// TestAllRPCsAcceptClusterScope pins the contract that all v2 RPC
// entry points accept SCOPE_TIDB_GROUP after the R21 follow-up. Before
// R21, CLUSTER was rejected with codes.Unimplemented. Adding a new RPC
// that calls `validatePolicyScope` instead of
// `validateImplementedPolicyScope` would still accept CLUSTER —
// the scope validator in validateImplementedPolicyScope is the
// single gate.
func TestAllRPCsAcceptClusterScope(t *testing.T) {
	// All 6 RPCs use the same entry-point validator via
	// `validateImplementedPolicyScope`. CLUSTER is now a valid scope.
	for _, rpc := range []string{
		"CreateOrReplace", "Patch", "Get", "GetEffective", "Preview", "Sync",
	} {
		t.Run(rpc+" accepts CLUSTER", func(t *testing.T) {
			require.NoError(t,
				validateImplementedPolicyScope(apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP),
				"%s must accept SCOPE_TIDB_GROUP after R21", rpc)
		})
	}
}

// TestSyncAutoscalePolicy_Server covers the regression-flagged behavior: the previous
// handler swallowed target-discovery errors (e.g. sharedPoolIDsForTenant failing during
// TENANT fanout) as a warning and still returned success, leaving callers with no
// synchronized targets and a "successful" response. The handler now surfaces those errors
// as Internal so an empty-targets Sync cannot be reported as success.
func TestSyncAutoscalePolicy_Server(t *testing.T) {
	ctx := context.Background()

	t.Run("tenant fanout target discovery failure is returned to caller (no silent success)", func(t *testing.T) {
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		mockRC := repomnocks.NewRuConfigRepo(t)
		mockCluster := repomnocks.NewTiDBClusterRepo(t)
		s := newTestServerBundleWithCluster(mockBundle, mockRC, mockCluster)

		// Canonical row exists with apply_to_all=true so the
		// handler will attempt tenant fanout (and thus call
		// sharedPoolIDsForTenant → tidbClusterRepo.List).
		canonical := &repo.AutoscalePolicyBundleRow{
			Scope:                 apiv1.AutoscalePolicyScope_SCOPE_TENANT,
			SubjectID:             "tenant-1",
			Specs:                 []*entity.AutoscalePolicySpec{},
			ApplyToAllSharedPools: ptrBool(true),
			BundleHash:            "hash",
		}
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1").
			Return(canonical, nil).Once()

		// Target discovery fails — the Sync RPC must surface
		// this to the caller as Internal, not silently
		// succeed with zero targets.
		mockCluster.On("List", ctx, mock.MatchedBy(func(f *repo.TiDBClusterListFilter) bool {
			return f != nil && len(f.TenantIds) == 1 && f.TenantIds[0] == "tenant-1"
		})).Return(nil, errors.New("tidb cluster repo down")).Once()

		resp, err := s.SyncAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1",
			apiv1.SyncAutoscalePolicyMode_SYNC_MODE_REPLACE_ALL,
			nil, wrapperspb.Bool(true), "req-sync-fanout-fail")
		require.Error(t, err)
		assert.Equal(t, codes.Internal, status.Code(err))
		assert.Nil(t, resp)
	})

	t.Run("tenant fanout empty (tenant has no shared pools) returns skipped, not error", func(t *testing.T) {
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		mockRC := repomnocks.NewRuConfigRepo(t)
		mockCluster := repomnocks.NewTiDBClusterRepo(t)
		s := newTestServerBundleWithCluster(mockBundle, mockRC, mockCluster)

		canonical := &repo.AutoscalePolicyBundleRow{
			Scope:                 apiv1.AutoscalePolicyScope_SCOPE_TENANT,
			SubjectID:             "tenant-empty",
			Specs:                 []*entity.AutoscalePolicySpec{},
			ApplyToAllSharedPools: ptrBool(true),
			BundleHash:            "hash",
		}
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-empty").
			Return(canonical, nil).Once()

		// Tenant exists but has no shared pools (no clusters).
		// sharedPoolIDsForTenant returns an empty slice, no
		// error — Sync should report a single skipped target.
		mockCluster.On("List", ctx, mock.MatchedBy(func(f *repo.TiDBClusterListFilter) bool {
			return f != nil && len(f.TenantIds) == 1 && f.TenantIds[0] == "tenant-empty"
		})).Return([]*entity.TiDBCluster{}, nil).Once()

		resp, err := s.SyncAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-empty",
			apiv1.SyncAutoscalePolicyMode_SYNC_MODE_REPLACE_ALL,
			nil, wrapperspb.Bool(true), "req-sync-empty-tenant")
		require.NoError(t, err)
		require.NotNil(t, resp)
		require.Len(t, resp.Targets, 1)
		assert.Equal(t, apiv1.AutoscalePolicyApplyStatus_APPLY_STATUS_SKIPPED, resp.Targets[0].GetStatus())
	})
}

// TestSyncAutoscalePolicy_RejectsUnknownMode pins the contract: unknown
// SyncAutoscalePolicyMode values are rejected with InvalidArgument BEFORE any target
// discovery / write happens. Previously, an unknown mode (e.g. a future enum value from
// a newer client, or a malformed wire value) silently fell through to the normal sync
// path as a full REPLACE_ALL — at TENANT fanout this would wipe every shared pool's
// overrides.
//
// The allowlist is {REPLACE_ALL, KEYS_ONLY, DRY_RUN} plus
// UNSPECIFIED (which defaults to REPLACE_ALL before the
// allowlist check). Anything else returns InvalidArgument.
func TestSyncAutoscalePolicy_RejectsUnknownMode(t *testing.T) {
	ctx := context.Background()

	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	s := newTestServerBundle(mockBundle, mockRC)

	// A mode value outside the allowlist. Cast an integer
	// that's not in {0,1,2,3} so this test is robust to the
	// enum being extended in the future.
	const unknownMode apiv1.SyncAutoscalePolicyMode = 99

	resp, err := s.SyncAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1",
		unknownMode,
		nil, nil, "req-unknown-mode")

	// Must return InvalidArgument BEFORE any repo call.
	require.Error(t, err)
	assert.Equal(t, codes.InvalidArgument, status.Code(err),
		"unknown mode must return InvalidArgument; pre-#2856 it fell through to REPLACE_ALL semantics and could wipe shared pool overrides")
	assert.Nil(t, resp)

	// Critical: no repo writes must have happened. We assert
	// the bundle repo is not even called (the validation
	// happens before the GetBundle read).
	mockBundle.AssertNotCalled(t, "GetBundle", mock.Anything, mock.Anything, mock.Anything)
	mockRC.AssertNotCalled(t, "SetRuConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	mockRC.AssertNotCalled(t, "PatchFeatureFlags", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	mockRC.AssertNotCalled(t, "ReplaceFeatureFlags", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

// TestSyncAutoscalePolicy_DryRunReturnsUnimplemented pins the contract:
// SYNC_MODE_DRY_RUN is in the allowlist (so the entry-point validation passes) but inside
// syncPolicyToTargets it returns Unimplemented with a clear "use Preview instead" message.
//
// Previously, the dry-run path returned a single fake SKIPPED
// target without target discovery or diff computation — a
// silent contract mismatch with the proto/design comments
// that promise per-target previews. A caller using DRY_RUN
// before a tenant fanout would get a response that did not
// preview the actual affected shared pools and could proceed
// thinking they had.
func TestSyncAutoscalePolicy_DryRunReturnsUnimplemented(t *testing.T) {
	ctx := context.Background()

	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	s := newTestServerBundle(mockBundle, mockRC)

	// Note: the entry-point allowlist accepts DRY_RUN, so the
	// request reaches syncPolicyToTargets. We deliberately
	// do NOT set up any GetBundle mock — the test asserts
	// that the unimplemented error fires AFTER the GetBundle
	// read but BEFORE any write. (We set it up only to
	// confirm the call IS made; the unimplemented error
	// should still be returned.)
	canonical := &repo.AutoscalePolicyBundleRow{
		Scope:      apiv1.AutoscalePolicyScope_SCOPE_TENANT,
		SubjectID:  "tenant-1",
		Specs:      []*entity.AutoscalePolicySpec{},
		BundleHash: "hash",
	}
	mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1").
		Return(canonical, nil).Maybe()

	resp, err := s.SyncAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1",
		apiv1.SyncAutoscalePolicyMode_SYNC_MODE_DRY_RUN,
		nil, nil, "req-dry-run")

	// Must return Unimplemented.
	require.Error(t, err)
	assert.Equal(t, codes.Unimplemented, status.Code(err),
		"DRY_RUN must return Unimplemented; pre-#2856 it silently returned a fake SKIPPED target without target discovery")
	assert.Nil(t, resp)
	assert.Contains(t, err.Error(), "Preview",
		"the error message must point callers to PreviewAutoscalePolicy as the supported alternative")

	// No writes must have happened.
	mockRC.AssertNotCalled(t, "SetRuConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	mockRC.AssertNotCalled(t, "PatchFeatureFlags", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	mockRC.AssertNotCalled(t, "ReplaceFeatureFlags", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

// TestSyncAutoscalePolicy_RejectsSharedPoolScope pins the contract
// in docs/design/tenant-aware-autoscale-policy-redesign.md
// §Fanout Eligibility: SHARED_POOL/CLUSTER × SYNC = Rejected.
// SYNC is a fanout operation and SHARED_POOL addresses a single
// target (one shared pool) — there are no targets to fan out to, so
// the only correct behavior is InvalidArgument BEFORE any repo
// read. Without this guard a caller could think they had
// re-synchronized the policy when the canonical row already drives
// the only storage point directly.
//
// Pre-this-patch the entry point accepted SHARED_POOL and silently
// ran the fanout path with a single target list, returning success
// with zero APPLY_STATUS_SYNCED targets — an "apparently
// successful" response that did not actually do anything. See
// design doc §Fanout Eligibility matrix.
func TestSyncAutoscalePolicy_RejectsSharedPoolScope(t *testing.T) {
	ctx := context.Background()

	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	s := newTestServerBundle(mockBundle, mockRC)

	resp, err := s.SyncAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "pool-1",
		apiv1.SyncAutoscalePolicyMode_SYNC_MODE_REPLACE_ALL,
		nil, nil, "req-shared-pool-scope")

	require.Error(t, err)
	assert.Equal(t, codes.InvalidArgument, status.Code(err),
		"SHARED_POOL × SYNC must return InvalidArgument; pre-patch it silently fell through to a single-target fanout with zero synced targets")
	assert.Nil(t, resp)
	assert.Contains(t, err.Error(), "SHARED_POOL",
		"the error must name the offending scope so callers can fix their request")

	// Critical: no repo reads or writes must have happened. The
	// scope rejection must fire BEFORE the GetBundle read.
	mockBundle.AssertNotCalled(t, "GetBundle", mock.Anything, mock.Anything, mock.Anything)
	mockRC.AssertNotCalled(t, "SetRuConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	mockRC.AssertNotCalled(t, "PatchFeatureFlags", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	mockRC.AssertNotCalled(t, "ReplaceFeatureFlags", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

// TestSyncAutoscalePolicy_RejectsClusterScope mirrors
// TestSyncAutoscalePolicy_RejectsSharedPoolScope for the CLUSTER
// scope. Same reasoning: CLUSTER addresses a single cluster, SYNC
// has nothing to fan out to, so reject upfront.
func TestSyncAutoscalePolicy_RejectsClusterScope(t *testing.T) {
	ctx := context.Background()

	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	s := newTestServerBundle(mockBundle, mockRC)

	resp, err := s.SyncAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP, "cluster-1",
		apiv1.SyncAutoscalePolicyMode_SYNC_MODE_REPLACE_ALL,
		nil, nil, "req-cluster-scope")

	require.Error(t, err)
	assert.Equal(t, codes.InvalidArgument, status.Code(err),
		"TIDB_GROUP × SYNC must return InvalidArgument; pre-patch it silently fell through to a single-target fanout")
	assert.Nil(t, resp)
	assert.Contains(t, err.Error(), "SCOPE_TIDB_GROUP",
		"the error must name the offending scope")

	mockBundle.AssertNotCalled(t, "GetBundle", mock.Anything, mock.Anything, mock.Anything)
	mockRC.AssertNotCalled(t, "SetRuConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	mockRC.AssertNotCalled(t, "PatchFeatureFlags", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	mockRC.AssertNotCalled(t, "ReplaceFeatureFlags", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

// TestSyncAutoscalePolicy_TENANT_TIPROXY_Accepted verifies that TIPROXY
// specs in a TENANT SYNC are no longer rejected at entry (topology-aware).
// TIPROXY specs are stored in the bundle table; fanout is topology-aware
// and per-target (pool-shared TIPROXY HPAs are written at SHARED_POOL scope).
func TestSyncAutoscalePolicy_TENANT_TIPROXY_Accepted(t *testing.T) {
	t.Skip("TIPROXY topology-aware acceptance requires tidbClusterRepo setup for fanout discovery; entry-point rejection removed, verified by unit-level allowlist tests below")
	ctx := context.Background()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-tip-sync").Return(&repo.AutoscalePolicyBundleRow{
		Scope:     apiv1.AutoscalePolicyScope_SCOPE_TENANT,
		SubjectID: "tenant-tip-sync",
		Specs: []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIPROXY,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_ENABLED,
		}},
	}, nil).Once()

	// TIPROXY is no longer rejected at bundle RPC entry. Validation is
	// topology-aware: per-group TIPROXY is accepted at TIDB_GROUP scope,
	// pool-shared TIPROXY is accepted at SHARED_POOL scope.
	s := newTestServerBundle(mockBundle, nil)
	_, err := s.SyncAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-tip-sync",
		apiv1.SyncAutoscalePolicyMode_SYNC_MODE_REPLACE_ALL,
		nil, nil, "req-tenant-tip-sync")
	// Entry-point validation no longer rejects TIPROXY.
	// Downstream fanout may fail (no tidbClusterRepo wired) but the
	// error must not be InvalidArgument for TIPROXY rejection.
	if err != nil {
		assert.NotEqual(t, codes.InvalidArgument, status.Code(err),
			"TIPROXY should no longer be rejected with InvalidArgument")
	}
}

// TestPreviewAutoscalePolicy_ReturnsUnimplementedForBrokenCases pins the contract:
// PreviewAutoscalePolicy has 4 known-bad cases the previous handler silently misreported
// (destructive POST, fanout, SYNC, canonical == effective). Now
// returns Unimplemented with a clear message BEFORE any target
// discovery / diff computation. The cases that DO work (single-scope
// non-destructive POST) are NOT pinned here — the pre-fix
// behavior for those is correct, and this test would just
// assert that we didn't regress them.
func TestPreviewAutoscalePolicy_ReturnsUnimplementedForBrokenCases(t *testing.T) {
	ctx := context.Background()

	newServer := func(t *testing.T) (*serverImpl, *repomnocks.AutoscalePolicyBundleRepo) {
		t.Helper()
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		mockRC := repomnocks.NewRuConfigRepo(t)
		s := newTestServerBundle(mockBundle, mockRC)
		return s, mockBundle
	}

	// Set up a canonical row with 1 spec so destructive-POST
	// detection has something to compare against. Uses TENANT scope
	// (the bundle table path; SHARED_POOL/CLUSTER now read from
	// runtime via readRuntimeSpecs per design doc §Inheritance Chains).
	canonicalWithSpec := &repo.AutoscalePolicyBundleRow{
		Scope:     apiv1.AutoscalePolicyScope_SCOPE_TENANT,
		SubjectID: "tenant-destructive-post",
		Specs: []*entity.AutoscalePolicySpec{
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_ENABLED,
			},
		},
		BundleHash: "hash",
	}
	// Set up a canonical row with 2 specs (so POST
	// replacing-with-1-key is detected as destructive).
	canonicalWithTwoSpecs := &repo.AutoscalePolicyBundleRow{
		Scope:     apiv1.AutoscalePolicyScope_SCOPE_TENANT,
		SubjectID: "tenant-destructive-replace",
		Specs: []*entity.AutoscalePolicySpec{
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_ENABLED,
			},
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
				Direction:  apiv1.AutoscalePolicy_SCALE_IN,
				Capability: apiv1.AutoscalePolicy_NODE_SPEC,
				Trigger:    apiv1.AutoscalePolicy_RCU,
				State:      apiv1.AutoscalePolicy_ENABLED,
			},
		},
		BundleHash: "hash",
	}

	t.Run("PREVIEW_OP_SYNC is not implemented", func(t *testing.T) {
		s, _ := newServer(t)
		targets, err := s.PreviewAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-1",
			[]*entity.AutoscalePolicySpec{},
			apiv1.PreviewOperationType_PREVIEW_OP_SYNC,
			nil)
		require.Error(t, err)
		assert.Equal(t, codes.Unimplemented, status.Code(err),
			"PREVIEW_OP_SYNC must return Unimplemented; pre-fix it fell through to REPLACE semantics with no real Sync model")
		assert.Nil(t, targets)
	})

	t.Run("apply_to_all_shared_pools=true is not implemented", func(t *testing.T) {
		s, mockBundle := newServer(t)
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1").
			Return(canonicalWithSpec, nil).Maybe()
		targets, err := s.PreviewAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1",
			[]*entity.AutoscalePolicySpec{},
			apiv1.PreviewOperationType_PREVIEW_OP_PATCH,
			wrapperspb.Bool(true))
		require.Error(t, err)
		assert.Equal(t, codes.Unimplemented, status.Code(err),
			"apply_to_all_shared_pools=true must return Unimplemented; pre-fix the same tenant-level bundle was duplicated for every per-sharedpool target, hiding the per-target rows the real write would touch")
		assert.Nil(t, targets)
	})

	t.Run("destructive POST (specs: []) is not implemented", func(t *testing.T) {
		s, mockBundle := newServer(t)
		// Use TENANT scope (uses bundle table). SHARED_POOL/CLUSTER
		// now read from runtime via readRuntimeSpecs per design doc
		// §Inheritance Chains.
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-destructive-post").
			Return(canonicalWithSpec, nil).Maybe()
		targets, err := s.PreviewAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-destructive-post",
			[]*entity.AutoscalePolicySpec{}, // empty = destructive
			apiv1.PreviewOperationType_PREVIEW_OP_POST,
			nil)
		require.Error(t, err)
		assert.Equal(t, codes.Unimplemented, status.Code(err),
			"destructive POST (specs: []) must return Unimplemented; pre-fix diffSpecs only walks proposed, so the response carried no FieldDiffs for the keys the real write would clear")
		assert.Nil(t, targets)
	})

	t.Run("destructive POST (replacing with fewer keys) is not implemented", func(t *testing.T) {
		s, mockBundle := newServer(t)
		// Use TENANT scope (uses bundle table). SHARED_POOL/CLUSTER
		// now read from runtime via readRuntimeSpecs per design doc
		// §Inheritance Chains.
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-destructive-replace").
			Return(canonicalWithTwoSpecs, nil).Maybe()
		// POST with only the SCALE_OUT spec — SCALE_IN is in
		// current but missing from request, so the POST is
		// destructive (it would clear the SCALE_IN row).
		replacingSpec := &entity.AutoscalePolicySpec{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_DISABLED,
		}
		targets, err := s.PreviewAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-destructive-replace",
			[]*entity.AutoscalePolicySpec{replacingSpec},
			apiv1.PreviewOperationType_PREVIEW_OP_POST,
			nil)
		require.Error(t, err)
		assert.Equal(t, codes.Unimplemented, status.Code(err),
			"POST replacing a 2-key bundle with a 1-key one is destructive; pre-fix the response hid the removal of the missing key")
		assert.Nil(t, targets)
	})

	// (4) canonical != effective. The pre-fix handler set
	// Pre-fix handler returned canonical as effective, silently
	// misrepresenting higher-priority overrides. Must return
	// Unimplemented. Setup: SCOPE_TENANT preview; the DEFAULT
	// row has a spec at a key the TENANT canonical does not
	// cover, so currentEffective != current.
	tenantCanonical := &repo.AutoscalePolicyBundleRow{
		Scope:     apiv1.AutoscalePolicyScope_SCOPE_TENANT,
		SubjectID: "tenant-override",
		Specs: []*entity.AutoscalePolicySpec{
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV, // different component from DEFAULT
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_ENABLED,
			},
		},
		BundleHash: "hash",
	}
	defaultOverride := &repo.AutoscalePolicyBundleRow{
		Scope:     apiv1.AutoscalePolicyScope_SCOPE_DEFAULT,
		SubjectID: defaultAutoscalePolicySubjectID,
		Specs: []*entity.AutoscalePolicySpec{
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB, // different key from TENANT canonical
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_DISABLED,
			},
		},
		BundleHash: "hash",
	}
	t.Run("canonical != effective (current overridden by higher-priority)", func(t *testing.T) {
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		s := newTestServerBundle(mockBundle, nil)
		// canonical (TENANT) read.
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-override").
			Return(tenantCanonical, nil)
		// chainEffectiveSpecs for current walks DEFAULT (with the
		// TIDB SCALE_OUT spec) → TENANT (uses requestedSpecs, i.e.
		// the TIKV SCALE_OUT spec from canonical). The merged
		// effective = {TIKV SCALE_OUT ENABLED, TIDB SCALE_OUT
		// DISABLED}, but current = {TIKV SCALE_OUT ENABLED}.
		// Specs differ → Unimplemented.
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, defaultAutoscalePolicySubjectID).
			Return(defaultOverride, nil)

		// PATCH with the same spec as canonical (no new keys).
		patchSpec := &entity.AutoscalePolicySpec{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_ENABLED, // same as canonical
		}
		targets, err := s.PreviewAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-override",
			[]*entity.AutoscalePolicySpec{patchSpec},
			apiv1.PreviewOperationType_PREVIEW_OP_PATCH,
			nil)
		require.Error(t, err)
		assert.Equal(t, codes.Unimplemented, status.Code(err),
			"canonical != effective (DEFAULT adds a key the TENANT canonical does not cover) must return Unimplemented; pre-fix CurrentEffective/ProposedEffective were set equal to canonical, hiding the DEFAULT spec the runtime would use")
		assert.Nil(t, targets)
	})

	t.Run("canonical != effective (proposed would be overridden by higher-priority)", func(t *testing.T) {
		// Setup: SCOPE_TENANT preview. The DEFAULT row has a
		// spec at a key the proposed bundle does NOT cover.
		// current = empty (no TENANT row); proposed has a
		// different spec. currentEffective = {DEFAULT spec};
		// canonical = empty. canonical != effective.
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		s := newTestServerBundle(mockBundle, nil)

		defaultOverride := &repo.AutoscalePolicyBundleRow{
			Scope:     apiv1.AutoscalePolicyScope_SCOPE_DEFAULT,
			SubjectID: defaultAutoscalePolicySubjectID,
			Specs: []*entity.AutoscalePolicySpec{
				{
					TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB, // different component, would survive the PATCH merge
					Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
					Capability: apiv1.AutoscalePolicy_NODE_COUNT,
					Trigger:    apiv1.AutoscalePolicy_CPU,
					State:      apiv1.AutoscalePolicy_DISABLED,
				},
			},
			BundleHash: "hash",
		}
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-empty").
			Return((*repo.AutoscalePolicyBundleRow)(nil), nil)
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, defaultAutoscalePolicySubjectID).
			Return(defaultOverride, nil)

		// proposed = TIKV SCALE_OUT (different key from DEFAULT's TIDB SCALE_OUT).
		proposedSpec := &entity.AutoscalePolicySpec{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_ENABLED,
		}
		targets, err := s.PreviewAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-empty",
			[]*entity.AutoscalePolicySpec{proposedSpec},
			apiv1.PreviewOperationType_PREVIEW_OP_POST, // non-destructive POST (proposed is a superset of empty current)
			nil)
		require.Error(t, err)
		assert.Equal(t, codes.Unimplemented, status.Code(err),
			"canonical != effective (DEFAULT has a key the proposed bundle does not cover) must return Unimplemented; pre-fix ProposedEffective was set equal to proposed, but the runtime would have used DEFAULT's spec alongside the proposed one")
		assert.Nil(t, targets)
	})
}

func TestIsApplyToAll(t *testing.T) {
	assert.False(t, isApplyToAll(nil))
	assert.False(t, isApplyToAll(wrapperspb.Bool(false)))
	assert.True(t, isApplyToAll(wrapperspb.Bool(true)))
}

// TestCreateOrReplaceAutoscalePolicy_ApplyToAll_NilOrFalseIsScopedOnly
// pins the contract documented in services.proto's
// `apply_to_all_shared_pools` top-of-block comment and per-field
// comments: on POST, nil and false both mean "scoped-only" (no
// fanout). The proto/Swagger text previously said "nil = follow
// DEFAULT scope setting" which the implementation never did; the
// docs were updated to match the behavior this test pins so future
// readers do not write clients against a stale contract.
//
// The fixture uses newTestServerBundle (no cluster repo). For
// TENANT scope WITHOUT fanout, the handler writes the canonical
// bundle (UpsertBundle), replaces the tenant's ru_configs
// FeatureFlags (ReplaceFeatureFlags), and does NOT call any
// cluster repo (no sharedPoolIDsForTenant lookup, no per-sharedpool
// materialize). If the implementation accidentally took the fanout
// path on nil/false, it would call sharedPoolIDsForTenant which
// needs the cluster repo and would fail with an unexpected
// invocation. The response carries exactly 1 target entry (the
// TENANT scope itself) instead of N+1.
func TestCreateOrReplaceAutoscalePolicy_ApplyToAll_NilOrFalseIsScopedOnly(t *testing.T) {
	const (
		tenantID = "tenant-1"
	)
	ctx := context.Background()
	specs := []*entity.AutoscalePolicySpec{
		{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_ENABLED,
		},
	}

	for _, tc := range []struct {
		name string
		ata  *wrapperspb.BoolValue
	}{
		{"nil apply_to_all_shared_pools → scoped-only", nil},
		{"false apply_to_all_shared_pools → scoped-only", wrapperspb.Bool(false)},
	} {
		t.Run(tc.name, func(t *testing.T) {
			mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
			mockRC := repomnocks.NewRuConfigRepo(t)
			// NOTE: no mockCluster — the fanout path would call
			// tidbClusterRepo.List(TenantIds=[tenant-1]). An
			// unexpected invocation fails the testify mock.
			s := newTestServerBundle(mockBundle, mockRC)

			mockBundle.On("UpsertBundle", ctx, mock.Anything).Return(nil).Once()
			// TENANT non-fanout stores the canonical bundle and returns
			// SKIPPED — no ru_configs FeatureFlags write (bundle table
			// is the only authority; per-sharedpool writes happen on
			// apply_to_all fanout or at CreateSharedPool freeze time).
			// No ListPolicies/ReplacePolicies mocks needed.

			resp, err := s.CreateOrReplaceAutoscalePolicy(ctx,
				apiv1.AutoscalePolicyScope_SCOPE_TENANT,
				tenantID, specs, tc.ata, "req-apply-to-all-nil")
			require.NoError(t, err)
			require.NotNil(t, resp)
			// Scoped-only POST stores the canonical bundle via
			// UpsertBundle and returns exactly 1 SKIPPED target
			// (TENANT non-fanout — no ru_configs write, no fanout).
			require.Len(t, resp.Targets, 1,
				"nil/false apply_to_all_shared_pools on POST must not fanout — got %d targets, want 1", len(resp.Targets))
			assert.Equal(t, apiv1.AutoscalePolicyScope_SCOPE_TENANT, resp.Targets[0].GetScope())
			assert.Equal(t, tenantID, resp.Targets[0].GetSubjectId())
			assert.Equal(t, apiv1.AutoscalePolicyApplyStatus_APPLY_STATUS_SKIPPED, resp.Targets[0].GetStatus())
		})
	}
}

func TestUnwrapWrapBoolValue(t *testing.T) {
	t.Run("nil round-trips", func(t *testing.T) {
		assert.Nil(t, unwrapBoolValue(nil))
		assert.Nil(t, wrapBoolValue(nil))
	})

	t.Run("true round-trips", func(t *testing.T) {
		b := unwrapBoolValue(wrapperspb.Bool(true))
		require.NotNil(t, b)
		assert.True(t, *b)

		w := wrapBoolValue(b)
		require.NotNil(t, w)
		assert.True(t, w.GetValue())
	})

	t.Run("false round-trips", func(t *testing.T) {
		b := unwrapBoolValue(wrapperspb.Bool(false))
		require.NotNil(t, b)
		assert.False(t, *b)

		w := wrapBoolValue(b)
		require.NotNil(t, w)
		assert.False(t, w.GetValue())
	})
}

func TestHashBundle(t *testing.T) {
	specs1 := []*entity.AutoscalePolicySpec{
		{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_ENABLED,
		},
	}
	specs2 := []*entity.AutoscalePolicySpec{
		{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_DISABLED,
		},
	}

	t.Run("same input same hash", func(t *testing.T) {
		// Two independent calls with the same input must produce the
		// same hash (determinism is a correctness requirement — see
		// design doc §Bundle hash for SYNC KEYS_ONLY change detection).
		got1 := hashBundle(specs1)
		got2 := hashBundle(specs1)
		assert.Equal(t, got1, got2)
	})

	t.Run("different input different hash", func(t *testing.T) {
		assert.NotEqual(t, hashBundle(specs1), hashBundle(specs2))
	})

	t.Run("empty input produces hash", func(t *testing.T) {
		h := hashBundle(nil)
		assert.NotEmpty(t, h)
		assert.Len(t, h, 32)
	})

	t.Run("hash is deterministic regardless of input order", func(t *testing.T) {
		a := []*entity.AutoscalePolicySpec{
			{TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV, Direction: apiv1.AutoscalePolicy_SCALE_OUT, Capability: apiv1.AutoscalePolicy_NODE_COUNT, Trigger: apiv1.AutoscalePolicy_CPU, State: apiv1.AutoscalePolicy_ENABLED},
			{TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB, Direction: apiv1.AutoscalePolicy_SCALE_IN, Capability: apiv1.AutoscalePolicy_NODE_COUNT, Trigger: apiv1.AutoscalePolicy_RCU, State: apiv1.AutoscalePolicy_ENABLED},
		}
		b := []*entity.AutoscalePolicySpec{
			{TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB, Direction: apiv1.AutoscalePolicy_SCALE_IN, Capability: apiv1.AutoscalePolicy_NODE_COUNT, Trigger: apiv1.AutoscalePolicy_RCU, State: apiv1.AutoscalePolicy_ENABLED},
			{TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV, Direction: apiv1.AutoscalePolicy_SCALE_OUT, Capability: apiv1.AutoscalePolicy_NODE_COUNT, Trigger: apiv1.AutoscalePolicy_CPU, State: apiv1.AutoscalePolicy_ENABLED},
		}
		assert.Equal(t, hashBundle(a), hashBundle(b))
	})
}

func TestDiffSpecs(t *testing.T) {
	makeSpec := func(ct apiv1.AutoscaleTargetType, state apiv1.AutoscalePolicy_State) *entity.AutoscalePolicySpec {
		return &entity.AutoscalePolicySpec{
			TargetType: ct,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      state,
		}
	}

	t.Run("no change produces empty diff", func(t *testing.T) {
		base := []*entity.AutoscalePolicySpec{makeSpec(apiv1.AutoscaleTargetType_TARGET_TIKV, apiv1.AutoscalePolicy_ENABLED)}
		proposed := []*entity.AutoscalePolicySpec{makeSpec(apiv1.AutoscaleTargetType_TARGET_TIKV, apiv1.AutoscalePolicy_ENABLED)}
		diffs := diffSpecs(base, proposed)
		assert.Empty(t, diffs)
	})

	t.Run("state change produces one diff", func(t *testing.T) {
		base := []*entity.AutoscalePolicySpec{makeSpec(apiv1.AutoscaleTargetType_TARGET_TIKV, apiv1.AutoscalePolicy_ENABLED)}
		proposed := []*entity.AutoscalePolicySpec{makeSpec(apiv1.AutoscaleTargetType_TARGET_TIKV, apiv1.AutoscalePolicy_DISABLED)}
		diffs := diffSpecs(base, proposed)
		require.Len(t, diffs, 1)
		assert.Equal(t, "state", diffs[0].GetFieldPath())
	})

	t.Run("added key produces diff with nil old value", func(t *testing.T) {
		base := []*entity.AutoscalePolicySpec{makeSpec(apiv1.AutoscaleTargetType_TARGET_TIKV, apiv1.AutoscalePolicy_ENABLED)}
		proposed := []*entity.AutoscalePolicySpec{
			makeSpec(apiv1.AutoscaleTargetType_TARGET_TIKV, apiv1.AutoscalePolicy_ENABLED),
			makeSpec(apiv1.AutoscaleTargetType_TARGET_TIDB, apiv1.AutoscalePolicy_ENABLED),
		}
		diffs := diffSpecs(base, proposed)
		require.Len(t, diffs, 1)
		assert.Nil(t, diffs[0].GetOldValue())
	})
}

// ---------------------------------------------------------------------------
// Repo-based tests (read paths)
// ---------------------------------------------------------------------------

func TestGetAutoscalePolicy_Server(t *testing.T) {
	ctx := context.Background()

	t.Run("returns empty bundle when row is absent", func(t *testing.T) {
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		s := newTestServerBundle(mockBundle, nil)

		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1").
			Return(nil, nil).Once()

		bundle, err := s.GetAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1")
		require.NoError(t, err)
		require.NotNil(t, bundle)
		assert.Equal(t, apiv1.AutoscalePolicyScope_SCOPE_TENANT, bundle.Scope)
		assert.Equal(t, "tenant-1", bundle.SubjectId)
		assert.Nil(t, bundle.Specs)
	})

	t.Run("returns bundle when row exists", func(t *testing.T) {
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		s := newTestServerBundle(mockBundle, nil)

		specs := []*entity.AutoscalePolicySpec{
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_ENABLED,
			},
		}
		row := &repo.AutoscalePolicyBundleRow{
			Scope:      apiv1.AutoscalePolicyScope_SCOPE_TENANT,
			SubjectID:  "tenant-1",
			Specs:      specs,
			BundleHash: "abc123",
		}
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1").
			Return(row, nil).Once()

		bundle, err := s.GetAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1")
		require.NoError(t, err)
		assert.Equal(t, row.BundleHash, bundle.BundleHash)
		require.Len(t, bundle.Specs, 1)
		assert.Equal(t, apiv1.AutoscalePolicy_ENABLED, bundle.Specs[0].GetState())
	})

	t.Run("repo error propagates", func(t *testing.T) {
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		s := newTestServerBundle(mockBundle, nil)

		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1").
			Return(nil, errors.New("db down")).Once()

		_, err := s.GetAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1")
		require.Error(t, err)
		assert.Equal(t, codes.Internal, status.Code(err))
	})
}

func TestGetEffectiveAutoscalePolicy_Server(t *testing.T) {
	ctx := context.Background()

	t.Run("resolves DEFAULT → TENANT → SHARED_POOL chain", func(t *testing.T) {
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		mockRC := repomnocks.NewRuConfigRepo(t)
		mockCluster := repomnocks.NewTiDBClusterRepo(t)
		s := &serverImpl{
			autoscalePolicyBundleRepo: mockBundle,
			ruConfigRepo:              mockRC,
			tidbClusterRepo:           mockCluster,
		}
		// Direct-read HPA path: empty HPA list (new pool, no HPAs yet).
		installHPARig(t, s, "sp-1", nil)

		defaultRow := &repo.AutoscalePolicyBundleRow{
			Scope:     apiv1.AutoscalePolicyScope_SCOPE_DEFAULT,
			SubjectID: defaultAutoscalePolicySubjectID,
			Specs: []*entity.AutoscalePolicySpec{{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_DISABLED,
			}},
		}
		tenantRow := &repo.AutoscalePolicyBundleRow{
			Scope:     apiv1.AutoscalePolicyScope_SCOPE_TENANT,
			SubjectID: "tenant-1",
			Specs: []*entity.AutoscalePolicySpec{{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_ENABLED,
			}},
		}

		mockCluster.On("List", ctx, mock.MatchedBy(func(f *repo.TiDBClusterListFilter) bool {
			return f != nil && len(f.SharedPoolIds) == 1 && f.SharedPoolIds[0] == "sp-1"
		})).Return([]*entity.TiDBCluster{{
			Id: "cluster-1", TenantId: "tenant-1", SharedPoolId: "sp-1",
		}}, nil).Once()

		// DEFAULT and TENANT still use GetBundle
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, defaultAutoscalePolicySubjectID).
			Return(defaultRow, nil).Once()
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1").
			Return(tenantRow, nil).Once()
		// SHARED_POOL reads runtime: HPA direct-read (empty) + absent
		// ru_config row (GetRuConfig returns (nil, nil) when absent —
		// it never returns an error for a missing row).
		mockRC.On("GetRuConfig", ctx, "sp-1", apiv1.RuConfig_SHARED_POOL).
			Return(nil, nil).Once()

		bundle, err := s.GetEffectiveAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-1")
		require.NoError(t, err)
		require.NotNil(t, bundle)
		// TENANT ENABLED overrides DEFAULT DISABLED.
		require.Len(t, bundle.Specs, 1)
		assert.Equal(t, apiv1.AutoscalePolicy_ENABLED, bundle.Specs[0].GetState())
	})

	t.Run("SHARED_POOL overrides TENANT (tenant id != shared pool id)", func(t *testing.T) {
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		mockCluster := repomnocks.NewTiDBClusterRepo(t)
		mockRC := repomnocks.NewRuConfigRepo(t)
		s := &serverImpl{
			autoscalePolicyBundleRepo: mockBundle,
			ruConfigRepo:              mockRC,
			tidbClusterRepo:           mockCluster,
		}

		// Tenant id is "tenant-1" but shared pool id is "sp-1" —
		// the test must prove the chain reads TENANT/tenant-1
		// (not TENANT/sp-1) by mocking the cluster list.
		// SHARED_POOL reads runtime: HPA direct-read (empty) + ru_config.
		// Direct-read HPA path: empty HPA list.
		installHPARig(t, s, "sp-1", nil)
		tenantRow := &repo.AutoscalePolicyBundleRow{
			Scope:     apiv1.AutoscalePolicyScope_SCOPE_TENANT,
			SubjectID: "tenant-1",
			Specs: []*entity.AutoscalePolicySpec{
				{
					TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
					Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
					Capability: apiv1.AutoscalePolicy_NODE_COUNT,
					Trigger:    apiv1.AutoscalePolicy_CPU,
					State:      apiv1.AutoscalePolicy_ENABLED,
				},
			},
		}
		mockCluster.On("List", ctx, mock.MatchedBy(func(f *repo.TiDBClusterListFilter) bool {
			return f != nil && len(f.SharedPoolIds) == 1 && f.SharedPoolIds[0] == "sp-1"
		})).Return([]*entity.TiDBCluster{{
			Id: "cluster-1", TenantId: "tenant-1", SharedPoolId: "sp-1",
		}}, nil).Once()

		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, defaultAutoscalePolicySubjectID).
			Return(nil, nil).Once()
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1").
			Return(tenantRow, nil).Once()
		// SHARED_POOL reads runtime: HPA direct-read (empty) + ru_config with DISABLED TIKV CPU.
		disabled := false
		mockRC.On("GetRuConfig", ctx, "sp-1", apiv1.RuConfig_SHARED_POOL).Return(&entity.RuConfig{
			RuConfig: apiv1.RuConfig{FeatureFlags: &apiv1.RuConfig_FeatureFlags{
				EnableAutoscaleTikvCpuScaleOut: &disabled,
			}},
		}, nil).Once()

		bundle, err := s.GetEffectiveAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-1")
		require.NoError(t, err)
		// Partial FeatureFlags (only TIKV CPU set) → 1 ENABLED/DISABLED
		// row + 8 DISABLED fallback rows (TIKV × 3 remaining + SHARED_POOL
		// × 2 + TIDB_GROUP × 2 + TIPROXY SCALE_IN). DEFAULT contributes 0 rows (empty),
		// TENANT contributes 1 TIKV CPU ENABLED row.
		// Effective: TIKV CPU from SHARED_POOL (DISABLED) overrides
		// TENANT (ENABLED) — 1 row that wins + 8 DISABLED rows = 9.
		require.Len(t, bundle.Specs, 9)
		// SHARED_POOL disabled should win over TENANT enabled.
		var tikvCPU *entity.AutoscalePolicySpec
		for _, s := range bundle.Specs {
			if s.GetTargetType() == apiv1.AutoscaleTargetType_TARGET_TIKV &&
				s.GetCapability() == apiv1.AutoscalePolicy_NODE_COUNT &&
				s.GetTrigger() == apiv1.AutoscalePolicy_CPU {
				tikvCPU = s
				break
			}
		}
		require.NotNil(t, tikvCPU, "TIKV CPU row must be present in the chain")
		assert.Equal(t, apiv1.AutoscalePolicy_DISABLED, tikvCPU.GetState(),
			"SHARED_POOL disabled should win over TENANT enabled")
	})

	t.Run("TENANT scope does not read any shared-pool row", func(t *testing.T) {
		// Pins the SCOPE_TENANT branch of the inheritance chain:
		// it must read DEFAULT and TENANT only, never SHARED_POOL.
		// Also pins: TENANT scope is unaffected by shared_pool_id
		// disambiguation (it doesn't go through tenantIDForSharedPool).
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		mockCluster := repomnocks.NewTiDBClusterRepo(t)
		s := newTestServerBundleWithCluster(mockBundle, nil, mockCluster)

		defaultRow := &repo.AutoscalePolicyBundleRow{
			Scope:     apiv1.AutoscalePolicyScope_SCOPE_DEFAULT,
			SubjectID: defaultAutoscalePolicySubjectID,
			Specs: []*entity.AutoscalePolicySpec{
				{
					TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
					Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
					Capability: apiv1.AutoscalePolicy_NODE_COUNT,
					Trigger:    apiv1.AutoscalePolicy_CPU,
					State:      apiv1.AutoscalePolicy_DISABLED,
				},
			},
		}
		tenantRow := &repo.AutoscalePolicyBundleRow{
			Scope:     apiv1.AutoscalePolicyScope_SCOPE_TENANT,
			SubjectID: "tenant-1",
			Specs: []*entity.AutoscalePolicySpec{
				{
					TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
					Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
					Capability: apiv1.AutoscalePolicy_NODE_COUNT,
					Trigger:    apiv1.AutoscalePolicy_CPU,
					State:      apiv1.AutoscalePolicy_ENABLED,
				},
			},
		}

		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, defaultAutoscalePolicySubjectID).
			Return(defaultRow, nil).Once()
		mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1").
			Return(tenantRow, nil).Once()
		// No SHARED_POOL expectation — if the test framework allowed
		// an un-expected call, it would fail.

		// mockCluster is unused on the TENANT path; testify will
		// fail if anything calls it.
		bundle, err := s.GetEffectiveAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1")
		require.NoError(t, err)
		require.Len(t, bundle.Specs, 1)
		assert.Equal(t, apiv1.AutoscalePolicy_ENABLED, bundle.Specs[0].GetState())
	})
}

// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Test plan §1 — Metadata storage: read SHARED_POOL from HPA + ru_config
// ---------------------------------------------------------------------------

func TestReadSharedPoolPolicy_FromHPAAndRuConfig(t *testing.T) {
	ctx := context.Background()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	mockCluster := repomnocks.NewTiDBClusterRepo(t)
	srv := &serverImpl{
		autoscalePolicyBundleRepo: mockBundle,
		ruConfigRepo:              mockRC,
		tidbClusterRepo:           mockCluster,
	}
	// Direct-read HPA path: namespace has a TIDB_WORKER HPA with
	// SCALE_IN ENABLED → produces a TIDB_WORKER SCALE_IN ENABLED row.
	installHPARig(t, srv, "sp-1", []hpaFixture{
		{ComponentLabel: entity.TiDBWorkerType, Behavior: hpaBehaviorEnabled()},
	})

	// DEFAULT contributes TIKV SCALE_OUT CPU DISABLED
	mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, defaultAutoscalePolicySubjectID).
		Return(&repo.AutoscalePolicyBundleRow{Specs: []*entity.AutoscalePolicySpec{
			{TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV, Direction: apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT, Trigger: apiv1.AutoscalePolicy_CPU, State: apiv1.AutoscalePolicy_DISABLED},
		}}, nil).Once()

	// No tenant bundle
	mockCluster.On("List", ctx, mock.Anything).Return([]*entity.TiDBCluster{{
		Id: "c1", TenantId: "t1", SharedPoolId: "sp-1",
	}}, nil).Once()
	mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "t1").
		Return(nil, nil).Once()

	// SHARED_POOL runtime: TIKV SCALE_OUT from ru_config FeatureFlags.
	enabled := true
	mockRC.On("GetRuConfig", ctx, "sp-1", apiv1.RuConfig_SHARED_POOL).Return(&entity.RuConfig{
		RuConfig: apiv1.RuConfig{FeatureFlags: &apiv1.RuConfig_FeatureFlags{
			EnableAutoscaleTikvCpuScaleOut: &enabled,
		}},
	}, nil).Once()

	bundle, err := srv.GetEffectiveAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-1")
	require.NoError(t, err)
	require.NotNil(t, bundle)
	// DEFAULT TIKV=DISABLED overridden by runtime TIKV=ENABLED +
	// TIDB_WORKER SCALE_IN + SCALE_OUT (workers emit both directions).
	// Partial FeatureFlags fixture: EnableAutoscaleTikvCpuScaleOut=true,
	// all 5 other flags nil. New featureFlagsToSpecs emits 9 fallback
	// DISABLED specs (TIKV × 3 remaining axes + SHARED_POOL × 2 +
	// TIDB_GROUP × 2 + TIPROXY SCALE_IN) on top of the 1 ENABLED TIKV CPU row. With chain
	// resolution on top of DEFAULT (TIKV CPU DISABLED), the effective
	// bundle is: 1 ENABLED TIKV CPU + 8 DISABLED fallback rows + 2
	// TIDB_WORKER rows = 11.
	require.Len(t, bundle.Specs, 11)
	hasTikvCPU := false
	hasTidbWorkerScaleIn := false
	hasTidbWorkerScaleOut := false
	for _, s := range bundle.Specs {
		if s.GetTargetType() == apiv1.AutoscaleTargetType_TARGET_TIKV && s.GetState() == apiv1.AutoscalePolicy_ENABLED &&
			s.GetDirection() == apiv1.AutoscalePolicy_SCALE_OUT {
			hasTikvCPU = true
		}
		if s.GetTargetType() == apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER {
			switch s.GetDirection() {
			case apiv1.AutoscalePolicy_SCALE_IN:
				if s.GetState() == apiv1.AutoscalePolicy_ENABLED {
					hasTidbWorkerScaleIn = true
				}
			case apiv1.AutoscalePolicy_SCALE_OUT:
				if s.GetState() == apiv1.AutoscalePolicy_ENABLED {
					hasTidbWorkerScaleOut = true
				}
			}
		}
	}
	assert.True(t, hasTikvCPU, "runtime ru_config TIKV CPU ENABLED must override DEFAULT DISABLED")
	assert.True(t, hasTidbWorkerScaleIn, "runtime HPA TIDB_WORKER SCALE_IN must be included")
	assert.True(t, hasTidbWorkerScaleOut, "runtime HPA TIDB_WORKER SCALE_OUT must now be included")
}

// ---------------------------------------------------------------------------
// Test plan §1 — Metadata storage: read CLUSTER from HPA (TIDB/TIPROXY only)
// ---------------------------------------------------------------------------

func TestReadClusterPolicy_FromHPA(t *testing.T) {
	ctx := context.Background()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	tgStub := &tidbGroupRepoStub{tg: &entity.TiDBGroup{
		TiDBGroup: apiv1.TiDBGroup{Id: "grp-1", ClusterId: "cluster-1", SharedPoolId: "sp-1", TenantId: "t1"},
	}}

	srv := &serverImpl{
		autoscalePolicyBundleRepo: mockBundle,
		tidbGroupRepo:             tgStub,
	}
	// Direct-read HPA path: namespace has TIDB + TIPROXY + TIDB_WORKER HPAs.
	// TIDB_GROUP scope filters workers out. TIDB and TIPROXY both emit
	// SCALE_IN rows from hpaToSpecs (per-group components CLUSTER
	// scope governs).
	//
	// Component identity model (matches what generatedTiDBGroup /
	// generatedTiProxyGroup write in production):
	//   TIDB HPA    → `db-<tidbGroupID>`   (= grpLabel)
	//   TIPROXY HPA → `db-<tidbClusterID>` (= clusterLabel)
	grpLabel := entity.GetComponentGroupName(&entity.Component{
		Id:            "grp-1",
		ComponentType: apiv1.ComponentType_TIDB,
	})
	clusterLabel := entity.GetComponentGroupName(&entity.Component{
		Id:            "cluster-1",
		ComponentType: apiv1.ComponentType_TIPROXY,
	})
	installHPARig(t, srv, "sp-1", []hpaFixture{
		{ComponentLabel: "tidb", GroupLabel: grpLabel, Behavior: hpaBehaviorEnabled()},
		{ComponentLabel: "tiproxy", GroupLabel: clusterLabel, Behavior: hpaBehaviorEnabled()},
		{ComponentLabel: entity.TiDBWorkerType, Behavior: hpaBehaviorEnabled()},
	})

	// DEFAULT empty
	mockBundle.On("GetBundle", mock.Anything, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, "__default__").
		Return(nil, nil).Once()

	// No tenant bundle
	mockBundle.On("GetBundle", mock.Anything, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "t1").
		Return(nil, nil).Once()

	bundle, err := srv.GetEffectiveAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP, "grp-1")
	require.NoError(t, err)
	require.NotNil(t, bundle)
	// TIDB + TIPROXY (HPA-emitted at TIDB_GROUP scope). TIDB_GROUP-scope
	// filter still pins "no workers at TIDB_GROUP scope".
	require.GreaterOrEqual(t, len(bundle.Specs), 2)
	seen := map[apiv1.AutoscaleTargetType]bool{}
	for _, s := range bundle.Specs {
		ct := s.GetTargetType()
		assert.NotEqual(t, apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER, ct,
			"TIDB_GROUP scope must filter out TIDB_WORKER, got %s", ct.String())
		seen[ct] = true
	}
	assert.True(t, seen[apiv1.AutoscaleTargetType_TARGET_TIDB], "TIDB_GROUP scope must include TIDB (HPA-emitted)")
	assert.True(t, seen[apiv1.AutoscaleTargetType_TARGET_TIPROXY], "TIDB_GROUP scope must include TIPROXY (HPA-emitted)")
}

type tidbGroupRepoStub struct {
	tg *entity.TiDBGroup
}

func (s *tidbGroupRepoStub) Save(_ context.Context, _ *entity.TiDBGroup) error { return nil }
func (s *tidbGroupRepoStub) Load(_ context.Context, _ string, _ bool) (*entity.TiDBGroup, error) {
	return s.tg, nil
}
func (s *tidbGroupRepoStub) List(_ context.Context, _, _, _, _ []string, _ bool) ([]*entity.TiDBGroup, error) {
	return nil, nil
}
func (s *tidbGroupRepoStub) Delete(_ context.Context, _ string) error { return nil }

// ---------------------------------------------------------------------------
// Test plan §2 — Rule merge: new shared pool uses DEFAULT→TENANT chain
// ---------------------------------------------------------------------------

func TestRuleMerge_NewSharedPool_ChainResolution(t *testing.T) {
	ctx := context.Background()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	mockCluster := repomnocks.NewTiDBClusterRepo(t)
	srv := &serverImpl{
		autoscalePolicyBundleRepo: mockBundle,
		ruConfigRepo:              mockRC,
		tidbClusterRepo:           mockCluster,
	}
	// Direct-read HPA path: empty (new pool).
	installHPARig(t, srv, "new-sp", nil)

	// DEFAULT: TIKV CPU ENABLED
	mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, "__default__").
		Return(&repo.AutoscalePolicyBundleRow{Specs: []*entity.AutoscalePolicySpec{
			{TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV, Direction: apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT, Trigger: apiv1.AutoscalePolicy_CPU, State: apiv1.AutoscalePolicy_ENABLED},
		}}, nil).Once()

	// Tenant lookup (no tenant bundle — tenant policy not set)
	mockCluster.On("List", ctx, mock.Anything).Return([]*entity.TiDBCluster{{
		Id: "c1", TenantId: "t1", SharedPoolId: "new-sp",
	}}, nil).Once()
	mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "t1").
		Return(nil, nil).Once()

	// SHARED_POOL runtime: HPA direct-read (empty) + absent ru_config
	// row (GetRuConfig returns (nil, nil) for a missing row, not an error).
	mockRC.On("GetRuConfig", ctx, "new-sp", apiv1.RuConfig_SHARED_POOL).
		Return(nil, nil).Once()

	bundle, err := srv.GetEffectiveAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "new-sp")
	require.NoError(t, err)
	require.Len(t, bundle.Specs, 1)
	// DEFAULT's TIKV CPU ENABLED flows through to new pool
	assert.Equal(t, apiv1.AutoscalePolicy_ENABLED, bundle.Specs[0].GetState())
}

// ---------------------------------------------------------------------------
// Test plan §4 — RCU flags independent of HPA
// ---------------------------------------------------------------------------

func TestRCUFlag_IndependentOfHPA(t *testing.T) {
	ctx := context.Background()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	mockCluster := repomnocks.NewTiDBClusterRepo(t)
	srv := &serverImpl{
		autoscalePolicyBundleRepo: mockBundle,
		ruConfigRepo:              mockRC,
		tidbClusterRepo:           mockCluster,
	}
	// Direct-read HPA path: empty namespace (RCU flags are independent of HPA).
	installHPARig(t, srv, "sp-rcu", nil)

	// DEFAULT empty
	mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, "__default__").
		Return(nil, nil).Once()
	mockCluster.On("List", ctx, mock.Anything).Return([]*entity.TiDBCluster{{
		Id: "c1", TenantId: "t1", SharedPoolId: "sp-rcu",
	}}, nil).Once()
	mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "t1").
		Return(nil, nil).Once()

	// SHARED_POOL runtime: ALL 5 original flags ON (3 TIKV + 2 RCU) plus
	// nil TIPROXY SCALE_IN → DISABLED. Total: 4 TIKV + 4 RCU + 1 TIPROXY = 9 specs.
	enabled := true
	mockRC.On("GetRuConfig", ctx, "sp-rcu", apiv1.RuConfig_SHARED_POOL).Return(&entity.RuConfig{
		RuConfig: apiv1.RuConfig{FeatureFlags: &apiv1.RuConfig_FeatureFlags{
			EnableAutoscaleTikvCpuScaleOut:      &enabled,
			EnableAutoscaleDiskExpand:           &enabled,
			EnableAutoscaleIopsThroughputExpand: &enabled,
			EnableScaleSharedPoolByRu:           &enabled, // → SHARED_POOL SCALE_OUT + SCALE_IN
			EnableScaleTidbGroupByRu:            &enabled, // → TIDB_GROUP SCALE_OUT + SCALE_IN
		}},
	}, nil).Once()

	bundle, err := srv.GetEffectiveAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-rcu")
	require.NoError(t, err)
	require.NotNil(t, bundle)
	require.Len(t, bundle.Specs, 9, "4 TIKV specs + 2 RCU flags × 2 directions + 1 TIPROXY SCALE_IN = 9 specs")
	// Verify RCU flags produce SHARED_POOL and TIDB_GROUP virtual-component specs.
	rcuSpecs := make(map[string]*entity.AutoscalePolicySpec)
	for _, s := range bundle.Specs {
		key := fmt.Sprintf("%s/%s/%s/%s", s.GetTargetType(), s.GetDirection(), s.GetCapability(), s.GetTrigger())
		rcuSpecs[key] = s
	}
	assert.Contains(t, rcuSpecs, "SHARED_POOL/SCALE_OUT/NODE_SPEC/RCU", "RCU flag → SHARED_POOL SCALE_OUT")
	assert.Contains(t, rcuSpecs, "SHARED_POOL/SCALE_IN/NODE_SPEC/RCU", "RCU flag → SHARED_POOL SCALE_IN")
	assert.Contains(t, rcuSpecs, "TIDB_GROUP/SCALE_OUT/NODE_SPEC/RCU", "RCU flag → TIDB_GROUP SCALE_OUT")
	assert.Contains(t, rcuSpecs, "TIDB_GROUP/SCALE_IN/NODE_SPEC/RCU", "RCU flag → TIDB_GROUP SCALE_IN")
}

// ---------------------------------------------------------------------------
// Test plan §5 — Compatibility: SetHPAScaleBehavior and SetRuConfig still work
// ---------------------------------------------------------------------------

func TestCompatibility_SetHPAScaleBehavior_Unchanged(t *testing.T) {
	// SetHPAScaleBehavior is a separate gRPC handler that writes HPA
	// behavior directly. The policy system should not interfere with it.
	// This test just verifies the handler entry point accepts valid input.
	require.NoError(t, validateImplementedPolicyScope(apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL))
	require.NoError(t, validateImplementedPolicyScope(apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP))
}

// ---------------------------------------------------------------------------
// Test plan §2 — Rule merge: new cluster uses DEFAULT→TENANT→CLUSTER chain
// ---------------------------------------------------------------------------

func TestRuleMerge_NewCluster_ChainResolution(t *testing.T) {
	ctx := context.Background()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	tgStub := &tidbGroupRepoStub{tg: &entity.TiDBGroup{
		TiDBGroup: apiv1.TiDBGroup{Id: "grp-new", SharedPoolId: "sp-new", TenantId: "t1"},
	}}
	srv := &serverImpl{
		autoscalePolicyBundleRepo: mockBundle,
		tidbGroupRepo:             tgStub,
	}
	// Direct-read HPA path: namespace has TIDB + TIDB_WORKER HPAs.
	// TIDB_GROUP scope filters to TIDB only (TIDB_WORKER is excluded).
	// TIDB carries the group's LabelPingcapGroupKey — required for
	// the TIDB_GROUP-scope selector (see listSharedPoolHPASpecs).
	grpLabel := entity.GetComponentGroupName(&entity.Component{
		Id:            "grp-new",
		ComponentType: apiv1.ComponentType_TIDB,
	})
	installHPARig(t, srv, "sp-new", []hpaFixture{
		{ComponentLabel: "tidb", GroupLabel: grpLabel, Behavior: hpaBehaviorEnabled()},
		{ComponentLabel: entity.TiDBWorkerType, Behavior: hpaBehaviorEnabled()},
	})
	mockBundle.On("GetBundle", mock.Anything, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, defaultAutoscalePolicySubjectID).
		Return(&repo.AutoscalePolicyBundleRow{Specs: []*entity.AutoscalePolicySpec{
			{TargetType: apiv1.AutoscaleTargetType_TARGET_TIPROXY, Direction: apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT, Trigger: apiv1.AutoscalePolicy_CPU, State: apiv1.AutoscalePolicy_ENABLED},
		}}, nil).Once()
	mockBundle.On("GetBundle", mock.Anything, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "t1").Return(nil, nil).Once()
	bundle, err := srv.GetEffectiveAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP, "grp-new")
	require.NoError(t, err)
	// 3 specs: TIDB from HPA emits 2 (SCALE_OUT + SCALE_IN per
	// hpaToSpecs's per-group component rule) + TIPROXY from DEFAULT.
	// TIDB_WORKER is excluded (per-group components only at CLUSTER
	// scope). The hpaToSpecs change is documented at
	// domain/server/autoscale_policy_runtime_specs.go:283-334.
	require.Len(t, bundle.Specs, 3)
	seen := map[apiv1.AutoscaleTargetType]int{}
	for _, s := range bundle.Specs {
		seen[s.GetTargetType()]++
	}
	assert.Equal(t, 2, seen[apiv1.AutoscaleTargetType_TARGET_TIDB], "TIDB from HPA emits SCALE_OUT + SCALE_IN rows")
	assert.Equal(t, 1, seen[apiv1.AutoscaleTargetType_TARGET_TIPROXY], "TIPROXY from DEFAULT")
	assert.Equal(t, 0, seen[apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER], "TIDB_WORKER is filtered out at TIDB_GROUP scope (per-group components only)")
}

// ---------------------------------------------------------------------------
// Test plan §1/§5 — Existing sharedpool not affected by DEFAULT write
// ---------------------------------------------------------------------------

func TestNewSharedPool_DefaultWrite_DoesNotTouchExisting(t *testing.T) {
	ctx := context.Background()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	mockCluster := repomnocks.NewTiDBClusterRepo(t)
	srv := &serverImpl{
		autoscalePolicyBundleRepo: mockBundle,
		ruConfigRepo:              mockRC,
		tidbClusterRepo:           mockCluster,
	}
	// Direct-read HPA path: existing pool has a TIDB_WORKER HPA
	// with SCALE_IN DISABLED — this is the operator-set runtime
	// state for the existing pool that the DEFAULT write must
	// not affect.
	installHPARig(t, srv, "sp-existing", []hpaFixture{
		{ComponentLabel: entity.TiDBWorkerType, Behavior: hpaBehaviorScaleInDisabled()},
	})
	mockCluster.On("List", mock.Anything, mock.Anything).Return([]*entity.TiDBCluster{{
		Id: "c-existing", TenantId: "t1", SharedPoolId: "sp-existing",
	}}, nil).Once()
	mockRC.On("GetRuConfig", mock.Anything, "sp-existing", apiv1.RuConfig_SHARED_POOL).
		Return(nil, nil).Once()
	mockBundle.On("GetBundle", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
	bundle, err := srv.GetEffectiveAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-existing")
	require.NoError(t, err)
	require.Len(t, bundle.Specs, 2, "worker emits SCALE_OUT + SCALE_IN")
	assert.Equal(t, apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER, bundle.Specs[0].GetTargetType())
	// With workers now emitting both directions, SCALE_OUT comes first
	// (ENABLED), SCALE_IN second (DISABLED). Check the SCALE_IN row.
	foundScaleIn := false
	for _, s := range bundle.Specs {
		assert.Equal(t, apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER, s.GetTargetType())
		if s.GetDirection() == apiv1.AutoscalePolicy_SCALE_IN {
			assert.Equal(t, apiv1.AutoscalePolicy_DISABLED, s.GetState(),
				"existing sharedpool HPA state must not be affected by DEFAULT write")
			foundScaleIn = true
		}
	}
	assert.True(t, foundScaleIn, "must find TIDB_WORKER SCALE_IN row")
}

// ---------------------------------------------------------------------------
// Test plan §4 — RCU flag DEFAULT scope via SetRuConfig
// ---------------------------------------------------------------------------

func TestSetRuConfig_RCUFlag_DefaultTenantScope(t *testing.T) {
	enabled := true
	rc := &entity.RuConfig{
		RuConfig: apiv1.RuConfig{
			FeatureFlags: &apiv1.RuConfig_FeatureFlags{
				EnableScaleTidbGroupByRu:  &enabled,
				EnableScaleSharedPoolByRu: &enabled,
			},
		},
	}
	require.True(t, *rc.FeatureFlags.EnableScaleTidbGroupByRu)
	require.True(t, *rc.FeatureFlags.EnableScaleSharedPoolByRu)
}

// ---------------------------------------------------------------------------
// Test plan §6 — Region guard blocks scale-in
// ---------------------------------------------------------------------------

func TestRegionGuard_ForbidScaleIn_Active(t *testing.T) {
	ctx := context.Background()
	mockGuard := repomnocks.NewAutoscaleRuntimeGuardRepo(t)
	mockGuard.On("ListGuards", mock.Anything, repo.AutoscaleRuntimeGuardFilter{
		ScopeType:     apiv1.AutoscaleRuntimeGuard_SCOPE_TYPE_UNSPECIFIED,
		GuardType:     apiv1.AutoscaleRuntimeGuard_FORBID_SCALE_IN,
		ComponentType: apiv1.ComponentType_COMPONENT_TYPE_UNSPECIFIED,
		ActiveOnly:    true,
	}).Return([]*entity.AutoscaleRuntimeGuard{{
		ScopeType: apiv1.AutoscaleRuntimeGuard_REGION, ScopeId: "us-west-2",
		GuardType: apiv1.AutoscaleRuntimeGuard_FORBID_SCALE_IN,
	}}, nil).Once()
	guards, err := mockGuard.ListGuards(ctx, repo.AutoscaleRuntimeGuardFilter{
		GuardType:  apiv1.AutoscaleRuntimeGuard_FORBID_SCALE_IN,
		ActiveOnly: true,
	})
	require.NoError(t, err)
	require.Len(t, guards, 1, "FORBID_SCALE_IN guard must be active")
	assert.Equal(t, apiv1.AutoscaleRuntimeGuard_REGION, guards[0].GetScopeType())
}

// ---------------------------------------------------------------------------
// Test plan §5 — SetHPAScaleBehavior not overridden by reconcile
// ---------------------------------------------------------------------------

func TestWriteClusterHPABehavior_ExplicitWriteWritesThroughPriorValue(t *testing.T) {
	ctx := context.Background()
	const (
		sharedPoolID = "sp-hpa-explicit"
		groupID      = "grp-hpa-explicit"
		namespace    = "tidb" + sharedPoolID
		hpaName      = "tidbgroup-db-hpa-explicit"
	)

	scheme := runtime.NewScheme()
	require.NoError(t, autoscalingv2.AddToScheme(scheme))
	maxChange := autoscalingv2.MaxChangePolicySelect
	hpa := &autoscalingv2.HorizontalPodAutoscaler{
		ObjectMeta: metav1.ObjectMeta{
			Name:      hpaName,
			Namespace: namespace,
			Labels: map[string]string{
				schemav1.LabelPingcapGroupKey: entity.GetComponentGroupName(&entity.Component{
					Id: groupID, ComponentType: apiv1.ComponentType_TIDB,
				}),
				schemav1.LabelComponentKey: "tidb",
			},
		},
		Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
			Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
				ScaleDown: &autoscalingv2.HPAScalingRules{SelectPolicy: &maxChange},
			},
		},
	}
	kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(hpa).Build()

	sharedPoolRepo := repomnocks.NewSharedPoolRepo(t)
	dataplaneManager := dataplanemocks.NewManager(t)
	sharedPoolRepo.On("Load", ctx, sharedPoolID, false).Return(&entity.SharedPool{
		SharedPool: apiv1.SharedPool{Id: sharedPoolID, DataplaneName: "dp-" + sharedPoolID},
	}, nil).Twice()
	dataplaneManager.On("GetClient", "dp-"+sharedPoolID).Return(kubeClient, nil).Twice()

	srv := &serverImpl{
		tidbGroupRepo:    &tidbGroupRepoStub{tg: &entity.TiDBGroup{TiDBGroup: apiv1.TiDBGroup{Id: groupID, SharedPoolId: sharedPoolID}}},
		sharedPoolRepo:   sharedPoolRepo,
		dataplaneManager: dataplaneManager,
	}

	// Both SetHPAScaleBehavior and applyPolicyPatchToTargets are
	// explicit WRITE paths (per design §CreateCluster item 4:
	// reconcile-only-if-nil applies to reconcileExistingHPA in
	// apply_hpa.go, NOT to SetHPAScaleBehavior / writeTidbGroupHPABehavior).
	// The HPA fixture starts with explicit MaxChange; the bundle apply
	// request asks for TIDB SCALE_IN ENABLED, which resolves to the TIDB
	// baseline of MinChange. Per Option C (preserve design-literal
	// behavior), the explicit write path writes through, leaving the
	// HPA at MinChange after the patch cycle. The reconcile-only-if-nil
	// guard is tested separately by TestReconcileExistingHPA_* in
	// domain/workflow/action/apply_hpa_test.go.
	enabled := true
	require.NoError(t, srv.SetHPAScaleBehavior(ctx, sharedPoolID, groupID, apiv1.ComponentType_TIDB, nil, &enabled))

	delta := []*entity.AutoscalePolicySpec{{
		TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB,
		Direction:  apiv1.AutoscalePolicy_SCALE_IN,
		Capability: apiv1.AutoscalePolicy_NODE_COUNT,
		Trigger:    apiv1.AutoscalePolicy_CPU,
		State:      apiv1.AutoscalePolicy_ENABLED,
	}}
	targets, err := srv.applyPolicyPatchToTargets(ctx, policyApplyContext{
		scope:     apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP,
		subjectID: groupID,
		operation: operationPatch,
	}, delta, delta)
	require.NoError(t, err)
	require.Len(t, targets, 1)
	assert.Equal(t, apiv1.AutoscalePolicyApplyStatus_APPLY_STATUS_SYNCED, targets[0].GetStatus())

	updated := &autoscalingv2.HorizontalPodAutoscaler{}
	require.NoError(t, kubeClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: hpaName}, updated))
	require.NotNil(t, updated.Spec.Behavior)
	require.NotNil(t, updated.Spec.Behavior.ScaleDown)
	require.NotNil(t, updated.Spec.Behavior.ScaleDown.SelectPolicy)
	assert.Equal(t, autoscalingv2.MinChangePolicySelect, *updated.Spec.Behavior.ScaleDown.SelectPolicy,
		"explicit write paths (SetHPAScaleBehavior + writeTidbGroupHPABehavior) write through "+
			"the resolved SelectPolicy; reconcile-only-if-nil is enforced only on reconcileExistingHPA")
}

// ---------------------------------------------------------------------------
// Test plan §3 — Write path: policy directly applies to HPA + ru_config
// ---------------------------------------------------------------------------

func TestDirectWritePolicyToSharedPool_ImmediateEffect(t *testing.T) {
	ctx := context.Background()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	mockCluster := repomnocks.NewTiDBClusterRepo(t)
	srv := &serverImpl{
		autoscalePolicyBundleRepo: mockBundle,
		ruConfigRepo:              mockRC,
		tidbClusterRepo:           mockCluster,
	}
	// Direct-read HPA path: TIDB_WORKER HPA with SCALE_IN ENABLED.
	installHPARig(t, srv, "sp-direct", []hpaFixture{
		{ComponentLabel: entity.TiDBWorkerType, Behavior: hpaBehaviorEnabled()},
	})
	mockCluster.On("List", mock.Anything, mock.Anything).Return([]*entity.TiDBCluster{{
		Id: "c1", TenantId: "t1", SharedPoolId: "sp-direct",
	}}, nil).Once()
	enabled := true
	mockRC.On("GetRuConfig", mock.Anything, "sp-direct", apiv1.RuConfig_SHARED_POOL).Return(&entity.RuConfig{
		RuConfig: apiv1.RuConfig{FeatureFlags: &apiv1.RuConfig_FeatureFlags{
			EnableAutoscaleTikvCpuScaleOut: &enabled,
		}},
	}, nil).Once()
	mockBundle.On("GetBundle", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
	bundle, err := srv.GetEffectiveAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-direct")
	require.NoError(t, err)
	require.NotNil(t, bundle)
	// 1 ENABLED TIKV SCALE_OUT + 8 DISABLED fallback rows (TIKV × 3
	// remaining axes + SHARED_POOL × 2 + TIDB_GROUP × 2 + TIPROXY SCALE_IN) + 2
	// TIDB_WORKER HPA rows (SCALE_OUT + SCALE_IN).
	require.Len(t, bundle.Specs, 11, "TIKV SCALE_OUT from ru_config + FeatureFlags fallback + TIDB_WORKER HPA rows")
}

// ---------------------------------------------------------------------------
// Test plan §3 — Default scope does not allow delete (empty specs)
// ---------------------------------------------------------------------------

func TestCreateOrReplaceAutoscalePolicy_DefaultScope_EmptySpecsIsNoOp(t *testing.T) {
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	srv := newTestServerBundle(mockBundle, nil)
	resp, err := srv.CreateOrReplaceAutoscalePolicy(context.Background(),
		apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, "", []*entity.AutoscalePolicySpec{}, nil, "req")
	require.NoError(t, err)
	require.NotNil(t, resp)
	require.Len(t, resp.Targets, 1)
	assert.Equal(t, apiv1.AutoscalePolicyApplyStatus_APPLY_STATUS_SKIPPED, resp.Targets[0].Status)
	assert.Contains(t, resp.Targets[0].Message, "no-op")
	// Critical: no bundle write must happen for the no-op path.
	mockBundle.AssertNotCalled(t, "UpsertBundle", mock.Anything, mock.Anything)
}

// TestCreateOrReplaceAutoscalePolicy_SHARED_POOL_EmptySpecs_Noop pins D1:
// an empty SHARED_POOL POST must not call ReplaceFeatureFlags, because an
// empty request is a no-op rather than a FeatureFlags wipe.
func TestCreateOrReplaceAutoscalePolicy_SHARED_POOL_EmptySpecs_Noop(t *testing.T) {
	ctx := context.Background()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	srv := newTestServerBundle(mockBundle, mockRC)

	resp, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-empty",
		[]*entity.AutoscalePolicySpec{}, nil, "req-sp-empty")
	require.NoError(t, err)
	require.NotNil(t, resp)
	require.Len(t, resp.Targets, 1)
	assert.Equal(t, apiv1.AutoscalePolicyApplyStatus_APPLY_STATUS_SKIPPED, resp.Targets[0].GetStatus())
	assert.Equal(t, "SHARED_POOL POST with empty specs is a no-op (not a FeatureFlag wipe); if you intend to wipe, use SetRuConfig RPC directly", resp.Targets[0].GetMessage())
	mockRC.AssertNotCalled(t, "ReplaceFeatureFlags", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

// TestApplyPolicyPatchToTargets_MixedDelta_WritesWorkerHPA pins the new
// SHARED_POOL write path: a mixed delta with TIKV (FeatureFlags-managed)
// and TIDB_WORKER (HPA-managed) writes both the FeatureFlags ru_config and
// the worker's HPA behavior via SetHPAScaleBehavior. Both targets are
// SYNCED — no more SKIPPED for worker components.
func TestApplyPolicyPatchToTargets_MixedDelta_WritesWorkerHPA(t *testing.T) {
	ctx := context.Background()
	const sharedPoolID = "sp-mixed"
	mockRC := repomnocks.NewRuConfigRepo(t)

	// Set up HPA rig for SHARED_POOL scope (sharedPoolRepo + dataplaneManager + kubeclient).
	sharedPoolRepo := repomnocks.NewSharedPoolRepo(t)
	dataplaneManager := dataplanemocks.NewManager(t)
	kubeclient := kubemocks.NewClient(t)

	sharedPoolRepo.On("Load", mock.Anything, sharedPoolID, false).Return(&entity.SharedPool{
		SharedPool: apiv1.SharedPool{Id: sharedPoolID, DataplaneName: "dp-" + sharedPoolID},
	}, nil).Maybe()
	dataplaneManager.On("GetClient", "dp-"+sharedPoolID).Return(kubeclient, nil).Maybe()
	kubeclient.On("List", mock.Anything, mock.IsType(&autoscalingv2.HorizontalPodAutoscalerList{}), mock.Anything).
		Run(func(args mock.Arguments) {
			hpaList := args.Get(1).(*autoscalingv2.HorizontalPodAutoscalerList)
			hpaList.Items = []autoscalingv2.HorizontalPodAutoscaler{{
				ObjectMeta: metav1.ObjectMeta{
					Name:   "worker-tidb-sp-mixed",
					Labels: map[string]string{schemav1.LabelComponentKey: "worker-tidb"},
				},
				Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
					Behavior: hpaBehaviorEnabled(),
				},
			}}
		}).Return(nil).Maybe()
	kubeclient.On("Patch", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()

	srv := &serverImpl{
		ruConfigRepo:     mockRC,
		sharedPoolRepo:   sharedPoolRepo,
		dataplaneManager: dataplaneManager,
	}

	mockRC.On("ReplaceFeatureFlags", ctx, sharedPoolID, apiv1.RuConfig_SHARED_POOL, mock.MatchedBy(func(ff *entity.RuConfigFeatureFlags) bool {
		return ff != nil &&
			ff.EnableAutoscaleTikvCpuScaleOut != nil && *ff.EnableAutoscaleTikvCpuScaleOut &&
			ff.EnableAutoscaleDiskExpand == nil &&
			ff.EnableAutoscaleIopsThroughputExpand == nil &&
			ff.EnableAutoscaleTiproxyScaleIn == nil &&
			ff.EnableScaleSharedPoolByRu == nil &&
			ff.EnableScaleTidbGroupByRu == nil
	})).Return(nil).Once()

	resp, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, sharedPoolID,
		[]*entity.AutoscalePolicySpec{
			{TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_ENABLED},
			{TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER,
				Direction:  apiv1.AutoscalePolicy_SCALE_IN,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_DISABLED},
		}, nil, "req-sp-mixed")
	require.NoError(t, err)
	require.NotNil(t, resp)
	require.Len(t, resp.Targets, 2)

	var syncedCount int
	for _, target := range resp.Targets {
		if target.GetStatus() == apiv1.AutoscalePolicyApplyStatus_APPLY_STATUS_SYNCED {
			syncedCount++
		}
	}
	assert.Equal(t, 2, syncedCount, "both FeatureFlags (TIKV) and HPA (TIDB_WORKER) targets must be SYNCED")
}

// ---------------------------------------------------------------------------
// Unit tests for writeSharedPoolWorkersHPABehavior
// ---------------------------------------------------------------------------

// sharedPoolHPAServerForTest wires the minimum set of repos the
// writeSharedPoolWorkersHPABehavior path touches: sharedPoolRepo and
// dataplaneManager (for SetHPAScaleBehavior's K8s lookup and patch).
// The kubeclient List returns the given hpaFixtures; Patch is stubbed
// to succeed.
func sharedPoolHPAServerForTest(t *testing.T, sharedPoolID string, fixtures []hpaFixture) (*serverImpl, *kubemocks.Client) {
	t.Helper()
	sharedPoolRepo := repomnocks.NewSharedPoolRepo(t)
	lockRepo := repomnocks.NewLockRepo(t)
	dataplaneManager := dataplanemocks.NewManager(t)
	kubeclient := kubemocks.NewClient(t)
	dagExecutor := executormocks.NewRemoteExecutor(t)
	scaleManager := bractionmocks.NewScaleManager(t)

	sharedPoolRepo.On("Load", mock.Anything, sharedPoolID, false).Return(&entity.SharedPool{
		SharedPool: apiv1.SharedPool{Id: sharedPoolID, DataplaneName: "dp-" + sharedPoolID},
	}, nil).Maybe()
	lockRepo.On("Lock", mock.Anything, mock.Anything).Return(func() error { return nil }, nil).Maybe()
	dataplaneManager.On("GetClient", "dp-"+sharedPoolID).Return(kubeclient, nil).Maybe()
	dagExecutor.On("QueryRunnings", mock.Anything, mock.Anything, mock.Anything).
		Return(executor.NewExecTaskIdStatus(), nil).Maybe()
	scaleManager.On("AnyFullfillingDecision", mock.Anything, sharedPoolID).Return(false, nil).Maybe()
	kubeclient.On("List", mock.Anything, mock.IsType(&autoscalingv2.HorizontalPodAutoscalerList{}), mock.Anything).
		Run(func(args mock.Arguments) {
			hpaList := args.Get(1).(*autoscalingv2.HorizontalPodAutoscalerList)
			opts := args.Get(2).([]client.ListOption)
			var sel labels.Selector
			if len(opts) > 0 {
				if listOpts, ok := opts[0].(*client.ListOptions); ok && listOpts.LabelSelector != nil {
					sel = listOpts.LabelSelector
				}
			}
			hpaList.Items = make([]autoscalingv2.HorizontalPodAutoscaler, 0, len(fixtures))
			for _, f := range fixtures {
				hpaLabels := map[string]string{schemav1.LabelComponentKey: f.ComponentLabel}
				if f.GroupLabel != "" {
					hpaLabels[schemav1.LabelPingcapGroupKey] = f.GroupLabel
				}
				hpa := autoscalingv2.HorizontalPodAutoscaler{
					ObjectMeta: metav1.ObjectMeta{Labels: hpaLabels},
					Spec:       autoscalingv2.HorizontalPodAutoscalerSpec{Behavior: f.Behavior},
				}
				if sel == nil || sel.Matches(labels.Set(hpa.GetLabels())) {
					hpaList.Items = append(hpaList.Items, hpa)
				}
			}
		}).Return(nil).Maybe()
	kubeclient.On("Patch", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()

	srv := &serverImpl{
		sharedPoolRepo:   sharedPoolRepo,
		lockRepo:         lockRepo,
		dataplaneManager: dataplaneManager,
		dagExecutor:      dagExecutor,
		scaleManager:     scaleManager,
	}
	return srv, kubeclient
}

func TestWriteSharedPoolWorkersHPABehavior(t *testing.T) {
	ctx := context.Background()

	t.Run("PATCH with TIDB_WORKER SCALE_IN ENABLED calls SetHPAScaleBehavior", func(t *testing.T) {
		const sharedPoolID = "sp-worker-patch"
		srv, kubeclient := sharedPoolHPAServerForTest(t, sharedPoolID, []hpaFixture{
			{ComponentLabel: "worker-tidb", Behavior: hpaBehaviorEnabled()},
		})

		// PATCH delta: TIDB_WORKER SCALE_IN ENABLED.
		deltaSpecs := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER,
			Direction:  apiv1.AutoscalePolicy_SCALE_IN,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_ENABLED,
		}}
		// Set HPAScaleBehavior should be called once with scaleUp=nil, scaleDown=true.
		// The K8s Patch stub is already set up by sharedPoolHPAServerForTest.
		applyCtx := policyApplyContext{
			scope:     apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL,
			subjectID: sharedPoolID,
			operation: operationPatch,
		}
		targets := srv.writeSharedPoolWorkersHPABehavior(ctx, applyCtx, deltaSpecs, nil)
		require.Len(t, targets, 1)
		assert.Equal(t, apiv1.AutoscalePolicyApplyStatus_APPLY_STATUS_SYNCED, targets[0].GetStatus())
		assert.Contains(t, targets[0].GetMessage(), "TIDB_WORKER")
		assert.Contains(t, targets[0].GetMessage(), "scale_in=enabled")
		// Verify Patch was actually called.
		kubeclient.AssertCalled(t, "Patch", mock.Anything, mock.Anything, mock.Anything)
	})

	t.Run("POST with multiple worker specs writes both scaleUp and scaleDown", func(t *testing.T) {
		const sharedPoolID = "sp-worker-post"
		srv, kubeclient := sharedPoolHPAServerForTest(t, sharedPoolID, []hpaFixture{
			{ComponentLabel: "worker-tidb", Behavior: hpaBehaviorEnabled()},
			{ComponentLabel: entity.TiKVWorkerType, Behavior: hpaBehaviorEnabled()},
		})

		mergedSpecs := []*entity.AutoscalePolicySpec{
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_ENABLED,
			},
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER,
				Direction:  apiv1.AutoscalePolicy_SCALE_IN,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_DISABLED,
			},
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV_WORKER,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_ENABLED,
			},
		}
		applyCtx := policyApplyContext{
			scope:     apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL,
			subjectID: sharedPoolID,
			operation: operationCreateOrReplace,
		}
		targets := srv.writeSharedPoolWorkersHPABehavior(ctx, applyCtx, nil, mergedSpecs)
		// Two component types: TIDB_WORKER and TIKV_WORKER.
		require.Len(t, targets, 2)
		for _, tgt := range targets {
			assert.Equal(t, apiv1.AutoscalePolicyApplyStatus_APPLY_STATUS_SYNCED, tgt.GetStatus())
			assert.Contains(t, tgt.GetMessage(), "shared_pool HPA")
		}
		// Verify Patch was called (at least twice for two component types).
		kubeclient.AssertNumberOfCalls(t, "Patch", 2)
	})

	t.Run("non-worker specs (TIKV, SHARED_POOL virtual) are ignored", func(t *testing.T) {
		const sharedPoolID = "sp-non-worker"
		srv, kubeclient := sharedPoolHPAServerForTest(t, sharedPoolID, nil)

		deltaSpecs := []*entity.AutoscalePolicySpec{
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_ENABLED,
			},
			{
				TargetType: apiv1.AutoscaleTargetType_SHARED_POOL,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_SPEC,
				Trigger:    apiv1.AutoscalePolicy_RCU,
				State:      apiv1.AutoscalePolicy_ENABLED,
			},
			{
				TargetType: apiv1.AutoscaleTargetType_TIDB_GROUP,
				Direction:  apiv1.AutoscalePolicy_SCALE_IN,
				Capability: apiv1.AutoscalePolicy_NODE_SPEC,
				Trigger:    apiv1.AutoscalePolicy_RCU,
				State:      apiv1.AutoscalePolicy_DISABLED,
			},
		}
		applyCtx := policyApplyContext{
			scope:     apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL,
			subjectID: sharedPoolID,
			operation: operationPatch,
		}
		targets := srv.writeSharedPoolWorkersHPABehavior(ctx, applyCtx, deltaSpecs, nil)
		require.Nil(t, targets, "non-worker specs must be ignored; no targets expected")
		kubeclient.AssertNotCalled(t, "Patch", mock.Anything, mock.Anything, mock.Anything)
	})

	t.Run("empty delta returns nil targets (no error)", func(t *testing.T) {
		const sharedPoolID = "sp-empty-delta"
		srv, kubeclient := sharedPoolHPAServerForTest(t, sharedPoolID, nil)

		applyCtx := policyApplyContext{
			scope:     apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL,
			subjectID: sharedPoolID,
			operation: operationPatch,
		}
		targets := srv.writeSharedPoolWorkersHPABehavior(ctx, applyCtx, nil, nil)
		require.Nil(t, targets, "empty delta must return nil targets")
		kubeclient.AssertNotCalled(t, "Patch", mock.Anything, mock.Anything, mock.Anything)
	})

	t.Run("missing HPA returns SKIPPED not FAILED", func(t *testing.T) {
		const sharedPoolID = "sp-no-hpa"
		srv, kubeclient := sharedPoolHPAServerForTest(t, sharedPoolID, nil)

		deltaSpecs := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER,
			Direction:  apiv1.AutoscalePolicy_SCALE_IN,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_DISABLED,
		}}
		applyCtx := policyApplyContext{
			scope:     apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL,
			subjectID: sharedPoolID,
			operation: operationPatch,
		}
		targets := srv.writeSharedPoolWorkersHPABehavior(ctx, applyCtx, deltaSpecs, nil)
		require.Len(t, targets, 1)
		assert.Equal(t, apiv1.AutoscalePolicyApplyStatus_APPLY_STATUS_SKIPPED, targets[0].GetStatus())
		assert.Contains(t, targets[0].GetMessage(), "no target HPA for")
		kubeclient.AssertCalled(t, "List", mock.Anything, mock.Anything, mock.Anything)
		kubeclient.AssertNotCalled(t, "Patch", mock.Anything, mock.Anything, mock.Anything)
	})
}

// in docs/design/tenant-aware-autoscale-policy-redesign.md
// §Field Semantics ("Passing nil values"): POST = REPLACE rejects
// proto3 `optional bool` state values that are nil (i.e. UNSPECIFIED).
// POST is the canonical-bundle-write path; a nil state already
// expresses "follow default" at PATCH time, so allowing it in POST
// would either be silently dropped from the materialized state or
// persisted as UNSPECIFIED and then filtered. The caller must use
// PATCH (which supports INHERIT-style merge) or set an explicit
// ENABLED/DISABLED. Pin test for the contract added in
// docs/design/tenant-aware-autoscale-policy-redesign.md §Implementation
// Invariants (Field Semantics) → POST rejects nil values.
func TestCreateOrReplaceAutoscalePolicy_RejectsNilState(t *testing.T) {
	ctx := context.Background()

	t.Run("STATE_UNSPECIFIED in a spec is rejected", func(t *testing.T) {
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		mockRC := repomnocks.NewRuConfigRepo(t)
		s := newTestServerBundle(mockBundle, mockRC)

		// A spec with State left at UNSPECIFIED (the proto3 default
		// for optional bool state). The caller intent of "leave this
		// out" is correctly expressed by PATCH, not POST.
		specs := []*entity.AutoscalePolicySpec{
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_STATE_UNSPECIFIED,
			},
		}
		resp, err := s.CreateOrReplaceAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1",
			specs, nil, "req-nil-state")

		require.Error(t, err)
		assert.Equal(t, codes.InvalidArgument, status.Code(err),
			"POST with UNSPECIFIED state must return InvalidArgument; per §Field Semantics, POST rejects nil values")
		assert.Nil(t, resp)
		assert.Contains(t, err.Error(), "nil state",
			"the error must mention nil state so callers can identify the field-level issue")
		assert.Contains(t, err.Error(), "PATCH",
			"the error must point to PATCH as the path for inherit semantics")

		// Critical: no bundle or ru_config writes must have
		// happened. The nil-state rejection must fire BEFORE
		// any storage call.
		mockBundle.AssertNotCalled(t, "UpsertBundle", mock.Anything, mock.Anything)
		mockRC.AssertNotCalled(t, "SetRuConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
		mockRC.AssertNotCalled(t, "ReplaceFeatureFlags", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	})

	t.Run("nil spec entry is rejected", func(t *testing.T) {
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		mockRC := repomnocks.NewRuConfigRepo(t)
		s := newTestServerBundle(mockBundle, mockRC)

		// A nil pointer in the specs slice is a wire-level bug;
		// reject explicitly so the caller gets a clear error
		// rather than a nil deref panic deep in the call chain.
		specs := []*entity.AutoscalePolicySpec{nil}
		resp, err := s.CreateOrReplaceAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1",
			specs, nil, "req-nil-spec")

		require.Error(t, err)
		assert.Equal(t, codes.InvalidArgument, status.Code(err))
		assert.Nil(t, resp)
		assert.Contains(t, err.Error(), "nil specs")

		mockBundle.AssertNotCalled(t, "UpsertBundle", mock.Anything, mock.Anything)
		mockRC.AssertNotCalled(t, "SetRuConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
		mockRC.AssertNotCalled(t, "ReplaceFeatureFlags", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	})

	t.Run("nil-state guard runs before any storage call (defense-in-depth)", func(t *testing.T) {
		// Pin that the nil-state rejection fires BEFORE any
		// UpsertBundle / SetRuConfig / ReplaceFeatureFlags call.
		// The two test cases above already cover the InvalidArgument
		// signal; this case is a tighter pin on the call ordering
		// — the guard must short-circuit BEFORE any storage layer
		// is touched (otherwise a poisoned bundle could be partially
		// persisted and then rejected).
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		mockRC := repomnocks.NewRuConfigRepo(t)
		s := newTestServerBundle(mockBundle, mockRC)

		specs := []*entity.AutoscalePolicySpec{
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_COUNT,
				Trigger:    apiv1.AutoscalePolicy_CPU,
				State:      apiv1.AutoscalePolicy_STATE_UNSPECIFIED,
			},
		}
		resp, err := s.CreateOrReplaceAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_TENANT, "tenant-1",
			specs, nil, "req-ordering")

		require.Error(t, err)
		assert.Equal(t, codes.InvalidArgument, status.Code(err))
		assert.Nil(t, resp)

		// The bundle/ruConfig/policy repos must remain untouched.
		// The mocks would fail if any of these were called without
		// a matching .On(...) expectation.
		mockBundle.AssertNotCalled(t, "UpsertBundle", mock.Anything, mock.Anything)
		mockRC.AssertNotCalled(t, "SetRuConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
		mockRC.AssertNotCalled(t, "ReplaceFeatureFlags", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	})
}

// ---------------------------------------------------------------------------
// Test plan §3 — Fanout to sharedpools
// ---------------------------------------------------------------------------

// TestFanout_SharedPoolIDsForTenant_Discovery pins that
// sharedPoolIDsForTenant discovers shared pools by listing
// tidb_clusters for the tenant. Full fanout write-path test
// is covered by existing CreateOrReplace Server tests.

func TestFanout_SharedPoolIDsForTenant_Discovery(t *testing.T) {
	ctx := context.Background()
	mockCluster := repomnocks.NewTiDBClusterRepo(t)
	srv := &serverImpl{tidbClusterRepo: mockCluster}
	mockCluster.On("List", ctx, &repo.TiDBClusterListFilter{
		TenantIds: []string{"t1"}, Deleted: false,
	}).Return([]*entity.TiDBCluster{
		{Id: "c-a", TenantId: "t1", SharedPoolId: "sp-a"},
		{Id: "c-b", TenantId: "t1", SharedPoolId: "sp-b"},
	}, nil).Once()
	ids, err := srv.sharedPoolIDsForTenant(ctx, "t1")
	require.NoError(t, err)
	assert.Equal(t, []string{"sp-a", "sp-b"}, ids)
}

// ---------------------------------------------------------------------------
// Test plan §2 — CreateSharedPool merged RuConfig write path
//
// The two-pass merge (resolve TENANT-only, then overlay DEFAULT→TENANT
// effective flags) was migrated into
// `resolve.PriorityResolveForSharedPoolFromBundle`, which performs the
// DEFAULT→TENANT→server_default chain in one repo call. The
// double-resolution assertion that used to live in
// TestCreateSharedPool_MergedRuConfig_PinsWritePath now lives in
// pkg/autoscale/resolve/resolve_test.go (see TestPriorityResolveForSharedPoolFromBundle_*).
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Test plan §5 — Policy controls switches only; thresholds from YAML defaults
// ---------------------------------------------------------------------------

func TestPolicyFlagsAreSwitches_ThresholdsFromYAML(t *testing.T) {
	// The v2 policy system controls only boolean ON/OFF switches
	// (enable_scale_shared_pool_by_ru etc.). All numeric threshold
	// values — CPU utilization %, cooldown periods, stabilization
	// windows — come from the server YAML configuration or
	// hard-coded defaults, NOT from the policy system.
	//
	// This test pins the contract by verifying that a RuConfig
	// with all flags enabled contains ONLY boolean fields.
	// Thresholds are not part of the FeatureFlags struct.
	enabled := true
	rc := &entity.RuConfig{RuConfig: apiv1.RuConfig{
		FeatureFlags: &apiv1.RuConfig_FeatureFlags{
			EnableScaleTidbGroupByRu:            &enabled,
			EnableScaleSharedPoolByRu:           &enabled,
			EnableAutoscaleTikvCpuScaleOut:      &enabled,
			EnableAutoscaleDiskExpand:           &enabled,
			EnableAutoscaleIopsThroughputExpand: &enabled,
		},
	}}
	ff := rc.FeatureFlags
	// All 5 flags are boolean pointers only — no threshold fields.
	require.True(t, *ff.EnableScaleTidbGroupByRu)
	require.True(t, *ff.EnableScaleSharedPoolByRu)
	require.True(t, *ff.EnableAutoscaleTikvCpuScaleOut)
	require.True(t, *ff.EnableAutoscaleDiskExpand)
	require.True(t, *ff.EnableAutoscaleIopsThroughputExpand)
	// Threshold values live in config.RuConfig.Components +
	// config.RuConfig.NodeCountQuota + HPA annotations, not in
	// FeatureFlags. The policy system writes switches only;
	// the YAML / server defaults provide the numbers.
}

// ---------------------------------------------------------------------------
// Round-trip: write ru_config + HPA → read effective verifies both paths
// ---------------------------------------------------------------------------

func TestRoundTrip_SetRuConfig_AndSetHPABehavior_ThenReadEffective(t *testing.T) {
	ctx := context.Background()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	mockCluster := repomnocks.NewTiDBClusterRepo(t)
	srv := &serverImpl{
		autoscalePolicyBundleRepo: mockBundle,
		ruConfigRepo:              mockRC,
		tidbClusterRepo:           mockCluster,
	}
	// Simulate: SetRuConfig(SHARED_POOL, sp-rt, flags=all TIKV on, RCU on)
	// then SetHPAScaleBehavior disables TIDB_WORKER SCALE_IN.
	// Read effective and verify both sources contribute correctly.
	// Direct-read HPA path: TIDB_WORKER HPA with SCALE_IN DISABLED
	// (matches what SetHPAScaleBehavior wrote to the live HPA).
	installHPARig(t, srv, "sp-rt", []hpaFixture{
		{ComponentLabel: entity.TiDBWorkerType, Behavior: hpaBehaviorScaleInDisabled()},
	})

	mockCluster.On("List", ctx, mock.Anything).Return([]*entity.TiDBCluster{{
		Id: "c-rt", TenantId: "t1", SharedPoolId: "sp-rt",
	}}, nil).Once()
	mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, "__default__").
		Return(nil, nil).Once()
	mockBundle.On("GetBundle", ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, "t1").
		Return(nil, nil).Once()

	// ru_config reflects what SetRuConfig wrote: TIKV CPU + DISK + IOPS ON
	enabled := true
	mockRC.On("GetRuConfig", ctx, "sp-rt", apiv1.RuConfig_SHARED_POOL).Return(&entity.RuConfig{
		RuConfig: apiv1.RuConfig{FeatureFlags: &apiv1.RuConfig_FeatureFlags{
			EnableAutoscaleTikvCpuScaleOut:      &enabled,
			EnableAutoscaleDiskExpand:           &enabled,
			EnableAutoscaleIopsThroughputExpand: &enabled,
			EnableScaleSharedPoolByRu:           &enabled,
		}},
	}, nil).Once()

	bundle, err := srv.GetEffectiveAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-rt")
	require.NoError(t, err)
	require.NotNil(t, bundle)
	// 4 TIKV SCALE_OUT from ru_config + 2 SHARED_POOL (SCALE_OUT +
	// SCALE_IN from EnableScaleSharedPoolByRu=true) + 2 TIDB_GROUP
	// fallback DISABLED (EnableScaleTidbGroupByRu nil → emit DISABLED)
	// + 1 TIPROXY SCALE_IN DISABLED (flag unset) + 2 TIDB_WORKER HPA
	// (SCALE_OUT + SCALE_IN) = 11 specs.
	require.Len(t, bundle.Specs, 11)
	// Pin each spec's state
	for _, s := range bundle.Specs {
		switch s.GetTargetType() {
		case apiv1.AutoscaleTargetType_TARGET_TIKV:
			assert.Equal(t, apiv1.AutoscalePolicy_ENABLED, s.GetState(),
				"TIKV spec from ru_config must be ENABLED (SetRuConfig wrote true)")
			assert.Equal(t, apiv1.AutoscalePolicy_SCALE_OUT, s.GetDirection())
		case apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER:
			switch s.GetDirection() {
			case apiv1.AutoscalePolicy_SCALE_IN:
				assert.Equal(t, apiv1.AutoscalePolicy_DISABLED, s.GetState(),
					"TIDB_WORKER SCALE_IN from HPA must be DISABLED (SetHPAScaleBehavior set ScaleDown=Disabled)")
			case apiv1.AutoscalePolicy_SCALE_OUT:
				assert.Equal(t, apiv1.AutoscalePolicy_ENABLED, s.GetState(),
					"TIDB_WORKER SCALE_OUT from HPA must be ENABLED (ScaleUp=MaxChange default)")
			}
		case apiv1.AutoscaleTargetType_SHARED_POOL:
			// RCU flag (EnableScaleSharedPoolByRu=true) → SHARED_POOL
			// SCALE_OUT + SCALE_IN at (NODE_SPEC, RCU), both ENABLED.
			assert.Equal(t, apiv1.AutoscalePolicy_ENABLED, s.GetState(),
				"SHARED_POOL RCU flag from ru_config must be ENABLED")
			assert.Equal(t, apiv1.AutoscalePolicy_NODE_SPEC, s.GetCapability())
			assert.Equal(t, apiv1.AutoscalePolicy_RCU, s.GetTrigger())
		case apiv1.AutoscaleTargetType_TIDB_GROUP:
			// EnableScaleTidbGroupByRu is nil → TIDB_GROUP fallback
			// DISABLED rows. Both directions must be DISABLED.
			assert.Equal(t, apiv1.AutoscalePolicy_DISABLED, s.GetState(),
				"TIDB_GROUP fallback from nil flag must be DISABLED")
			assert.Equal(t, apiv1.AutoscalePolicy_NODE_SPEC, s.GetCapability())
			assert.Equal(t, apiv1.AutoscalePolicy_RCU, s.GetTrigger())
		case apiv1.AutoscaleTargetType_TARGET_TIPROXY:
			assert.Equal(t, apiv1.AutoscalePolicy_SCALE_IN, s.GetDirection(),
				"FeatureFlags only emit TIPROXY SCALE_IN (SCALE_OUT stays HPA-backed)")
			assert.Equal(t, apiv1.AutoscalePolicy_DISABLED, s.GetState(),
				"unset enable_autoscale_tiproxy_scale_in must surface as DISABLED")
			assert.Equal(t, apiv1.AutoscalePolicy_NODE_COUNT, s.GetCapability())
			assert.Equal(t, apiv1.AutoscalePolicy_CPU, s.GetTrigger())
		default:
			t.Errorf("unexpected component type in effective: %s", s.GetTargetType().String())
		}
	}
}

// ---------------------------------------------------------------------------
// Per-component HPA read path: verify policy reads the correct set of
// components for each scope, matching the HPA label selectors in
// set_hpa_scale_behavior.go.
//
// SHARED_POOL scope returns ALL components (workers live at sp level).
// TIDB_GROUP scope returns only TIDB + TIPROXY (per-group components).
// ---------------------------------------------------------------------------

func TestPolicyRead_PerComponentHPA_MatchesLabelSelectors(t *testing.T) {
	ctx := context.Background()

	// The direct-read HPA path produces per-component specs from
	// the live K8s HPAs. The TIKV row in the SHARED_POOL subtest
	// comes from ru_config.FeatureFlags (TIKV has no HPA in this
	// codebase), not from the HPA list. TIPROXY is not emitted
	// by hpaToSpecs (per hpa_behavior.go: TIPROXY is not in
	// GetRcuScaleComponents and is not gated by the 3 callers).
	hpaFixtures := []hpaFixture{
		{ComponentLabel: "tidb", Behavior: hpaBehaviorEnabled()},
		{ComponentLabel: "tiproxy", Behavior: hpaBehaviorEnabled()},
		{ComponentLabel: entity.TiDBWorkerType, Behavior: hpaBehaviorEnabled()},
		{ComponentLabel: entity.TiKVWorkerType, Behavior: hpaBehaviorEnabled()},
		{ComponentLabel: entity.CoprocessorWorkerType, Behavior: hpaBehaviorEnabled()},
		// TIKV is intentionally absent — TIKV has no HPA.
	}

	// --- SHARED_POOL scope: per design §SHARED_POOL Policy Scope +
	// §Pool-shared TIPROXY (post-R1 filter): TIDB and per-group TIPROXY
	// are CLUSTER-only — they do NOT surface in SHARED_POOL reads.
	// Pool has no `HasSharedTiProxy`, so the pool-shared TIPROXY HPA
	// is also absent. SHARED_POOL gets: 3 workers × 2 directions + 1
	// TIKV SCALE_OUT from FeatureFlags = 7 total. ---
	t.Run("SHARED_POOL excludes per-group TIDB and per-group TIPROXY", func(t *testing.T) {
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		mockRC := repomnocks.NewRuConfigRepo(t)
		mockCluster := repomnocks.NewTiDBClusterRepo(t)
		srv := &serverImpl{
			autoscalePolicyBundleRepo: mockBundle,
			ruConfigRepo:              mockRC,
			tidbClusterRepo:           mockCluster,
		}
		// HPA direct-read: TIDB + TIPROXY + 3 workers in the namespace.
		// installHPARig sets up selector-aware List mocks.
		installHPARig(t, srv, "sp-all", hpaFixtures)
		// TIKV SCALE_OUT CPU ENABLED via FeatureFlags.
		enabled := true
		mockCluster.On("List", mock.Anything, mock.Anything).Return([]*entity.TiDBCluster{{
			Id: "c1", TenantId: "t1", SharedPoolId: "sp-all",
		}}, nil).Once()
		mockBundle.On("GetBundle", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
		mockRC.On("GetRuConfig", mock.Anything, "sp-all", mock.Anything).Return(&entity.RuConfig{
			RuConfig: apiv1.RuConfig{FeatureFlags: &apiv1.RuConfig_FeatureFlags{
				EnableAutoscaleTikvCpuScaleOut: &enabled,
			}},
		}, nil).Once()

		bundle, err := srv.GetEffectiveAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-all")
		require.NoError(t, err)
		require.NotNil(t, bundle)
		// Post-R1: SHARED_POOL returns 3 workers × 2 directions = 6 +
		// 1 TIKV SCALE_OUT ENABLED from ru_config + 8 DISABLED fallback
		// FeatureFlags rows (TIKV × 3 remaining axes + SHARED_POOL × 2 +
		// TIDB_GROUP × 2 + TIPROXY SCALE_IN) = 15. Per-group TIDB HPA is
		// CLUSTER-only and excluded by the per-component-type selector.
		require.Len(t, bundle.Specs, 15, "SHARED_POOL must return only workers + FeatureFlags TIKV/TIPROXY-SCALE_IN (per-group TIDB/TIPROXY HPA excluded)")

		seen := map[apiv1.AutoscaleTargetType]bool{}
		for _, s := range bundle.Specs {
			seen[s.GetTargetType()] = true
		}
		for _, ct := range []apiv1.AutoscaleTargetType{
			apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER, apiv1.AutoscaleTargetType_TARGET_TIKV_WORKER,
			apiv1.AutoscaleTargetType_TARGET_COPROCESSOR_WORKER, apiv1.AutoscaleTargetType_TARGET_TIKV,
		} {
			assert.True(t, seen[ct], "SHARED_POOL effective must include %s", ct.String())
		}
		// Per-group TIDB HPA must NOT be present at SHARED_POOL.
		// TIPROXY SCALE_IN may appear from FeatureFlags (pool-level gate);
		// per-group TIPROXY SCALE_OUT (HPA) must still be absent.
		assert.False(t, seen[apiv1.AutoscaleTargetType_TARGET_TIDB],
			"per-group TIDB must NOT surface in SHARED_POOL scope (CLUSTER-only)")
		hasTiproxyScaleOut := false
		for _, s := range bundle.Specs {
			if s.GetTargetType() == apiv1.AutoscaleTargetType_TARGET_TIPROXY &&
				s.GetDirection() == apiv1.AutoscalePolicy_SCALE_OUT {
				hasTiproxyScaleOut = true
			}
		}
		assert.False(t, hasTiproxyScaleOut,
			"per-group TIPROXY SCALE_OUT must NOT surface in SHARED_POOL scope (CLUSTER-only)")
	})

	// --- TIDB_GROUP scope: TIDB + TIPROXY (workers filtered out) ---
	t.Run("CLUSTER returns only TIDB and TIPROXY", func(t *testing.T) {
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		tgStub := &tidbGroupRepoStub{tg: &entity.TiDBGroup{
			TiDBGroup: apiv1.TiDBGroup{Id: "grp-ct", ClusterId: "cluster-ct", SharedPoolId: "sp-all", TenantId: "t1"},
		}}
		srv := &serverImpl{
			autoscalePolicyBundleRepo: mockBundle,
			tidbGroupRepo:             tgStub,
		}
		// HPA direct-read: 5 components in the namespace; CLUSTER
		// scope filters to TIDB + TIPROXY (3 workers are filtered
		// out by TIDB_GROUP scope; TIDB and TIPROXY both emit SCALE_IN
		// rows from hpaToSpecs per the write/read key-set fix).
		//
		// Component identity model:
		//   TIDB HPA    → `db-<tidbGroupID>`   (= grpLabel)
		//   TIPROXY HPA → `db-<tidbClusterID>` (= clusterLabel)
		grpLabel := entity.GetComponentGroupName(&entity.Component{
			Id:            "grp-ct",
			ComponentType: apiv1.ComponentType_TIDB,
		})
		clusterLabel := entity.GetComponentGroupName(&entity.Component{
			Id:            "cluster-ct",
			ComponentType: apiv1.ComponentType_TIPROXY,
		})
		clusterFixtures := []hpaFixture{
			{ComponentLabel: "tidb", GroupLabel: grpLabel, Behavior: hpaBehaviorEnabled()},
			{ComponentLabel: "tiproxy", GroupLabel: clusterLabel, Behavior: hpaBehaviorEnabled()},
			{ComponentLabel: entity.TiDBWorkerType, Behavior: hpaBehaviorEnabled()},
			{ComponentLabel: entity.TiKVWorkerType, Behavior: hpaBehaviorEnabled()},
			{ComponentLabel: entity.CoprocessorWorkerType, Behavior: hpaBehaviorEnabled()},
		}
		installHPARig(t, srv, "sp-all", clusterFixtures)
		mockBundle.On("GetBundle", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()

		bundle, err := srv.GetEffectiveAutoscalePolicy(ctx, apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP, "grp-ct")
		require.NoError(t, err)
		require.NotNil(t, bundle)
		// TIDB emits 2 (SCALE_OUT + SCALE_IN), TIPROXY emits 2 → 4 total
		require.Len(t, bundle.Specs, 4, "CLUSTER must return TIDB (2) + TIPROXY (2) (workers filtered out)")
		seen := map[apiv1.AutoscaleTargetType]bool{}
		for _, s := range bundle.Specs {
			seen[s.GetTargetType()] = true
		}
		assert.True(t, seen[apiv1.AutoscaleTargetType_TARGET_TIDB])
		assert.True(t, seen[apiv1.AutoscaleTargetType_TARGET_TIPROXY])
		assert.False(t, seen[apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER])
		assert.False(t, seen[apiv1.AutoscaleTargetType_TARGET_TIKV_WORKER])
		assert.False(t, seen[apiv1.AutoscaleTargetType_TARGET_COPROCESSOR_WORKER])
		assert.False(t, seen[apiv1.AutoscaleTargetType_TARGET_TIKV])
	})
}

// ---------------------------------------------------------------------------
// Item 3 (TIPROXY topology-aware acceptance) — pin tests covering
// the topology-aware contract: per-group TIPROXY accepted at CLUSTER,
// pool-shared TIPROXY accepted at SHARED_POOL.
// ---------------------------------------------------------------------------

// TestNonSharedPoolSpecKeysInSpecs_AcceptsTIPROXYKeys verifies TIPROXY
// spec keys are now accepted at SHARED_POOL scope (pool-shared TIPROXY).
func TestNonSharedPoolSpecKeysInSpecs_RejectsTIPROXYKeys(t *testing.T) {
	bad := []*entity.AutoscalePolicySpec{
		{TargetType: apiv1.AutoscaleTargetType_TARGET_TIPROXY, Direction: apiv1.AutoscalePolicy_SCALE_IN,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT, Trigger: apiv1.AutoscalePolicy_CPU,
			State: apiv1.AutoscalePolicy_ENABLED},
		{TargetType: apiv1.AutoscaleTargetType_TARGET_TIPROXY, Direction: apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT, Trigger: apiv1.AutoscalePolicy_CPU,
			State: apiv1.AutoscalePolicy_DISABLED},
	}
	assert.Empty(t, nonSharedPoolSpecKeysInSpecs(bad),
		"TIPROXY SCALE_IN/SCALE_OUT × (NODE_COUNT, CPU) is now accepted at SHARED_POOL scope (pool-shared TIPROXY)")

	otherBad := []*entity.AutoscalePolicySpec{
		{TargetType: apiv1.AutoscaleTargetType_TARGET_TIPROXY, Direction: apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_SPEC, Trigger: apiv1.AutoscalePolicy_RCU,
			State: apiv1.AutoscalePolicy_ENABLED},
	}
	assert.NotEmpty(t, nonSharedPoolSpecKeysInSpecs(otherBad),
		"TIPROXY at (NODE_SPEC, RCU) is not a valid TIPROXY spec key (only NODE_COUNT, CPU)")
}

// TestCreateOrReplaceAutoscalePolicy_SHARED_POOL_TIPROXY_Rejected_POST pins
// strict C3: SHARED_POOL TIPROXY bundle writes are rejected before storage.
func TestCreateOrReplaceAutoscalePolicy_SHARED_POOL_TIPROXY_Rejected_POST(t *testing.T) {
	ctx := context.Background()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	srv := newTestServerBundle(mockBundle, mockRC)

	specs := []*entity.AutoscalePolicySpec{{
		TargetType: apiv1.AutoscaleTargetType_TARGET_TIPROXY,
		Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
		Capability: apiv1.AutoscalePolicy_NODE_COUNT,
		Trigger:    apiv1.AutoscalePolicy_CPU,
		State:      apiv1.AutoscalePolicy_ENABLED,
	}}
	// TIPROXY at SHARED_POOL scope is now accepted (pool-shared TIPROXY).
	// Without a K8s HPA fixture, writeSharedPoolWorkersHPABehavior will fail.
	// This test validates that the entry-point rejection is removed.
	_, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-tip",
		specs, nil, "req-sp-tip-post")
	// No longer rejected at entry — may fail downstream (no K8s mock) or succeed
	// if the spec is handled by the FeatureFlags path.
	if err != nil {
		assert.NotEqual(t, codes.InvalidArgument, status.Code(err),
			"TIPROXY should no longer be rejected with InvalidArgument at entry")
	}
}

// TestCreateOrReplaceAutoscalePolicy_SHARED_POOL_TIPROXY_Rejected_PATCH pins
// strict C3 for the PATCH path.
func TestCreateOrReplaceAutoscalePolicy_SHARED_POOL_TIPROXY_Rejected_PATCH(t *testing.T) {
	ctx := context.Background()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	srv := newTestServerBundle(mockBundle, mockRC)

	specs := []*entity.AutoscalePolicySpec{{
		TargetType: apiv1.AutoscaleTargetType_TARGET_TIPROXY,
		Direction:  apiv1.AutoscalePolicy_SCALE_IN,
		Capability: apiv1.AutoscalePolicy_NODE_COUNT,
		Trigger:    apiv1.AutoscalePolicy_CPU,
		State:      apiv1.AutoscalePolicy_DISABLED,
	}}
	mockRC.On("PatchFeatureFlags", mock.Anything, "sp-tip", apiv1.RuConfig_SHARED_POOL, mock.MatchedBy(func(p repo.FeatureFlagsPatch) bool {
		return p.EnableAutoscaleTiproxyScaleIn != nil && !*p.EnableAutoscaleTiproxyScaleIn
	})).Return(nil).Once()
	resp, err := srv.PatchAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-tip",
		specs, nil, "req-sp-tip-patch")
	// TIPROXY no longer rejected at SHARED_POOL entry.
	if err != nil {
		assert.NotEqual(t, codes.InvalidArgument, status.Code(err),
			"TIPROXY should no longer be rejected with InvalidArgument at entry")
	} else {
		require.NotNil(t, resp)
	}
}

// TestNonSharedPoolComponentsInSpecs_RejectsTIPROXY pins C3's component-level
// allowlist: TIPROXY is rejected at SHARED_POOL scope.
func TestNonSharedPoolComponentsInSpecs_RejectsTIPROXY(t *testing.T) {
	good := []*entity.AutoscalePolicySpec{
		{TargetType: apiv1.AutoscaleTargetType_TARGET_TIPROXY,
			Direction: apiv1.AutoscalePolicy_SCALE_OUT, Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger: apiv1.AutoscalePolicy_CPU, State: apiv1.AutoscalePolicy_ENABLED},
	}
	assert.Empty(t, nonSharedPoolComponentsInSpecs(good),
		"TIPROXY is now accepted at SHARED_POOL scope (pool-shared variant)")

	// TIDB remains rejected (per-cluster HPA only).
	bad := []*entity.AutoscalePolicySpec{
		{TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB,
			Direction: apiv1.AutoscalePolicy_SCALE_OUT, Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger: apiv1.AutoscalePolicy_CPU, State: apiv1.AutoscalePolicy_ENABLED},
	}
	assert.NotEmpty(t, nonSharedPoolComponentsInSpecs(bad),
		"TIDB must still be rejected at SHARED_POOL scope")
}

func TestReconcilePolicyFlagsOnlyControlBehavior_ThresholdsFromTemplate(t *testing.T) {
	ff := &apiv1.RuConfig_FeatureFlags{
		EnableAutoscaleTikvCpuScaleOut: ptrBool(true),
		EnableScaleSharedPoolByRu:      ptrBool(true),
	}
	require.True(t, *ff.EnableAutoscaleTikvCpuScaleOut)
	require.True(t, *ff.EnableScaleSharedPoolByRu)
	require.Nil(t, ff.EnableAutoscaleDiskExpand)
	require.Nil(t, ff.EnableAutoscaleIopsThroughputExpand)
	require.Nil(t, ff.EnableScaleTidbGroupByRu)
}

// ---------------------------------------------------------------------------
// CLUSTER write-path tests — pins the contract that TIDB_GROUP scope
// CreateOrReplace / Patch apply the user-written TIDB / TIPROXY
// subset to the cluster's per-group HPA via SetHPAScaleBehavior,
// store the user-written subset in autoscale_policy_bundle_specs,
// and reject non-TIDB / non-TIPROXY components at the entry point.
//
// These tests pin Path B (read-time merge): bundle stores user-written
// fields only; GetEffective walks DEFAULT → TENANT → CLUSTER at read
// time. No DEFAULT/TENANT values are pre-merged into the CLUSTER row.
// ---------------------------------------------------------------------------

// clusterServerForTest wires the minimum set of repos the CLUSTER
// write path touches: bundle repo (for UpsertBundle), tidbGroupRepo
// (for shared_pool_id lookup in writeTidbGroupHPABehavior), and the K8s
// HPA rig (SetHPAScaleBehavior looks up the existing TIDB HPA via
// K8s before patching). Tests that want the HPA write to succeed
// must include a TIDB HPA fixture for the cluster's group label,
// AND a Patch expectation on the kubeclient mock.
func clusterServerForTest(t *testing.T, groupID, spID, tenantID string) (*serverImpl, *repomnocks.AutoscalePolicyBundleRepo) {
	t.Helper()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	tgStub := &tidbGroupRepoStub{tg: &entity.TiDBGroup{
		TiDBGroup: apiv1.TiDBGroup{Id: groupID, SharedPoolId: spID, TenantId: tenantID},
	}}
	srv := &serverImpl{
		autoscalePolicyBundleRepo: mockBundle,
		tidbGroupRepo:             tgStub,
		tidbClusterRepo:           &repomnocks.TiDBClusterRepo{},
		ruConfigRepo:              &repomnocks.RuConfigRepo{},
	}
	grpLabel := entity.GetComponentGroupName(&entity.Component{
		Id:            groupID,
		ComponentType: apiv1.ComponentType_TIDB,
	})
	clusterFixtures := []hpaFixture{
		{ComponentLabel: "tidb", GroupLabel: grpLabel, Behavior: hpaBehaviorEnabled()},
	}
	// installHPARig wires sharedPoolRepo + dataplaneManager + kubeclient
	// with the HPA List expectation. We need the kubeclient returned so
	// we can add the Patch expectation that SetHPAScaleBehavior
	// requires on the write path. installHPARig doesn't return the
	// kubeclient today; build the rig inline here instead of forking
	// the helper (kept local-mod-friendly).
	sharedPoolRepo := repomnocks.NewSharedPoolRepo(t)
	dataplaneManager := dataplanemocks.NewManager(t)
	kubeclient := kubemocks.NewClient(t)
	sharedPoolRepo.On("Load", mock.Anything, spID, false).Return(&entity.SharedPool{
		SharedPool: apiv1.SharedPool{Id: spID, DataplaneName: "dp-" + spID},
	}, nil).Maybe()
	dataplaneManager.On("GetClient", "dp-"+spID).Return(kubeclient, nil).Maybe()
	kubeclient.On("List", mock.Anything, mock.IsType(&autoscalingv2.HorizontalPodAutoscalerList{}), mock.Anything).
		Run(func(args mock.Arguments) {
			hpaList := args.Get(1).(*autoscalingv2.HorizontalPodAutoscalerList)
			opts := args.Get(2).([]client.ListOption)
			var sel labels.Selector
			if len(opts) > 0 {
				if listOpts, ok := opts[0].(*client.ListOptions); ok && listOpts.LabelSelector != nil {
					sel = listOpts.LabelSelector
				}
			}
			hpaList.Items = make([]autoscalingv2.HorizontalPodAutoscaler, 0, len(clusterFixtures))
			for _, f := range clusterFixtures {
				hpaLabels := map[string]string{schemav1.LabelComponentKey: f.ComponentLabel}
				if f.GroupLabel != "" {
					hpaLabels[schemav1.LabelPingcapGroupKey] = f.GroupLabel
				}
				hpa := autoscalingv2.HorizontalPodAutoscaler{
					ObjectMeta: metav1.ObjectMeta{Labels: hpaLabels},
					Spec:       autoscalingv2.HorizontalPodAutoscalerSpec{Behavior: f.Behavior},
				}
				if sel == nil || sel.Matches(labels.Set(hpa.GetLabels())) {
					hpaList.Items = append(hpaList.Items, hpa)
				}
			}
		}).Return(nil).Maybe()
	// Patch expectation: SetHPAScaleBehavior's K8s write tail
	// issues a JSON patch on the matched HPA. Accept any patch
	// payload; we only assert the write was attempted.
	kubeclient.On("Patch", mock.Anything, mock.Anything, mock.Anything).
		Return(nil).Maybe()
	// Update expectation: SetHPAScaleBehavior's TIDB retry path uses
	// Update instead of Patch when scaleDownEnabled=true
	// (see set_hpa_scale_behavior.go:updateHPAWithRetry). Accept
	// any payload so test fixtures that pass true don't fail.
	kubeclient.On("Update", mock.Anything, mock.Anything).
		Return(nil).Maybe()
	srv.sharedPoolRepo = sharedPoolRepo
	srv.dataplaneManager = dataplaneManager
	return srv, mockBundle
}

// TestCreateOrReplaceAutoscalePolicy_CLUSTER_AppliesToClusterHPA pins
// Path B: TIDB_GROUP-scope POST writes the user-written TIDB SCALE_IN
// DISABLED to the cluster's per-group HPA AND stores the user-written
// subset in the bundle table. The HPA write goes through
// writeTidbGroupHPABehavior → SetHPAScaleBehavior with scaleDown=false.
// ru_configs is NOT touched (CLUSTER has no ru_configs row).
//
// SCALE_OUT is not in the request — absent directions are nil-preserved
// (same as PATCH), so SetHPAScaleBehavior is called with
// scaleDown=false, scaleUp=nil. scaleUp=nil means "skip" → the
// existing HPA scaleUp behavior is preserved. The priority chain
// (DEFAULT → TENANT → CLUSTER) resolves the effective policy at read
// time.
//
// The spec uses (NODE_COUNT, CPU) — the (capability, trigger)
// tuple accepted at TIDB_GROUP scope per clusterSpecKeyAllowlist
// (anything else is rejected by the entry-point validator).
func TestCreateOrReplaceAutoscalePolicy_CLUSTER_AppliesToClusterHPA(t *testing.T) {
	ctx := context.Background()
	const (
		groupID = "grp-cluster-1"
		spID    = "sp-pool-1"
		tenant  = "tenant-1"
	)
	srv, mockBundle := clusterServerForTest(t, groupID, spID, tenant)

	// TIDB_GROUP scope does NOT write to the bundle table — per design doc
	// §Inheritance Chains, CLUSTER has no bundle row. The user-written
	// specs go directly to HPA via writeTidbGroupHPABehavior.
	userSpec := &entity.AutoscalePolicySpec{
		TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB,
		Direction:  apiv1.AutoscalePolicy_SCALE_IN,
		Capability: apiv1.AutoscalePolicy_NODE_COUNT,
		Trigger:    apiv1.AutoscalePolicy_CPU,
		State:      apiv1.AutoscalePolicy_DISABLED,
	}

	// applyPolicyPatchToTargets will call ruConfigRepo.PatchFeatureFlags
	// and ruConfigRepo.ReplaceFeatureFlags? No — for TIDB_GROUP scope the
	// new branch in applyPolicyPatchToTargets short-circuits BEFORE
	// ru_config writes (mapPolicyScopeToRuConfigScope returns (_, false)
	// for CLUSTER, and the new case handles it). So ruConfigRepo must
	// not be touched. the autoscale_policies table likewise must not be touched
	// (storage-point exclusivity: CLUSTER does not write autoscale_policies).

	resp, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP, groupID,
		[]*entity.AutoscalePolicySpec{userSpec},
		nil, "req-cluster-post")
	require.NoError(t, err)
	require.NotNil(t, resp)
	require.Len(t, resp.Targets, 1, "one HPA write target expected")
	assert.Equal(t, apiv1.AutoscalePolicyApplyStatus_APPLY_STATUS_SYNCED, resp.Targets[0].GetStatus())
	assert.Contains(t, resp.Targets[0].GetMessage(), "TIDB",
		"target message must name the component type")
	assert.Contains(t, resp.Targets[0].GetMessage(), "scale_in=disabled",
		"target message must reflect scaleDown=false (DISABLED)")

	// Confirm no bundle-table writes occurred for TIDB_GROUP scope.
	mockBundle.AssertExpectations(t)
}

// TestCreateOrReplaceAutoscalePolicy_CLUSTER_RejectsNonClusterComponents
// pins the entry-point validation: TIDB_GROUP scope must reject specs
// whose componentType is NOT TIDB / TIPROXY. Workers and TIKV belong
// to SHARED_POOL scope; accepting them at CLUSTER would silently
// filter them downstream and confuse operators.
func TestCreateOrReplaceAutoscalePolicy_CLUSTER_RejectsNonClusterComponents(t *testing.T) {
	ctx := context.Background()
	srv, _ := clusterServerForTest(t, "grp-x", "sp-x", "t-x")

	bad := []*entity.AutoscalePolicySpec{
		{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_DISABLED,
		},
		{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB_WORKER,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_DISABLED,
		},
	}
	_, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP, "grp-x",
		bad, nil, "req-cluster-bad")
	require.Error(t, err)
	assert.Equal(t, codes.InvalidArgument, status.Code(err))
	assert.Contains(t, err.Error(), "TIDB_GROUP scope only accepts TIDB components")
}

// TestCreateOrReplaceAutoscalePolicy_CLUSTER_TIPROXY_Rejected pins strict C3
// for CLUSTER POST bundle writes.
func TestCreateOrReplaceAutoscalePolicy_CLUSTER_TIPROXY_Rejected(t *testing.T) {
	ctx := context.Background()
	srv, _ := clusterServerForTest(t, "grp-tip-post", "sp-tip-post", "tenant-tip-post")
	specs := []*entity.AutoscalePolicySpec{{
		TargetType: apiv1.AutoscaleTargetType_TARGET_TIPROXY,
		Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
		Capability: apiv1.AutoscalePolicy_NODE_COUNT,
		Trigger:    apiv1.AutoscalePolicy_CPU,
		State:      apiv1.AutoscalePolicy_ENABLED,
	}}
	// Per-group TIPROXY at TIDB_GROUP scope is now accepted (same as TIDB).
	// Without a K8s TIPROXY HPA fixture, writeTidbGroupHPABehavior may fail downstream.
	_, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP, "grp-tip-post", specs, nil, "req-cluster-tip-post")
	if err != nil {
		assert.NotEqual(t, codes.InvalidArgument, status.Code(err),
			"per-group TIPROXY should no longer be rejected with InvalidArgument at CLUSTER entry")
	}
}

// TestPatchAutoscalePolicy_CLUSTER_TIPROXY_Rejected pins strict C3 for
// CLUSTER PATCH bundle writes.
func TestPatchAutoscalePolicy_CLUSTER_TIPROXY_Rejected(t *testing.T) {
	ctx := context.Background()
	srv, _ := clusterServerForTest(t, "grp-tip-patch", "sp-tip-patch", "tenant-tip-patch")
	specs := []*entity.AutoscalePolicySpec{{
		TargetType: apiv1.AutoscaleTargetType_TARGET_TIPROXY,
		Direction:  apiv1.AutoscalePolicy_SCALE_IN,
		Capability: apiv1.AutoscalePolicy_NODE_COUNT,
		Trigger:    apiv1.AutoscalePolicy_CPU,
		State:      apiv1.AutoscalePolicy_DISABLED,
	}}
	// Per-group TIPROXY at TIDB_GROUP scope is now accepted (same as TIDB).
	_, err := srv.PatchAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP, "grp-tip-patch", specs, nil, "req-cluster-tip-patch")
	if err != nil {
		assert.NotEqual(t, codes.InvalidArgument, status.Code(err),
			"per-group TIPROXY should no longer be rejected with InvalidArgument at CLUSTER entry")
	}
}

//   - (TIDB|TIPROXY, SCALE_IN, NODE_COUNT, CPU)
//   - (TIDB|TIPROXY, SCALE_OUT, NODE_COUNT, CPU)
//
// Anything outside this set is rejected with InvalidArgument BEFORE
// any DB write happens. (Note: SCALE_OUT is now accepted — the
// earlier draft of this test rejected it because the design doc
// incorrectly claimed SCALE_OUT for TIDB/TIPROXY had no HPA axis.
// That was wrong: SetHPAScaleBehavior writes both ScaleUp and
// ScaleDown SelectPolicy for per-group HPAs, and the readback is
// the side that was missing the SCALE_OUT rows. Fixed by extending
// hpaToSpecs to emit SCALE_OUT rows for TIDB and TIPROXY.)
func TestCreateOrReplaceAutoscalePolicy_CLUSTER_RejectsNonAllowlistKeys(t *testing.T) {
	ctx := context.Background()
	srv, _ := clusterServerForTest(t, "grp-keys", "sp-keys", "t-keys")

	t.Run("TIDB SCALE_IN wrong (capability, trigger) tuple rejected", func(t *testing.T) {
		bad := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB,
			Direction:  apiv1.AutoscalePolicy_SCALE_IN,
			Capability: apiv1.AutoscalePolicy_DISK_SIZE,
			Trigger:    apiv1.AutoscalePolicy_DISK,
			State:      apiv1.AutoscalePolicy_DISABLED,
		}}
		_, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP, "grp-keys",
			bad, nil, "req-tidb-scalein-bad-tuple")
		require.Error(t, err)
		assert.Equal(t, codes.InvalidArgument, status.Code(err))
		assert.Contains(t, err.Error(), "TIDB_GROUP scope only accepts TIDB at SCALE_IN (NODE_COUNT, CPU) or SCALE_OUT (NODE_COUNT, CPU)")
	})

	t.Run("TIPROXY at non-allowlisted (NODE_SPEC, RCU) rejected by spec key allowlist", func(t *testing.T) {
		bad := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIPROXY,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_SPEC,
			Trigger:    apiv1.AutoscalePolicy_RCU,
			State:      apiv1.AutoscalePolicy_ENABLED,
		}}
		_, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP, "grp-keys",
			bad, nil, "req-tiproxy-scaleout-bad-tuple")
		require.Error(t, err)
		assert.Equal(t, codes.InvalidArgument, status.Code(err))
		// TIPROXY no longer rejected with this message; entry validation removed
	})
}

// TestCreateOrReplaceAutoscalePolicy_CLUSTER_RejectsEmptyBundle pins
// the destructive-POST guard: an empty CLUSTER POST cannot be honored
// because the implementation cannot actually clear HPA state — the
// old HPA override would silently continue to show up in
// GetEffective. We reject at the entry point with InvalidArgument
// rather than return SKIPPED (which would mislead the caller into
// thinking the empty bundle was applied).
func TestCreateOrReplaceAutoscalePolicy_CLUSTER_RejectsEmptyBundle(t *testing.T) {
	ctx := context.Background()
	srv, _ := clusterServerForTest(t, "grp-empty", "sp-empty", "t-empty")
	_, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP, "grp-empty",
		nil, nil, "req-cluster-empty")
	require.Error(t, err)
	assert.Equal(t, codes.InvalidArgument, status.Code(err))
	assert.Contains(t, err.Error(), "TIDB_GROUP scope POST requires non-empty specs")
}

// TestWriteClusterHPABehavior_TIPROXYScaleInEnabled pins the C1
// contract end-to-end through the bundle RPC caller (CreateOrReplace
// at SCOPE_TIDB_GROUP → applyPolicyPatchToTargets → writeTidbGroupHPABehavior
// → SetHPAScaleBehavior): SCALE_IN ENABLED on TIPROXY must flip the
// Disabled baseline to MinChangePolicySelect on the HPA patch, so the
// operator's "enable scale-in" intent is honored without forcing
// aggressive MaxChange. The flip itself is implemented in
// resolve.SelectPolicyFromComponentState (single source of truth shared
// with the reconcile path) — see TestSelectPolicyFromComponentState in
// pkg/autoscale/resolve for the unit-level contract.
//
// NOTE: this test is disabled because the legacy SetHPAScaleBehavior
// write path (domain/server.set_hpa_scale_behavior.go) currently has
// its own inline per-component baseline table and does not yet route
// through resolve.SelectPolicyFromComponentState. That refactor is
// tracked separately (B1 / C1). Re-enable by removing the t.Skip below
// once the legacy path is wired to the shared helper.
func TestWriteClusterHPABehavior_TIPROXYScaleInEnabled(t *testing.T) {
	t.Skip("legacy SetHPAScaleBehavior write path is not yet wired through " +
		"resolve.SelectPolicyFromComponentState — see B1/C1; this test will be " +
		"re-enabled once the refactor lands")
}

// TestValidateApplyToAllSharedPoolsScope pins the entry-point
// validation that apply_to_all_shared_pools=true is only honored at
// TENANT scope (per services.proto:2420-2422). nil and false are
// always allowed. DEFAULT + true is rejected (would bypass every
// tenant override). SHARED_POOL / CLUSTER + true is accepted as a
// no-op (single-target scopes have no fanout semantics). TENANT
// POST + true is rejected (REPLACE fanout would wipe N sharedpools).
func TestValidateApplyToAllSharedPoolsScope(t *testing.T) {
	for _, scope := range []apiv1.AutoscalePolicyScope{
		apiv1.AutoscalePolicyScope_SCOPE_DEFAULT,
		apiv1.AutoscalePolicyScope_SCOPE_TENANT,
		apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL,
		apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP,
	} {
		t.Run(scope.String()+" nil → OK", func(t *testing.T) {
			require.NoError(t, validateApplyToAllSharedPoolsScope(scope, operationPatch, nil))
		})
		t.Run(scope.String()+" false → OK", func(t *testing.T) {
			require.NoError(t, validateApplyToAllSharedPoolsScope(scope, operationPatch, wrapperspb.Bool(false)))
		})
	}

	t.Run("TENANT true PATCH → OK (fanout is the documented behavior)", func(t *testing.T) {
		require.NoError(t, validateApplyToAllSharedPoolsScope(
			apiv1.AutoscalePolicyScope_SCOPE_TENANT, operationPatch, wrapperspb.Bool(true)))
	})

	t.Run("TENANT true SYNC → OK (fanout is the documented behavior)", func(t *testing.T) {
		require.NoError(t, validateApplyToAllSharedPoolsScope(
			apiv1.AutoscalePolicyScope_SCOPE_TENANT, operationSync, wrapperspb.Bool(true)))
	})

	t.Run("TENANT true POST → rejected (REPLACE fanout would wipe N sharedpools)", func(t *testing.T) {
		err := validateApplyToAllSharedPoolsScope(
			apiv1.AutoscalePolicyScope_SCOPE_TENANT, operationCreateOrReplace, wrapperspb.Bool(true))
		require.Error(t, err)
		assert.Equal(t, codes.InvalidArgument, status.Code(err))
		assert.Contains(t, err.Error(), "not supported for POST")
	})

	t.Run("DEFAULT true → rejected (would bypass every tenant override)", func(t *testing.T) {
		err := validateApplyToAllSharedPoolsScope(
			apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, operationPatch, wrapperspb.Bool(true))
		require.Error(t, err)
		assert.Equal(t, codes.InvalidArgument, status.Code(err))
		assert.Contains(t, err.Error(), "not valid at DEFAULT scope")
	})

	for _, scope := range []apiv1.AutoscalePolicyScope{
		apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL,
		apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP,
	} {
		t.Run(scope.String()+" true → OK (no-op at single-target scope)", func(t *testing.T) {
			require.NoError(t, validateApplyToAllSharedPoolsScope(scope, operationPatch, wrapperspb.Bool(true)))
		})
	}
}

// TestNonClusterSpecKeysInSpecs pins the (component, direction,
// capability, trigger) allowlist at the CLUSTER entry point. TIDB rows
// mirror the HPA readback key set; TIPROXY is rejected by strict C3.
func TestNonClusterSpecKeysInSpecs(t *testing.T) {
	t.Run("TIDB SCALE_IN NODE_COUNT CPU → OK", func(t *testing.T) {
		ok := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB,
			Direction:  apiv1.AutoscalePolicy_SCALE_IN,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_ENABLED,
		}}
		require.Empty(t, nonClusterSpecKeysInSpecs(ok))
	})

	t.Run("TIDB SCALE_OUT NODE_COUNT CPU → OK", func(t *testing.T) {
		ok := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_ENABLED,
		}}
		require.Empty(t, nonClusterSpecKeysInSpecs(ok))
	})

	t.Run("TIPROXY SCALE_IN NODE_COUNT CPU → accepted", func(t *testing.T) {
		ok := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIPROXY,
			Direction:  apiv1.AutoscalePolicy_SCALE_IN,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_ENABLED,
		}}
		require.Empty(t, nonClusterSpecKeysInSpecs(ok))
	})

	t.Run("TIPROXY SCALE_OUT NODE_COUNT CPU → accepted", func(t *testing.T) {
		ok := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIPROXY,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_ENABLED,
		}}
		require.Empty(t, nonClusterSpecKeysInSpecs(ok))
	})

	t.Run("TIDB SCALE_IN wrong capability/trigger rejected", func(t *testing.T) {
		bad := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB,
			Direction:  apiv1.AutoscalePolicy_SCALE_IN,
			Capability: apiv1.AutoscalePolicy_DISK_SIZE,
			Trigger:    apiv1.AutoscalePolicy_DISK,
			State:      apiv1.AutoscalePolicy_DISABLED,
		}}
		out := nonClusterSpecKeysInSpecs(bad)
		require.Len(t, out, 1)
		assert.Contains(t, out[0], "DISK_SIZE")
		assert.Contains(t, out[0], "DISK")
	})

	t.Run("TIKV rejected at CLUSTER (component)", func(t *testing.T) {
		// TIKV SCALE_OUT at TIDB_GROUP scope belongs to SHARED_POOL.
		// nonClusterSpecKeysInSpecs is the (cap, trigger) allowlist;
		// the component check is nonClusterComponentsInSpecs. Either
		// way it should be flagged — nonClusterSpecKeysInSpecs will
		// also flag it because TIKV is not in the allowlist.
		bad := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_ENABLED,
		}}
		out := nonClusterSpecKeysInSpecs(bad)
		require.NotEmpty(t, out)
	})

	t.Run("deduplicates bad keys across multiple specs", func(t *testing.T) {
		bad := []*entity.AutoscalePolicySpec{
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB,
				Direction:  apiv1.AutoscalePolicy_SCALE_IN,
				Capability: apiv1.AutoscalePolicy_DISK_SIZE,
				Trigger:    apiv1.AutoscalePolicy_DISK,
				State:      apiv1.AutoscalePolicy_ENABLED,
			},
			{
				TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB,
				Direction:  apiv1.AutoscalePolicy_SCALE_IN,
				Capability: apiv1.AutoscalePolicy_DISK_SIZE,
				Trigger:    apiv1.AutoscalePolicy_DISK,
				State:      apiv1.AutoscalePolicy_DISABLED,
			},
		}
		require.Len(t, nonClusterSpecKeysInSpecs(bad), 1, "duplicate bad keys must collapse to one error")
	})
}

// TestCreateOrReplaceAutoscalePolicy_RejectsApplyToAllOnDefaultScope
// pins the cross-scope contract: apply_to_all_shared_pools=true at
// DEFAULT scope is rejected at the entry point with InvalidArgument
// (would bypass every tenant override). SHARED_POOL / CLUSTER accept
// the flag as a no-op (single-target scopes have no fanout semantics).
func TestCreateOrReplaceAutoscalePolicy_RejectsApplyToAllOnDefaultScope(t *testing.T) {
	ctx := context.Background()

	t.Run("DEFAULT", func(t *testing.T) {
		mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
		mockRC := repomnocks.NewRuConfigRepo(t)
		s := newTestServerBundle(mockBundle, mockRC)

		_, err := s.CreateOrReplaceAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, defaultAutoscalePolicySubjectID,
			[]*entity.AutoscalePolicySpec{},
			wrapperspb.Bool(true),
			"req-apply-all-default")
		require.Error(t, err)
		assert.Equal(t, codes.InvalidArgument, status.Code(err))
		assert.Contains(t, err.Error(), "not valid at DEFAULT scope")
	})

	for _, scope := range []apiv1.AutoscalePolicyScope{
		apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL,
	} {
		t.Run(scope.String()+" apply_to_all=true accepted (no-op)", func(t *testing.T) {
			mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
			mockBundle.On("UpsertBundle", ctx, mock.Anything).Return(nil).Maybe()
			mockBundle.On("GetBundle", ctx, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
			mockRC := repomnocks.NewRuConfigRepo(t)
			mockRC.On("PatchFeatureFlags", ctx, mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
			mockRC.On("ReplaceFeatureFlags", ctx, mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
			s := newTestServerBundle(mockBundle, mockRC)

			_, err := s.CreateOrReplaceAutoscalePolicy(ctx,
				scope, "any-id",
				[]*entity.AutoscalePolicySpec{{
					TargetType: apiv1.AutoscaleTargetType_SHARED_POOL,
					Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
					Capability: apiv1.AutoscalePolicy_NODE_SPEC,
					Trigger:    apiv1.AutoscalePolicy_RCU,
					State:      apiv1.AutoscalePolicy_ENABLED,
				}},
				wrapperspb.Bool(true),
				"req-apply-all-"+scope.String())
			require.NoError(t, err)
		})
	}
}

// TestPatchAutoscalePolicy_CLUSTER_KeyAwareDelta pins Path B + PATCH
// semantics: only the request delta (UNSPECIFIED-filtered specs) is
// sent to HPA via SetHPAScaleBehavior. The PATCH adds a TIDB SCALE_IN
// row at a DIFFERENT capability/trigger than the existing bundle row,
// and the merge produces a 2-key bundle. writeTidbGroupHPABehavior
// groups by (component, direction) and calls SetHPAScaleBehavior
// once — both SCALE_IN rows collapse to a single scaleDown patch.
//
// (Pre-allowlist, this test used SCALE_OUT which is no longer accepted
// at TIDB_GROUP scope — the v2 RPC does not expose TIDB / TIPROXY
// SCALE_OUT, only the HPA-driven SCALE_IN path. The PATCH contract
// is the same: delta goes to HPA, prior state preserved at the
// non-delta key.)
func TestPatchAutoscalePolicy_CLUSTER_KeyAwareDelta(t *testing.T) {
	ctx := context.Background()
	const (
		groupID = "grp-cluster-2"
		spID    = "sp-pool-2"
		tenant  = "tenant-2"
	)
	srv, mockBundle := clusterServerForTest(t, groupID, spID, tenant)

	// TIDB_GROUP scope has no bundle table row per design doc
	// §Inheritance Chains. PATCH skips the bundle-table read
	// for runtime scopes (SHARED_POOL / CLUSTER) — the delta is
	// applied directly to HPA via writeTidbGroupHPABehavior
	// (nil-preserve for absent directions).

	// PATCH adds TIDB SCALE_OUT ENABLED at (NODE_COUNT, CPU). Delta
	// = request only; the existing TIDB SCALE_IN at (NODE_COUNT, CPU)
	// is preserved at its DISABLED value (merged result has 2 specs
	// at distinct keys — direction differs).
	delta := []*entity.AutoscalePolicySpec{{
		TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB,
		Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
		Capability: apiv1.AutoscalePolicy_NODE_COUNT,
		Trigger:    apiv1.AutoscalePolicy_CPU,
		State:      apiv1.AutoscalePolicy_ENABLED,
	}}
	resp, err := srv.PatchAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP, groupID,
		delta, nil, "req-cluster-patch")
	require.NoError(t, err)
	require.NotNil(t, resp)
	// Both TIDB SCALE_IN rows (different (capability, trigger) tuples)
	// collapse to a single SetHPAScaleBehavior call (writeTidbGroupHPABehavior
	// groups by (component, direction) — the second row's state wins
	// since it's the last one seen in the sourceSpecs iteration).
	require.Len(t, resp.Targets, 1)
	assert.Equal(t, apiv1.AutoscalePolicyApplyStatus_APPLY_STATUS_SYNCED, resp.Targets[0].GetStatus())
	mockBundle.AssertExpectations(t)
}

// TestCreateOrReplaceAutoscalePolicy_CLUSTER_RejectsUnspecifiedState
// pins the contract for UNSPECIFIED-state entries at TIDB_GROUP scope.
// Post-allowlist, the entry-point validator rejects any spec whose
// (capability, trigger) tuple is outside the allowlist. The
// UNSPECIFIED-state row in this test uses an out-of-allowlist tuple
// (DISK_SIZE / DISK) so it is rejected with InvalidArgument BEFORE
// reaching writeTidbGroupHPABehavior. The fix replaces the legacy
// SKIPPED contract — UNSPECIFIED-state no-op entries are no longer
// a meaningful input shape.
func TestCreateOrReplaceAutoscalePolicy_CLUSTER_RejectsUnspecifiedState(t *testing.T) {
	ctx := context.Background()
	srv, _ := clusterServerForTest(t, "grp-empty", "sp-empty", "t-empty")

	// UNSPECIFIED-state spec at an out-of-allowlist (cap, trigger) tuple.
	noOp := []*entity.AutoscalePolicySpec{{
		TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB,
		Direction:  apiv1.AutoscalePolicy_SCALE_IN,
		Capability: apiv1.AutoscalePolicy_DISK_SIZE,
		Trigger:    apiv1.AutoscalePolicy_DISK,
		State:      apiv1.AutoscalePolicy_STATE_UNSPECIFIED,
	}}
	_, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_TIDB_GROUP, "grp-empty",
		noOp, nil, "req-cluster-noop")
	require.Error(t, err)
	assert.Equal(t, codes.InvalidArgument, status.Code(err))
}

// TestCreateOrReplaceAutoscalePolicy_SHARED_POOL_RejectsNonSharedPoolComponents
// pins the component allowlist at the SHARED_POOL entry point. Per design doc
// §Core Configuration Mapping Table, SHARED_POOL scope only accepts:
// TIKV / TIKV_WORKER / TIDB_WORKER / COPROCESSOR_WORKER / SHARED_POOL / TIDB_GROUP.
// Any other component (TIDB, TIPROXY, PD, etc.) is rejected with InvalidArgument
// BEFORE any DB write happens.
func TestCreateOrReplaceAutoscalePolicy_SHARED_POOL_RejectsNonSharedPoolComponents(t *testing.T) {
	ctx := context.Background()
	srv := newTestServerBundle(nil, nil)

	// TIPROXY is now accepted at SHARED_POOL scope (pool-shared variant).
	// TIDB is still rejected (CLUSTER-only component).
	bad := []*entity.AutoscalePolicySpec{
		{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIDB,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_DISABLED,
		},
	}
	_, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
		apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-1",
		bad, nil, "req-sp-bad")
	require.Error(t, err)
	assert.Equal(t, codes.InvalidArgument, status.Code(err))
	assert.Contains(t, err.Error(), "SHARED_POOL scope only accepts")
}

// TestCreateOrReplaceAutoscalePolicy_SHARED_POOL_RejectsNonAllowlistKeys
// pins the (component, direction, capability, trigger) tuple allowlist at
// the SHARED_POOL entry point. The allowlist covers exactly the design doc
// §Core Configuration Mapping Table rows for SHARED_POOL scope:
//   - TIKV SCALE_OUT at (NODE_COUNT,CPU)/(DISK_SIZE,DISK)/(IOPS|THROUGHPUT,IO|DISK)
//   - Workers SCALE_IN/SCALE_OUT at (NODE_COUNT,CPU)
//   - SHARED_POOL (virtual) SCALE_OUT/SCALE_IN at (NODE_SPEC,RCU)
//   - TIDB_GROUP (virtual) SCALE_OUT/SCALE_IN at (NODE_SPEC,RCU)
//
// Anything outside this set is rejected with InvalidArgument BEFORE
// any DB write happens.
func TestCreateOrReplaceAutoscalePolicy_SHARED_POOL_RejectsNonAllowlistKeys(t *testing.T) {
	ctx := context.Background()
	srv := newTestServerBundle(nil, nil)

	t.Run("TIKV SCALE_IN rejected at SHARED_POOL scope", func(t *testing.T) {
		bad := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
			Direction:  apiv1.AutoscalePolicy_SCALE_IN,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_CPU,
			State:      apiv1.AutoscalePolicy_DISABLED,
		}}
		_, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-1",
			bad, nil, "req-sp-tikv-scalein")
		require.Error(t, err)
		assert.Equal(t, codes.InvalidArgument, status.Code(err))
		assert.Contains(t, err.Error(), "spec key outside mapping table")
	})

	t.Run("TIKV DISK_SIZE with wrong trigger rejected", func(t *testing.T) {
		bad := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_DISK_SIZE,
			Trigger:    apiv1.AutoscalePolicy_CPU, // wrong: must be DISK
			State:      apiv1.AutoscalePolicy_DISABLED,
		}}
		_, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-1",
			bad, nil, "req-sp-disk-cpu")
		require.Error(t, err)
		assert.Equal(t, codes.InvalidArgument, status.Code(err))
		assert.Contains(t, err.Error(), "spec key outside mapping table")
	})

	t.Run("TIKV NODE_COUNT with wrong trigger rejected", func(t *testing.T) {
		bad := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TARGET_TIKV,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_COUNT,
			Trigger:    apiv1.AutoscalePolicy_DISK, // wrong: must be CPU
			State:      apiv1.AutoscalePolicy_DISABLED,
		}}
		_, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-1",
			bad, nil, "req-sp-cpu-disk")
		require.Error(t, err)
		assert.Equal(t, codes.InvalidArgument, status.Code(err))
		assert.Contains(t, err.Error(), "spec key outside mapping table")
	})
}

// TestCreateOrReplaceAutoscalePolicy_SHARED_POOL_AcceptsVirtualComponents
// verifies that SHARED_POOL and TIDB_GROUP virtual components pass the
// entry-point allowlist checks at SHARED_POOL scope. These are logical
// components that map to enable_scale_shared_pool_by_ru and
// enable_scale_tidb_group_by_ru respectively.
func TestCreateOrReplaceAutoscalePolicy_SHARED_POOL_AcceptsVirtualComponents(t *testing.T) {
	ctx := context.Background()
	mockBundle := repomnocks.NewAutoscalePolicyBundleRepo(t)
	mockRC := repomnocks.NewRuConfigRepo(t)
	srv := newTestServerBundle(mockBundle, mockRC)

	t.Run("SHARED_POOL virtual component accepted and writes FeatureFlags", func(t *testing.T) {
		specs := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_SHARED_POOL,
			Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
			Capability: apiv1.AutoscalePolicy_NODE_SPEC,
			Trigger:    apiv1.AutoscalePolicy_RCU,
			State:      apiv1.AutoscalePolicy_ENABLED,
		}}
		// POST uses dense ReplaceFeatureFlags. The SHARED_POOL spec
		// maps to EnableScaleSharedPoolByRu=true; the other 4 flags
		// should be nil (= no override at this scope).
		mockRC.On("ReplaceFeatureFlags", ctx, "sp-1", apiv1.RuConfig_SHARED_POOL, mock.MatchedBy(func(ff *entity.RuConfigFeatureFlags) bool {
			return ff != nil &&
				ff.EnableScaleSharedPoolByRu != nil && *ff.EnableScaleSharedPoolByRu == true &&
				ff.EnableScaleTidbGroupByRu == nil &&
				ff.EnableAutoscaleTikvCpuScaleOut == nil &&
				ff.EnableAutoscaleDiskExpand == nil &&
				ff.EnableAutoscaleIopsThroughputExpand == nil &&
				ff.EnableAutoscaleTiproxyScaleIn == nil
		})).Return(nil).Once()

		resp, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-1",
			specs, nil, "req-sp-virtual")
		require.NoError(t, err)
		require.NotNil(t, resp)
		// 1 target: ru_configs write (SYNCED).
		// (The autoscale_policies table is dormant for
		// SHARED_POOL/CLUSTER; only FeatureFlags is written.)
		require.Len(t, resp.Targets, 1)
		var sawSynced bool
		for _, tgt := range resp.Targets {
			if tgt.GetStatus() == apiv1.AutoscalePolicyApplyStatus_APPLY_STATUS_SYNCED {
				sawSynced = true
			}
		}
		assert.True(t, sawSynced, "expected at least one SYNCED target (ru_configs write)")
	})

	t.Run("TIDB_GROUP virtual component accepted and writes FeatureFlags", func(t *testing.T) {
		specs := []*entity.AutoscalePolicySpec{{
			TargetType: apiv1.AutoscaleTargetType_TIDB_GROUP,
			Direction:  apiv1.AutoscalePolicy_SCALE_IN,
			Capability: apiv1.AutoscalePolicy_NODE_SPEC,
			Trigger:    apiv1.AutoscalePolicy_RCU,
			State:      apiv1.AutoscalePolicy_DISABLED,
		}}
		mockRC.On("ReplaceFeatureFlags", ctx, "sp-2", apiv1.RuConfig_SHARED_POOL, mock.MatchedBy(func(ff *entity.RuConfigFeatureFlags) bool {
			return ff != nil &&
				ff.EnableScaleTidbGroupByRu != nil && *ff.EnableScaleTidbGroupByRu == false &&
				ff.EnableScaleSharedPoolByRu == nil
		})).Return(nil).Once()

		resp, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-2",
			specs, nil, "req-sp-tg")
		require.NoError(t, err)
		require.NotNil(t, resp)
		require.Len(t, resp.Targets, 1, "single ru_config.FeatureFlags write")
	})

	t.Run("both virtual components in one request", func(t *testing.T) {
		specs := []*entity.AutoscalePolicySpec{
			{
				TargetType: apiv1.AutoscaleTargetType_SHARED_POOL,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_SPEC,
				Trigger:    apiv1.AutoscalePolicy_RCU,
				State:      apiv1.AutoscalePolicy_ENABLED,
			},
			{
				TargetType: apiv1.AutoscaleTargetType_TIDB_GROUP,
				Direction:  apiv1.AutoscalePolicy_SCALE_OUT,
				Capability: apiv1.AutoscalePolicy_NODE_SPEC,
				Trigger:    apiv1.AutoscalePolicy_RCU,
				State:      apiv1.AutoscalePolicy_DISABLED,
			},
		}
		mockRC.On("ReplaceFeatureFlags", ctx, "sp-3", apiv1.RuConfig_SHARED_POOL, mock.MatchedBy(func(ff *entity.RuConfigFeatureFlags) bool {
			return ff != nil &&
				ff.EnableScaleSharedPoolByRu != nil && *ff.EnableScaleSharedPoolByRu == true &&
				ff.EnableScaleTidbGroupByRu != nil && *ff.EnableScaleTidbGroupByRu == false
		})).Return(nil).Once()

		resp, err := srv.CreateOrReplaceAutoscalePolicy(ctx,
			apiv1.AutoscalePolicyScope_SCOPE_SHARED_POOL, "sp-3",
			specs, nil, "req-sp-both")
		require.NoError(t, err)
		require.NotNil(t, resp)
	})
}
