// Copyright 2017 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package gps

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/Masterminds/semver"
	"github.com/golang/dep/gps/pkgtree"
	"github.com/golang/dep/internal/fs"
	"github.com/pkg/errors"
)

type baseVCSSource struct {
	repo ctxRepo
}

func (bs *baseVCSSource) sourceType() string {
	return string(bs.repo.Vcs())
}

func (bs *baseVCSSource) existsLocally(ctx context.Context) bool {
	return bs.repo.CheckLocal()
}

func (bs *baseVCSSource) existsUpstream(ctx context.Context) bool {
	return bs.repo.Ping()
}

func (*baseVCSSource) existsCallsListVersions() bool {
	return false
}

func (*baseVCSSource) listVersionsRequiresLocal() bool {
	return false
}

func (bs *baseVCSSource) upstreamURL() string {
	return bs.repo.Remote()
}

func (bs *baseVCSSource) disambiguateRevision(ctx context.Context, r Revision) (Revision, error) {
	ci, err := bs.repo.CommitInfo(string(r))
	if err != nil {
		return "", err
	}
	return Revision(ci.Commit), nil
}

func (bs *baseVCSSource) getManifestAndLock(ctx context.Context, pr ProjectRoot, r Revision, an ProjectAnalyzer) (Manifest, Lock, error) {
	err := bs.repo.updateVersion(ctx, r.String())
	if err != nil {
		return nil, nil, unwrapVcsErr(err)
	}

	m, l, err := an.DeriveManifestAndLock(bs.repo.LocalPath(), pr)
	if err != nil {
		return nil, nil, err
	}

	if l != nil && l != Lock(nil) {
		l = prepLock(l)
	}

	return prepManifest(m), l, nil
}

func (bs *baseVCSSource) revisionPresentIn(r Revision) (bool, error) {
	return bs.repo.IsReference(string(r)), nil
}

// initLocal clones/checks out the upstream repository to disk for the first
// time.
func (bs *baseVCSSource) initLocal(ctx context.Context) error {
	err := bs.repo.get(ctx)

	if err != nil {
		return unwrapVcsErr(err)
	}
	return nil
}

// updateLocal ensures the local data (versions and code) we have about the
// source is fully up to date with that of the canonical upstream source.
func (bs *baseVCSSource) updateLocal(ctx context.Context) error {
	err := bs.repo.fetch(ctx)
	if err == nil {
		return nil
	}

	ec, ok := bs.repo.(ensureCleaner)
	if !ok {
		return err
	}

	if err := ec.ensureClean(ctx); err != nil {
		return unwrapVcsErr(err)
	}

	if err := bs.repo.fetch(ctx); err != nil {
		return unwrapVcsErr(err)
	}
	return nil
}

func (bs *baseVCSSource) maybeClean(ctx context.Context) error {
	ec, ok := bs.repo.(ensureCleaner)
	if !ok {
		return nil
	}

	if err := ec.ensureClean(ctx); err != nil {
		return unwrapVcsErr(err)
	}
	return nil
}

func (bs *baseVCSSource) listPackages(ctx context.Context, pr ProjectRoot, r Revision) (ptree pkgtree.PackageTree, err error) {
	err = bs.repo.updateVersion(ctx, r.String())

	if err != nil {
		err = unwrapVcsErr(err)
	} else {
		ptree, err = pkgtree.ListPackages(bs.repo.LocalPath(), string(pr))
	}

	return
}

func (bs *baseVCSSource) exportRevisionTo(ctx context.Context, r Revision, to string) error {
	// Only make the parent dir, as CopyDir will balk on trying to write to an
	// empty but existing dir.
	if err := os.MkdirAll(filepath.Dir(to), 0777); err != nil {
		return err
	}

	if err := bs.repo.updateVersion(ctx, r.String()); err != nil {
		return unwrapVcsErr(err)
	}

	return fs.CopyDir(bs.repo.LocalPath(), to)
}

var (
	gitHashRE = regexp.MustCompile(`^[a-f0-9]{40}$`)
)

// gitSource is a generic git repository implementation that should work with
// all standard git remotes.
type gitSource struct {
	baseVCSSource
}

