// Copyright (c) All respective contributors to the Peridot Project. All rights reserved.
// Copyright (c) 2021-2022 Rocky Enterprise Software Foundation, Inc. All rights reserved.
// Copyright (c) 2021-2022 Ctrl IQ, Inc. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its contributors
// may be used to endorse or promote products derived from this software without
// specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.

package workflow

import (
	"database/sql"
	"encoding/base64"
	"encoding/xml"
	"fmt"
	"github.com/gobwas/glob"
	"io/ioutil"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"github.com/go-git/go-billy/v5"
	"github.com/go-git/go-billy/v5/memfs"
	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/transport"
	"github.com/go-git/go-git/v5/storage/memory"
	"github.com/google/uuid"
	"github.com/sirupsen/logrus"
	"go.temporal.io/sdk/temporal"
	"go.temporal.io/sdk/workflow"
	"google.golang.org/protobuf/encoding/prototext"
	"google.golang.org/protobuf/types/known/wrapperspb"
	peridotdb "peridot.resf.org/peridot/db"
	"peridot.resf.org/peridot/db/models"
	peridotpb "peridot.resf.org/peridot/pb"
	"peridot.resf.org/peridot/yummeta"
	yumrepofspb "peridot.resf.org/peridot/yumrepofs/pb"
	"peridot.resf.org/utils"
)

var (
	archPrefixRegex = regexp.MustCompile("(\\[.+])?")
)

type RepoSyncPackage struct {
	Name string
	Type peridotpb.PackageType
}

type RepoSyncIndex struct {
	Packages              []RepoSyncPackage
	IncludeFilter         map[string][]string
	ExcludeFilter         []string
	Multilib              []string
	AdditionalMultilib    []string
	ExcludeMultilibFilter []string
	GlobIncludeFilter     []string
}

func recursiveSearchBillyFs(fs billy.Filesystem, root string, ext string) ([]string, error) {
	var files []string
	read, err := fs.ReadDir(root)
	if err != nil {
		return nil, err
	}
	for _, f := range read {
		if f.IsDir() {
			nFiles, err := recursiveSearchBillyFs(fs, path.Join(root, f.Name()), ext)
			if err != nil {
				return nil, err
			}
			files = append(files, nFiles...)
		} else {
			if path.Ext(f.Name()) == ext {
				files = append(files, path.Join(root, f.Name()))
			}
		}
	}
	return files, nil
}

func (c *Controller) SyncCatalogWorkflow(ctx workflow.Context, req *peridotpb.SyncCatalogRequest, task *models.Task) (*peridotpb.SyncCatalogTask, error) {
	syncCatalogTask := peridotpb.SyncCatalogTask{}

	deferTask, errorDetails, err := c.commonCreateTask(task, &syncCatalogTask)
	defer deferTask()
	if err != nil {
		return nil, err
	}

	// Let's provision an ephemeral worker
	taskQueue, cleanupWorker, err := c.provisionWorker(ctx, &ProvisionWorkerRequest{
		TaskId:       task.ID.String(),
		ParentTaskId: task.ParentTaskId,
		Purpose:      "sync",
		Arch:         "noarch",
		ProjectId:    req.ProjectId.Value,
	})
	if err != nil {
		setInternalError(errorDetails, err)
		return nil, err
	}
	defer cleanupWorker()

	syncCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
		ScheduleToStartTimeout: 25 * time.Minute,
		StartToCloseTimeout:    15 * time.Minute,
		TaskQueue:              taskQueue,
		RetryPolicy: &temporal.RetryPolicy{
			MaximumAttempts: 1,
		},
	})
	err = workflow.ExecuteActivity(syncCtx, c.SyncCatalogActivity, req).Get(ctx, &syncCatalogTask)
	if err != nil {
		setActivityError(errorDetails, err)
		return nil, err
	}

	if len(syncCatalogTask.ReprocessBuildIds) > 0 {
		yumrepoCtx := workflow.WithChildOptions(ctx, workflow.ChildWorkflowOptions{
			TaskQueue: "yumrepofs",
		})
		taskID := task.ID.String()
		updateRepoRequest := &UpdateRepoRequest{
			ProjectID: req.ProjectId.Value,
			BuildIDs:  syncCatalogTask.ReprocessBuildIds,
			Delete:    false,
			TaskID:    &taskID,
		}
		updateRepoTask := &yumrepofspb.UpdateRepoTask{}
		err = workflow.ExecuteChildWorkflow(yumrepoCtx, c.RepoUpdaterWorkflow, updateRepoRequest).Get(yumrepoCtx, updateRepoTask)
		if err != nil {
			setActivityError(errorDetails, err)
			return nil, err
		}
	}

	task.Status = peridotpb.TaskStatus_TASK_STATUS_SUCCEEDED

	return &syncCatalogTask, nil
}

