package serverlessaction

import (
	"context"
	"errors"
	"testing"

	"github.com/go-logr/logr"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"

	opent "github.com/tidbcloud/devops/entity/operation"
	"github.com/tidbcloud/devops/pkg/infra/clients/coresvccli"
	"github.com/tidbcloud/devops/pkg/infra/clients/serverlesscli"
	"github.com/tidbcloud/devops/pkg/service/operation/actions"
	"github.com/tidbcloud/serverless-service/idl/pbgen/proto/rpc"
	mgmtv1 "github.com/tidbcloud/tidb-management-service/api/spec/tidb_mgmt_service/v1"
)

const (
	testClusterID    = "123"
	testProjectID    = uint64(10)
	testOrgID        = uint64(20)
	testProvider     = "aws"
	testRegion       = "us-west-2"
	testK8sCluster   = "prod-serverless-us-west-2"
	testKeyspaceTok  = "ks-token-abc"
	testNamespace    = "canary-tidb-pool"
	testBadNamespace = "kube-system"
)

type fixtureOpts struct {
	mgmtCluster *mgmtv1.Cluster
	mgmtErr     error
	// getClusterResp is the response for ClusterCROperationServiceClient.GetCluster.
	getClusterResp *rpc.ClusterDetails
	getClusterErr  error
	// nsResp / nsErr control RegionalK8SOperationServiceClient.RestartTiDBPodsByNamespace.
	nsResp *rpc.OpsRestartByNamespaceResponse
	nsErr  error
	// clusterResp / clusterErr control RegionalK8SOperationServiceClient.RestartTiDBPodsByCluster.
	clusterResp *rpc.OpsRestartByClusterResponse
	clusterErr  error
}

func newFixture(t *testing.T, opts fixtureOpts) *restartTiDBPods {
	t.Helper()
	ctrl := gomock.NewController(t)

	mgmt := coresvccli.NewMockClusterServiceClient(ctrl)
	if opts.mgmtErr != nil {
		mgmt.EXPECT().ListClusters(gomock.Any(), gomock.Any()).Return(nil, opts.mgmtErr).AnyTimes()
	} else {
		resp := &mgmtv1.ListClustersResponse{}
		if opts.mgmtCluster != nil {
			resp.Clusters = []*mgmtv1.Cluster{opts.mgmtCluster}
		}
		mgmt.EXPECT().ListClusters(gomock.Any(), gomock.Any()).Return(resp, nil).AnyTimes()
	}

	crOps := serverlesscli.NewMockClusterCROperationServiceClient(ctrl)
	if opts.getClusterErr != nil {
		crOps.EXPECT().GetCluster(gomock.Any(), gomock.Any()).Return(nil, opts.getClusterErr).AnyTimes()
	} else if opts.getClusterResp != nil {
		crOps.EXPECT().GetCluster(gomock.Any(), gomock.Any()).Return(opts.getClusterResp, nil).AnyTimes()
	}

	regional := serverlesscli.NewMockRegionalK8SOperationServiceClient(ctrl)
	if opts.nsErr != nil {
		regional.EXPECT().RestartTiDBPodsByNamespace(gomock.Any(), gomock.Any()).Return(nil, opts.nsErr).AnyTimes()
	} else if opts.nsResp != nil {
		regional.EXPECT().RestartTiDBPodsByNamespace(gomock.Any(), gomock.Any()).Return(opts.nsResp, nil).AnyTimes()
	}
	if opts.clusterErr != nil {
		regional.EXPECT().RestartTiDBPodsByCluster(gomock.Any(), gomock.Any()).Return(nil, opts.clusterErr).AnyTimes()
	} else if opts.clusterResp != nil {
		regional.EXPECT().RestartTiDBPodsByCluster(gomock.Any(), gomock.Any()).Return(opts.clusterResp, nil).AnyTimes()
	}

	comps := &actions.Components{
		Logger: logr.Discard(),
		CoreSvc: &coresvccli.CoreSvcCli{
			Mgmt: &coresvccli.MgmtService{Cluster: mgmt},
		},
		ServerlessCli: serverlesscli.NewTestClient(regional, crOps),
	}

	return &restartTiDBPods{comps: comps}
}

