package instance

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"strconv"
	"testing"
	"time"

	apprent "github.com/tidbcloud/devops/entity/approval"
	maintent "github.com/tidbcloud/devops/entity/maintenance"
	opent "github.com/tidbcloud/devops/entity/operation"
	"github.com/tidbcloud/devops/pkg/infra/clients/coresvccli"
	"github.com/tidbcloud/devops/pkg/infra/clients/mgmthttpcli"
	"github.com/tidbcloud/devops/pkg/infra/eventbus"
	"github.com/tidbcloud/devops/pkg/infra/log"
	"github.com/tidbcloud/devops/pkg/infra/utils/pointer"
	"github.com/tidbcloud/devops/pkg/infra/utils/repoopt"
	"github.com/tidbcloud/devops/pkg/infra/utils/test"
	apprsvcapi "github.com/tidbcloud/devops/pkg/service/approval/api"
	cmxapi "github.com/tidbcloud/devops/pkg/service/clustermaintenance/api"
	"github.com/tidbcloud/devops/pkg/service/operation/actions"
	"github.com/tidbcloud/devops/pkg/service/operation/actions/ngoperation"
	actregistry "github.com/tidbcloud/devops/pkg/service/operation/actions/registry"
	"github.com/tidbcloud/devops/pkg/service/operation/operation"
	oprepo "github.com/tidbcloud/devops/pkg/service/operation/repo"
	tenantsvc "github.com/tidbcloud/devops/pkg/service/tenant"

	gk "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	commonv1 "github.com/tidbcloud/tidb-management-service/api/spec/global/common/v1"
	nextgenv1 "github.com/tidbcloud/tidb-management-service/api/spec/global/nextgen/v1"
	mgmtv1 "github.com/tidbcloud/tidb-management-service/api/spec/tidb_mgmt_service/v1"
	"go.uber.org/mock/gomock"
)

func TestActor(t *testing.T) {
	RegisterFailHandler(gk.Fail)
	gk.RunSpecs(t, "Operation Instance Actor Suite", test.ReporterConfig())
}