func (s *gitSource) exportRevisionTo(ctx context.Context, rev Revision, to string) error {
	r := s.repo

	if err := os.MkdirAll(to, 0777); err != nil {
		return err
	}

	// Back up original index
	idx, bak := filepath.Join(r.LocalPath(), ".git", "index"), filepath.Join(r.LocalPath(), ".git", "origindex")
	err := fs.RenameWithFallback(idx, bak)
	if err != nil {
		return err
	}

	// could have an err here...but it's hard to imagine how?
	defer fs.RenameWithFallback(bak, idx)

	{
		cmd := commandContext(ctx, "git", "read-tree", rev.String())
		cmd.SetDir(r.LocalPath())
		if out, err := cmd.CombinedOutput(); err != nil {
			return errors.Wrap(err, string(out))
		}
	}

	// Ensure we have exactly one trailing slash
	to = strings.TrimSuffix(to, string(os.PathSeparator)) + string(os.PathSeparator)
	// Checkout from our temporary index to the desired target location on
	// disk; now it's git's job to make it fast.
	//
	// Sadly, this approach *does* also write out vendor dirs. There doesn't
	// appear to be a way to make checkout-index respect sparse checkout
	// rules (-a supersedes it). The alternative is using plain checkout,
	// though we have a bunch of housekeeping to do to set up, then tear
	// down, the sparse checkout controls, as well as restore the original
	// index and HEAD.
	{
		cmd := commandContext(ctx, "git", "checkout-index", "-a", "--prefix="+to)
		cmd.SetDir(r.LocalPath())
		if out, err := cmd.CombinedOutput(); err != nil {
			return errors.Wrap(err, string(out))
		}
	}

	return nil
}

func (s *gitSource) isValidHash(hash []byte) bool {
	return gitHashRE.Match(hash)
}

func (*gitSource) existsCallsListVersions() bool {
	return true
}

func (s *gitSource) listVersions(ctx context.Context) (vlist []PairedVersion, err error) {
	r := s.repo

	cmd := commandContext(ctx, "git", "ls-remote", r.Remote())
	// We want to invoke from a place where it's not possible for there to be a
	// .git file instead of a .git directory, as git ls-remote will choke on the
	// former and erroneously quit. However, we can't be sure that the repo
	// exists on disk yet at this point; if it doesn't, then instead use the
	// parent of the local path, as that's still likely a good bet.
	if r.CheckLocal() {
		cmd.SetDir(r.LocalPath())
	} else {
		cmd.SetDir(filepath.Dir(r.LocalPath()))
	}
	// Ensure no prompting for PWs
	cmd.SetEnv(append([]string{"GIT_ASKPASS=", "GIT_TERMINAL_PROMPT=0"}, os.Environ()...))
	out, err := cmd.CombinedOutput()
	if err != nil {
		return nil, errors.Wrap(err, string(out))
	}

	all := bytes.Split(bytes.TrimSpace(out), []byte("\n"))
	if len(all) == 1 && len(all[0]) == 0 {
		return nil, fmt.Errorf("no data returned from ls-remote")
	}

	// Pull out the HEAD rev (it's always first) so we know what branches to
	// mark as default. This is, perhaps, not the best way to glean this, but it
	// was good enough for git itself until 1.8.5. Also, the alternative is
	// sniffing data out of the pack protocol, which is a separate request, and
	// also waaaay more than we want to do right now.
	//
	// The cost is that we could potentially have multiple branches marked as
	// the default. If that does occur, a later check (again, emulating git
	// <1.8.5 behavior) further narrows the failure mode by choosing master as
	// the sole default branch if a) master exists and b) master is one of the
	// branches marked as a default.
	//
	// This all reduces the failure mode to a very narrow range of
	// circumstances. Nevertheless, if we do end up emitting multiple
	// default branches, it is possible that a user could end up following a
	// non-default branch, IF:
	//
	// * Multiple branches match the HEAD rev
	// * None of them are master
	// * The solver makes it into the branch list in the version queue
	// * The user/tool has provided no constraint (so, anyConstraint)
	// * A branch that is not actually the default, but happens to share the
	//   rev, is lexicographically less than the true default branch
	//
	// If all of those conditions are met, then the user would end up with an
	// erroneous non-default branch in their lock file.
	var headrev Revision
	var onedef, multidef, defmaster bool

	smap := make(map[string]int)
	uniq := 0
	vlist = make([]PairedVersion, len(all))
	for _, pair := range all {
		var v PairedVersion
		// Valid `git ls-remote` output should start with hash, be at least
		// 45 chars long and 40th character should be '\t'
		//
		// See: https://github.com/golang/dep/pull/1160#issuecomment-328843519
		if len(pair) < 45 || pair[40] != '\t' || !s.isValidHash(pair[:40]) {
			continue
		}
		if string(pair[41:]) == "HEAD" {
			// If HEAD is present, it's always first
			headrev = Revision(pair[:40])
		} else if string(pair[46:51]) == "heads" {
			rev := Revision(pair[:40])

			isdef := rev == headrev
			n := string(pair[52:])
			if isdef {
				if onedef {
					multidef = true
				}
				onedef = true
				if n == "master" {
					defmaster = true
				}
			}
			v = branchVersion{
				name:      n,
				isDefault: isdef,
			}.Pair(rev).(PairedVersion)

			vlist[uniq] = v
			uniq++
		} else if string(pair[46:50]) == "tags" {
			vstr := string(pair[51:])
			if strings.HasSuffix(vstr, "^{}") {
				// If the suffix is there, then we *know* this is the rev of
				// the underlying commit object that we actually want
				vstr = strings.TrimSuffix(vstr, "^{}")
				if i, ok := smap[vstr]; ok {
					v = NewVersion(vstr).Pair(Revision(pair[:40]))
					vlist[i] = v
					continue
				}
			} else if _, ok := smap[vstr]; ok {
				// Already saw the deref'd version of this tag, if one
				// exists, so skip this.
				continue
				// Can only hit this branch if we somehow got the deref'd
				// version first. Which should be impossible, but this
				// covers us in case of weirdness, anyway.
			}
			v = NewVersion(vstr).Pair(Revision(pair[:40]))
			smap[vstr] = uniq
			vlist[uniq] = v
			uniq++
		}
	}

	// Trim off excess from the slice
	vlist = vlist[:uniq]

	// There were multiple default branches, but one was master. So, go through
	// and strip the default flag from all the non-master branches.
	if multidef && defmaster {
		for k, v := range vlist {
			pv := v.(PairedVersion)
			if bv, ok := pv.Unpair().(branchVersion); ok {
				if bv.name != "master" && bv.isDefault {
					bv.isDefault = false
					vlist[k] = bv.Pair(pv.Revision())
				}
			}
		}
	}

	return
}

