package controller

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/grafana/grafana-app-sdk/logging"
	common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
	"go.opentelemetry.io/otel/attribute"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/types"
	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
	"k8s.io/apimachinery/pkg/util/wait"
	"k8s.io/apiserver/pkg/endpoints/request"
	"k8s.io/client-go/tools/cache"
	"k8s.io/client-go/util/workqueue"

	provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
	"github.com/grafana/grafana/apps/provisioning/pkg/connection"
	appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller"
	client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
	informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1"
	listers "github.com/grafana/grafana/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1"
	"github.com/grafana/grafana/apps/provisioning/pkg/quotas"
	"github.com/grafana/grafana/apps/provisioning/pkg/repository"
	"github.com/grafana/grafana/pkg/apimachinery/identity"
	"github.com/grafana/grafana/pkg/infra/tracing"
	"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
	"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
	"github.com/prometheus/client_golang/prometheus"
)

const loggerName = "provisioning-repository-controller"

const (
	maxAttempts = 3
)

type queueItem struct {
	key      string
	obj      interface{}
	attempts int
}

//go:generate mockery --name finalizerProcessor --structname MockFinalizerProcessor --inpackage --filename finalizer_mock.go --with-expecter
type finalizerProcessor interface {
	process(ctx context.Context, repo repository.Repository, finalizers []string) error
}

// RepositoryController controls how and when CRD is established.
type RepositoryController struct {
	client     client.ProvisioningV0alpha1Interface
	repoLister listers.RepositoryLister
	repoSynced cache.InformerSynced
	logger     logging.Logger

	jobs interface {
		jobs.Queue
		jobs.Store
	}
	finalizer     finalizerProcessor
	statusPatcher StatusPatcher

	repoFactory       repository.Factory
	connectionFactory connection.Factory
	healthChecker     *RepositoryHealthChecker
	quotaChecker      *RepositoryQuotaChecker
	// To allow injection for testing.
	processFn         func(item *queueItem) error
	enqueueRepository func(obj any)
	keyFunc           func(obj any) (string, error)

	queue           workqueue.TypedRateLimitingInterface[*queueItem]
	resyncInterval  time.Duration
	minSyncInterval time.Duration
	drainTimeout    time.Duration

	registry              prometheus.Registerer
	tracer                tracing.Tracer
	quotaGetter           quotas.QuotaGetter
	tokenMetrics          *repositoryTokenMetrics
	folderMetadataEnabled bool
}

// NewRepositoryController creates new RepositoryController.
func NewRepositoryController(
	provisioningClient client.ProvisioningV0alpha1Interface,
	repoInformer informer.RepositoryInformer,
	repoFactory repository.Factory,
	connectionFactory connection.Factory,
	resourceLister resources.ResourceLister,
	clients resources.ClientFactory,
	jobs interface {
		jobs.Queue
		jobs.Store
	},
	healthChecker *RepositoryHealthChecker,
	statusPatcher StatusPatcher,
	registry prometheus.Registerer,
	tracer tracing.Tracer,
	parallelOperations int,
	resyncInterval time.Duration,
	minSyncInterval time.Duration,
	drainTimeout time.Duration,
	quotaGetter quotas.QuotaGetter,
	folderMetadataEnabled bool,
) (*RepositoryController, error) {
	finalizerMetrics := registerFinalizerMetrics(registry)
	repoTokenMetrics := registerRepositoryTokenMetrics(registry)

	rc := &RepositoryController{
		client:     provisioningClient,
		repoLister: repoInformer.Lister(),
		repoSynced: repoInformer.Informer().HasSynced,
		queue: workqueue.NewTypedRateLimitingQueueWithConfig(
			workqueue.DefaultTypedControllerRateLimiter[*queueItem](),
			workqueue.TypedRateLimitingQueueConfig[*queueItem]{
				Name: "provisioningRepositoryController",
			},
		),
		repoFactory:       repoFactory,
		connectionFactory: connectionFactory,
		healthChecker:     healthChecker,
		quotaChecker:      NewRepositoryQuotaChecker(repoInformer.Lister()),
		statusPatcher:     statusPatcher,
		finalizer: &finalizer{
			lister:        resourceLister,
			clientFactory: clients,
			metrics:       &finalizerMetrics,
			maxWorkers:    parallelOperations,
		},
		jobs:                  jobs,
		logger:                logging.DefaultLogger.With("logger", loggerName),
		registry:              registry,
		tracer:                tracer,
		resyncInterval:        resyncInterval,
		minSyncInterval:       minSyncInterval,
		drainTimeout:          drainTimeout,
		quotaGetter:           quotaGetter,
		tokenMetrics:          repoTokenMetrics,
		folderMetadataEnabled: folderMetadataEnabled,
	}

	_, err := repoInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
		AddFunc: rc.enqueue,
		UpdateFunc: func(oldObj, newObj interface{}) {
			rc.enqueue(newObj)
		},
	})
	if err != nil {
		return nil, err
	}

	rc.processFn = rc.process
	rc.enqueueRepository = rc.enqueue
	rc.keyFunc = repoKeyFunc

	return rc, nil
}

