package action

import (
	"context"
	"errors"
	"fmt"
	"strconv"

	"github.com/samber/lo"
	apiv1 "github.com/tidbcloud/cluster-service-ng/api/cluster-service-ng/v1beta"
	"github.com/tidbcloud/cluster-service-ng/config"
	"github.com/tidbcloud/cluster-service-ng/domain/entity"
	"github.com/tidbcloud/cluster-service-ng/domain/workflow"
	"github.com/tidbcloud/cluster-service-ng/pkg/autoscale/resolve"
	"github.com/tidbcloud/cluster-service-ng/pkg/dataplane"
	"github.com/tidbcloud/cluster-service-ng/pkg/metricsprovider"
	"github.com/tidbcloud/cluster-service-ng/pkg/runtime/actions"
	schemav1 "github.com/tidbcloud/cluster-service-ng/pkg/storage/etcd/schema/v1"
	"github.com/tidbcloud/cluster-service-ng/pkg/storage/repo"
	templateversion "github.com/tidbcloud/cluster-service-ng/pkg/template/version"
	"github.com/tidbcloud/cluster-service-ng/pkg/utils"
	"github.com/tidbcloud/tiinf/monitor/tilog"
	"go.uber.org/zap"
	"google.golang.org/protobuf/encoding/protojson"
	autoscalingv2 "k8s.io/api/autoscaling/v2"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/api/resource"
	"k8s.io/apimachinery/pkg/types"
	"sigs.k8s.io/controller-runtime/pkg/client"
)

type ApplyComponentHpaAction struct {
	dataplaneManager          dataplane.Manager
	sharedPoolRepo            repo.SharedPoolRepo
	autoscalePolicyBundleRepo repo.AutoscalePolicyBundleRepo
	ruConfigRepo              repo.RuConfigRepo
	templateVersionSvc        *templateversion.Service
	bizConfig                 *config.BizConfig
}

const ApplyComponentHpaActionName = "apply-component-hpa"

var errAutoscalePolicyUnsupportedForMultiTenant = errors.New("autoscale policy is not supported for multi-tenant shared pools")

// getHPAName returns the HPA name for a component based on component type.
// Matches naming convention in HPA templates.
func getHPAName(comp *entity.Component) string {
	groupName := entity.GetComponentGroupName(comp)
	switch comp.GetComponentType() {
	case apiv1.ComponentType_TIKV_WORKER, apiv1.ComponentType_COPROCESSOR_WORKER:
		return "tikvworker-" + groupName
	case apiv1.ComponentType_TIPROXY:
		return "tiproxygroup-" + groupName
	default: // TIDB, TIDB_WORKER
		return "tidbgroup-" + groupName
	}
}

type hpaPolicySelection struct {
	scaleUp          autoscalingv2.ScalingPolicySelect
	scaleDown        autoscalingv2.ScalingPolicySelect
	reconcileScaleUp bool
}

func defaultHPAPolicySelection(componentType apiv1.ComponentType) hpaPolicySelection {
	return hpaPolicySelection{
		scaleUp:   resolve.OriginalScaleUpPolicy(),
		scaleDown: resolve.OriginalScaleDownPolicy(componentType),
	}
}

func selectHPAPolicy(
	ctx context.Context,
	bizConfig *config.BizConfig,
	bundleRepo repo.AutoscalePolicyBundleRepo,
	taskTenantID string,
	sharedPool *entity.SharedPool,
	componentType apiv1.ComponentType,
) (hpaPolicySelection, error) {
	baseline := defaultHPAPolicySelection(componentType)
	if bizConfig == nil || !bizConfig.AutoscalePolicy.IsEnforced() {
		return baseline, nil
	}
	if bundleRepo == nil {
		return baseline, fmt.Errorf("autoscale policy bundle repository is unavailable while policy is enforced")
	}
	tenantID, err := resolveHPAPolicyTenantID(taskTenantID, sharedPool)
	if errors.Is(err, errAutoscalePolicyUnsupportedForMultiTenant) {
		return baseline, nil
	}
	if err != nil {
		return baseline, err
	}

	scaleUp, scaleDown, err := resolve.ResolveHPABehaviorFromBundle(ctx, bundleRepo, tenantID, componentType)
	if err != nil {
		return baseline, err
	}
	return hpaPolicySelection{
		scaleUp:          scaleUp,
		scaleDown:        scaleDown,
		reconcileScaleUp: true,
	}, nil
}

