/*
* Copyright (c) 2026 Broadcom. All rights reserved.
* The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries.
* All trademarks, trade names, service marks, and logos referenced
* herein belong to their respective companies.
*
* This software and all information contained therein is confidential
* and proprietary and shall not be duplicated, used, disclosed or
* disseminated in any way except as authorized by the applicable
* license agreement, without the express written permission of Broadcom.
* All authorized reproductions must be marked with this language.
*
* EXCEPT AS SET FORTH IN THE APPLICABLE LICENSE AGREEMENT, TO THE
* EXTENT PERMITTED BY APPLICABLE LAW OR AS AGREED BY BROADCOM IN ITS
* APPLICABLE LICENSE AGREEMENT, BROADCOM PROVIDES THIS DOCUMENTATION
* "AS IS" WITHOUT WARRANTY OF ANY KIND, INCLUDING WITHOUT LIMITATION,
* ANY IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
* PURPOSE, OR. NONINFRINGEMENT. IN NO EVENT WILL BROADCOM BE LIABLE TO
* THE END USER OR ANY THIRD PARTY FOR ANY LOSS OR DAMAGE, DIRECT OR
* INDIRECT, FROM THE USE OF THIS DOCUMENTATION, INCLUDING WITHOUT LIMITATION,
* LOST PROFITS, LOST INVESTMENT, BUSINESS INTERRUPTION, GOODWILL, OR
* LOST DATA, EVEN IF BROADCOM IS EXPRESSLY ADVISED IN ADVANCE OF THE
* POSSIBILITY OF SUCH LOSS OR DAMAGE.
*
* AI assistance has been used to generate some or all contents of this file. That includes, but is not limited to, new code, modifying existing code, stylistic edits.
 */
package reconcile

import (
	"context"
	"encoding/json"
	"net/url"
	"os"
	"reflect"
	"strings"
	"time"

	securityv1 "github.com/caapim/layer7-operator/api/v1"
	securityv1alpha1 "github.com/caapim/layer7-operator/api/v1alpha1"
	"github.com/caapim/layer7-operator/internal/graphman"
	"github.com/caapim/layer7-operator/pkg/util"
	"github.com/go-git/go-git/v5"
	"github.com/go-logr/logr"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/metric"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/types"
	"sigs.k8s.io/controller-runtime/pkg/client"
)