// gopkginSource is a specialized git source that performs additional filtering
// according to the input URL.
type gopkginSource struct {
	gitSource
	major    uint64
	unstable bool
	// The aliased URL we report as being the one we talk to, even though we're
	// actually talking directly to GitHub.
	aliasURL string
}

func (s *gopkginSource) upstreamURL() string {
	return s.aliasURL
}

func (s *gopkginSource) listVersions(ctx context.Context) ([]PairedVersion, error) {
	ovlist, err := s.gitSource.listVersions(ctx)
	if err != nil {
		return nil, err
	}

	// Apply gopkg.in's filtering rules
	vlist := make([]PairedVersion, len(ovlist))
	k := 0
	var dbranch int // index of branch to be marked default
	var bsv semver.Version
	var defaultBranch PairedVersion
	tryDefaultAsV0 := s.major == 0
	for _, v := range ovlist {
		// all git versions will always be paired
		pv := v.(versionPair)
		switch tv := pv.v.(type) {
		case semVersion:
			tryDefaultAsV0 = false
			if tv.sv.Major() == s.major && !s.unstable {
				vlist[k] = v
				k++
			}
		case branchVersion:
			if tv.isDefault && defaultBranch == nil {
				defaultBranch = pv
			}

			// The semver lib isn't exactly the same as gopkg.in's logic, but
			// it's close enough that it's probably fine to use. We can be more
			// exact if real problems crop up.
			sv, err := semver.NewVersion(tv.name)
			if err != nil {
				continue
			}
			tryDefaultAsV0 = false

			if sv.Major() != s.major {
				// not the same major version as specified in the import path constraint
				continue
			}

			// Gopkg.in has a special "-unstable" suffix which we need to handle
			// separately.
			if s.unstable != strings.HasSuffix(tv.name, gopkgUnstableSuffix) {
				continue
			}

			// Turn off the default branch marker unconditionally; we can't know
			// which one to mark as default until we've seen them all
			tv.isDefault = false
			// Figure out if this is the current leader for default branch
			if bsv == (semver.Version{}) || bsv.LessThan(sv) {
				bsv = sv
				dbranch = k
			}
			pv.v = tv
			vlist[k] = pv
			k++
		}
		// The switch skips plainVersions because they cannot possibly meet
		// gopkg.in's requirements
	}

	vlist = vlist[:k]
	if bsv != (semver.Version{}) {
		dbv := vlist[dbranch].(versionPair)
		vlist[dbranch] = branchVersion{
			name:      dbv.v.(branchVersion).name,
			isDefault: true,
		}.Pair(dbv.r)
	}

	// Treat the default branch as v0 only when no other semver branches/tags exist
	// See http://labix.org/gopkg.in#VersionZero
	if tryDefaultAsV0 && defaultBranch != nil {
		vlist = append(vlist, defaultBranch)
	}

	return vlist, nil
}

