package controller

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"testing"
	"time"

	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/resource"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/types"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/client/fake"
	ctrllog "sigs.k8s.io/controller-runtime/pkg/log"
	"sigs.k8s.io/controller-runtime/pkg/log/zap"

	"github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/api/v1alpha1"
	"github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/executor"
	decisionmetrics "github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/metrics"
	recordstore "github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/recorder"
	metricstarget "github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/target"
	"github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/tier"
)

func TestReconcileMissingObjectNoError(t *testing.T) {
	scheme := runtime.NewScheme()
	if err := v1alpha1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}
	_ = corev1.AddToScheme(scheme)
	c := fake.NewClientBuilder().WithScheme(scheme).Build()
	r := &Reconciler{Client: c, Scheme: scheme}
	res, err := r.Reconcile(context.Background(), ctrl.Request{
		NamespacedName: types.NamespacedName{Namespace: "dp-x", Name: "missing"},
	})
	if err != nil {
		t.Fatalf("missing object must be ignored: %v", err)
	}
	if res.Requeue || res.RequeueAfter != 0 {
		t.Fatalf("missing object must not requeue: %+v", res)
	}
}

func TestReconcileDisabledObjectNoSideEffect(t *testing.T) {
	scheme := runtime.NewScheme()
	if err := v1alpha1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}
	as := &v1alpha1.Autoscaler{
		ObjectMeta: metav1.ObjectMeta{Name: "tidb", Namespace: "dp-x"},
		Spec: v1alpha1.AutoscalerSpec{
			Mode: v1alpha1.AutoscalerModeDisabled,
		},
	}
	c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(as).Build()
	r := &Reconciler{Client: c, Scheme: scheme}
	res, err := r.Reconcile(context.Background(), ctrl.Request{
		NamespacedName: types.NamespacedName{Namespace: "dp-x", Name: "tidb"},
	})
	if err != nil {
		t.Fatalf("reconcile: %v", err)
	}
	if res.Requeue || res.RequeueAfter != 0 {
		t.Fatalf("disabled object must not requeue: %+v", res)
	}
	// No side effects: object unchanged.
	got := &v1alpha1.Autoscaler{}
	if err := c.Get(context.Background(), types.NamespacedName{Namespace: "dp-x", Name: "tidb"}, got); err != nil {
		t.Fatal(err)
	}
	if got.Status.ObservedGeneration != 0 || len(got.Status.Conditions) != 0 {
		t.Fatalf("disabled object must not write status: %+v", got.Status)
	}
}