func validServerlessCluster() *mgmtv1.Cluster {
	return &mgmtv1.Cluster{
		ClusterId:      uint64(123),
		ProjectId:      testProjectID,
		OrganizationId: testOrgID,
		DeployType:     mgmtv1.DeployType_DEPLOY_TYPE_SERVERLESS,
		Region:         testRegion,
	}
}

func validServerlessDetails() *rpc.ClusterDetails {
	return &rpc.ClusterDetails{
		ClusterId:     testClusterID,
		Provider:      testProvider,
		Kubernetes:    testK8sCluster,
		KeyspaceToken: testKeyspaceTok,
	}
}

// params is a set of instance params. empty strings are not set.
type params struct {
	clusterID, scope, provider, region, kubernetes, namespace, force string
}

func buildInstance(p params) *opent.Instance {
	m := map[opent.ParamName]string{}
	if p.clusterID != "" {
		m[opent.ParamClusterID] = p.clusterID
	}
	if p.scope != "" {
		m[opent.ParamRestartScope] = p.scope
	}
	if p.provider != "" {
		m[opent.ParamProvider] = p.provider
	}
	if p.region != "" {
		m[opent.ParamRegion] = p.region
	}
	if p.kubernetes != "" {
		m[opent.ParamKubernetes] = p.kubernetes
	}
	if p.namespace != "" {
		m[opent.ParamNamespace] = p.namespace
	}
	if p.force != "" {
		m[opent.ParamForceRestart] = p.force
	}
	return &opent.Instance{ID: 1, Params: m}
}

func validNamespaceParams() params {
	return params{
		scope:      RestartScopeNamespace,
		provider:   testProvider,
		region:     testRegion,
		kubernetes: testK8sCluster,
		namespace:  testNamespace,
	}
}

func TestPreCheck_NamespaceScope(t *testing.T) {
	tests := []struct {
		name       string
		mutate     func(*params)
		wantErr    bool
		wantErrSub string
	}{
		{
			name:    "happy path",
			mutate:  func(p *params) {},
			wantErr: false,
		},
		{
			name:       "missing provider",
			mutate:     func(p *params) { p.provider = "" },
			wantErr:    true,
			wantErrSub: "cloud_provider is required",
		},
		{
			name:       "missing region",
			mutate:     func(p *params) { p.region = "" },
			wantErr:    true,
			wantErrSub: "region is required",
		},
		{
			name:       "missing kubernetes",
			mutate:     func(p *params) { p.kubernetes = "" },
			wantErr:    true,
			wantErrSub: "kubernetes is required",
		},
		{
			name:       "missing namespace",
			mutate:     func(p *params) { p.namespace = "" },
			wantErr:    true,
			wantErrSub: "namespace is required",
		},
		{
			name:       "namespace not in whitelist",
			mutate:     func(p *params) { p.namespace = testBadNamespace },
			wantErr:    true,
			wantErrSub: "not in the pool whitelist",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			g := NewGomegaWithT(t)
			a := newFixture(t, fixtureOpts{})
			p := validNamespaceParams()
			tt.mutate(&p)
			instance := buildInstance(p)

			err := a.PreCheck(context.Background(), instance)
			if tt.wantErr {
				g.Expect(err).To(HaveOccurred())
				if tt.wantErrSub != "" {
					g.Expect(err.Error()).To(ContainSubstring(tt.wantErrSub))
				}
				return
			}
			g.Expect(err).ToNot(HaveOccurred())
			g.Expect(instance.Outputs).To(HaveKeyWithValue(outputProvider, testProvider))
			g.Expect(instance.Outputs).To(HaveKeyWithValue(outputRegion, testRegion))
			g.Expect(instance.Outputs).To(HaveKeyWithValue(outputKubernetes, testK8sCluster))
			g.Expect(instance.Outputs).To(HaveKeyWithValue(outputNamespace, testNamespace))
			g.Expect(instance.Outputs).To(HaveKey(outputTargets))
			g.Expect(instance.Outputs[outputTargets]).To(ContainSubstring(testNamespace))
		})
	}
}