// bzrSource is a generic bzr repository implementation that should work with
// all standard bazaar remotes.
type bzrSource struct {
	baseVCSSource
}

func (s *bzrSource) exportRevisionTo(ctx context.Context, rev Revision, to string) error {
	if err := s.baseVCSSource.exportRevisionTo(ctx, rev, to); err != nil {
		return err
	}

	return os.RemoveAll(filepath.Join(to, ".bzr"))
}

func (s *bzrSource) listVersionsRequiresLocal() bool {
	return true
}

func (s *bzrSource) listVersions(ctx context.Context) ([]PairedVersion, error) {
	r := s.repo

	// Now, list all the tags
	tagsCmd := commandContext(ctx, "bzr", "tags", "--show-ids", "-v")
	tagsCmd.SetDir(r.LocalPath())
	out, err := tagsCmd.CombinedOutput()
	if err != nil {
		return nil, errors.Wrap(err, string(out))
	}

	all := bytes.Split(bytes.TrimSpace(out), []byte("\n"))

	viCmd := commandContext(ctx, "bzr", "version-info", "--custom", "--template={revision_id}", "--revision=branch:.")
	viCmd.SetDir(r.LocalPath())
	branchrev, err := viCmd.CombinedOutput()
	if err != nil {
		return nil, errors.Wrap(err, string(branchrev))
	}

	vlist := make([]PairedVersion, 0, len(all)+1)

	// Now, all the tags.
	for _, line := range all {
		idx := bytes.IndexByte(line, 32) // space
		v := NewVersion(string(line[:idx]))
		r := Revision(bytes.TrimSpace(line[idx:]))
		vlist = append(vlist, v.Pair(r))
	}

	// Last, add the default branch, hardcoding the visual representation of it
	// that bzr uses when operating in the workflow mode we're using.
	v := newDefaultBranch("(default)")
	vlist = append(vlist, v.Pair(Revision(string(branchrev))))

	return vlist, nil
}

func (s *bzrSource) disambiguateRevision(ctx context.Context, r Revision) (Revision, error) {
	// If we used the default baseVCSSource behavior here, we would return the
	// bazaar revision number, which is not a globally unique identifier - it is
	// only unique within a branch. This is just the way that
	// github.com/Masterminds/vcs chooses to handle bazaar. We want a
	// disambiguated unique ID, though, so we need slightly different behavior:
	// check whether r doesn't error when we try to look it up. If so, trust that
	// it's a revision.
	_, err := s.repo.CommitInfo(string(r))
	if err != nil {
		return "", err
	}
	return r, nil
}

// hgSource is a generic hg repository implementation that should work with
// all standard mercurial servers.
type hgSource struct {
	baseVCSSource
}

func (s *hgSource) exportRevisionTo(ctx context.Context, rev Revision, to string) error {
	// TODO: use hg instead of the generic approach in
	// baseVCSSource.exportRevisionTo to make it faster.
	if err := s.baseVCSSource.exportRevisionTo(ctx, rev, to); err != nil {
		return err
	}

	return os.RemoveAll(filepath.Join(to, ".hg"))
}

func (s *hgSource) listVersionsRequiresLocal() bool {
	return true
}