func TestReconcileAutoExecutesCostOptimizedDecision(t *testing.T) {
	scheme := runtime.NewScheme()
	if err := v1alpha1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}
	if err := corev1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}

	as := validAutoscaler()
	c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(as, validTargetPod("tidb-0")).Build()
	provider := &recordingProvider{
		out: []decisionmetrics.Metrics{{
			Series: []decisionmetrics.TimeSeries{
				{Ref: &decisionmetrics.Reference{Name: "tidb-0"}, Samples: []decisionmetrics.Sample{{Timestamp: time.Unix(1, 0), Value: 2.5}}},
			},
		}},
	}
	exec := &recordingExecutor{}
	recorder, err := recordstore.NewJSONLRecorder(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	r := &Reconciler{
		Client:          c,
		Scheme:          scheme,
		TierTemplates:   validTierTemplates(),
		MetricsProvider: provider,
		Executor:        exec,
		Recorder:        recorder,
		SyncPeriod:      time.Minute,
	}

	res, err := r.Reconcile(context.Background(), ctrl.Request{
		NamespacedName: types.NamespacedName{Namespace: "dp-x", Name: "tidb"},
	})
	if err != nil {
		t.Fatalf("reconcile: %v", err)
	}
	if res.RequeueAfter != time.Minute {
		t.Fatalf("RequeueAfter = %s, want 1m", res.RequeueAfter)
	}
	if !reflect.DeepEqual(provider.promQLs, []string{"cpu"}) {
		t.Fatalf("provider PromQLs = %v, want [cpu]", provider.promQLs)
	}
	wantTargetLabels := map[string]string{
		metricstarget.LabelGroup:     "tidb",
		metricstarget.LabelComponent: "tidb",
	}
	if provider.targetNamespace != "dp-x" {
		t.Fatalf("provider target namespace = %q, want dp-x", provider.targetNamespace)
	}
	if !reflect.DeepEqual(provider.targetLabels, wantTargetLabels) {
		t.Fatalf("provider target labels = %v, want %v", provider.targetLabels, wantTargetLabels)
	}
	if exec.calls != 1 {
		t.Fatalf("executor calls = %d, want 1", exec.calls)
	}
	if exec.target.Namespace != "dp-x" || exec.target.Kind != "TiDBGroup" || exec.target.Name != "tidb" {
		t.Fatalf("executor target = %+v, want TiDBGroup dp-x/tidb", exec.target)
	}
	if exec.tierName != "tidb-node-0" {
		t.Fatalf("executor tier = %q, want tidb-node-0", exec.tierName)
	}
	if exec.replicas != 3 {
		t.Fatalf("executor replicas = %d, want 3", exec.replicas)
	}
	records := readDecisionRecords(t, recorder.Dir)
	if len(records) != 1 {
		t.Fatalf("recorded decisions = %d, want 1", len(records))
	}
	record := records[0]
	if record.Mode != v1alpha1.AutoscalerModeAuto || record.Outcome != recordstore.OutcomeOK {
		t.Fatalf("AUTO decision mode/outcome = %q/%q", record.Mode, record.Outcome)
	}
	if record.Recommendation.TierName != "tidb-node-0" || record.Recommendation.Replicas != 3 {
		t.Fatalf("AUTO decision recommendation = %+v", record.Recommendation)
	}
}

func TestReconcileAutoRecordsExecutionFailure(t *testing.T) {
	scheme := runtime.NewScheme()
	if err := v1alpha1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}
	if err := corev1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}

	as := validAutoscaler()
	c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(as, validTargetPod("tidb-0")).Build()
	recorder, err := recordstore.NewJSONLRecorder(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	exec := &recordingExecutor{err: errors.New("apply failed")}
	r := &Reconciler{
		Client:        c,
		Scheme:        scheme,
		TierTemplates: validTierTemplates(),
		MetricsProvider: &recordingProvider{out: []decisionmetrics.Metrics{{
			Series: []decisionmetrics.TimeSeries{{
				Ref:     &decisionmetrics.Reference{Name: "tidb-0"},
				Samples: []decisionmetrics.Sample{{Timestamp: time.Unix(1, 0), Value: 2.5}},
			}},
		}}},
		Executor: exec,
		Recorder: recorder,
	}

	_, err = r.Reconcile(context.Background(), ctrl.Request{
		NamespacedName: types.NamespacedName{Namespace: "dp-x", Name: "tidb"},
	})
	if err == nil || !strings.Contains(err.Error(), "apply failed") {
		t.Fatalf("reconcile error = %v, want apply failed", err)
	}

	records := readDecisionRecords(t, recorder.Dir)
	if len(records) != 1 {
		t.Fatalf("recorded decisions = %d, want 1", len(records))
	}
	if records[0].Outcome != recordstore.OutcomeExecutionError {
		t.Fatalf("AUTO decision outcome = %q, want %q", records[0].Outcome, recordstore.OutcomeExecutionError)
	}
	if !strings.Contains(records[0].Error, "apply failed") {
		t.Fatalf("AUTO decision error = %q, want apply failed", records[0].Error)
	}
}

