// Copyright 2017 Google Inc. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
	"errors"
	"flag"
	"fmt"
	"io/ioutil"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
)

var (
	sandboxesRoot string
	rawCommand    string
	outputRoot    string
	keepOutDir    bool
	depfileOut    string
)

func init() {
	flag.StringVar(&sandboxesRoot, "sandbox-path", "",
		"root of temp directory to put the sandbox into")
	flag.StringVar(&rawCommand, "c", "",
		"command to run")
	flag.StringVar(&outputRoot, "output-root", "",
		"root of directory to copy outputs into")
	flag.BoolVar(&keepOutDir, "keep-out-dir", false,
		"whether to keep the sandbox directory when done")

	flag.StringVar(&depfileOut, "depfile-out", "",
		"file path of the depfile to generate. This value will replace '__SBOX_DEPFILE__' in the command and will be treated as an output but won't be added to __SBOX_OUT_FILES__")

}

func usageViolation(violation string) {
	if violation != "" {
		fmt.Fprintf(os.Stderr, "Usage error: %s.\n\n", violation)
	}

	fmt.Fprintf(os.Stderr,
		"Usage: sbox -c <commandToRun> --sandbox-path <sandboxPath> --output-root <outputRoot> --overwrite [--depfile-out depFile] <outputFile> [<outputFile>...]\n"+
			"\n"+
			"Deletes <outputRoot>,"+
			"runs <commandToRun>,"+
			"and moves each <outputFile> out of <sandboxPath> and into <outputRoot>\n")

	flag.PrintDefaults()

	os.Exit(1)
}

func main() {
	flag.Usage = func() {
		usageViolation("")
	}
	flag.Parse()

	error := run()
	if error != nil {
		fmt.Fprintln(os.Stderr, error)
		os.Exit(1)
	}
}

func findAllFilesUnder(root string) (paths []string) {
	paths = []string{}
	filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if !info.IsDir() {
			relPath, err := filepath.Rel(root, path)
			if err != nil {
				// couldn't find relative path from ancestor?
				panic(err)
			}
			paths = append(paths, relPath)
		}
		return nil
	})
	return paths
}

