package agentk

import (
	"context"
	"errors"
	"io"
	"testing"
	"time"

	notificationv1 "github.com/fluxcd/notification-controller/api/v1"
	"github.com/fluxcd/pkg/apis/meta"
	"github.com/stretchr/testify/assert"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/api"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/flux/rpc"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/testing/matcher"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/testing/mock_k8s"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/testing/mock_modagent"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/testing/testlogger"
	"gitlab.com/gitlab-org/cluster-integration/tunnel/tool/retry"
	"go.uber.org/mock/gomock"
	"google.golang.org/grpc"
	"google.golang.org/protobuf/testing/protocmp"
	v1 "k8s.io/api/core/v1"
	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/util/sets"
	"k8s.io/apimachinery/pkg/util/wait"
)

func TestClient_isEqualProjectSets(t *testing.T) {
	testcases := []struct {
		name                     string
		projects                 []string
		cachedProjects           []string
		expectedIsUpdateRequired bool
	}{
		{
			name:                     "no projects",
			projects:                 []string{},
			cachedProjects:           []string{},
			expectedIsUpdateRequired: true,
		},
		{
			name:                     "new projects on empty cache",
			projects:                 []string{"foo"},
			cachedProjects:           []string{},
			expectedIsUpdateRequired: false,
		},
		{
			name:                     "removed project on existing cache",
			projects:                 []string{},
			cachedProjects:           []string{"foo"},
			expectedIsUpdateRequired: false,
		},
		{
			name:                     "same projects in same order",
			projects:                 []string{"foo", "bar"},
			cachedProjects:           []string{"foo", "bar"},
			expectedIsUpdateRequired: true,
		},
		{
			name:                     "same projects in different order",
			projects:                 []string{"foo", "bar"},
			cachedProjects:           []string{"bar", "foo"},
			expectedIsUpdateRequired: true,
		},
		{
			name:                     "with duplicates",
			projects:                 []string{"foo", "foo", "bar"},
			cachedProjects:           []string{"foo", "bar"},
			expectedIsUpdateRequired: true,
		},
		{
			name:                     "with duplicates in cache",
			projects:                 []string{"foo", "bar"},
			cachedProjects:           []string{"foo", "foo", "bar"},
			expectedIsUpdateRequired: true,
		},
	}
	for _, tc := range testcases {
		t.Run(tc.name, func(t *testing.T) {
			// WHEN
			actualIsEqual := isEqualProjectSets(sets.New(tc.cachedProjects...), sets.New(tc.projects...))

			// THEN
			assert.Equal(t, tc.expectedIsUpdateRequired, actualIsEqual)
		})
	}
}