func repoKeyFunc(obj any) (string, error) {
	repo, ok := obj.(*provisioning.Repository)
	if !ok {
		return "", fmt.Errorf("expected a Repository but got %T", obj)
	}
	return cache.DeletionHandlingMetaNamespaceKeyFunc(repo)
}

// Run starts the RepositoryController.
//
// The onStarted callback is invoked once all workers have been launched.
// The onShutdown callback is invoked immediately when context cancellation is
// detected, before draining in-flight work.
//
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
// until shutdown. Individual processing operations already have their own spans.
func (rc *RepositoryController) Run(ctx context.Context, workerCount int, onStarted func(), onShutdown func()) {
	defer utilruntime.HandleCrash()
	defer rc.queue.ShutDown()

	logger := rc.logger
	ctx = logging.Context(ctx, logger)
	logger.Info("Starting RepositoryController")
	defer logger.Info("Shutting down RepositoryController")

	if !cache.WaitForCacheSync(ctx.Done(), rc.repoSynced) {
		return
	}

	logger.Info("Starting workers", "count", workerCount)
	for i := 0; i < workerCount; i++ {
		go wait.UntilWithContext(ctx, rc.runWorker, time.Second)
	}

	logger.Info("Started workers")
	onStarted()

	<-ctx.Done()
	onShutdown()
	logger.Info("Shutting down workers, draining queue")

	drainDone := make(chan struct{})
	go func() {
		rc.queue.ShutDownWithDrain()
		close(drainDone)
	}()

	select {
	case <-drainDone:
		logger.Info("Queue drained successfully")
	case <-time.After(rc.drainTimeout):
		logger.Warn("Drain timeout exceeded, forcing shutdown")
		rc.queue.ShutDown()
	}
}

func (rc *RepositoryController) runWorker(ctx context.Context) {
	for rc.processNextWorkItem(ctx) {
	}
}

func (rc *RepositoryController) enqueue(obj interface{}) {
	key, err := rc.keyFunc(obj)
	if err != nil {
		utilruntime.HandleError(fmt.Errorf("couldn't get key for object: %v", err))
		return
	}

	item := queueItem{key: key, obj: obj}
	rc.queue.Add(&item)
}

// processNextWorkItem deals with one key off the queue.
// It returns false when it's time to quit.
func (rc *RepositoryController) processNextWorkItem(ctx context.Context) bool {
	item, quit := rc.queue.Get()
	if quit {
		return false
	}
	defer rc.queue.Done(item)

	namespace, name, _ := cache.SplitMetaNamespaceKey(item.key)
	logger := logging.FromContext(ctx).With("work_key", item.key, "namespace", namespace, "repository", name)
	logger.Info("RepositoryController processing key")

	err := rc.processFn(item)
	if err == nil {
		rc.queue.Forget(item)
		return true
	}

	item.attempts++
	logger = logger.With("error", err, "attempts", item.attempts)
	logger.Error("RepositoryController failed to process key")

	if item.attempts >= maxAttempts {
		logger.Error("RepositoryController failed too many times")
		rc.queue.Forget(item)
		return true
	}

	if !apierrors.IsServiceUnavailable(err) {
		logger.Info("RepositoryController will not retry")
		rc.queue.Forget(item)
		return true
	} else {
		logger.Info("RepositoryController will retry as service is unavailable")
	}

	utilruntime.HandleError(fmt.Errorf("%v failed with: %v", item, err))
	rc.queue.AddRateLimited(item)

	return true
}

