package agent

//goland:noinspection GoSnakeCaseUsage
import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
	"sync/atomic"
	"time"

	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/api"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/modagent"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/remote_development/agent/k8s"
	rdutil "gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/remote_development/agent/util"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/errz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/httpz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/ioz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/logz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/retry"
	k8serrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

type MessageType string
type WorkspaceNamespaceMode string
type WorkspaceUpdateType string
type TerminationProgress string
type ErrorType string

const (
	WorkspaceUpdateTypePartial WorkspaceUpdateType = "partial"
	WorkspaceUpdateTypeFull    WorkspaceUpdateType = "full"

	WorkspaceNamespaceModeUnique WorkspaceNamespaceMode = "unique"
	WorkspaceNamespaceModeShared WorkspaceNamespaceMode = "shared"

	WorkspaceStateTerminated string = "Terminated"
	WorkspaceStateError      string = "Error"

	TerminationProgressTerminating TerminationProgress = "Terminating"
	TerminationProgressTerminated  TerminationProgress = "Terminated"

	ErrorTypeApplier    ErrorType = "applier"
	ErrorTypeKubernetes ErrorType = "kubernetes"
)

// reconciler is equipped to and responsible for carrying
// one cycle of reconciliation when reconciler.Run() is invoked
type reconciler struct {
	log                    *slog.Logger
	agentKey               api.AgentKey
	api                    modagent.API
	pollConfig             retry.PollConfigFactory
	stateTracker           *persistedStateTracker
	deploymentInformer     informer
	secretInformer         informer
	k8sClient              k8s.Client
	imagePullSecretHandler imagePullSecretsHandler

	// This is used to determine whether the reconciliation cycle corresponds to
	// a full or partial reconciliation. When a reconciler runs for the first time, this will be false
	// indicating a full reconciliation. After the full reconciliation successfully completes, this will be
	// indicating partial reconciliation for subsequent cycles for the same reconciler
	hasFullReconciliationRunBefore bool

	// namespaceHandler provides an abstraction for creating and terminating
	// workspaces that may use a different namespace mode
	namespaceHandler namespaceHandler

	// terminationTracker tracks all workspaces for whom termination has been initiated or
	// they have been terminated.  These workspaces are evicted from the tracker once a workspace
	// has been completely removed from the cluster and the corresponding termination progress i.e
	// Terminated has been successfully reported to the
	terminationTracker terminationTracker

	// errDetailsTracker tracks the asynchronous error details received and serves
	// as the single source of truth when reporting these to Rails in subsequent reconciliation cycles
	errDetailsTracker *errorDetailsTracker

	// versionCounter is an atomic counter that creates monotonically increasing values for use
	// as versions by the errDetailsTracker. It is monotonically increasing in order to determine
	// if one version is older than another and thereby only watch for errors corresponding to the
	// latest operation
	versionCounter atomic.Uint64
}

// TODO: revisit all request and response types, and make more strongly typed if possible
// issue: https://gitlab.com/gitlab-org/gitlab/-/issues/396882

type WorkspaceAgentInfo struct {
	Name                    string              `json:"name"`
	Namespace               string              `json:"namespace,omitempty"`
	LatestK8sDeploymentInfo map[string]any      `json:"latest_k8s_deployment_info,omitempty"`
	TerminationProgress     TerminationProgress `json:"termination_progress,omitempty"`
	ErrorDetails            *ErrorDetails       `json:"error_details,omitempty"`
}

type ErrorDetails struct {
	ErrorType    ErrorType `json:"error_type,omitempty"`
	ErrorMessage string    `json:"error_message,omitempty"`
}

func newErrorDetails(errorType ErrorType, errorMessage string) *ErrorDetails {
	return &ErrorDetails{ErrorType: errorType, ErrorMessage: errorMessage}
}

type RequestPayload struct {
	UpdateType          WorkspaceUpdateType  `json:"update_type"`
	WorkspaceAgentInfos []WorkspaceAgentInfo `json:"workspace_agent_infos"`
}

type ImagePullSecret struct {
	Name      string `json:"name"`
	Namespace string `json:"namespace"`
}

type WorkspaceRailsInfo struct {
	Name                      string            `json:"name"`
	Namespace                 string            `json:"namespace"`
	DeploymentResourceVersion string            `json:"deployment_resource_version,omitempty"`
	ActualState               string            `json:"actual_state,omitempty"`
	DesiredState              string            `json:"desired_state,omitempty"`
	ConfigToApply             string            `json:"config_to_apply,omitempty"`
	ImagePullSecrets          []ImagePullSecret `json:"image_pull_secrets,omitempty"`
}

