package agent

import (
	"context"
	"crypto/rand"
	"errors"
	"fmt"
	"log/slog"
	"net/url"
	"regexp"
	"strconv"
	"strings"
	"time"

	notificationv1 "github.com/fluxcd/notification-controller/api/v1"
	"github.com/fluxcd/pkg/apis/meta"
	sourcev1 "github.com/fluxcd/source-controller/api/v1"
	"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/tool/cryptoz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/fieldz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/logz"
	"k8s.io/apimachinery/pkg/api/equality"
	kubeerrors "k8s.io/apimachinery/pkg/api/errors"
	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/runtime/schema"
	"k8s.io/apimachinery/pkg/util/wait"
	applycorev1 "k8s.io/client-go/applyconfigurations/core/v1"
	applymetav1 "k8s.io/client-go/applyconfigurations/meta/v1"
	"k8s.io/client-go/dynamic"
	"k8s.io/client-go/informers"
	v1 "k8s.io/client-go/kubernetes/typed/core/v1"
	"k8s.io/client-go/tools/cache"
	"k8s.io/client-go/util/workqueue"
)

const (
	objectNamePrefix = "gitlab-"
	// See https://kubernetes.io/docs/reference/labels-annotations-taints/#app-kubernetes-io-managed-by
	managedByAnnotationKey   = "app.kubernetes.io/managed-by"
	managedByAnnotationValue = "gitlab"
	receiverSecretInterval   = 5 * time.Minute
	maxReceiverNameLength    = 63
	maxSecretNameLength      = 253
)

var (
	isAlphanumeric = regexp.MustCompile(`^[A-Za-z0-9]+$`).MatchString
)

type gitRepositoryController struct {
	log                                 *slog.Logger
	api                                 modagent.API
	agentKey                            api.AgentKey
	gitLabExternalURL                   url.URL
	gitRepositoryInformerCacheHasSynced cache.InformerSynced
	gitRepositoryLister                 cache.GenericLister
	receiverInformerCacheHasSynced      cache.InformerSynced
	projectReconciler                   projectReconciler
	receiverAPIClient                   dynamic.NamespaceableResourceInterface
	corev1APIClient                     v1.CoreV1Interface
	workqueue                           workqueue.TypedRateLimitingInterface[string]
	receiverSecret                      string
}