var _ = gk.Describe("[Operation Instance Actor]", func() {
	var fw = test.Framework()
	var ctx context.Context
	var comps Components
	var ins *opent.Instance

	gk.BeforeEach(func() {
		ctx = log.NewContext(context.Background(), fw.Logger)
		comps = Components{
			ApprovalService: apprsvcapi.NewMockApprovalServiceAPI(fw.Ctrl),
			MaintenanceSvc:  cmxapi.NewMockServiceAPI(fw.Ctrl),
			TenantService:   tenantsvc.NewMockTenantServiceAPI(fw.Ctrl),
		}
		comps.OperationRegistry = newTestOperationRegistry(comps.ApprovalService)

		ins = &opent.Instance{
			ID:        123,
			Type:      opent.OperationTargetCluster,
			Operation: "test-operation",
			Applicant: "operator@pingcap.com",
			Reason:    "routine maintenance",
			Phase:     opent.InstancePhaseCreated,
			Params: map[opent.ParamName]string{
				opent.ParamClusterID: "1001",
			},
		}

		comps.CoreSvcCli = &coresvccli.CoreSvcCli{
			Mgmt: &coresvccli.MgmtService{
				Cluster: coresvccli.NewMockClusterServiceClient(fw.Ctrl),
			},
		}
		comps.CoreSvcCli.Mgmt.Cluster.(*coresvccli.MockClusterServiceClient).EXPECT().
			ListClusters(gomock.Any(), gomock.Any()).
			Return(&mgmtv1.ListClustersResponse{Clusters: []*mgmtv1.Cluster{{
				ClusterName:    "cluster-1001",
				Region:         "aws-us-west-2",
				CloudProvider:  "aws",
				OrganizationId: 0,
				DeployType:     mgmtv1.DeployType_DEPLOY_TYPE_DEDICATED,
			}}}, nil).AnyTimes()
	})

	gk.It("skips maintenance window check for non-cluster target", func() {
		ins.Type = opent.OperationTargetTenant
		ins.Params = nil

		comps.ApprovalService.(*apprsvcapi.MockApprovalServiceAPI).EXPECT().
			CreateApprovalFromTemplate(gomock.Any(), gomock.Any()).
			DoAndReturn(func(ctx context.Context, param apprsvcapi.CreateApprovalFromTemplateParam, mutates ...func(*apprsvcapi.CreateApprovalParam)) (*uint64, error) {
				Expect(mutates).To(BeEmpty())
				Expect(param.FromTemplate).To(Equal("operation/test-operation"))
				return pointer.Pointer(uint64(1)), nil
			}).Times(1)

		_, err := newBaseActor(ctx, comps, ins).sendApproval()
		Expect(err).To(BeNil())
	})

	gk.It("skips applicant notification when operator entity is api key", func() {
		ins.Type = opent.OperationTargetTenant
		ins.Params = nil
		ins.OperatorEntity = "api_key:key-a"

		comps.ApprovalService.(*apprsvcapi.MockApprovalServiceAPI).EXPECT().
			CreateApprovalFromTemplate(gomock.Any(), gomock.Any()).
			DoAndReturn(func(ctx context.Context, param apprsvcapi.CreateApprovalFromTemplateParam, mutates ...func(*apprsvcapi.CreateApprovalParam)) (*uint64, error) {
				Expect(mutates).To(BeEmpty())
				Expect(param.Configuration.SkipApplicantNotification).To(BeTrue())
				return pointer.Pointer(uint64(1)), nil
			}).Times(1)

		_, err := newBaseActor(ctx, comps, ins).sendApproval()
		Expect(err).To(BeNil())
	})

	gk.It("does not append maintenance node when cluster is in maintenance window", func() {
		comps.MaintenanceSvc.(*cmxapi.MockServiceAPI).EXPECT().
			IsInClusterOpsMaintenanceWindow(gomock.Any(), "1001", nil).
			Return(true, &maintent.ClusterOpsMaintenanceWindow{
				Approvers: []string{"mw-1@pingcap.com"},
			}, nil).Times(1)
		comps.ApprovalService.(*apprsvcapi.MockApprovalServiceAPI).EXPECT().
			CreateApprovalFromTemplate(gomock.Any(), gomock.Any()).
			DoAndReturn(func(ctx context.Context, param apprsvcapi.CreateApprovalFromTemplateParam, mutates ...func(*apprsvcapi.CreateApprovalParam)) (*uint64, error) {
				Expect(mutates).To(BeEmpty())
				Expect(param.Overview["Cluster Name"]).To(Equal("cluster-1001"))
				return pointer.Pointer(uint64(1)), nil
			}).Times(1)

		_, err := newBaseActor(ctx, comps, ins).sendApproval()
		Expect(err).To(BeNil())
	})

	gk.It("checks execution window times by execution window id", func() {
		ins.ExecutionWindowID = pointer.Pointer(uint64(123))

		comps.MaintenanceSvc.(*cmxapi.MockServiceAPI).EXPECT().
			GetOpsExecutionWindow(gomock.Any(), uint64(123)).
			Return(&maintent.OpsExecutionWindow{
				StartAt: 100,
				EndAt:   200,
			}, nil).Times(1)
		comps.MaintenanceSvc.(*cmxapi.MockServiceAPI).EXPECT().
			IsInClusterOpsMaintenanceWindow(gomock.Any(), "1001", []time.Time{time.Unix(100, 0), time.Unix(200, 0)}).
			Return(true, &maintent.ClusterOpsMaintenanceWindow{
				Approvers: []string{"mw-1@pingcap.com"},
			}, nil).Times(1)
		comps.ApprovalService.(*apprsvcapi.MockApprovalServiceAPI).EXPECT().
			CreateApprovalFromTemplate(gomock.Any(), gomock.Any()).
			DoAndReturn(func(ctx context.Context, param apprsvcapi.CreateApprovalFromTemplateParam, mutates ...func(*apprsvcapi.CreateApprovalParam)) (*uint64, error) {
				Expect(mutates).To(BeEmpty())
				return pointer.Pointer(uint64(1)), nil
			}).Times(1)

		_, err := newBaseActor(ctx, comps, ins).sendApproval()
		Expect(err).To(BeNil())
	})

	gk.It("appends maintenance approvers when cluster is outside maintenance window", func() {
		comps.MaintenanceSvc.(*cmxapi.MockServiceAPI).EXPECT().
			IsInClusterOpsMaintenanceWindow(gomock.Any(), "1001", nil).
			Return(false, &maintent.ClusterOpsMaintenanceWindow{Approvers: []string{"mw-1@pingcap.com", "mw-2@pingcap.com"}}, nil).Times(1)
		comps.ApprovalService.(*apprsvcapi.MockApprovalServiceAPI).EXPECT().
			CreateApprovalFromTemplate(gomock.Any(), gomock.Any(), gomock.Any()).
			DoAndReturn(func(ctx context.Context, param apprsvcapi.CreateApprovalFromTemplateParam, mutates ...func(*apprsvcapi.CreateApprovalParam)) (*uint64, error) {
				Expect(mutates).To(HaveLen(1))

				approvalParam := &apprsvcapi.CreateApprovalParam{
					Nodes: []*apprent.ApproverNode{{
						Method: apprent.ApproveMethodAny,
						Approver: &apprent.Approver{
							Emails: []string{"template@pingcap.com"},
						},
					}},
				}
				mutates[0](approvalParam)

				Expect(approvalParam.Nodes).To(HaveLen(2))
				Expect(approvalParam.Nodes[1].Method).To(Equal(apprent.ApproveMethodAny))
				Expect(approvalParam.Nodes[1].Approver.Emails).To(Equal([]string{"mw-1@pingcap.com", "mw-2@pingcap.com"}))

				return pointer.Pointer(uint64(1)), nil
			}).Times(1)

		_, err := newBaseActor(ctx, comps, ins).sendApproval()
		Expect(err).To(BeNil())
	})

	gk.It("appends maintenance approvers when execution window is outside maintenance window", func() {
		ins.ExecutionWindowID = pointer.Pointer(uint64(123))

		comps.MaintenanceSvc.(*cmxapi.MockServiceAPI).EXPECT().
			GetOpsExecutionWindow(gomock.Any(), uint64(123)).
			Return(&maintent.OpsExecutionWindow{
				StartAt: 100,
				EndAt:   200,
			}, nil).Times(1)
		comps.MaintenanceSvc.(*cmxapi.MockServiceAPI).EXPECT().
			IsInClusterOpsMaintenanceWindow(gomock.Any(), "1001", []time.Time{time.Unix(100, 0), time.Unix(200, 0)}).
			Return(false, &maintent.ClusterOpsMaintenanceWindow{Approvers: []string{"mw-1@pingcap.com", "mw-2@pingcap.com"}}, nil).Times(1)
		comps.ApprovalService.(*apprsvcapi.MockApprovalServiceAPI).EXPECT().
			CreateApprovalFromTemplate(gomock.Any(), gomock.Any(), gomock.Any()).
			DoAndReturn(func(ctx context.Context, param apprsvcapi.CreateApprovalFromTemplateParam, mutates ...func(*apprsvcapi.CreateApprovalParam)) (*uint64, error) {
				Expect(mutates).To(HaveLen(1))

				approvalParam := &apprsvcapi.CreateApprovalParam{}
				mutates[0](approvalParam)

				Expect(approvalParam.Nodes).To(HaveLen(1))
				Expect(approvalParam.Nodes[0].Method).To(Equal(apprent.ApproveMethodAny))
				Expect(approvalParam.Nodes[0].Approver.Emails).To(Equal([]string{"mw-1@pingcap.com", "mw-2@pingcap.com"}))

				return pointer.Pointer(uint64(1)), nil
			}).Times(1)

		_, err := newBaseActor(ctx, comps, ins).sendApproval()
		Expect(err).To(BeNil())
	})

	gk.It("skips maintenance node when approvers are empty", func() {
		comps.MaintenanceSvc.(*cmxapi.MockServiceAPI).EXPECT().
			IsInClusterOpsMaintenanceWindow(gomock.Any(), "1001", nil).
			Return(false, &maintent.ClusterOpsMaintenanceWindow{Approvers: []string{}}, nil).Times(1)
		comps.ApprovalService.(*apprsvcapi.MockApprovalServiceAPI).EXPECT().
			CreateApprovalFromTemplate(gomock.Any(), gomock.Any()).
			DoAndReturn(func(ctx context.Context, param apprsvcapi.CreateApprovalFromTemplateParam, mutates ...func(*apprsvcapi.CreateApprovalParam)) (*uint64, error) {
				Expect(mutates).To(BeEmpty())
				return pointer.Pointer(uint64(1)), nil
			}).Times(1)

		_, err := newBaseActor(ctx, comps, ins).sendApproval()
		Expect(err).To(BeNil())
	})

	gk.It("returns error when maintenance window check fails", func() {
		comps.MaintenanceSvc.(*cmxapi.MockServiceAPI).EXPECT().
			IsInClusterOpsMaintenanceWindow(gomock.Any(), "1001", nil).
			Return(false, nil, fmt.Errorf("maintenance check failed")).Times(1)

		_, err := newBaseActor(ctx, comps, ins).sendApproval()
		Expect(err).To(HaveOccurred())
		Expect(err.Error()).To(ContainSubstring("build maintenance window approval mutate failed"))
	})

	gk.It("creates execution window when creating instance", func() {
		windowParam := cmxapi.CreateOpsExecutionWindowParam{StartAt: 100, EndAt: 200}
		window := &maintent.OpsExecutionWindow{ID: 123, StartAt: 100, EndAt: 200}
		comps.MaintenanceSvc.(*cmxapi.MockServiceAPI).EXPECT().
			CreateOpsExecutionWindow(gomock.Any(), windowParam).
			Return(window, nil).Times(1)

		comps.OperationRepo = operationRepoStub{
			createInstance: func(ctx context.Context, instance *opent.Instance) (*opent.Instance, error) {
				Expect(instance.ExecutionWindowID).To(Equal(pointer.Pointer(uint64(123))))
				Expect(instance.ExecutionWindow).To(Equal(window))
				instance.ID = 456
				return instance, nil
			},
		}

		actor := NewInstanceActor(ctx, comps, nil)
		err := actor.Create(CreateInstanceParam{
			Type:      opent.OperationTargetCluster,
			Operation: "test-operation",
			Reason:    "routine maintenance",
			Applicant: "operator@pingcap.com",
			Params: map[opent.ParamName]string{
				opent.ParamClusterID: "1001",
			},
			ExecutionWindow: &windowParam,
		})
		Expect(err).To(BeNil())
		Expect(actor.GetContext().Instance.ID).To(Equal(uint64(456)))
		Expect(actor.GetContext().Instance.ExecutionWindowID).To(Equal(pointer.Pointer(uint64(123))))
	})

	gk.It("allows TiDB restart create, preview, and start while TiProxy restart is running", func() {
		const (
			clusterID    = "1001"
			sharedPoolID = "2002"
			tidbGroupID  = "default"
		)

		runningTiProxyRestart := &opent.Instance{
			ID:        321,
			Type:      opent.OperationTargetCluster,
			Operation: "RestartPremiumClusterComponent",
			Applicant: "operator@pingcap.com",
			Reason:    "restart tiproxy",
			Phase:     opent.InstancePhaseRunning,
			Params: map[opent.ParamName]string{
				opent.ParamClusterID:          clusterID,
				opent.ParamRestartTargetType:  "component",
				opent.ParamRestartTarget:      "tiproxy",
				opent.ParamRestartTargetGroup: tidbGroupID,
				opent.ParamForceRestart:       "false",
			},
			Actions: []*opent.InstanceAction{{
				Order:       0,
				Name:        ngoperation.ActionRestartNextGenClusterComponent.Name,
				Type:        opent.ActionTypeAuto,
				StartPolicy: opent.ActionStartPolicyImmediate,
				StartedAt:   pointer.Pointer(int64(100)),
				Status:      opent.ActionStatusRunning,
			}},
		}

		var createdInstance *opent.Instance
		opRepo := operationRepoStub{
			listInstances: func(context.Context, ...repoopt.Option) ([]*opent.Instance, error) {
				return []*opent.Instance{runningTiProxyRestart}, nil
			},
			countInstance: func(context.Context, ...repoopt.Option) (int64, error) {
				return 1, nil
			},
			createInstance: func(ctx context.Context, instance *opent.Instance) (*opent.Instance, error) {
				Expect(instance.Operation).To(Equal("RestartPremiumClusterComponent"))
				Expect(instance.Params[opent.ParamRestartTarget]).To(Equal("tidb"))
				instance.ID = 456
				createdInstance = instance
				return instance, nil
			},
			patchInstance: func(ctx context.Context, instance *opent.Instance, patches ...oprepo.PatchInstance) error {
				for _, patch := range patches {
					if err := patch(instance); err != nil {
						return err
					}
				}
				return nil
			},
		}

		actionCoreSvc := &coresvccli.CoreSvcCli{
			Mgmt: &coresvccli.MgmtService{
				Cluster: coresvccli.NewMockClusterServiceClient(fw.Ctrl),
			},
		}
		actionCoreSvc.Mgmt.Cluster.(*coresvccli.MockClusterServiceClient).EXPECT().
			ListClusters(gomock.Any(), gomock.Any()).
			Return(&mgmtv1.ListClustersResponse{Clusters: []*mgmtv1.Cluster{{
				ClusterId:      1001,
				ClusterName:    "premium-cluster",
				OrganizationId: 9001,
				DeployType:     mgmtv1.DeployType_DEPLOY_TYPE_NEXTGEN,
				ClusterInfo: &mgmtv1.Cluster_NextGenCluster{
					NextGenCluster: &nextgenv1.Cluster{
						SharedPoolId: sharedPoolID,
					},
				},
			}}}, nil).
			AnyTimes()

		var (
			receivedMethod string
			receivedPath   string
			receivedBody   map[string]any
		)
		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			receivedMethod = r.Method
			receivedPath = r.URL.Path
			rawBody, err := io.ReadAll(r.Body)
			Expect(err).To(BeNil())
			Expect(json.Unmarshal(rawBody, &receivedBody)).To(Succeed())

			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusOK)
			_, _ = w.Write([]byte(`{"message":"accepted"}`))
		}))
		gk.DeferCleanup(server.Close)

		mgmtHTTP, err := mgmthttpcli.NewMgmtHTTPClient(server.URL, "", nil)
		Expect(err).To(BeNil())

		comps.OperationRegistry = newRestartPremiumClusterComponentOperationRegistry(comps.ApprovalService)
		comps.OperationRepo = opRepo
		comps.ActionRegistry = actregistry.Registry{
			ngoperation.ActionRestartNextGenClusterComponent.Name: ngoperation.NewRestartNextGenClusterComponent(&actions.Components{
				Logger:   fw.Logger,
				CoreSvc:  actionCoreSvc,
				EventBus: eventbus.NewMockEventBus(),
				MgmtHTTP: mgmtHTTP,
				OpRepo:   opRepo,
			}),
		}

		restartTiDBParam := restartPremiumClusterComponentParam(clusterID, tidbGroupID, "tidb")

		actor := NewInstanceActor(ctx, comps, nil)
		advisories, err := actor.Preview(restartTiDBParam)
		Expect(err).To(BeNil())
		Expect(advisories).To(BeEmpty())

		actor = NewInstanceActor(ctx, comps, nil)
		Expect(actor.Create(restartTiDBParam)).To(Succeed())
		Expect(createdInstance).NotTo(BeNil())
		Expect(createdInstance.ID).To(Equal(uint64(456)))
		Expect(createdInstance.Params[opent.ParamRestartTarget]).To(Equal("tidb"))

		startInstance := &opent.Instance{
			ID:        789,
			Type:      opent.OperationTargetCluster,
			Operation: "RestartPremiumClusterComponent",
			Applicant: "operator@pingcap.com",
			Reason:    "restart tidb",
			Phase:     opent.InstancePhasePending,
			Params:    restartTiDBParam.Params,
			Actions: []*opent.InstanceAction{{
				Order:       0,
				Name:        ngoperation.ActionRestartNextGenClusterComponent.Name,
				Type:        opent.ActionTypeAuto,
				StartPolicy: opent.ActionStartPolicyImmediate,
				Status:      opent.ActionStatusCreated,
			}},
		}

		actor = NewInstanceActor(ctx, comps, startInstance)
		Expect(actor.StartAction(0, false)).To(Succeed())
		Expect(startInstance.Phase).To(Equal(opent.InstancePhaseRunning))
		Expect(startInstance.Actions[0].Status).To(Equal(opent.ActionStatusRunning))
		Expect(receivedMethod).To(Equal(http.MethodPost))
		Expect(receivedPath).To(Equal("/proxy/nextgen/clusters/1001/dataplane/proxy/clusters/1001/cluster-agent/tidb2002/api/v1/clusters/2002:restart"))
		Expect(receivedBody).To(HaveKeyWithValue("clusterId", clusterID))
		Expect(receivedBody).To(HaveKeyWithValue("targetName", "tidb"))
		Expect(receivedBody).To(HaveKeyWithValue("targetGroup", "db-"+tidbGroupID))
	})

	gk.It("allows non-TiProxy resource-pool scale create, preview, and start while TiProxy resource-pool scale is running", func() {
		const sharedPoolID = "2002"

		runningTiProxyScale := &opent.Instance{
			ID:        321,
			Type:      opent.OperationTargetResourcePool,
			Operation: "ScaleNextGenResourcePool",
			Applicant: "operator@pingcap.com",
			Reason:    "scale tiproxy",
			Phase:     opent.InstancePhaseRunning,
			Params: map[opent.ParamName]string{
				opent.ParamResourcePoolID: sharedPoolID,
				opent.ParamComponentType:  "TIPROXY",
				opent.ParamNodeCount:      "3",
				opent.ParamCpuCores:       "4",
				opent.ParamMemoryGi:       "8",
			},
			Actions: []*opent.InstanceAction{{
				Order:       0,
				Name:        ngoperation.ActionScaleResourcePool.Name,
				Type:        opent.ActionTypeAuto,
				StartPolicy: opent.ActionStartPolicyImmediate,
				StartedAt:   pointer.Pointer(int64(100)),
				Status:      opent.ActionStatusRunning,
			}},
		}

		var createdInstance *opent.Instance
		opRepo := operationRepoStub{
			listInstances: func(context.Context, ...repoopt.Option) ([]*opent.Instance, error) {
				return []*opent.Instance{runningTiProxyScale}, nil
			},
			countInstance: func(context.Context, ...repoopt.Option) (int64, error) {
				return 1, nil
			},
			createInstance: func(ctx context.Context, instance *opent.Instance) (*opent.Instance, error) {
				Expect(instance.Operation).To(Equal("ScaleNextGenResourcePool"))
				Expect(instance.Params[opent.ParamComponentType]).To(Equal("TIKV"))
				instance.ID = 456
				createdInstance = instance
				return instance, nil
			},
			patchInstance: func(ctx context.Context, instance *opent.Instance, patches ...oprepo.PatchInstance) error {
				for _, patch := range patches {
					if err := patch(instance); err != nil {
						return err
					}
				}
				return nil
			},
		}

		resourcePoolClient := coresvccli.NewMockResourcePoolServiceClient(fw.Ctrl)
		resourcePoolClient.EXPECT().
			GetResourcePool(gomock.Any(), gomock.Any()).
			Return(operationResourcePoolProfile(sharedPoolID), nil).
			AnyTimes()

		sharedPool := &mgmthttpcli.SharedPool{
			Id:    sharedPoolID,
			Phase: "MODIFYING",
			ComponentGroups: []*mgmthttpcli.Component{
				{
					Id:            "tiproxy",
					ComponentType: "TIPROXY",
					Phase:         "MODIFYING",
					NodeQuota: &mgmthttpcli.NodeQuota{
						NodeCount: 3,
						NodeSpec:  &mgmthttpcli.NodeSpec{Cpu: 4, MemoryGi: 8},
					},
				},
				{
					Id:            "tikv",
					ComponentType: "TIKV",
					Phase:         "AVAILABLE",
					NodeQuota: &mgmthttpcli.NodeQuota{
						NodeCount: 2,
						NodeSpec:  &mgmthttpcli.NodeSpec{Cpu: 4, MemoryGi: 8},
					},
				},
			},
		}

		var scaleReq *mgmthttpcli.ScaleSharedPoolRequest
		regionServer := mgmthttpcli.NewMockNextGenRegionServer(fw.Ctrl)
		regionServer.EXPECT().
			GetSharedPool(gomock.Any(), sharedPoolID).
			Return(sharedPool, nil).
			AnyTimes()
		regionServer.EXPECT().
			ScaleSharedPool(gomock.Any(), sharedPoolID, gomock.Any()).
			DoAndReturn(func(_ context.Context, _ string, req *mgmthttpcli.ScaleSharedPoolRequest) (*mgmthttpcli.SharedPool, error) {
				scaleReq = req
				return sharedPool, nil
			}).
			Times(1)

		mgmtHTTP := mgmthttpcli.NewMockMgmtHTTPClient(fw.Ctrl)
		mgmtHTTP.EXPECT().
			NextGenRegionServer("aws-us-1", "aws").
			Return(regionServer).
			AnyTimes()

		comps.OperationRegistry = newScaleNextGenResourcePoolOperationRegistry(comps.ApprovalService)
		comps.OperationRepo = opRepo
		comps.ActionRegistry = actregistry.Registry{
			ngoperation.ActionScaleResourcePool.Name: ngoperation.NewScaleScaleResourcePool(&actions.Components{
				Logger: fw.Logger,
				CoreSvc: &coresvccli.CoreSvcCli{
					Mgmt: &coresvccli.MgmtService{
						ResourcePool: resourcePoolClient,
					},
				},
				EventBus: eventbus.NewMockEventBus(),
				MgmtHTTP: mgmtHTTP,
				OpRepo:   opRepo,
			}),
		}

		scaleTiKVParam := scaleNextGenResourcePoolParam(sharedPoolID, "TIKV")
		scaleTiKVParam.Params[opent.ParamComponentID] = "tikv"

		actor := NewInstanceActor(ctx, comps, nil)
		advisories, err := actor.Preview(scaleTiKVParam)
		Expect(err).To(BeNil())
		Expect(advisories).To(BeEmpty())

		actor = NewInstanceActor(ctx, comps, nil)
		Expect(actor.Create(scaleTiKVParam)).To(Succeed())
		Expect(createdInstance).NotTo(BeNil())
		Expect(createdInstance.ID).To(Equal(uint64(456)))
		Expect(createdInstance.Params[opent.ParamComponentType]).To(Equal("TIKV"))
		Expect(createdInstance.Params[opent.ParamComponentID]).To(Equal("tikv"))
		Expect(createdInstance.Params[opent.ParamScaleDirection]).To(Equal("up"))

		startInstance := &opent.Instance{
			ID:        789,
			Type:      opent.OperationTargetResourcePool,
			Operation: "ScaleNextGenResourcePool",
			Applicant: "operator@pingcap.com",
			Reason:    "scale tikv",
			Phase:     opent.InstancePhasePending,
			Params:    scaleTiKVParam.Params,
			Actions: []*opent.InstanceAction{{
				Order:       0,
				Name:        ngoperation.ActionScaleResourcePool.Name,
				Type:        opent.ActionTypeAuto,
				StartPolicy: opent.ActionStartPolicyImmediate,
				Status:      opent.ActionStatusCreated,
			}},
		}

		actor = NewInstanceActor(ctx, comps, startInstance)
		Expect(actor.StartAction(0, false)).To(Succeed())
		Expect(startInstance.Phase).To(Equal(opent.InstancePhaseRunning))
		Expect(startInstance.Actions[0].Status).To(Equal(opent.ActionStatusRunning))
		Expect(scaleReq).NotTo(BeNil())
		Expect(scaleReq.Component.Id).To(Equal("tikv"))
		Expect(scaleReq.Component.ComponentType).To(Equal("TIKV"))
		Expect(scaleReq.Component.NodeQuota.NodeCount).To(Equal(uint32(3)))
	})

	gk.Context("Pending StartAction execution window", func() {
		const actionName opent.ActionName = "TrackStart"

		var (
			actionStarted bool
			patchCalled   bool
		)

		gk.BeforeEach(func() {
			actionStarted = false
			patchCalled = false
			ins.Phase = opent.InstancePhasePending
			ins.Actions = []*opent.InstanceAction{{
				Order:       0,
				Name:        actionName,
				Type:        opent.ActionTypeAuto,
				StartPolicy: opent.ActionStartPolicyImmediate,
				Status:      opent.ActionStatusCreated,
			}}
			comps.ActionRegistry = actregistry.Registry{
				actionName: trackingAction{started: &actionStarted},
			}
			comps.OperationRepo = operationRepoStub{
				patchInstance: func(ctx context.Context, instance *opent.Instance, patches ...oprepo.PatchInstance) error {
					patchCalled = true
					for _, patch := range patches {
						if err := patch(instance); err != nil {
							return err
						}
					}
					return nil
				},
			}
		})

		gk.It("fails before starting when execution window has not started", func() {
			now := time.Now()
			ins.ExecutionWindowID = pointer.Pointer(uint64(123))
			comps.MaintenanceSvc.(*cmxapi.MockServiceAPI).EXPECT().
				GetOpsExecutionWindow(gomock.Any(), uint64(123)).
				Return(&maintent.OpsExecutionWindow{
					ID:      123,
					StartAt: now.Add(time.Hour).Unix(),
					EndAt:   now.Add(2 * time.Hour).Unix(),
				}, nil).Times(1)

			actor := NewInstanceActor(ctx, comps, ins)
			err := actor.StartAction(0, false)
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("current time is outside execute window"))
			Expect(actionStarted).To(BeFalse())
			Expect(patchCalled).To(BeFalse())
		})

		gk.It("fails before starting when execution window has ended", func() {
			now := time.Now()
			ins.ExecutionWindowID = pointer.Pointer(uint64(123))
			comps.MaintenanceSvc.(*cmxapi.MockServiceAPI).EXPECT().
				GetOpsExecutionWindow(gomock.Any(), uint64(123)).
				Return(&maintent.OpsExecutionWindow{
					ID:      123,
					StartAt: now.Add(-2 * time.Hour).Unix(),
					EndAt:   now.Add(-time.Hour).Unix(),
				}, nil).Times(1)

			actor := NewInstanceActor(ctx, comps, ins)
			err := actor.StartAction(0, false)
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("current time is outside execute window"))
			Expect(actionStarted).To(BeFalse())
			Expect(patchCalled).To(BeFalse())
		})

		gk.It("continues starting when current time is inside execution window", func() {
			now := time.Now()
			ins.ExecutionWindowID = pointer.Pointer(uint64(123))
			comps.MaintenanceSvc.(*cmxapi.MockServiceAPI).EXPECT().
				GetOpsExecutionWindow(gomock.Any(), uint64(123)).
				Return(&maintent.OpsExecutionWindow{
					ID:      123,
					StartAt: now.Add(-time.Hour).Unix(),
					EndAt:   now.Add(time.Hour).Unix(),
				}, nil).Times(1)

			actor := NewInstanceActor(ctx, comps, ins)
			err := actor.StartAction(0, false)
			Expect(err).To(BeNil())
			Expect(actionStarted).To(BeTrue())
			Expect(patchCalled).To(BeTrue())
			Expect(ins.Actions[0].Status).To(Equal(opent.ActionStatusSucceeded))
			Expect(ins.Phase).To(Equal(opent.InstancePhaseSucceeded))
		})

		gk.It("keeps existing behavior when execution window is absent", func() {
			actor := NewInstanceActor(ctx, comps, ins)
			err := actor.StartAction(0, false)
			Expect(err).To(BeNil())
			Expect(actionStarted).To(BeTrue())
			Expect(patchCalled).To(BeTrue())
			Expect(ins.Actions[0].Status).To(Equal(opent.ActionStatusSucceeded))
		})
	})
})

