package cloudconfig

import (
	"context"
	"errors"
	"testing"

	auditent "github.com/tidbcloud/devops/entity/audit"
	ccent "github.com/tidbcloud/devops/entity/cloudconfig"
	maintent "github.com/tidbcloud/devops/entity/maintenance"
	ccmod "github.com/tidbcloud/devops/models/cloudconfig"
	"github.com/tidbcloud/devops/pkg/infra/consts"
	"github.com/tidbcloud/devops/pkg/infra/utils/pointer"
	"github.com/tidbcloud/devops/pkg/infra/utils/repoopt"
	testutil "github.com/tidbcloud/devops/pkg/infra/utils/test"
	auditsvc "github.com/tidbcloud/devops/pkg/service/audit"
	ccapi "github.com/tidbcloud/devops/pkg/service/cloudconfig/api"
	ccrepo "github.com/tidbcloud/devops/pkg/service/cloudconfig/repo"
	cmxapi "github.com/tidbcloud/devops/pkg/service/clustermaintenance/api"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

func TestCloudConfigConfigurationChange(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "CloudConfig ConfigurationChange Suite", testutil.ReporterConfig())
}

var _ = Describe("ConfigurationChangeService", func() {
	var (
		ctx            context.Context
		ctrl           *gomock.Controller
		repo           *ccrepo.MockCloudConfigRepo
		dbp            *testutil.MockTransProvider
		maintenanceSvc *cmxapi.MockServiceAPI
		service        *svc
	)

	BeforeEach(func() {
		ctx = context.Background()
		ctrl = gomock.NewController(GinkgoT())
		repo = ccrepo.NewMockCloudConfigRepo(ctrl)
		dbp = testutil.NewMockTransProvider(ctrl)
		maintenanceSvc = cmxapi.NewMockServiceAPI(ctrl)
		service = &svc{
			repo:           repo,
			dbp:            dbp,
			auditSvc:       cloudConfigTestAuditService{},
			syncChgChan:    make(chan uint64, 1),
			maintenanceSvc: maintenanceSvc,
		}
	})

	AfterEach(func() {
		ctrl.Finish()
	})

	Describe("GetConfigurationChange", func() {
		It("fills execution window when execution window id exists", func() {
			change := &ccent.ConfigurationChange{
				ID:                1,
				ExecutionWindowID: pointer.Pointer(uint64(20)),
			}
			window := &maintent.OpsExecutionWindow{
				ID:      20,
				StartAt: 100,
				EndAt:   200,
			}
			repo.EXPECT().GetConfigurationChange(ctx, uint64(1)).Return(change, nil)
			maintenanceSvc.EXPECT().
				ListOpsExecutionWindows(ctx, gomock.Any()).
				DoAndReturn(func(_ context.Context, param cmxapi.ListOpsExecutionWindowParam) ([]*maintent.OpsExecutionWindow, int64, error) {
					Expect(param.IDs).To(ConsistOf(uint64(20)))
					Expect(param.Page).To(BeZero())
					Expect(param.PerPage).To(BeZero())
					return []*maintent.OpsExecutionWindow{window}, int64(1), nil
				})

			got, err := service.GetConfigurationChange(ctx, 1)

			Expect(err).ToNot(HaveOccurred())
			Expect(got).To(Equal(change))
			Expect(got.ExecutionWindow).To(Equal(window))
		})

		It("does not call maintenance service when execution window id is empty", func() {
			change := &ccent.ConfigurationChange{ID: 1}
			repo.EXPECT().GetConfigurationChange(ctx, uint64(1)).Return(change, nil)

			got, err := service.GetConfigurationChange(ctx, 1)

			Expect(err).ToNot(HaveOccurred())
			Expect(got).To(Equal(change))
			Expect(got.ExecutionWindow).To(BeNil())
		})

		It("returns error when execution window lookup fails", func() {
			expectedErr := errors.New("window lookup failed")
			change := &ccent.ConfigurationChange{
				ID:                1,
				ExecutionWindowID: pointer.Pointer(uint64(20)),
			}
			repo.EXPECT().GetConfigurationChange(ctx, uint64(1)).Return(change, nil)
			maintenanceSvc.EXPECT().
				ListOpsExecutionWindows(ctx, gomock.Any()).
				Return(nil, int64(0), expectedErr)

			got, err := service.GetConfigurationChange(ctx, 1)

			Expect(err).To(MatchError(expectedErr))
			Expect(got).To(BeNil())
		})
	})

	Describe("ListConfigurationChanges", func() {
		It("fills execution windows only for changes with execution window id", func() {
			changes := []*ccent.ConfigurationChange{
				{ID: 1},
				{ID: 2, ExecutionWindowID: pointer.Pointer(uint64(20))},
				{ID: 3, ExecutionWindowID: pointer.Pointer(uint64(30))},
			}
			window20 := &maintent.OpsExecutionWindow{ID: 20, StartAt: 100, EndAt: 200}
			window30 := &maintent.OpsExecutionWindow{ID: 30, StartAt: 300, EndAt: 400}
			repo.EXPECT().CountConfigurationChange(ctx).Return(int64(len(changes)), nil)
			repo.EXPECT().ListConfigurationChanges(ctx).Return(changes, nil)
			maintenanceSvc.EXPECT().
				ListOpsExecutionWindows(ctx, gomock.Any()).
				DoAndReturn(func(_ context.Context, param cmxapi.ListOpsExecutionWindowParam) ([]*maintent.OpsExecutionWindow, int64, error) {
					Expect(param.IDs).To(ConsistOf(uint64(20), uint64(30)))
					Expect(param.Page).To(BeZero())
					Expect(param.PerPage).To(BeZero())
					return []*maintent.OpsExecutionWindow{window20, window30}, int64(2), nil
				})

			got, total, err := service.ListConfigurationChanges(ctx, ccapi.ListConfigurationChangesParam{})

			Expect(err).ToNot(HaveOccurred())
			Expect(total).To(Equal(int64(3)))
			Expect(got).To(Equal(changes))
			Expect(got[0].ExecutionWindow).To(BeNil())
			Expect(got[1].ExecutionWindow).To(Equal(window20))
			Expect(got[2].ExecutionWindow).To(Equal(window30))
		})

		It("returns error when execution window lookup fails", func() {
			expectedErr := errors.New("window lookup failed")
			changes := []*ccent.ConfigurationChange{
				{ID: 1, ExecutionWindowID: pointer.Pointer(uint64(20))},
			}
			repo.EXPECT().CountConfigurationChange(ctx).Return(int64(1), nil)
			repo.EXPECT().ListConfigurationChanges(ctx).Return(changes, nil)
			maintenanceSvc.EXPECT().
				ListOpsExecutionWindows(ctx, gomock.Any()).
				Return(nil, int64(0), expectedErr)

			got, total, err := service.ListConfigurationChanges(ctx, ccapi.ListConfigurationChangesParam{})

			Expect(err).To(MatchError(expectedErr))
			Expect(got).To(BeNil())
			Expect(total).To(Equal(int64(0)))
		})

		It("returns error when execution window result is missing", func() {
			changes := []*ccent.ConfigurationChange{
				{ID: 1, ExecutionWindowID: pointer.Pointer(uint64(20))},
			}
			repo.EXPECT().CountConfigurationChange(ctx).Return(int64(1), nil)
			repo.EXPECT().ListConfigurationChanges(ctx).Return(changes, nil)
			maintenanceSvc.EXPECT().
				ListOpsExecutionWindows(ctx, gomock.Any()).
				Return([]*maintent.OpsExecutionWindow{}, int64(0), nil)

			got, total, err := service.ListConfigurationChanges(ctx, ccapi.ListConfigurationChangesParam{})

			Expect(err).To(MatchError("ops execution window 20 not found"))
			Expect(got).To(BeNil())
			Expect(total).To(Equal(int64(0)))
		})

		It("passes create time range filters to repo", func() {
			createAfter := int64(1779000000)
			createBefore := int64(1779426353)
			repo.EXPECT().
				CountConfigurationChange(ctx, gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, opts ...repoopt.Option) (int64, error) {
					expectConfigurationChangeCreatedAtFilters(opts, createAfter, createBefore)
					return int64(0), nil
				})
			repo.EXPECT().
				ListConfigurationChanges(ctx, gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, opts ...repoopt.Option) ([]*ccent.ConfigurationChange, error) {
					expectConfigurationChangeCreatedAtFilters(opts, createAfter, createBefore)
					return []*ccent.ConfigurationChange{}, nil
				})

			got, total, err := service.ListConfigurationChanges(ctx, ccapi.ListConfigurationChangesParam{
				CreatedAtStartUnixSec: &createAfter,
				CreatedAtEndUnixSec:   &createBefore,
			})

			Expect(err).ToNot(HaveOccurred())
			Expect(total).To(Equal(int64(0)))
			Expect(got).To(BeEmpty())
		})
	})

	Describe("CreateConfigurationChange", func() {
		It("fills operator entity from request context", func() {
			ctx = context.WithValue(ctx, consts.CtxOperatorType, consts.CtxOperatorTypeAPIKey)
			ctx = context.WithValue(ctx, consts.CtxOperator, "key-a")
			expectCreateConfigurationChangeOperatorEntity(ctx, service, repo, dbp, "api_key:key-a", ccapi.CreateConfigurationChangeParam{
				Applicant: "owner@pingcap.com",
			})
		})

		It("falls back to applicant when request context does not have operator", func() {
			expectCreateConfigurationChangeOperatorEntity(ctx, service, repo, dbp, "unknown:owner@pingcap.com", ccapi.CreateConfigurationChangeParam{
				Applicant: "owner@pingcap.com",
			})
		})

		It("preserves prefilled operator entity", func() {
			ctx = context.WithValue(ctx, consts.CtxOperatorType, consts.CtxOperatorTypeAPIKey)
			ctx = context.WithValue(ctx, consts.CtxOperator, "key-a")
			expectCreateConfigurationChangeOperatorEntity(ctx, service, repo, dbp, "user:prefilled@pingcap.com", ccapi.CreateConfigurationChangeParam{
				Applicant:      "owner@pingcap.com",
				OperatorEntity: "user:prefilled@pingcap.com",
			})
		})
	})

	Describe("ManagedConfigurationChange", func() {
		It("finds a managed change by its unique step reference", func() {
			stepID := uint64(99)
			change := &ccent.ConfigurationChange{ID: 10, PremiumUpgradeStepID: &stepID}
			repo.EXPECT().ListConfigurationChanges(ctx, gomock.Any()).Return([]*ccent.ConfigurationChange{change}, nil)

			got, err := service.GetManagedConfigurationChangeByStep(ctx, stepID)

			Expect(err).NotTo(HaveOccurred())
			Expect(got).To(Equal(change))
		})

		It("rejects a public apply for a managed change", func() {
			stepID := uint64(99)
			repo.EXPECT().GetConfigurationChange(ctx, uint64(10)).Return(&ccent.ConfigurationChange{
				ID: 10, PremiumUpgradeStepID: &stepID,
			}, nil)

			err := service.ApplyConfigurationChange(ctx, 10, ccapi.ApplyConfigurationChangeParam{OperatorEmail: "owner@pingcap.com"})

			Expect(err).To(MatchError(ContainSubstring("must be applied through its premium upgrade plan")))
		})

		It("rejects a public cancel for a managed change", func() {
			stepID := uint64(99)
			repo.EXPECT().GetConfigurationChange(ctx, uint64(10)).Return(&ccent.ConfigurationChange{
				ID: 10, PremiumUpgradeStepID: &stepID,
			}, nil)

			err := service.CancelConfigurationChange(ctx, 10)

			Expect(err).To(MatchError(ContainSubstring("must be cancelled through its premium upgrade plan")))
		})

		It("marks a managed change failed with a terminal phase", func() {
			stepID := uint64(3)
			change := &ccent.ConfigurationChange{
				ID: 10, Phase: ccent.ConfigurationChangePhaseApplying,
				PremiumUpgradeStepID: &stepID,
			}
			repo.EXPECT().GetConfigurationChange(ctx, uint64(10)).Return(change, nil)
			repo.EXPECT().PatchConfigurationChange(ctx, change, gomock.Any()).DoAndReturn(
				func(_ context.Context, current *ccent.ConfigurationChange, patches ...ccrepo.PatchConfigurationChange) error {
					for _, patch := range patches {
						Expect(patch(current)).To(Succeed())
					}
					return nil
				},
			)

			err := service.FailManagedConfigurationChange(ctx, ccapi.ManagedConfigurationChangeRef{StepID: stepID}, 10)

			Expect(err).NotTo(HaveOccurred())
			Expect(change.Phase).To(Equal(ccent.ConfigurationChangePhaseFailed))
			Expect(change.ClosedAt).NotTo(BeNil())
		})
	})
})

