package agentk

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/suite"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/modagent"
	"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/testhelpers"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/testing/testlogger"
	"go.uber.org/mock/gomock"
	corev1 "k8s.io/api/core/v1"
	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

type ReconcilerTestSuite struct {
	suite.Suite

	runner                 reconciler
	mockAPI                *mock_modagent.MockAPI
	mockDeploymentInformer *mockInformer
	mockSecretInformer     *mockInformer
	mockK8sClient          *MockClient
	namespaceMode          WorkspaceNamespaceMode
}

func TestRemoteDevModuleReconciler(t *testing.T) {
	suite1 := new(ReconcilerTestSuite)
	suite1.namespaceMode = WorkspaceNamespaceModeUnique

	suite2 := new(ReconcilerTestSuite)
	suite2.namespaceMode = WorkspaceNamespaceModeShared

	suite.Run(t, suite1)
	suite.Run(t, suite2)
}

func (r *ReconcilerTestSuite) TestSuccessfulTerminationOfWorkspace_HappyPath() {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
	defer cancel()

	existingWorkspaceA := r.newMockWorkspaceInAgent(ctx, "workspaceA")

	trackerKeyForWorkspace := terminationTrackerKey{
		name:      existingWorkspaceA.Name,
		namespace: existingWorkspaceA.Namespace,
	}

	r.mockDeploymentInformer.AddResource(existingWorkspaceA)

	// test assumes an existing running workspace that rails intends to terminate
	r.ensureWorkspaceExists(ctx, r.runner.stateTracker, existingWorkspaceA)

	// In the first full reconciliation, the agent will receive information to terminate the workspace.
	workspaceChangesFromRails := r.generateInfoForWorkspaceChanges(existingWorkspaceA, WorkspaceStateTerminated, "Running")
	r.expectRequestAndReturnReply(r.mockAPI, r.generateRailsRequest(WorkspaceUpdateTypeFull), r.generateRailsResponse(workspaceChangesFromRails))
	_, err := r.runner.Run(ctx)
	r.NoError(err)
	r.ensureWorkspaceIsDeletedInCluster(ctx, existingWorkspaceA, true, false)
	r.ensureWorkspaceHasTerminationDetails(existingWorkspaceA, TerminationProgressTerminating, false)
	r.Contains(r.runner.stateTracker.persistedVersion, existingWorkspaceA.Name)
	r.Len(r.mockK8sClient.ApplyRecorder, 1, "ApplyRecorder should have exactly one entry")
	r.mockK8sClient.ResetApplyRecorder()

	// In the next partial reconciliation, agent will report its termination progress to be Terminating.
	// While applying the changes received from Rails, let's assume the tracked resources of the inventory config map
	// are not yet deleted i.e. the workspace is still terminating
	r.ensureWorkspaceExists(ctx, r.runner.stateTracker, existingWorkspaceA)
	workspaceChangesFromRails = r.generateInfoForWorkspaceChanges(existingWorkspaceA, WorkspaceStateTerminated, "Terminating")
	r.expectRequestAndReturnReply(
		r.mockAPI,
		r.generateRailsRequest(WorkspaceUpdateTypePartial, r.agentInfoWithTerminationProgress(existingWorkspaceA, TerminationProgressTerminating)),
		r.generateRailsResponse(workspaceChangesFromRails),
	)
	_, err = r.runner.Run(ctx)
	r.NoError(err)
	r.ensureWorkspaceIsDeletedInCluster(ctx, existingWorkspaceA, true, false)
	r.ensureWorkspaceHasTerminationDetails(existingWorkspaceA, TerminationProgressTerminating, true)
	r.Contains(r.runner.stateTracker.persistedVersion, existingWorkspaceA.Name)
	r.Len(r.mockK8sClient.ApplyRecorder, 1, "ApplyRecorder should have exactly one entry")
	r.mockK8sClient.ResetApplyRecorder()

	// In the next partial reconciliation, agent will not report its termination progress as there is no change in the progress.
	// While applying the changes received from Rails, let's assume the tracked resources of the inventory config map
	// are now deleted.
	// If WorkspaceNamespaceModeUnique, it will now delete the namespace. This in turn, will delete the
	// inventory config maps and other objects like synced image pull secrets.
	// If WorkspaceNamespaceModeShared, it will now delete the inventory config maps.
	r.ensureWorkspaceExists(ctx, r.runner.stateTracker, existingWorkspaceA)
	workspaceChangesFromRails = r.generateInfoForWorkspaceChanges(existingWorkspaceA, WorkspaceStateTerminated, "Terminating")
	r.expectRequestAndReturnReply(
		r.mockAPI,
		r.generateRailsRequest(WorkspaceUpdateTypePartial),
		r.generateRailsResponse(workspaceChangesFromRails),
	)
	_, err = r.runner.Run(ctx)
	r.NoError(err)
	r.ensureWorkspaceIsDeletedInCluster(ctx, existingWorkspaceA, false, true)
	r.ensureWorkspaceHasTerminationDetails(existingWorkspaceA, TerminationProgressTerminating, true)
	r.Contains(r.runner.stateTracker.persistedVersion, existingWorkspaceA.Name)
	r.Len(r.mockK8sClient.ApplyRecorder, 1, "ApplyRecorder should have exactly one entry")
	r.mockK8sClient.ResetApplyRecorder()

	// In the next partial reconciliation, agent will not report its termination progress as there is no change in the progress.
	// While applying the changes received from Rails, it will mark the workspace as terminated.
	r.ensureWorkspaceExists(ctx, r.runner.stateTracker, existingWorkspaceA)
	workspaceChangesFromRails = r.generateInfoForWorkspaceChanges(existingWorkspaceA, WorkspaceStateTerminated, "Terminating")
	r.expectRequestAndReturnReply(
		r.mockAPI,
		r.generateRailsRequest(WorkspaceUpdateTypePartial),
		r.generateRailsResponse(workspaceChangesFromRails),
	)
	_, err = r.runner.Run(ctx)
	r.NoError(err)
	r.ensureWorkspaceHasTerminationDetails(existingWorkspaceA, TerminationProgressTerminated, false)
	r.Contains(r.runner.stateTracker.persistedVersion, existingWorkspaceA.Name)
	r.Empty(r.mockK8sClient.ApplyRecorder, "ApplyRecorder should have exactly zero entry")
	r.mockK8sClient.ResetApplyRecorder()

	// In the next partial reconciliation, agent will discover that the workspace has been deleted and persisted in Rails.
	// It will thus remove the workspace from all the trackers
	r.ensureWorkspaceExists(ctx, r.runner.stateTracker, existingWorkspaceA)
	workspaceChangesFromRails = r.generateInfoForWorkspaceChanges(existingWorkspaceA, WorkspaceStateTerminated, WorkspaceStateTerminated)
	r.expectRequestAndReturnReply(
		r.mockAPI,
		r.generateRailsRequest(WorkspaceUpdateTypePartial, r.agentInfoWithTerminationProgress(existingWorkspaceA, TerminationProgressTerminated)),
		r.generateRailsResponse(workspaceChangesFromRails),
	)
	_, err = r.runner.Run(ctx)
	r.NoError(err)
	r.NotContains(r.runner.terminationTracker, trackerKeyForWorkspace)
	r.NotContains(r.runner.stateTracker.persistedVersion, existingWorkspaceA.Name)
	r.Empty(r.mockK8sClient.ApplyRecorder, "ApplyRecorder should have exactly zero entry")
	r.mockK8sClient.ResetApplyRecorder()

	// In the next partial reconciliation, no more information for the terminated workspace will be shared with rails as there is
	// no information about this workspace either in agent's internal state nor in the cluster
	r.expectRequestAndReturnReply(
		r.mockAPI,
		r.generateRailsRequest(WorkspaceUpdateTypePartial),
		r.generateRailsResponse(),
	)
	_, err = r.runner.Run(ctx)
	r.NoError(err)
}