func (rc *RepositoryController) handleDelete(ctx context.Context, obj *provisioning.Repository) error {
	logger := logging.FromContext(ctx)
	logger.Info("handle repository delete")

	// Process any finalizers
	if len(obj.Finalizers) > 0 {
		repo, err := rc.repoFactory.Build(ctx, obj)
		if err != nil {
			return fmt.Errorf("create repository from configuration: %w", err)
		}

		err = rc.finalizer.process(ctx, repo, obj.Finalizers)
		if err != nil {
			if statusErr := rc.updateDeleteStatus(ctx, obj, fmt.Errorf("remove finalizers: %w", err)); statusErr != nil {
				logger.Error("failed to update repository status after finalizer removal error", "error", statusErr)
			}
			return fmt.Errorf("process finalizers: %w", err)
		}

		// remove the finalizers
		_, err = rc.client.Repositories(obj.GetNamespace()).
			Patch(ctx, obj.Name, types.JSONPatchType, []byte(`[
					{ "op": "remove", "path": "/metadata/finalizers" }
				]`), v1.PatchOptions{
				FieldManager: "provisioning-controller",
			})
		if err != nil {
			return fmt.Errorf("remove finalizers: %w", err)
		}
		return nil
	} else {
		logger.Info("no finalizers to process")
	}

	return nil
}

func (rc *RepositoryController) updateDeleteStatus(ctx context.Context, obj *provisioning.Repository, err error) error {
	logger := logging.FromContext(ctx)
	logger.Info("updating repository status with deletion error", "error", err.Error())
	return rc.statusPatcher.Patch(ctx, obj, map[string]interface{}{
		"op":    "replace",
		"path":  "/status/deleteError",
		"value": err.Error(),
	})
}

func (rc *RepositoryController) shouldResync(ctx context.Context, obj *provisioning.Repository) bool {
	// don't trigger resync if a sync was never started
	if obj.Status.Sync.Finished == 0 && obj.Status.Sync.State == "" {
		return false
	}

	syncAge := time.Since(time.UnixMilli(obj.Status.Sync.Finished))
	syncInterval := time.Duration(obj.Spec.Sync.IntervalSeconds) * time.Second
	if syncInterval < rc.minSyncInterval {
		// In case the sync interval is lower than the minimum sync interval set by the system
		// we should default to the latter
		syncInterval = rc.minSyncInterval
	}
	tolerance := time.Second

	// Check for stale sync status - if sync status indicates a job is running but the job no longer exists
	// Only check if Finished is set (meaning a sync has completed before) to avoid interfering with initial syncs
	// Only trigger resync if sync is enabled and sync interval has elapsed (to avoid unnecessary operations)
	if obj.Status.Sync.Finished > 0 &&
		obj.Spec.Sync.Enabled &&
		(obj.Status.Sync.State == provisioning.JobStatePending || obj.Status.Sync.State == provisioning.JobStateWorking) &&
		obj.Status.Sync.JobID != "" {
		_, err := rc.jobs.Get(ctx, obj.Namespace, obj.Status.Sync.JobID)
		if apierrors.IsNotFound(err) {
			// Job was cleaned up but sync status wasn't updated - trigger resync to reconcile
			// Only trigger if sync interval has elapsed to avoid unnecessary operations
			if syncAge >= (syncInterval - tolerance) {
				logger := logging.FromContext(ctx)
				logger.Info("detected stale sync status", "job_id", obj.Status.Sync.JobID)
				return true
			}
		}
		// For other errors, log but continue with normal logic
		if err != nil {
			logger := logging.FromContext(ctx)
			logger.Warn("failed to check job existence for stale sync status", "error", err, "job_id", obj.Status.Sync.JobID)
		}
	}

	// HACK: how would this work in a multi-tenant world or under heavy load?
	// It will start queueing up jobs and we will have to deal with that
	pendingForTooLong := syncAge >= syncInterval/2 && obj.Status.Sync.State == provisioning.JobStatePending
	isRunning := obj.Status.Sync.State == provisioning.JobStateWorking

	return obj.Spec.Sync.Enabled && syncAge >= (syncInterval-tolerance) && !pendingForTooLong && !isRunning
}