func (s *hgSource) hgCmd(ctx context.Context, args ...string) cmd {
	r := s.repo
	cmd := commandContext(ctx, "hg", args...)
	cmd.SetDir(r.LocalPath())
	// Let's make sure extensions don't interfere with our expectations
	// regarding the output of commands.
	cmd.Cmd.Env = append(cmd.Cmd.Env, "HGRCPATH=")
	return cmd
}

func (s *hgSource) listVersions(ctx context.Context) ([]PairedVersion, error) {
	var vlist []PairedVersion

	// Now, list all the tags
	tagsCmd := s.hgCmd(ctx, "tags", "--debug", "--verbose")
	out, err := tagsCmd.CombinedOutput()
	if err != nil {
		return nil, errors.Wrap(err, string(out))
	}

	all := bytes.Split(bytes.TrimSpace(out), []byte("\n"))
	lbyt := []byte("local")
	nulrev := []byte("0000000000000000000000000000000000000000")
	for _, line := range all {
		if bytes.Equal(lbyt, line[len(line)-len(lbyt):]) {
			// Skip local tags
			continue
		}

		// tip is magic, don't include it
		if bytes.HasPrefix(line, []byte("tip")) {
			continue
		}

		// Split on colon; this gets us the rev and the tag plus local revno
		pair := bytes.Split(line, []byte(":"))
		if bytes.Equal(nulrev, pair[1]) {
			// null rev indicates this tag is marked for deletion
			continue
		}

		idx := bytes.IndexByte(pair[0], 32) // space
		v := NewVersion(string(pair[0][:idx])).Pair(Revision(pair[1])).(PairedVersion)
		vlist = append(vlist, v)
	}

	// bookmarks next, because the presence of the magic @ bookmark has to
	// determine how we handle the branches
	var magicAt bool
	bookmarksCmd := s.hgCmd(ctx, "bookmarks", "--debug")
	out, err = bookmarksCmd.CombinedOutput()
	if err != nil {
		// better nothing than partial and misleading
		return nil, errors.Wrap(err, string(out))
	}

	out = bytes.TrimSpace(out)
	if !bytes.Equal(out, []byte("no bookmarks set")) {
		all = bytes.Split(out, []byte("\n"))
		for _, line := range all {
			// Trim leading spaces, and * marker if present
			line = bytes.TrimLeft(line, " *")
			pair := bytes.Split(line, []byte(":"))
			// if this doesn't split exactly once, we have something weird
			if len(pair) != 2 {
				continue
			}

			// Split on colon; this gets us the rev and the branch plus local revno
			idx := bytes.IndexByte(pair[0], 32) // space
			// if it's the magic @ marker, make that the default branch
			str := string(pair[0][:idx])
			var v PairedVersion
			if str == "@" {
				magicAt = true
				v = newDefaultBranch(str).Pair(Revision(pair[1])).(PairedVersion)
			} else {
				v = NewBranch(str).Pair(Revision(pair[1])).(PairedVersion)
			}
			vlist = append(vlist, v)
		}
	}

	cmd := s.hgCmd(ctx, "branches", "-c", "--debug")
	out, err = cmd.CombinedOutput()
	if err != nil {
		// better nothing than partial and misleading
		return nil, errors.Wrap(err, string(out))
	}

	all = bytes.Split(bytes.TrimSpace(out), []byte("\n"))
	for _, line := range all {
		// Trim inactive and closed suffixes, if present; we represent these
		// anyway
		line = bytes.TrimSuffix(line, []byte(" (inactive)"))
		line = bytes.TrimSuffix(line, []byte(" (closed)"))

		// Split on colon; this gets us the rev and the branch plus local revno
		pair := bytes.Split(line, []byte(":"))
		idx := bytes.IndexByte(pair[0], 32) // space
		str := string(pair[0][:idx])
		// if there was no magic @ bookmark, and this is mercurial's magic
		// "default" branch, then mark it as default branch
		var v PairedVersion
		if !magicAt && str == "default" {
			v = newDefaultBranch(str).Pair(Revision(pair[1])).(PairedVersion)
		} else {
			v = NewBranch(str).Pair(Revision(pair[1])).(PairedVersion)
		}
		vlist = append(vlist, v)
	}

	return vlist, nil
}