var _ = gk.Describe("[Operation Registry Loading]", func() {
	var fw = test.Framework()

	gk.It("loads ManageTiProxyExternalDNS operation from YAML", func() {
		apprSvc := apprsvcapi.NewMockApprovalServiceAPI(fw.Ctrl)
		registry := newTestOperationRegistry(apprSvc)

		op := registry.Get("ManageTiProxyExternalDNS")
		Expect(op).NotTo(BeNil(), "ManageTiProxyExternalDNS operation should be registered")
		Expect(op.Name).To(Equal("ManageTiProxyExternalDNS"))
		Expect(op.Alias).To(Equal("Manage TiProxy External DNS"))
		Expect(op.Target).To(Equal(opent.OperationTargetCluster))
	})
})

var _ = gk.Describe("[Create Instance Param Normalization]", func() {
	gk.It("normalizes missing params by operation definition", func() {
		constValue := "const-value"
		defaultValue := "default-value"
		tests := []struct {
			name       string
			params     map[opent.ParamName]opent.ParamDefine
			wantParams map[opent.ParamName]string
			wantErr    bool
		}{
			{
				name: "fills missing const and default params",
				params: map[opent.ParamName]opent.ParamDefine{
					"const_param":   {Const: &constValue},
					"default_param": {Default: &defaultValue},
				},
				wantParams: map[opent.ParamName]string{
					"const_param":   constValue,
					"default_param": defaultValue,
				},
			},
			{
				name: "allows missing optional params",
				params: map[opent.ParamName]opent.ParamDefine{
					"optional_param": {Optional: true},
				},
				wantParams: map[opent.ParamName]string{},
			},
			{
				name: "still rejects missing required params without const or default",
				params: map[opent.ParamName]opent.ParamDefine{
					"required_param": {},
				},
				wantErr: true,
			},
		}

		for _, tc := range tests {
			tc := tc
			gk.By(tc.name)
			ins := &opent.Instance{Params: map[opent.ParamName]string{}}
			op := &opent.Operation{Params: tc.params}

			err := (&actorCreateInstance{}).normalizeParams(ins, op)
			if tc.wantErr {
				Expect(err).To(HaveOccurred())
				continue
			}
			Expect(err).To(BeNil())
			Expect(ins.Params).To(Equal(tc.wantParams))
		}
	})
})