func (rc *RepositoryController) runHooks(ctx context.Context, repo repository.Repository, obj *provisioning.Repository) ([]map[string]interface{}, error) {
	logger := logging.FromContext(ctx)
	hooks, _ := repo.(repository.Hooks)
	if hooks == nil {
		return nil, nil
	}

	if obj.Status.ObservedGeneration < 1 {
		logger.Info("handle repository create")
		patchOperations, err := hooks.OnCreate(ctx)
		if err != nil {
			return nil, fmt.Errorf("error running OnCreate: %w", err)
		}
		return patchOperations, nil
	}

	logger.Info("handle repository spec update", "Generation", obj.Generation, "ObservedGeneration", obj.Status.ObservedGeneration)
	patchOperations, err := hooks.OnUpdate(ctx)
	if err != nil {
		return nil, fmt.Errorf("error running OnUpdate: %w", err)
	}

	return patchOperations, nil
}

// isQuotaExceeded checks if the given conditions have a RepositoryQuotaExceeded one.
func isQuotaExceeded(conditions []v1.Condition) bool {
	for _, condition := range conditions {
		if condition.Type == provisioning.ConditionTypeNamespaceQuota {
			return condition.Status == v1.ConditionFalse &&
				condition.Reason == provisioning.ReasonQuotaExceeded
		}
	}
	return false
}

func (rc *RepositoryController) determineSyncStrategy(
	ctx context.Context,
	obj *provisioning.Repository,
	repo repository.Repository,
	shouldResync bool,
	isBlocked bool,
	healthStatus provisioning.HealthStatus,
) *provisioning.SyncJobOptions {
	logger := logging.FromContext(ctx)

	switch {
	case !obj.Spec.Sync.Enabled:
		logger.Info("skip sync as it's disabled")
		return nil
	case isBlocked:
		logger.Info("skip sync for repository over quota")
		return nil
	case !healthStatus.Healthy:
		logger.Info("skip sync for unhealthy repository")
		return nil
	case healthStatus.Healthy != obj.Status.Health.Healthy:
		logger.Info("repository became healthy, full resync")
		return &provisioning.SyncJobOptions{}
	case obj.Status.ObservedGeneration < 1:
		logger.Info("full sync for new repository")
		return &provisioning.SyncJobOptions{}
	case obj.Generation != obj.Status.ObservedGeneration:
		logger.Info("full sync for spec change")
		return &provisioning.SyncJobOptions{}
	case shouldResync:
		// Continue to see if we could skip for other reasons
		versioned, ok := repo.(repository.Versioned)
		// If the repository is not versioned, we don't have a way to check for incremental updates
		if !ok {
			logger.Info("full sync on interval for non-versioned repository")
			return &provisioning.SyncJobOptions{}
		}
		latestRef, err := versioned.LatestRef(ctx)
		if err != nil {
			logger.Warn("incremental sync on interval without knowing if ref has actually changed", "error", err)
			return &provisioning.SyncJobOptions{Incremental: true}
		}

		// Only resync if the latest ref is different from the last synced ref
		if latestRef == obj.Status.Sync.LastRef {
			logger.Info("skip incremental sync as reference is the same")
			return nil
		}

		// Whenever possible, we try to keep it as an incremental sync to keep things performant.
		// However, if there are any .keep file deletions inside a folder with no other deletions, we need
		// to do a full sync to see if the folder was deleted as well in git.
		incremental, err := shouldUseIncrementalSync(ctx, versioned, obj, latestRef, rc.folderMetadataEnabled)
		if err != nil {
			logger.Warn("unable to compare files for incremental sync, doing full sync", "error", err)
			return &provisioning.SyncJobOptions{}
		}

		logger.Info("sync on interval", "incremental", incremental)
		return &provisioning.SyncJobOptions{Incremental: incremental}
	default:
		return nil
	}
}

