/*
Copyright (c) Edgeless Systems GmbH

SPDX-License-Identifier: AGPL-3.0-only
*/

package git

import (
	"errors"
	"regexp"
	"strings"
	"time"

	git "github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"
	"github.com/go-git/go-git/v5/plumbing/storer"
)

var (
	versionRegex = regexp.MustCompile(`^v\d+\.\d+\.\d+(?:-pre)?$`)
	tagReference = regexp.MustCompile(`^refs/tags/([^/]+)$`)
)

type Git struct {
	repo *git.Repository
}

func New() (*Git, error) {
	repo, err := git.PlainOpenWithOptions("", &git.PlainOpenOptions{DetectDotGit: true})
	return &Git{repo: repo}, err
}

// Revision returns the current revision (HEAD) of the repository in the format used by go pseudo versions.
func (g *Git) Revision() (string, time.Time, error) {
	commitRef, err := g.repo.Head()
	if err != nil {
		return "", time.Time{}, err
	}
	commit, err := g.repo.CommitObject(commitRef.Hash())
	if err != nil {
		return "", time.Time{}, err
	}
	return commitRef.Hash().String()[:12], commit.Author.When, nil
}

// FirstParentWithVersionTag returns the first parent of the HEAD commit (or HEAD itself) that has a version tag.
func (g *Git) FirstParentWithVersionTag() (revision string, versionTag string, err error) {
	commitRef, err := g.repo.Head()
	if err != nil {
		return "", "", err
	}
	commit, err := g.repo.CommitObject(commitRef.Hash())
	if err != nil {
		return "", "", err
	}
	commitToHash, err := g.tagsByRevisionHash()
	if err != nil {
		return "", "", err
	}

	iter := object.NewCommitIterCTime(commit, nil, nil)
	if err := iter.ForEach(
		func(c *object.Commit) error {
			tags, ok := commitToHash[c.Hash.String()]
			if !ok {
				return nil
			}
			version := g.findVersionTag(tags)
			if version == nil {
				return nil
			}
			versionTag = *version
			revision = c.Hash.String()
			return storer.ErrStop
		},
	); err != nil {
		return "", "", err
	}
	if revision == "" || versionTag == "" {
		return "", "", errors.New("no version tag found")
	}
	return revision, versionTag, nil
}

// ParsedBranchName returns the name of the current branch.
// Special characters are replaced with "-", and the name is lowercased and trimmed to 49 characters.
// This makes sure that the branch name is usable as a GCP image name.
func (g *Git) ParsedBranchName() (string, error) {
	commitRef, err := g.repo.Head()
	if err != nil {
		return "", err
	}

	rxp, err := regexp.Compile("[^a-zA-Z0-9-]+")
	if err != nil {
		return "", err
	}

	branch := strings.ToLower(rxp.ReplaceAllString(commitRef.Name().Short(), "-"))
	if len(branch) > 49 {
		branch = branch[:49]
	}

	return strings.TrimSuffix(branch, "-"), nil
}

func (g *Git) BranchName() (string, error) {
	commitRef, err := g.repo.Head()
	if err != nil {
		return "", err
	}
	return commitRef.Name().Short(), nil
}

// tagsByRevisionHash returns a map from revision hash to a list of associated tags.
func (g *Git) tagsByRevisionHash() (map[string][]string, error) {
	tags := make(map[string][]string)
	refs, err := g.repo.Tags()
	if err != nil {
		return nil, err
	}
	if err := refs.ForEach(
		func(ref *plumbing.Reference) error {
			hash, err := g.tagRefToHash(ref)
			if err != nil {
				return err
			}
			message, err := tagRefToName(ref)
			if err != nil {
				return err
			}
			tags[hash] = append(tags[hash], message)
			return nil
		},
	); err != nil {
		return nil, err
	}
	return tags, nil
}

// findVersionTag tries to find a tag for a semantic version (e.g.: v1.0.0).
func (g *Git) findVersionTag(tags []string) *string {
	for _, tag := range tags {
		if versionRegex.MatchString(tag) {
			return &tag
		}
	}
	return nil
}

func (g *Git) tagRefToHash(tagRef *plumbing.Reference) (string, error) {
	tagObject, err := g.repo.TagObject(tagRef.Hash())
	switch err {
	case nil:
		return tagObject.Target.String(), nil
	case plumbing.ErrObjectNotFound:
		return tagRef.Hash().String(), nil
	default:
		// Some other error
		return "", err
	}
}

// tagRefToName extracts the name of a tag reference.
func tagRefToName(tagRef *plumbing.Reference) (string, error) {
	matches := tagReference.FindStringSubmatch(tagRef.Name().String())
	if len(matches) != 2 {
		return "", errors.New("invalid tag reference")
	}
	return matches[1], nil
}