package main

/*
 * This file is part of Autogits.
 *
 * Copyright © 2024 SUSE LLC
 *
 * Autogits is free software: you can redistribute it and/or modify it under
 * the terms of the GNU General Public License as published by the Free Software
 * Foundation, either version 2 of the License, or (at your option) any later
 * version.
 *
 * Autogits is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
 * PARTICULAR PURPOSE. See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * Foobar. If not, see <https://www.gnu.org/licenses/>.
 */

import (
	"bytes"
	"errors"
	"flag"
	"fmt"
	"log"
	"net/url"
	"os"
	"path"
	"regexp"
	"runtime/debug"
	"slices"
	"strconv"
	"strings"
	"time"

	"src.opensuse.org/autogits/common"
	"src.opensuse.org/autogits/common/gitea-generated/models"
)

const (
	GitAuthor   = "GiteaBot - Obs Staging"
	BotName     = "ObsStaging"
	ObsBuildBot = "/obsbuild"

	Username = "autogits_obs_staging_bot"
)

var GiteaToken string
var runId uint

func failOnError(err error, msg string) {
	if err != nil {
		log.Panicf("%s: %s", err, msg)
	}
}

func fetchPrGit(git common.Git, pr *models.PullRequest) error {
	// clone PR head and base and return path
	cloneURL := pr.Head.Repo.CloneURL
	if (giteaUseSshClone) {
		cloneURL = pr.Head.Repo.SSHURL
	}
	if _, err := os.Stat(path.Join(git.GetPath(), pr.Head.Sha)); os.IsNotExist(err) {
		git.GitExec("", "clone", "--depth", "1", cloneURL, pr.Head.Sha)
		git.GitExec(pr.Head.Sha, "fetch", "--depth", "1", "origin", pr.Head.Sha, pr.Base.Sha)
	} else if err != nil {
		return err
	}

	return nil
}

func getObsProjectAssociatedWithPr(baseProject string, pr *models.PullRequest) string {
	if pr.Base.Repo.Name == common.DefaultGitPrj {
		// if default project git, we don't need it in project name to be unique
		return fmt.Sprintf(
			"%s:%s:PR:%d",
			baseProject,
			common.ObsSafeProjectName(pr.Base.Repo.Owner.UserName),
			pr.Index,
		)
	}
	return fmt.Sprintf(
		"%s:%s:%s:PR:%d",
		baseProject,
		common.ObsSafeProjectName(pr.Base.Repo.Owner.UserName),
		common.ObsSafeProjectName(pr.Base.Repo.Name),
		pr.Index,
	)
}

func processBuildStatusUpdate() {
}

type RequestModification int

const (
	RequestModificationNoChange  = 1
	RequestModificationProjectCreated   = 2
	RequestModificationSourceChanged = 3
)

type BuildStatusSummary int

const (
	BuildStatusSummarySuccess  = 1
	BuildStatusSummaryFailed   = 2
	BuildStatusSummaryBuilding = 3
	BuildStatusSummaryUnknown  = 4
)

