// Copyright 2015 Google Inc. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.

// Program aebundler turns a Go app into a fully self-contained tar file.
// The app and its subdirectories (if any) are placed under "."
// and the dependencies from $GOPATH are placed under ./_gopath/src.
// A main func is synthesized if one does not exist.
//
// A sample Dockerfile to be used with this bundler could look like this:
//     FROM gcr.io/google-appengine/go-compat
//     ADD . /app
//     RUN GOPATH=/app/_gopath go build -tags appenginevm -o /app/_ah/exe
package main

import (
	"archive/tar"
	"flag"
	"fmt"
	"go/ast"
	"go/build"
	"go/parser"
	"go/token"
	"io"
	"io/ioutil"
	"os"
	"path/filepath"
	"strings"
)

var (
	output  = flag.String("o", "", "name of output tar file or '-' for stdout")
	rootDir = flag.String("root", ".", "directory name of application root")
	vm      = flag.Bool("vm", true, `bundle an app for App Engine "flexible environment"`)

	skipFiles = map[string]bool{
		".git":        true,
		".gitconfig":  true,
		".hg":         true,
		".travis.yml": true,
	}
)

const (
	newMain = `package main
import "google.golang.org/appengine"
func main() {
	appengine.Main()
}
`
)

func usage() {
	fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
	fmt.Fprintf(os.Stderr, "\t%s -o <file.tar|->\tBundle app to named tar file or stdout\n", os.Args[0])
	fmt.Fprintf(os.Stderr, "\noptional arguments:\n")
	flag.PrintDefaults()
}

func main() {
	flag.Usage = usage
	flag.Parse()

	var tags []string
	if *vm {
		tags = append(tags, "appenginevm")
	} else {
		tags = append(tags, "appengine")
	}

	tarFile := *output
	if tarFile == "" {
		usage()
		errorf("Required -o flag not specified.")
	}

	app, err := analyze(tags)
	if err != nil {
		errorf("Error analyzing app: %v", err)
	}
	if err := app.bundle(tarFile); err != nil {
		errorf("Unable to bundle app: %v", err)
	}
}

// errorf prints the error message and exits.
func errorf(format string, a ...interface{}) {
	fmt.Fprintf(os.Stderr, "aebundler: "+format+"\n", a...)
	os.Exit(1)
}

type app struct {
	hasMain  bool
	appFiles []string
	imports  map[string]string
}

// analyze checks the app for building with the given build tags and returns hasMain,
// app files, and a map of full directory import names to original import names.
func analyze(tags []string) (*app, error) {
	ctxt := buildContext(tags)
	hasMain, appFiles, err := checkMain(ctxt)
	if err != nil {
		return nil, err
	}
	gopath := filepath.SplitList(ctxt.GOPATH)
	im, err := imports(ctxt, *rootDir, gopath)
	return &app{
		hasMain:  hasMain,
		appFiles: appFiles,
		imports:  im,
	}, err
}

// buildContext returns the context for building the source.
func buildContext(tags []string) *build.Context {
	return &build.Context{
		GOARCH:    build.Default.GOARCH,
		GOOS:      build.Default.GOOS,
		GOROOT:    build.Default.GOROOT,
		GOPATH:    build.Default.GOPATH,
		Compiler:  build.Default.Compiler,
		BuildTags: append(build.Default.BuildTags, tags...),
	}
}

// bundle bundles the app into the named tarFile ("-"==stdout).
func (s *app) bundle(tarFile string) (err error) {
	var out io.Writer
	if tarFile == "-" {
		out = os.Stdout
	} else {
		f, err := os.Create(tarFile)
		if err != nil {
			return err
		}
		defer func() {
			if cerr := f.Close(); err == nil {
				err = cerr
			}
		}()
		out = f
	}
	tw := tar.NewWriter(out)

	for srcDir, importName := range s.imports {
		dstDir := "_gopath/src/" + importName
		if err = copyTree(tw, dstDir, srcDir); err != nil {
			return fmt.Errorf("unable to copy directory %v to %v: %v", srcDir, dstDir, err)
		}
	}
	if err := copyTree(tw, ".", *rootDir); err != nil {
		return fmt.Errorf("unable to copy root directory to /app: %v", err)
	}
	if !s.hasMain {
		if err := synthesizeMain(tw, s.appFiles); err != nil {
			return fmt.Errorf("unable to synthesize new main func: %v", err)
		}
	}

	if err := tw.Close(); err != nil {
		return fmt.Errorf("unable to close tar file %v: %v", tarFile, err)
	}
	return nil
}

// synthesizeMain generates a new main func and writes it to the tarball.
func synthesizeMain(tw *tar.Writer, appFiles []string) error {
	appMap := make(map[string]bool)
	for _, f := range appFiles {
		appMap[f] = true
	}
	var f string
	for i := 0; i < 100; i++ {
		f = fmt.Sprintf("app_main%d.go", i)
		if !appMap[filepath.Join(*rootDir, f)] {
			break
		}
	}
	if appMap[filepath.Join(*rootDir, f)] {
		return fmt.Errorf("unable to find unique name for %v", f)
	}
	hdr := &tar.Header{
		Name: f,
		Mode: 0644,
		Size: int64(len(newMain)),
	}
	if err := tw.WriteHeader(hdr); err != nil {
		return fmt.Errorf("unable to write header for %v: %v", f, err)
	}
	if _, err := tw.Write([]byte(newMain)); err != nil {
		return fmt.Errorf("unable to write %v to tar file: %v", f, err)
	}
	return nil
}