func TestReconcileAutoWaitsForCompleteMetrics(t *testing.T) {
	scheme := runtime.NewScheme()
	if err := v1alpha1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}
	if err := corev1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}

	as := validAutoscaler()
	pod := validTargetPod("tidb-0")
	c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(as, pod).Build()
	exec := &recordingExecutor{}
	r := &Reconciler{
		Client:          c,
		Scheme:          scheme,
		TierTemplates:   validTierTemplates(),
		MetricsProvider: &recordingProvider{out: []decisionmetrics.Metrics{{}}},
		Executor:        exec,
		SyncPeriod:      time.Minute,
	}

	res, err := r.Reconcile(context.Background(), ctrl.Request{
		NamespacedName: types.NamespacedName{Namespace: "dp-x", Name: "tidb"},
	})
	if err != nil {
		t.Fatalf("reconcile: %v", err)
	}
	if res.RequeueAfter != time.Minute {
		t.Fatalf("RequeueAfter = %s, want 1m", res.RequeueAfter)
	}
	if exec.calls != 0 {
		t.Fatalf("executor calls = %d, want 0", exec.calls)
	}
}

func TestReconcileAutoWaitsForMetricsFromEveryTargetPod(t *testing.T) {
	scheme := runtime.NewScheme()
	if err := v1alpha1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}
	if err := corev1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}

	as := validAutoscaler()
	c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
		as,
		validTargetPod("tidb-0"),
		validTargetPod("tidb-1"),
	).Build()
	exec := &recordingExecutor{}
	r := &Reconciler{
		Client:        c,
		Scheme:        scheme,
		TierTemplates: validTierTemplates(),
		MetricsProvider: &recordingProvider{out: []decisionmetrics.Metrics{{
			Series: []decisionmetrics.TimeSeries{{
				Ref:     &decisionmetrics.Reference{Name: "tidb-0"},
				Samples: []decisionmetrics.Sample{{Timestamp: time.Unix(1, 0), Value: 2.5}},
			}},
		}}},
		Executor:   exec,
		SyncPeriod: time.Minute,
	}

	res, err := r.Reconcile(context.Background(), ctrl.Request{
		NamespacedName: types.NamespacedName{Namespace: "dp-x", Name: "tidb"},
	})
	if err != nil {
		t.Fatalf("reconcile: %v", err)
	}
	if res.RequeueAfter != time.Minute {
		t.Fatalf("RequeueAfter = %s, want 1m", res.RequeueAfter)
	}
	if exec.calls != 0 {
		t.Fatalf("executor calls = %d, want 0", exec.calls)
	}
}