func processBuildStatus(project, refProject *common.BuildResultList) BuildStatusSummary {
	if _, finished := project.BuildResultSummary(); !finished {
		return BuildStatusSummaryBuilding
	}

	if _, finished := refProject.BuildResultSummary(); !finished {
		log.Println("refProject not finished building??")
		return BuildStatusSummaryUnknown
	}

	// the repositories should be setup equally between the projects. We
	// need to verify that packages that are building in `refProject` are not
	// failing in the `project`
	BuildResultSorter := func(a, b common.BuildResult) int {
		if c := strings.Compare(a.Repository, b.Repository); c != 0 {
			return c
		}
		if c := strings.Compare(a.Arch, b.Arch); c != 0 {
			return c
		}

		panic("Should not happen -- BuiltResultSorter equal repos?")
	}
	slices.SortFunc(project.Result, BuildResultSorter)
	if refProject == nil {
		// just return if buid finished and have some successes, since new package
		SomeSuccess := false
		for i := 0; i < len(project.Result); i++ {
			repoRes := &project.Result[i]
			repoResStatus, ok := common.ObsRepoStatusDetails[repoRes.Code]
			if !ok {
				log.Println("cannot find code:", repoRes.Code)
				return BuildStatusSummaryUnknown
			}
			if !repoResStatus.Finished {
				return BuildStatusSummaryBuilding
			}

			for _, pkg := range repoRes.Status {
				pkgStatus, ok := common.ObsBuildStatusDetails[pkg.Code]
				if !ok {
					log.Println("Unknown package build status:", pkg.Code, "for", pkg.Package)
					log.Println("Details:", pkg.Details)
				}

				if pkgStatus.Success {
					SomeSuccess = true
				}
			}
		}

		if SomeSuccess {
			return BuildStatusSummarySuccess
		}
		return BuildStatusSummaryFailed
	}

	slices.SortFunc(refProject.Result, BuildResultSorter)

	log.Printf("comparing results %d vs. ref %d\n", len(project.Result), len(refProject.Result))
	for i := 0; i < len(project.Result); i++ {
		log.Println("searching for", project.Result[i].Repository, "/", project.Result[i].Arch)
		j := 0
	found:
		for ; j < len(refProject.Result); j++ {
			if project.Result[i].Repository != refProject.Result[j].Repository ||
				project.Result[i].Arch != refProject.Result[j].Arch {
				continue
			}

			log.Printf("found match for %s/%s @ %d\n", project.Result[i].Repository, project.Result[i].Arch, j)
			res := processRepoBuildStatus(project.Result[i].Status, refProject.Result[j].Status)
			switch res {
			case BuildStatusSummarySuccess:
				break found
			default:
				return res
			}
		}

		log.Println(j)
		if j >= len(refProject.Result) {
			log.Printf("Cannot find results... %#v \n %#v\n", project.Result[i], refProject.Result)
			return BuildStatusSummaryUnknown
		}
	}
	return BuildStatusSummarySuccess
}

func processRepoBuildStatus(results, ref []common.PackageBuildStatus) BuildStatusSummary {
	PackageBuildStatusSorter := func(a, b common.PackageBuildStatus) int {
		return strings.Compare(a.Package, b.Package)
	}

	// compare build result
	slices.SortFunc(results, PackageBuildStatusSorter)
	slices.SortFunc(ref, PackageBuildStatusSorter)

	j := 0
	for i := 0; i < len(results); i++ {
		res, ok := common.ObsBuildStatusDetails[results[i].Code]
		if !ok {
			log.Printf("unknown package result code: %s for package %s\n", results[i].Code, results[i].Package)
			return BuildStatusSummaryUnknown
		}

		if !res.Finished {
			return BuildStatusSummaryBuilding
		}

		if !res.Success {
			// not failed if reference project also failed for same package here
			for ; j < len(results) && strings.Compare(results[i].Package, ref[j].Package) < 0; j++ {
			}

			if j < len(results) && results[i].Package == ref[j].Package {
				refRes, ok := common.ObsBuildStatusDetails[ref[j].Code]
				if !ok {
					log.Printf("unknown package result code: %s for package %s\n", ref[j].Code, ref[j].Package)
					return BuildStatusSummaryUnknown
				}

				if !refRes.Finished {
					log.Println("not finished building in reference project?!")
				}

				if refRes.Success {
					return BuildStatusSummaryFailed
				}
			}
		}
	}

	return BuildStatusSummarySuccess
}

func generateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullRequest, obsClient *common.ObsClient) (*common.ProjectMeta, error) {
	log.Println("repo content fetching ...")
	err := fetchPrGit(git, pr)
	if err != nil {
		log.Println("Cannot fetch PR git:", pr.URL)
		return nil, err
	}

	// find modified submodules and new submodules -- build them
	dir := pr.Head.Sha
	headSubmodules, err := git.GitSubmoduleList(dir, pr.Head.Sha)
	if err != nil {
		return nil, err
	}
	baseSubmodules, err := git.GitSubmoduleList(dir, pr.Base.Sha)
	if err != nil {
		return nil, err
	}

	modifiedOrNew := make([]string, 0, 16)
	for pkg, headOid := range headSubmodules {
		if baseOid, exists := baseSubmodules[pkg]; !exists || baseOid != headOid {
			modifiedOrNew = append(modifiedOrNew, pkg)
		}
	}
	prjBuild, err := git.GitCatFile(dir, pr.Head.Sha, "project.build")
	if err != nil {
		return nil, err
	}
	buildPrj := string(bytes.TrimSpace(prjBuild))

	if len(buildPrj) < 1 {
		_, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Cannot find reference project")
		if err != nil {
			log.Println(err)
			return nil, err
		}
		return nil, fmt.Errorf("Cannot find reference project for %s PR#%d", pr.Base.Name, pr.Index)
	}

	meta, err := obsClient.GetProjectMeta(buildPrj)
	if err != nil {
		log.Println("error fetching project meta for", buildPrj, ". Err:", err)
		return nil, err
	}

	// generate new project with paths pointinig back to original repos
	// disable publishing

	meta.Name = getObsProjectAssociatedWithPr(obsClient.HomeProject, pr)
	meta.Description = fmt.Sprintf(`Pull request build job PR#%d to branch %s of %s/%s`,
		pr.Index, pr.Base.Name, pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name)
	meta.Url = fmt.Sprintf(
		"%s/%s/%s/pulls/%d",
		giteaUrl,
		url.PathEscape(pr.Base.Repo.Owner.UserName),
		url.PathEscape(pr.Base.Repo.Name),
		pr.Index,
	)

	urlPkg := make([]string, 0, len(modifiedOrNew))
	for _, pkg := range modifiedOrNew {
		urlPkg = append(urlPkg, "onlybuild="+url.QueryEscape(pkg))
	}
	meta.ScmSync = pr.Head.Repo.CloneURL + "?" + strings.Join(urlPkg, "&") + "#" + pr.Head.Sha
	meta.Title = fmt.Sprintf("PR#%d to %s", pr.Index, pr.Base.Name)
	meta.PublicFlags = common.Flags{Contents: "<disable/>"}

	// set paths to parent project
	for idx, r := range meta.Repositories {
		meta.Repositories[idx].Paths = []common.RepositoryPathMeta{{
			Project:    buildPrj,
			Repository: r.Name,
		}}
	}
	log.Println(meta)
	return meta, nil
}

func startOrUpdateBuild(git common.Git, gitea common.Gitea, pr *models.PullRequest, obsClient *common.ObsClient) (RequestModification, error) {
	log.Println("fetching OBS project Meta")
	obsPrProject := getObsProjectAssociatedWithPr(obsClient.HomeProject, pr)
	meta, err := obsClient.GetProjectMeta(obsPrProject)
	if err != nil {
		log.Println("error fetching project meta for", obsPrProject, ":", err)
		return RequestModificationNoChange, err
	}

	if meta != nil {
		path, err := url.Parse(meta.ScmSync)
		if err != nil {
			log.Println("Cannot parse SCMSYNC url: '", meta.ScmSync, "' .. regenerating build")
			meta = nil
		} else {
			if path.Fragment == pr.Head.Sha {
				// build in progress
				return RequestModificationNoChange, nil
			}
			// build needs update
			log.Println("Detected Head update... regenerating build...")
			meta = nil
		}
	}
	var state RequestModification
	state = RequestModificationSourceChanged
	if meta == nil {
		// new build
		meta, err = generateObsPrjMeta(git, gitea, pr, obsClient)
		if err != nil {
			return RequestModificationNoChange, err
		}
		state = RequestModificationProjectCreated
	}

	err = obsClient.SetProjectMeta(meta)
	if err != nil {
		log.Println("cannot create meta project:", err)
		return RequestModificationNoChange, err
	}

	return state, nil
}

