// Package resolve encapsulates HPA Behavior resolution from autoscale
// policy bundle specs, plus legacy FeatureFlags resolution for shared-pool
// creation. It is shared between domain/server (gRPC layer) and
// domain/workflow/action (K8s reconcile) — a leaf package to avoid the
// import cycle that would result from workflow/action importing server.
//
// Four rules live here:
//  1. PriorityResolveForSharedPool{FromBundle}: freezes a tenant's
//     autoscale flag overrides into the shared pool's RuConfig row at
//     create time. See design doc §CreateSharedPool — Freezes FeatureFlags.
//     The
//     `FromBundle` variant is the preferred path — it reads BOTH
//     DEFAULT and TENANT bundle rows itself in one pass, eliminating
//     the double-resolution bug-class described in the PR review.
//  2. OriginalScale{Up,Down}Policy: per-component hardcoded HPA defaults
//     used as the fallback tier in rule 4 and as the disabled-mode
//     baseline. These helpers do not read FeatureFlags. The one
//     FeatureFlags→HPA overlay is applyTiProxyScaleInGate in
//     ApplyComponentHpaAction: it runs only when
//     AutoscalePolicy.IsEnforced() is false (or enforce+multi-tenant)
//     and only for TIPROXY ScaleDown. Enforce mode uses rule 4 and
//     does not read FeatureFlags. The same operator action therefore
//     has opposite HPA results on the two sides of that switch — see
//     design doc §Authority when configs conflict.
//  3. SelectPolicyFromComponentState: the **single source of truth** for
//     translating a (componentType, direction, enabled) triple into an
//     HPA SelectPolicy (see design doc §HPA Behavior Assembly Rule).
//     Used by both the bundle-aware reconcile path (rule 4, via
//     pickSelectPolicyFromSpecs) and the legacy write path
//     (domain/server.set_hpa_scale_behavior.getSelectPolicy →
//     writeClusterHPABehavior → SetHPAScaleBehavior). Consolidating
//     these into one helper eliminates the duplicated per-component
//     baselines (B1) and the Disabled→MinChange flip oversight in the
//     legacy write path (C1).
//  4. ResolveHPABehaviorFromBundle: assembles the desired HPA
//     `Behavior.Scale{Up,Down}.SelectPolicy` for a component by merging
//     DEFAULT and TENANT bundle rows (TENANT wins) and falling back to
//     `OriginalScale*`. Called at HPA reconcile time when policy is
//     enforced. Disabled mode never calls this helper. CLUSTER scope
//     is NOT consulted — see design doc §HPA Behavior Assembly Rule.
package resolve

import (
	"context"
	"fmt"

	apiv1 "github.com/tidbcloud/cluster-service-ng/api/cluster-service-ng/v1beta"
	"github.com/tidbcloud/cluster-service-ng/domain/entity"
	specpkg "github.com/tidbcloud/cluster-service-ng/pkg/autoscale/spec"
	"github.com/tidbcloud/cluster-service-ng/pkg/storage/repo"
	"k8s.io/api/autoscaling/v2"
)

// defaultBundleSubjectID is the subject_id used for the SCOPE_DEFAULT
// row in the autoscale_policy_bundle_specs table. There is at most one
// such row in the policy bundle table. Must match
// `defaultAutoscalePolicySubjectID` in domain/server/autoscale_policy_bundle.go.
const defaultBundleSubjectID = "__default__"

// TenantBundleSpec is the typed representation of the 6 autoscale flag
// overrides carried by the bundle table's TENANT row. The bundle
// table's protojson-encoded AutoscalePolicySpec list is parsed into
// this struct by the caller (the bundle repo lives in a different
// package to avoid the import cycle).
//
// Each flag is proto3 `optional bool` semantics: nil = "not set"
// (server default takes over); true/false = "explicit value". The
// bundle table is the canonical source under the active design —
// legacy `tenant.ru_configs.FeatureFlags` is reserved for
// non-autoscale writes (EnableTici, Components map).
type TenantBundleSpec struct {
	EnableScaleSharedPoolByRu           *bool
	EnableScaleTidbGroupByRu            *bool
	EnableAutoscaleTikvCpuScaleOut      *bool
	EnableAutoscaleDiskExpand           *bool
	EnableAutoscaleIopsThroughputExpand *bool
	EnableAutoscaleTiproxyScaleIn       *bool
}