func syncRepository(ctx context.Context, params Params) error {
	repository, err := getRepository(ctx, params)
	var commit string
	var username string
	var token string
	var repositorySecret *corev1.Secret
	var sshKey []byte
	var sshKeyPass string
	var knownHosts []byte
	var statestore securityv1alpha1.L7StateStore
	stateStoreSynced := true

	authType := repository.Spec.Auth.Type
	if err != nil {
		params.Log.Info("repository unavailable", "name", params.Instance.Name, "namespace", params.Instance.Namespace, "error", err.Error())
		_ = s.RemoveByTag(params.Instance.Name + "-" + params.Instance.Namespace + "-sync-repository")
		return nil
	}

	params.Instance = &repository

	// repoStatus := repository.Status
	if !repository.Spec.Enabled {
		return nil
	}
	start := time.Now()

	if repository.Spec.Auth != (securityv1.RepositoryAuth{}) && repository.Spec.Auth.Type != securityv1.RepositoryAuthTypeNone {

		repositorySecret, err = getRepositorySecret(ctx, repository, params)

		if err != nil {
			params.Log.Info("secret unavailable", "name", repository.Name, "namespace", repository.Namespace, "error", err.Error())
			return nil
		}

		token = string(repositorySecret.Data["TOKEN"])
		if token == "" {
			token = string(repositorySecret.Data["PASSWORD"])
		}

		username = string(repositorySecret.Data["USERNAME"])
		sshKey = repositorySecret.Data["SSH_KEY"]
		sshKeyPass = string(repositorySecret.Data["SSH_KEY_PASS"])
		knownHosts = repositorySecret.Data["KNOWN_HOSTS"]
	}

	ext := repository.Spec.Branch
	if ext == "" {
		ext = repository.Spec.Tag
	}

	storageSecretName := repository.Name + "-repository-" + ext

	requestCacheEntry := repository.Namespace + "/" + repository.Name
	syncRequest, _ := syncCache.Read(requestCacheEntry)

	backoffRequestCacheEntry := repository.Namespace + "/" + repository.Name + "-backoff"
	backoffSyncRequest, _ := syncCache.Read(backoffRequestCacheEntry)

	if backoffSyncRequest.Attempts > 4 {
		params.Log.Info("several attempts to sync this repository have failed, please check your configuration", "name", repository.Name, "namespace", repository.Namespace)
		return nil
	}

	if syncRequest.Attempts > 2 {
		params.Log.V(2).Info("request has failed more than 3 times in the last 30 seconds", "name", repository.Name, "namespace", repository.Namespace)
		return nil
	}

	if repository.Spec.StateStoreReference != "" {
		storageSecretName = "_"
		statestore, err = getStateStore(ctx, params)
		if err != nil {
			params.Log.V(2).Error(err, "failed to retrieve statestore", "namespace", params.Instance.Namespace, "name", params.Instance.Name)
			return nil
		}
	}

	patch := []byte(`[{"op": "replace", "path": "/status/ready", "value": false}]`)
	switch strings.ToLower(string(repository.Spec.Type)) {
	case "http":
		forceUpdate := false
		if repository.Status.Summary != repository.Spec.Endpoint {
			forceUpdate = true
		}
		commit, err = util.DownloadArtifact(repository.Spec.Endpoint, username, token, repository.Name, forceUpdate, repository.Namespace, repository.Spec.InsecureSkipVerify)
		if err != nil {
			if err == util.ErrInvalidFileFormatError || err == util.ErrInvalidTarArchive || err == util.ErrInvalidZipArchive {
				params.Log.Info(err.Error(), "name", repository.Name, "namespace", repository.Namespace)
				backoffAttempts := 5
				syncCache.Update(util.SyncRequest{RequestName: backoffRequestCacheEntry, Attempts: backoffAttempts}, time.Now().Add(360*time.Second).Unix())
				err = setRepoReady(ctx, params, patch)
				if err != nil {
					params.Log.V(2).Error(err, "failed to patch repository status", "namespace", params.Instance.Namespace, "name", params.Instance.Name)
				}
				_ = captureRepositorySyncMetrics(ctx, params, start, commit, true)
				return nil
			}
			params.Log.Info(err.Error(), "name", repository.Name, "namespace", repository.Namespace)

			// If a state store reference is configured and the local cache is missing,
			// rebuild it from the state store so gateways can continue operating with
			// the last known good content while the HTTP endpoint is unavailable.
			if repository.Spec.StateStoreReference != "" {
				cacheDir := util.StateStoreCacheDir(repository.Name, repository.Namespace)
				if _, statErr := os.Stat(cacheDir + "/latest.json"); os.IsNotExist(statErr) {
					if rebuildErr := RebuildCacheFromStateStore(ctx, params, statestore); rebuildErr != nil {
						params.Log.V(2).Info("http unavailable; state store cache rebuild failed",
							"name", repository.Name, "namespace", repository.Namespace, "error", rebuildErr.Error())
					} else {
						params.Log.Info("http unavailable; rebuilt local cache from state store",
							"name", repository.Name, "namespace", repository.Namespace)
					}
				}
			}

			attempts := syncRequest.Attempts + 1
			backoffAttempts := backoffSyncRequest.Attempts + 1
			syncCache.Update(util.SyncRequest{RequestName: backoffRequestCacheEntry, Attempts: backoffAttempts}, time.Now().Add(360*time.Second).Unix())
			syncCache.Update(util.SyncRequest{RequestName: requestCacheEntry, Attempts: attempts}, time.Now().Add(30*time.Second).Unix())
			err = setRepoReady(ctx, params, patch)
			if err != nil {
				params.Log.V(2).Error(err, "failed to patch repository status", "namespace", params.Instance.Namespace, "name", params.Instance.Name)
			}
			_ = captureRepositorySyncMetrics(ctx, params, start, commit, true)
			return nil
		}
		fileURL, _ := url.Parse(repository.Spec.Endpoint)
		path := fileURL.Path
		segments := strings.Split(path, "/")
		fileName := segments[len(segments)-1]
		ext := strings.Split(fileName, ".")[len(strings.Split(fileName, "."))-1]
		folderName := strings.ReplaceAll(fileName, "."+ext, "")
		if ext == "gz" && strings.Split(fileName, ".")[len(strings.Split(fileName, "."))-2] == "tar" {
			folderName = strings.ReplaceAll(fileName, ".tar.gz", "")
		}
		storageSecretName = repository.Name + "-repository-" + folderName
		if params.Instance.Status.Commit == commit {
			params.Log.V(5).Info("already up-to-date", "name", repository.Name, "namespace", repository.Namespace)
			idleStateStoreSynced := true
			if repository.Spec.StateStoreReference != "" {
				idleStateStoreSynced = repository.Status.StateStoreSynced
			}
			ensureReadyOnIdleSync(ctx, params, start, commit, storageSecretName, idleStateStoreSynced)
			return nil
		}
	case "git":
		commit, err = util.CloneRepository(repository.Spec.Endpoint, username, token, sshKey, sshKeyPass, repository.Spec.Branch, repository.Spec.Tag, repository.Spec.RemoteName, repository.Name, repository.Spec.Auth.Vendor, string(authType), knownHosts, repository.Namespace, repository.Spec.InsecureSkipVerify)
		if err == git.NoErrAlreadyUpToDate || err == git.ErrRemoteExists {
			params.Log.V(5).Info(err.Error(), "name", repository.Name, "namespace", repository.Namespace)

			// Check if we need to build cache/storage secret (only if status doesn't match)
			if params.Instance.Status.Commit == commit && (params.Instance.Status.StorageSecretName != "" && params.Instance.Status.StorageSecretName != "_") {
				params.Log.V(5).Info("already up-to-date with cache built", "name", repository.Name, "namespace", repository.Namespace)
				ensureReadyOnIdleSync(ctx, params, start, commit, params.Instance.Status.StorageSecretName, true)
				return nil
			}

			// Continue to build cache and storage secret if status doesn't match
			err = nil
		}

		if err != nil {
			params.Log.Info("repository error", "name", repository.Name, "namespace", repository.Namespace, "error", err.Error())

			// If a state store reference is configured and the local cache is missing,
			// rebuild it from the state store so gateways can continue operating with
			// the last known good content while git is unavailable.
			if repository.Spec.StateStoreReference != "" {
				cacheDir := util.StateStoreCacheDir(repository.Name, repository.Namespace)
				if _, statErr := os.Stat(cacheDir + "/latest.json"); os.IsNotExist(statErr) {
					if rebuildErr := RebuildCacheFromStateStore(ctx, params, statestore); rebuildErr != nil {
						params.Log.V(2).Info("git unavailable; state store cache rebuild failed",
							"name", repository.Name, "namespace", repository.Namespace, "error", rebuildErr.Error())
					} else {
						params.Log.Info("git unavailable; rebuilt local cache from state store",
							"name", repository.Name, "namespace", repository.Namespace)
					}
				}
			}

			attempts := syncRequest.Attempts + 1
			syncCache.Update(util.SyncRequest{RequestName: requestCacheEntry, Attempts: attempts}, time.Now().Add(30*time.Second).Unix())
			err = setRepoReady(ctx, params, patch)
			if err != nil {
				params.Log.V(2).Error(err, "failed to patch repository status", "namespace", params.Instance.Namespace, "name", params.Instance.Name)
			}
			_ = captureRepositorySyncMetrics(ctx, params, start, commit, true)
			return nil
		}
	case "statestore":
		storageSecretName = "_"
		commit, err = GetStateStoreChecksum(ctx, params, statestore)
		if err != nil {
			params.Log.Info("repository error", "name", repository.Name, "namespace", repository.Namespace, "error", err.Error())
			attempts := syncRequest.Attempts + 1
			syncCache.Update(util.SyncRequest{RequestName: requestCacheEntry, Attempts: attempts}, time.Now().Add(30*time.Second).Unix())
			err = setRepoReady(ctx, params, patch)
			if err != nil {
				params.Log.V(2).Error(err, "failed to patch repository status", "namespace", params.Instance.Namespace, "name", params.Instance.Name)
			}
			_ = captureRepositorySyncMetrics(ctx, params, start, commit, true)
			return nil
		}

		if params.Instance.Status.Commit == commit {
			if repository.Spec.StateStoreReference != "" {
				cacheDir := util.StateStoreCacheDir(repository.Name, repository.Namespace)
				if _, statErr := os.Stat(cacheDir + "/latest.json"); os.IsNotExist(statErr) {
					if cacheErr := BuildStateStoreRepositoryCache(ctx, params, statestore, commit); cacheErr != nil {
						params.Log.V(2).Info("failed to materialize statestore disk cache", "name", repository.Name+"-repository", "namespace", repository.Namespace, "error", cacheErr.Error())
					}
				}
			}
			params.Log.V(5).Info("already up-to-date", "name", repository.Name, "namespace", repository.Namespace)
			ensureReadyOnIdleSync(ctx, params, start, commit, "_", true)
			return nil
		}
	case "local":
		return nil
	default:
		params.Log.Info("repository type not set or not supported", "name", repository.Name, "namespace", repository.Namespace)
		return nil
	}

	if strings.ToLower(string(repository.Spec.Type)) != "statestore" {

		// Build cache before StateStorage so bundle failures do not advance Redis/commit tracking.
		// With StateStoreReference, status uses storageSecretName "_" but cache still uses localRepoStorageInfo's secret name.
		cacheSecretName, _, _, cacheInfoErr := localRepoStorageInfo(params)
		if cacheInfoErr != nil {
			params.Log.V(2).Info("could not resolve name for repository cache build", "name", repository.Name, "namespace", repository.Namespace, "error", cacheInfoErr.Error())
		}
		if cacheInfoErr == nil && cacheSecretName != "" {
			bundleMap, buildCacheErr := BuildRepositoryCache(ctx, params, commit, cacheSecretName)
			if buildCacheErr != nil {
				// Log full error (includes graphman entity paths from readBundle / loose JSON) for operators.
				params.Log.Error(buildCacheErr, "failed to build repository cache", "name", repository.Name, "namespace", repository.Namespace)
				err = setRepoReady(ctx, params, patch)
				if err != nil {
					params.Log.V(2).Error(err, "failed to patch repository status", "namespace", params.Instance.Namespace, "name", params.Instance.Name)
				}
				return nil
			}

			// Create storage secret from bundleMap only when status tracks a real secret (not "_" for state-store-only repos).
			// Skip when status already has "_" (too large for secret / state-store-only): avoids rebuilding and re-logging every reconcile.
			// Filter out delta bundles - we don't use them in Gateway yet and they take up space
			if storageSecretName != "_" && storageSecretName != "" && repository.Status.StorageSecretName != "_" {
				storageSecretBundleMap := make(map[string][]byte)
				for k, v := range bundleMap {
					if !strings.HasSuffix(k, "-delta.gz") {
						storageSecretBundleMap[k] = v
					}
				}

				err = StorageSecretFromBundleMap(ctx, params, storageSecretBundleMap, storageSecretName)
				if err != nil {
					params.Log.V(2).Info("failed to reconcile storage secret", "name", repository.Name+"-repository", "namespace", repository.Namespace, "error", err.Error())
					storageSecretName = ""
					switch err.Error() {
					case "exceededMaxSize", "file size exceeded", "combined file size exceeded":
						storageSecretName = "_"
					}
				} else if len(storageSecretBundleMap) == 0 && repository.Spec.StateStoreReference == "" {
					// Empty git/http clone: no bundle artifacts and no storage secret created — omit from gateway bootstrap (not "_" large/statestore paths).
					// (buildCacheErr is always nil here — we return above on build failure.)
					switch strings.ToLower(string(repository.Spec.Type)) {
					case "git", "http":
						storageSecretName = ""
					}
				}
			}
		}

		if repository.Spec.StateStoreReference != "" {
			// For state store repos, sync to Redis
			err = StateStorage(ctx, params, statestore, commit)
			if err != nil {
				params.Log.V(2).Info("failed to reconcile state storage", "name", repository.Name+"-repository", "namespace", repository.Namespace, "error", err.Error())
				params.Instance.Status.StateStoreSynced = false
			}
		}
	}

	if repository.Spec.StateStoreReference != "" && (!repository.Status.StateStoreSynced || commit != repository.Status.Commit) {
		if repository.Spec.Type == securityv1.RepositoryTypeStateStore {
			err = BuildStateStoreRepositoryCache(ctx, params, statestore, commit)
		} else {
			err = StateStorage(ctx, params, statestore, commit)
		}
		if err != nil {
			params.Log.V(2).Info("failed to reconcile state storage", "name", repository.Name+"-repository", "namespace", repository.Namespace, "error", err.Error())
			stateStoreSynced = false
		}
	}

	prevCommit := params.Instance.Status.Commit

	err = updateStatus(ctx, params, commit, storageSecretName, stateStoreSynced)
	if err != nil {
		_ = captureRepositorySyncMetrics(ctx, params, start, commit, true)
		params.Log.Info("failed to update repository status", "namespace", repository.Namespace, "name", repository.Name, "error", err.Error())
		return nil
	}

	_ = captureRepositorySyncMetrics(ctx, params, start, commit, false)

	insecure := repository.Spec.InsecureSkipVerify || strings.Contains(strings.ToLower(repository.Spec.Auth.Vendor), "insecure")
	if commit != prevCommit || prevCommit == "" {
		if insecure {
			params.Log.Info("TLS certificate validation is disabled for this repository", "name", repository.Name, "namespace", repository.Namespace)
		}
		params.Log.Info("reconciled", "name", repository.Name, "namespace", repository.Namespace, "commit", commit)
	}
	return nil
}

