package github

import (
	"context"
	"encoding/base64"
	"fmt"
	"math"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"strconv"
	"strings"

	"github.com/google/go-github/v53/github"

	"github.com/aws/eks-anywhere-build-tooling/tools/version-tracker/pkg/constants"
	"github.com/aws/eks-anywhere-build-tooling/tools/version-tracker/pkg/util/file"
	"github.com/aws/eks-anywhere-build-tooling/tools/version-tracker/pkg/util/logger"
	"github.com/aws/eks-anywhere-build-tooling/tools/version-tracker/pkg/util/semver"
	"github.com/aws/eks-anywhere-build-tooling/tools/version-tracker/pkg/util/tar"
	"github.com/aws/eks-anywhere-build-tooling/tools/version-tracker/pkg/util/version"
)

// getTagsForRepo retrieves the list of tags for the given GitHub repository.
func getTagsForRepo(client *github.Client, org, repo string) ([]*github.RepositoryTag, error) {
	logger.V(6).Info(fmt.Sprintf("Getting tags for [%s/%s] repository", org, repo))
	var allTags []*github.RepositoryTag
	listTagOptions := &github.ListOptions{
		PerPage: constants.GithubPerPage,
	}

	for {
		tags, resp, err := client.Repositories.ListTags(context.Background(), org, repo, listTagOptions)
		if err != nil {
			return nil, fmt.Errorf("calling ListTags API for [%s/%s] repository: %v", org, repo, err)
		}
		allTags = append(allTags, tags...)

		if resp.NextPage == 0 {
			break
		}
		listTagOptions.Page = resp.NextPage
	}

	return allTags, nil
}

// getCommitsForRepo retrieves the list of commits for the given GitHub repository.
func getCommitsForRepo(client *github.Client, org, repo string) ([]*github.RepositoryCommit, error) {
	logger.V(6).Info(fmt.Sprintf("Getting commits for [%s/%s] repository", org, repo))
	var allCommits []*github.RepositoryCommit
	listCommitOptions := &github.CommitsListOptions{
		ListOptions: github.ListOptions{
			PerPage: constants.GithubPerPage,
		},
	}

	for {
		commits, resp, err := client.Repositories.ListCommits(context.Background(), org, repo, listCommitOptions)
		if err != nil {
			return nil, fmt.Errorf("calling ListCommits API for [%s/%s] repository: %v", org, repo, err)
		}
		allCommits = append(allCommits, commits...)

		if resp.NextPage == 0 {
			break
		}
		listCommitOptions.ListOptions.Page = resp.NextPage
	}

	return allCommits, nil
}

// getCommitDateEpoch gets the Unix epoch time equivalent of a given Github commit's date.
func getCommitDateEpoch(client *github.Client, org, repo, commitSHA string) (int64, error) {
	logger.V(6).Info(fmt.Sprintf("Getting date for commit %s in [%s/%s] repository", commitSHA, org, repo))

	commit, _, err := client.Repositories.GetCommit(context.Background(), org, repo, commitSHA, nil)
	if err != nil {
		return 0, fmt.Errorf("getting date for commit %s in [%s/%s] repository: %v", commitSHA, org, repo, err)
	}

	return (*commit.Commit.Author.Date).Unix(), nil
}

func GetFileContents(client *github.Client, org, repo, filePath, ref string) ([]byte, error) {
	contents, _, _, err := client.Repositories.GetContents(context.Background(), org, repo, filePath, &github.RepositoryContentGetOptions{Ref: ref})
	if err != nil {
		return nil, fmt.Errorf("getting contents of file [%s]: %v", filePath, err)
	}
	contentsDecoded, err := base64.StdEncoding.DecodeString(*contents.Content)
	if err != nil {
		return nil, fmt.Errorf("decoding contents of file [%s]: %v", filePath, err)
	}

	return contentsDecoded, nil
}