// imports returns a map of all import directories (recursively) used by the app.
// The return value maps full directory names to original import names.
func imports(ctxt *build.Context, srcDir string, gopath []string) (map[string]string, error) {
	pkg, err := ctxt.ImportDir(srcDir, 0)
	if err != nil {
		return nil, fmt.Errorf("unable to analyze source: %v", err)
	}

	// Resolve all non-standard-library imports
	result := make(map[string]string)
	for _, v := range pkg.Imports {
		if !strings.Contains(v, ".") {
			continue
		}
		src, err := findInGopath(v, gopath)
		if err != nil {
			return nil, fmt.Errorf("unable to find import %v in gopath %v: %v", v, gopath, err)
		}
		result[src] = v
		im, err := imports(ctxt, src, gopath)
		if err != nil {
			return nil, fmt.Errorf("unable to parse package %v: %v", src, err)
		}
		for k, v := range im {
			result[k] = v
		}
	}
	return result, nil
}

// findInGopath searches the gopath for the named import directory.
func findInGopath(dir string, gopath []string) (string, error) {
	for _, v := range gopath {
		dst := filepath.Join(v, "src", dir)
		if _, err := os.Stat(dst); err == nil {
			return dst, nil
		}
	}
	return "", fmt.Errorf("unable to find package %v in gopath %v", dir, gopath)
}

// copyTree copies srcDir to tar file dstDir, ignoring skipFiles.
func copyTree(tw *tar.Writer, dstDir, srcDir string) error {
	entries, err := ioutil.ReadDir(srcDir)
	if err != nil {
		return fmt.Errorf("unable to read dir %v: %v", srcDir, err)
	}
	for _, entry := range entries {
		n := entry.Name()
		if skipFiles[n] {
			continue
		}
		s := filepath.Join(srcDir, n)
		d := filepath.Join(dstDir, n)
		if entry.IsDir() {
			if err := copyTree(tw, d, s); err != nil {
				return fmt.Errorf("unable to copy dir %v to %v: %v", s, d, err)
			}
			continue
		}
		if err := copyFile(tw, d, s); err != nil {
			return fmt.Errorf("unable to copy dir %v to %v: %v", s, d, err)
		}
	}
	return nil
}

// copyFile copies src to tar file dst.
func copyFile(tw *tar.Writer, dst, src string) error {
	s, err := os.Open(src)
	if err != nil {
		return fmt.Errorf("unable to open %v: %v", src, err)
	}
	defer s.Close()
	fi, err := s.Stat()
	if err != nil {
		return fmt.Errorf("unable to stat %v: %v", src, err)
	}

	hdr, err := tar.FileInfoHeader(fi, dst)
	if err != nil {
		return fmt.Errorf("unable to create tar header for %v: %v", dst, err)
	}
	hdr.Name = dst
	if err := tw.WriteHeader(hdr); err != nil {
		return fmt.Errorf("unable to write header for %v: %v", dst, err)
	}
	_, err = io.Copy(tw, s)
	if err != nil {
		return fmt.Errorf("unable to copy %v to %v: %v", src, dst, err)
	}
	return nil
}

// checkMain verifies that there is a single "main" function.
// It also returns a list of all Go source files in the app.
func checkMain(ctxt *build.Context) (bool, []string, error) {
	pkg, err := ctxt.ImportDir(*rootDir, 0)
	if err != nil {
		return false, nil, fmt.Errorf("unable to analyze source: %v", err)
	}
	if !pkg.IsCommand() {
		errorf("Your app's package needs to be changed from %q to \"main\".\n", pkg.Name)
	}
	// Search for a "func main"
	var hasMain bool
	var appFiles []string
	for _, f := range pkg.GoFiles {
		n := filepath.Join(*rootDir, f)
		appFiles = append(appFiles, n)
		if hasMain, err = readFile(n); err != nil {
			return false, nil, fmt.Errorf("error parsing %q: %v", n, err)
		}
	}
	return hasMain, appFiles, nil
}

// isMain returns whether the given function declaration is a main function.
// Such a function must be called "main", not have a receiver, and have no arguments or return types.
func isMain(f *ast.FuncDecl) bool {
	ft := f.Type
	return f.Name.Name == "main" && f.Recv == nil && ft.Params.NumFields() == 0 && ft.Results.NumFields() == 0
}

// readFile reads and parses the Go source code file and returns whether it has a main function.
func readFile(filename string) (hasMain bool, err error) {
	var src []byte
	src, err = ioutil.ReadFile(filename)
	if err != nil {
		return
	}
	fset := token.NewFileSet()
	file, err := parser.ParseFile(fset, filename, src, 0)
	for _, decl := range file.Decls {
		funcDecl, ok := decl.(*ast.FuncDecl)
		if !ok {
			continue
		}
		if !isMain(funcDecl) {
			continue
		}
		hasMain = true
		break
	}
	return
}