func processPullNotification(gitea common.Gitea, thread *models.NotificationThread) {
	defer func() {
		err := recover()
		if err != nil {
			log.Println(err)
			log.Println(string(debug.Stack()))
		}
	}()

	gh := common.GitHandlerGeneratorImpl{}
	git, err := gh.CreateGitHandler(GitAuthor, "noaddress@suse.de", BotName)
	if err != nil {
		log.Panicln(err)
	}

	rx := regexp.MustCompile(`^https://src\.(?:open)?suse\.(?:org|de)/api/v\d+/repos/(?<org>[-_a-zA-Z0-9]+)/(?<project>[-_a-zA-Z0-9]+)/issues/(?<num>[0-9]+)$`)
	notification := thread.Subject
	match := rx.FindStringSubmatch(notification.URL)
	if match == nil {
		log.Panicf("Unexpected format of notification: %s", notification.URL)
	}

	log.Println("processing")
	log.Println("project:", match[2])
	log.Println("org:    ", match[1])
	log.Println("number: ", match[3])

	org := match[1]
	repo := match[2]
	id, _ := strconv.ParseInt(match[3], 10, 64)

	pr, err := gitea.GetPullRequest(org, repo, id)
	if err != nil {
		log.Println("No PR associated with review:", notification.URL, "Error:", err)
		return
	}

	log.Println("PR state:", pr.State)
	if pr.State == "closed" {
		// dismiss the review
		log.Println(" -- closed request, so nothing to review")
		gitea.SetNotificationRead(thread.ID)
		return
	}

	reviews, err := gitea.GetPullRequestReviews(org, repo, id)
	if err != nil {
		log.Println("No reviews associated with request:", notification.URL, "Error:", err)
		return
	}

	obsClient, err := common.NewObsClient(obsApiHost)
	if err != nil {
		log.Println(err)
		return
	}

	if len(BuildRoot) > 0 {
		obsClient.HomeProject = BuildRoot
	}

	reviewRequested := false
	for _, reviewer := range pr.RequestedReviewers {
		if reviewer.UserName == Username {
			reviewRequested = true
			break
		}
	}

	if !reviewRequested {
		log.Println("Review not requested in notification. Setting to status 'read'")
		gitea.SetNotificationRead(thread.ID)
		return
	}

	newReviews := make([]*models.PullReview, 0, len(reviews))
	for _, review := range reviews {
		if review.User.UserName == Username {
			newReviews = append(newReviews, review)
		}
	}
	reviews = newReviews

	slices.SortFunc(reviews, func(a, b *models.PullReview) int {
		return time.Time(a.Submitted).Compare(time.Time(b.Submitted))
	})

	for idx := len(reviews) - 1; idx >= 0; idx-- {
		review := reviews[idx]
		log.Printf("state: %s,   body: %s,   id:%d\n", string(review.State), review.Body, review.ID)

		if review.User.UserName != Username {
			continue
		}

		log.Println("processing state...")
		log.Println("processing state... %d", review.State)


		switch review.State {

		// create build project, if doesn't exist, and add it to pending requests
		case common.ReviewStateUnknown:
			// what to do?


		case common.ReviewStatePending, common.ReviewStateRequestReview:
			change, err := startOrUpdateBuild(git, gitea, pr, obsClient)
			if err != nil {
				return
			}
			if change != RequestModificationNoChange {
				msg := "Changed source updated for build"
				if change == RequestModificationProjectCreated {
					msg = "Build is started in https://" + obsWebHost + "/project/show/" +
						getObsProjectAssociatedWithPr(obsClient.HomeProject, pr)
				}
				gitea.AddComment(pr, msg)
		 	}

			err = fetchPrGit(git, pr)
			if err != nil {
				log.Println("Cannot fetch PR git:", pr.URL)
				return
			}

			// find modified submodules and new submodules -- build them
			dir := pr.Head.Sha
			headSubmodules, err := git.GitSubmoduleList(dir, pr.Head.Sha)
			if err != nil {
				log.Panicln(err)
			}
			baseSubmodules, err := git.GitSubmoduleList(dir, pr.Base.Sha)
			if err != nil {
				log.Panicln(err)
			}

			modifiedOrNew := make([]string, 0, 16)
			for pkg, headOid := range headSubmodules {
				if baseOid, exists := baseSubmodules[pkg]; !exists || baseOid != headOid {
					modifiedOrNew = append(modifiedOrNew, pkg)
				}
			}

			log.Println("repo content fetching ...")
			refPrjData, err := git.GitCatFile(dir, pr.Head.Sha, "project.build")
			if err != nil {
				log.Panicln(err)
			}
			refPrj := string(bytes.TrimSpace(refPrjData))

			if len(refPrj) < 1 {
				_, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Cannot find reference project")
				if err != nil {
					log.Println(err)
					return
				}
				log.Printf("Cannot find reference project for %s PR#%d\n", pr.Base.Name, pr.Index)
				return
			}

			obsProject := getObsProjectAssociatedWithPr(obsClient.HomeProject, pr)
			prjResult, err := obsClient.BuildStatus(obsProject)
			if err != nil {
				if errors.Is(err, common.ObsProjectNotFound{Project: obsProject}) {
					// recreate missing project
					log.Printf("missing OBS project ... recreating '%s': %v\n", obsProject, err)
					startOrUpdateBuild(git, gitea, pr, obsClient)
					return
				}
				log.Printf("failed fetching build status for '%s': %v\n", obsProject, err)
				return
			}

			refProjectResult, err := obsClient.BuildStatus(refPrj, prjResult.GetPackageList()...)
			if err != nil {
				log.Printf("failed fetching ref project status for '%s': %v\n", refPrj, err)
			}
			buildStatus := processBuildStatus(prjResult, refProjectResult)

			switch buildStatus {
			case BuildStatusSummarySuccess:
				_, err := gitea.AddReviewComment(pr, common.ReviewStateApproved, "Build successful")
				if err != nil {
					log.Println(err)
				}
			case BuildStatusSummaryFailed:
				_, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Build failed")
				if err != nil {
					log.Println(err)
				}
			}
			log.Println("Build status waiting:", buildStatus)
			// waiting for build results -- nothing to do

		case common.ReviewStateApproved:
			// done, mark notification as read
			log.Println("processing request for success build ...")
			gitea.SetNotificationRead(thread.ID)

		case common.ReviewStateRequestChanges:
			// build failures, nothing to do here, mark notification as read
			log.Println("processing request for failed request changes...")
			gitea.SetNotificationRead(thread.ID)
		}

		break
	}
}