// resolveHPAPolicyTenantID uses the persisted provider tag as the authority.
// Task arguments are hints only because several legacy HPA-bearing DAGs do not
// carry TenantIdKey, while stale tasks may carry an incorrect value.
func resolveHPAPolicyTenantID(taskTenantID string, sharedPool *entity.SharedPool) (string, error) {
	tenantID, err := entity.ResolveSharedPoolTenantID(sharedPool)
	if err != nil {
		return "", fmt.Errorf("tenant id is unavailable while policy is enforced: %w", err)
	}
	if tenantID == schemav1.MultiTenantTag {
		return "", errAutoscalePolicyUnsupportedForMultiTenant
	}
	if taskTenantID != "" && taskTenantID != tenantID {
		return "", fmt.Errorf("task tenant id %q does not match shared pool tenant id %q", taskTenantID, tenantID)
	}
	return tenantID, nil
}

// applyTiProxyScaleInGate overlays enable_autoscale_tiproxy_scale_in onto
// the HPA ScaleDown policy. Callers must only invoke this when
// selectHPAPolicy returned the per-component baseline
// (reconcileScaleUp=false): disabled mode and enforce+multi-tenant.
// Enforce mode reads the bundle instead and must not overlay FeatureFlags
// — the same flag/YAML/bundle write has opposite HPA results on the two
// sides of AutoscalePolicy.IsEnforced(). See design doc §Authority when
// configs conflict.
//
// The overlay is per-pool, not region-wide. YAML
// `server.ruConfig.enableAutoscaleTiproxyScaleIn` is not an HPA input:
// GetRuConfigWithFound reports when the pool row is missing, and that
// case is ignored. Only a persisted SHARED_POOL FeatureFlags value that
// is explicitly true (SetRuConfig / tenant freeze) flips ScaleDown to
// MinChange. Flag false or unset keeps Disabled.
//
// For existing HPAs, explicit SetRuConfig(SHARED_POOL) flag writes sync
// ScaleDown through the server write path. Unrelated ApplyComponentHpaAction
// reconciles remain fill-nil-only so manual HPA writes are not clobbered.
//
// A nil ruConfigRepo, a missing/unset flag, a YAML fallback, or a
// GetRuConfig error all keep the baseline. Fallback is the safe
// direction (Disabled = no scale-in) and must not block HPA
// create/reconcile/delete. A nil repo also covers migrations that
// construct this action without a repo.
func applyTiProxyScaleInGate(
	ctx context.Context,
	ruConfigRepo repo.RuConfigRepo,
	sharedPoolID string,
	selection hpaPolicySelection,
) hpaPolicySelection {
	if ruConfigRepo == nil {
		return selection
	}
	rc, found, err := ruConfigRepo.GetRuConfigWithFound(ctx, sharedPoolID, apiv1.RuConfig_SHARED_POOL)
	if err != nil {
		tilog.Warn(ctx, "get ru config for tiproxy scale-in gate failed, keeping baseline ScaleDown",
			zap.String("sharedPoolId", sharedPoolID),
			zap.Error(err))
		return selection
	}
	if !found {
		return selection
	}
	ff := rc.GetFeatureFlags()
	if ff == nil || ff.EnableAutoscaleTiproxyScaleIn == nil || !*ff.EnableAutoscaleTiproxyScaleIn {
		return selection
	}
	selection.scaleDown = resolve.SelectPolicyFromComponentState(
		apiv1.ComponentType_TIPROXY, apiv1.AutoscalePolicy_SCALE_IN, true)
	return selection
}