func getRepositorySecret(ctx context.Context, repository securityv1.Repository, params Params) (*corev1.Secret, error) {
	repositorySecret := &corev1.Secret{}
	name := repository.Name

	if repository.Spec.Auth.ExistingSecretName != "" {
		name = repository.Spec.Auth.ExistingSecretName
	}

	err := params.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: repository.Namespace}, repositorySecret)
	if err != nil {
		return repositorySecret, err
	}
	return repositorySecret, nil
}

func getRepository(ctx context.Context, params Params) (securityv1.Repository, error) {
	repository := securityv1.Repository{}

	err := params.Client.Get(ctx, types.NamespacedName{Name: params.Instance.Name, Namespace: params.Instance.Namespace}, &repository)
	if err != nil {
		return repository, err
	}
	return repository, nil
}

func updateStatus(ctx context.Context, params Params, commit string, storageSecretName string, stateStoreSynced bool) (err error) {

	if params.Instance.Status.StorageSecretName == "_" {
		storageSecretName = "_"
	}

	rs := params.Instance.Status
	r := params.Instance

	rs.Commit = commit
	rs.Name = r.Name
	rs.Vendor = r.Spec.Auth.Vendor
	rs.Ready = true
	rs.StateStoreSynced = stateStoreSynced

	if r.Spec.Type == "http" {
		rs.Summary = r.Spec.Endpoint
	}

	rs.StorageSecretName = storageSecretName

	if !reflect.DeepEqual(rs, r.Status) || r.Status.StateStoreSynced != rs.StateStoreSynced {
		params.Log.Info("syncing repository", "name", r.Name, "namespace", r.Namespace)
		rs.Updated = time.Now().String()
		r.Status = rs
		err = params.Client.Status().Update(ctx, r)
		if err != nil {
			return err
		}
	}
	return nil
}

