package main

import (
	"bufio"
	"bytes"
	"errors"
	"fmt"
	"go/build"
	"io"
	"io/ioutil"
	"log"
	"os"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/tools/godep/Godeps/_workspace/src/github.com/kr/fs"
)

var cmdSave = &Command{
	Name:  "save",
	Args:  "[-r] [-t] [packages]",
	Short: "list and copy dependencies into Godeps",
	Long: `

Save writes a list of the named packages and their dependencies along
with the exact source control revision of each package, and copies
their source code into a subdirectory. Packages inside "." are excluded
from the list to be copied.

The list is written to Godeps/Godeps.json, and source code for all
dependencies is copied into either Godeps/_workspace or, if the vendor
experiment is turned on, vendor/.

The dependency list is a JSON document with the following structure:

	type Godeps struct {
		ImportPath string
		GoVersion  string   // Abridged output of 'go version'.
		Packages   []string // Arguments to godep save, if any.
		Deps       []struct {
			ImportPath string
			Comment    string // Tag or description of commit.
			Rev        string // VCS-specific commit ID.
		}
	}

Any packages already present in the list will be left unchanged.
To update a dependency to a newer revision, use 'godep update'.

If -r is given, import statements will be rewritten to refer directly
to the copied source code. This is not compatible with the vendor
experiment. Note that this will not rewrite the statements in the
files outside the project.

If -t is given, test files (*_test.go files + testdata directories) are
also saved.

For more about specifying packages, see 'go help packages'.
`,
	Run:          runSave,
	OnlyInGOPATH: true,
}

var (
	saveR, saveT bool
)

func init() {
	cmdSave.Flag.BoolVar(&saveR, "r", false, "rewrite import paths")
	cmdSave.Flag.BoolVar(&saveT, "t", false, "save test files")

}

func runSave(cmd *Command, args []string) {
	if VendorExperiment && saveR {
		log.Println("flag -r is incompatible with the vendoring experiment")
		cmd.UsageExit()
	}
	err := save(args)
	if err != nil {
		log.Fatalln(err)
	}
}

func dotPackage() (*build.Package, error) {
	dir, err := filepath.Abs(".")
	if err != nil {
		return nil, err
	}
	return build.ImportDir(dir, build.FindOnly)
}

func projectPackages(dDir string, a []*Package) []*Package {
	var projPkgs []*Package
	dotDir := fmt.Sprintf("%s%c", dDir, filepath.Separator)
	for _, p := range a {
		pkgDir := fmt.Sprintf("%s%c", p.Dir, filepath.Separator)
		if strings.HasPrefix(pkgDir, dotDir) {
			projPkgs = append(projPkgs, p)
		}
	}
	return projPkgs
}