func TestClient_OnlyRestartReconcilingIndexedProjectsWhenNecessary(t *testing.T) {
	testcases := []struct {
		name                     string
		projects                 []string
		cachedProjects           []string
		expectedIsUpdateRequired bool
	}{
		{
			name:                     "no projects",
			projects:                 []string{},
			cachedProjects:           []string{},
			expectedIsUpdateRequired: false,
		},
		{
			name:                     "new projects on empty cache",
			projects:                 []string{"foo"},
			cachedProjects:           []string{},
			expectedIsUpdateRequired: true,
		},
		{
			name:                     "removed project on existing cache",
			projects:                 []string{},
			cachedProjects:           []string{"foo"},
			expectedIsUpdateRequired: true,
		},
		{
			name:                     "same projects in same order",
			projects:                 []string{"foo", "bar"},
			cachedProjects:           []string{"foo", "bar"},
			expectedIsUpdateRequired: false,
		},
		{
			name:                     "same projects in different order",
			projects:                 []string{"foo", "bar"},
			cachedProjects:           []string{"bar", "foo"},
			expectedIsUpdateRequired: false,
		},
	}
	for _, tc := range testcases {
		t.Run(tc.name, func(t *testing.T) {
			// GIVEN
			var wg wait.Group
			defer wg.Wait()

			ctrl := gomock.NewController(t)
			mockReceiverIndexer := mock_k8s.NewMockIndexer(ctrl)
			mockGitLabFluxClient := NewMockGitLabFluxClient(ctrl)
			mockAgentAPI := mock_modagent.NewMockAPI(ctrl)
			ch := make(chan []string, 1)
			c := client{
				log:                        testlogger.New(t),
				agentAPI:                   mockAgentAPI,
				receiverIndexer:            mockReceiverIndexer,
				fluxGitLabClient:           mockGitLabFluxClient,
				updateProjectsToReconcileC: ch,
				pollConfig: retry.PollConfig{
					Interval: 1 * time.Hour,
					Backoff:  retry.NewExponentialBackoff(time.Hour, time.Hour, time.Hour, 0, 0),
				},
				reconciliationDebounceDuration: 0,
			}
			ctx, cancel := context.WithCancel(t.Context())
			defer cancel()

			// setup mock expectations
			gomock.InOrder(
				mockReceiverIndexer.EXPECT().
					ListIndexFuncValues(projectReceiverIndex).
					Return(tc.cachedProjects),
				mockReceiverIndexer.EXPECT().
					ListIndexFuncValues(projectReceiverIndex).
					Return(tc.projects),
			)

			next := make(chan struct{})
			done := make(chan struct{})

			c1 := mockGitLabFluxClient.EXPECT().
				ReconcileProjects(gomock.Any(), matchProjectsSlice(tc.cachedProjects), gomock.Any()).
				DoAndReturn(func(context.Context, *rpc.ReconcileProjectsRequest, ...grpc.CallOption) (grpc.ServerStreamingClient[rpc.ReconcileProjectsResponse], error) {
					close(next)
					return nil, errors.New("just for testing, it's okay")
				})
			c2 := mockAgentAPI.EXPECT().HandleProcessingError(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
			calls := []any{c1, c2}

			// we need this to abort the PollWithBackoff in reconcileProjects eventually
			if tc.expectedIsUpdateRequired {
				c3 := mockGitLabFluxClient.EXPECT().
					ReconcileProjects(gomock.Any(), matchProjectsSlice(tc.projects), gomock.Any()).
					DoAndReturn(func(context.Context, *rpc.ReconcileProjectsRequest, ...grpc.CallOption) (grpc.ServerStreamingClient[rpc.ReconcileProjectsResponse], error) {
						close(done)
						return nil, errors.New("just for testing, it's okay")
					})
				c4 := mockAgentAPI.EXPECT().HandleProcessingError(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
				calls = append(calls, c3, c4)
			}
			gomock.InOrder(calls...)

			// start reconciliation ...
			wg.StartWithContext(ctx, c.RunProjectReconciliation)

			// WHEN
			c.ReconcileIndexedProjects(ctx)
			<-next
			c.ReconcileIndexedProjects(ctx)
			// THEN
			if tc.expectedIsUpdateRequired {
				<-done
			} else {
				time.Sleep(100 * time.Millisecond) // wait a bit to ensure there are no unexpected calls.
			}
		})
	}
}

func TestClient_RestartsProjectReconciliationOnProjectsUpdate(t *testing.T) {
	// GIVEN
	var wg wait.Group
	defer wg.Wait()

	ctrl := gomock.NewController(t)
	mockGitLabFluxClient := NewMockGitLabFluxClient(ctrl)
	mockReceiverIndexer := mock_k8s.NewMockIndexer(ctrl)
	mockAgentAPI := mock_modagent.NewMockAPI(ctrl)
	ch := make(chan []string)
	c := client{
		log:                        testlogger.New(t),
		agentAPI:                   mockAgentAPI,
		fluxGitLabClient:           mockGitLabFluxClient,
		receiverIndexer:            mockReceiverIndexer,
		updateProjectsToReconcileC: ch,
		pollConfig: retry.PollConfig{
			Interval: 1 * time.Hour,
			Backoff:  retry.NewExponentialBackoff(time.Hour, time.Hour, time.Hour, 0, 0),
		},
		reconciliationDebounceDuration: 0,
	}
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	firstProjects := []string{"foo", "bar"}
	secondProjects := []string{"tst", "baz"}

	// setup mock expectations
	// we need this to abort the PollWithBackoff in reconcileProjects eventually
	mockAgentAPI.EXPECT().HandleProcessingError(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2)
	mockGitLabFluxClient.EXPECT().
		ReconcileProjects(gomock.Any(), matchProjectsSlice(firstProjects), gomock.Any()).
		Return(nil, errors.New("just for testing, it's okay"))
	mockGitLabFluxClient.EXPECT().
		ReconcileProjects(gomock.Any(), matchProjectsSlice(secondProjects), gomock.Any()).
		DoAndReturn(func(context.Context, *rpc.ReconcileProjectsRequest, ...grpc.CallOption) (grpc.ServerStreamingClient[rpc.ReconcileProjectsResponse], error) {
			cancel()
			return nil, errors.New("just for testing, it's okay")
		})

	// WHEN
	// start reconciliation ...
	wg.StartWithContext(ctx, c.RunProjectReconciliation)

	// start with first set of projects
	ch <- firstProjects

	// give some time to start reconciliation after debounce
	time.Sleep(1 * time.Second)

	// update to the second set of projects
	ch <- secondProjects

	// give some time to start reconciliation after debounce
	time.Sleep(1 * time.Second)

	// THEN
	// we cancel the context in the mock function - that's all we need to know regarding execution.
	<-ctx.Done()
}

func TestClient_SuccessfullyReconcileProjects(t *testing.T) {
	// GIVEN
	var wg wait.Group
	defer wg.Wait()

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// projects to reconcile
	projects := sets.New("foo", "bar")
	tokenValue := []byte("secretValue")
	ns := "namespace"
	secretName := "receiverSecret"

	c, mockReceiverIndexer, mockReconcileTrigger, mockCoreV1APIClient, mockSecretsAPIClient := setupClientForProjectReconciliation(t, projects, "foo")

	receiverObjs := []*notificationv1.Receiver{{
		ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{api.ProjectKey: "foo"}, Namespace: ns},
		Status:     notificationv1.ReceiverStatus{WebhookPath: "/some/webhook/path"},
		Spec:       notificationv1.ReceiverSpec{SecretRef: meta.LocalObjectReference{Name: secretName}},
	}}
	mockReceiverIndexer.EXPECT().
		ByIndex(projectReceiverIndex, "foo").
		Return(receiversToUnstructuredInterfaceSlice(t, receiverObjs), nil)
	mockReconcileTrigger.EXPECT().
		reconcile(gomock.Any(), "/some/webhook/path", tokenValue).
		Do(func(_ context.Context, _ string, _ []byte) error {
			cancel()
			return nil
		})
	mockCoreV1APIClient.EXPECT().Secrets(ns).Return(mockSecretsAPIClient)
	mockSecretsAPIClient.EXPECT().Get(gomock.Any(), secretName, gomock.Any()).Return(&v1.Secret{
		Data: map[string][]byte{"token": tokenValue},
	}, nil)

	// WHEN
	wg.Start(func() {
		c.reconcileProjects(ctx, projects)
	})

	// THEN
	// we cancel the ctx in reconcile mock function to signal that the last call we expected has been executed.
	<-ctx.Done()
}

func TestClient_ReconcileProjectsWithoutAnyInIndex(t *testing.T) {
	// GIVEN
	var wg wait.Group
	defer wg.Wait()

	// projects to reconcile
	projects := sets.New("foo", "bar")

	c, mockReceiverIndexer, mockReconcileTrigger, _, _ := setupClientForProjectReconciliation(t, projects, "foo")

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	mockReceiverIndexer.EXPECT().ByIndex(projectReceiverIndex, "foo").DoAndReturn(func(string, string) ([]any, error) {
		cancel()
		return []any{}, nil
	})
	mockReconcileTrigger.EXPECT().reconcile(gomock.Any(), gomock.Any(), gomock.Any()).Times(0)

	// WHEN
	wg.Start(func() {
		c.reconcileProjects(ctx, projects)
	})

	// THEN
	// we cancel the context in the ByIndex mock function
	<-ctx.Done()
}

func TestClient_ReconcileProjectsReceiverWithoutProjectAnnotationIsIgnored(t *testing.T) {
	// GIVEN
	var wg wait.Group
	defer wg.Wait()

	// projects to reconcile
	projects := sets.New("foo", "bar")

	c, mockReceiverIndexer, mockReconcileTrigger, _, _ := setupClientForProjectReconciliation(t, projects, "foo")

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	receiverObjs := []*notificationv1.Receiver{{
		ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}},
		Status:     notificationv1.ReceiverStatus{WebhookPath: "/some/webhook/path"},
	}}
	mockReceiverIndexer.EXPECT().
		ByIndex(projectReceiverIndex, "foo").
		DoAndReturn(func(string, string) ([]any, error) {
			cancel()
			return receiversToUnstructuredInterfaceSlice(t, receiverObjs), nil
		})
	mockReconcileTrigger.EXPECT().reconcile(gomock.Any(), gomock.Any(), gomock.Any()).Times(0)

	// WHEN
	wg.Start(func() {
		c.reconcileProjects(ctx, projects)
	})

	// THEN
	// we cancel the context in the ByIndex mock function
	<-ctx.Done()
}