// ensureReadyOnIdleSync calls updateStatus when sync exits early with an up-to-date commit but
// status.Ready is still false from an earlier failure. Uses the same metrics and reconciled log as the main success path.
func ensureReadyOnIdleSync(ctx context.Context, params Params, start time.Time, commit, storageSecretName string, stateStoreSynced bool) {
	if params.Instance.Status.Ready {
		return
	}
	repositoryType := strings.ToLower(string(params.Instance.Spec.Type))
	cacheSecretName := storageSecretName
	if (cacheSecretName == "_" || cacheSecretName == "") && repositoryType != "statestore" {
		if localStorageSecretName, _, _, err := localRepoStorageInfo(params); err == nil && localStorageSecretName != "" {
			cacheSecretName = localStorageSecretName
		}
	}
	if repositoryType != "statestore" && cacheSecretName != "" && cacheSecretName != "_" {
		if _, err := BuildRepositoryCache(ctx, params, commit, cacheSecretName); err != nil {
			params.Log.Error(err, "failed to build repository cache", "name", params.Instance.Name, "namespace", params.Instance.Namespace)
			return
		}
	}
	err := updateStatus(ctx, params, commit, storageSecretName, stateStoreSynced)
	if err != nil {
		_ = captureRepositorySyncMetrics(ctx, params, start, commit, true)
		params.Log.Info("failed to update repository status", "namespace", params.Instance.Namespace, "name", params.Instance.Name, "error", err.Error())
		return
	}
	_ = captureRepositorySyncMetrics(ctx, params, start, commit, false)
	params.Log.Info("reconciled", "name", params.Instance.Name, "namespace", params.Instance.Namespace, "commit", commit)
}