type ResponsePayload struct {
	WorkspaceRailsInfos []*WorkspaceRailsInfo `json:"workspace_rails_infos"`
	Settings            *Settings             `json:"settings"`
}

type Settings struct {
	FullReconciliationIntervalSeconds    int `json:"full_reconciliation_interval_seconds"`
	PartialReconciliationIntervalSeconds int `json:"partial_reconciliation_interval_seconds"`
}

type WorkerSettings struct {
	FullReconciliationInterval    time.Duration
	PartialReconciliationInterval time.Duration
}

func (r *reconciler) Run(ctx context.Context) (WorkerSettings, error) {
	r.log.Debug("Running reconciliation loop")
	defer r.log.Debug("Reconciliation loop ended")

	errDetailsSnapshot := r.errDetailsTracker.createSnapshot()

	// Load and the info on the workspaces from the deploymentInformer. Skip it if the persisted state in
	// rails is already up-to-date for the workspace
	workspaceAgentInfos := r.generateWorkspaceAgentInfos(errDetailsSnapshot)
	updateType := WorkspaceUpdateTypePartial
	if !r.hasFullReconciliationRunBefore {
		updateType = WorkspaceUpdateTypeFull
	}

	// Submit the workspace update request to the Rails API. Sends the latest AgentInfos in the request,
	// and receives the latest RailsInfos in the response.
	response, err := r.performWorkspaceUpdateRequestToRailsAPI(ctx, updateType, workspaceAgentInfos)
	if err != nil {
		return WorkerSettings{}, err
	}

	// Workspace update request was completed successfully, now process any RailsInfos received in the response
	for _, workspaceRailsInfo := range response.WorkspaceRailsInfos {
		// TODO: discuss whether/how to deal with errors returned by rails when processing individual workspaces
		//  for ex. there may be a case where no workspace is found for a given combination of workspace name &
		//  workspace
		// issue: https://gitlab.com/gitlab-org/gitlab/-/issues/409807

		// increment version to guarantee ordering
		version := r.versionCounter.Add(1)

		errDetailsCh := r.applyWorkspaceChanges(ctx, workspaceRailsInfo, errDetailsSnapshot)
		r.errDetailsTracker.watchForLatestErrors(ctx, workspaceRailsInfo.Name, workspaceRailsInfo.Namespace, version, errDetailsCh)
	}

	r.hasFullReconciliationRunBefore = true
	return WorkerSettings{
		FullReconciliationInterval:    time.Duration(response.Settings.FullReconciliationIntervalSeconds * int(time.Second)),
		PartialReconciliationInterval: time.Duration(response.Settings.PartialReconciliationIntervalSeconds * int(time.Second)),
	}, nil
}

func (r *reconciler) applyWorkspaceChanges(ctx context.Context, workspaceRailsInfo *WorkspaceRailsInfo, errorDetailsSnapshot map[errorTrackerKey]operationState) <-chan *ErrorDetails {
	r.stateTracker.recordVersion(workspaceRailsInfo)

	name := workspaceRailsInfo.Name
	namespace := workspaceRailsInfo.Namespace

	r.evictReportedErrors(name, namespace, errorDetailsSnapshot)

	// When desired state is Terminated, trigger workspace deletion and exit early
	// to avoid processing/applying the workspace config
	if workspaceRailsInfo.DesiredState == WorkspaceStateTerminated {
		// Handle Terminated state (delete the namespace and workspace) and return
		return r.handleDesiredStateIsTerminated(ctx, workspaceRailsInfo)
	}

	if err := r.namespaceHandler.Create(ctx, workspaceRailsInfo); err != nil {
		return err
	}

	err := r.imagePullSecretHandler(ctx, r.agentKey, namespace, name, workspaceRailsInfo.ImagePullSecrets)
	if err != nil {
		return rdutil.ToAsync[*ErrorDetails](newErrorDetails(ErrorTypeKubernetes, err.Error()))
	}

	// Apply workspace config if one was provided in the workspaceRailsInfo
	if workspaceRailsInfo.ConfigToApply != "" {
		errCh := r.k8sClient.Apply(ctx, workspaceRailsInfo.ConfigToApply)
		return rdutil.RunWithAsyncResult[*ErrorDetails](func(outCh chan<- *ErrorDetails) {
			for e := range errCh {
				if e != nil {
					outCh <- newErrorDetails(ErrorTypeApplier, e.Error())
				}
			}
		})
	}

	return rdutil.ToAsync[*ErrorDetails](nil)
}