func (r *ReconcilerTestSuite) TestSuccessfulTerminationOfWorkspace_ApiErrorDuringReporting() {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
	defer cancel()

	existingWorkspaceA := r.newMockWorkspaceInAgent(ctx, "test-workspace")

	trackerKeyForWorkspace := terminationTrackerKey{
		name:      existingWorkspaceA.Name,
		namespace: existingWorkspaceA.Namespace,
	}

	r.mockDeploymentInformer.AddResource(existingWorkspaceA)

	// test assumes an existing running workspace that rails intends to terminate
	r.ensureWorkspaceExists(ctx, r.runner.stateTracker, existingWorkspaceA)

	// In the first full reconciliation, rails shares intent terminate the running workspace
	workspaceChangesFromRails := r.generateInfoForWorkspaceChanges(existingWorkspaceA, WorkspaceStateTerminated, "Running")
	r.expectRequestAndReturnReply(r.mockAPI, r.generateRailsRequest(WorkspaceUpdateTypeFull), r.generateRailsResponse(workspaceChangesFromRails))
	_, err := r.runner.Run(ctx)
	r.NoError(err)
	r.ensureWorkspaceHasTerminationDetails(existingWorkspaceA, TerminationProgressTerminating, false)
	r.Contains(r.runner.stateTracker.persistedVersion, existingWorkspaceA.Name)

	// In the next partial reconciliation, agent will report its termination progress to be Terminating.
	// While applying the changes received from Rails, let's assume the tracked resources of the inventory config map
	// are now deleted.
	// If WorkspaceNamespaceModeUnique, it will now delete the namespace. This in turn, will delete the
	// inventory config maps and other objects like synced image pull secrets.
	// If WorkspaceNamespaceModeShared, it will now delete the inventory config maps.
	// However, due to API error when communicating with Rails, the reporting fails and will be retried in the subsequent partial reconciliation
	r.ensureWorkspaceIsDeletedInCluster(ctx, existingWorkspaceA, false, true)
	r.expectRequestAndReturnError(
		r.mockAPI,
		r.generateRailsRequest(WorkspaceUpdateTypePartial, r.agentInfoWithTerminationProgress(existingWorkspaceA, TerminationProgressTerminating)),
	)
	_, err = r.runner.Run(ctx)
	r.Error(err)
	r.ensureWorkspaceHasTerminationDetails(existingWorkspaceA, TerminationProgressTerminating, false)
	r.Contains(r.runner.stateTracker.persistedVersion, existingWorkspaceA.Name)

	// In the next partial reconciliation, agent will retry reporting the termination progress for workspace which will succeed now.
	// While applying the changes received from Rails, it will mark the workspace as terminated.
	workspaceChangesFromRails = r.generateInfoForWorkspaceChanges(existingWorkspaceA, WorkspaceStateTerminated, "Terminating")
	r.expectRequestAndReturnReply(
		r.mockAPI,
		r.generateRailsRequest(WorkspaceUpdateTypePartial, r.agentInfoWithTerminationProgress(existingWorkspaceA, "Terminating")),
		r.generateRailsResponse(workspaceChangesFromRails),
	)
	_, err = r.runner.Run(ctx)
	r.NoError(err)
	r.ensureWorkspaceHasTerminationDetails(existingWorkspaceA, TerminationProgressTerminated, false)
	r.Contains(r.runner.stateTracker.persistedVersion, existingWorkspaceA.Name)

	// In the next partial reconciliation, agent will discover that the workspace has been deleted and persisted in Rails.
	// It will thus remove the workspace from all the trackers
	r.ensureWorkspaceExists(ctx, r.runner.stateTracker, existingWorkspaceA)
	workspaceChangesFromRails = r.generateInfoForWorkspaceChanges(existingWorkspaceA, WorkspaceStateTerminated, WorkspaceStateTerminated)
	r.expectRequestAndReturnReply(
		r.mockAPI,
		r.generateRailsRequest(WorkspaceUpdateTypePartial, r.agentInfoWithTerminationProgress(existingWorkspaceA, TerminationProgressTerminated)),
		r.generateRailsResponse(workspaceChangesFromRails),
	)
	_, err = r.runner.Run(ctx)
	r.NoError(err)
	r.NotContains(r.runner.terminationTracker, trackerKeyForWorkspace)
	r.NotContains(r.runner.stateTracker.persistedVersion, existingWorkspaceA.Name)
}