func shouldUseIncrementalSync(ctx context.Context, versioned repository.Versioned, obj *provisioning.Repository, latestRef string, folderMetadataEnabled bool) (bool, error) {
	changes, err := versioned.CompareFiles(ctx, obj.Status.Sync.LastRef, latestRef)
	if err != nil {
		return false, err
	}
	var deletedPaths []string
	for _, change := range changes {
		if change.Action == repository.FileActionDeleted {
			deletedPaths = append(deletedPaths, change.Path)
		}
	}

	return repository.CanUseIncrementalSync(deletedPaths, folderMetadataEnabled), nil
}

func (rc *RepositoryController) addSyncJob(ctx context.Context, obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions) error {
	ctx, span := rc.tracer.Start(ctx, "provisioning.controller.add_sync_job")
	defer span.End()

	span.SetAttributes(
		attribute.String("repository", obj.GetName()),
		attribute.String("namespace", obj.Namespace),
		attribute.Bool("incremental", syncOptions != nil && syncOptions.Incremental),
	)

	job, err := rc.jobs.Insert(ctx, obj.Namespace, provisioning.JobSpec{
		Repository: obj.GetName(),
		Action:     provisioning.JobActionPull,
		Pull:       syncOptions,
	})
	if apierrors.IsAlreadyExists(err) {
		logging.FromContext(ctx).Info("sync job already exists")
		return nil
	}
	if err != nil {
		span.RecordError(err)
		// FIXME: should we update the status of the repository if we fail to add the job?
		return fmt.Errorf("error adding sync job: %w", err)
	}

	span.SetAttributes(attribute.String("job.name", job.Name))
	return nil
}

func (rc *RepositoryController) determineSyncStatusOps(obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions, healthStatus provisioning.HealthStatus) []map[string]interface{} {
	const unhealthyMessage = "Repository is unhealthy"

	hasUnhealthyMessage := len(obj.Status.Sync.Message) > 0 && obj.Status.Sync.Message[0] == unhealthyMessage
	var patchOperations []map[string]interface{}

	switch {
	case syncOptions != nil:
		// We will try to trigger a new sync job if we have sync options
		patchOperations = append(patchOperations, map[string]interface{}{
			"op":    "replace",
			"path":  "/status/sync/state",
			"value": provisioning.JobStatePending,
		})
		patchOperations = append(patchOperations, map[string]interface{}{
			"op":    "replace",
			"path":  "/status/sync/started",
			"value": int64(0),
		})
	case healthStatus.Healthy && hasUnhealthyMessage: // if the repository is healthy and the message is set, clear it
		// FIXME: is this the clearest way to do this? Should we introduce another status or way of way of handling more
		// specific errors?
		patchOperations = append(patchOperations, map[string]interface{}{
			"op":    "replace",
			"path":  "/status/sync/message",
			"value": []string{},
		})
	case !healthStatus.Healthy && !hasUnhealthyMessage: // if the repository is unhealthy and the message is not already set, set it
		patchOperations = append(patchOperations, map[string]interface{}{
			"op":    "replace",
			"path":  "/status/sync/state",
			"value": provisioning.JobStateError,
		})
		patchOperations = append(patchOperations, map[string]interface{}{
			"op":    "replace",
			"path":  "/status/sync/message",
			"value": []string{unhealthyMessage},
		})
	}

	return patchOperations
}