var _ = gk.Describe("[Create Instance Param Validation]", func() {
	gk.DescribeTable("validates first action schedule against execution window",
		func(param CreateInstanceParam, wantErr string) {
			err := param.Validate()
			if wantErr == "" {
				Expect(err).To(BeNil())
				return
			}
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring(wantErr))
		},
		gk.Entry("allows missing execution window", CreateInstanceParam{
			Actions: []CreateInstanceActionParam{{
				StartScheduleAt: pointer.Pointer(int64(100)),
			}},
		}, ""),
		gk.Entry("allows missing actions", CreateInstanceParam{
			ExecutionWindow: &cmxapi.CreateOpsExecutionWindowParam{StartAt: 100, EndAt: 200},
		}, ""),
		gk.Entry("allows first action without schedule", CreateInstanceParam{
			ExecutionWindow: &cmxapi.CreateOpsExecutionWindowParam{StartAt: 100, EndAt: 200},
			Actions:         []CreateInstanceActionParam{{}},
		}, ""),
		gk.Entry("allows first action schedule at window start", CreateInstanceParam{
			ExecutionWindow: &cmxapi.CreateOpsExecutionWindowParam{StartAt: 100, EndAt: 200},
			Actions: []CreateInstanceActionParam{{
				StartScheduleAt: pointer.Pointer(int64(100)),
			}},
		}, ""),
		gk.Entry("allows first action schedule at window end", CreateInstanceParam{
			ExecutionWindow: &cmxapi.CreateOpsExecutionWindowParam{StartAt: 100, EndAt: 200},
			Actions: []CreateInstanceActionParam{{
				StartScheduleAt: pointer.Pointer(int64(200)),
			}},
		}, ""),
		gk.Entry("rejects first action schedule before window", CreateInstanceParam{
			ExecutionWindow: &cmxapi.CreateOpsExecutionWindowParam{StartAt: 100, EndAt: 200},
			Actions: []CreateInstanceActionParam{{
				StartScheduleAt: pointer.Pointer(int64(99)),
			}},
		}, "must be within execution_window"),
		gk.Entry("rejects first action schedule after window", CreateInstanceParam{
			ExecutionWindow: &cmxapi.CreateOpsExecutionWindowParam{StartAt: 100, EndAt: 200},
			Actions: []CreateInstanceActionParam{{
				StartScheduleAt: pointer.Pointer(int64(201)),
			}},
		}, "must be within execution_window"),
	)
})