// setRepoReady patches status.ready to false when the sync already determined the repo should not be ready
// (e.g. bundle/cache build failure). It only sends a patch if the instance is currently Ready, to avoid
// redundant writes; if Ready is already false, the reconcile still exits before success paths (storage secret,
// updateStatus with ready true) so no spurious "success" update runs.
func setRepoReady(ctx context.Context, params Params, patch []byte) error {

	if !params.Instance.Status.Ready {
		return nil
	}

	if err := params.Client.Status().Patch(ctx, params.Instance,
		client.RawPatch(types.JSONPatchType, patch)); err != nil {
		return err
	}
	params.Log.Info("repository status has been updated", "namespace", params.Instance.Namespace, "name", params.Instance.Name)
	return nil
}

// analyzeGraphmanCloneRoot runs when DetectGraphmanFolders found no project subdirs: decide whether to
// log "no layout", and whether the clone root alone is a valid graphman bundle (flat / JSON-only layout).
func analyzeGraphmanCloneRoot(repositoryPath string, log logr.Logger) (warnNoProjectDirs bool, useCloneRootAsProject bool, err error) {
	rootBytes, err := util.BuildAndValidateBundle(repositoryPath, false, log)
	if err != nil {
		return false, false, err
	}
	if util.GraphmanBundleBytesHaveNoEntities(rootBytes) {
		return true, false, nil
	}
	return false, true, nil
}