// PriorityResolveForSharedPool takes a tenant's bundle specs
// (loaded from the bundle table's TENANT row by the caller) and the
// server default (loaded from YAML config.RuConfig; all flags are
// always set because YAML always has a value). The returned RuConfig
// has all 6 autoscale flags set (non-nil) and is meant to be written
// into the shared pool's RuConfig row at creation time.
//
// Resolution rule per field (per the active design's
// `if tenant.field != nil → tenant.field; else → server_default`
// rule):
//   - if tenantSpec.field != nil (tenant set the field, true or false) → use tenant
//   - else (tenant did not set) → use server default
//
// Note: this overload ONLY sees the TENANT row. It misses the DEFAULT
// row from the bundle table. New callers should prefer
// `PriorityResolveForSharedPoolFromBundle` (which loads DEFAULT + TENANT
// itself in a single pass) — see design doc §CreateSharedPool — Freezes
// FeatureFlags. This legacy overload is kept for callers that already
// have the tenant spec in hand and for unit tests that pre-date the
// bundle-table path.
func PriorityResolveForSharedPool(tenantSpec *TenantBundleSpec, serverDefault *entity.RuConfig) *entity.RuConfig {
	if tenantSpec == nil {
		tenantSpec = &TenantBundleSpec{}
	}
	if serverDefault == nil {
		serverDefault = &entity.RuConfig{}
	}
	if serverDefault.FeatureFlags == nil {
		serverDefault.FeatureFlags = &entity.RuConfigFeatureFlags{}
	}

	resolve := func(tenantVal, defaultVal *bool) *bool {
		if tenantVal != nil {
			return tenantVal
		}
		return defaultVal
	}

	flags := &entity.RuConfigFeatureFlags{
		// 6 autoscale flags resolved from the bundle table (active
		// design's canonical source). Non-autoscale fields are not
		// resolved here — use the *entity.RuConfig overload for those.
		EnableScaleSharedPoolByRu:           resolve(tenantSpec.EnableScaleSharedPoolByRu, serverDefault.FeatureFlags.EnableScaleSharedPoolByRu),
		EnableScaleTidbGroupByRu:            resolve(tenantSpec.EnableScaleTidbGroupByRu, serverDefault.FeatureFlags.EnableScaleTidbGroupByRu),
		EnableAutoscaleTikvCpuScaleOut:      resolve(tenantSpec.EnableAutoscaleTikvCpuScaleOut, serverDefault.FeatureFlags.EnableAutoscaleTikvCpuScaleOut),
		EnableAutoscaleDiskExpand:           resolve(tenantSpec.EnableAutoscaleDiskExpand, serverDefault.FeatureFlags.EnableAutoscaleDiskExpand),
		EnableAutoscaleIopsThroughputExpand: resolve(tenantSpec.EnableAutoscaleIopsThroughputExpand, serverDefault.FeatureFlags.EnableAutoscaleIopsThroughputExpand),
		EnableAutoscaleTiproxyScaleIn:       resolve(tenantSpec.EnableAutoscaleTiproxyScaleIn, serverDefault.FeatureFlags.EnableAutoscaleTiproxyScaleIn),
	}

	return &entity.RuConfig{
		RuConfig: apiv1.RuConfig{
			FeatureFlags: flags,
		},
	}
}

