// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.

package service

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"regexp"
	"strings"
	"time"

	"github.com/harness/gitness/gitrpc/internal/middleware"
	"github.com/harness/gitness/gitrpc/internal/tempdir"
	"github.com/harness/gitness/gitrpc/internal/types"
	"github.com/harness/gitness/gitrpc/rpc"

	"code.gitea.io/gitea/modules/git"
	"github.com/rs/zerolog/log"
	"google.golang.org/grpc/metadata"
)

// SharedRepo is a type to wrap our upload repositories as a shallow clone.
type SharedRepo struct {
	repoUID    string
	repo       *git.Repository
	remoteRepo *git.Repository
	tmpPath    string
}

// NewSharedRepo creates a new temporary upload repository.
func NewSharedRepo(baseTmpDir, repoUID string, remoteRepo *git.Repository) (*SharedRepo, error) {
	tmpPath, err := tempdir.CreateTemporaryPath(baseTmpDir, repoUID)
	if err != nil {
		return nil, err
	}
	t := &SharedRepo{
		repoUID:    repoUID,
		remoteRepo: remoteRepo,
		tmpPath:    tmpPath,
	}
	return t, nil
}

// Close the repository cleaning up all files.
func (r *SharedRepo) Close(ctx context.Context) {
	defer r.repo.Close()
	if err := tempdir.RemoveTemporaryPath(r.tmpPath); err != nil {
		log.Ctx(ctx).Err(err).Msgf("Failed to remove temporary path %s", r.tmpPath)
	}
}

// Clone the base repository to our path and set branch as the HEAD.
func (r *SharedRepo) Clone(ctx context.Context, branchName string) error {
	args := []string{"clone", "-s", "--bare"}
	if branchName != "" {
		args = append(args, "-b", strings.TrimPrefix(branchName, gitReferenceNamePrefixBranch))
	}
	args = append(args, r.remoteRepo.Path, r.tmpPath)

	if _, _, err := git.NewCommand(ctx, args...).RunStdString(nil); err != nil {
		stderr := err.Error()
		if matched, _ := regexp.MatchString(".*Remote branch .* not found in upstream origin.*", stderr); matched {
			return git.ErrBranchNotExist{
				Name: branchName,
			}
		} else if matched, _ = regexp.MatchString(".* repository .* does not exist.*", stderr); matched {
			return fmt.Errorf("%s %w", r.repoUID, types.ErrNotFound)
		} else {
			return fmt.Errorf("Clone: %w %s", err, stderr)
		}
	}
	gitRepo, err := git.OpenRepository(ctx, r.tmpPath)
	if err != nil {
		return processGitErrorf(err, "failed to open repo")
	}
	r.repo = gitRepo
	return nil
}

// Init the repository.
func (r *SharedRepo) Init(ctx context.Context) error {
	if err := git.InitRepository(ctx, r.tmpPath, false); err != nil {
		return err
	}
	gitRepo, err := git.OpenRepository(ctx, r.tmpPath)
	if err != nil {
		return processGitErrorf(err, "failed to open repo")
	}
	r.repo = gitRepo
	return nil
}

// SetDefaultIndex sets the git index to our HEAD.
func (r *SharedRepo) SetDefaultIndex(ctx context.Context) error {
	if _, _, err := git.NewCommand(ctx, "read-tree", "HEAD").RunStdString(&git.RunOpts{Dir: r.tmpPath}); err != nil {
		return fmt.Errorf("SetDefaultIndex: %w", err)
	}
	return nil
}

// LsFiles checks if the given filename arguments are in the index.
func (r *SharedRepo) LsFiles(ctx context.Context, filenames ...string) ([]string, error) {
	stdOut := new(bytes.Buffer)
	stdErr := new(bytes.Buffer)

	cmdArgs := []string{"ls-files", "-z", "--"}
	for _, arg := range filenames {
		if arg != "" {
			cmdArgs = append(cmdArgs, arg)
		}
	}

	if err := git.NewCommand(ctx, cmdArgs...).
		Run(&git.RunOpts{
			Dir:    r.tmpPath,
			Stdout: stdOut,
			Stderr: stdErr,
		}); err != nil {
		return nil, fmt.Errorf("unable to run git ls-files for temporary repo of: "+
			"%s Error: %w\nstdout: %s\nstderr: %s",
			r.repoUID, err, stdOut.String(), stdErr.String())
	}

	filelist := make([]string, 0)
	for _, line := range bytes.Split(stdOut.Bytes(), []byte{'\000'}) {
		filelist = append(filelist, string(line))
	}

	return filelist, nil
}