func (r *ReconcilerTestSuite) TestSuccessfulCreationOfWorkspace() {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
	defer cancel()

	workspace := "workspaceA"
	currentWorkspaceState := r.newMockWorkspaceInAgent(ctx, workspace)
	trackerKeyForWorkspace := terminationTrackerKey{
		name:      currentWorkspaceState.Name,
		namespace: currentWorkspaceState.Namespace,
	}

	// Make image pull secret available for happy path.
	imagePullSecret := ImagePullSecret{Name: "secret", Namespace: "secret-namespace"}
	secret := toUnstructuredSecret(r.T(), &corev1.Secret{
		ObjectMeta: v1.ObjectMeta{
			Name:      imagePullSecret.Name,
			Namespace: imagePullSecret.Namespace,
		},
	})
	_, err := r.mockK8sClient.UpdateOrCreate(ctx, secretGVR, imagePullSecret.Namespace, secret, true)
	r.NoError(err)

	// step1: expect nothing in rails req, get creation req in rails resp => expect changes to be applied
	r.expectRequestAndReturnReply(r.mockAPI, r.generateRailsRequest(WorkspaceUpdateTypeFull),
		r.generateRailsResponse(r.generateInfoForNewWorkspace(workspace, []ImagePullSecret{imagePullSecret})),
	)

	_, err = r.runner.Run(ctx)
	r.NoError(err)
	r.NotContains(r.runner.terminationTracker, trackerKeyForWorkspace)
	r.Contains(r.runner.stateTracker.persistedVersion, workspace)
	imagePullSecretObj, err := r.mockK8sClient.Get(ctx, secretGVR, currentWorkspaceState.Namespace, imagePullSecret.Name)
	r.NoError(err)
	r.NotNil(imagePullSecretObj)

	// step2: simulate transition to "Starting" step (modify resource version in deploymentInformer), expect rails req to contain update
	r.updateMockWorkspaceStateInInformer(r.mockDeploymentInformer, currentWorkspaceState)

	// ensure rails acks the latest persisted version
	workspaceChangesFromRails := r.generateInfoForWorkspaceChanges(currentWorkspaceState, "Running", "Starting")
	workspaceChangesFromRails.DeploymentResourceVersion = currentWorkspaceState.ResourceVersion

	r.expectRequestAndReturnReply(
		r.mockAPI,
		r.generateRailsRequest(WorkspaceUpdateTypePartial, r.agentInfoForNonTerminatedWorkspace(currentWorkspaceState)),
		r.generateRailsResponse(workspaceChangesFromRails),
	)
	_, err = r.runner.Run(ctx)
	r.NoError(err)
	r.NotContains(r.runner.terminationTracker, trackerKeyForWorkspace)
	r.Contains(r.runner.stateTracker.persistedVersion, workspace)

	// step3: simulate transition to "Running" step(modify resource version in deploymentInformer), expect rails req to contain update
	r.updateMockWorkspaceStateInInformer(r.mockDeploymentInformer, currentWorkspaceState)
	workspaceChangesFromRails = r.generateInfoForWorkspaceChanges(currentWorkspaceState, "Running", "Running")
	workspaceChangesFromRails.DeploymentResourceVersion = currentWorkspaceState.ResourceVersion

	r.expectRequestAndReturnReply(
		r.mockAPI,
		r.generateRailsRequest(WorkspaceUpdateTypePartial, r.agentInfoForNonTerminatedWorkspace(currentWorkspaceState)),
		r.generateRailsResponse(workspaceChangesFromRails),
	)
	_, err = r.runner.Run(ctx)
	r.NoError(err)
	r.NotContains(r.runner.terminationTracker, trackerKeyForWorkspace)
	r.Contains(r.runner.stateTracker.persistedVersion, workspace)

	// step4: nothing changes in resource, expect rails req to not contain workspace metadata, expect metadata to be present in tracker (but not in terminated tracker)
	r.expectRequestAndReturnReply(r.mockAPI, r.generateRailsRequest(WorkspaceUpdateTypePartial), r.generateRailsResponse())
	_, err = r.runner.Run(ctx)
	r.NoError(err)
	r.NotContains(r.runner.terminationTracker, trackerKeyForWorkspace)
	r.Contains(r.runner.stateTracker.persistedVersion, workspace)
}