func (r *reconciler) evictReportedErrors(name, namespace string, errorDetailsSnapshot map[errorTrackerKey]operationState) {
	key := errorTrackerKey{
		name:      name,
		namespace: namespace,
	}

	// If an error was reported to rails successfully, it can now be safely cleaned up from the tracker.
	// we can do a cleanup if the version in the tracker matches the version of
	// error sent to Rails
	if state, hasEntry := errorDetailsSnapshot[key]; hasEntry && state.errorDetails != nil {
		r.errDetailsTracker.deleteErrorIfVersion(name, namespace, state.version)
	}
}

func (r *reconciler) generateWorkspaceAgentInfos(errorDetailsSnapshot map[errorTrackerKey]operationState) []WorkspaceAgentInfo {
	deployments := r.deploymentInformer.ListAll()
	parsedWorkspaces := make([]*parsedWorkspace, 0, len(deployments))
	for _, rawWorkspace := range deployments {
		parsedWorkspaces = append(parsedWorkspaces, &parsedWorkspace{
			Name:              rawWorkspace.GetName(),
			Namespace:         rawWorkspace.GetNamespace(),
			ResourceVersion:   rawWorkspace.GetResourceVersion(),
			K8sDeploymentInfo: rawWorkspace.Object,
		})
	}

	workspaceInfos := r.collectInfoForUnpersistedWorkspaces(parsedWorkspaces)
	r.enrichRailsPayloadWithWorkspaceTerminationProgress(workspaceInfos)
	r.enrichRailsPayloadWithErrorDetails(workspaceInfos, errorDetailsSnapshot)

	// "result" is constructed by taking the values collected in workspaceInfos which in turn
	// is prepared by starting off with unpersisted workspace data and enrich it with termination progress
	// and error details
	result := make([]WorkspaceAgentInfo, 0, len(workspaceInfos))
	for _, agentInfo := range workspaceInfos {
		result = append(result, agentInfo)
	}

	return result
}

func (r *reconciler) collectInfoForUnpersistedWorkspaces(existingWorkspaces []*parsedWorkspace) map[workspaceInfoKey]WorkspaceAgentInfo {
	workspaceAgentInfos := make(map[workspaceInfoKey]WorkspaceAgentInfo)

	for _, workspace := range existingWorkspaces {
		// if Rails already knows about the latest version of the resource, don't send the info again
		if r.stateTracker.isPersisted(workspace.Name, workspace.ResourceVersion) {
			r.log.Debug("Skipping sending workspace info. GitLab already has the latest version", logz.WorkspaceName(workspace.Name))
			continue
		}

		key := workspaceInfoKey{
			Name:      workspace.Name,
			Namespace: workspace.Namespace,
		}
		workspaceAgentInfos[key] = WorkspaceAgentInfo{
			Name:                    workspace.Name,
			Namespace:               workspace.Namespace,
			LatestK8sDeploymentInfo: workspace.K8sDeploymentInfo,
		}
	}

	return workspaceAgentInfos
}

func (r *reconciler) enrichRailsPayloadWithErrorDetails(payload map[workspaceInfoKey]WorkspaceAgentInfo, errorsSnapshot map[errorTrackerKey]operationState) {
	// for each entry in the error snapshot, either a new payload has to be
	// created or the error details must be merged into the existing payload
	// being sent over to rails
	for trackerKey, state := range errorsSnapshot {
		if state.errorDetails == nil {
			// this case will occur when the corresponding operation is still
			// running and the result is not known yet
			continue
		}

		key := workspaceInfoKey{
			Name:      trackerKey.name,
			Namespace: trackerKey.namespace,
		}

		var agentInfo WorkspaceAgentInfo
		var exists bool

		agentInfo, exists = payload[key]
		if !exists {
			agentInfo = WorkspaceAgentInfo{
				Name:      trackerKey.name,
				Namespace: trackerKey.namespace,
			}
		}

		agentInfo.ErrorDetails = state.errorDetails
		payload[key] = agentInfo
	}
}