func newBaseActor(ctx context.Context, comps Components, ins *opent.Instance) *baseActor {
	copyIns, err := pointer.DeepCopy(ins)
	Expect(err).To(BeNil())

	return &baseActor{
		Context: &Context{
			Context:    ctx,
			Components: comps,
			Objects: Objects{
				Origin:      ins,
				Instance:    copyIns,
				EventDetail: nil,
			},
		},
	}
}

type operationRepoStub struct {
	listInstances  func(context.Context, ...repoopt.Option) ([]*opent.Instance, error)
	getInstance    func(context.Context, uint64) (*opent.Instance, error)
	createInstance func(context.Context, *opent.Instance) (*opent.Instance, error)
	patchInstance  func(context.Context, *opent.Instance, ...oprepo.PatchInstance) error
	countInstance  func(context.Context, ...repoopt.Option) (int64, error)
}

func (r operationRepoStub) ListInstances(ctx context.Context, opts ...repoopt.Option) ([]*opent.Instance, error) {
	if r.listInstances != nil {
		return r.listInstances(ctx, opts...)
	}
	return nil, nil
}

func (r operationRepoStub) GetInstance(ctx context.Context, id uint64) (*opent.Instance, error) {
	if r.getInstance != nil {
		return r.getInstance(ctx, id)
	}
	return nil, nil
}