func TestReconcileDryRunLogsDecisionWithoutExecuting(t *testing.T) {
	scheme := runtime.NewScheme()
	if err := v1alpha1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}
	if err := corev1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}
	targetGVK := schema.GroupVersionKind{Group: "core.pingcap.com", Version: "v1alpha1", Kind: "TiDBGroup"}
	scheme.AddKnownTypeWithName(targetGVK, &unstructured.Unstructured{})
	scheme.AddKnownTypeWithName(
		targetGVK.GroupVersion().WithKind("TiDBGroupList"),
		&unstructured.UnstructuredList{},
	)
	target := &unstructured.Unstructured{}
	target.SetGroupVersionKind(targetGVK)
	target.SetName("tidb")
	target.SetNamespace("dp-x")
	target.SetLabels(map[string]string{"tidbcloud.com/node-spec": "2c"})
	if err := unstructured.SetNestedField(target.Object, int64(2), "spec", "replicas"); err != nil {
		t.Fatal(err)
	}

	as := validAutoscaler()
	as.UID = types.UID("autoscaler-uid")
	as.Generation = 7
	as.Spec.Mode = v1alpha1.AutoscalerModeDryRun
	as.Spec.Policy = ""
	c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(as, validTargetPod("tidb-0"), target).Build()
	provider := &recordingProvider{
		out: []decisionmetrics.Metrics{{
			Series: []decisionmetrics.TimeSeries{
				{Ref: &decisionmetrics.Reference{Name: "tidb-0"}, Samples: []decisionmetrics.Sample{{Timestamp: time.Unix(1, 0), Value: 2.5}}},
			},
		}},
	}
	exec := &recordingExecutor{}
	recorder, err := recordstore.NewJSONLRecorder(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	r := &Reconciler{
		Client:          c,
		Scheme:          scheme,
		TierTemplates:   validTierTemplates(),
		MetricsProvider: provider,
		Executor:        exec,
		Recorder:        recorder,
		SyncPeriod:      time.Minute,
	}

	var logs bytes.Buffer
	ctx := ctrllog.IntoContext(context.Background(), zap.New(zap.WriteTo(&logs), zap.UseDevMode(false)))
	for i := 0; i < 2; i++ {
		res, err := r.Reconcile(ctx, ctrl.Request{
			NamespacedName: types.NamespacedName{Namespace: "dp-x", Name: "tidb"},
		})
		if err != nil {
			t.Fatalf("reconcile %d: %v", i, err)
		}
		if res.RequeueAfter != time.Minute {
			t.Fatalf("RequeueAfter = %s, want 1m", res.RequeueAfter)
		}
	}
	if !reflect.DeepEqual(provider.promQLs, []string{"cpu"}) {
		t.Fatalf("provider PromQLs = %v, want [cpu]", provider.promQLs)
	}
	if exec.calls != 0 {
		t.Fatalf("executor calls = %d, want 0", exec.calls)
	}

	lines := bytes.Split(bytes.TrimSpace(logs.Bytes()), []byte("\n"))
	if len(lines) != 2 {
		t.Fatalf("decision log entries = %d, want 2: %q", len(lines), logs.String())
	}
	entry := map[string]interface{}{}
	if err := json.Unmarshal(lines[0], &entry); err != nil {
		t.Fatalf("decode decision log %q: %v", string(lines[0]), err)
	}
	want := map[string]interface{}{
		"msg":                   "scaling decision",
		"event":                 "capacity_planner_dry_run_decision",
		"mode":                  "DRY_RUN",
		"autoscaler_namespace":  "dp-x",
		"autoscaler_name":       "tidb",
		"autoscaler_generation": float64(7),
		"target_api_version":    "core.pingcap.com/v1alpha1",
		"target_kind":           "TiDBGroup",
		"target_namespace":      "dp-x",
		"target_name":           "tidb",
		"policy":                "CostOptimized",
		"sample_pods":           map[string]interface{}{"cpu": float64(1)},
		"recommended_tier":      "tidb-node-0",
		"recommended_replicas":  float64(3),
	}
	for key, wantValue := range want {
		if got := entry[key]; !reflect.DeepEqual(got, wantValue) {
			t.Errorf("decision log field %q = %#v, want %#v", key, got, wantValue)
		}
	}
	if id, ok := entry["decision_id"].(string); !ok || id == "" {
		t.Fatalf("decision log decision_id = %#v, want non-empty string", entry["decision_id"])
	}
	if entry["decision"] != nil {
		t.Fatalf("log entry carries a full decision record despite the file recorder: %v", entry["decision"])
	}
	records := readDecisionRecords(t, recorder.Dir)
	if len(records) != 2 {
		t.Fatalf("recorded decisions = %d, want 2", len(records))
	}
	record := records[0]
	if record.Autoscaler.UID != "autoscaler-uid" {
		t.Fatalf("decision autoscaler UID = %q, want autoscaler-uid", record.Autoscaler.UID)
	}
	if len(record.Metrics) != 1 || len(record.Metrics[0].Series) != 1 || len(record.Metrics[0].Series[0].Samples) != 1 {
		t.Fatalf("decision metrics = %+v", record.Metrics)
	}
	sample := record.Metrics[0].Series[0].Samples[0]
	if !sample.Timestamp.Equal(time.Unix(1, 0)) || sample.Value != 2.5 {
		t.Fatalf("decision sample = %+v", sample)
	}
	if record.Recommendation.TierName != "tidb-node-0" || record.Recommendation.Replicas != 3 {
		t.Fatalf("decision recommendation = %+v", record.Recommendation)
	}
	if record.PlannerVersion == "" || record.PlannerCommit == "" {
		t.Fatalf("decision planner version/commit = %q/%q", record.PlannerVersion, record.PlannerCommit)
	}
	if record.DecisionID == "" {
		t.Fatal("decision id is empty")
	}
	if record.DecisionTime.IsZero() {
		t.Fatal("decision time is zero")
	}
	if record.Mode != v1alpha1.AutoscalerModeDryRun {
		t.Fatalf("decision mode = %q, want DRY_RUN", record.Mode)
	}
	if record.Outcome != recordstore.OutcomeOK {
		t.Fatalf("decision outcome = %q, want %q", record.Outcome, recordstore.OutcomeOK)
	}
	if record.Current == nil {
		t.Fatal("decision current capacity is nil")
	}
	if record.Current.TierOrdinal != 0 {
		t.Fatalf("decision current tier ordinal = %d, want 0", record.Current.TierOrdinal)
	}
	if record.Current.Replicas == nil || *record.Current.Replicas != 2 {
		t.Fatalf("decision current replicas = %v, want 2", record.Current.Replicas)
	}
	if record.Current.Pods != 1 {
		t.Fatalf("decision current pods = %d, want 1", record.Current.Pods)
	}
	if !reflect.DeepEqual(record.ObservedPods, []string{"tidb-0"}) {
		t.Fatalf("decision observed pods = %v, want [tidb-0]", record.ObservedPods)
	}
}