// GetLatestRevision returns the latest revision (GitHub release or tag) for a given GitHub repository.
func GetLatestRevision(client *github.Client, org, repo, currentRevision, branchName string, isTrackedUsingCommitHash, releaseBranched bool) (string, bool, error) {
	logger.V(6).Info(fmt.Sprintf("Getting latest revision for [%s/%s] repository", org, repo))
	var currentRevisionCommit, latestRevision string
	var needsUpgrade bool

	projectFullName := fmt.Sprintf("%s/%s", org, repo)

	// Get all GitHub tags for this project.
	allTags, err := getTagsForRepo(client, org, repo)
	if err != nil {
		return "", false, fmt.Errorf("getting all tags for [%s/%s] repository: %v", org, repo, err)
	}

	// Get commit hash corresponding to current revision tag.
	if isTrackedUsingCommitHash {
		currentRevisionCommit = currentRevision
	} else {
		currentRevisionCommit = getCommitForTag(allTags, currentRevision)
	}

	// Get Unix timestamp for current revision's commit.
	currentRevisionCommitEpoch, err := getCommitDateEpoch(client, org, repo, currentRevisionCommit)
	if err != nil {
		return "", false, fmt.Errorf("getting epoch time corresponding to current revision commit: %v", err)
	}

	// If the project is tracked using a commit hash, upgrade to the latest commit.
	if isTrackedUsingCommitHash {
		// If the project does not have Github tags, pick the latest commit.
		allCommits, err := getCommitsForRepo(client, org, repo)
		if err != nil {
			return "", false, fmt.Errorf("getting all commits for [%s/%s] repository: %v", org, repo, err)
		}
		latestRevision = *allCommits[0].SHA
		needsUpgrade = true
	} else {
		semverRegex := regexp.MustCompile(constants.SemverRegex)
		currentRevisionForSemver := version.EnsurePatchVersion(semverRegex.FindString(currentRevision))

		// Get SemVer construct corresponding to the current revision tag.
		currentRevisionSemver, err := semver.New(currentRevisionForSemver)
		if err != nil {
			return "", false, fmt.Errorf("getting semver for current version %s: %v", currentRevisionForSemver, err)
		}

		for _, tag := range allTags {
			tagName := *tag.Name
			if strings.Contains(tagName, "chart") || strings.Contains(tagName, "helm") {
				continue
			}
			if org == "kubernetes" && repo == "autoscaler" {
				if !strings.HasPrefix(tagName, "cluster-autoscaler-") {
					continue
				}
			}
			detectedSemver := semverRegex.FindString(tagName)
			if detectedSemver == "" {
				continue
			}
			tagNameForSemver := version.EnsurePatchVersion(detectedSemver)

			if releaseBranched {
				releaseBranch := os.Getenv(constants.ReleaseBranchEnvvar)
				releaseNumber := strings.Split(releaseBranch, "-")[1]
				tagRegex := regexp.MustCompile(fmt.Sprintf(`^v?1(\.|_)%s(\.|_)\d+$`, releaseNumber))
				if !tagRegex.MatchString(tagNameForSemver) {
					continue
				}
			}
			if branchName != constants.MainBranchName {
				tagRegex := regexp.MustCompile(fmt.Sprintf(`^v?%d(\.|_)%d(\.|_)\d+`, currentRevisionSemver.Major, currentRevisionSemver.Minor))
				if !tagRegex.MatchString(tagNameForSemver) {
					continue
				}
			}

			revisionSemver, err := semver.New(tagNameForSemver)
			if err != nil {
				return "", false, fmt.Errorf("getting semver for the version under consideration: %v", err)
			}
			if !slices.Contains(constants.ProjectsSupportingPrereleaseTags, projectFullName) && revisionSemver.Prerelease != "" {
				continue
			}
			if _, ok := constants.ProjectMaximumSemvers[projectFullName]; ok {
				maximumAllowedVersion := constants.ProjectMaximumSemvers[projectFullName]
				numDots := strings.Count(maximumAllowedVersion, ".")
				for range 2 - numDots {
					maximumAllowedVersion += fmt.Sprintf(".%s", strconv.Itoa(math.MaxInt))
				}

				maximumSemver, err := semver.New(maximumAllowedVersion)
				if err != nil {
					return "", false, fmt.Errorf("getting semver for the maximum allowed version: %v", err)
				}

				if revisionSemver.GreaterThan(maximumSemver) {
					continue
				}
			}

			latestRevision = tagName

			// Determine if upgrade is required based on current and latest revisions
			upgradeRequired, shouldBreak, err := isUpgradeRequired(client, org, repo, latestRevision, currentRevisionCommitEpoch, currentRevisionSemver, allTags)
			if err != nil {
				return "", false, fmt.Errorf("determining if upgrade is required for project: %v", err)
			}
			if shouldBreak {
				needsUpgrade = upgradeRequired
				break
			}
		}
	}

	return latestRevision, needsUpgrade, nil
}