func (r operationRepoStub) CreateInstance(ctx context.Context, instance *opent.Instance) (*opent.Instance, error) {
	return r.createInstance(ctx, instance)
}

func (r operationRepoStub) PatchInstance(ctx context.Context, instance *opent.Instance, patches ...oprepo.PatchInstance) error {
	if r.patchInstance != nil {
		return r.patchInstance(ctx, instance, patches...)
	}
	return nil
}

func (r operationRepoStub) CountInstance(ctx context.Context, opts ...repoopt.Option) (int64, error) {
	if r.countInstance != nil {
		return r.countInstance(ctx, opts...)
	}
	return 0, nil
}

type trackingAction struct {
	started *bool
}

func (a trackingAction) Metadata() opent.ActionMetadata {
	return opent.ActionMetadata{
		Name: "TrackStart",
		Type: opent.ActionTypeAuto,
	}
}

func (a trackingAction) PreCheck(context.Context, *opent.Instance) error {
	return nil
}

func (a trackingAction) Start(context.Context, *opent.Instance) (*actions.ActionResult, error) {
	*a.started = true
	return actions.SucceededActionResult(nil), nil
}

func newTestOperationRegistry(apprSvc apprsvcapi.ApprovalServiceAPI) *operation.Registry {
	const content = `
- name: test-operation
  alias: Test Operation
  target: cluster
  approval:
    id: operation/test-operation
- name: ManageTiProxyExternalDNS
  alias: Manage TiProxy External DNS
  target: cluster
  actions:
    - name: ManageTiProxyExternalDNS
  params:
    cluster_id:
      type: string
    tidb_group_id:
      type: string
    enable:
      type: string
      enum: ["true", "false"]
    ttl:
      type: int
      optional: true
  approval:
    id: operation/ManageTiProxyExternalDNS
`

	file, err := os.CreateTemp("", "operation-registry-*.yaml")
	Expect(err).To(BeNil())
	gk.DeferCleanup(func() {
		_ = os.Remove(file.Name())
	})

	_, err = file.WriteString(content)
	Expect(err).To(BeNil())
	Expect(file.Close()).To(Succeed())

	apprSvc.(*apprsvcapi.MockApprovalServiceAPI).EXPECT().
		RegisterTemplate(gomock.Any(), gomock.Any()).
		Return(nil).Times(2)

	registry, err := operation.InitRegistry(file.Name(), apprSvc)
	Expect(err).To(BeNil())

	return registry
}