func TestReconcileDryRunWithoutRecorderLogsSummaryOnly(t *testing.T) {
	scheme := runtime.NewScheme()
	if err := v1alpha1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}
	if err := corev1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}

	as := validAutoscaler()
	as.Spec.Mode = v1alpha1.AutoscalerModeDryRun
	c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(as, validTargetPod("tidb-0")).Build()
	r := &Reconciler{
		Client:        c,
		Scheme:        scheme,
		TierTemplates: validTierTemplates(),
		MetricsProvider: &recordingProvider{out: []decisionmetrics.Metrics{{
			Series: []decisionmetrics.TimeSeries{{
				Ref:     &decisionmetrics.Reference{Name: "tidb-0"},
				Samples: []decisionmetrics.Sample{{Timestamp: time.Unix(1, 0), Value: 2.5}},
			}},
		}}},
		Executor: &recordingExecutor{},
	}

	var logs bytes.Buffer
	ctx := ctrllog.IntoContext(context.Background(), zap.New(zap.WriteTo(&logs), zap.UseDevMode(false)))
	if _, err := r.Reconcile(ctx, ctrl.Request{
		NamespacedName: types.NamespacedName{Namespace: "dp-x", Name: "tidb"},
	}); err != nil {
		t.Fatal(err)
	}

	entry := map[string]interface{}{}
	if err := json.Unmarshal(bytes.TrimSpace(logs.Bytes()), &entry); err != nil {
		t.Fatalf("decode decision log: %v", err)
	}
	if entry["event"] != dryRunDecisionEvent || entry["outcome"] != recordstore.OutcomeOK {
		t.Fatalf("decision summary = %v", entry)
	}
	if entry["decision"] != nil {
		t.Fatalf("decision summary carries full record: %v", entry["decision"])
	}
}

// readDecisionRecords reads every decision record appended under dir.
func readDecisionRecords(t *testing.T, dir string) []recordstore.Decision {
	t.Helper()
	files, err := filepath.Glob(filepath.Join(dir, "decisions-*.jsonl"))
	if err != nil {
		t.Fatal(err)
	}
	var records []recordstore.Decision
	for _, file := range files {
		data, err := os.ReadFile(file)
		if err != nil {
			t.Fatal(err)
		}
		for _, line := range bytes.Split(bytes.TrimSpace(data), []byte("\n")) {
			if len(line) == 0 {
				continue
			}
			var record recordstore.Decision
			if err := json.Unmarshal(line, &record); err != nil {
				t.Fatalf("decode decision record %q: %v", string(line), err)
			}
			records = append(records, record)
		}
	}
	return records
}