func (r *reconciler) enrichRailsPayloadWithWorkspaceTerminationProgress(payload map[workspaceInfoKey]WorkspaceAgentInfo) {
	// enrich payload with termination progress for workspaces that haven't been
	// reconciled with Rails yet
	for entry, latestTerminationDetails := range r.terminationTracker {
		if latestTerminationDetails.isReconciled {
			continue
		}

		key := workspaceInfoKey{
			Name:      entry.name,
			Namespace: entry.namespace,
		}

		var agentInfo WorkspaceAgentInfo
		var exists bool

		agentInfo, exists = payload[key]
		if !exists {
			agentInfo = WorkspaceAgentInfo{
				Name:      entry.name,
				Namespace: entry.namespace,
			}
		}

		agentInfo.TerminationProgress = latestTerminationDetails.progress
		payload[key] = agentInfo
	}
}

func (r *reconciler) performWorkspaceUpdateRequestToRailsAPI(
	ctx context.Context,
	updateType WorkspaceUpdateType,
	workspaceAgentInfos []WorkspaceAgentInfo,
) (responsePayload *ResponsePayload, retError error) {
	// Do the POST request to the Rails API
	r.log.Debug("Making GitLab request")

	if workspaceAgentInfos == nil {
		// In case there is nothing to report to rails, populate the payload with an empty slice
		// to be explicit about the intent
		// TODO: add a test case - https://gitlab.com/gitlab-org/gitlab/-/issues/407554
		workspaceAgentInfos = []WorkspaceAgentInfo{}
	}

	startTime := time.Now()
	var requestPayload = RequestPayload{
		UpdateType:          updateType,
		WorkspaceAgentInfos: workspaceAgentInfos,
	}
	// below code is from internal/module/starboard_vulnerability/agent/reporter.go
	resp, err := r.api.MakeGitLabRequest(ctx, "/reconcile",
		modagent.WithRequestMethod(http.MethodPost),
		modagent.WithJSONRequestBody(requestPayload),
	)
	if err != nil {
		return nil, fmt.Errorf("error making api request: %w", err)
	}
	defer errz.SafeClose(resp.Body, &retError)
	r.log.Debug("Made request to the Rails API",
		logz.StatusCode(resp.StatusCode),
		logz.RequestID(resp.Header.Get(httpz.RequestIDHeader)),
		logz.DurationInMilliseconds(time.Since(startTime)),
	)

	if resp.StatusCode != http.StatusCreated {
		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}

	var bodyLen int
	err = ioz.ReadAllFunc(resp.Body, func(body []byte) error {
		bodyLen = len(body)
		responsePayload = newResponsePayloadWithDefaultSettings()
		return json.Unmarshal(body, responsePayload)
	})
	if err != nil {
		return nil, fmt.Errorf("response body: %w", err)
	}

	r.log.Debug(
		"Read body from the Rails API",
		logz.PayloadSizeInBytes(bodyLen),
		logz.WorkspaceDataCount(len(responsePayload.WorkspaceRailsInfos)),
	)

	return responsePayload, nil
}