func newGitRepositoryController(
	ctx context.Context,
	log *slog.Logger,
	api modagent.API,
	agentKey api.AgentKey,
	gitLabExternalURL url.URL, //nolint: gocritic
	gitRepositoryInformer informers.GenericInformer,
	receiverInformer informers.GenericInformer,
	projectReconciler projectReconciler,
	receiverAPIClient dynamic.NamespaceableResourceInterface,
	corev1APIClient v1.CoreV1Interface) (*gitRepositoryController, error) {

	gitRepositorySharedInformer := gitRepositoryInformer.Informer()
	receiverSharedInformer := receiverInformer.Informer()

	receiverSecret, err := generateReceiverSecret(cryptoz.HMACSHA256MaxKeySize)
	if err != nil {
		return nil, fmt.Errorf("could not generate a secret value for the Receiver: %w", err)
	}

	c := &gitRepositoryController{
		log:                                 log,
		api:                                 api,
		agentKey:                            agentKey,
		gitLabExternalURL:                   gitLabExternalURL,
		gitRepositoryInformerCacheHasSynced: gitRepositorySharedInformer.HasSynced,
		gitRepositoryLister:                 gitRepositoryInformer.Lister(),
		receiverInformerCacheHasSynced:      receiverSharedInformer.HasSynced,
		projectReconciler:                   projectReconciler,
		receiverAPIClient:                   receiverAPIClient,
		corev1APIClient:                     corev1APIClient,
		workqueue: workqueue.NewTypedRateLimitingQueueWithConfig(
			workqueue.DefaultTypedControllerRateLimiter[string](),
			workqueue.TypedRateLimitingQueueConfig[string]{
				Name: "GitRepositories",
			},
		),
		receiverSecret: receiverSecret,
	}

	// register for GitRepository informer events
	_, err = gitRepositorySharedInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{
		AddFunc: func(obj any) {
			c.log.Debug("Handling add of GitRepository")
			c.enqueue(obj)
		},
		UpdateFunc: func(oldObj, newObj any) {
			newU := newObj.(*unstructured.Unstructured)
			oldU := oldObj.(*unstructured.Unstructured)
			if oldU.GetResourceVersion() == newU.GetResourceVersion() {
				return
			}

			var newGitRepository sourcev1.GitRepository
			err = runtime.DefaultUnstructuredConverter.FromUnstructured(newU.UnstructuredContent(), &newGitRepository)
			if err != nil {
				c.log.Error("Unable to convert unstructured object to GitRepository", logz.Error(err))
				return
			}

			var oldGitRepository sourcev1.GitRepository
			err = runtime.DefaultUnstructuredConverter.FromUnstructured(oldU.UnstructuredContent(), &oldGitRepository)
			if err != nil {
				c.log.Error("Unable to convert unstructured object to GitRepository", logz.Error(err))
				return
			}

			if equality.Semantic.DeepEqual(oldGitRepository.Spec, newGitRepository.Spec) {
				c.log.Debug("Ignoring updated GitRepository because there are no changes in the spec", logz.K8sObjectNsAndName(&newGitRepository))
				return
			}

			c.log.Debug("Handling update of GitRepository")
			c.enqueue(newObj)
		},
	})
	if err != nil {
		return nil, fmt.Errorf("failed to add event handlers for GitRepository resources: %w", err)
	}

	// register for Receiver informer events
	_, err = receiverSharedInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{
		AddFunc: func(obj any) {
			c.log.Debug("Handling add of Receiver")
			c.handleReceiverObj(ctx, obj)
		},
		UpdateFunc: func(oldObj, newObj any) {
			newU := newObj.(*unstructured.Unstructured)
			oldU := oldObj.(*unstructured.Unstructured)
			if oldU.GetResourceVersion() == newU.GetResourceVersion() {
				return
			}

			var newReceiver notificationv1.Receiver
			err := runtime.DefaultUnstructuredConverter.FromUnstructured(newU.UnstructuredContent(), &newReceiver) //nolint: govet
			if err != nil {
				c.log.Error("Unable to convert unstructured object to Receiver", logz.Error(err))
				return
			}

			var oldReceiver notificationv1.Receiver
			err = runtime.DefaultUnstructuredConverter.FromUnstructured(oldU.UnstructuredContent(), &oldReceiver)
			if err != nil {
				c.log.Error("Unable to convert unstructured object to Receiver", logz.Error(err))
				return
			}

			if equality.Semantic.DeepEqual(oldReceiver.Spec, newReceiver.Spec) {
				c.log.Debug("Ignoring updated Receiver because there are no changes in the spec", logz.K8sObjectNsAndName(&newReceiver))
				return
			}
			c.log.Debug("Handling update of Receiver")
			c.handleReceiverObj(ctx, newObj)
		},
		DeleteFunc: func(obj any) {
			c.log.Debug("Handling delete of Receiver")
			c.handleReceiverObj(ctx, obj)
		},
	})
	if err != nil {
		return nil, fmt.Errorf("failed to add event handlers for Receiver resources: %w", err)
	}
	return c, nil
}