func TestReconcileDryRunLogsNonOKOutcomes(t *testing.T) {
	tests := []struct {
		name        string
		provider    *recordingProvider
		objects     []client.Object
		wantOutcome string
		wantErr     string
	}{
		{
			name:        "query failure",
			provider:    &recordingProvider{err: errors.New("query failed")},
			wantOutcome: recordstore.OutcomeReadError,
			wantErr:     "query failed",
		},
		{
			name: "policy failure",
			provider: &recordingProvider{out: []decisionmetrics.Metrics{{
				Series: []decisionmetrics.TimeSeries{{
					Ref:     &decisionmetrics.Reference{Name: "tidb-0"},
					Samples: []decisionmetrics.Sample{{Timestamp: time.Unix(1, 0), Value: -1}},
				}},
			}}},
			objects:     []client.Object{validTargetPod("tidb-0")},
			wantOutcome: recordstore.OutcomeComputationError,
			wantErr:     "replicas",
		},
		{
			name:        "metrics incomplete",
			provider:    &recordingProvider{out: []decisionmetrics.Metrics{{}}},
			wantOutcome: recordstore.OutcomeWaitingMetrics,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			scheme := runtime.NewScheme()
			if err := v1alpha1.AddToScheme(scheme); err != nil {
				t.Fatal(err)
			}
			if err := corev1.AddToScheme(scheme); err != nil {
				t.Fatal(err)
			}
			as := validAutoscaler()
			as.Spec.Mode = v1alpha1.AutoscalerModeDryRun
			objects := append([]client.Object{as}, tt.objects...)
			c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()
			exec := &recordingExecutor{}
			recorder, err := recordstore.NewJSONLRecorder(t.TempDir())
			if err != nil {
				t.Fatal(err)
			}
			r := &Reconciler{
				Client:          c,
				Scheme:          scheme,
				TierTemplates:   validTierTemplates(),
				MetricsProvider: tt.provider,
				Executor:        exec,
				Recorder:        recorder,
			}

			var logs bytes.Buffer
			ctx := ctrllog.IntoContext(context.Background(), zap.New(zap.WriteTo(&logs), zap.UseDevMode(false)))
			_, err = r.Reconcile(ctx, ctrl.Request{
				NamespacedName: types.NamespacedName{Namespace: "dp-x", Name: "tidb"},
			})
			if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
				t.Fatalf("reconcile error = %v, want error containing %q", err, tt.wantErr)
			}
			if tt.wantErr == "" && err != nil {
				t.Fatalf("reconcile error = %v, want nil", err)
			}
			if !strings.Contains(logs.String(), dryRunDecisionEvent) {
				t.Fatalf("no decision log entry: %q", logs.String())
			}
			records := readDecisionRecords(t, recorder.Dir)
			if len(records) != 1 {
				t.Fatalf("recorded decisions = %d, want 1", len(records))
			}
			record := records[0]
			if record.Outcome != tt.wantOutcome {
				t.Fatalf("decision outcome = %q, want %q", record.Outcome, tt.wantOutcome)
			}
			if tt.wantErr == "" {
				if record.Error != "" {
					t.Fatalf("decision error = %q, want empty", record.Error)
				}
			} else if !strings.Contains(record.Error, tt.wantErr) {
				t.Fatalf("decision error = %q, want containing %q", record.Error, tt.wantErr)
			}
			if record.DecisionID == "" {
				t.Fatal("decision id is empty")
			}
			if record.DecisionTime.IsZero() {
				t.Fatalf("decision time is zero for outcome %q", record.Outcome)
			}
			if record.ObservedPods == nil {
				t.Fatalf("decision observed pods is nil for outcome %q", record.Outcome)
			}
			if exec.calls != 0 {
				t.Fatalf("executor calls = %d, want 0", exec.calls)
			}
		})
	}
}