func (r *ReconcilerTestSuite) TestSuccessfulReportingOfApplierErrors() {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
	defer cancel()

	workspace := "workspaceA"
	currentWorkspaceState := r.newMockWorkspaceInAgent(ctx, workspace)
	applierError := errors.New("applier error")

	// step1: expect nothing in rails req, get creation req in rails resp => changes should FAIL to apply
	r.expectRequestAndReturnReply(
		r.mockAPI,
		r.generateRailsRequest(WorkspaceUpdateTypeFull),
		r.generateRailsResponse(
			r.generateInfoForWorkspaceChanges(
				currentWorkspaceState,
				"Running",
				"Creating",
			),
		),
	)
	r.ensureK8sClientReturnsApplierError(applierError)

	_, err := r.runner.Run(ctx)
	r.runner.errDetailsTracker.waitForErrors()

	r.NoError(err)
	r.Contains(r.runner.stateTracker.persistedVersion, workspace)
	r.Contains(r.runner.errDetailsTracker.store, errorTrackerKey{
		name:      currentWorkspaceState.Name,
		namespace: currentWorkspaceState.Namespace,
	})

	// step 2: expect applier error details in Rails request and ensure tracked error is purged
	// from the tracker
	r.expectRequestAndReturnReply(
		r.mockAPI,
		r.generateRailsRequest(
			WorkspaceUpdateTypePartial,
			r.agentInfoWithApplierErrors(currentWorkspaceState, applierError),
		),
		r.generateRailsResponse(
			r.generateInfoForWorkspaceInErrorState(workspace),
		),
	)

	_, err = r.runner.Run(ctx)
	r.runner.errDetailsTracker.waitForErrors()

	r.NoError(err)
	r.Contains(r.runner.stateTracker.persistedVersion, workspace)
	r.NotContains(r.runner.errDetailsTracker.store, errorTrackerKey{
		name:      currentWorkspaceState.Name,
		namespace: currentWorkspaceState.Namespace,
	})
}

func (r *ReconcilerTestSuite) TestUnsuccessfulCreationOfWorkspaceWhenImagePullSecretsCreationFail() {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
	defer cancel()

	workspace := "workspaceA"

	existingImagePullSecret := ImagePullSecret{Name: "secret", Namespace: "secret-namespace"}
	r.expectRequestAndReturnReply(r.mockAPI, r.generateRailsRequest(WorkspaceUpdateTypeFull),
		r.generateRailsResponse(r.generateInfoForNewWorkspace(workspace, []ImagePullSecret{existingImagePullSecret})),
	)

	imagePullSecret := ImagePullSecret{Name: "secret", Namespace: "secret-namespace"}
	secret := toUnstructuredSecret(r.T(), &corev1.Secret{
		ObjectMeta: v1.ObjectMeta{
			Name:      imagePullSecret.Name,
			Namespace: imagePullSecret.Namespace,
		},
	})
	_, err := r.mockK8sClient.UpdateOrCreate(ctx, secretGVR, imagePullSecret.Namespace, secret, true)
	r.NoError(err)
	r.mockK8sClient.MockErrorClient = errors.New("could not create secret")

	_, err = r.runner.Run(ctx)
	r.NoError(err)

	// Assert no config was applied due to secret creation failure
	r.Empty(r.mockK8sClient.ApplyRecorder)
}

func (r *ReconcilerTestSuite) TestTerminationOfATerminatedWorkspace() {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
	defer cancel()

	workspaceToTerminate := r.newMockWorkspaceInAgent(ctx, "workspace-to-terminate")
	trackerKey := terminationTrackerKey{
		name:      workspaceToTerminate.Name,
		namespace: workspaceToTerminate.Namespace,
	}

	r.mockDeploymentInformer.AddResource(workspaceToTerminate)
	_, err := r.mockK8sClient.UpdateOrCreate(ctx, namespaceGVR, workspaceToTerminate.Namespace, &corev1.Namespace{
		ObjectMeta: v1.ObjectMeta{
			Name: workspaceToTerminate.Namespace,
		},
	}, true)
	r.NoError(err)

	// test assumes an existing running workspace that rails intends to terminate
	r.ensureWorkspaceExists(ctx, r.runner.stateTracker, workspaceToTerminate)

	// In the first full reconciliation, rails shares intent terminate the running workspace
	workspaceChangesFromRails := r.generateInfoForWorkspaceChanges(workspaceToTerminate, WorkspaceStateTerminated, "Running")
	r.expectRequestAndReturnReply(r.mockAPI, r.generateRailsRequest(WorkspaceUpdateTypeFull), r.generateRailsResponse(workspaceChangesFromRails))
	_, err = r.runner.Run(ctx)
	r.NoError(err)
	r.ensureWorkspaceHasTerminationDetails(workspaceToTerminate, TerminationProgressTerminating, false)
	r.Contains(r.runner.stateTracker.persistedVersion, workspaceToTerminate.Name)

	// In the next partial reconciliation, agent will report its termination progress to be Terminating.
	// While applying the changes received from Rails, let's assume the tracked resources of the inventory config map
	// are now deleted.
	// If WorkspaceNamespaceModeUnique, it will now delete the namespace. This in turn, will delete the
	// inventory config maps and other objects like synced image pull secrets.
	// If WorkspaceNamespaceModeShared, it will now delete the inventory config maps.
	r.ensureWorkspaceIsDeletedInCluster(ctx, workspaceToTerminate, false, true)
	r.expectRequestAndReturnReply(
		r.mockAPI,
		r.generateRailsRequest(WorkspaceUpdateTypePartial, r.agentInfoWithTerminationProgress(workspaceToTerminate, "Terminating")),
		r.generateRailsResponse(workspaceChangesFromRails),
	)
	_, err = r.runner.Run(ctx)
	r.NoError(err)
	r.ensureWorkspaceHasTerminationDetails(workspaceToTerminate, TerminationProgressTerminated, false)
	r.Contains(r.runner.stateTracker.persistedVersion, workspaceToTerminate.Name)

	// In the next partial reconciliation, agent will discover that the workspace has been deleted and persisted in Rails.
	// It will thus remove the workspace from all the trackers
	r.ensureWorkspaceExists(ctx, r.runner.stateTracker, workspaceToTerminate)
	workspaceChangesFromRails = r.generateInfoForWorkspaceChanges(workspaceToTerminate, WorkspaceStateTerminated, WorkspaceStateTerminated)
	r.expectRequestAndReturnReply(
		r.mockAPI,
		r.generateRailsRequest(WorkspaceUpdateTypePartial, r.agentInfoWithTerminationProgress(workspaceToTerminate, TerminationProgressTerminated)),
		r.generateRailsResponse(workspaceChangesFromRails),
	)
	_, err = r.runner.Run(ctx)
	r.NoError(err)
	r.NotContains(r.runner.terminationTracker, trackerKey)
	r.NotContains(r.runner.stateTracker.persistedVersion, workspaceToTerminate.Name)
}