func save(pkgs []string) error {
	var err error
	dp, err := dotPackage()
	if err != nil {
		return err
	}
	debugln("dotPackageImportPath:", dp.ImportPath)
	debugln("dotPackageDir:", dp.Dir)

	cv, err := goVersion()
	if err != nil {
		return err
	}
	verboseln("Go Version:", cv)

	gold, err := loadDefaultGodepsFile()
	if err != nil {
		if !os.IsNotExist(err) {
			return err
		}
		verboseln("No old Godeps.json found.")
		gold.GoVersion = cv
	}

	printVersionWarnings(gold.GoVersion)
	if len(gold.GoVersion) == 0 {
		gold.GoVersion = majorGoVersion
	} else {
		majorGoVersion, err = trimGoVersion(gold.GoVersion)
		if err != nil {
			log.Fatalf("Unable to determine go major version from value specified in %s: %s\n", gold.file(), gold.GoVersion)
		}
	}

	gnew := &Godeps{
		ImportPath: dp.ImportPath,
		GoVersion:  gold.GoVersion,
	}

	switch len(pkgs) {
	case 0:
		pkgs = []string{"."}
	default:
		gnew.Packages = pkgs
	}

	verboseln("Finding dependencies for", pkgs)
	a, err := LoadPackages(pkgs...)
	if err != nil {
		return err
	}

	for _, p := range a {
		verboseln("Found package:", p.ImportPath)
		verboseln("\tDeps:", strings.Join(p.Deps, " "))
	}
	ppln(a)

	projA := projectPackages(dp.Dir, a)
	debugln("Filtered projectPackages")
	ppln(projA)

	verboseln("Computing new Godeps.json file")
	err = gnew.fill(a, dp.ImportPath)
	if err != nil {
		return err
	}
	debugln("New Godeps Filled")
	ppln(gnew)

	if gnew.Deps == nil {
		gnew.Deps = make([]Dependency, 0) // produce json [], not null
	}
	gdisk := gnew.copy()
	err = carryVersions(&gold, gnew)
	if err != nil {
		return err
	}

	if gold.isOldFile {
		// If we are migrating from an old format file,
		// we require that the listed version of every
		// dependency must be installed in GOPATH, so it's
		// available to copy.
		if !eqDeps(gnew.Deps, gdisk.Deps) {
			return errors.New(strings.TrimSpace(needRestore))
		}
		gold = Godeps{}
	}
	os.Remove("Godeps") // remove regular file if present; ignore error
	readme := filepath.Join("Godeps", "Readme")
	err = writeFile(readme, strings.TrimSpace(Readme)+"\n")
	if err != nil {
		log.Println(err)
	}
	_, err = gnew.save()
	if err != nil {
		return err
	}

	verboseln("Computing diff between old and new deps")
	// We use a name starting with "_" so the go tool
	// ignores this directory when traversing packages
	// starting at the project's root. For example,
	//   godep go list ./...
	srcdir := filepath.FromSlash(strings.Trim(sep, "/"))
	rem := subDeps(gold.Deps, gnew.Deps)
	ppln(rem)
	add := subDeps(gnew.Deps, gold.Deps)
	ppln(add)
	if len(rem) > 0 {
		verboseln("Deps to remove:")
		for _, r := range rem {
			verboseln("\t", r.ImportPath)
		}
		verboseln("Removing unused dependencies")
		err = removeSrc(srcdir, rem)
		if err != nil {
			return err
		}
	}
	if len(add) > 0 {
		verboseln("Deps to add:")
		for _, a := range add {
			verboseln("\t", a.ImportPath)
		}
		verboseln("Adding new dependencies")
		err = copySrc(srcdir, add)
		if err != nil {
			return err
		}
	}
	if !VendorExperiment {
		f, _ := filepath.Split(srcdir)
		writeVCSIgnore(f)
	}
	var rewritePaths []string
	if saveR {
		for _, dep := range gnew.Deps {
			rewritePaths = append(rewritePaths, dep.ImportPath)
		}
	}
	verboseln("Rewriting paths (if necessary)")
	ppln(rewritePaths)
	return rewrite(projA, dp.ImportPath, rewritePaths)
}

func printVersionWarnings(ov string) {
	var warning bool
	cv, err := goVersion()
	if err != nil {
		return
	}
	tov, err := trimGoVersion(ov)
	if err != nil {
		return
	}
	tcv, err := trimGoVersion(cv)
	if err != nil {
		return
	}

	if tov != ov {
		log.Printf("WARNING: Recorded go version (%s) with minor version string found.\n", ov)
		warning = true
	}
	if tcv != tov {
		log.Printf("WARNING: Recorded major go version (%s) and in-use major go version (%s) differ.\n", tov, tcv)
		warning = true
	}
	if warning {
		log.Println("To record current major go version run `godep update -goversion`.")
	}
}

type revError struct {
	ImportPath string
	WantRev    string
	HavePath   string
	HaveRev    string
}

func (v *revError) Error() string {
	return fmt.Sprintf("cannot save %s at revision %s: already have %s at revision %s.\n"+
		"Run `godep update %s' first.", v.ImportPath, v.WantRev, v.HavePath, v.HaveRev, v.HavePath)
}

// carryVersions copies Rev and Comment from a to b for
// each dependency with an identical ImportPath. For any
// dependency in b that appears to be from the same repo
// as one in a (for example, a parent or child directory),
// the Rev must already match - otherwise it is an error.
func carryVersions(a, b *Godeps) error {
	for i := range b.Deps {
		err := carryVersion(a, &b.Deps[i])
		if err != nil {
			return err
		}
	}
	return nil
}

