package server

import (
	"context"
	"errors"
	"testing"

	"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"
	"github.com/tidbcloud/cluster-service-ng/pkg/storage/repo"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

type autoscaleRuntimeGuardRepoStub struct {
	listGuardsFn  func(context.Context, repo.AutoscaleRuntimeGuardFilter) ([]*entity.AutoscaleRuntimeGuard, error)
	upsertGuardFn func(context.Context, *entity.AutoscaleRuntimeGuard) (*entity.AutoscaleRuntimeGuard, error)
	deleteGuardFn func(context.Context, entity.AutoscaleRuntimeGuardScopeType, string, entity.AutoscaleRuntimeGuardType, entity.ComponentType) error
}

func (s *autoscaleRuntimeGuardRepoStub) UpsertGuard(ctx context.Context, guard *entity.AutoscaleRuntimeGuard) (*entity.AutoscaleRuntimeGuard, error) {
	if s.upsertGuardFn != nil {
		return s.upsertGuardFn(ctx, guard)
	}
	return guard, nil
}

func (s *autoscaleRuntimeGuardRepoStub) DeleteGuard(ctx context.Context, scopeType entity.AutoscaleRuntimeGuardScopeType, scopeID string, guardType entity.AutoscaleRuntimeGuardType, componentType entity.ComponentType) error {
	if s.deleteGuardFn != nil {
		return s.deleteGuardFn(ctx, scopeType, scopeID, guardType, componentType)
	}
	return nil
}

func (s *autoscaleRuntimeGuardRepoStub) ListGuards(ctx context.Context, filter repo.AutoscaleRuntimeGuardFilter) ([]*entity.AutoscaleRuntimeGuard, error) {
	if s.listGuardsFn != nil {
		return s.listGuardsFn(ctx, filter)
	}
	return nil, nil
}

func newServerWithStubs() (*serverImpl, *autoscaleRuntimeGuardRepoStub) {
	guardStub := &autoscaleRuntimeGuardRepoStub{}
	return &serverImpl{
		autoscaleRuntimeGuardRepo: guardStub,
		region:                    "us-east-1",
	}, guardStub
}

// errRuConfigStubUnset is the sentinel error returned by the
// ruConfigRepoStubFn's no-op methods when a per-method function
// hook is not configured. Callers that exercise the no-op path
// must match this error rather than relying on a (nil, nil) pair.
var errRuConfigStubUnset = errors.New("ruConfigRepoStubFn: method hook not configured")

// ruConfigRepoStubFn is a minimal in-test stub for repo.RuConfigRepo.
type ruConfigRepoStubFn struct {
	getFn    func(context.Context, string, apiv1.RuConfig_Scope) (*entity.RuConfig, error)
	setFn    func(context.Context, string, entity.RuConfigScope, *entity.RuConfig) error
	deleteFn func(context.Context, string, entity.RuConfigScope) error
}

func (s *ruConfigRepoStubFn) GetRuConfig(ctx context.Context, id string, scope apiv1.RuConfig_Scope) (*entity.RuConfig, error) {
	if s.getFn != nil {
		return s.getFn(ctx, id, scope)
	}
	return nil, errRuConfigStubUnset
}
func (s *ruConfigRepoStubFn) GetRuConfigWithFound(ctx context.Context, id string, scope apiv1.RuConfig_Scope) (*entity.RuConfig, bool, error) {
	rc, err := s.GetRuConfig(ctx, id, scope)
	return rc, err == nil, err
}
func (s *ruConfigRepoStubFn) SetRuConfig(ctx context.Context, id string, scope entity.RuConfigScope, rc *entity.RuConfig) error {
	if s.setFn != nil {
		return s.setFn(ctx, id, scope, rc)
	}
	return nil
}
func (s *ruConfigRepoStubFn) DeleteRuConfig(ctx context.Context, id string, scope entity.RuConfigScope) error {
	if s.deleteFn != nil {
		return s.deleteFn(ctx, id, scope)
	}
	return nil
}
func (s *ruConfigRepoStubFn) GetServerDefaultRuConfig(context.Context) (*entity.RuConfig, error) {
	return nil, errRuConfigStubUnset
}
func (s *ruConfigRepoStubFn) GetFeatureFlags(context.Context, string, entity.RuConfigScope) (*apiv1.RuConfig_FeatureFlags, error) {
	return &apiv1.RuConfig_FeatureFlags{}, nil
}
func (s *ruConfigRepoStubFn) PatchFeatureFlags(context.Context, string, entity.RuConfigScope, repo.FeatureFlagsPatch) error {
	return nil
}
func (s *ruConfigRepoStubFn) ReplaceFeatureFlags(context.Context, string, entity.RuConfigScope, *apiv1.RuConfig_FeatureFlags) error {
	return nil
}

func stubRuConfigRepo(rc *entity.RuConfig) *ruConfigRepoStubFn {
	return &ruConfigRepoStubFn{getFn: func(_ context.Context, _ string, _ apiv1.RuConfig_Scope) (*entity.RuConfig, error) {
		return rc, nil
	}}
}

func stubErrRuConfigRepo(err error) *ruConfigRepoStubFn {
	return &ruConfigRepoStubFn{getFn: func(_ context.Context, _ string, _ apiv1.RuConfig_Scope) (*entity.RuConfig, error) {
		return nil, err
	}}
}

// TestEvaluateAutoscalePolicy_NoPolicyInScopeAllowsAsLegacyCompat pins the
// legacy-compat semantics: under the storage-point exclusivity invariant,
// EvaluateAutoscalePolicy uses direct read (readRuntimeSpecs). When
// sharedPoolRepo / ruConfigRepo are nil, the read produces 0 specs →
// no_policy_in_scope (implicit allow). The 3 gate callers
// (ScaleSharedPool, CreateTiKVCapacityReservation, RCU scale-down cron)
// all check !resp.GetAllowed() and therefore pass the call through.
func TestEvaluateAutoscalePolicy_NoPolicyInScopeAllowsAsLegacyCompat(t *testing.T) {
	s, _ := newServerWithStubs()
	// No repos wired → readRuntimeSpecs returns 0 specs → no_policy_in_scope.
	resp, err := s.EvaluateAutoscalePolicy(context.Background(), "pool-1", apiv1.ComponentType_TIKV,
		apiv1.AutoscalePolicy_SCALE_OUT, apiv1.AutoscalePolicy_NODE_COUNT, apiv1.AutoscalePolicy_CPU)
	require.NoError(t, err)
	require.NotNil(t, resp)
	require.True(t, resp.GetAllowed(), "no_policy_in_scope must be a legacy-compat allow, not a deny")
	require.Equal(t, "no_policy_in_scope", resp.GetReason())
}

// TestEvaluateAutoscalePolicy_MissingPolicyWhenRowsExistButNoMatch pins:
// under the new "every key always emits a row" FeatureFlags semantics
// (nil → DISABLED), a partial FeatureFlags write still produces a
// (NODE_COUNT, CPU) row at DISABLED. The query therefore resolves to
// "disabled" — strict deny, but explicit per-key rather than the
// pre-patch "missing_policy" fall-through. This is the contract that
// surfaces a per-flag operator intent for every (target, direction,
// capability, trigger) tuple, even when only a subset of flags was
// persisted.
func TestEvaluateAutoscalePolicy_MissingPolicyWhenRowsExistButNoMatch(t *testing.T) {
	s, _ := newServerWithStubs()
	// FeatureFlags with disk_expand=true → SCALE_OUT @ (DISK_SIZE, DISK).
	// Query asks for (NODE_COUNT, CPU) which is now emitted as DISABLED
	// via the FeatureFlags fallback (EnableAutoscaleTikvCpuScaleOut nil).
	enableDisk := true
	s.ruConfigRepo = stubRuConfigRepo(&entity.RuConfig{
		RuConfig: apiv1.RuConfig{FeatureFlags: &apiv1.RuConfig_FeatureFlags{EnableAutoscaleDiskExpand: &enableDisk}},
	})
	// HPA path is nil-guarded (dataplaneManager nil → no HPA specs).
	resp, err := s.EvaluateAutoscalePolicy(context.Background(), "pool-1", apiv1.ComponentType_TIKV,
		apiv1.AutoscalePolicy_SCALE_OUT, apiv1.AutoscalePolicy_NODE_COUNT, apiv1.AutoscalePolicy_CPU)
	require.NoError(t, err)
	require.NotNil(t, resp)
	require.False(t, resp.GetAllowed(), "explicit DISABLED row must still be a strict deny")
	require.Equal(t, "disabled", resp.GetReason(),
		"every key now emits a row, so the policy resolves to DISABLED instead of missing_policy")
}

func TestEvaluateAutoscalePolicy_DisabledPolicy(t *testing.T) {
	s, _ := newServerWithStubs()
	enableCPU := false
	s.ruConfigRepo = stubRuConfigRepo(&entity.RuConfig{
		RuConfig: apiv1.RuConfig{FeatureFlags: &apiv1.RuConfig_FeatureFlags{EnableAutoscaleTikvCpuScaleOut: &enableCPU}},
	})
	resp, err := s.EvaluateAutoscalePolicy(context.Background(), "pool-1", apiv1.ComponentType_TIKV,
		apiv1.AutoscalePolicy_SCALE_OUT, apiv1.AutoscalePolicy_NODE_COUNT, apiv1.AutoscalePolicy_CPU)
	require.NoError(t, err)
	require.False(t, resp.GetAllowed())
	require.Equal(t, "disabled", resp.GetReason())
}

func TestEvaluateAutoscalePolicy_EnabledPolicy(t *testing.T) {
	s, _ := newServerWithStubs()
	enableCPU := true
	s.ruConfigRepo = stubRuConfigRepo(&entity.RuConfig{
		RuConfig: apiv1.RuConfig{FeatureFlags: &apiv1.RuConfig_FeatureFlags{EnableAutoscaleTikvCpuScaleOut: &enableCPU}},
	})
	resp, err := s.EvaluateAutoscalePolicy(context.Background(), "pool-1", apiv1.ComponentType_TIKV,
		apiv1.AutoscalePolicy_SCALE_OUT, apiv1.AutoscalePolicy_NODE_COUNT, apiv1.AutoscalePolicy_CPU)
	require.NoError(t, err)
	require.True(t, resp.GetAllowed())
	require.Equal(t, "enabled", resp.GetReason())
}

// TestEvaluateAutoscalePolicy_DirectReadErrorPropagates pins that
// a readRuntimeSpecs error is surfaced as codes.Internal.
func TestEvaluateAutoscalePolicy_DirectReadErrorPropagates(t *testing.T) {
	s, _ := newServerWithStubs()
	// GetRuConfig returns the server default (not an error) for an
	// absent row, so a non-nil ru_config error is a real failure and
	// must surface as codes.Internal — not be swallowed into an
	// implicit-allow decision.
	s.ruConfigRepo = stubErrRuConfigRepo(errors.New("ru config down"))
	resp, err := s.EvaluateAutoscalePolicy(context.Background(), "pool-1", apiv1.ComponentType_TIKV,
		apiv1.AutoscalePolicy_SCALE_OUT, apiv1.AutoscalePolicy_NODE_COUNT, apiv1.AutoscalePolicy_CPU)
	require.Error(t, err)
	require.Equal(t, codes.Internal, status.Code(err))
	require.Nil(t, resp)
}

// TestApplyMaterializedAutoscalePolicies_RejectsAllScopes pins
// that the deprecated RPC rejects all scopes at the service boundary.
func TestApplyMaterializedAutoscalePolicies_RejectsAllScopes(t *testing.T) {
	for _, tc := range []struct {
		name        string
		subjectType entity.AutoscalePolicySubjectType
		wantCode    codes.Code
	}{
		{"SHARED_POOL → FailedPrecondition", apiv1.AutoscalePolicy_SHARED_POOL, codes.FailedPrecondition},
		{"CLUSTER → FailedPrecondition", apiv1.AutoscalePolicy_CLUSTER, codes.FailedPrecondition},
		{"DEFAULT → Unimplemented", apiv1.AutoscalePolicy_SUBJECT_TYPE_DEFAULT, codes.Unimplemented},
		{"TENANT → Unimplemented", apiv1.AutoscalePolicy_SUBJECT_TYPE_TENANT, codes.Unimplemented},
		{"UNSPECIFIED → InvalidArgument", apiv1.AutoscalePolicy_SUBJECT_TYPE_UNSPECIFIED, codes.InvalidArgument},
	} {
		t.Run(tc.name, func(t *testing.T) {
			s, _ := newServerWithStubs()
			_, err := s.ApplyMaterializedAutoscalePolicies(context.Background(), tc.subjectType, "sub-1", nil)
			require.Error(t, err)
			require.Equal(t, tc.wantCode, status.Code(err))
		})
	}
}

// ---------------------------------------------------------------------------
// Existing runtime-guard + cache tests (unchanged shape, these are still
// valid under the new invariant because they exercise the guard layer and
// the cache layer — both are untouched by storage-point exclusivity).
// ---------------------------------------------------------------------------

func TestUpsertAutoscaleRuntimeGuard_RejectsUnspecifiedScope(t *testing.T) {
	s, _ := newServerWithStubs()
	_, err := s.UpsertAutoscaleRuntimeGuard(context.Background(), &entity.AutoscaleRuntimeGuard{
		ScopeType: apiv1.AutoscaleRuntimeGuard_SCOPE_TYPE_UNSPECIFIED, ScopeId: "r1",
		GuardType: apiv1.AutoscaleRuntimeGuard_FORBID_SCALE_IN,
	})
	require.Error(t, err)
	require.Equal(t, codes.InvalidArgument, status.Code(err))
}

func TestUpsertAutoscaleRuntimeGuard_RejectsUnknownScopeType(t *testing.T) {
	s, _ := newServerWithStubs()
	for _, tc := range []struct {
		name      string
		scopeType entity.AutoscaleRuntimeGuardScopeType
	}{
		{"legacy-shared-pool-2", entity.AutoscaleRuntimeGuardScopeType(2)},
		{"unknown-99", entity.AutoscaleRuntimeGuardScopeType(99)},
	} {
		t.Run(tc.name, func(t *testing.T) {
			_, err := s.UpsertAutoscaleRuntimeGuard(context.Background(), &entity.AutoscaleRuntimeGuard{
				ScopeType: tc.scopeType, ScopeId: "r1",
				GuardType: apiv1.AutoscaleRuntimeGuard_FORBID_SCALE_IN,
			})
			require.Error(t, err)
			require.Equal(t, codes.InvalidArgument, status.Code(err))
		})
	}
}

func TestUpsertAutoscaleRuntimeGuard_RejectsEmptyScopeID(t *testing.T) {
	s, _ := newServerWithStubs()
	_, err := s.UpsertAutoscaleRuntimeGuard(context.Background(), &entity.AutoscaleRuntimeGuard{
		ScopeType: apiv1.AutoscaleRuntimeGuard_REGION, ScopeId: "",
		GuardType: apiv1.AutoscaleRuntimeGuard_FORBID_SCALE_IN,
	})
	require.Error(t, err)
	require.Equal(t, codes.InvalidArgument, status.Code(err))
}

func TestDeleteAutoscaleRuntimeGuard_RejectsBadInputs(t *testing.T) {
	s, _ := newServerWithStubs()
	for _, tc := range []struct {
		name       string
		scopeType  entity.AutoscaleRuntimeGuardScopeType
		scopeID    string
		guardType  entity.AutoscaleRuntimeGuardType
		expectCode codes.Code
	}{
		{"unspecified-scope", apiv1.AutoscaleRuntimeGuard_SCOPE_TYPE_UNSPECIFIED, "r1", apiv1.AutoscaleRuntimeGuard_FORBID_SCALE_IN, codes.InvalidArgument},
		{"empty-scope-id", apiv1.AutoscaleRuntimeGuard_REGION, "", apiv1.AutoscaleRuntimeGuard_FORBID_SCALE_IN, codes.InvalidArgument},
		{"unspecified-guard-type", apiv1.AutoscaleRuntimeGuard_REGION, "r1", apiv1.AutoscaleRuntimeGuard_GUARD_TYPE_UNSPECIFIED, codes.InvalidArgument},
		{"unknown-scope-type", entity.AutoscaleRuntimeGuardScopeType(2), "r1", apiv1.AutoscaleRuntimeGuard_FORBID_SCALE_IN, codes.InvalidArgument},
	} {
		t.Run(tc.name, func(t *testing.T) {
			err := s.DeleteAutoscaleRuntimeGuard(context.Background(), tc.scopeType, tc.scopeID, tc.guardType, apiv1.ComponentType_COMPONENT_TYPE_UNSPECIFIED)
			require.Error(t, err)
			require.Equal(t, tc.expectCode, status.Code(err))
		})
	}
}

func TestDeleteAutoscaleRuntimeGuard_RepoErrorPropagates(t *testing.T) {
	s, guardStub := newServerWithStubs()
	guardStub.deleteGuardFn = func(context.Context, entity.AutoscaleRuntimeGuardScopeType, string, entity.AutoscaleRuntimeGuardType, entity.ComponentType) error {
		return errors.New("db boom")
	}
	err := s.DeleteAutoscaleRuntimeGuard(context.Background(), apiv1.AutoscaleRuntimeGuard_REGION, "us-east-1", apiv1.AutoscaleRuntimeGuard_FORBID_SCALE_IN, apiv1.ComponentType_COMPONENT_TYPE_UNSPECIFIED)
	require.Error(t, err)
}

func TestListAutoscaleRuntimeGuards_RepoErrorPropagates(t *testing.T) {
	s, guardStub := newServerWithStubs()
	guardStub.listGuardsFn = func(context.Context, repo.AutoscaleRuntimeGuardFilter) ([]*entity.AutoscaleRuntimeGuard, error) {
		return nil, errors.New("db boom")
	}
	_, err := s.ListAutoscaleRuntimeGuards(context.Background(), repo.AutoscaleRuntimeGuardFilter{})
	require.Error(t, err)
}

func TestListAutoscaleRuntimeGuards_RejectsUnknownScopeType(t *testing.T) {
	s, _ := newServerWithStubs()
	_, err := s.ListAutoscaleRuntimeGuards(context.Background(), repo.AutoscaleRuntimeGuardFilter{
		ScopeType: entity.AutoscaleRuntimeGuardScopeType(2),
	})
	require.Error(t, err)
	require.Equal(t, codes.InvalidArgument, status.Code(err))
}

// ---------------------------------------------------------------------------
// Direct-read helpers
// ---------------------------------------------------------------------------