// RemoveFilesFromIndex removes the given files from the index.
func (r *SharedRepo) RemoveFilesFromIndex(ctx context.Context, filenames ...string) error {
	stdOut := new(bytes.Buffer)
	stdErr := new(bytes.Buffer)
	stdIn := new(bytes.Buffer)
	for _, file := range filenames {
		if file != "" {
			stdIn.WriteString("0 0000000000000000000000000000000000000000\t")
			stdIn.WriteString(file)
			stdIn.WriteByte('\000')
		}
	}

	if err := git.NewCommand(ctx, "update-index", "--remove", "-z", "--index-info").
		Run(&git.RunOpts{
			Dir:    r.tmpPath,
			Stdin:  stdIn,
			Stdout: stdOut,
			Stderr: stdErr,
		}); err != nil {
		return fmt.Errorf("unable to update-index for temporary repo: %s Error: %w\nstdout: %s\nstderr: %s",
			r.repoUID, err, stdOut.String(), stdErr.String())
	}
	return nil
}

// WriteGitObject writes the provided content to the object db and returns its hash.
func (r *SharedRepo) WriteGitObject(ctx context.Context, content io.Reader) (string, error) {
	stdOut := new(bytes.Buffer)
	stdErr := new(bytes.Buffer)

	if err := git.NewCommand(ctx, "hash-object", "-w", "--stdin").
		Run(&git.RunOpts{
			Dir:    r.tmpPath,
			Stdin:  content,
			Stdout: stdOut,
			Stderr: stdErr,
		}); err != nil {
		return "", fmt.Errorf("unable to hash-object to temporary repo: %s Error: %w\nstdout: %s\nstderr: %s",
			r.repoUID, err, stdOut.String(), stdErr.String())
	}

	return strings.TrimSpace(stdOut.String()), nil
}

// ShowFile dumps show file and write to io.Writer.
func (r *SharedRepo) ShowFile(ctx context.Context, filePath, commitHash string, writer io.Writer) error {
	stderr := new(bytes.Buffer)
	file := strings.TrimSpace(commitHash) + ":" + strings.TrimSpace(filePath)
	cmd := git.NewCommand(ctx, "show", file)
	if err := cmd.Run(&git.RunOpts{
		Dir:    r.repo.Path,
		Stdout: writer,
		Stderr: stderr,
	}); err != nil {
		return fmt.Errorf("show file: %w - %s", err, stderr)
	}
	return nil
}

// AddObjectToIndex adds the provided object hash to the index with the provided mode and path.
func (r *SharedRepo) AddObjectToIndex(ctx context.Context, mode, objectHash, objectPath string) error {
	if _, _, err := git.NewCommand(ctx, "update-index", "--add", "--replace", "--cacheinfo", mode, objectHash,
		objectPath).RunStdString(&git.RunOpts{Dir: r.tmpPath}); err != nil {
		if matched, _ := regexp.MatchString(".*Invalid path '.*", err.Error()); matched {
			return types.ErrInvalidPath
		}
		return fmt.Errorf("unable to add object to index at %s in temporary repo %s Error: %w",
			objectPath, r.repoUID, err)
	}
	return nil
}

// WriteTree writes the current index as a tree to the object db and returns its hash.
func (r *SharedRepo) WriteTree(ctx context.Context) (string, error) {
	stdout, _, err := git.NewCommand(ctx, "write-tree").RunStdString(&git.RunOpts{Dir: r.tmpPath})
	if err != nil {
		return "", fmt.Errorf("unable to write-tree in temporary repo for: %s Error: %w",
			r.repoUID, err)
	}
	return strings.TrimSpace(stdout), nil
}

// GetLastCommit gets the last commit ID SHA of the repo.
func (r *SharedRepo) GetLastCommit(ctx context.Context) (string, error) {
	return r.GetLastCommitByRef(ctx, "HEAD")
}