func TestClient_ReconcileProjectsReceiverWithoutWebhookPathIsIgnored(t *testing.T) {
	// GIVEN
	var wg wait.Group
	defer wg.Wait()

	// projects to reconcile
	projects := sets.New("foo", "bar")

	c, mockReceiverIndexer, mockReconcileTrigger, _, _ := setupClientForProjectReconciliation(t, projects, "foo")

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	receiverObjs := []*notificationv1.Receiver{{
		ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{api.ProjectKey: "foo"}},
		Status:     notificationv1.ReceiverStatus{},
	}}
	mockReceiverIndexer.EXPECT().
		ByIndex(projectReceiverIndex, "foo").
		DoAndReturn(func(string, string) ([]any, error) {
			cancel()
			return receiversToUnstructuredInterfaceSlice(t, receiverObjs), nil
		})
	mockReconcileTrigger.EXPECT().reconcile(gomock.Any(), gomock.Any(), gomock.Any()).Times(0)

	// WHEN
	wg.Start(func() {
		c.reconcileProjects(ctx, projects)
	})

	// THEN
	// we cancel the context in the ByIndex mock function
	<-ctx.Done()
}

func setupClientForProjectReconciliation(t *testing.T, projects sets.Set[string], projectToReconcile string) (*client, *mock_k8s.MockIndexer, *MockreconcileTrigger, *mock_k8s.MockCoreV1Interface, *mock_k8s.MockSecretInterface) { //nolint:unparam
	ctrl := gomock.NewController(t)
	mockGitLabFluxClient := NewMockGitLabFluxClient(ctrl)
	mockRPCClient := NewMockGitLabFlux_ReconcileProjectsClient(ctrl)
	mockReceiverIndexer := mock_k8s.NewMockIndexer(ctrl)
	mockReconcileTrigger := NewMockreconcileTrigger(ctrl)
	mockCoreV1APIClient := mock_k8s.NewMockCoreV1Interface(ctrl)
	mockSecretsAPIClient := mock_k8s.NewMockSecretInterface(ctrl)
	ch := make(chan []string)
	c := &client{
		log:                        testlogger.New(t),
		fluxGitLabClient:           mockGitLabFluxClient,
		receiverIndexer:            mockReceiverIndexer,
		reconcileTrigger:           mockReconcileTrigger,
		updateProjectsToReconcileC: ch,
		pollConfig: retry.PollConfig{
			Interval: 1 * time.Hour,
			Backoff:  retry.NewExponentialBackoff(time.Hour, time.Hour, time.Hour, 0, 0),
		},
		corev1APIClient: mockCoreV1APIClient,
	}

	// setup mock expectations
	mockGitLabFluxClient.EXPECT().
		ReconcileProjects(gomock.Any(), matchProjects(projects), gomock.Any()).
		Return(mockRPCClient, nil)

	mockRPCClient.EXPECT().Recv().Return(&rpc.ReconcileProjectsResponse{Project: &rpc.Project{Id: projectToReconcile}}, nil)
	mockRPCClient.EXPECT().Recv().Return(nil, io.EOF)

	return c, mockReceiverIndexer, mockReconcileTrigger, mockCoreV1APIClient, mockSecretsAPIClient
}

func receiversToUnstructuredInterfaceSlice(t *testing.T, receivers []*notificationv1.Receiver) []any {
	var objs = make([]any, len(receivers))
	for i, o := range receivers {
		u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(o)
		assert.NoError(t, err)
		objs[i] = &unstructured.Unstructured{Object: u}
	}
	return objs
}

func matchProjects(projects sets.Set[string]) gomock.Matcher {
	req := &rpc.ReconcileProjectsRequest{Project: rpc.ReconcileProjectsFromSet(projects)}
	return matcher.ProtoEq(nil, req, protocmp.SortRepeated(func(p1, p2 *rpc.Project) bool {
		return p1.Id < p2.Id
	}))
}

func matchProjectsSlice(projects []string) gomock.Matcher {
	return matchProjects(sets.New(projects...))
}