func globFiltering(incomingFilter []string, currentFilter []string) []string {
	var globs []string
	for _, filter := range incomingFilter {
		if utils.StrContains(filter, currentFilter) {
			continue
		}
		noPrefix := archPrefixRegex.ReplaceAllString(filter, "")
		if !utils.StrContains(noPrefix, globs) {
			globs = append(globs, noPrefix)
		}
	}
	for _, filter := range currentFilter {
		if !utils.StrContains(filter, incomingFilter) {
			noPrefix := archPrefixRegex.ReplaceAllString(filter, "")
			globs = append(globs, noPrefix)
		}
	}
	return globs
}

func kindCatalogSync(tx peridotdb.Access, req *peridotpb.SyncCatalogRequest, catalogs []*peridotpb.CatalogSync) (*peridotpb.KindCatalogSync, error) {
	var ret peridotpb.KindCatalogSync

	repoIndex := map[string]*RepoSyncIndex{}
	// This index contains package names
	// Example:
	//   perl.aarch64 -> perl
	nvrIndex := map[string]string{}
	for _, catalog := range catalogs {
		for _, pkg := range catalog.Package {
			for _, repo := range pkg.Repository {
				if repoIndex[repo.Name] == nil {
					repoIndex[repo.Name] = &RepoSyncIndex{
						Packages:              []RepoSyncPackage{},
						IncludeFilter:         map[string][]string{},
						ExcludeFilter:         []string{},
						Multilib:              []string{},
						AdditionalMultilib:    []string{},
						ExcludeMultilibFilter: []string{},
						GlobIncludeFilter:     []string{},
					}
				}

				pkgExistsAlready := false
				for _, p := range repoIndex[repo.Name].Packages {
					if p.Name == pkg.Name {
						pkgExistsAlready = true
						break
					}
				}
				if !pkgExistsAlready {
					repoIndex[repo.Name].Packages = append(repoIndex[repo.Name].Packages, RepoSyncPackage{
						Name: pkg.Name,
						Type: pkg.Type,
					})
				}
				for _, inf := range repo.IncludeFilter {
					nvrIndex[inf] = pkg.Name
					if repoIndex[repo.Name].IncludeFilter[pkg.Name] == nil {
						repoIndex[repo.Name].IncludeFilter[pkg.Name] = []string{}
					}
					if !utils.StrContains(inf, repoIndex[repo.Name].IncludeFilter[pkg.Name]) {
						repoIndex[repo.Name].IncludeFilter[pkg.Name] = append(repoIndex[repo.Name].IncludeFilter[pkg.Name], inf)
					}
				}
				for _, multilib := range repo.Multilib {
					if !utils.StrContains(multilib, repoIndex[repo.Name].Multilib) {
						repoIndex[repo.Name].Multilib = append(repoIndex[repo.Name].Multilib, multilib)
					}
				}
			}
		}
		for _, excludeFilter := range catalog.ExcludeFilter {
			for repoName, repo := range repoIndex {
				if excludeFilter.RepoMatch != "*" {
					matchRegex, err := regexp.Compile(excludeFilter.RepoMatch)
					if err != nil {
						return nil, fmt.Errorf("failed to compile repo match regex: %w", err)
					}
					if !matchRegex.MatchString(repoName) {
						continue
					}
				}

				for _, arch := range excludeFilter.Arch {
					for _, archGlob := range arch.GlobMatch {
						var filterString string
						if arch.Key != "*" {
							filterString = fmt.Sprintf("[%s]", arch.Key)
						}
						filterString += archGlob
						if !utils.StrContains(filterString, repo.ExcludeFilter) {
							repo.ExcludeFilter = append(repo.ExcludeFilter, filterString)
						}
					}
				}
			}
		}
		for _, includeFilter := range catalog.IncludeFilter {
			for repoName, repo := range repoIndex {
				if includeFilter.RepoMatch != "*" {
					matchRegex, err := regexp.Compile(includeFilter.RepoMatch)
					if err != nil {
						return nil, fmt.Errorf("failed to compile repo match regex: %w", err)
					}
					if !matchRegex.MatchString(repoName) {
						continue
					}
				}

				for _, arch := range includeFilter.Arch {
					for _, archGlob := range arch.GlobMatch {
						var filterString string
						if arch.Key != "*" {
							filterString = fmt.Sprintf("[%s]", arch.Key)
						}
						filterString += archGlob
						if !utils.StrContains(filterString, repo.GlobIncludeFilter) {
							repo.GlobIncludeFilter = append(repo.GlobIncludeFilter, filterString)
						}
					}
				}
			}
		}

		for _, repo := range repoIndex {
			if catalog.AdditionalMultilib != nil {
				repo.AdditionalMultilib = catalog.AdditionalMultilib
			}
			if catalog.ExcludeMultilibFilter != nil {
				repo.ExcludeMultilibFilter = catalog.ExcludeMultilibFilter
			}
		}

		logrus.Infof("Syncing %d repositories", len(repoIndex))

		// Create a package index, so we can skip previously synced packages
		packageExistsIndex := map[string]bool{}
		for repoName, _ := range repoIndex {
			dbRepo, err := tx.GetRepository(nil, &repoName, &req.ProjectId.Value)
			if err != nil && err != sql.ErrNoRows {
				return nil, fmt.Errorf("failed to get repository: %w", err)
			}

			// Repo doesn't exist yet, so we can skip it
			if err == sql.ErrNoRows {
				continue
			}
			for _, pkg := range dbRepo.Packages {
				packageExistsIndex[pkg] = true
			}
		}

		for _, repo := range repoIndex {
			for _, pkg := range repo.Packages {
				// Skip if already in project
				if packageExistsIndex[pkg.Name] {
					continue
				}

				pkgId, err := tx.GetPackageID(pkg.Name)
				if err != nil && err != sql.ErrNoRows {
					return nil, fmt.Errorf("failed to check if package exists: %w", err)
				}

				if pkgId != "" {
					pkgs, err := tx.GetPackagesInProject(&peridotpb.PackageFilters{NameExact: wrapperspb.String(pkg.Name)}, req.ProjectId.Value, 0, 1)
					if err != nil {
						return nil, fmt.Errorf("failed to get package %s: %w", pkg.Name, err)
					}

					// Package already in project, skip
					if len(pkgs) > 0 {
						continue
					}
				}

				logrus.Infof("Package %s not found in project %s, creating", pkg.Name, req.ProjectId.Value)
				ret.NewPackages = append(ret.NewPackages, pkg.Name)

				if pkgId == "" {
					dbPkg, err := tx.CreatePackage(pkg.Name, pkg.Type)
					if err != nil {
						return nil, fmt.Errorf("failed to upsert package %s: %w", pkg.Name, err)
					}

					pkgId = dbPkg.ID.String()
				}

				err = tx.AddPackageToProject(req.ProjectId.Value, pkgId, pkg.Type)
				if err != nil {
					return nil, fmt.Errorf("failed to add package %s to project %s: %w", pkg.Name, req.ProjectId.Value, err)
				}
			}
		}

		for repoName, repo := range repoIndex {
			dbRepo, err := tx.GetRepository(nil, &repoName, &req.ProjectId.Value)
			if err != nil && err != sql.ErrNoRows {
				return nil, fmt.Errorf("failed to get repository: %w", err)
			}

			var stringPkgs []string
			if dbRepo != nil {
				stringPkgs = dbRepo.Packages
			}
			for _, pkg := range repo.Packages {
				if !utils.StrContains(pkg.Name, stringPkgs) {
					stringPkgs = append(stringPkgs, pkg.Name)
				}
			}
			var stringIncludeFilter []string
			for _, includeFilter := range repo.IncludeFilter {
				for _, inf := range includeFilter {
					stringIncludeFilter = append(stringIncludeFilter, inf)
				}
			}

			if dbRepo == nil {
				logrus.Infof("Creating repository %s", repoName)
				ret.NewRepositories = append(ret.NewRepositories, repoName)

				dbRepo, err = tx.CreateRepositoryWithPackages(repoName, req.ProjectId.Value, false, stringPkgs)
				if err != nil {
					return nil, fmt.Errorf("failed to create repository: %w", err)
				}
				// This is a new repo, force a new sync for non-new packages as well
				ret.ModifiedPackages = append(ret.ModifiedPackages, stringPkgs...)
			} else {
				logrus.Infof("Updating repository %s", repoName)
				ret.ModifiedRepositories = append(ret.ModifiedRepositories, repoName)

				// Let's go through all lists and filters and create a list of packages this touches
				// The result will then be used to submit correct updates to correct repositories
				// Include filters comes from prepopulate, so we can just use the list as-is.
				for pkg, filters := range repo.IncludeFilter {
					// Only re-process if not already in repository
					shouldContinue := false
					for _, filter := range filters {
						if utils.StrContains(filter, dbRepo.IncludeFilter) {
							continue
						}
						shouldContinue = true
					}
					if !shouldContinue {
						continue
					}
					if !utils.StrContains(pkg, ret.ModifiedPackages) {
						ret.ModifiedPackages = append(ret.ModifiedPackages, pkg)
					}
				}

				// Exclude filter / additional include may include arch prefix, but we'll trim and force a sync on
				// all arches.
				// We're going to store the globs in a temporary slice, and replace
				// wildcard "*" with postgres wildcard "%"
				var globs []string
				globs = append(globs, globFiltering(repo.ExcludeFilter, dbRepo.ExcludeFilter)...)
				globs = append(globs, globFiltering(repo.ExcludeMultilibFilter, dbRepo.ExcludeMultilibFilter)...)
				globs = append(globs, globFiltering(repo.AdditionalMultilib, dbRepo.AdditionalMultilib)...)
				globs = append(globs, globFiltering(repo.GlobIncludeFilter, dbRepo.GlobIncludeFilter)...)
				for _, g := range globs {
					// Last wildcard may be a bit problematic because of conflicting package names,
					// but currently the only thing that does is bringing in extra builds to re-sync.
					// Processing extra builds is better than missing any builds
					// todo(mustafa): Evaluate
					ret.AdditionalNvrGlobs = append(ret.AdditionalNvrGlobs, "%/"+strings.ReplaceAll(g, "*", "%")+"%")
				}
			}

			err = tx.SetRepositoryOptions(dbRepo.ID.String(), stringPkgs, repo.ExcludeFilter, stringIncludeFilter, repo.AdditionalMultilib, repo.ExcludeMultilibFilter, repo.Multilib, repo.GlobIncludeFilter)
			if err != nil {
				return nil, fmt.Errorf("failed to set repository options: %w", err)
			}
		}
	}

	logrus.Infof("New packages: %v", ret.NewPackages)
	logrus.Infof("Modified packages: %v", ret.ModifiedPackages)
	logrus.Infof("New repositories: %v", ret.NewRepositories)
	logrus.Infof("Modified repositories: %v", ret.ModifiedRepositories)
	logrus.Infof("Additional NVR globs: %v", ret.AdditionalNvrGlobs)

	return &ret, nil
}