func carryVersion(a *Godeps, db *Dependency) error {
	// First see if this exact package is already in the list.
	for _, da := range a.Deps {
		if db.ImportPath == da.ImportPath {
			db.Rev = da.Rev
			db.Comment = da.Comment
			return nil
		}
	}
	// No exact match, check for child or sibling package.
	// We can't handle mismatched versions for packages in
	// the same repo, so report that as an error.
	for _, da := range a.Deps {
		if strings.HasPrefix(db.ImportPath, da.ImportPath+"/") ||
			strings.HasPrefix(da.ImportPath, db.root+"/") {
			if da.Rev != db.Rev {
				return &revError{
					ImportPath: db.ImportPath,
					WantRev:    db.Rev,
					HavePath:   da.ImportPath,
					HaveRev:    da.Rev,
				}
			}
		}
	}
	// No related package in the list, must be a new repo.
	return nil
}

// subDeps returns a - b, using ImportPath for equality.
func subDeps(a, b []Dependency) (diff []Dependency) {
Diff:
	for _, da := range a {
		for _, db := range b {
			if da.ImportPath == db.ImportPath {
				continue Diff
			}
		}
		diff = append(diff, da)
	}
	return diff
}

func removeSrc(srcdir string, deps []Dependency) error {
	for _, dep := range deps {
		path := filepath.FromSlash(dep.ImportPath)
		err := os.RemoveAll(filepath.Join(srcdir, path))
		if err != nil {
			return err
		}
	}
	return nil
}

func copySrc(dir string, deps []Dependency) error {
	// mapping to see if we visited a parent directory already
	visited := make(map[string]bool)
	ok := true
	for _, dep := range deps {
		debugln("copySrc for", dep.ImportPath)
		srcdir := filepath.Join(dep.ws, "src")
		rel, err := filepath.Rel(srcdir, dep.dir)
		if err != nil { // this should never happen
			return err
		}
		dstpkgroot := filepath.Join(dir, rel)
		err = os.RemoveAll(dstpkgroot)
		if err != nil {
			log.Println(err)
			ok = false
		}

		// copy actual dependency
		vf := dep.vcs.listFiles(dep.dir)
		debugln("vf", vf)
		w := fs.Walk(dep.dir)
		for w.Step() {
			err = copyPkgFile(vf, dir, srcdir, w)
			if err != nil {
				log.Println(err)
				ok = false
			}
		}

		// Look for legal files in root
		//  some packages are imports as a sub-package but license info
		//  is at root:  exampleorg/common has license file in exampleorg
		//
		if dep.ImportPath == dep.root {
			// we are already at root
			continue
		}

		// prevent copying twice This could happen if we have
		//   two subpackages listed someorg/common and
		//   someorg/anotherpack which has their license in
		//   the parent dir of someorg
		rootdir := filepath.Join(srcdir, filepath.FromSlash(dep.root))
		if visited[rootdir] {
			continue
		}
		visited[rootdir] = true
		vf = dep.vcs.listFiles(rootdir)
		w = fs.Walk(rootdir)
		for w.Step() {
			fname := filepath.Base(w.Path())
			if IsLegalFile(fname) && !strings.Contains(w.Path(), sep) {
				err = copyPkgFile(vf, dir, srcdir, w)
				if err != nil {
					log.Println(err)
					ok = false
				}
			}
		}
	}

	if !ok {
		return errorCopyingSourceCode
	}

	return nil
}

func copyPkgFile(vf vcsFiles, dstroot, srcroot string, w *fs.Walker) error {
	if w.Err() != nil {
		return w.Err()
	}
	name := w.Stat().Name()
	if w.Stat().IsDir() {
		if name[0] == '.' || name[0] == '_' || (!saveT && name == "testdata") {
			// Skip directories starting with '.' or '_' or
			// 'testdata' (last is only skipped if saveT is false)
			w.SkipDir()
		}
		return nil
	}
	rel, err := filepath.Rel(srcroot, w.Path())
	if err != nil { // this should never happen
		return err
	}
	if !saveT && strings.HasSuffix(name, "_test.go") {
		if verbose {
			log.Printf("save: skipping test file: %s", w.Path())
		}
		return nil
	}
	if !vf.Contains(w.Path()) {
		if verbose {
			log.Printf("save: skipping untracked file: %s", w.Path())
		}
		return nil
	}
	return copyFile(filepath.Join(dstroot, rel), w.Path())
}