func ensureCPUScaleInGateMetric(hpa *autoscalingv2.HorizontalPodAutoscaler, metricName string) bool {
	desiredMetric := autoscalingv2.MetricSpec{
		Type: autoscalingv2.ObjectMetricSourceType,
		Object: &autoscalingv2.ObjectMetricSource{
			Metric: autoscalingv2.MetricIdentifier{
				Name: metricName,
			},
			DescribedObject: autoscalingv2.CrossVersionObjectReference{
				APIVersion: autoscalingv2.SchemeGroupVersion.String(),
				Kind:       "HorizontalPodAutoscaler",
				Name:       hpa.Name,
			},
			Target: autoscalingv2.MetricTarget{
				Type:  autoscalingv2.ValueMetricType,
				Value: resource.NewQuantity(1, resource.DecimalSI),
			},
		},
	}

	for i := range hpa.Spec.Metrics {
		metric := &hpa.Spec.Metrics[i]
		if metric.Type != autoscalingv2.ObjectMetricSourceType || metric.Object == nil {
			continue
		}
		if metric.Object.Metric.Name != metricName {
			continue
		}

		changed := false
		if metric.Object.DescribedObject.APIVersion != desiredMetric.Object.DescribedObject.APIVersion {
			metric.Object.DescribedObject.APIVersion = desiredMetric.Object.DescribedObject.APIVersion
			changed = true
		}
		if metric.Object.DescribedObject.Kind != desiredMetric.Object.DescribedObject.Kind {
			metric.Object.DescribedObject.Kind = desiredMetric.Object.DescribedObject.Kind
			changed = true
		}
		if metric.Object.DescribedObject.Name != desiredMetric.Object.DescribedObject.Name {
			metric.Object.DescribedObject.Name = desiredMetric.Object.DescribedObject.Name
			changed = true
		}
		if metric.Object.Target.Type != desiredMetric.Object.Target.Type {
			metric.Object.Target.Type = desiredMetric.Object.Target.Type
			changed = true
		}
		if metric.Object.Target.Value == nil || metric.Object.Target.Value.Value() != desiredMetric.Object.Target.Value.Value() {
			metric.Object.Target.Value = resource.NewQuantity(desiredMetric.Object.Target.Value.Value(), resource.DecimalSI)
			changed = true
		}
		return changed
	}

	hpa.Spec.Metrics = append([]autoscalingv2.MetricSpec{desiredMetric}, hpa.Spec.Metrics...)
	return true
}

func ensureCPUScaleInGateForHPA(hpa *autoscalingv2.HorizontalPodAutoscaler, cfg metricsprovider.CPUScaleInGateConfig) bool {
	if hpa == nil {
		return false
	}

	changed := false
	if hpa.Annotations == nil {
		hpa.Annotations = make(map[string]string)
	}
	threshold := hpa.Annotations[cfg.ThresholdAnnotation]
	if threshold == "" {
		threshold = strconv.FormatInt(cfg.DefaultThreshold, 10)
		hpa.Annotations[cfg.ThresholdAnnotation] = threshold
		changed = true
	}

	if ensureCPUScaleInGateMetric(hpa, cfg.MetricName) {
		changed = true
	}
	return changed
}

// ensureHPAHasScaleInGate adds the CPU scale-in gate metric and threshold
// annotation to a newly created TiDB or TiProxy HPA. The gate metric acts as a
// second line of defense: scale-in only fires when average CPU utilization is
// below the configured threshold (default 30%).
//
// The function silently returns on failure — gate injection errors should not
// block HPA creation. Missing gate metric does not prevent the HPA from scaling;
// it only removes the additional CPU-threshold safety check.
func ensureHPAHasScaleInGate(
	ctx context.Context,
	remoteCli client.Client,
	comp *entity.Component,
	namespace, hpaName, sharedPoolId string,
	desiredDownPolicy, desiredUpPolicy autoscalingv2.ScalingPolicySelect,
	reconcileScaleUp bool,
) {
	hpa := &autoscalingv2.HorizontalPodAutoscaler{}
	componentType := entity.GetComponentTypeName(comp)
	minReplicas := int32(comp.GetNodeQuota().GetMinNodeCount())
	maxReplicas := int32(comp.GetNodeQuota().GetMaxNodeCount())

	for retry := 0; retry < 3; retry++ {
		if fetchErr := remoteCli.Get(ctx, types.NamespacedName{Namespace: namespace, Name: hpaName}, hpa); fetchErr != nil {
			if apierrors.IsNotFound(fetchErr) && retry < 2 {
				tilog.Warn(ctx, "HPA not found during scale-in gate injection, retrying",
					zap.String("hpaName", hpaName),
					zap.String("sharedPoolId", sharedPoolId),
					zap.String("componentId", comp.GetId()),
					zap.String("componentType", componentType),
					zap.Int("retry", retry))
				continue
			}
			tilog.Warn(ctx, "failed to re-fetch newly created HPA for scale-in gate",
				zap.String("hpaName", hpaName),
				zap.String("sharedPoolId", sharedPoolId),
				zap.String("componentId", comp.GetId()),
				zap.String("componentType", componentType),
				zap.Error(fetchErr))
			return
		}
		changed := reconcileExistingHPA(hpa, comp, minReplicas, maxReplicas, desiredDownPolicy, desiredUpPolicy, reconcileScaleUp)
		if !changed {
			return
		}
		if updateErr := remoteCli.Update(ctx, hpa); updateErr != nil {
			if apierrors.IsConflict(updateErr) && retry < 2 {
				continue
			}
			tilog.Warn(ctx, "failed to update HPA with scale-in gate",
				zap.String("hpaName", hpaName),
				zap.String("sharedPoolId", sharedPoolId),
				zap.String("componentId", comp.GetId()),
				zap.String("componentType", componentType),
				zap.Error(updateErr))
		}
		return
	}
}