// BuildRepositoryCache scans a repository, builds bundles per directory, and caches them
// Returns the bundleMap for reuse (e.g., in StorageSecret)
func BuildRepositoryCache(ctx context.Context, params Params, commit string, storageSecretName string) (map[string][]byte, error) {
	repository := params.Instance
	cachePath := util.RepoCacheDir(repository.Name, repository.Namespace)

	// there is a flag that facilitates delete being managed using mappings outside of the operator which is the default
	// behaviour if you are not using a L7StateStore.
	// This is a safer approach for database backed gateways.
	combinedBundlefileName := "combined.json"
	trackedBundleFileName := commit + ".json"

	// Create cache directory
	if err := os.MkdirAll(cachePath, 0755); err != nil {
		return nil, err
	}

	// Check if already cached - load and return existing bundleMap
	if _, err := os.Stat(cachePath + "/" + trackedBundleFileName); err == nil {
		params.Log.V(2).Info("repository cache already exists", "name", repository.Name, "commit", commit)

		// Read existing cache
		bundleMapBytes, err := os.ReadFile(cachePath + "/" + trackedBundleFileName)
		if err != nil {
			return nil, err
		}

		bundleMap := map[string][]byte{}
		if err := json.Unmarshal(bundleMapBytes, &bundleMap); err != nil {
			return nil, err
		}
		return bundleMap, nil
	}

	_, repositoryPath, _, err := localRepoStorageInfo(params)
	if err != nil {
		return nil, err
	}

	// Detect all graphman folders in the repository
	projects, err := util.DetectGraphmanFolders(repositoryPath)
	if err != nil {
		return nil, err
	}

	var warnNoProjectDirs, useCloneRootAsProject bool
	if len(projects) == 0 {
		warnNoProjectDirs, useCloneRootAsProject, err = analyzeGraphmanCloneRoot(repositoryPath, params.Log)
		if err != nil {
			return nil, err
		}
		if warnNoProjectDirs {
			params.Log.Info("no graphman repository layout detected under clone path. repository cache will contain no directory bundles",
				"name", repository.Name,
				"namespace", repository.Namespace,
				"commit", commit)
		}
	}

	projectsToBuild := projects
	if len(projects) == 0 && useCloneRootAsProject {
		projectsToBuild = []string{repositoryPath}
	}

	bundleMap := map[string][]byte{}
	currentBundles := map[string][]byte{}

	// Build the current commit
	for _, project := range projectsToBuild {
		bundleBytes, err := util.BuildAndValidateBundle(project, false, params.Log)
		if err != nil {
			return nil, err
		}

		// Generate key name (normalize directory path)
		keyName := strings.Replace(project, repositoryPath, "", 1)
		keyName = strings.TrimPrefix(strings.ReplaceAll(keyName, "/", "-"), "-")
		if keyName == "" {
			keyName = repository.Name
		}

		currentBundles[keyName+".json"] = bundleBytes

		// Compress the bundle
		bundleGzip, err := util.GzipCompress(bundleBytes)
		if err != nil {
			return nil, err
		}

		bundleMap[keyName+".gz"] = bundleGzip
	}

	// Write the current commit to file
	bundleMapBytes, err := json.Marshal(bundleMap)
	if err != nil {
		return nil, err
	}

	if err := os.WriteFile(cachePath+"/"+trackedBundleFileName, bundleMapBytes, 0755); err != nil {
		return nil, err
	}

	// Load previous bundle map for delta calculation (if exists)
	previousBundleMap := map[string][]byte{}
	if repository.Status.Commit != "" && repository.Status.Commit != commit {
		if previousBytes, err := os.ReadFile(cachePath + "/" + combinedBundlefileName); err == nil {
			_ = json.Unmarshal(previousBytes, &previousBundleMap)
		}
	}

	// Calculate deltas against previous version (exactly like StateStorage does)
	if len(previousBundleMap) > 0 {
		for k1, b1bytes := range currentBundles {
			keyName := strings.TrimSuffix(k1, ".json")

			for k2, b2 := range previousBundleMap {
				// Look for the full bundle from previous commit (not delta)
				if k2 == keyName+".gz" {
					// Decompress previous bundle
					b2Bytes, err := util.GzipDecompress(b2)
					if err != nil {
						b2Bytes = b2
					}

					srcBundle := graphman.Bundle{}
					destBundle := graphman.Bundle{}

					if err := json.Unmarshal(b2Bytes, &srcBundle); err != nil {
						continue
					}
					if err := json.Unmarshal(b1bytes, &destBundle); err != nil {
						continue
					}

					// Reset mappings from previous state (removes delete mappings from last iteration)
					if err := graphman.ResetMappings(&srcBundle); err != nil {
						continue
					}

					// Calculate delta: current (cleaned previous) vs desired (new)
					deltaBundle, combinedBundle, err := graphman.CalculateDelta(srcBundle, destBundle)
					if err != nil {
						continue
					}

					// Store delta bundle
					deltaBundleBytes, err := json.Marshal(deltaBundle)
					if err != nil {
						continue
					}
					deltaGzip, err := util.GzipCompress(deltaBundleBytes)
					if err != nil {
						continue
					}
					bundleMap[keyName+"-delta.gz"] = deltaGzip

					// Overwrite the full bundle with combined (has delete mappings for next iteration)
					// This is the same as StateStorage - the full bundle becomes the "state" for next delta
					combinedBundleBytes, err := json.Marshal(combinedBundle)
					if err != nil {
						continue
					}
					combinedGzip, err := util.GzipCompress(combinedBundleBytes)
					if err != nil {
						continue
					}
					bundleMap[keyName+".gz"] = combinedGzip
					break
				}
			}
		}

		// Handle deleted folders (folders in previous but not in current)
		for k2, b2 := range previousBundleMap {
			// Skip delta bundles, only process full bundles
			if strings.HasSuffix(k2, "-delta.gz") {
				continue
			}

			keyName := strings.TrimSuffix(k2, ".gz")
			found := false

			for k1 := range currentBundles {
				if strings.TrimSuffix(k1, ".json") == keyName {
					found = true
					break
				}
			}

			if !found {
				// Folder was deleted - create delete bundle
				b2Bytes, err := util.GzipDecompress(b2)
				if err != nil {
					b2Bytes = b2
				}

				srcBundle := graphman.Bundle{}
				if err := json.Unmarshal(b2Bytes, &srcBundle); err != nil {
					continue
				}

				// Reset mappings (clean the state)
				if err := graphman.ResetMappings(&srcBundle); err != nil {
					continue
				}

				// Calculate delta against empty bundle (deletes everything)
				emptyBundle := graphman.Bundle{}
				deltaBundle, combinedBundle, err := graphman.CalculateDelta(srcBundle, emptyBundle)
				if err != nil {
					continue
				}

				// Store delta bundle
				deltaBundleBytes, err := json.Marshal(deltaBundle)
				if err != nil {
					continue
				}
				deltaGzip, err := util.GzipCompress(deltaBundleBytes)
				if err != nil {
					continue
				}
				bundleMap[keyName+"-delta.gz"] = deltaGzip

				// Store combined bundle with delete mappings
				combinedBundleBytes, err := json.Marshal(combinedBundle)
				if err != nil {
					continue
				}
				combinedGzip, err := util.GzipCompress(combinedBundleBytes)
				if err != nil {
					continue
				}
				bundleMap[keyName+".gz"] = combinedGzip
			}
		}
	}

	// Marshal the bundle map
	bundleMapBytes, err = json.Marshal(bundleMap)
	if err != nil {
		return nil, err
	}

	// Write dynamic bundle to file
	if err := os.WriteFile(cachePath+"/"+combinedBundlefileName, bundleMapBytes, 0755); err != nil {
		return nil, err
	}

	params.Log.Info("built repository cache",
		"name", repository.Name,
		"directories", len(bundleMap),
		"commit", commit)

	return bundleMap, nil
}