func (r *ReconcilerTestSuite) TestSerializationOfRailsRequests() {
	workspace := &parsedWorkspace{
		Name:            "workspace1",
		Namespace:       "namespace1",
		ResourceVersion: "123",
		K8sDeploymentInfo: map[string]any{
			"a": 1,
		},
	}

	tests := []struct {
		testCase              string
		request               RequestPayload
		expectedSerialization string
	}{
		{
			testCase: "partial reconciliation payload with latest k8s info",
			request: r.generateRailsRequest(
				WorkspaceUpdateTypePartial,
				WorkspaceAgentInfo{
					Name:                    workspace.Name,
					Namespace:               workspace.Namespace,
					LatestK8sDeploymentInfo: workspace.K8sDeploymentInfo,
				},
			),
			expectedSerialization: "{\"update_type\":\"partial\",\"workspace_agent_infos\":[{\"name\":\"workspace1\",\"namespace\":\"namespace1\",\"latest_k8s_deployment_info\":{\"a\":1}}]}",
		},
		{
			testCase: "partial reconciliation payload with only terminating progress",
			request: r.generateRailsRequest(
				WorkspaceUpdateTypePartial,
				r.agentInfoWithTerminationProgress(workspace, TerminationProgressTerminating),
			),
			expectedSerialization: "{\"update_type\":\"partial\",\"workspace_agent_infos\":[{\"name\":\"workspace1\",\"namespace\":\"namespace1\",\"termination_progress\":\"Terminating\"}]}",
		},
		{
			testCase: "partial reconciliation payload with applier errors",
			request: r.generateRailsRequest(
				WorkspaceUpdateTypePartial,
				r.agentInfoWithApplierErrors(workspace, errors.New("applierError")),
			),
			expectedSerialization: "{\"update_type\":\"partial\",\"workspace_agent_infos\":[{\"name\":\"workspace1\",\"namespace\":\"namespace1\",\"error_details\":{\"error_type\":\"applier\",\"error_message\":\"applierError\"}}]}",
		},
		{
			testCase: "partial reconciliation payload with kubernetes errors",
			request: r.generateRailsRequest(
				WorkspaceUpdateTypePartial,
				r.agentInfoWithKubernetesErrors(workspace, errors.New("kubernetesError")),
			),
			expectedSerialization: "{\"update_type\":\"partial\",\"workspace_agent_infos\":[{\"name\":\"workspace1\",\"namespace\":\"namespace1\",\"error_details\":{\"error_type\":\"kubernetes\",\"error_message\":\"kubernetesError\"}}]}",
		},
		{
			testCase:              "full reconciliation payload",
			request:               r.generateRailsRequest(WorkspaceUpdateTypeFull),
			expectedSerialization: "{\"update_type\":\"full\",\"workspace_agent_infos\":[]}",
		},
	}

	for _, tc := range tests {
		raw, err := json.Marshal(tc.request)

		r.NoError(err)
		r.Equal(tc.expectedSerialization, string(raw))
	}
}

func (r *ReconcilerTestSuite) TestSettingsReturned() {
	tests := []struct {
		testCase               string
		expectedWorkerSettings WorkerSettings
		railsAPIReturnBody     string
	}{
		{
			testCase: "when Settings is set different from default",
			expectedWorkerSettings: WorkerSettings{
				FullReconciliationInterval:    5800 * time.Second,
				PartialReconciliationInterval: 20 * time.Second,
			},
			railsAPIReturnBody: `{"workspace_rails_infos":[],"settings":{"full_reconciliation_interval_seconds":5800,"partial_reconciliation_interval_seconds":20}}`,
		},
		{
			testCase: "when Settings not present",
			expectedWorkerSettings: WorkerSettings{
				FullReconciliationInterval:    3600 * time.Second,
				PartialReconciliationInterval: 10 * time.Second,
			},
			railsAPIReturnBody: `{"workspace_rails_infos":[]}`,
		},
		{
			testCase: "when Settings presents, but no nested fields",
			expectedWorkerSettings: WorkerSettings{
				FullReconciliationInterval:    3600 * time.Second,
				PartialReconciliationInterval: 10 * time.Second,
			},
			railsAPIReturnBody: `{"workspace_rails_infos":[],"settings":{}}`,
		},
		{
			testCase: "when Settings presents, and partial nested fields present",
			expectedWorkerSettings: WorkerSettings{
				FullReconciliationInterval:    5800 * time.Second,
				PartialReconciliationInterval: 10 * time.Second,
			},
			railsAPIReturnBody: `{"workspace_rails_infos":[],"settings":{"full_reconciliation_interval_seconds":5800}}`,
		},
	}

	for _, tc := range tests {
		ctx := context.Background()

		r.mockAPI.EXPECT().
			MakeGitLabRequest(gomock.Any(), "/reconcile", gomock.Any()).Times(1).
			Return(&modagent.GitLabResponse{
				StatusCode: http.StatusCreated,
				Body:       io.NopCloser(strings.NewReader(tc.railsAPIReturnBody)),
			}, nil)

		workerSettings, err := r.runner.Run(ctx)

		r.NoError(err)
		r.Equal(tc.expectedWorkerSettings, workerSettings)
	}
}