func (r *reconciler) handleDesiredStateIsTerminated(ctx context.Context, workspaceRailsInfo *WorkspaceRailsInfo) <-chan *ErrorDetails {
	name := workspaceRailsInfo.Name
	namespace := workspaceRailsInfo.Namespace
	actualState := workspaceRailsInfo.ActualState

	// If the actual state of the workspaces as record by rails is Terminated,
	// delete the record from the trackers and exit since the termination is complete and persisted in Rails.
	if actualState == WorkspaceStateTerminated {
		r.log.Debug("ActualState=Terminated has been persisted by Rails and hence deleting it from terminationTracker", logz.WorkspaceNamespace(namespace))
		r.terminationTracker.delete(name, namespace)
		r.stateTracker.delete(name)
		return rdutil.ToAsync[*ErrorDetails](nil)
	}

	// For all other cases, Rails will respond with
	// the inventory config maps with empty 'data' key as the config to apply.
	invConfigMapNames, err := r.getKubernetesObjectsNames(workspaceRailsInfo.ConfigToApply)
	if err != nil {
		errMsg := fmt.Sprintf("failed to parse inventory config map names for workspace %s: %s", name, err)
		return rdutil.ToAsync[*ErrorDetails](newErrorDetails(ErrorTypeKubernetes, errMsg))
	}

	invConfigMaps := make([]*unstructured.Unstructured, 0, len(invConfigMapNames))
	for _, invConfigMapName := range invConfigMapNames {
		var invConfigMap *unstructured.Unstructured
		invConfigMap, err = r.k8sClient.Get(ctx, configMapGVR, namespace, invConfigMapName)
		if err != nil {
			if k8serrors.IsNotFound(err) {
				continue
			}
			errMsg := fmt.Sprintf("failed to get inventory config map %s: %v", invConfigMapName, err)
			return rdutil.ToAsync[*ErrorDetails](newErrorDetails(ErrorTypeKubernetes, errMsg))
		}
		invConfigMaps = append(invConfigMaps, invConfigMap)
	}

	// Workspace is marked terminated when
	// 1. The namespace is deleted in case of WorkspaceNamespaceModeUnique
	// 2. The inventory config maps are deleted in case of WorkspaceNamespaceModeShared
	ok, err := r.namespaceHandler.IsTerminated(ctx, workspaceRailsInfo, invConfigMaps)
	if err != nil {
		return rdutil.ToAsync(newErrorDetails(ErrorTypeApplier, fmt.Sprintf("failed to check if namespace %s is terminated: %v", namespace, err)))
	}
	if ok {
		r.terminationTracker.updateOrSet(name, namespace, TerminationProgressTerminated)
		// nothing to do as the workspace is already absent in the cluster
		return rdutil.ToAsync[*ErrorDetails](nil)
	}

	// Check if the inventory config maps in Kubernetes are tracking any objects by reading the 'data' key.
	// If the 'data' key is not found, all the tracked objects have been deleted.
	// If the 'data' key is found, and it is not empty, it means the objects are still present in the cluster.
	// Thus, we should initiate their deletion by applying the config which contains
	// only the inventory config maps with no 'data' key.
	// This will automatically initiate the deletion of the objects tracked by the inventory config map.
	allTrackedObjectsDeleted := true
	for _, invConfigMap := range invConfigMaps {
		var found bool
		var data map[string]string
		data, found, err = unstructured.NestedStringMap(invConfigMap.Object, "data")
		if err != nil {
			errMsg := fmt.Sprintf("failed to read inventory config map for workspace %s: %s", name, err)
			return rdutil.ToAsync[*ErrorDetails](newErrorDetails(ErrorTypeKubernetes, errMsg))
		}
		if found && len(data) > 0 {
			allTrackedObjectsDeleted = false
		}
	}

	if !allTrackedObjectsDeleted {
		r.log.Debug("Inventory config map for terminated workspace exists and is not empty. Deleting the tracked objects.", logz.WorkspaceNamespace(namespace))
		errCh := r.k8sClient.Apply(ctx, workspaceRailsInfo.ConfigToApply)

		r.terminationTracker.updateOrSet(name, namespace, TerminationProgressTerminating)

		return rdutil.RunWithAsyncResult[*ErrorDetails](func(outCh chan<- *ErrorDetails) {
			for e := range errCh {
				if e != nil {
					outCh <- newErrorDetails(ErrorTypeApplier, e.Error())
				}
			}
		})
	}

	// Since all the tracked objects by the inventory config map are deleted,
	// it is safe to delete the namespace / inventory config map
	if err := r.namespaceHandler.Terminate(ctx, workspaceRailsInfo, invConfigMaps); err != nil {
		return err
	}

	r.terminationTracker.updateOrSet(name, namespace, TerminationProgressTerminating)
	return rdutil.ToAsync[*ErrorDetails](nil)
}

// getKubernetesObjectsNames extracts the metadata.name field from a list
// of string-encoded Kubernetes resources
func (r *reconciler) getKubernetesObjectsNames(objects string) ([]string, error) {
	objs, err := r.k8sClient.Decode(strings.NewReader(objects))
	if err != nil {
		return nil, err
	}
	names := make([]string, 0)
	for _, obj := range objs {
		names = append(names, obj.GetName())
	}
	return names, nil
}

func (r *reconciler) Stop() {
	// this will be invoked at the end of each full cycle with the outgoing reconciler and its informers being stopped
	// and new reconcilers (with new informers) created from scratch. The underlying principle is to prevent a re-use of
	// reconciler state when a full reconciliation occurs to prevent issues due to corruption of internal state
	// However, this decision can be revisited in the future in of any issues
	// issue: https://gitlab.com/gitlab-org/gitlab/-/issues/404748
	r.deploymentInformer.Stop()
	r.secretInformer.Stop()
}