func pollWorkNotifications(giteaUrl string) {
	gitea := common.AllocateGiteaTransport(giteaUrl)
	data, err := gitea.GetPullNotifications(nil)

	if err != nil {
		log.Println(err)
		return
	}

	if data != nil {
		log.Println("Processing", len(data), "notifications.")
		for _, notification := range data {
			log.Println(notification.ID, "--", notification.Subject)

			if !ListPullNotificationsOnly && (ProcessIDOnly < 0 || ProcessIDOnly == notification.ID) {
				switch notification.Subject.Type {
				case "Pull":
					processPullNotification(gitea, notification)
				default:
					gitea.SetNotificationRead(notification.ID)
				}
			}
		}
	}
}

var ListPullNotificationsOnly bool
var ProcessIDOnly int64
var Debug bool
var BuildRoot string
var giteaUrl string
var giteaUseSshClone bool
var obsApiHost string
var obsWebHost string

func ObsWebHostFromApiHost(apihost string) string {
	if len(apihost) > 4 && apihost[0:4] == "api." {
		return "build" + apihost[3:]
	}

	return apihost
}

func main() {
	flag.BoolVar(&Debug, "debug", false, "One-shot run. Use for debugging")
	flag.BoolVar(&ListPullNotificationsOnly, "list-notifications-only", false, "Only lists notifications without acting on them")
	flag.Int64Var(&ProcessIDOnly, "id", -1, "Process only the specific ID and ignore the rest. Use for debugging")
	flag.StringVar(&BuildRoot, "build-root", "", "Default build location for staging projects. Default is bot's home project")
	flag.StringVar(&giteaUrl, "gitea-url", "https://src.opensuse.org", "Gitea instance")
	flag.BoolVar(&giteaUseSshClone, "use-ssh-clone", false, "enforce cloning via ssh")
	flag.StringVar(&obsApiHost, "obs", "api.opensuse.org", "API for OBS instance")
	flag.StringVar(&obsWebHost, "obs-web", "", "Web OBS instance, if not derived from the obs config")
	flag.Parse()

	if len(obsWebHost) == 0 {
		obsWebHost = ObsWebHostFromApiHost(obsApiHost)
	}

	failOnError(common.RequireGiteaSecretToken(), "Cannot find GITEA_TOKEN")
	failOnError(common.RequireObsSecretToken(), "Cannot find OBS_USER and OBS_PASSWORD")

	//	go ProcessingObsMessages("rabbit.opensuse.org", "opensuse", "opensuse", "")

	for {
		pollWorkNotifications(giteaUrl)
		if Debug {
			break
		}
		log.Println("Poll cycle finished")
		time.Sleep(10 * time.Minute)
	}
}