func (r *ReconcilerTestSuite) ensureWorkspaceHasTerminationDetails(workspace *parsedWorkspace, expectedProgress TerminationProgress, isReconciled bool) {
	trackerKey := terminationTrackerKey{
		name:      workspace.Name,
		namespace: workspace.Namespace,
	}

	r.Contains(r.runner.terminationTracker, trackerKey)
	expectedDetails := terminationDetails{
		progress:     expectedProgress,
		isReconciled: isReconciled,
	}
	r.Equal(expectedDetails, r.runner.terminationTracker[trackerKey])
}

func (r *ReconcilerTestSuite) ensureK8sClientReturnsApplierError(err error) {
	r.mockK8sClient.MockErrorApplier = err
}

func (r *ReconcilerTestSuite) updateMockWorkspaceStateInInformer(mockInformer *mockInformer, workspace *parsedWorkspace) {
	workspace.ResourceVersion += "1"
	workspace.K8sDeploymentInfo = map[string]any{
		"metadata": map[string]any{
			"name":            workspace.Name,
			"namespace":       workspace.Namespace,
			"resourceVersion": workspace.ResourceVersion,
		},
	}
	mockInformer.AddResource(workspace)
}

func (r *ReconcilerTestSuite) ensureWorkspaceExists(_ context.Context, stateTracker *persistedStateTracker, existingWorkspaceA *parsedWorkspace) {
	if _, ok := stateTracker.persistedVersion[existingWorkspaceA.Name]; !ok {
		stateTracker.recordVersion(&WorkspaceRailsInfo{
			Name:                      existingWorkspaceA.Name,
			Namespace:                 existingWorkspaceA.Namespace,
			DeploymentResourceVersion: existingWorkspaceA.ResourceVersion,
		})
	}
}

func (r *ReconcilerTestSuite) ensureWorkspaceIsDeletedInCluster(ctx context.Context, workspace *parsedWorkspace, invConfigMapPresent, invConfigMapDataEmpty bool) {
	// sanity check
	if !invConfigMapPresent && !invConfigMapDataEmpty {
		r.FailNow("If inventory config map is not present, then the count of its 'data' has to be empty")
	}

	delete(r.mockDeploymentInformer.Resources[workspace.Namespace], workspace.Name)

	invConfigMapName := fmt.Sprintf("%s-workspace-inventory-1", workspace.Name)
	invConfigMapName2 := fmt.Sprintf("%s-workspace-inventory-2", workspace.Name)
	invConfigMapObj, err := r.mockK8sClient.Get(ctx, configMapGVR, workspace.Namespace, invConfigMapName)
	r.NoError(err)
	r.NotNilf(invConfigMapObj, "inventory config map hasn't been created yet")
	invConfigMapObj2, err := r.mockK8sClient.Get(ctx, configMapGVR, workspace.Namespace, invConfigMapName2)
	r.NoError(err)
	r.NotNilf(invConfigMapObj2, "inventory config map 2 hasn't been created yet")
	if invConfigMapPresent && invConfigMapDataEmpty {
		// remove the 'data' key from invConfigMapObj and update it
		delete(invConfigMapObj.Object, "data")
		delete(invConfigMapObj2.Object, "data")
		var updatedInvConfigMapObj *unstructured.Unstructured
		updatedInvConfigMapObj, err = r.mockK8sClient.UpdateOrCreate(ctx, configMapGVR, workspace.Namespace, invConfigMapObj, true)
		r.NoError(err)
		r.Nil(updatedInvConfigMapObj.Object["data"])
		updatedInvConfigMapObj, err = r.mockK8sClient.UpdateOrCreate(ctx, configMapGVR, workspace.Namespace, invConfigMapObj2, true)
		r.NoError(err)
		r.Nil(updatedInvConfigMapObj.Object["data"])
	}

	if !invConfigMapPresent && invConfigMapDataEmpty {
		// If WorkspaceNamespaceModeUnique, it will now delete the namespace. This will in turn delete the inventory config maps.
		// If WorkspaceNamespaceModeShared, it will now delete the inventory config maps.
		if r.namespaceMode == WorkspaceNamespaceModeUnique {
			var namespaceObj *unstructured.Unstructured
			namespaceObj, err = r.mockK8sClient.Get(ctx, namespaceGVR, "", workspace.Namespace)
			r.NoError(err)
			r.NotNilf(namespaceObj, "namespace hasn't been created yet")
			err = r.mockK8sClient.Delete(ctx, namespaceGVR, "", workspace.Namespace)
			r.NoError(err)
		}
		err = r.mockK8sClient.Delete(ctx, configMapGVR, workspace.Namespace, invConfigMapName)
		r.NoError(err)
		err = r.mockK8sClient.Delete(ctx, configMapGVR, workspace.Namespace, invConfigMapName2)
		r.NoError(err)
	}
}