// reconcileExistingHPA syncs MinReplicas/MaxReplicas and fills nil
// Behavior.SelectPolicy slots from the desired values. Existing non-nil
// SelectPolicy values are not overwritten, so explicit SetHPAScaleBehavior /
// bundle writes are preserved across unrelated component reconciles.
func reconcileExistingHPA(
	hpa *autoscalingv2.HorizontalPodAutoscaler,
	comp *entity.Component,
	minReplicas, maxReplicas int32,
	desiredDownPolicy, desiredUpPolicy autoscalingv2.ScalingPolicySelect,
	reconcileScaleUp bool,
) bool {
	changed := false

	if hpa.Spec.MinReplicas == nil || *hpa.Spec.MinReplicas != minReplicas {
		hpa.Spec.MinReplicas = lo.ToPtr(minReplicas)
		changed = true
	}
	if hpa.Spec.MaxReplicas != maxReplicas {
		hpa.Spec.MaxReplicas = maxReplicas
		changed = true
	}

	if hpa.Spec.Behavior == nil {
		hpa.Spec.Behavior = &autoscalingv2.HorizontalPodAutoscalerBehavior{}
	}
	if hpa.Spec.Behavior.ScaleDown == nil {
		hpa.Spec.Behavior.ScaleDown = &autoscalingv2.HPAScalingRules{}
	}
	if hpa.Spec.Behavior.ScaleDown.SelectPolicy == nil {
		hpa.Spec.Behavior.ScaleDown.SelectPolicy = lo.ToPtr(desiredDownPolicy)
		changed = true
	}
	if reconcileScaleUp {
		if hpa.Spec.Behavior.ScaleUp == nil {
			hpa.Spec.Behavior.ScaleUp = &autoscalingv2.HPAScalingRules{}
		}
		if hpa.Spec.Behavior.ScaleUp.SelectPolicy == nil {
			hpa.Spec.Behavior.ScaleUp.SelectPolicy = lo.ToPtr(desiredUpPolicy)
			changed = true
		}
	}

	// For TiDB and TiProxy components, unconditionally ensure the scale-in gate metric and threshold
	// annotation are present. This is safe even when scale-in is disabled (SelectPolicy =
	// DisabledPolicySelect) because the gate metric has no effect while scale-in is off.
	// Adding it proactively ensures protection is in place as soon as scale-in is enabled,
	// without requiring additional manual configuration.
	switch comp.GetComponentType() {
	case apiv1.ComponentType_TIDB:
		if ensureCPUScaleInGateForHPA(hpa, metricsprovider.TiDBCPUScaleInGateConfig) {
			changed = true
		}
	case apiv1.ComponentType_TIPROXY:
		if ensureCPUScaleInGateForHPA(hpa, metricsprovider.TiProxyCPUScaleInGateConfig) {
			changed = true
		}
	}

	return changed
}