// isUpgradeRequired determines if the project requires an upgrade by comparing the current revision to the latest revision.
func isUpgradeRequired(client *github.Client, org, repo, latestRevision string, currentRevisionCommitEpoch int64, currentRevisionSemver *semver.Version, allTags []*github.RepositoryTag) (bool, bool, error) {
	var needsUpgrade, shouldBreak bool

	// Get commit hash corresponding to latest revision tag.
	latestRevisionCommit := getCommitForTag(allTags, latestRevision)
	if latestRevisionCommit == "" {
		return false, false, fmt.Errorf("empty commit hash for latest revision: %s", latestRevision)
	}

	// Get Unix timestamp for latest revision's commit.
	latestRevisionCommitEpoch, err := getCommitDateEpoch(client, org, repo, latestRevisionCommit)
	if err != nil {
		return false, false, fmt.Errorf("getting epoch time corresponding to latest revision commit: %v", err)
	}

	semverRegex := regexp.MustCompile(constants.SemverRegex)
	latestRevisionForSemver := version.EnsurePatchVersion(semverRegex.FindString(latestRevision))

	// Get SemVer construct corresponding to the latest revision tag.
	latestRevisionSemver, err := semver.New(latestRevisionForSemver)
	if err != nil {
		return false, false, fmt.Errorf("getting semver for latest version: %v", err)
	}

	// If the latest revision comes after the current revision both chronologically and semantically, then declare that
	// an upgrade is required
	if latestRevisionSemver.GreaterThan(currentRevisionSemver) || latestRevisionCommitEpoch > currentRevisionCommitEpoch {
		needsUpgrade = true
		shouldBreak = true
	} else if latestRevisionSemver.Equal(currentRevisionSemver) {
		needsUpgrade = false
		shouldBreak = true
	}

	return needsUpgrade, shouldBreak, nil
}

// getCommitForTag returns the commit hash corresponding to the given tag.
func getCommitForTag(allTags []*github.RepositoryTag, searchTag string) string {
	for _, tag := range allTags {
		if searchTag == *tag.Name {
			return *tag.Commit.SHA
		}
	}
	return ""
}