func TestPreCheck_NamespaceScope_DoesNotCallMgmt(t *testing.T) {
	g := NewGomegaWithT(t)
	a := newFixture(t, fixtureOpts{})
	err := a.PreCheck(context.Background(), buildInstance(validNamespaceParams()))
	g.Expect(err).ToNot(HaveOccurred())
}

func TestPreCheck_ClusterScope(t *testing.T) {
	tests := []struct {
		name     string
		instance *opent.Instance
		opts     fixtureOpts
		wantErr  bool
	}{
		{
			name:     "missing cluster id",
			instance: buildInstance(params{scope: RestartScopeCluster}),
			opts:     fixtureOpts{},
			wantErr:  true,
		},
		{
			name:     "mgmt cluster not found",
			instance: buildInstance(params{clusterID: testClusterID, scope: RestartScopeCluster}),
			opts:     fixtureOpts{},
			wantErr:  true,
		},
		{
			name:     "deploy type mismatch",
			instance: buildInstance(params{clusterID: testClusterID, scope: RestartScopeCluster}),
			opts: fixtureOpts{
				mgmtCluster: &mgmtv1.Cluster{
					ClusterId:      123,
					DeployType:     mgmtv1.DeployType_DEPLOY_TYPE_NEXTGEN,
					ProjectId:      testProjectID,
					OrganizationId: testOrgID,
					Region:         testRegion,
				},
			},
			wantErr: true,
		},
		{
			name:     "serverless get cluster error",
			instance: buildInstance(params{clusterID: testClusterID, scope: RestartScopeCluster}),
			opts: fixtureOpts{
				mgmtCluster:   validServerlessCluster(),
				getClusterErr: errors.New("boom"),
			},
			wantErr: true,
		},
		{
			name:     "happy path",
			instance: buildInstance(params{clusterID: testClusterID, scope: RestartScopeCluster}),
			opts: fixtureOpts{
				mgmtCluster:    validServerlessCluster(),
				getClusterResp: validServerlessDetails(),
			},
			wantErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			g := NewGomegaWithT(t)
			a := newFixture(t, tt.opts)

			err := a.PreCheck(context.Background(), tt.instance)
			if tt.wantErr {
				g.Expect(err).To(HaveOccurred())
				return
			}
			g.Expect(err).ToNot(HaveOccurred())
			g.Expect(tt.instance.Outputs).To(HaveKeyWithValue(outputKeyspaceToken, testKeyspaceTok))
			g.Expect(tt.instance.Outputs).To(HaveKeyWithValue(outputProvider, testProvider))
			g.Expect(tt.instance.Outputs).To(HaveKeyWithValue(outputRegion, testRegion))
			g.Expect(tt.instance.Outputs).To(HaveKeyWithValue(outputKubernetes, testK8sCluster))
			g.Expect(tt.instance.Outputs).To(HaveKey(outputTargets))
		})
	}
}

func TestPreCheck_EmptyKeyspaceTokenIsExplicitError(t *testing.T) {
	g := NewGomegaWithT(t)
	a := newFixture(t, fixtureOpts{
		mgmtCluster: validServerlessCluster(),
		getClusterResp: &rpc.ClusterDetails{
			ClusterId:  testClusterID,
			Provider:   testProvider,
			Kubernetes: testK8sCluster,
			KeyspaceId: 42,
		},
	})

	err := a.PreCheck(context.Background(), buildInstance(params{clusterID: testClusterID, scope: RestartScopeCluster}))
	g.Expect(err).To(HaveOccurred())
	g.Expect(err.Error()).To(ContainSubstring("KeyspaceToken"))
}