func restartPremiumClusterComponentParam(clusterID, tidbGroupID, target string) CreateInstanceParam {
	return CreateInstanceParam{
		Type:      opent.OperationTargetCluster,
		Operation: "RestartPremiumClusterComponent",
		Reason:    "restart tidb",
		Applicant: "operator@pingcap.com",
		Params: map[opent.ParamName]string{
			opent.ParamClusterID:          clusterID,
			opent.ParamTenantID:           "9001",
			opent.ParamTenantRegion:       "TEST_INFRA",
			opent.ParamRestartTargetType:  "component",
			opent.ParamRestartTarget:      target,
			opent.ParamRestartTargetGroup: tidbGroupID,
			opent.ParamForceRestart:       "false",
		},
		Actions: []CreateInstanceActionParam{{
			Name:        ngoperation.ActionRestartNextGenClusterComponent.Name,
			StartPolicy: opent.ActionStartPolicyImmediate,
		}},
	}
}

func scaleNextGenResourcePoolParam(sharedPoolID, componentType string) CreateInstanceParam {
	return CreateInstanceParam{
		Type:      opent.OperationTargetResourcePool,
		Operation: "ScaleNextGenResourcePool",
		Reason:    "scale resource pool component",
		Applicant: "operator@pingcap.com",
		Params: map[opent.ParamName]string{
			opent.ParamResourcePoolID: sharedPoolID,
			opent.ParamComponentType:  componentType,
			opent.ParamNodeCount:      "3",
			opent.ParamCpuCores:       "4",
			opent.ParamMemoryGi:       "8",
		},
		Actions: []CreateInstanceActionParam{{
			Name:        ngoperation.ActionScaleResourcePool.Name,
			StartPolicy: opent.ActionStartPolicyImmediate,
		}},
	}
}