func NewApplyComponentHpaAction(
	sharedPoolRepo repo.SharedPoolRepo,
	autoscalePolicyBundleRepo repo.AutoscalePolicyBundleRepo,
	dataplaneManager dataplane.Manager,
	templateVersionSvc *templateversion.Service,
	bizConfig *config.BizConfig,
	ruConfigRepo repo.RuConfigRepo,
) actions.Action {
	return &ApplyComponentHpaAction{
		sharedPoolRepo:            sharedPoolRepo,
		autoscalePolicyBundleRepo: autoscalePolicyBundleRepo,
		ruConfigRepo:              ruConfigRepo,
		dataplaneManager:          dataplaneManager,
		templateVersionSvc:        templateVersionSvc,
		bizConfig:                 bizConfig,
	}
}

func (a *ApplyComponentHpaAction) Name() string {
	return ApplyComponentHpaActionName
}

func (a *ApplyComponentHpaAction) Invoke(args actions.ActionArgs) error {
	// generate context
	ctx := utils.GenerateContext(args)
	tilog.Info(ctx, "apply component hpa action")
	if skip, _ := args.GetString(workflow.SkipComponentHPAApplyKey); skip == workflow.SkipComponentHPAApplyValue {
		tilog.Info(ctx, "skip component hpa apply because the DAG has no component mutation target")
		return nil
	}

	if !a.bizConfig.EnableHpa {
		tilog.Info(ctx, "hpa is disabled")
		return nil
	}

	_, err := args.GetString(workflow.ComponentIdsKey)
	if err != nil {
		return fmt.Errorf("target component id is required, but not found")
	}

	sharedPoolId, err := args.GetString(workflow.SharedPoolIdKey)
	if err != nil {
		return fmt.Errorf("failed to get sharedPool id: %w", err)
	}

	// load sharedPool
	sharedPool, err := a.sharedPoolRepo.Load(ctx, sharedPoolId, false)
	if err != nil {
		return fmt.Errorf("failed to load sharedPool: %w", err)
	}

	sharedPoolStr, err := args.GetString(workflow.SharedPoolKey)
	if err != nil {
		return fmt.Errorf("failed to get sharedPool: %w", err)
	}

	var tempSharedPool entity.SharedPool
	err = protojson.Unmarshal([]byte(sharedPoolStr), &tempSharedPool)
	if err != nil {
		tilog.Error(ctx, "failed to unmarshal sharedPool", zap.Error(err))
		return fmt.Errorf("failed to unmarshal sharedPool: %w", err)
	}

	// TOPOLOGY_DRIVEN shared pools are managed by the caller; skip HPA entirely.
	if tempSharedPool.GetCapacityMode() == apiv1.CapacityMode_TOPOLOGY_DRIVEN {
		tilog.Info(ctx, "skip hpa for TOPOLOGY_DRIVEN shared pool", zap.String("sharedPoolId", sharedPoolId))
		return nil
	}

	sharedPool.ComponentGroups = tempSharedPool.GetComponentGroups()

	for _, comp := range sharedPool.GetComponentGroups() {

		// check if the component is target component
		if !workflow.CheckIsTargetComponent(ctx, comp, args) {
			// Defensive: surfaces a future regression of the
			// ComponentIdsKey bug (PR-C.1). If a HAP-eligible
			// component is filtered out here, ComponentIdsKey
			// was not populated correctly by the caller.
			if lo.Contains(entity.EnableHAPComponent, comp.GetComponentType()) {
				tilog.Error(ctx, "ApplyComponentHpaAction: HAP-eligible component filtered by CheckIsTargetComponent",
					zap.String("sharedPoolId", sharedPoolId),
					zap.String("componentId", comp.GetId()),
					zap.String("componentType", entity.GetComponentTypeName(comp)),
				)
			}
			continue
		}

		if !lo.Contains(entity.EnableHAPComponent, comp.GetComponentType()) {
			tilog.Info(ctx, "component type is not supported for hpa", zap.String("componentId", comp.GetId()), zap.String("componentType", entity.GetComponentTypeName(comp)))
			continue
		}

		if comp.GetPhase() == apiv1.Component_AVAILABLE {
			tilog.Info(ctx, "component is already available, skip applying", zap.String("componentId", comp.GetId()), zap.String("componentType", entity.GetComponentTypeName(comp)))
			continue
		}

		// Policy selection is gated independently from the legacy HPA
		// lifecycle. Disabled mode never reads the bundle and uses the
		// built-in per-component baseline.
		taskTenantID, _ := args.GetString(workflow.TenantIdKey)
		selection, resolveErr := selectHPAPolicy(
			ctx, a.bizConfig, a.autoscalePolicyBundleRepo, taskTenantID, sharedPool, comp.GetComponentType())
		if resolveErr != nil {
			tilog.Error(ctx, "resolve HPA behavior from bundle failed while policy is enforced",
				zap.String("sharedPoolId", sharedPoolId),
				zap.String("componentId", comp.GetId()),
				zap.String("componentType", entity.GetComponentTypeName(comp)),
				zap.Error(resolveErr))
			return fmt.Errorf("failed to resolve HPA behavior while autoscale policy is enforced: %w", resolveErr)
		}
		// Overlay only when selectHPAPolicy returned the per-component
		// baseline (reconcileScaleUp=false): disabled mode and
		// enforce+multi-tenant. Bundle-resolved ScaleDown is left alone.
		if comp.GetComponentType() == apiv1.ComponentType_TIPROXY && !selection.reconcileScaleUp {
			selection = applyTiProxyScaleInGate(ctx, a.ruConfigRepo, sharedPoolId, selection)
		}

		// render template
		templArgs := map[string]string{
			"ComponentGroup":  entity.GetComponentGroupName(comp),
			"ComponentType":   entity.GetComponentTypeName(comp),
			"ClusterId":       sharedPool.GetId(),
			"SharedPoolId":    sharedPool.GetId(),
			"ScaleDownPolicy": string(selection.scaleDown),
			"ScaleUpPolicy":   string(selection.scaleUp),
		}

		// tikv worker use dxf_hscale_tikv_worker_hint_v2
		// tikv worker use more aggressive hpa scale in policy
		if comp.GetComponentType() == apiv1.ComponentType_TIKV_WORKER {
			templArgs["CPUAverageUtilization"] = "70" // use the value serverless uses
		}

		if comp.GetComponentType() == apiv1.ComponentType_COPROCESSOR_WORKER {
			templArgs["CPUAverageUtilization"] = "55" // use the value serverless uses
		}

		// if max node count not set, set it to node count
		if comp.GetNodeQuota().GetMaxNodeCount() == 0 {
			comp.GetNodeQuota().MaxNodeCount = comp.GetNodeQuota().GetNodeCount()
		}
		templArgs["MaxNodeCount"] = strconv.FormatUint(uint64(comp.GetNodeQuota().GetMaxNodeCount()), 10)
		templArgs["MinNodeCount"] = strconv.FormatUint(uint64(comp.GetNodeQuota().GetMinNodeCount()), 10)

		templateName, err := entity.GetComponentHPATemplateName(comp)
		if err != nil {
			tilog.Error(ctx, "failed to get component hpa template name", zap.String("componentId", comp.GetId()), zap.String("componentType", entity.GetComponentTypeName(comp)), zap.Error(err))
			return fmt.Errorf("failed to get component hpa template name: %w", err)
		}

		remoteCli, err := a.dataplaneManager.GetClient(sharedPool.GetDataplaneName())
		if err != nil {
			tilog.Error(ctx, "failed to get remote client for shared pool", zap.String("dataplaneName", sharedPool.GetDataplaneName()), zap.Error(err))
			return fmt.Errorf("failed to get remote client for shared pool: %w", err)
		}

		namespace := "tidb" + sharedPool.GetId()
		hpaName := getHPAName(comp)

		existingHPA := &autoscalingv2.HorizontalPodAutoscaler{}
		err = remoteCli.Get(ctx, types.NamespacedName{Namespace: namespace, Name: hpaName}, existingHPA)

		switch {
		case apierrors.IsNotFound(err) || comp.GetPhase() == apiv1.Component_DELETING:
			err = templateExecuteWithVersionResolver(ctx, remoteCli, templateName, "", templArgs, comp.GetPhase() == apiv1.Component_DELETING, a.templateVersionSvc)
			if err != nil {
				tilog.Error(ctx, "failed to execute hpa template", zap.String("componentId", comp.GetId()), zap.String("componentType", entity.GetComponentTypeName(comp)), zap.Error(err))
				return fmt.Errorf("failed to execute hpa template: %w", err)
			}
			if comp.GetPhase() != apiv1.Component_DELETING {
				switch comp.GetComponentType() {
				case apiv1.ComponentType_TIDB, apiv1.ComponentType_TIPROXY:
					// Shared helper: only re-fetches + reconcileExistingHPA (which
					// branches on component type for the actual gate injection).
					ensureHPAHasScaleInGate(ctx, remoteCli, comp, namespace, hpaName, sharedPoolId, selection.scaleDown, selection.scaleUp, selection.reconcileScaleUp)
				}
			}
		case err != nil:
			tilog.Error(ctx, "failed to get existing HPA", zap.String("hpaName", hpaName), zap.Error(err))
			return fmt.Errorf("failed to get existing HPA: %w", err)
		default:
			minNodeCountStr, ok := templArgs["MinNodeCount"]
			if !ok {
				return fmt.Errorf("MinNodeCount not set in template args")
			}
			maxNodeCountStr, ok := templArgs["MaxNodeCount"]
			if !ok {
				return fmt.Errorf("MaxNodeCount not set in template args")
			}

			minReplicas, err := strconv.ParseInt(minNodeCountStr, 10, 32)
			if err != nil {
				return fmt.Errorf("invalid MinNodeCount: %w", err)
			}
			maxReplicas, err := strconv.ParseInt(maxNodeCountStr, 10, 32)
			if err != nil {
				return fmt.Errorf("invalid MaxNodeCount: %w", err)
			}

			if err := reconcileAndUpdateHPA(ctx, remoteCli, existingHPA, comp, int32(minReplicas), int32(maxReplicas), selection.scaleDown, selection.scaleUp, selection.reconcileScaleUp, templateName, templArgs, a.templateVersionSvc); err != nil {
				return err
			}
		}
	}

	return nil
}