func TestPreCheck_ScopeValidation(t *testing.T) {
	tests := []struct {
		name       string
		instance   *opent.Instance
		wantErrSub string
	}{
		{
			name:       "missing scope",
			instance:   buildInstance(params{clusterID: testClusterID}),
			wantErrSub: "restart_scope is required",
		},
		{
			name:       "invalid scope",
			instance:   buildInstance(params{clusterID: testClusterID, scope: "weird"}),
			wantErrSub: "invalid restart_scope",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			g := NewGomegaWithT(t)
			a := newFixture(t, fixtureOpts{})
			err := a.PreCheck(context.Background(), tt.instance)
			g.Expect(err).To(HaveOccurred())
			g.Expect(err.Error()).To(ContainSubstring(tt.wantErrSub))
		})
	}
}

func TestStart(t *testing.T) {
	tests := []struct {
		name       string
		instance   *opent.Instance
		opts       fixtureOpts
		wantStatus opent.ActionStatus
	}{
		{
			name:     "namespace scope: happy path returns running",
			instance: buildInstance(validNamespaceParams()),
			opts: fixtureOpts{
				nsResp: &rpc.OpsRestartByNamespaceResponse{Message: "ok"},
			},
			wantStatus: opent.ActionStatusRunning,
		},
		{
			name:     "namespace scope: rpc error propagates",
			instance: buildInstance(validNamespaceParams()),
			opts: fixtureOpts{
				nsErr: errors.New("grpc unavailable"),
			},
			wantStatus: opent.ActionStatusFailed,
		},
		{
			name: "namespace scope: whitelist enforced at Start",
			instance: buildInstance(params{
				scope:      RestartScopeNamespace,
				provider:   testProvider,
				region:     testRegion,
				kubernetes: testK8sCluster,
				namespace:  testBadNamespace,
			}),
			opts:       fixtureOpts{},
			wantStatus: opent.ActionStatusFailed,
		},
		{
			name:     "cluster scope: happy path returns running",
			instance: buildInstance(params{clusterID: testClusterID, scope: RestartScopeCluster}),
			opts: fixtureOpts{
				mgmtCluster:    validServerlessCluster(),
				getClusterResp: validServerlessDetails(),
				clusterResp:    &rpc.OpsRestartByClusterResponse{Message: "ok"},
			},
			wantStatus: opent.ActionStatusRunning,
		},
		{
			name:     "cluster scope: empty KeyspaceToken fails at Start too",
			instance: buildInstance(params{clusterID: testClusterID, scope: RestartScopeCluster}),
			opts: fixtureOpts{
				mgmtCluster: validServerlessCluster(),
				getClusterResp: &rpc.ClusterDetails{
					ClusterId:  testClusterID,
					Provider:   testProvider,
					Kubernetes: testK8sCluster,
					KeyspaceId: 42,
				},
			},
			wantStatus: opent.ActionStatusFailed,
		},
		{
			name:     "cluster scope: rpc error propagates",
			instance: buildInstance(params{clusterID: testClusterID, scope: RestartScopeCluster}),
			opts: fixtureOpts{
				mgmtCluster:    validServerlessCluster(),
				getClusterResp: validServerlessDetails(),
				clusterErr:     errors.New("grpc unavailable"),
			},
			wantStatus: opent.ActionStatusFailed,
		},
		{
			name:       "missing scope fails at Start",
			instance:   buildInstance(params{clusterID: testClusterID}),
			opts:       fixtureOpts{},
			wantStatus: opent.ActionStatusFailed,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			g := NewGomegaWithT(t)
			a := newFixture(t, tt.opts)

			res, err := a.Start(context.Background(), tt.instance)
			g.Expect(err).ToNot(HaveOccurred())
			g.Expect(res).ToNot(BeNil())
			g.Expect(res.Result).To(Equal(tt.wantStatus))
		})
	}
}

