package regionaction

import (
	"context"
	"errors"
	"fmt"
	"testing"

	"github.com/crossplane-contrib/provider-aws/apis/eks/manualv1alpha1"
	"github.com/go-logr/logr"
	. "github.com/onsi/gomega"
	"github.com/tidbcloud/devops/pkg/infra/clients/mgmthttpcli"
	apiv1 "github.com/tidbcloud/infra-provider/api/tidbcloud/cluster-server/v1"
	regionalv1 "github.com/tidbcloud/infra-provider/api/tidbcloud/regional-server/v1"
	"go.buf.build/protocolbuffers/go/linuxgit/tidbcloud/base"
	"go.uber.org/mock/gomock"
	"k8s.io/client-go/rest"
	"k8s.io/utils/ptr"
	"sigs.k8s.io/controller-runtime/pkg/client"

	"github.com/tidbcloud/core-svc/idl/pbgen/proto/core"
	opent "github.com/tidbcloud/devops/entity/operation"
	"github.com/tidbcloud/devops/pkg/infra/clients/coresvccli"
	"github.com/tidbcloud/devops/pkg/service/operation/actions"
)

// mockClient is a mock implementation of client.Client
type mockClient struct {
	client.Client
	mockList   func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error
	mockGet    func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error
	mockUpdate func(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error
}

type mockInfraAPIClient struct {
	cli client.Client
}

func (m *mockInfraAPIClient) GetClient() client.Client {
	return m.cli
}

func (m *mockInfraAPIClient) GetRestConfig() *rest.Config {
	return nil
}

func (m *mockClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
	if m.mockList != nil {
		return m.mockList(ctx, list, opts...)
	}
	return nil
}

func (m *mockClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
	if m.mockGet != nil {
		return m.mockGet(ctx, key, obj, opts...)
	}
	return nil
}

func (m *mockClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
	if m.mockUpdate != nil {
		return m.mockUpdate(ctx, obj, opts...)
	}
	return nil
}

type testSetup struct {
	ctrl   *gomock.Controller
	action *upgradeCDCNodes
	ctx    context.Context
	g      *WithT
}

type mockSetupOptions struct {
	coreError bool
}

func setupTest(t *testing.T, opts mockSetupOptions) *testSetup {
	g := NewGomegaWithT(t)
	ctrl := gomock.NewController(t)
	ctx := context.Background()

	mockCore := setupMockCoreService(ctrl, opts.coreError)
	mockMgmtClient := setupMockMgmtClient(ctrl)

	comps := &actions.Components{
		Logger:      logr.Discard(),
		InfraAPICli: &mockInfraAPIClient{cli: &mockClient{}},
		CoreSvc:     &coresvccli.CoreSvcCli{Core: mockCore},
		MgmtHTTP:    mockMgmtClient,
	}

	action := NewUpgradeCDCNodes(comps).(*upgradeCDCNodes)
	g.Expect(action).NotTo(BeNil())

	return &testSetup{
		ctrl:   ctrl,
		action: action,
		ctx:    ctx,
		g:      g,
	}
}

func setupMockCoreService(ctrl *gomock.Controller, withError bool) *coresvccli.MockCoreServiceClient {
	mockCore := coresvccli.NewMockCoreServiceClient(ctrl)
	if withError {
		mockCore.EXPECT().ListClusters(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("base resp is nil")).AnyTimes()
	} else {
		mockCore.EXPECT().ListClusters(gomock.Any(), gomock.Any()).Return(&core.ListClustersResp{
			BaseResp: &base.BaseResp{},
			Clusters: map[uint64]*core.Cluster{123: {RegionName: "us-west-2", CloudProviderName: "aws"}},
		}, nil).AnyTimes()
	}
	return mockCore
}

func setupMockMgmtClient(ctrl *gomock.Controller) *mgmthttpcli.MockMgmtHTTPClient {
	mockClusterServer := mgmthttpcli.NewMockClusterServer(ctrl)
	mockClusterServer.EXPECT().GetCluster(gomock.Any(), gomock.Any()).Return(&apiv1.Cluster{
		Kubernetes: &apiv1.Kubernetes{Name: "test-cluster"},
	}, nil).AnyTimes()

	mockRegionalServer := mgmthttpcli.NewMockRegionServer(ctrl)
	mockRegionalServer.EXPECT().GetK8sInformation(gomock.Any(), gomock.Any()).Return(&regionalv1.K8SInformation{
		Region:              "us-west-2",
		CloudProvider:       "aws",
		ControlPlaneVersion: "1.30",
	}, nil).AnyTimes()

	mockMgmtClient := mgmthttpcli.NewMockMgmtHTTPClient(ctrl)
	mockMgmtClient.EXPECT().ClusterServer(gomock.Any()).Return(mockClusterServer).AnyTimes()
	mockMgmtClient.EXPECT().RegionServer("us-west-2", "aws").Return(mockRegionalServer).AnyTimes()

	return mockMgmtClient
}

func createNodeGroup(name, version, releaseVersion string, status manualv1alpha1.NodeGroupStatusType) manualv1alpha1.NodeGroup {
	ng := manualv1alpha1.NodeGroup{
		Status: manualv1alpha1.NodeGroupStatus{
			AtProvider: manualv1alpha1.NodeGroupObservation{
				Version: version,
				Status:  status,
			},
		},
	}
	ng.Name = name
	ng.Namespace = "default"

	if releaseVersion != "" {
		ng.Status.AtProvider.ReleaseVersion = releaseVersion
		ng.Spec.ForProvider.ReleaseVersion = ptr.To(releaseVersion)
	}

	return ng
}

func createNodeGroupList(count int, version string, status manualv1alpha1.NodeGroupStatusType) *manualv1alpha1.NodeGroupList {
	items := make([]manualv1alpha1.NodeGroup, count)
	for i := 0; i < count; i++ {
		items[i] = createNodeGroup(fmt.Sprintf("nodegroup-%d", i+1), version, "", status)
	}
	return &manualv1alpha1.NodeGroupList{
		Items: items,
	}
}

func createInstance(clusterID string, outputs map[opent.OutputName]string) *opent.Instance {
	params := map[opent.ParamName]string{
		opent.ParamClusterID: clusterID,
	}

	if outputs == nil {
		outputs = make(map[opent.OutputName]string)
	}

	return &opent.Instance{
		ID:      123,
		Params:  params,
		Outputs: outputs,
	}
}

func Test_upgradeCDCNodes_Start(t *testing.T) {
	t.Run("early failures", func(t *testing.T) {
		tests := []struct {
			name               string
			instance           *opent.Instance
			expectedErrMessage string
		}{
			{
				name:               "missing cluster ID",
				instance:           createInstance("", nil),
				expectedErrMessage: "cluster_id is required",
			},
			{
				name:               "invalid cluster ID",
				instance:           createInstance("invalid", nil),
				expectedErrMessage: "invalid syntax",
			},
			{
				name:               "get k8s information error",
				instance:           createInstance("123", nil),
				expectedErrMessage: "failed to get cluster from core-svc: base resp is nil",
			},
		}

		for _, tt := range tests {
			t.Run(tt.name, func(t *testing.T) {
				setup := setupTest(t, mockSetupOptions{coreError: true})

				result, err := setup.action.Start(setup.ctx, tt.instance)

				setup.g.Expect(err).To(BeNil())
				setup.g.Expect(result.Result).To(Equal(opent.ActionStatusFailed))
				setup.g.Expect(result.ActionOutput).To(ContainSubstring(tt.expectedErrMessage))
			})
		}
	})

	t.Run("success scenarios", func(t *testing.T) {
		tests := []struct {
			name            string
			instance        *opent.Instance
			setupMocks      func(*mockClient)
			expectedResult  opent.ActionStatus
			expectedOutputs map[opent.OutputName]string
		}{
			{
				name:     "no nodegroups found",
				instance: createInstance("123", nil),
				setupMocks: func(m *mockClient) {
					m.mockList = func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
						ngList := list.(*manualv1alpha1.NodeGroupList)
						ngList.Items = []manualv1alpha1.NodeGroup{}
						return nil
					}
				},
				expectedResult: opent.ActionStatusSucceeded,
			},
			{
				name:     "use control plane version when desired version is empty",
				instance: createInstance("123", nil),
				setupMocks: func(m *mockClient) {
					m.mockList = func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
						ngList := list.(*manualv1alpha1.NodeGroupList)
						*ngList = *createNodeGroupList(1, "1.29", manualv1alpha1.NodeGroupStatusActive)
						return nil
					}
					m.mockGet = func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
						ng := obj.(*manualv1alpha1.NodeGroup)
						ng.Name = key.Name
						ng.Namespace = key.Namespace
						ng.Status.AtProvider.Version = "1.29"
						return nil
					}
					m.mockUpdate = func(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
						return nil
					}
				},
				expectedResult:  opent.ActionStatusRunning,
				expectedOutputs: map[opent.OutputName]string{opent.OutputNodeGroupNames: "nodegroup-1"},
			},
			{
				name:     "all nodegroups already at desired version",
				instance: createInstance("123", nil),
				setupMocks: func(m *mockClient) {
					m.mockList = func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
						ngList := list.(*manualv1alpha1.NodeGroupList)
						*ngList = *createNodeGroupList(2, "1.32", manualv1alpha1.NodeGroupStatusActive)
						return nil
					}
				},
				expectedResult:  opent.ActionStatusRunning,
				expectedOutputs: map[opent.OutputName]string{opent.OutputNodeGroupNames: "nodegroup-1,nodegroup-2"},
			},
			{
				name:     "update nodegroups successfully",
				instance: createInstance("123", nil),
				setupMocks: func(m *mockClient) {
					m.mockList = func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
						ngList := list.(*manualv1alpha1.NodeGroupList)
						*ngList = *createNodeGroupList(2, "1.22", manualv1alpha1.NodeGroupStatusActive)
						return nil
					}
					m.mockGet = func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
						ng := obj.(*manualv1alpha1.NodeGroup)
						ng.Name = key.Name
						ng.Namespace = key.Namespace
						ng.Status.AtProvider.Version = "1.22"
						return nil
					}
					m.mockUpdate = func(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
						return nil
					}
				},
				expectedResult:  opent.ActionStatusRunning,
				expectedOutputs: map[opent.OutputName]string{opent.OutputNodeGroupNames: "nodegroup-1,nodegroup-2"},
			},
			{
				name:     "list nodegroups error",
				instance: createInstance("123", nil),
				setupMocks: func(m *mockClient) {
					m.mockList = func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
						return errors.New("list error")
					}
				},
				expectedResult: opent.ActionStatusFailed,
			},
			{
				name:     "get nodegroup error during update",
				instance: createInstance("123", nil),
				setupMocks: func(m *mockClient) {
					m.mockList = func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
						ngList := list.(*manualv1alpha1.NodeGroupList)
						*ngList = *createNodeGroupList(1, "1.22", manualv1alpha1.NodeGroupStatusActive)
						return nil
					}
					m.mockGet = func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
						return errors.New("get error")
					}
				},
				expectedResult:  opent.ActionStatusRunning,
				expectedOutputs: map[opent.OutputName]string{opent.OutputNodeGroupNames: "nodegroup-1"},
			},
		}

		for _, tt := range tests {
			t.Run(tt.name, func(t *testing.T) {
				setup := setupTest(t, mockSetupOptions{coreError: false})

				mockCli := &mockClient{}
				tt.setupMocks(mockCli)
				setup.action.comps.InfraAPICli = &mockInfraAPIClient{cli: mockCli}

				result, err := setup.action.Start(setup.ctx, tt.instance)

				setup.g.Expect(err).To(BeNil())
				setup.g.Expect(result.Result).To(Equal(tt.expectedResult))

				if tt.expectedOutputs != nil {
					for k, v := range tt.expectedOutputs {
						setup.g.Expect(result.InstanceOutputs).To(HaveKeyWithValue(k, v))
					}
				}
			})
		}
	})
}