// Run runs the reconciliation loop of this controller.
// New reconciliation requests can be enqueued using the enqueue
// method. The reconciliation loop runs a single worker,
// but this may be changed easily in the future.
func (c *gitRepositoryController) Run(ctx context.Context) {
	var wg wait.Group
	// this wait group has strictly to be the last thing to wait for,
	// the queue must be shutdown before.
	defer wg.Wait()
	// making the sure the work queue is being stopped when shutting down
	defer c.workqueue.ShutDown()

	c.log.Info("Starting GitRepository controller")
	defer c.log.Info("Stopped GitRepository controller")

	c.log.Debug("Waiting for GitRepository informer caches to sync")

	if ok := cache.WaitForCacheSync(ctx.Done(), c.gitRepositoryInformerCacheHasSynced, c.receiverInformerCacheHasSynced); !ok {
		// NOTE: context was canceled and we can just return
		return
	}

	c.log.Debug("Starting GitRepository worker")
	wg.Start(func() {
		// this is a long-running function that continuously
		// processes the items from the work queue.
		for c.processNextItem(ctx) {
		}
	})

	c.log.Debug("Started GitRepository worker")
	<-ctx.Done()
	c.log.Debug("Shutting down GitRepository worker")
}

// processNextItem processes the next item in the work queue.
// It returns false when the work queue was shutdown otherwise true (even for errors so that the loop continues)
func (c *gitRepositoryController) processNextItem(ctx context.Context) bool {
	// get next item to process
	key, shutdown := c.workqueue.Get()
	if shutdown {
		return false
	}

	err := c.processItem(ctx, key)
	if err != nil {
		c.api.HandleProcessingError(ctx, c.log.With(logz.ObjectKey(key)), "Failed to reconcile GitRepository", err, fieldz.AgentKey(c.agentKey))
	}
	return true
}

// processItem processes a single given item.
func (c *gitRepositoryController) processItem(ctx context.Context, key string) error {
	defer c.workqueue.Done(key)

	result := c.reconcile(ctx, key)
	switch result.status {
	case RetryRateLimited:
		// put back to work queue to handle transient errors
		c.workqueue.AddRateLimited(key)
		return result.error
	case Error:
		c.workqueue.Forget(key)
		return result.error
	case Success:
		c.log.Debug("Successfully reconciled GitRepository", logz.ObjectKey(key))
		c.workqueue.Forget(key)
	}
	return nil
}