// copyFile copies a regular file from src to dst.
// dst is opened with os.Create.
// If the file name ends with .go,
// copyFile strips canonical import path annotations.
// These are comments of the form:
//   package foo // import "bar/foo"
//   package foo /* import "bar/foo" */
func copyFile(dst, src string) error {
	err := os.MkdirAll(filepath.Dir(dst), 0777)
	if err != nil {
		return err
	}

	linkDst, err := os.Readlink(src)
	if err == nil {
		return os.Symlink(linkDst, dst)
	}

	r, err := os.Open(src)
	if err != nil {
		return err
	}
	defer r.Close()

	w, err := os.Create(dst)
	if err != nil {
		return err
	}

	if strings.HasSuffix(dst, ".go") {
		debugln("Copy Without Import Comment", w, r)
		err = copyWithoutImportComment(w, r)
	} else {
		debugln("Copy (plain)", w, r)
		_, err = io.Copy(w, r)
	}
	err1 := w.Close()
	if err == nil {
		err = err1
	}

	return err
}

func copyWithoutImportComment(w io.Writer, r io.Reader) error {
	b := bufio.NewReader(r)
	for {
		l, err := b.ReadBytes('\n')
		eof := err == io.EOF
		if err != nil && err != io.EOF {
			return err
		}

		// If we have data then write it out...
		if len(l) > 0 {
			// Strip off \n if it exists because stripImportComment
			_, err := w.Write(append(stripImportComment(bytes.TrimRight(l, "\n")), '\n'))
			if err != nil {
				return err
			}
		}

		if eof {
			return nil
		}
	}
}

const (
	importAnnotation = `import\s+(?:"[^"]*"|` + "`[^`]*`" + `)`
	importComment    = `(?://\s*` + importAnnotation + `\s*$|/\*\s*` + importAnnotation + `\s*\*/)`
)

var (
	importCommentRE = regexp.MustCompile(`^\s*(package\s+\w+)\s+` + importComment + `(.*)`)
	pkgPrefix       = []byte("package ")
)

// stripImportComment returns line with its import comment removed.
// If s is not a package statement containing an import comment,
// it is returned unaltered.
// FIXME: expects lines w/o a \n at the end
// See also http://golang.org/s/go14customimport.
func stripImportComment(line []byte) []byte {
	if !bytes.HasPrefix(line, pkgPrefix) {
		// Fast path; this will skip all but one line in the file.
		// This assumes there is no whitespace before the keyword.
		return line
	}
	if m := importCommentRE.FindSubmatch(line); m != nil {
		return append(m[1], m[2]...)
	}
	return line
}

// Func writeVCSIgnore writes "ignore" files inside dir for known VCSs,
// so that dir/pkg and dir/bin don't accidentally get committed.
// It logs any errors it encounters.
func writeVCSIgnore(dir string) {
	// Currently git is the only VCS for which we know how to do this.
	// Mercurial and Bazaar have similar mechanisms, but they apparently
	// require writing files outside of dir.
	const ignore = "/pkg\n/bin\n"
	name := filepath.Join(dir, ".gitignore")
	err := writeFile(name, ignore)
	if err != nil {
		log.Println(err)
	}
}

// writeFile is like ioutil.WriteFile but it creates
// intermediate directories with os.MkdirAll.
func writeFile(name, body string) error {
	err := os.MkdirAll(filepath.Dir(name), 0777)
	if err != nil {
		return err
	}
	return ioutil.WriteFile(name, []byte(body), 0666)
}

const (
	// Readme contains the README text.
	Readme = `
This directory tree is generated automatically by godep.

Please do not edit.

See https://github.com/tools/godep for more information.
`
	needRestore = `
mismatched versions while migrating

It looks like you are switching from the old Godeps format
(from flag -copy=false). The old format is just a file; it
doesn't contain source code. For this migration, godep needs
the appropriate version of each dependency to be installed in
GOPATH, so that the source code is available to copy.

To fix this, run 'godep restore'.
`
)