func processGroupInstallScopedPackageOptions(tx peridotdb.Access, req *peridotpb.SyncCatalogRequest, groupInstallOptionSet *peridotpb.CatalogGroupInstallOption) (scopedPackages *peridotpb.CatalogGroupInstallScopedPackage, err error) {
	// handle scoped packages relationships on packages for injection into build root
	for _, scopedPackage := range groupInstallOptionSet.ScopedPackage {
		filters := &peridotpb.PackageFilters{NameExact: wrapperspb.String(scopedPackage.Name)}
		isGlob := false
		if strings.HasPrefix(scopedPackage.Name, "*") || strings.HasSuffix(scopedPackage.Name, "*") {
			filters.Name = wrapperspb.String(strings.TrimSuffix(strings.TrimPrefix(scopedPackage.Name, "*"), "*"))
			filters.NameExact = nil
			isGlob = true
		}

		pkgs, err := tx.GetPackagesInProject(filters, req.ProjectId.Value, 0, -1)
		if err != nil {
			return nil, fmt.Errorf("failed to get package %s: %w", scopedPackage.Name, err)
		}
		if len(pkgs) == 0 {
			return nil, fmt.Errorf("package %s not found in project %s (scoped package)", scopedPackage.Name, req.ProjectId.Value)
		}

		var dbPkgs []models.Package
		if isGlob {
			for _, p := range pkgs {
				g, err := glob.Compile(scopedPackage.Name)
				if err != nil {
					return nil, fmt.Errorf("failed to compile glob %s: %w", scopedPackage.Name, err)
				}
				if g.Match(p.Name) {
					dbPkgs = append(dbPkgs, p)
				}
			}
		} else {
			if scopedPackage.Name != pkgs[0].Name {
				return nil, fmt.Errorf("package %s not found in project %s (cannot set extra options, not glob)", scopedPackage.Name, req.ProjectId.Value)
			}
			dbPkgs = append(dbPkgs, pkgs[0])
		}
		if len(dbPkgs) == 0 {
			return nil, fmt.Errorf("package %s not found in project %s (cannot set extra options, glob)", scopedPackage.Name, req.ProjectId.Value)
		}

		for _, dbPkg := range dbPkgs {
			err = tx.SetGroupInstallOptionsForPackage(req.ProjectId.Value, dbPkg.Name, scopedPackage.DependsOn)
			if err != nil {
				return nil, fmt.Errorf("failed to set scoped package options for package %s", scopedPackage.Name)
			}
		}
	}
	return scopedPackages, nil
}