// PriorityResolveForSharedPoolFromBundle resolves the 6 autoscale
// flags for a SHARED_POOL RuConfig freeze (see design doc
// §CreateSharedPool — Freezes FeatureFlags) by reading BOTH the
// DEFAULT and TENANT bundle rows itself and applying the three-tier
// per-field rule:
//
//   - if tenant.field != nil → tenant.field (TENANT wins, even when false)
//   - else if default.field != nil → default.field (DEFAULT fills in)
//   - else → serverDefault.field (YAML fills in)
//
// This is the preferred overload for callers that have access to the
// bundle table. It eliminates the double-resolution bug-class
// where a caller (e.g. CreateSharedPool) first ran
// `PriorityResolveForSharedPool(tenantSpec, serverDefault)` (which
// only sees TENANT, missing DEFAULT) and then overlaid the effective
// chain-resolved flags via `mergeFeatureFlagsIntoResolved`. Both
// passes did redundant work AND silently disagreed on the same field
// if chain resolution picked a higher-priority spec that the
// TENANT-only first pass ignored.
//
// nil bundleRepo / empty tenantID: the helper degrades to the legacy
// `PriorityResolveForSharedPool(tenantSpec, serverDefault)` semantics
// by reading no bundle rows at all — the server default is the
// bottom tier, and with no DEFAULT or TENANT row the resolved
// FeatureFlags == server default. Used in migration windows where
// the bundle repo is not wired.
//
// Repo errors propagate (caller must surface Internal). Missing
// DEFAULT / TENANT rows are treated as "no override at this scope"
// and fall through to the next tier — they do NOT cause an error.
//
// Only the 6 autoscale flags are resolved (same scope as
// `PriorityResolveForSharedPool`); non-autoscale fields like
// EnableTici / EnableResourceManager / Components are NOT touched
// here and the caller carries them through from the tenant's
// `ru_configs.FeatureFlags` row.
func PriorityResolveForSharedPoolFromBundle(
	ctx context.Context,
	bundleRepo repo.AutoscalePolicyBundleRepo,
	tenantID string,
	serverDefault *entity.RuConfig,
) (*entity.RuConfig, error) {
	if serverDefault == nil {
		serverDefault = &entity.RuConfig{}
	}
	if serverDefault.FeatureFlags == nil {
		serverDefault.FeatureFlags = &entity.RuConfigFeatureFlags{}
	}

	resolve := func(tenantVal, defaultVal *bool) *bool {
		if tenantVal != nil {
			return tenantVal
		}
		return defaultVal
	}

	// nil bundleRepo / empty tenantID: skip bundle reads entirely,
	// tenant spec stays empty (all nil pointers) so resolution falls
	// through to serverDefault.
	if bundleRepo == nil || tenantID == "" {
		return &entity.RuConfig{
			RuConfig: apiv1.RuConfig{
				FeatureFlags: &entity.RuConfigFeatureFlags{
					EnableScaleSharedPoolByRu:           resolve(nil, serverDefault.FeatureFlags.EnableScaleSharedPoolByRu),
					EnableScaleTidbGroupByRu:            resolve(nil, serverDefault.FeatureFlags.EnableScaleTidbGroupByRu),
					EnableAutoscaleTikvCpuScaleOut:      resolve(nil, serverDefault.FeatureFlags.EnableAutoscaleTikvCpuScaleOut),
					EnableAutoscaleDiskExpand:           resolve(nil, serverDefault.FeatureFlags.EnableAutoscaleDiskExpand),
					EnableAutoscaleIopsThroughputExpand: resolve(nil, serverDefault.FeatureFlags.EnableAutoscaleIopsThroughputExpand),
					EnableAutoscaleTiproxyScaleIn:       resolve(nil, serverDefault.FeatureFlags.EnableAutoscaleTiproxyScaleIn),
				},
			},
		}, nil
	}

	defaultRow, err := bundleRepo.GetBundle(ctx, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, defaultBundleSubjectID)
	if err != nil {
		return nil, fmt.Errorf("load DEFAULT bundle row: %w", err)
	}
	tenantRow, err := bundleRepo.GetBundle(ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, tenantID)
	if err != nil {
		return nil, fmt.Errorf("load TENANT bundle row for tenant %s: %w", tenantID, err)
	}

	var defaultSpecs, tenantSpecs []*entity.AutoscalePolicySpec
	if defaultRow != nil {
		defaultSpecs = defaultRow.Specs
	}
	if tenantRow != nil {
		tenantSpecs = tenantRow.Specs
	}

	// Translate each row's specs to a FeatureFlagsPatch via the
	// single source-of-truth (spec.SpecToFeatureFlags). The patches
	// preserve proto3 optional-bool semantics: nil = "not set at this
	// scope, fall through to the next tier".
	defaultPatch, _ := specpkg.SpecToFeatureFlags(defaultSpecs)
	tenantPatch, _ := specpkg.SpecToFeatureFlags(tenantSpecs)

	return &entity.RuConfig{
		RuConfig: apiv1.RuConfig{
			FeatureFlags: &entity.RuConfigFeatureFlags{
				// per-field three-tier rule: TENANT → DEFAULT → serverDefault.
				EnableScaleSharedPoolByRu:           resolve(tenantPatch.EnableScaleSharedPoolByRu, resolve(defaultPatch.EnableScaleSharedPoolByRu, serverDefault.FeatureFlags.EnableScaleSharedPoolByRu)),
				EnableScaleTidbGroupByRu:            resolve(tenantPatch.EnableScaleTidbGroupByRu, resolve(defaultPatch.EnableScaleTidbGroupByRu, serverDefault.FeatureFlags.EnableScaleTidbGroupByRu)),
				EnableAutoscaleTikvCpuScaleOut:      resolve(tenantPatch.EnableAutoscaleTikvCpuScaleOut, resolve(defaultPatch.EnableAutoscaleTikvCpuScaleOut, serverDefault.FeatureFlags.EnableAutoscaleTikvCpuScaleOut)),
				EnableAutoscaleDiskExpand:           resolve(tenantPatch.EnableAutoscaleDiskExpand, resolve(defaultPatch.EnableAutoscaleDiskExpand, serverDefault.FeatureFlags.EnableAutoscaleDiskExpand)),
				EnableAutoscaleIopsThroughputExpand: resolve(tenantPatch.EnableAutoscaleIopsThroughputExpand, resolve(defaultPatch.EnableAutoscaleIopsThroughputExpand, serverDefault.FeatureFlags.EnableAutoscaleIopsThroughputExpand)),
				EnableAutoscaleTiproxyScaleIn:       resolve(tenantPatch.EnableAutoscaleTiproxyScaleIn, resolve(defaultPatch.EnableAutoscaleTiproxyScaleIn, serverDefault.FeatureFlags.EnableAutoscaleTiproxyScaleIn)),
			},
		},
	}, nil
}