func Test_upgradeCDCNodes_Result(t *testing.T) {
	tests := []struct {
		name           string
		instance       *opent.Instance
		setupMocks     func(*mockClient)
		expectedResult opent.ActionStatus
		expectedError  bool
	}{
		{
			name:          "missing desired version",
			instance:      createInstance("123", nil),
			setupMocks:    func(m *mockClient) {},
			expectedError: true,
		},
		{
			name:           "missing cluster ID",
			instance:       createInstance("", map[opent.OutputName]string{opent.OutputDesiredVersion: "1.23"}),
			setupMocks:     func(m *mockClient) {},
			expectedResult: opent.ActionStatusFailed,
		},
		{
			name:     "list nodegroups error",
			instance: createInstance("123", map[opent.OutputName]string{opent.OutputDesiredVersion: "1.23"}),
			setupMocks: func(m *mockClient) {
				m.mockList = func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
					return errors.New("list error")
				}
			},
			expectedResult: opent.ActionStatusFailed,
		},
		{
			name:     "no nodegroups found",
			instance: createInstance("123", map[opent.OutputName]string{opent.OutputDesiredVersion: "1.23"}),
			setupMocks: func(m *mockClient) {
				m.mockList = func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
					ngList := list.(*manualv1alpha1.NodeGroupList)
					ngList.Items = []manualv1alpha1.NodeGroup{}
					return nil
				}
			},
			expectedResult: opent.ActionStatusSucceeded,
		},
		{
			name:     "nodegroup not upgraded yet",
			instance: createInstance("123", map[opent.OutputName]string{opent.OutputDesiredVersion: "1.23"}),
			setupMocks: func(m *mockClient) {
				m.mockList = func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
					ngList := list.(*manualv1alpha1.NodeGroupList)
					ngList.Items = []manualv1alpha1.NodeGroup{createNodeGroup("test-ng", "1.22", "", manualv1alpha1.NodeGroupStatusActive)}
					return nil
				}
			},
			expectedResult: opent.ActionStatusRunning,
		},
		{
			name:     "nodegroup not active yet",
			instance: createInstance("123", map[opent.OutputName]string{opent.OutputDesiredVersion: "1.23"}),
			setupMocks: func(m *mockClient) {
				m.mockList = func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
					ngList := list.(*manualv1alpha1.NodeGroupList)
					ngList.Items = []manualv1alpha1.NodeGroup{createNodeGroup("test-ng", "1.23", "1.23-20240101", manualv1alpha1.NodeGroupStatusUpdating)}
					return nil
				}
			},
			expectedResult: opent.ActionStatusRunning,
		},
		{
			name:     "all nodegroups upgraded successfully",
			instance: createInstance("123", map[opent.OutputName]string{opent.OutputDesiredVersion: "1.23"}),
			setupMocks: func(m *mockClient) {
				m.mockList = func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
					ngList := list.(*manualv1alpha1.NodeGroupList)
					ngList.Items = []manualv1alpha1.NodeGroup{createNodeGroup("test-ng", "1.23", "1.23-20240101", manualv1alpha1.NodeGroupStatusActive)}
					return nil
				}
				m.mockUpdate = func(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
					return nil
				}
			},
			expectedResult: opent.ActionStatusSucceeded,
		},
		{
			name:     "upgrade complete but update config reset fails",
			instance: createInstance("123", map[opent.OutputName]string{opent.OutputDesiredVersion: "1.23"}),
			setupMocks: func(m *mockClient) {
				m.mockList = func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
					ngList := list.(*manualv1alpha1.NodeGroupList)
					ngList.Items = []manualv1alpha1.NodeGroup{createNodeGroup("test-ng", "1.23", "1.23-20240101", manualv1alpha1.NodeGroupStatusActive)}
					return nil
				}
				m.mockUpdate = func(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
					return errors.New("update error")
				}
			},
			expectedResult: opent.ActionStatusRunning,
		},
		{
			name:     "release version mismatch gets fixed",
			instance: createInstance("123", map[opent.OutputName]string{opent.OutputDesiredVersion: "1.23"}),
			setupMocks: func(m *mockClient) {
				m.mockList = func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
					ngList := list.(*manualv1alpha1.NodeGroupList)
					ng := createNodeGroup("test-ng", "1.23", "1.23-20240101", manualv1alpha1.NodeGroupStatusActive)
					ng.Status.AtProvider.ReleaseVersion = "1.23-20240102"
					ngList.Items = []manualv1alpha1.NodeGroup{ng}
					return nil
				}
			},
			expectedResult: opent.ActionStatusRunning,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			setup := setupTest(t, mockSetupOptions{coreError: false})

			mockCli := &mockClient{}
			tt.setupMocks(mockCli)
			setup.action.comps.InfraAPICli = &mockInfraAPIClient{cli: mockCli}

			result, err := setup.action.Result(setup.ctx, tt.instance)

			if tt.expectedError {
				setup.g.Expect(err).NotTo(BeNil())
				return
			}

			setup.g.Expect(err).To(BeNil())
			setup.g.Expect(result.Result).To(Equal(tt.expectedResult))
		})
	}
}