// GetGoVersionForLatestRevision gets the Go version used to build the latest revision of the project.
func GetGoVersionForLatestRevision(client *github.Client, org, repo, latestRevision string) (string, error) {
	logger.V(6).Info(fmt.Sprintf("Getting Go version corresponding to latest revision %s for [%s/%s] repository", latestRevision, org, repo))

	var goVersion string
	var err error
	projectFullName := fmt.Sprintf("%s/%s", org, repo)
	if _, ok := constants.ProjectReleaseAssets[projectFullName]; ok {
		release, response, err := client.Repositories.GetReleaseByTag(context.Background(), org, repo, latestRevision)
		if err != nil {
			if response.StatusCode == http.StatusNotFound {
				logger.V(6).Info(fmt.Sprintf("GitHub release for tag %s not found. Falling back to GitHub source of truth file for Go version", latestRevision))
				goVersion, err = getGoVersionFromGitHubFile(client, org, repo, projectFullName, latestRevision)
				if err != nil {
					return "", fmt.Errorf("getting Go version from GitHub source of truth file: %v", err)
				}
			} else {
				return "", fmt.Errorf("calling GetReleaseByTag API for tag %s in [%s/%s] repository: %v", latestRevision, org, repo, err)
			}
		} else {
			goVersion, err = getGoVersionFromGitHubRelease(release, projectFullName, latestRevision)
			if err != nil {
				return "", fmt.Errorf("getting Go version from GitHub release assets: %v", err)
			}
		}
	} else if _, ok := constants.ProjectGoVersionSourceOfTruth[projectFullName]; ok {
		goVersion, err = getGoVersionFromGitHubFile(client, org, repo, projectFullName, latestRevision)
		if err != nil {
			return "", fmt.Errorf("getting Go version from GitHub source of truth file: %v", err)
		}
	}

	return goVersion, nil
}

// CreatePullRequest creates a pull request from the head branch to the base branch on the base repository.
func CreatePullRequest(client *github.Client, org, repo, title, body, baseRepoOwner, baseBranch, headRepoOwner, headBranch, currentRevision, latestRevision string) (*github.PullRequest, error) {
	var pullRequest *github.PullRequest

	// Check if there is already a pull request from the head branch to the base branch.
	pullRequests, _, err := client.PullRequests.List(context.Background(), baseRepoOwner, constants.BuildToolingRepoName, &github.PullRequestListOptions{
		Base: baseBranch,
		Head: fmt.Sprintf("%s:%s", headRepoOwner, headBranch),
	})
	if err != nil {
		return nil, fmt.Errorf("listing pull requests from %s:%s -> %s:%s: %v", headRepoOwner, headBranch, baseRepoOwner, baseBranch, err)
	}

	if len(pullRequests) > 0 {
		pullRequest = pullRequests[0]
		logger.Info(fmt.Sprintf("A pull request already exists for %s:%s", headRepoOwner, headBranch), "Pull request", *pullRequest.HTMLURL)

		pullRequest.Body = github.String(body)
		pullRequest, _, err = client.PullRequests.Edit(context.Background(), baseRepoOwner, constants.BuildToolingRepoName, *pullRequest.Number, pullRequest)
		if err != nil {
			return nil, fmt.Errorf("editing existing pull request [%s]: %v", *pullRequest.HTMLURL, err)
		}
	} else {
		logger.V(6).Info(fmt.Sprintf("Creating pull request with updated versions for [%s/%s] repository", org, repo))

		newPR := &github.NewPullRequest{
			Title:               github.String(title),
			Head:                github.String(fmt.Sprintf("%s:%s", headRepoOwner, headBranch)),
			Base:                github.String(baseBranch),
			Body:                github.String(body),
			MaintainerCanModify: github.Bool(true),
		}
		pullRequest, _, err = client.PullRequests.Create(context.Background(), baseRepoOwner, constants.BuildToolingRepoName, newPR)
		if err != nil {
			return nil, fmt.Errorf("creating pull request with updated versions from %s to %s: %v", headBranch, baseBranch, err)
		}

		logger.Info(fmt.Sprintf("Created pull request: %s", *pullRequest.HTMLURL))
	}

	return pullRequest, nil
}

func AddCommentOnPR(client *github.Client, baseRepoOwner, comment string, pullRequest *github.PullRequest) error {
	prComment := &github.IssueComment{
		Body: github.String(comment),
	}

	_, _, err := client.Issues.CreateComment(context.Background(), baseRepoOwner, constants.BuildToolingRepoName, *pullRequest.Number, prComment)
	if err != nil {
		return fmt.Errorf("commenting on pull request [%s]: %v", *pullRequest.HTMLURL, err)
	}

	return nil
}