// OriginalScaleUpPolicy returns the default HPA ScaleUp SelectPolicy
// for a component. HPA `spec.behavior` is an independent storage
// point; initial value is always MaxChange (enabled by default).
// Disabling HPA scaling requires an explicit SetHPAScaleBehavior.
func OriginalScaleUpPolicy() v2.ScalingPolicySelect {
	return v2.MaxChangePolicySelect
}

// OriginalScaleDownPolicy returns the default HPA ScaleDown SelectPolicy
// for a component (per-component hardcoded baseline). This helper does
// not read FeatureFlags. Disabling scale-in on an existing HPA requires
// an explicit SetHPAScaleBehavior. The disabled-mode TIPROXY overlay
// lives in applyTiProxyScaleInGate, not here — see design doc
// §Authority when configs conflict.
func OriginalScaleDownPolicy(componentType apiv1.ComponentType) v2.ScalingPolicySelect {
	switch componentType {
	case apiv1.ComponentType_TIDB_WORKER, apiv1.ComponentType_TIKV_WORKER:
		return v2.MaxChangePolicySelect
	case apiv1.ComponentType_COPROCESSOR_WORKER:
		return v2.MinChangePolicySelect
	case apiv1.ComponentType_TIDB:
		return v2.MinChangePolicySelect
	default:
		return v2.DisabledPolicySelect
	}
}

// SelectPolicyFromComponentState resolves the HPA SelectPolicy for a
// given (componentType, direction, enabled) tuple. This is the single
// source of truth shared by both the bundle-aware reconcile path
// (ResolveHPABehaviorFromBundle → pickSelectPolicyFromSpecs) and the
// legacy write path (writeClusterHPABehavior → SetHPAScaleBehavior).
// See design doc §HPA Behavior Assembly Rule.
//
// Translation table (mirrors the design doc):
//
//   - SCALE_OUT ENABLED  → OriginalScaleUpPolicy() = MaxChangePolicySelect
//     (no-op; baseline already is MaxChange — preserved for symmetry)
//   - SCALE_OUT DISABLED → DisabledPolicySelect
//   - SCALE_IN  ENABLED  → OriginalScaleDownPolicy(compType);
//     if the per-component baseline is Disabled (e.g. TIPROXY), flip
//     to MinChangePolicySelect so the operator's "allow scale-in"
//     intent is honored without forcing aggressive MaxChange.
//   - SCALE_IN  DISABLED → DisabledPolicySelect
//
// The Disabled→MinChange flip on SCALE_IN is the only special case
// here. If an operator wants to keep Disabled, they must OMIT the
// SCALE_IN spec — writing ENABLED would flip to MinChange. "Keep
// baseline" and "explicit Disabled" are indistinguishable at the
// bundle layer (see design doc §HPA Behavior Assembly Rule → flip
// note).
func SelectPolicyFromComponentState(
	componentType apiv1.ComponentType,
	direction apiv1.AutoscalePolicy_Direction,
	enabled bool,
) v2.ScalingPolicySelect {
	if !enabled {
		return v2.DisabledPolicySelect
	}
	if direction == apiv1.AutoscalePolicy_SCALE_OUT {
		return OriginalScaleUpPolicy()
	}
	// SCALE_IN, enabled.
	baseline := OriginalScaleDownPolicy(componentType)
	if baseline == v2.DisabledPolicySelect {
		// Disabled-baseline components (e.g. TIPROXY): flip to
		// MinChange so the operator's "allow scale-in" intent is
		// honored without forcing aggressive MaxChange.
		return v2.MinChangePolicySelect
	}
	return baseline
}

