// Copyright (c) 2014-2017 Cesanta Software Limited
// All rights reserved

package ourgit

import (
	"encoding/hex"
	"fmt"
	"io/ioutil"
	"os"
	"path"
	"path/filepath"

	git "github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/config"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/storer"
	"github.com/go-git/go-git/v5/plumbing/transport"
	"github.com/go-git/go-git/v5/plumbing/transport/http"
	"github.com/juju/errors"
	glog "k8s.io/klog/v2"
)

// NewOurGit returns a go-git-based implementation of OurGit
// (it doesn't require an external git binary, but is somewhat limited; for
// example, it doesn't support referenced repositories)
func NewOurGitGoGit(creds *Credentials) OurGit {
	res := &ourGitGoGit{}
	if creds != nil {
		res.auth = &http.BasicAuth{
			Username: creds.User,
			Password: creds.Pass,
		}
	}
	return res
}

type ourGitGoGit struct {
	auth transport.AuthMethod
}

func (m *ourGitGoGit) GetCurrentHash(localDir string) (string, error) {
	repo, err := git.PlainOpen(localDir)
	if err != nil {
		return "", errors.Trace(err)
	}

	head, err := repo.Head()
	if err != nil {
		return "", errors.Annotatef(err, "%s", localDir)
	}

	return head.Hash().String(), nil
}

func doesRefExist(iter storer.ReferenceIter, name string) (bool, error) {
	exists := false

	err := iter.ForEach(func(branch *plumbing.Reference) error {
		if branch.Name().Short() == name {
			exists = true
		}
		return nil
	})
	if err != nil {
		return false, errors.Trace(err)
	}

	return exists, nil
}

func (m *ourGitGoGit) DoesBranchExist(localDir string, branchName string) (bool, error) {
	repo, err := git.PlainOpen(localDir)
	if err != nil {
		return false, errors.Trace(err)
	}

	branches, err := repo.Branches()
	if err != nil {
		return false, errors.Trace(err)
	}

	exists, err := doesRefExist(branches, branchName)
	if err != nil {
		return false, errors.Trace(err)
	}

	return exists, nil
}

func (m *ourGitGoGit) DoesTagExist(localDir string, tagName string) (bool, error) {
	repo, err := git.PlainOpen(localDir)
	if err != nil {
		return false, errors.Trace(err)
	}

	tags, err := repo.Tags()
	if err != nil {
		return false, errors.Trace(err)
	}

	exists, err := doesRefExist(tags, tagName)
	if err != nil {
		return false, errors.Trace(err)
	}

	return exists, nil
}

func (m *ourGitGoGit) GetToplevelDir(localDir string) (string, error) {
	localDir, err := filepath.Abs(localDir)
	if err != nil {
		return "", errors.Trace(err)
	}

	for localDir != "" {
		if _, err := os.Stat(filepath.Join(localDir, ".git")); err == nil {
			return localDir, nil
		}

		localDirNew, err := filepath.Abs(filepath.Join(localDir, ".."))
		if err != nil {
			return "", errors.Trace(err)
		}

		if localDirNew == localDir {
			return "", nil
		}

		localDir = localDirNew
	}

	return localDir, nil
}

func (m *ourGitGoGit) Checkout(localDir string, id string, refType RefType) error {
	repo, err := git.PlainOpen(localDir)
	if err != nil {
		return errors.Trace(err)
	}

	wt, err := repo.Worktree()
	if err != nil {
		return errors.Trace(err)
	}

	switch refType {
	case RefTypeBranch:
		err = wt.Checkout(&git.CheckoutOptions{
			Branch: plumbing.ReferenceName("refs/heads/" + id),
		})

	case RefTypeTag:
		err = wt.Checkout(&git.CheckoutOptions{
			Branch: plumbing.ReferenceName("refs/tags/" + id),
		})

	case RefTypeHash:
		var hash plumbing.Hash
		hash, err = newHashSafe(id)
		if err == nil {
			err = wt.Checkout(&git.CheckoutOptions{
				Hash: hash,
			})
		}
	}
	if err != nil {
		return errors.Annotatef(err, "checking out a %s %q in %q", refType, id, localDir)
	}

	return nil
}

func (m *ourGitGoGit) ResetHard(localDir string) error {
	repo, err := git.PlainOpen(localDir)
	if err != nil {
		return errors.Trace(err)
	}

	wt, err := repo.Worktree()
	if err != nil {
		return errors.Trace(err)
	}

	err = wt.Reset(&git.ResetOptions{
		Mode: git.HardReset,
	})
	if err != nil {
		return errors.Trace(err)
	}

	return nil
}

func (m *ourGitGoGit) Pull(localDir string, branch string) error {
	glog.Infof("Pulling %s %s", localDir, branch)

	repo, err := git.PlainOpen(localDir)
	if err != nil {
		return errors.Trace(err)
	}

	wt, err := repo.Worktree()
	if err != nil {
		return errors.Trace(err)
	}

	err = wt.Pull(&git.PullOptions{
		Auth:          m.auth,
		ReferenceName: plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branch)),
	})
	if err != nil && errors.Cause(err) != git.NoErrAlreadyUpToDate {
		return errors.Trace(err)
	}

	return nil
}