// reconcile reconciles a single GitRepository object specified with the key argument.
// The key must be in the format of `namespace/name` (GitRepositories are namespaced)
// and references an object.
// This reconcile may be call on events for any kinds of objects, but the key
// argument must always reference a GitRepository. This is common in controllers
// that manage more than one resource type - mostly those are created for the
// resource given by the key.
// In this GitRepository controller case these additional objects are
// Receiver and Secret.
// A reconcile will create or update the Receiver and Secret resource
// required for the GitRepository at hand.
// If the given GitRepository object does no longer exists the reconciliation is stopped,
// but this is normal behavior because reconcile may have been called because of Receiver
// events (as explained above).
// The hostname of the GitRepository.Spec.URL must match the configured GitLab External URL
// in order for this controller to take any action on the GitRepository.
// If the hostname doesn't match the object is left untouched and the reconciliation request
// is dropped.
func (c *gitRepositoryController) reconcile(ctx context.Context, key string) reconciliationResult {
	namespace, name, err := cache.SplitMetaNamespaceKey(key)
	if err != nil {
		return reconciliationResult{status: Error, error: fmt.Errorf("invalid key format: %q, should be in `namespace/name` format", key)}
	}

	obj, err := c.gitRepositoryLister.ByNamespace(namespace).Get(name)
	if err != nil {
		if kubeerrors.IsNotFound(err) {
			c.log.Debug("Queued GitRepository no longer exists, dropping it", logz.K8sObjectNamespace(namespace), logz.K8sObjectName(name))
			return reconciliationResult{status: Success}
		}
		return reconciliationResult{status: RetryRateLimited, error: fmt.Errorf("unable to list GitRepository object %s", key)}
	}

	u, ok := obj.(*unstructured.Unstructured)
	if !ok {
		return reconciliationResult{status: Error, error: fmt.Errorf("received GitRepository object %s cannot be parsed to unstructured data", key)}
	}

	var gitRepository sourcev1.GitRepository
	err = runtime.DefaultUnstructuredConverter.FromUnstructured(u.UnstructuredContent(), &gitRepository)
	if err != nil {
		return reconciliationResult{status: Error, error: fmt.Errorf("unable to convert unstructured object to GitRepository: %w", err)}
	}

	// check if the hosts of the GitRepository URL matches the GitLab external URL that this agent is connected to.
	// If not, then we don't reconcile this GitRepository and leave it untouched
	grURL, err := url.Parse(gitRepository.Spec.URL)
	if err != nil {
		return reconciliationResult{status: Error, error: fmt.Errorf("unable to parse GitRepository URL %q: %w", gitRepository.Spec.URL, err)}
	}

	if c.gitLabExternalURL.Hostname() != grURL.Hostname() {
		c.log.Debug("Dropping reconciliation for GitRepository that is not on configured GitLab host", logz.K8sObjectNamespace(namespace), logz.K8sObjectName(name), logz.URL(c.gitLabExternalURL.Hostname()), logz.URL(grURL.Hostname()))
		return reconciliationResult{status: Success}
	}

	gitRepositoryGitLabPath, err := getProjectPathFromRepositoryURL(gitRepository.Spec.URL)
	if err != nil {
		return reconciliationResult{status: Error, error: fmt.Errorf("unable to extract GitLab project path from URL: %w", err)}
	}

	c.log.Debug("Reconciling GitRepository", logz.K8sObjectNamespace(namespace), logz.K8sObjectName(name), logz.GitRepositoryURL(gitRepository.Spec.URL), logz.ProjectID(gitRepositoryGitLabPath))

	// reconcile the Secret required for the Receiver
	secret := newWebhookReceiverSecret(&gitRepository, c.receiverSecret)
	if err = c.reconcileWebhookReceiverSecret(ctx, secret, gitRepository.Name); err != nil {
		return reconciliationResult{status: RetryRateLimited, error: err}
	}

	// reconcile the actual Receiver
	receiver := newWebhookReceiver(&gitRepository, gitRepositoryGitLabPath, *secret.Name)
	if err = c.reconcileWebhookReceiver(ctx, receiver); err != nil {
		return reconciliationResult{status: RetryRateLimited, error: err}
	}

	return reconciliationResult{status: Success}
}

// enqueue adds the given object to the controller work queue for processing
// The given object in the obj argument must be a GitRepository resource
// even if enqueue was called because of an event in another resource.
// Most likely that other resource is owned by said GitRepository.
func (c *gitRepositoryController) enqueue(obj any) {
	key, err := cache.MetaNamespaceKeyFunc(obj)
	if err != nil {
		c.log.Error("Unable to enqueue object, because key cannot be retrieved from object", logz.Error(err))
		return
	}
	c.workqueue.Add(key)
}