// GetLastCommitByRef gets the last commit ID SHA of the repo by ref.
func (r *SharedRepo) GetLastCommitByRef(ctx context.Context, ref string) (string, error) {
	if ref == "" {
		ref = "HEAD"
	}
	stdout, _, err := git.NewCommand(ctx, "rev-parse", ref).RunStdString(&git.RunOpts{Dir: r.tmpPath})
	if err != nil {
		return "", fmt.Errorf("unable to rev-parse %s in temporary repo for: %s Error: %w",
			ref, r.repoUID, err)
	}
	return strings.TrimSpace(stdout), nil
}

// CommitTreeWithDate creates a commit from a given tree for the user with provided message.
func (r *SharedRepo) CommitTreeWithDate(
	ctx context.Context,
	parent string,
	author, committer *rpc.Identity,
	treeHash, message string,
	signoff bool,
	authorDate, committerDate time.Time,
) (string, error) {
	// setup environment variables used by git-commit-tree
	// See https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables
	env := []string{
		"GIT_AUTHOR_NAME=" + author.Name,
		"GIT_AUTHOR_EMAIL=" + author.Email,
		"GIT_AUTHOR_DATE=" + authorDate.Format(time.RFC3339),
		"GIT_COMMITTER_NAME=" + committer.Name,
		"GIT_COMMITTER_EMAIL=" + committer.Email,
		"GIT_COMMITTER_DATE=" + committerDate.Format(time.RFC3339),
	}
	messageBytes := new(bytes.Buffer)
	_, _ = messageBytes.WriteString(message)
	_, _ = messageBytes.WriteString("\n")

	var args []string
	if parent != "" {
		args = []string{"commit-tree", treeHash, "-p", parent}
	} else {
		args = []string{"commit-tree", treeHash}
	}

	// temporary no signing
	args = append(args, "--no-gpg-sign")

	if signoff {
		giteaSignature := &git.Signature{
			Name:  committer.Name,
			Email: committer.Email,
			When:  committerDate,
		}
		// Signed-off-by
		_, _ = messageBytes.WriteString("\n")
		_, _ = messageBytes.WriteString("Signed-off-by: ")
		_, _ = messageBytes.WriteString(giteaSignature.String())
	}

	stdout := new(bytes.Buffer)
	stderr := new(bytes.Buffer)
	if err := git.NewCommand(ctx, args...).
		Run(&git.RunOpts{
			Env:    env,
			Dir:    r.tmpPath,
			Stdin:  messageBytes,
			Stdout: stdout,
			Stderr: stderr,
		}); err != nil {
		return "", fmt.Errorf("unable to commit-tree in temporary repo: %s Error: %w\nStdout: %s\nStderr: %s",
			r.repoUID, err, stdout, stderr)
	}
	return strings.TrimSpace(stdout.String()), nil
}

func (r *SharedRepo) PushDeleteBranch(ctx context.Context, writeRequest *rpc.WriteRequest,
	branch string) error {
	return r.push(ctx, writeRequest, "", GetReferenceFromBranchName(branch))
}

func (r *SharedRepo) PushCommitToBranch(ctx context.Context, writeRequest *rpc.WriteRequest,
	commitSHA string, branch string) error {
	return r.push(ctx, writeRequest, commitSHA, GetReferenceFromBranchName(branch))
}

func (r *SharedRepo) PushBranch(ctx context.Context, writeRequest *rpc.WriteRequest,
	sourceBranch string, branch string) error {
	return r.push(ctx, writeRequest, GetReferenceFromBranchName(sourceBranch), GetReferenceFromBranchName(branch))
}
func (r *SharedRepo) PushTag(ctx context.Context, writeRequest *rpc.WriteRequest,
	tagName string) error {
	refTag := GetReferenceFromTagName(tagName)
	return r.push(ctx, writeRequest, refTag, refTag)
}

func (r *SharedRepo) PushDeleteTag(ctx context.Context, writeRequest *rpc.WriteRequest,
	tagName string) error {
	refTag := GetReferenceFromTagName(tagName)
	return r.push(ctx, writeRequest, "", refTag)
}