func processGroupInstallOptionSet(groupInstallOptionSet *peridotpb.CatalogGroupInstallOption) (packages []string, err error) {
	for _, name := range groupInstallOptionSet.Name {
		packages = append(packages, name)
	}
	if len(packages) == 0 {
		return nil, fmt.Errorf("failed to parse packages from GroupInstall options")
	}

	return packages, nil
}

func kindCatalogGroupInstallOptions(tx peridotdb.Access, req *peridotpb.SyncCatalogRequest, groupInstallOptions []*peridotpb.CatalogGroupInstallOptions) (*peridotpb.KindCatalogGroupInstallOptions, error) {
	ret := &peridotpb.KindCatalogGroupInstallOptions{}

	for _, groupInstallOption := range groupInstallOptions {

		// Proces scoped packages
		scopedPackages, err := processGroupInstallScopedPackageOptions(tx, req, groupInstallOption.Srpm)
		if err != nil {
			return nil, fmt.Errorf("failed to parse srpm groupinstall options: %s", err.Error())
		}
		ret.ScopedPackage = append(ret.ScopedPackage, scopedPackages)

		// Process build root packages
		srpmPackages, err := processGroupInstallOptionSet(groupInstallOption.Srpm)
		if err != nil {
			return nil, fmt.Errorf("failed to parse srpm groupinstall options: %w", err)
		}
		buildPackages, err := processGroupInstallOptionSet(groupInstallOption.Build)
		if err != nil {
			return nil, fmt.Errorf("failed to parse build groupinstall options: %w", err)
		}
		err = tx.SetBuildRootPackages(req.ProjectId.Value, srpmPackages, buildPackages)

		ret.SrpmPackages = append(ret.SrpmPackages, srpmPackages...)
		ret.BuildPackages = append(ret.BuildPackages, buildPackages...)
	}

	return ret, nil
}