func reconcileAndUpdateHPA(
	ctx context.Context,
	remoteCli client.Client,
	hpa *autoscalingv2.HorizontalPodAutoscaler,
	comp *entity.Component,
	minReplicas, maxReplicas int32,
	desiredDownPolicy, desiredUpPolicy autoscalingv2.ScalingPolicySelect,
	reconcileScaleUp bool,
	templateName string,
	templArgs map[string]string,
	templateVersionSvc *templateversion.Service,
) error {
	for i := 0; i < 3; i++ {
		changed := reconcileExistingHPA(hpa, comp, minReplicas, maxReplicas, desiredDownPolicy, desiredUpPolicy, reconcileScaleUp)
		if !changed {
			return nil
		}
		if err := remoteCli.Update(ctx, hpa); err != nil {
			if apierrors.IsNotFound(err) {
				tilog.Warn(ctx, "HPA deleted during update, creating via template", zap.String("hpaName", hpa.Name))
				return templateExecuteWithVersionResolver(ctx, remoteCli, templateName, "", templArgs, false, templateVersionSvc)
			}
			if !apierrors.IsConflict(err) {
				tilog.Error(ctx, "failed to reconcile existing HPA", zap.String("hpaName", hpa.Name), zap.Error(err))
				return fmt.Errorf("failed to reconcile existing HPA: %w", err)
			}
			// Conflict: re-fetch and retry
			if err := remoteCli.Get(ctx, client.ObjectKeyFromObject(hpa), hpa); err != nil {
				if apierrors.IsNotFound(err) {
					tilog.Warn(ctx, "HPA deleted during retry, creating via template", zap.String("hpaName", hpa.Name))
					return templateExecuteWithVersionResolver(ctx, remoteCli, templateName, "", templArgs, false, templateVersionSvc)
				}
				return fmt.Errorf("failed to re-fetch HPA for retry: %w", err)
			}
			continue
		}
		tilog.Info(ctx, "reconciled existing HPA",
			zap.String("hpaName", hpa.Name),
			zap.Int64("minReplicas", int64(minReplicas)),
			zap.Int64("maxReplicas", int64(maxReplicas)),
			zap.String("scaleDownPolicy", templArgs["ScaleDownPolicy"]))
		return nil
	}
	return fmt.Errorf("failed to reconcile HPA after retries")
}