func TestStart_ForcePropagates(t *testing.T) {
	cases := []struct {
		name      string
		forceStr  string
		wantForce bool
	}{
		{"force=true", "true", true},
		{"force=false", "false", false},
		{"force unset defaults to false", "", false},
	}
	for _, tc := range cases {
		t.Run("namespace scope: "+tc.name, func(t *testing.T) {
			g := NewGomegaWithT(t)
			ctrl := gomock.NewController(t)

			regional := serverlesscli.NewMockRegionalK8SOperationServiceClient(ctrl)
			regional.EXPECT().
				RestartTiDBPodsByNamespace(gomock.Any(), &rpc.OpsRestartByNamespaceRequest{
					Provider:   testProvider,
					Region:     testRegion,
					K8SCluster: testK8sCluster,
					Namespace:  testNamespace,
					Force:      tc.wantForce,
				}).
				Return(&rpc.OpsRestartByNamespaceResponse{Message: "ok"}, nil)

			a := &restartTiDBPods{
				comps: &actions.Components{
					Logger:        logr.Discard(),
					ServerlessCli: serverlesscli.NewTestClient(regional, nil),
				},
			}
			p := validNamespaceParams()
			p.force = tc.forceStr
			res, err := a.Start(context.Background(), buildInstance(p))
			g.Expect(err).ToNot(HaveOccurred())
			g.Expect(res.Result).To(Equal(opent.ActionStatusRunning))
		})
		t.Run("cluster scope: "+tc.name, func(t *testing.T) {
			g := NewGomegaWithT(t)
			ctrl := gomock.NewController(t)

			mgmt := coresvccli.NewMockClusterServiceClient(ctrl)
			mgmt.EXPECT().ListClusters(gomock.Any(), gomock.Any()).Return(
				&mgmtv1.ListClustersResponse{Clusters: []*mgmtv1.Cluster{validServerlessCluster()}},
				nil,
			).AnyTimes()

			crOps := serverlesscli.NewMockClusterCROperationServiceClient(ctrl)
			crOps.EXPECT().GetCluster(gomock.Any(), gomock.Any()).Return(validServerlessDetails(), nil).AnyTimes()

			regional := serverlesscli.NewMockRegionalK8SOperationServiceClient(ctrl)
			regional.EXPECT().
				RestartTiDBPodsByCluster(gomock.Any(), &rpc.OpsRestartByClusterRequest{
					Provider:      testProvider,
					Region:        testRegion,
					K8SCluster:    testK8sCluster,
					KeyspaceToken: testKeyspaceTok,
					Force:         tc.wantForce,
				}).
				Return(&rpc.OpsRestartByClusterResponse{Message: "ok"}, nil)

			a := &restartTiDBPods{
				comps: &actions.Components{
					Logger:        logr.Discard(),
					CoreSvc:       &coresvccli.CoreSvcCli{Mgmt: &coresvccli.MgmtService{Cluster: mgmt}},
					ServerlessCli: serverlesscli.NewTestClient(regional, crOps),
				},
			}
			res, err := a.Start(context.Background(), buildInstance(params{
				clusterID: testClusterID,
				scope:     RestartScopeCluster,
				force:     tc.forceStr,
			}))
			g.Expect(err).ToNot(HaveOccurred())
			g.Expect(res.Result).To(Equal(opent.ActionStatusRunning))
		})
	}
}

func TestOnCancel(t *testing.T) {
	g := NewGomegaWithT(t)
	a := newFixture(t, fixtureOpts{})
	cancelled, err := a.OnCancel(context.Background(), buildInstance(validNamespaceParams()))
	g.Expect(err).ToNot(HaveOccurred())
	g.Expect(cancelled).To(BeFalse())
}

func TestMetadata(t *testing.T) {
	g := NewGomegaWithT(t)
	a := newFixture(t, fixtureOpts{})
	g.Expect(a.Metadata().Name).To(Equal(ActionServerlessOpsRestartTiDBPods.Name))
	g.Expect(a.Metadata().Type).To(Equal(opent.ActionTypeAuto))
}