func kindCatalogExtraOptions(tx peridotdb.Access, req *peridotpb.SyncCatalogRequest, extraOptions []*peridotpb.CatalogExtraOptions) (*peridotpb.KindCatalogExtraOptions, error) {
	ret := &peridotpb.KindCatalogExtraOptions{}

	for _, extraOption := range extraOptions {
		for _, pkg := range extraOption.PackageOptions {
			pkgs, err := tx.GetPackagesInProject(&peridotpb.PackageFilters{NameExact: wrapperspb.String(pkg.Name)}, req.ProjectId.Value, 0, 1)
			if err != nil {
				return nil, fmt.Errorf("failed to get package %s: %w", pkg.Name, err)
			}
			if len(pkgs) == 0 {
				return nil, fmt.Errorf("package %s not found in project %s (cannot set extra options)", pkg.Name, req.ProjectId.Value)
			}
			err = tx.SetExtraOptionsForPackage(req.ProjectId.Value, pkg.Name, pkg.With, pkg.Without)
			if err != nil {
				return nil, fmt.Errorf("failed to set extra options for package %s", pkg.Name)
			}
		}
	}

	return ret, nil
}

func checkApplyComps(w *git.Worktree, tx peridotdb.Access, projectId string) error {
	_, err := w.Filesystem.Stat("comps")
	if err != nil {
		if !os.IsNotExist(err) {
			return fmt.Errorf("failed to stat comps: %w", err)
		}
		logrus.Infof("No comps detected")
		return nil
	}

	compsXml, err := w.Filesystem.ReadDir("comps")
	if err != nil {
		return fmt.Errorf("failed to read comps: %w", err)
	}
	for _, comps := range compsXml {
		if comps.IsDir() {
			continue
		}
		if !strings.HasSuffix(comps.Name(), ".xml") {
			logrus.Infof("Skipping non-xml file %s", comps.Name())
			continue
		}
		// Comps have the filename format of REPO-ARCH.xml
		logrus.Infof("Applying comps %s", comps.Name())
		repoArch := strings.SplitN(strings.TrimSuffix(comps.Name(), ".xml"), "-", 2)
		repo := repoArch[0]
		arch := repoArch[1]

		// Check if the repo exists
		dbRepo, err := tx.GetRepository(nil, &repo, &projectId)
		if err != nil {
			logrus.Infof("Repository %s does not exist, skipping comps", repo)
			continue
		}

		compFile, err := w.Filesystem.Open(filepath.Join("comps", comps.Name()))
		if err != nil {
			return fmt.Errorf("failed to open %s: %w", comps.Name(), err)
		}
		defer compFile.Close()
		groupsXml, err := ioutil.ReadAll(compFile)
		if err != nil {
			return fmt.Errorf("failed to read %s: %w", comps.Name(), err)
		}
		var groupsXmlGz []byte
		err = compressWithGz(groupsXml, &groupsXmlGz)
		if err != nil {
			return fmt.Errorf("failed to compress %s: %w", comps.Name(), err)
		}
		groupsXmlB64 := base64.StdEncoding.EncodeToString(groupsXmlGz)
		openChecksums, err := getChecksums(groupsXml)
		if err != nil {
			return fmt.Errorf("failed to get checksums for %s: %w", comps.Name(), err)
		}
		closedChecksums, err := getChecksums(groupsXmlGz)
		if err != nil {
			return fmt.Errorf("failed to get checksums for %s: %w", comps.Name(), err)
		}

		activeRevision, err := tx.GetLatestActiveRepositoryRevision(dbRepo.ID.String(), arch)
		if err != nil {
			if err != sql.ErrNoRows {
				return fmt.Errorf("failed to get latest active revision for repo %s: %w", repo, err)
			}

			revisionId := uuid.New().String()
			repomdRoot := yummeta.RepoMdRoot{
				Rpm:      "http://linux.duke.edu/metadata/rpm",
				XmlnsRpm: "http://linux.duke.edu/metadata/rpm",
				Xmlns:    "http://linux.duke.edu/metadata/repo",
				Revision: revisionId,
			}

			var newRepoMd []byte
			err = xmlMarshal(repomdRoot, &newRepoMd)
			if err != nil {
				return fmt.Errorf("failed to marshal repomd root: %w", err)
			}
			newRepoMdB64 := base64.StdEncoding.EncodeToString(newRepoMd)

			activeRevision, err = tx.CreateRevisionForRepository(revisionId, dbRepo.ID.String(), arch, newRepoMdB64, "", "", "", "", "", "", groupsXmlB64, "{}")
			if err != nil {
				return fmt.Errorf("failed to create revision for repo %s: %w", repo, err)
			}
		}

		newRevision := uuid.New().String()
		blobHref := func(blob string) string {
			ext := "xml"
			if blob == "MODULES" {
				ext = "yaml"
			}
			return fmt.Sprintf("repodata/%s-%s.%s.gz", newRevision, blob, ext)
		}

		now := time.Now()

		// Create data entries
		openEntry := &yummeta.RepoMdData{
			Type: "group",
			Checksum: &yummeta.RepoMdDataChecksum{
				Type:  "sha256",
				Value: openChecksums[0],
			},
			Location: &yummeta.RepoMdDataLocation{
				Href: strings.TrimSuffix(blobHref("GROUPS"), ".gz"),
			},
			Timestamp: now.Unix(),
			Size:      len(groupsXml),
		}
		closedEntry := &yummeta.RepoMdData{
			Type: "group_gz",
			Checksum: &yummeta.RepoMdDataChecksum{
				Type:  "sha256",
				Value: closedChecksums[0],
			},
			OpenChecksum: &yummeta.RepoMdDataChecksum{
				Type:  "sha256",
				Value: openChecksums[0],
			},
			Location: &yummeta.RepoMdDataLocation{
				Href: blobHref("GROUPS"),
			},
			Timestamp: now.Unix(),
			Size:      len(groupsXmlGz),
			OpenSize:  len(groupsXml),
		}

		// Unmarshal the old repomd
		var oldRepoMd yummeta.RepoMdRoot
		repoMdXml, err := base64.StdEncoding.DecodeString(activeRevision.RepomdXml)
		if err != nil {
			return fmt.Errorf("failed to decode repomd xml: %w", err)
		}
		err = xml.Unmarshal(repoMdXml, &oldRepoMd)
		if err != nil {
			return fmt.Errorf("failed to unmarshal repomd xml: %w", err)
		}
		openEntrySet := false
		closedEntrySet := false
		for i := range oldRepoMd.Data {
			if oldRepoMd.Data[i].Type == "group" {
				oldRepoMd.Data[i] = openEntry
				openEntrySet = true
			}
			if oldRepoMd.Data[i].Type == "group_gz" {
				oldRepoMd.Data[i] = closedEntry
				closedEntrySet = true
			}
		}
		if !openEntrySet {
			oldRepoMd.Data = append(oldRepoMd.Data, openEntry)
		}
		if !closedEntrySet {
			oldRepoMd.Data = append(oldRepoMd.Data, closedEntry)
		}

		// Re-marshal repomd with new group information
		var newRepoMd []byte
		err = xmlMarshal(oldRepoMd, &newRepoMd)
		if err != nil {
			return fmt.Errorf("failed to marshal repomd root: %w", err)
		}
		_, err = tx.CreateRevisionForRepository(
			newRevision,
			dbRepo.ID.String(),
			arch,
			base64.StdEncoding.EncodeToString(newRepoMd),
			activeRevision.PrimaryXml,
			activeRevision.FilelistsXml,
			activeRevision.OtherXml,
			activeRevision.UpdateinfoXml,
			activeRevision.ModuleDefaultsYaml,
			activeRevision.ModulesYaml,
			groupsXmlB64,
			"{}",
		)
		if err != nil {
			return fmt.Errorf("failed to create revision for repo %s: %w", repo, err)
		}
	}

	return nil
}