func Test_hasUpgraded(t *testing.T) {
	tests := []struct {
		name           string
		ng             *manualv1alpha1.NodeGroup
		desiredVersion string
		want           bool
	}{
		{
			name:           "fully upgraded",
			ng:             createNodeGroupPtr("test-ng", "1.23", "1.23-20240101", manualv1alpha1.NodeGroupStatusActive),
			desiredVersion: "1.23",
			want:           true,
		},
		{
			name:           "version not upgraded",
			ng:             createNodeGroupPtr("test-ng", "1.22", "1.22-20240101", manualv1alpha1.NodeGroupStatusActive),
			desiredVersion: "1.23",
			want:           false,
		},
		{
			name:           "not active yet",
			ng:             createNodeGroupPtr("test-ng", "1.23", "1.23-20240101", manualv1alpha1.NodeGroupStatusUpdating),
			desiredVersion: "1.23",
			want:           false,
		},
		{
			name:           "release version nil in spec",
			ng:             createNodeGroupPtrWithMismatch("test-ng", "1.23", "", "1.23-20240101", manualv1alpha1.NodeGroupStatusActive),
			desiredVersion: "1.23",
			want:           false,
		},
		{
			name:           "release version mismatch",
			ng:             createNodeGroupPtrWithMismatch("test-ng", "1.23", "1.23-20240101", "1.23-20240102", manualv1alpha1.NodeGroupStatusActive),
			desiredVersion: "1.23",
			want:           false,
		},
	}

	for _, tt := range tests {
		runTestCase(t, tt.name, func(g *WithT) {
			got := hasUpgraded(tt.ng, tt.desiredVersion)
			g.Expect(got).To(Equal(tt.want))
		})
	}
}