type recordingProvider struct {
	promQLs         []string
	targetNamespace string
	targetLabels    map[string]string
	out             []decisionmetrics.Metrics
	err             error
}

var _ decisionmetrics.Provider = (*recordingProvider)(nil)

func (p *recordingProvider) Query(_ context.Context, target metricstarget.Target, promQLs ...string) ([]decisionmetrics.Metrics, error) {
	p.promQLs = append([]string(nil), promQLs...)
	p.targetNamespace = target.Namespace()
	p.targetLabels = target.Labels()
	if p.err != nil {
		return nil, p.err
	}
	return p.out, nil
}

type recordingExecutor struct {
	calls    int
	target   *executor.Target
	tierName string
	replicas int32
	err      error
}

var _ executor.Executor = (*recordingExecutor)(nil)

func (e *recordingExecutor) Execute(_ context.Context, target *executor.Target, targetTier tier.Tier, replicas int32) error {
	e.calls++
	e.target = target
	e.tierName = targetTier.Name()
	e.replicas = replicas
	return e.err
}

func validAutoscaler() *v1alpha1.Autoscaler {
	return &v1alpha1.Autoscaler{
		ObjectMeta: metav1.ObjectMeta{Name: "tidb", Namespace: "dp-x"},
		Spec: v1alpha1.AutoscalerSpec{
			TargetRef: v1alpha1.CrossVersionObjectReference{
				APIVersion: "core.pingcap.com/v1alpha1",
				Kind:       "TiDBGroup",
				Name:       "tidb",
			},
			Mode:     v1alpha1.AutoscalerModeAuto,
			Replicas: &v1alpha1.ReplicasSpec{Min: 1, Max: 5},
			Tiers: v1alpha1.Tiers{
				Template: v1alpha1.TierTemplateRef{Name: "tidb-node"},
				Sets: []v1alpha1.TierSet{{
					Dimensions: []v1alpha1.TierDimension{{
						Resources: []v1alpha1.TierDimensionResource{
							{Name: corev1.ResourceCPU, Variants: []resource.Quantity{resource.MustParse("2"), resource.MustParse("4")}},
						},
					}},
				}},
			},
			Policy: v1alpha1.AutoscalingPolicyCostOptimized,
			Metrics: []v1alpha1.MetricSpec{{
				Type: v1alpha1.MetricSourceTypeResource,
				Resource: &v1alpha1.ResourceMetricSource{
					Name: corev1.ResourceCPU,
					Target: v1alpha1.MetricTarget{
						Type:               v1alpha1.MetricTargetTypeUtilization,
						AverageUtilization: int32Ptr(50),
					},
				},
			}},
		},
	}
}

func validTargetPod(name string) *corev1.Pod {
	return &corev1.Pod{
		ObjectMeta: metav1.ObjectMeta{
			Name:      name,
			Namespace: "dp-x",
			Labels: map[string]string{
				metricstarget.PodLabelGroup:     "tidb",
				metricstarget.PodLabelComponent: "tidb",
			},
		},
	}
}

func validTierTemplates() *v1alpha1.TierTemplates {
	return &v1alpha1.TierTemplates{Templates: map[string]v1alpha1.TierTemplate{
		"tidb-node": {
			Matchers: []v1alpha1.TierMatcher{{
				Name: "cpu",
				Resource: &v1alpha1.TierMatcherResource{
					Name:     corev1.ResourceCPU,
					Variants: []resource.Quantity{resource.MustParse("2"), resource.MustParse("4")},
				},
			}},
			Params: []v1alpha1.TierParam{{
				Name:    "cpuspec",
				Matcher: "cpu",
				Values:  []string{"2c", "4c"},
			}},
			Costs: []v1alpha1.TierCost{{
				Name:    "cpu",
				Matcher: "cpu",
				Values:  []int32{1, 2},
			}},
			Template: `metadata:
  labels:
    tidbcloud.com/node-spec: "{{cpuspec}}"
`,
		},
	}}
}

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