func captureRepositorySyncMetrics(ctx context.Context, params Params, start time.Time, commitId string, hasError bool) error {
	operatorNamespace, err := util.GetOperatorNamespace()
	if err != nil {
		params.Log.Info("could not determine operator namespace")
		return err
	}
	gateway := params.Instance
	otelEnabled, err := util.GetOtelEnabled()
	if err != nil {
		params.Log.Info("could not determine if OTel is enabled")
		return err
	}

	if !otelEnabled {
		return nil
	}

	otelMetricPrefix, err := util.GetOtelMetricPrefix()
	if err != nil {
		params.Log.Info("could not determine otel metric prefix")
		return err
	}

	if otelMetricPrefix == "" {
		otelMetricPrefix = "layer7_"
	}

	hostname, err := util.GetHostname()
	if err != nil {
		params.Log.Error(err, "failed to retrieve operator hostname")
		return err
	}

	meter := otel.Meter("layer7-operator-repository-sync-metrics")
	repoSyncLatency, err := meter.Float64Histogram(otelMetricPrefix+"operator_repository_sync_latency",
		metric.WithDescription("repository sync latency"), metric.WithUnit("ms"))
	if err != nil {
		return err
	}

	repoSyncSuccess, err := meter.Int64Counter(otelMetricPrefix+"operator_repository_sync_success",
		metric.WithDescription("graphman request success"))
	if err != nil {
		return err
	}

	repoSyncFailure, err := meter.Int64Counter(otelMetricPrefix+"operator_repository_sync_failure",
		metric.WithDescription("graphman request failure"))
	if err != nil {
		return err
	}

	repoSyncTotal, err := meter.Int64Counter(otelMetricPrefix+"operator_repository_sync_total",
		metric.WithDescription("graphman request total"))
	if err != nil {
		return err
	}

	duration := time.Since(start)
	repoSyncLatency.Record(ctx, duration.Seconds(),
		metric.WithAttributes(
			attribute.String("k8s.pod.name", hostname),
			attribute.String("k8s.namespace.name", operatorNamespace),
			attribute.String("repository_namespace", gateway.Namespace)))

	repoSyncTotal.Add(ctx, 1,
		metric.WithAttributes(
			attribute.String("k8s.pod.name", hostname),
			attribute.String("k8s.namespace.name", operatorNamespace),
			attribute.String("repository_namespace", gateway.Namespace)))

	if hasError {
		repoSyncFailure.Add(ctx, 1,
			metric.WithAttributes(
				attribute.String("k8s.pod.name", hostname),
				attribute.String("k8s.namespace.name", operatorNamespace),
				attribute.String("repository_namespace", gateway.Namespace),
				attribute.String("repository_type", string(params.Instance.Spec.Type)),
				attribute.String("repository_name", params.Instance.Name),
				attribute.String("commit_id", commitId)))
	} else {
		repoSyncSuccess.Add(ctx, 1,
			metric.WithAttributes(
				attribute.String("k8s.pod.name", hostname),
				attribute.String("k8s.namespace.name", operatorNamespace),
				attribute.String("repository_namespace", gateway.Namespace),
				attribute.String("repository_type", string(params.Instance.Spec.Type)),
				attribute.String("repository_name", params.Instance.Name),
				attribute.String("commit_id", commitId)))
	}

	return nil
}