// handleReceiverObj handles informer events for the Receiver object given in the obj argument.
// If that Receiver object is owned by a GitRepository that GitRepository is enqueued
// for reconciliation by this controller.
// No matter the outcome of handleReceiverObj the projects to reconcile will always
// be updated to the current indexed Receiver objects.
func (c *gitRepositoryController) handleReceiverObj(ctx context.Context, obj any) {
	var object metav1.Object
	var ok bool

	defer c.projectReconciler.ReconcileIndexedProjects(ctx)

	if object, ok = obj.(metav1.Object); !ok {
		tombstone, ok := obj.(cache.DeletedFinalStateUnknown)
		if !ok {
			c.api.HandleProcessingError(ctx, c.log, "Failed to handle Receiver object", errors.New("unable to decode object, invalid type"), fieldz.AgentKey(c.agentKey))
			return
		}
		object, ok = tombstone.Obj.(metav1.Object)
		if !ok {
			c.api.HandleProcessingError(ctx, c.log, "Failed to handle Receiver object", errors.New("unable to decode tombstone object, invalid type"), fieldz.AgentKey(c.agentKey))
			return
		}
		c.log.Debug("Recovered deleted object", logz.K8sObjectNsAndName(object))
	}

	ownerRef := metav1.GetControllerOf(object)
	if ownerRef == nil {
		return
	}

	// If this object is not owned by a GitRepository, we should not do anything more with it.
	gv, err := schema.ParseGroupVersion(ownerRef.APIVersion)
	if err != nil {
		c.api.HandleProcessingError(ctx, c.log, fmt.Sprintf("Failed to parse Receiver owner group version %q", ownerRef.APIVersion), err, fieldz.AgentKey(c.agentKey))
		return
	}

	if gv.Group != sourcev1.GroupVersion.Group || ownerRef.Kind != sourcev1.GitRepositoryKind {
		return
	}

	gitRepository, err := c.gitRepositoryLister.ByNamespace(object.GetNamespace()).Get(ownerRef.Name)
	if err != nil {
		if kubeerrors.IsNotFound(err) {
			c.log.Debug("Ignoring orphaned Receiver object", logz.K8sObjectNsAndName(object))
		} else {
			c.api.HandleProcessingError(ctx, c.log, "Failed to handle Receiver object", errors.New("unable to get owner reference of Receiver"), fieldz.AgentKey(c.agentKey))
		}
		return
	}

	c.enqueue(gitRepository)
}

func (c *gitRepositoryController) reconcileWebhookReceiver(ctx context.Context, receiver *notificationv1.Receiver) error {
	namespacedReceiverAPIClient := c.receiverAPIClient.Namespace(receiver.Namespace)

	o, err := runtime.DefaultUnstructuredConverter.ToUnstructured(receiver)
	if err != nil {
		return fmt.Errorf("failed to convert Receiver %s/%s to unstructured object: %w", receiver.Namespace, receiver.Name, err)
	}
	u := &unstructured.Unstructured{Object: o}

	if _, err = namespacedReceiverAPIClient.Apply(ctx, receiver.Name, u, metav1.ApplyOptions{FieldManager: api.FieldManager, Force: true}); err != nil {
		if kubeerrors.IsConflict(err) {
			c.log.Debug("Unable to apply Receiver, because there is a newer version of it available", logz.K8sObjectNsAndName(u))
			return nil
		}
		return fmt.Errorf("failed to apply Receiver: %w", err)
	}
	return nil
}

func (c *gitRepositoryController) reconcileWebhookReceiverSecret(ctx context.Context, secret *applycorev1.SecretApplyConfiguration, gitRepositoryName string) error {
	secrets := c.corev1APIClient.Secrets(*secret.Namespace)
	if _, err := secrets.Apply(ctx, secret, metav1.ApplyOptions{FieldManager: api.FieldManager, Force: true}); err != nil {
		if kubeerrors.IsConflict(err) {
			c.log.Debug("Unable to apply Secret, because there is a newer version of it available", logz.K8sObjectNamespace(*secret.Namespace), logz.K8sObjectName(*secret.Name))
			return nil
		}
		return fmt.Errorf("failed to apply Secret for Receiver: %w", err)
	}

	c.deleteDeprecatedReceiverSecret(ctx, secrets, objectWithPrefix(gitRepositoryName, maxSecretNameLength))
	return nil
}

// deleteDeprecatedReceiverSecret deletes the old receiver secret that has been replaced by a new one
// This method was introduced with %16.3 and may be removed with a future version.
func (c *gitRepositoryController) deleteDeprecatedReceiverSecret(ctx context.Context, secrets v1.SecretInterface, name string) {
	secret, err := secrets.Get(ctx, name, metav1.GetOptions{})
	if err != nil {
		// we don't really care at this point.
		// All relevant possible errors with the k8s API
		// will be detected in other places.
		return
	}

	if agentID := secret.Annotations[api.AgentIDKey]; agentID != strconv.FormatInt(c.agentKey.ID, 10) {
		// we don't manage this object, so let's ignore it.
		return
	}

	if err = secrets.Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
		c.log.Debug("Failed to delete deprecated Receiver Secret", logz.Error(err))
	}
}