//nolint:gocyclo
func (rc *RepositoryController) process(item *queueItem) error {
	logger := rc.logger.With("key", item.key)
	ctx := logging.Context(context.Background(), logger)

	namespace, name, err := cache.SplitMetaNamespaceKey(item.key)
	if err != nil {
		return err
	}

	obj, err := rc.repoLister.Repositories(namespace).Get(name)
	switch {
	case apierrors.IsNotFound(err):
		return errors.New("repository not found in cache")
	case err != nil:
		return err
	}

	logger = logger.With(
		"namespace", namespace,
		"repository", name,
		"repositoryType", string(obj.Spec.Type),
		"connection", obj.ConnectionName(),
	)
	ctx = logging.Context(ctx, logger)

	ctx, _, err = identity.WithProvisioningIdentity(ctx, namespace)
	if err != nil {
		return err
	}
	ctx = request.WithNamespace(ctx, namespace)
	logger = logger.WithContext(ctx)

	if obj.DeletionTimestamp != nil {
		return rc.handleDelete(ctx, obj)
	}

	// Skip reconciliation for resources whose namespace is being soft-deleted.
	if appcontroller.IsPendingDelete(obj.Labels) {
		logger.Info("skipping reconciliation: namespace is pending deletion")
		return nil
	}

	// Check quota state early - before trigger evaluation
	// This allows blocked repos to check if they can unblock even without other triggers
	newQuota, err := rc.quotaGetter.GetQuotaStatus(ctx, namespace)
	if err != nil {
		return fmt.Errorf("failed to get quota status: %w", err)
	}
	quotaCondition, err := rc.quotaChecker.RepositoryQuotaConditions(ctx, namespace, newQuota)
	if err != nil {
		return fmt.Errorf("check repository quota: %w", err)
	}
	isCurrentlyBlocked := isQuotaExceeded(obj.Status.Conditions)
	isOverQuota := isQuotaExceeded([]v1.Condition{quotaCondition})

	// Blocked repos MUST process to check if they can unblock
	forceProcessForUnblock := isCurrentlyBlocked && !isOverQuota

	shouldResync := rc.shouldResync(ctx, obj)
	shouldCheckHealth := rc.healthChecker.ShouldCheckHealth(obj)
	hasSpecChanged := obj.Generation != obj.Status.ObservedGeneration
	var patchOperations []map[string]interface{}

	hasQuotaChanged := obj.Status.Quota.MaxRepositories != newQuota.MaxRepositories ||
		obj.Status.Quota.MaxResourcesPerRepository != newQuota.MaxResourcesPerRepository

	var shouldGenerateToken bool
	if obj.Spec.Connection != nil && obj.Spec.Connection.Name != "" {
		shouldGenerateToken = rc.shouldGenerateTokenFromConnection(obj)
	}

	// Determine the main triggering condition
	switch {
	// First, we check if the repository is blocked
	case isCurrentlyBlocked && isOverQuota:
		logger.Info("repository blocked and over quota, reconciling but skipping sync")
	case !isCurrentlyBlocked && isOverQuota:
		logger.Info("namespace over quota, blocking repository", "max_repositories", newQuota.MaxRepositories)
	case hasSpecChanged:
		logger.Info("spec changed", "Generation", obj.Generation, "ObservedGeneration", obj.Status.ObservedGeneration)
	case shouldResync:
		logger.Info("sync interval triggered", "sync_interval", time.Duration(obj.Spec.Sync.IntervalSeconds)*time.Second, "sync_status", obj.Status.Sync)
	case shouldCheckHealth:
		logger.Info("health is stale", "health_status", obj.Status.Health.Healthy)
	case forceProcessForUnblock:
		logger.Info("repository was blocked but now within quota, processing to unblock")
	case shouldGenerateToken:
		logger.Info("repository token needs to be generated", "connection", obj.Spec.Connection.Name)
	case hasQuotaChanged:
		logger.Info("quota changed", "quota", newQuota)
	default:
		logger.Info("skipping as conditions are not met", "status", obj.Status, "generation", obj.Generation, "sync_spec", obj.Spec.Sync)
		return nil
	}

	// In any case - repo blocked, repo unblocked, or simply spec has changed - we need to
	// update the observedGeneration to alight with the given metadata.generation.
	if hasSpecChanged {
		patchOperations = append(patchOperations, map[string]interface{}{
			"op":    "replace",
			"path":  "/status/observedGeneration",
			"value": obj.Generation,
		})
	}

	// Set quota information from configuration (only if changed)
	if hasQuotaChanged {
		patchOperations = append(patchOperations, map[string]interface{}{
			"op":    "replace",
			"path":  "/status/quota",
			"value": newQuota,
		})
	}

	if shouldGenerateToken {
		logger.Info("updating token for repository")

		c, err := rc.client.Connections(obj.Namespace).Get(ctx, obj.Spec.Connection.Name, v1.GetOptions{})
		if err != nil {
			logger.Error("retrieving connection", "error", err)
			return err
		}

		token, tokenOps, err := rc.generateRepositoryToken(ctx, obj, c)
		if err != nil {
			logger.Error("generating token for repository", "error", err)
			return err
		}

		if len(tokenOps) > 0 {
			patchOperations = append(patchOperations, tokenOps...)
		}

		obj.Secure.Token.Create = token
	}

	repo, err := rc.repoFactory.Build(ctx, obj)
	if err != nil {
		return fmt.Errorf("unable to create repository from configuration: %w", err)
	}

	// Handle hooks - may return early if hooks fail
	hookOps, shouldContinue, err := rc.processHooks(ctx, repo, obj)
	if err != nil {
		return fmt.Errorf("process hooks: %w", err)
	}
	if !shouldContinue {
		return nil // Hook handling already updated status and returned early
	}
	if len(hookOps) > 0 {
		patchOperations = append(patchOperations, hookOps...)
	}

	// If branch is empty, fetch and set the default branch before running health check
	if branchHandler, ok := repo.(repository.BranchHandler); ok {
		if branchHandler.GetCurrentBranch() == "" {
			logger.Info("given repository branch is empty, getting default branch")

			defaultBranch, err := branchHandler.GetDefaultBranch(ctx)
			if err != nil {
				return fmt.Errorf("failed to get default branch: %w", err)
			}

			branchHandler.SetBranch(defaultBranch)

			patchOperations = append(patchOperations, map[string]interface{}{
				"op":    "replace",
				"path":  fmt.Sprintf("/spec/%s/branch", repo.Config().Spec.Type),
				"value": defaultBranch,
			})
		}
	}

	// Handle health checks using the health checker
	healthResult, err := rc.healthChecker.RefreshHealthWithPatchOps(ctx, repo)
	if err != nil {
		return fmt.Errorf("update health status: %w", err)
	}
	testResults := healthResult.TestResults
	healthStatus := healthResult.HealthStatus

	// If over quota, override health to unhealthy.
	if isOverQuota {
		healthStatus = provisioning.HealthStatus{
			Healthy: false,
			Error:   provisioning.HealthFailureHealth,
			Checked: time.Now().UnixMilli(),
			Message: []string{quotaCondition.Message},
		}
		patchOperations = append(patchOperations, map[string]interface{}{
			"op":    "replace",
			"path":  "/status/health",
			"value": healthStatus,
		})
	} else if len(healthResult.PatchOps) > 0 {
		patchOperations = append(patchOperations, healthResult.PatchOps...)
	}

	// Build ALL condition patches together to avoid one overwriting another.
	if conditionPatchOps := BuildConditionPatchOpsFromExisting(
		obj.Status.Conditions, obj.GetGeneration(), quotaCondition, healthResult.ReadyCondition,
	); conditionPatchOps != nil {
		patchOperations = append(patchOperations, conditionPatchOps...)
	}

	// Update fieldErrors from test results - always update to ensure fieldErrors are cleared when there are no errors
	if testResults != nil {
		fieldErrors := testResults.Errors
		if fieldErrors == nil {
			fieldErrors = []provisioning.ErrorDetails{}
		}
		patchOperations = append(patchOperations, map[string]interface{}{
			"op":    "replace",
			"path":  "/status/fieldErrors",
			"value": fieldErrors,
		})
	}

	// determine the sync strategy and sync status to apply
	syncOptions := rc.determineSyncStrategy(ctx, obj, repo, shouldResync, isOverQuota, healthStatus)
	patchOperations = append(patchOperations, rc.determineSyncStatusOps(obj, syncOptions, healthStatus)...)

	// Apply all patch operations
	if len(patchOperations) > 0 {
		err := rc.statusPatcher.Patch(ctx, obj, patchOperations...)
		if err != nil {
			return fmt.Errorf("status patch operations failed: %w", err)
		}
	}

	// QUESTION: should we trigger the sync job after we have applied all patch operations or before?
	// Is there are risk of race condition here?
	// Trigger sync job after we have applied all patch operations
	if syncOptions != nil {
		if err := rc.addSyncJob(ctx, obj, syncOptions); err != nil {
			return err
		}
	}

	return nil
}