func operationResourcePoolProfile(sharedPoolID string) *nextgenv1.ResourcePoolProfile {
	id, err := strconv.ParseUint(sharedPoolID, 10, 64)
	Expect(err).To(BeNil())
	return &nextgenv1.ResourcePoolProfile{
		ResourcePool: &nextgenv1.ResourcePool{
			Id:        id,
			OwnerType: nextgenv1.ResourcePool_SHARED,
		},
		Region: &commonv1.Region{
			LogicalName:     "aws-us-1",
			CloudProviderId: commonv1.CloudProvider_AWS,
		},
	}
}

func newRestartPremiumClusterComponentOperationRegistry(apprSvc apprsvcapi.ApprovalServiceAPI) *operation.Registry {
	const content = `
- name: RestartPremiumClusterComponent
  alias: Restart Pod/Component/Node For Premium Cluster
  target: cluster
  categories: ["premium"]
  actions:
  - name: RestartNextGenClusterComponent
  params:
    cluster_id:
      type: string
    tenant_id:
      type: string
    tenant_region:
      type: string
    restart_target_type:
      type: string
      enum: ["pod", "component", "node"]
    restart_target:
      type: string
    restart_target_group:
      type: string
    force_restart:
      type: string
      enum: ["false", "true"]
      default: "false"
      optional: true
  approval:
    id: operation/RestartPremiumClusterComponent
`

	file, err := os.CreateTemp("", "restart-operation-registry-*.yaml")
	Expect(err).To(BeNil())
	gk.DeferCleanup(func() {
		_ = os.Remove(file.Name())
	})

	_, err = file.WriteString(content)
	Expect(err).To(BeNil())
	Expect(file.Close()).To(Succeed())

	apprSvc.(*apprsvcapi.MockApprovalServiceAPI).EXPECT().
		RegisterTemplate(gomock.Any(), gomock.Any()).
		Return(nil).Times(1)

	registry, err := operation.InitRegistry(file.Name(), apprSvc)
	Expect(err).To(BeNil())

	return registry
}

func newScaleNextGenResourcePoolOperationRegistry(apprSvc apprsvcapi.ApprovalServiceAPI) *operation.Registry {
	const content = `
- name: ScaleNextGenResourcePool
  alias: Scale NextGen Resource Pool Component
  target: resource_pool
  categories: ["premium"]
  actions:
  - name: ScaleNextGenResourcePool
  params:
    resource_pool_id:
      type: string
    component_type:
      type: string
    component_id:
      type: string
      optional: true
    node_count:
      type: int
    cpu_cores:
      type: int
      optional: true
    memory_gi:
      type: int
      optional: true
    force_scale:
      type: string
      enum: ["false", "true"]
      default: "false"
      optional: true
  approval:
    id: operation/ScaleNextGenResourcePool
`

	file, err := os.CreateTemp("", "scale-resource-pool-operation-registry-*.yaml")
	Expect(err).To(BeNil())
	gk.DeferCleanup(func() {
		_ = os.Remove(file.Name())
	})

	_, err = file.WriteString(content)
	Expect(err).To(BeNil())
	Expect(file.Close()).To(Succeed())

	apprSvc.(*apprsvcapi.MockApprovalServiceAPI).EXPECT().
		RegisterTemplate(gomock.Any(), gomock.Any()).
		Return(nil).Times(1)

	registry, err := operation.InitRegistry(file.Name(), apprSvc)
	Expect(err).To(BeNil())

	return registry
}