func run() error {
	if rawCommand == "" {
		usageViolation("-c <commandToRun> is required and must be non-empty")
	}
	if sandboxesRoot == "" {
		// In practice, the value of sandboxesRoot will mostly likely be at a fixed location relative to OUT_DIR,
		// and the sbox executable will most likely be at a fixed location relative to OUT_DIR too, so
		// the value of sandboxesRoot will most likely be at a fixed location relative to the sbox executable
		// However, Soong also needs to be able to separately remove the sandbox directory on startup (if it has anything left in it)
		// and by passing it as a parameter we don't need to duplicate its value
		usageViolation("--sandbox-path <sandboxPath> is required and must be non-empty")
	}
	if len(outputRoot) == 0 {
		usageViolation("--output-root <outputRoot> is required and must be non-empty")
	}

	// the contents of the __SBOX_OUT_FILES__ variable
	outputsVarEntries := flag.Args()
	if len(outputsVarEntries) == 0 {
		usageViolation("at least one output file must be given")
	}

	// all outputs
	var allOutputs []string

	// setup directories
	err := os.MkdirAll(sandboxesRoot, 0777)
	if err != nil {
		return err
	}
	err = os.RemoveAll(outputRoot)
	if err != nil {
		return err
	}
	err = os.MkdirAll(outputRoot, 0777)
	if err != nil {
		return err
	}

	tempDir, err := ioutil.TempDir(sandboxesRoot, "sbox")

	for i, filePath := range outputsVarEntries {
		if !strings.HasPrefix(filePath, "__SBOX_OUT_DIR__/") {
			return fmt.Errorf("output files must start with `__SBOX_OUT_DIR__/`")
		}
		outputsVarEntries[i] = strings.TrimPrefix(filePath, "__SBOX_OUT_DIR__/")
	}

	allOutputs = append([]string(nil), outputsVarEntries...)

	if depfileOut != "" {
		sandboxedDepfile, err := filepath.Rel(outputRoot, depfileOut)
		if err != nil {
			return err
		}
		allOutputs = append(allOutputs, sandboxedDepfile)
		if !strings.Contains(rawCommand, "__SBOX_DEPFILE__") {
			return fmt.Errorf("the --depfile-out argument only makes sense if the command contains the text __SBOX_DEPFILE__")
		}
		rawCommand = strings.Replace(rawCommand, "__SBOX_DEPFILE__", filepath.Join(tempDir, sandboxedDepfile), -1)

	}

	if err != nil {
		return fmt.Errorf("Failed to create temp dir: %s", err)
	}

	// In the common case, the following line of code is what removes the sandbox
	// If a fatal error occurs (such as if our Go process is killed unexpectedly),
	// then at the beginning of the next build, Soong will retry the cleanup
	defer func() {
		// in some cases we decline to remove the temp dir, to facilitate debugging
		if !keepOutDir {
			os.RemoveAll(tempDir)
		}
	}()

	if strings.Contains(rawCommand, "__SBOX_OUT_DIR__") {
		rawCommand = strings.Replace(rawCommand, "__SBOX_OUT_DIR__", tempDir, -1)
	}

	if strings.Contains(rawCommand, "__SBOX_OUT_FILES__") {
		// expands into a space-separated list of output files to be generated into the sandbox directory
		tempOutPaths := []string{}
		for _, outputPath := range outputsVarEntries {
			tempOutPath := path.Join(tempDir, outputPath)
			tempOutPaths = append(tempOutPaths, tempOutPath)
		}
		pathsText := strings.Join(tempOutPaths, " ")
		rawCommand = strings.Replace(rawCommand, "__SBOX_OUT_FILES__", pathsText, -1)
	}

	for _, filePath := range allOutputs {
		dir := path.Join(tempDir, filepath.Dir(filePath))
		err = os.MkdirAll(dir, 0777)
		if err != nil {
			return err
		}
	}

	commandDescription := rawCommand

	cmd := exec.Command("bash", "-c", rawCommand)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	err = cmd.Run()

	if exit, ok := err.(*exec.ExitError); ok && !exit.Success() {
		return fmt.Errorf("sbox command (%s) failed with err %#v\n", commandDescription, err.Error())
	} else if err != nil {
		return err
	}

	// validate that all files are created properly
	var missingOutputErrors []string
	for _, filePath := range allOutputs {
		tempPath := filepath.Join(tempDir, filePath)
		fileInfo, err := os.Stat(tempPath)
		if err != nil {
			missingOutputErrors = append(missingOutputErrors, fmt.Sprintf("%s: does not exist", filePath))
			continue
		}
		if fileInfo.IsDir() {
			missingOutputErrors = append(missingOutputErrors, fmt.Sprintf("%s: not a file", filePath))
		}
	}
	if len(missingOutputErrors) > 0 {
		// find all created files for making a more informative error message
		createdFiles := findAllFilesUnder(tempDir)

		// build error message
		errorMessage := "mismatch between declared and actual outputs\n"
		errorMessage += "in sbox command(" + commandDescription + ")\n\n"
		errorMessage += "in sandbox " + tempDir + ",\n"
		errorMessage += fmt.Sprintf("failed to create %v files:\n", len(missingOutputErrors))
		for _, missingOutputError := range missingOutputErrors {
			errorMessage += "  " + missingOutputError + "\n"
		}
		if len(createdFiles) < 1 {
			errorMessage += "created 0 files."
		} else {
			errorMessage += fmt.Sprintf("did create %v files:\n", len(createdFiles))
			creationMessages := createdFiles
			maxNumCreationLines := 10
			if len(creationMessages) > maxNumCreationLines {
				creationMessages = creationMessages[:maxNumCreationLines]
				creationMessages = append(creationMessages, fmt.Sprintf("...%v more", len(createdFiles)-maxNumCreationLines))
			}
			for _, creationMessage := range creationMessages {
				errorMessage += "  " + creationMessage + "\n"
			}
		}

		// Keep the temporary output directory around in case a user wants to inspect it for debugging purposes.
		// Soong will delete it later anyway.
		keepOutDir = true
		return errors.New(errorMessage)
	}
	// the created files match the declared files; now move them
	for _, filePath := range allOutputs {
		tempPath := filepath.Join(tempDir, filePath)
		destPath := filePath
		if len(outputRoot) != 0 {
			destPath = filepath.Join(outputRoot, filePath)
		}
		err := os.MkdirAll(filepath.Dir(destPath), 0777)
		if err != nil {
			return err
		}
		err = os.Rename(tempPath, destPath)
		if err != nil {
			return err
		}
	}

	// TODO(jeffrygaston) if a process creates more output files than it declares, should there be a warning?
	return nil
}