func (r *ReconcilerTestSuite) generateRailsResponse(infos ...*WorkspaceRailsInfo) ResponsePayload {
	settings := newDefaultSettings()
	return ResponsePayload{WorkspaceRailsInfos: infos, Settings: settings}
}

func (r *ReconcilerTestSuite) expectRequestAndReturnReply(mockAPI *mock_modagent.MockAPI, expectedRequest RequestPayload, response ResponsePayload) {
	extractRequestPayload := func(dataReader io.ReadCloser) RequestPayload {
		var request RequestPayload

		raw, err := io.ReadAll(dataReader)
		r.NoError(err)

		err = json.Unmarshal(raw, &request)
		r.NoError(err)

		return request
	}

	mockAPI.EXPECT().
		MakeGitLabRequest(gomock.Any(), "/reconcile", gomock.Any()).Times(1).
		DoAndReturn(func(ctx context.Context, path string, opts ...modagent.GitLabRequestOption) (*modagent.GitLabResponse, error) {
			requestConfig, err := modagent.ApplyRequestOptions(opts)
			r.NoError(err)

			requestBody := extractRequestPayload(requestConfig.Body)
			r.Equal(expectedRequest, requestBody)

			var body []byte

			body, err = json.Marshal(response)
			r.NoError(err)

			return &modagent.GitLabResponse{
				StatusCode: http.StatusCreated,
				Body:       io.NopCloser(bytes.NewReader(body)),
			}, nil
		})
}

func (r *ReconcilerTestSuite) expectRequestAndReturnError(mockAPI *mock_modagent.MockAPI, expectedRequest RequestPayload) {
	extractRequestPayload := func(dataReader io.ReadCloser) RequestPayload {
		var request RequestPayload

		raw, err := io.ReadAll(dataReader)
		r.NoError(err)

		err = json.Unmarshal(raw, &request)
		r.NoError(err)

		return request
	}

	mockAPI.EXPECT().
		MakeGitLabRequest(gomock.Any(), "/reconcile", gomock.Any()).Times(1).
		DoAndReturn(func(ctx context.Context, path string, opts ...modagent.GitLabRequestOption) (*modagent.GitLabResponse, error) {
			requestConfig, err := modagent.ApplyRequestOptions(opts)
			r.NoError(err)

			requestBody := extractRequestPayload(requestConfig.Body)
			r.Equal(expectedRequest, requestBody)

			var emptyResponse []byte

			return &modagent.GitLabResponse{
				StatusCode: http.StatusInternalServerError,
				Body:       io.NopCloser(bytes.NewReader(emptyResponse)),
			}, nil
		})
}

func (r *ReconcilerTestSuite) generateInfoForNewWorkspace(name string, imagePullSecrets []ImagePullSecret) *WorkspaceRailsInfo {
	return &WorkspaceRailsInfo{
		Name:                      name,
		Namespace:                 name + "-namespace",
		DeploymentResourceVersion: "",
		ActualState:               "Creating",
		DesiredState:              "Running",
		ConfigToApply:             "",
		ImagePullSecrets:          imagePullSecrets,
	}
}

func (r *ReconcilerTestSuite) generateInfoForWorkspaceChanges(workspace *parsedWorkspace, desiredState, actualState string) *WorkspaceRailsInfo {
	configToApply := fmt.Sprintf(`
inventory_configmap:
    name: "%s-workspace-inventory-1"
    namespace: "%s"
---
inventory_configmap:
    name: "%s-workspace-inventory-2"
    namespace: "%s"
`, workspace.Name, workspace.Namespace, workspace.Name, workspace.Namespace)

	return &WorkspaceRailsInfo{
		Name:                      workspace.Name,
		Namespace:                 workspace.Namespace,
		DeploymentResourceVersion: "1",
		ActualState:               actualState,
		DesiredState:              desiredState,
		ConfigToApply:             configToApply,
	}
}

func (r *ReconcilerTestSuite) generateInfoForWorkspaceInErrorState(name string) *WorkspaceRailsInfo {
	return &WorkspaceRailsInfo{
		Name:                      name,
		Namespace:                 name + "-namespace",
		DeploymentResourceVersion: "1",
		ActualState:               WorkspaceStateError,
		DesiredState:              "Running",
		ConfigToApply:             "",
	}
}

func (r *ReconcilerTestSuite) generateRailsRequest(updateType WorkspaceUpdateType, agentInfos ...WorkspaceAgentInfo) RequestPayload {
	// agentInfos may be a nil slice. However, we want it to be an empty(0-length) slice. Hence, the explicit initialization.
	if len(agentInfos) == 0 {
		agentInfos = make([]WorkspaceAgentInfo, 0)
	}

	return RequestPayload{
		UpdateType:          updateType,
		WorkspaceAgentInfos: agentInfos,
	}
}

func (r *ReconcilerTestSuite) agentInfoForNonTerminatedWorkspace(workspace *parsedWorkspace) WorkspaceAgentInfo {
	return WorkspaceAgentInfo{
		Name:                    workspace.Name,
		Namespace:               workspace.Namespace,
		LatestK8sDeploymentInfo: workspace.K8sDeploymentInfo,
	}
}

func (r *ReconcilerTestSuite) agentInfoWithApplierErrors(workspace *parsedWorkspace, err error) WorkspaceAgentInfo {
	return WorkspaceAgentInfo{
		Name:      workspace.Name,
		Namespace: workspace.Namespace,
		ErrorDetails: &ErrorDetails{
			ErrorType:    ErrorTypeApplier,
			ErrorMessage: err.Error(),
		},
	}
}