func getGoVersionFromGitHubRelease(release *github.RepositoryRelease, projectFullName, latestRevision string) (string, error) {
	var tarballName, tarballUrl string
	projectReleaseAsset := constants.ProjectReleaseAssets[projectFullName]
	searchAssetName := projectReleaseAsset.AssetName
	assetVersionReplacement := latestRevision
	if constants.ProjectReleaseAssets[projectFullName].TrimLeadingVersionPrefix {
		assetVersionReplacement = latestRevision[1:]
	}
	if strings.Count(searchAssetName, "%s") > 0 {
		searchAssetName = fmt.Sprintf(searchAssetName, assetVersionReplacement)
	}
	if projectReleaseAsset.OverrideAssetURL != "" {
		tarballName = searchAssetName
		tarballUrl = projectReleaseAsset.OverrideAssetURL
		if strings.Count(tarballUrl, "%s") > 0 {
			tarballUrl = fmt.Sprintf(tarballUrl, assetVersionReplacement)
		}
	} else {
		for _, asset := range release.Assets {
			if *asset.Name == searchAssetName {
				tarballName = *asset.Name
				tarballUrl = *asset.BrowserDownloadURL
				break
			}
		}
	}

	cwd, err := os.Getwd()
	if err != nil {
		return "", fmt.Errorf("retrieving current working directory: %v", err)
	}

	tarballDownloadPath := filepath.Join(cwd, "github-release-downloads")
	err = os.MkdirAll(tarballDownloadPath, 0o755)
	if err != nil {
		return "", fmt.Errorf("failed to create GitHub release downloads folder: %v", err)
	}
	tarballFilePath := filepath.Join(tarballDownloadPath, tarballName)

	err = file.Download(tarballUrl, tarballFilePath)
	if err != nil {
		return "", fmt.Errorf("downloading release tarball from URL [%s]: %v", tarballUrl, err)
	}

	binaryName := projectReleaseAsset.BinaryName
	if strings.Count(binaryName, "%s") > 0 {
		binaryName = fmt.Sprintf(binaryName, assetVersionReplacement)
	}
	if projectReleaseAsset.Extract {
		tarballFile, err := os.Open(tarballFilePath)
		if err != nil {
			return "", fmt.Errorf("opening tarball filepath: %v", err)
		}

		err = tar.ExtractFileFromTarball(tarballDownloadPath, tarballFile, binaryName)
		if err != nil {
			return "", fmt.Errorf("extracting tarball file: %v", err)
		}
	}

	binaryFilePath := filepath.Join(tarballDownloadPath, binaryName)
	goVersion, err := version.GetGoVersion(binaryFilePath)
	if err != nil {
		return "", fmt.Errorf("getting Go version embedded in binary [%s]: %v", binaryFilePath, err)
	}

	err = os.RemoveAll(tarballDownloadPath)
	if err != nil {
		return "", fmt.Errorf("removing tarball download path: %v", err)
	}

	return goVersion, nil
}

func getGoVersionFromGitHubFile(client *github.Client, org, repo, projectFullName, latestRevision string) (string, error) {
	projectGoVersionSourceOfTruthFile := constants.ProjectGoVersionSourceOfTruth[projectFullName].SourceOfTruthFile
	workflowContents, err := GetFileContents(client, org, repo, projectGoVersionSourceOfTruthFile, latestRevision)
	if err != nil {
		return "", fmt.Errorf("getting contents of file [%s]: %v", projectGoVersionSourceOfTruthFile, err)
	}

	pattern := regexp.MustCompile(constants.ProjectGoVersionSourceOfTruth[projectFullName].GoVersionSearchString)
	matches := pattern.FindStringSubmatch(string(workflowContents))

	return matches[1], nil
}