// processHooks handles hook execution with intelligent retry logic
// Returns hook operations, whether processing should continue, and any error
func (rc *RepositoryController) processHooks(ctx context.Context, repo repository.Repository, obj *provisioning.Repository) ([]map[string]interface{}, bool, error) {
	shouldRunHooks := obj.Generation != obj.Status.ObservedGeneration

	// Skip hooks if status already indicates recent hook failure to avoid infinite retry
	if shouldRunHooks && rc.healthChecker.HasRecentFailure(obj.Status.Health, provisioning.HealthFailureHook) {
		shouldRunHooks = false
	}

	if !shouldRunHooks {
		return nil, true, nil
	}

	hookOps, err := rc.runHooks(ctx, repo, obj)
	if err != nil {
		if err := rc.healthChecker.RecordFailure(ctx, provisioning.HealthFailureHook, err, obj); err != nil {
			return nil, false, fmt.Errorf("update status after hook failure: %w", err)
		}

		return nil, false, err
	}

	return hookOps, true, nil
}

// HACK: we need a proper way of doing this check by adding Conditions
// We're going to work on this in https://github.com/grafana/git-ui-sync-project/issues/744.
func (rc *RepositoryController) shouldGenerateTokenFromConnection(
	obj *provisioning.Repository,
) bool {
	// We should generate a token from the connection when
	// - The token has never been generated, i.e. a new Repository is being added
	// or
	// - The token has not been recently created, and
	// - The token will expire before the next resync interval
	if obj.Secure.Token.IsZero() {
		rc.tokenMetrics.recordRefreshReason(refreshReasonMissing)
		return true
	}

	expiration := time.UnixMilli(obj.Status.Token.Expiration)
	rc.tokenMetrics.recordTimeToExpiry(time.Until(expiration).Seconds())

	recentlyCreated := tokenRecentlyCreated(time.UnixMilli(obj.Status.Token.LastUpdated))
	if !recentlyCreated && shouldRefreshBeforeExpiration(expiration, rc.resyncInterval) {
		rc.tokenMetrics.recordRefreshReason(refreshReasonExpiring)
		return true
	}

	return false
}