func (r *ReconcilerTestSuite) agentInfoWithKubernetesErrors(workspace *parsedWorkspace, err error) WorkspaceAgentInfo {
	return WorkspaceAgentInfo{
		Name:      workspace.Name,
		Namespace: workspace.Namespace,
		ErrorDetails: &ErrorDetails{
			ErrorType:    ErrorTypeKubernetes,
			ErrorMessage: err.Error(),
		},
	}
}

func (r *ReconcilerTestSuite) agentInfoWithTerminationProgress(workspace *parsedWorkspace, progress TerminationProgress) WorkspaceAgentInfo {
	return WorkspaceAgentInfo{
		Name:                workspace.Name,
		Namespace:           workspace.Namespace,
		TerminationProgress: progress,
	}
}

func (r *ReconcilerTestSuite) newMockWorkspaceInAgent(ctx context.Context, name string) *parsedWorkspace {
	namespace := name + "-namespace"

	_, err := r.mockK8sClient.UpdateOrCreate(ctx, namespaceGVR, "", &corev1.Namespace{
		ObjectMeta: v1.ObjectMeta{
			Name: namespace,
		},
	}, true)
	r.NoError(err)

	_, err = r.mockK8sClient.UpdateOrCreate(ctx, configMapGVR, namespace, &corev1.ConfigMap{
		ObjectMeta: v1.ObjectMeta{
			Name:      fmt.Sprintf("%s-workspace-inventory-1", name),
			Namespace: namespace,
		},
		Data: map[string]string{
			"apps_v1_Deployment": "",
		},
	}, true)
	r.NoError(err)

	_, err = r.mockK8sClient.UpdateOrCreate(ctx, configMapGVR, namespace, &corev1.ConfigMap{
		ObjectMeta: v1.ObjectMeta{
			Name:      fmt.Sprintf("%s-workspace-inventory-2", name),
			Namespace: namespace,
		},
		Data: map[string]string{
			"v1_Secret": "",
		},
	}, true)
	r.NoError(err)

	return &parsedWorkspace{
		Name:            name,
		Namespace:       namespace,
		ResourceVersion: "1",
		K8sDeploymentInfo: map[string]any{
			"metadata": map[string]any{
				"name":            name,
				"namespace":       namespace,
				"resourceVersion": "1",
			},
		},
	}
}

func (r *ReconcilerTestSuite) SetupTest() {
	ctrl := gomock.NewController(r.T())
	r.mockAPI = mock_modagent.NewMockAPI(ctrl)

	r.mockK8sClient = NewMockClient()
	r.mockDeploymentInformer = newMockInformer(func(i any) {
		ws, ok := i.(*parsedWorkspace)
		if !ok {
			r.T().Fatal("cannot add non parsedWorkspace to deployment informer")
		}
		_, exist := r.mockDeploymentInformer.Resources[ws.Namespace]
		if !exist {
			r.mockDeploymentInformer.Resources[ws.Namespace] = make(map[string]*unstructured.Unstructured)
		}

		r.mockDeploymentInformer.Resources[ws.Namespace][ws.Name] = &unstructured.Unstructured{
			Object: map[string]any{
				"metadata": map[string]any{
					"name":            ws.Name,
					"namespace":       ws.Namespace,
					"resourceVersion": ws.ResourceVersion,
				},
			},
		}
	})

	r.mockSecretInformer = newMockInformer(func(i any) {
		secret, ok := i.(*corev1.Secret)
		if !ok {
			r.T().Fatal("cannot add non secret to secret informer")
		}
		_, exist := r.mockSecretInformer.Resources[secret.Namespace]
		if !exist {
			r.mockSecretInformer.Resources[secret.Namespace] = make(map[string]*unstructured.Unstructured)
		}
		r.mockSecretInformer.Resources[secret.Namespace][secret.Name] = &unstructured.Unstructured{
			Object: map[string]any{
				"metadata": map[string]any{
					"name":            secret.Name,
					"namespace":       secret.Namespace,
					"resourceVersion": secret.ResourceVersion,
				},
			},
		}

	})

	// this should ideally be called once per run
	//  however, since each test may have multiple runs, this is just put here for simplicity
	r.mockAPI.EXPECT().GetAgentKey(gomock.Any()).AnyTimes()

	// this may be called in cases where the reconciliation loop results in applier errors
	r.mockAPI.EXPECT().HandleProcessingError(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()

	var nsHandler namespaceHandler
	switch r.namespaceMode {
	case WorkspaceNamespaceModeUnique:
		nsHandler = newUniqueNamespaceHandler(r.mockK8sClient, testhelpers.AgentkKey1)
	case WorkspaceNamespaceModeShared:
		nsHandler = newSharedNamespaceHandler(r.mockK8sClient)
	}

	r.runner = reconciler{
		log:                    testlogger.New(r.T()),
		agentKey:               testhelpers.AgentkKey1,
		api:                    r.mockAPI,
		pollConfig:             testhelpers.NewPollConfig(time.Second),
		stateTracker:           newPersistedStateTracker(),
		deploymentInformer:     r.mockDeploymentInformer,
		k8sClient:              r.mockK8sClient,
		imagePullSecretHandler: newImagePullSecretsHandler(testlogger.New(r.T()), r.mockK8sClient, r.mockSecretInformer, newImagePullSecretTracker()),
		terminationTracker:     newTerminationTracker(),
		errDetailsTracker:      newErrorDetailsTracker(),
		namespaceHandler:       nsHandler,
	}
}