func (m *ourGitGoGit) Fetch(localDir string, what string, opts FetchOptions) error {
	repo, err := git.PlainOpen(localDir)
	if err != nil {
		return errors.Annotatef(err, "failed to open repo %s", localDir)
	}

	// Try fetching as a branch.
	err = repo.Fetch(&git.FetchOptions{
		Auth:     m.auth,
		RefSpecs: []config.RefSpec{config.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", what, what))},
		Tags:     git.AllTags,
		Depth:    opts.Depth,
	})
	if err == nil || errors.Cause(err) == git.NoErrAlreadyUpToDate {
		return nil
	}

	// Try fetching as a tag.
	err = repo.Fetch(&git.FetchOptions{
		Auth:     m.auth,
		RefSpecs: []config.RefSpec{config.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", what, what))},
		Tags:     git.AllTags,
		Depth:    opts.Depth,
	})
	if err != nil && errors.Cause(err) != git.NoErrAlreadyUpToDate {
		return errors.Annotatef(err, "failed to git fetch %s %s", localDir, what)
	}

	return nil
}

// IsClean returns true if there are no modified, deleted or untracked files,
// and no non-pushed commits since the given version.
func (m *ourGitGoGit) IsClean(localDir, version string, excludeGlobs []string) (bool, error) {
	repo, err := git.PlainOpen(localDir)
	if err != nil {
		return false, errors.Trace(err)
	}

	wt, err := repo.Worktree()
	if err != nil {
		return false, errors.Trace(err)
	}

	status, err := wt.Status()
	if err != nil {
		return false, errors.Annotatef(err, "IsClean(%s)", localDir)
	}

	r := true
s:
	for fn, fs := range status {
		for _, g := range excludeGlobs {
			m1, _ := path.Match(g, fn)
			m2, _ := path.Match(g, path.Base(fn))
			if m1 || m2 {
				continue s
			}
		}
		if fs.Worktree != git.Unmodified || fs.Staging != git.Unmodified {
			glog.Errorf("%s: dirty: %s %c %c", localDir, fn, fs.Worktree, fs.Staging)
			r = false
		}
	}

	return r, nil
}

func (m *ourGitGoGit) Clone(srcURL, localDir string, opts CloneOptions) error {
	// Check if the dir existed before we try to do the clone
	existed := false
	if _, err := os.Stat(localDir); !os.IsNotExist(err) {
		files, err := ioutil.ReadDir(localDir)
		if err != nil {
			return errors.Trace(err)
		}

		existed = len(files) > 0
	}

	if opts.ReferenceDir != "" {
		return errors.Errorf("ReferenceDir is not implemented for go-git impl")
	}

	goGitOpts := []git.CloneOptions{
		git.CloneOptions{
			Auth:  m.auth,
			URL:   srcURL,
			Depth: opts.Depth,
			Tags:  git.TagFollowing,
		},
	}

	// If depth is non-zero, also assume we should only clone a single branch
	if opts.Depth != 0 {
		goGitOpts[0].Depth = opts.Depth
		goGitOpts[0].SingleBranch = true
	}

	if opts.Ref != "" {
		// We asked to clone at the certain ref instead of master. Unfortunately
		// there's no way (that I know of) in go-git to specify a name of a branch
		// OR a name of tag OR a hash, so we have to try all of the three
		// separately.
		goGitOpts = append(goGitOpts, goGitOpts[0], goGitOpts[0])
		goGitOpts[0].ReferenceName = plumbing.ReferenceName("refs/heads/" + opts.Ref)
		goGitOpts[1].ReferenceName = plumbing.ReferenceName("refs/tags/" + opts.Ref)
		goGitOpts[2].ReferenceName = "" // TODO(dfrank): use hash
	}

	// Do the clone. If opts.Ref was empty, goGitOpts contains just a single
	// element, so there will be just one iteration of the loop. If opts.Ref was
	// non-empty, there will be up to 3 iterations (try branch, try tag, try
	// hash)
	var err error
	for _, o := range goGitOpts {
		if !existed {
			os.RemoveAll(localDir)
		}
		_, err = git.PlainClone(localDir, false, &o)
		if err == nil {
			break
		}
	}

	if err != nil {
		return errors.Annotatef(err, "cloning %q to %q", srcURL, localDir)
	}

	return nil
}

func (m *ourGitGoGit) GetOriginURL(localDir string) (string, error) {
	repo, err := git.PlainOpen(localDir)
	if err != nil {
		return "", errors.Trace(err)
	}

	remotes, err := repo.Remotes()
	if err != nil {
		return "", errors.Trace(err)
	}

	for _, r := range remotes {
		if r.Config().Name == "origin" {
			return r.Config().URLs[0], nil
		}
	}

	return "", errors.Errorf("failed to get origin URL")
}

// NewHash return a new Hash from a hexadecimal hash representation
func newHashSafe(s string) (plumbing.Hash, error) {
	b, err := hex.DecodeString(s)
	if err != nil {
		return plumbing.Hash{}, errors.Annotatef(err, "trying to interpret %q as a git hash", s)
	}

	// TODO(dfrank): at the moment (10/11/2017) git-go doesn't support partial
	// hashes; hopefully it will be fixed in the future.
	if len(s) != fullHashLen {
		return plumbing.Hash{}, errors.Errorf(
			"partial git hashes are not supported (given: %s), hash should have exactly %d characters",
			s, fullHashLen,
		)
	}

	var h plumbing.Hash
	copy(h[:], b)

	return h, nil
}