func (c *Controller) SyncCatalogActivity(req *peridotpb.SyncCatalogRequest) (*peridotpb.SyncCatalogTask, error) {
	var ret peridotpb.SyncCatalogTask

	beginTx, err := c.db.Begin()
	if err != nil {
		return nil, err
	}
	tx := c.db.UseTransaction(beginTx)

	fmt.Printf("Cloning repository %s\n", req.ScmUrl.Value)

	authenticator, err := c.getAuthenticator(req.ProjectId.Value)
	cloneOpts := &git.CloneOptions{
		URL:        req.ScmUrl.Value,
		Auth:       authenticator,
		RemoteName: "origin",
	}
	repo, err := git.Clone(memory.NewStorage(), memfs.New(), cloneOpts)
	if err != nil {
		if err == transport.ErrInvalidAuthMethod || err == transport.ErrAuthenticationRequired {
			cloneOpts.Auth = nil
			repo, err = git.Clone(memory.NewStorage(), memfs.New(), cloneOpts)
			if err != nil {
				return nil, err
			}
		}
	}

	w, err := repo.Worktree()
	if err != nil {
		return nil, fmt.Errorf("failed to get worktree: %w", err)
	}

	fmt.Printf("Checking out branch %s\n", req.Branch.Value)

	err = w.Checkout(&git.CheckoutOptions{
		Branch: plumbing.NewRemoteReferenceName("origin", req.Branch.Value),
		Force:  true,
	})
	if err != nil {
		return nil, fmt.Errorf("failed to checkout branch: %w", err)
	}

	fmt.Println("Scanning repository...")

	var catalogs []*peridotpb.CatalogSync
	var extraOptions []*peridotpb.CatalogExtraOptions
	var groupInstallOptions []*peridotpb.CatalogGroupInstallOptions

	files, err := recursiveSearchBillyFs(w.Filesystem, ".", ".cfg")
	if err != nil {
		fmt.Printf("Failed to scan repository: %s\n", err)
		return nil, fmt.Errorf("failed to scan repository: %w", err)
	}

	for _, file := range files {
		f, err := w.Filesystem.Open(file)
		if err != nil {
			return nil, fmt.Errorf("failed to open file %s: %w", file, err)
		}
		bts, err := ioutil.ReadAll(f)
		if err != nil {
			return nil, fmt.Errorf("failed to read file: %w", err)
		}

		messageDecorator := strings.Split(string(bts), "\n")[0]

		if messageDecorator == "" {
			return nil, fmt.Errorf("invalid format for %s", file)
		}

		format := strings.TrimSpace(strings.Replace(messageDecorator, "#", "", 1))
		format = strings.TrimSpace(strings.Replace(format, "kind:", "", 1))

		switch format {
		case "resf.peridot.v1.CatalogSync":
			cs1 := &peridotpb.CatalogSync{}
			err = prototext.Unmarshal(bts, cs1)
			if err != nil {
				return nil, fmt.Errorf("failed to parse kind resf.peridot.v1.CatalogSync: %w", err)
			}
			catalogs = append(catalogs, cs1)
		case "resf.peridot.v1.CatalogExtraOptions":
			ce1 := &peridotpb.CatalogExtraOptions{}
			err = prototext.Unmarshal(bts, ce1)
			if err != nil {
				return nil, fmt.Errorf("failed to parse kind resf.peridot.v1.CatalogExtraOptions: %w", err)
			}
			extraOptions = append(extraOptions, ce1)
		case "resf.peridot.v1.CatalogGroupInstallOptions":
			cg1 := &peridotpb.CatalogGroupInstallOptions{}
			err = prototext.Unmarshal(bts, cg1)
			if err != nil {
				return nil, fmt.Errorf("failed to parse kind resf.peridot.v1.CatalogGroupInstallOptions: %w", err)
			}
			groupInstallOptions = append(groupInstallOptions, cg1)
		default:
			return nil, fmt.Errorf("unknown format %s", format)
		}
	}

	resKindCatalogSync, err := kindCatalogSync(tx, req, catalogs)
	if err != nil {
		return nil, fmt.Errorf("failed to process kind CatalogSync: %w", err)
	}
	ret.CatalogSync = resKindCatalogSync

	// Check if we have comps
	err = checkApplyComps(w, tx, req.ProjectId.Value)
	if err != nil {
		return nil, fmt.Errorf("failed to apply comps: %w", err)
	}

	resKindCatalogExtraOptions, err := kindCatalogExtraOptions(tx, req, extraOptions)
	if err != nil {
		return nil, fmt.Errorf("failed to process kind CatalogSyncExtraOptions: %w", err)
	}
	ret.ExtraOptions = resKindCatalogExtraOptions

	resKindCatalogGroupInstallOptions, err := kindCatalogGroupInstallOptions(tx, req, groupInstallOptions)
	if err != nil {
		return nil, fmt.Errorf("failed to process kind CatalogSyncGroupInstallOptions: %w", err)
	}
	ret.GroupInstallOptions = resKindCatalogGroupInstallOptions

	var buildIDs []string
	var newBuildPackages []string
	for _, newPackage := range ret.CatalogSync.NewPackages {
		if utils.StrContains(newPackage, newBuildPackages) {
			continue
		}
		dbIDs, err := c.db.GetLatestBuildIdsByPackageName(newPackage, req.ProjectId.Value)
		if err != nil {
			if err == sql.ErrNoRows {
				continue
			}
			return nil, fmt.Errorf("failed to get build for package %s: %w", newPackage, err)
		}
		for _, buildID := range dbIDs {
			if utils.StrContains(buildID, buildIDs) {
				continue
			}
			buildIDs = append(buildIDs, buildID)
		}
		newBuildPackages = append(newBuildPackages, newPackage)
	}
	for _, newPackage := range ret.CatalogSync.ModifiedPackages {
		if utils.StrContains(newPackage, newBuildPackages) {
			continue
		}
		dbIDs, err := c.db.GetLatestBuildIdsByPackageName(newPackage, req.ProjectId.Value)
		if err != nil {
			if err == sql.ErrNoRows {
				continue
			}
			return nil, fmt.Errorf("failed to get build for package %s: %w", newPackage, err)
		}
		for _, buildID := range dbIDs {
			if utils.StrContains(buildID, buildIDs) {
				continue
			}
			buildIDs = append(buildIDs, buildID)
		}
		newBuildPackages = append(newBuildPackages, newPackage)
	}
	for _, nvr := range ret.CatalogSync.AdditionalNvrGlobs {
		dbIDs, err := c.db.GetActiveBuildIdsByTaskArtifactGlob(nvr, req.ProjectId.Value)
		if err != nil {
			if err == sql.ErrNoRows {
				continue
			}
			return nil, fmt.Errorf("failed to get build for nvr %s: %w", nvr, err)
		}
		for _, buildID := range dbIDs {
			if utils.StrContains(buildID, buildIDs) {
				continue
			}
			buildIDs = append(buildIDs, buildID)
		}
	}
	ret.ReprocessBuildIds = buildIDs

	fmt.Println("Following build IDs will be re-processed:")
	for _, buildID := range buildIDs {
		fmt.Printf("\t* %s\n", buildID)
	}

	err = beginTx.Commit()
	if err != nil {
		return nil, fmt.Errorf("failed to commit transaction: %w", err)
	}

	return &ret, nil
}
