package git

import (
	"github.com/SAP/jenkins-library/pkg/log"
	"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/transport/http"
	"github.com/pkg/errors"
	"time"
)

// utilsWorkTree interface abstraction of git.Worktree to enable tests
type utilsWorkTree interface {
	Add(path string) (plumbing.Hash, error)
	Commit(msg string, opts *git.CommitOptions) (plumbing.Hash, error)
	Checkout(opts *git.CheckoutOptions) error
}

// utilsRepository interface abstraction of git.Repository to enable tests
type utilsRepository interface {
	Worktree() (*git.Worktree, error)
	Push(o *git.PushOptions) error
}

// utilsGit interface abstraction of git to enable tests
type utilsGit interface {
	plainClone(path string, isBare bool, o *git.CloneOptions) (*git.Repository, error)
	plainOpen(path string) (*git.Repository, error)
}

// CommitSingleFile Commits the file located in the relative file path with the commitMessage to the given worktree.
// In case of errors, the error is returned. In the successful case the commit is provided.
func CommitSingleFile(filePath, commitMessage, author string, worktree *git.Worktree) (plumbing.Hash, error) {
	return commitSingleFile(filePath, commitMessage, author, worktree)
}

func commitSingleFile(filePath, commitMessage, author string, worktree utilsWorkTree) (plumbing.Hash, error) {
	_, err := worktree.Add(filePath)
	if err != nil {
		return [20]byte{}, errors.Wrap(err, "failed to add file to git")
	}

	commit, err := worktree.Commit(commitMessage, &git.CommitOptions{
		All:    true,
		Author: &object.Signature{Name: author, When: time.Now()},
	})
	if err != nil {
		return [20]byte{}, errors.Wrap(err, "failed to commit file")
	}

	return commit, nil
}

// PushChangesToRepository Pushes all committed changes in the repository to the remote repository
func PushChangesToRepository(username, password string, force *bool, repository *git.Repository) error {
	return pushChangesToRepository(username, password, force, repository)
}

func pushChangesToRepository(username, password string, force *bool, repository utilsRepository) error {
	pushOptions := &git.PushOptions{
		Auth: &http.BasicAuth{Username: username, Password: password},
	}
	if force != nil {
		pushOptions.Force = *force
	}
	err := repository.Push(pushOptions)
	if err != nil {
		return errors.Wrap(err, "failed to push commit")
	}
	return nil
}

// PlainClone Clones a non-bare repository to the provided directory
func PlainClone(username, password, serverURL, directory string) (*git.Repository, error) {
	abstractedGit := &abstractionGit{}
	return plainClone(username, password, serverURL, directory, abstractedGit)
}

func plainClone(username, password, serverURL, directory string, abstractionGit utilsGit) (*git.Repository, error) {
	gitCloneOptions := git.CloneOptions{
		Auth: &http.BasicAuth{Username: username, Password: password},
		URL:  serverURL,
	}
	repository, err := abstractionGit.plainClone(directory, false, &gitCloneOptions)
	if err != nil {
		return nil, errors.Wrap(err, "failed to clone git")
	}
	return repository, nil
}

// PlainOpen opens a git repository from the given path
func PlainOpen(path string) (*git.Repository, error) {
	abstractedGit := &abstractionGit{}
	return plainOpen(path, abstractedGit)
}

func plainOpen(path string, abstractionGit utilsGit) (*git.Repository, error) {
	log.Entry().Infof("Opening git repo at '%s'", path)
	r, err := abstractionGit.plainOpen(path)
	if err != nil {
		return nil, errors.Wrapf(err, "Unable to open git repository at '%s'", path)
	}
	return r, nil
}

// ChangeBranch checkout the provided branch.
// It will create a new branch if the branch does not exist yet.
// It will return an error if no branch name if provided
func ChangeBranch(branchName string, worktree *git.Worktree) error {
	return changeBranch(branchName, worktree)
}

func changeBranch(branchName string, worktree utilsWorkTree) error {
	if branchName == "" {
		return errors.New("no branch name provided")
	}

	var checkoutOptions = &git.CheckoutOptions{}
	checkoutOptions.Branch = plumbing.NewBranchReferenceName(branchName)
	checkoutOptions.Create = false
	err := worktree.Checkout(checkoutOptions)
	if err != nil {
		// branch might not exist, try to create branch
		checkoutOptions.Create = true
		err = worktree.Checkout(checkoutOptions)
		if err != nil {
			return errors.Wrap(err, "failed to checkout branch")
		}
	}

	return nil
}

// LogRange Returns a CommitIterator providing all commits reachable from 'to', but
// not reachable by 'from'.
func LogRange(repo *git.Repository, from, to string) (object.CommitIter, error) {

	cTo, err := getCommitObject(to, repo)
	if err != nil {
		return nil, errors.Wrapf(err, "Cannot provide log range (to: '%s' not found)", to)
	}
	cFrom, err := getCommitObject(from, repo)
	if err != nil {
		return nil, errors.Wrapf(err, "Cannot provide log range (from: '%s' not found)", from)
	}
	ignore := []plumbing.Hash{}
	err = object.NewCommitPreorderIter(
		cFrom,
		map[plumbing.Hash]bool{},
		[]plumbing.Hash{},
	).ForEach(func(c *object.Commit) error {
		ignore = append(ignore, c.ID())
		return nil
	})
	if err != nil {
		return nil, errors.Wrap(err, "Cannot provide log range")
	}

	return object.NewCommitPreorderIter(cTo, map[plumbing.Hash]bool{}, ignore), nil
}

func getCommitObject(ref string, repo *git.Repository) (*object.Commit, error) {
	if len(ref) == 0 {
		// with go-git v5.1.0 we panic otherwise inside ResolveRevision
		return nil, errors.New("Cannot get a commit for an empty ref")
	}
	r, err := repo.ResolveRevision(plumbing.Revision(ref))
	if err != nil {
		return nil, errors.Wrapf(err, "Trouble resolving '%s'", ref)
	}
	c, err := repo.CommitObject(*r)
	if err != nil {
		return nil, errors.Wrapf(err, "Trouble resolving '%s'", ref)
	}
	return c, nil
}

type abstractionGit struct{}

func (abstractionGit) plainClone(path string, isBare bool, o *git.CloneOptions) (*git.Repository, error) {
	return git.PlainClone(path, isBare, o)
}

func (abstractionGit) plainOpen(path string) (*git.Repository, error) {
	return git.PlainOpen(path)
}