// newWebhookReceiver instantiates a new Receiver object.
// The name of the Receiver object must adhere to RFC 1123 with a length of max 63 characters.
func newWebhookReceiver(repository *sourcev1.GitRepository, project, secretName string) *notificationv1.Receiver {
	return &notificationv1.Receiver{
		TypeMeta: metav1.TypeMeta{
			Kind:       notificationv1.ReceiverKind,
			APIVersion: notificationv1.GroupVersion.String(),
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:      objectWithPrefix(repository.Name, maxReceiverNameLength),
			Namespace: repository.Namespace,
			OwnerReferences: []metav1.OwnerReference{
				*metav1.NewControllerRef(repository, repository.GroupVersionKind()),
			},
			Annotations: map[string]string{
				managedByAnnotationKey: managedByAnnotationValue,
				api.ProjectKey:         project,
			},
		},
		Spec: notificationv1.ReceiverSpec{
			Type:     notificationv1.GenericHMACReceiver,
			Interval: &metav1.Duration{Duration: receiverSecretInterval},
			Resources: []notificationv1.CrossNamespaceObjectReference{
				{
					Kind:      repository.Kind,
					Name:      repository.Name,
					Namespace: repository.Namespace,
				},
			},
			SecretRef: meta.LocalObjectReference{
				Name: secretName,
			},
		},
	}
}

func newWebhookReceiverSecret(repository *sourcev1.GitRepository, receiverSecret string) *applycorev1.SecretApplyConfiguration {
	return applycorev1.Secret(objectWithPrefix("receiver-"+repository.Name, maxSecretNameLength), repository.Namespace).
		WithOwnerReferences(
			applymetav1.OwnerReference().
				WithAPIVersion(repository.GroupVersionKind().GroupVersion().String()).
				WithKind(repository.Kind).
				WithName(repository.Name).
				WithUID(repository.GetUID()).
				WithBlockOwnerDeletion(true).
				WithController(true)).
		WithAnnotations(map[string]string{
			managedByAnnotationKey: managedByAnnotationValue,
		}).
		WithData(map[string][]byte{"token": []byte(receiverSecret)})
}

// objectWithPrefix generate an RFC1123 compliant object name with a pre-defined prefix.
// It implements RFC1123 in best-effort manner, meaning:
// - generated name is no longer than n characters
// - ends with an alphanumeric character. If name doesn't, then -x is appended
// Limitations:
// - it doesn't ensure uniqueness. This may be implemented later in case it really causes issues.
// - n must be at least 2
func objectWithPrefix(name string, n int) string {
	result := objectNamePrefix + name
	if len(result) > n {
		result = result[:n]
		if !isAlphanumeric(result[n-1:]) {
			result = result[:n-2] + "-x"
		}
	}
	return result
}

// getProjectPathFromRepositoryURL converts a full HTTP(S) or SSH Url into a GitLab full project path
// Flux does not support the SCP-like syntax for SSH and requires a correct SSH Url.
// See https://fluxcd.io/flux/components/source/gitrepositories/#url
func getProjectPathFromRepositoryURL(fullURL string) (string, error) {
	u, err := url.Parse(fullURL)
	if err != nil {
		return "", err
	}

	fullPath := strings.TrimLeft(u.Path, "/")
	path := strings.TrimSuffix(fullPath, ".git")
	return path, nil
}

func generateReceiverSecret(length int) (string, error) {
	receiverSecret := make([]byte, length)

	_, err := rand.Read(receiverSecret)
	if err != nil {
		return "", err
	}

	return fmt.Sprintf("%x", receiverSecret), nil
}