func expectCreateConfigurationChangeOperatorEntity(
	ctx context.Context,
	service *svc,
	repo *ccrepo.MockCloudConfigRepo,
	dbp *testutil.MockTransProvider,
	want string,
	param ccapi.CreateConfigurationChangeParam,
) {
	dbp.EXPECT().
		Transaction(gomock.Any(), gomock.Any()).
		DoAndReturn(func(ctx context.Context, cb func(context.Context) error) error {
			return cb(ctx)
		})
	repo.EXPECT().
		CreateConfigurationChange(gomock.Any(), gomock.Any()).
		DoAndReturn(func(_ context.Context, change *ccent.ConfigurationChange) (*ccent.ConfigurationChange, error) {
			Expect(change.OperatorEntity).To(Equal(want))
			change.ID = 1
			return change, nil
		})

	param.Reason = "reason"
	param.ChangeType = ccapi.ChangeTypeTidb
	param.ApplyPolicy = ccent.ParameterApplyPolicyRestart
	param.TargetTemplate = &ccent.ConfigurationTemplateLocator{
		ClusterType:  ccent.ClusterTypeDedicatedTiDB,
		TemplateType: ccent.TemplateTypeCustom,
	}

	id, err := service.CreateConfigurationChange(ctx, param)
	Expect(err).ToNot(HaveOccurred())
	Expect(id).To(Equal(pointer.Pointer(uint64(1))))
}

type cloudConfigTestAuditService struct{}

func (cloudConfigTestAuditService) ListAuditLog(context.Context, auditsvc.ListAuditLogParam) ([]*auditent.AuditLog, int64, error) {
	return nil, 0, nil
}

func (cloudConfigTestAuditService) ListAuditLogTypes(context.Context) ([]auditent.AuditLogType, error) {
	return nil, nil
}

func (cloudConfigTestAuditService) Create(context.Context, *auditent.AuditLog) error {
	return nil
}

func expectConfigurationChangeCreatedAtFilters(opts []repoopt.Option, createAfter, createBefore int64) {
	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DryRun: true})
	Expect(err).ToNot(HaveOccurred())

	query := repoopt.Aggregate(opts...).Apply(db.Model(&ccmod.ConfigurationChange{}))
	stmt := query.Find(&[]ccmod.ConfigurationChange{}).Statement

	Expect(stmt.SQL.String()).To(ContainSubstring("created_at >= FROM_UNIXTIME(?)"))
	Expect(stmt.SQL.String()).To(ContainSubstring("created_at <= FROM_UNIXTIME(?)"))
	Expect(stmt.Vars).To(ContainElement(createAfter))
	Expect(stmt.Vars).To(ContainElement(createBefore))
}