// push pushes the provided references to the provided branch in the original repository.
func (r *SharedRepo) push(ctx context.Context, writeRequest *rpc.WriteRequest,
	sourceRef, destinationRef string) error {
	// Because calls hooks we need to pass in the environment
	env := CreateEnvironmentForPush(ctx, writeRequest)
	if err := git.Push(ctx, r.tmpPath, git.PushOptions{
		Remote: r.remoteRepo.Path,
		Branch: sourceRef + ":" + destinationRef,
		Env:    env,
	}); err != nil {
		if git.IsErrPushOutOfDate(err) {
			return err
		} else if git.IsErrPushRejected(err) {
			rejectErr := new(git.ErrPushRejected)
			if errors.As(err, &rejectErr) {
				log.Ctx(ctx).Info().Msgf("Unable to push back to repo from temporary repo due to rejection:"+
					" %s (%s)\nStdout: %s\nStderr: %s\nError: %v",
					r.repoUID, r.tmpPath, rejectErr.StdOut, rejectErr.StdErr, rejectErr.Err)
			}
			return err
		}
		return fmt.Errorf("unable to push back to repo from temporary repo: %s (%s) Error: %w",
			r.repoUID, r.tmpPath, err)
	}
	return nil
}

// GetBranchCommit Gets the commit object of the given branch.
func (r *SharedRepo) GetBranchCommit(branch string) (*git.Commit, error) {
	if r.repo == nil {
		return nil, fmt.Errorf("repository has not been cloned")
	}

	return r.repo.GetBranchCommit(strings.TrimPrefix(branch, gitReferenceNamePrefixBranch))
}

// GetCommit Gets the commit object of the given commit ID.
func (r *SharedRepo) GetCommit(commitID string) (*git.Commit, error) {
	if r.repo == nil {
		return nil, fmt.Errorf("repository has not been cloned")
	}
	return r.repo.GetCommit(commitID)
}

// ASSUMPTION: writeRequst and writeRequst.Actor is never nil.
func CreateEnvironmentForPush(ctx context.Context, writeRequest *rpc.WriteRequest) []string {
	// don't send existing environment variables (os.Environ()), only send what's explicitly necessary.
	// Otherwise we create implicit dependencies that are easy to break.
	environ := []string{
		// request id to use for hooks
		EnvRequestID + "=" + middleware.RequestIDFrom(ctx),
		// repo related info
		EnvRepoUID + "=" + writeRequest.RepoUid,
		// actor related info
		EnvActorName + "=" + writeRequest.Actor.Name,
		EnvActorEmail + "=" + writeRequest.Actor.Email,
	}

	// add all environment variables coming from client request
	for _, envVar := range writeRequest.EnvVars {
		environ = append(environ, fmt.Sprintf("%s=%s", envVar.Name, envVar.Value))
	}

	// add all environment variables from the metadata
	if metadata, mOK := metadata.FromIncomingContext(ctx); mOK {
		if envVars, eOK := metadata[rpc.MetadataKeyEnvironmentVariables]; eOK {
			// TODO: should we do a sanity check?
			environ = append(environ, envVars...)
		}
	}

	return environ
}

// GetReferenceFromBranchName assumes the provided value is the branch name (not the ref!)
// and first sanitizes the branch name (remove any spaces or 'refs/heads/' prefix)
// It then returns the full form of the branch reference.
func GetReferenceFromBranchName(branchName string) string {
	// remove spaces
	branchName = strings.TrimSpace(branchName)
	// remove `refs/heads/` prefix (shouldn't be there, but if it is remove it to try to avoid complications)
	// NOTE: This is used to reduce missconfigurations via api
	// TODO: block via CLI, too
	branchName = strings.TrimPrefix(branchName, gitReferenceNamePrefixBranch)

	// return reference
	return gitReferenceNamePrefixBranch + branchName
}

func GetReferenceFromTagName(tagName string) string {
	// remove spaces
	tagName = strings.TrimSpace(tagName)
	// remove `refs/heads/` prefix (shouldn't be there, but if it is remove it to try to avoid complications)
	// NOTE: This is used to reduce missconfigurations via api
	// TODO: block via CLI, too
	tagName = strings.TrimPrefix(tagName, gitReferenceNamePrefixTag)

	// return reference
	return gitReferenceNamePrefixTag + tagName
}