func createNodeGroupPtr(name, version, releaseVersion string, status manualv1alpha1.NodeGroupStatusType) *manualv1alpha1.NodeGroup {
	ng := createNodeGroup(name, version, releaseVersion, status)
	return &ng
}

func createNodeGroupPtrWithMismatch(name, version, specReleaseVersion, statusReleaseVersion string, status manualv1alpha1.NodeGroupStatusType) *manualv1alpha1.NodeGroup {
	ng := createNodeGroup(name, version, specReleaseVersion, status)
	ng.Status.AtProvider.ReleaseVersion = statusReleaseVersion
	return &ng
}

func runTestCase(t *testing.T, name string, testFunc func(*WithT)) {
	t.Run(name, func(t *testing.T) {
		g := NewGomegaWithT(t)
		testFunc(g)
	})
}

func Test_needUpgrade(t *testing.T) {
	tests := []struct {
		name           string
		currentVersion string
		desiredVersion string
		want           bool
	}{
		{"Same version", "1.30", "1.30", false},
		{"Desired version is one minor version higher", "1.30", "1.31", true},
		{"Desired version is two minor versions higher", "1.30", "1.32", true},
		{"Desired version is three minor versions higher", "1.30", "1.33", false},
		{"Desired version is lower", "1.32", "1.31", false},
		{"Different major version", "1.30", "2.30", false},
		{"Empty current version", "", "1.30", false},
		{"Empty desired version", "1.30", "", false},
		{"GKE version format", "1.28.9-gke.1069000", "1.30", true},
		{"Invalid current version", "invalid", "1.30", false},
		{"Invalid desired version", "1.30", "invalid", false},
	}
	for _, tt := range tests {
		runTestCase(t, tt.name, func(g *WithT) {
			got := needUpgrade(tt.currentVersion, tt.desiredVersion)
			g.Expect(got).To(Equal(tt.want))
		})
	}
}