// ResolveHPABehaviorFromBundle assembles the desired HPA
// `Behavior.Scale{Up,Down}.SelectPolicy` for a component by reading
// the DEFAULT and TENANT rows of autoscale_policy_bundle_specs and
// applying the 3-tier priority chain (see design doc §HPA Behavior
// Assembly Rule): TENANT wins if present, then DEFAULT, then
// OriginalScale* baseline. Called at HPA reconcile time for every
// component with an HPA. CLUSTER scope is NOT consulted —
// CLUSTER-scope writes go directly to the per-group HPA via
// writeClusterHPABehavior.
//
// Bundle state → SelectPolicy translation:
//   - SCALE_OUT ENABLED  → OriginalScaleUpPolicy() (= MaxChange)
//   - SCALE_OUT DISABLED → DisabledPolicySelect
//   - SCALE_IN  ENABLED  → OriginalScaleDownPolicy(compType); if baseline
//     is DisabledPolicySelect (e.g. TIPROXY), flip to MinChangePolicySelect
//     so the operator's "allow scale-in" intent is honored.
//   - SCALE_IN  DISABLED → DisabledPolicySelect
//
// Returns an error only on bundle repo read failure. A missing
// DEFAULT or TENANT row is "no override at this scope" (falls
// through to the next tier). nil bundleRepo / empty tenantID →
// per-component baseline without touching the repo.
func ResolveHPABehaviorFromBundle(
	ctx context.Context,
	bundleRepo repo.AutoscalePolicyBundleRepo,
	tenantID string,
	compType apiv1.ComponentType,
) (v2.ScalingPolicySelect, v2.ScalingPolicySelect, error) {
	up := OriginalScaleUpPolicy()
	down := OriginalScaleDownPolicy(compType)

	if bundleRepo == nil || tenantID == "" {
		return up, down, nil
	}

	defaultRow, err := bundleRepo.GetBundle(ctx, apiv1.AutoscalePolicyScope_SCOPE_DEFAULT, defaultBundleSubjectID)
	if err != nil {
		return up, down, fmt.Errorf("load DEFAULT bundle row: %w", err)
	}
	tenantRow, err := bundleRepo.GetBundle(ctx, apiv1.AutoscalePolicyScope_SCOPE_TENANT, tenantID)
	if err != nil {
		return up, down, fmt.Errorf("load TENANT bundle row for tenant %s: %w", tenantID, err)
	}

	var defaultSpecs, tenantSpecs []*entity.AutoscalePolicySpec
	if defaultRow != nil {
		defaultSpecs = defaultRow.Specs
	}
	if tenantRow != nil {
		tenantSpecs = tenantRow.Specs
	}

	merged := specpkg.MergeSpecs(defaultSpecs, tenantSpecs, specpkg.MergeModeMerge)

	if v := pickSelectPolicyFromSpecs(merged, compType, apiv1.AutoscalePolicy_SCALE_OUT); v != nil {
		up = *v
	}
	if v := pickSelectPolicyFromSpecs(merged, compType, apiv1.AutoscalePolicy_SCALE_IN); v != nil {
		down = *v
	}

	return up, down, nil
}

// pickSelectPolicyFromSpecs searches specs for a row keyed by
// (compType, direction, NODE_COUNT, CPU) and translates its State
// to a ScalingPolicySelect (see §HPA Behavior Assembly Rule). Returns
// nil when no matching spec is found — caller falls back to the
// OriginalScale* baseline. UNSPECIFIED-state specs are skipped
// (same convention as spec.CompactSpecs / spec.MergeSpecs).
//
// Translation is delegated to SelectPolicyFromComponentState so the
// Disabled→MinChange flip for SCALE_IN on Disabled-baseline
// components (e.g. TIPROXY) is consistent with the legacy write path.
func pickSelectPolicyFromSpecs(
	specs []*entity.AutoscalePolicySpec,
	compType apiv1.ComponentType,
	direction apiv1.AutoscalePolicy_Direction,
) *v2.ScalingPolicySelect {
	targetType := specpkg.ComponentTypeToAutoscaleTargetType(compType)
	for _, s := range specs {
		if s == nil {
			continue
		}
		if s.GetState() == apiv1.AutoscalePolicy_STATE_UNSPECIFIED {
			continue
		}
		if s.GetTargetType() != targetType {
			continue
		}
		if s.GetDirection() != direction {
			continue
		}
		if s.GetCapability() != apiv1.AutoscalePolicy_NODE_COUNT {
			continue
		}
		if s.GetTrigger() != apiv1.AutoscalePolicy_CPU {
			continue
		}

		enabled := s.GetState() == apiv1.AutoscalePolicy_ENABLED
		policy := SelectPolicyFromComponentState(compType, direction, enabled)
		return &policy
	}
	return nil
}