func (rc *RepositoryController) generateRepositoryToken(
	ctx context.Context,
	obj *provisioning.Repository,
	c *provisioning.Connection,
) (_ common.RawSecureValue, _ []map[string]any, err error) {
	start := time.Now()
	defer func() {
		elapsed := time.Since(start).Seconds()
		if err != nil {
			rc.tokenMetrics.recordGenerationError()
		} else {
			rc.tokenMetrics.recordGeneration(elapsed)
		}
	}()

	conn, err := rc.connectionFactory.Build(ctx, c)
	if err != nil {
		return "", nil, fmt.Errorf("unable to create connection from configuration: %w", err)
	}

	token, err := conn.GenerateRepositoryToken(ctx, obj)
	if err != nil {
		return "", nil, fmt.Errorf("unable to create token for repository: %w", err)
	}

	patchOperations := []map[string]any{
		{
			"op":   "replace",
			"path": "/status/token",
			"value": provisioning.TokenStatus{
				LastUpdated: time.Now().UnixMilli(),
				Expiration:  token.ExpiresAt.UnixMilli(),
			},
		},
	}

	// HACK - here we need to do different things based on the status of repository
	// https://github.com/grafana/git-ui-sync-project/issues/745 to have a proper fix on it
	switch {
	case obj.Secure.IsZero():
		patchOperations = append(patchOperations, map[string]any{
			"op":   "add",
			"path": "/secure",
			"value": map[string]any{
				"token": map[string]string{
					"create": string(token.Token),
				},
			},
		})
	case obj.Secure.Token.IsZero():
		patchOperations = append(patchOperations, map[string]any{
			"op":   "add",
			"path": "/secure/token",
			"value": map[string]string{
				"create": string(token.Token),
			},
		})
	default:
		patchOperations = append(patchOperations, map[string]any{
			"op":   "replace",
			"path": "/secure/token",
			"value": map[string]string{
				"create": string(token.Token),
			},
		})
	}

	return token.Token, patchOperations, nil
}
