// Package goenv returns environment variables that are used in various parts of
// the compiler. You can query it manually with the `tinygo env` subcommand.
package goenv

import (
	"bytes"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"os/user"
	"path/filepath"
	"runtime"
	"strings"
)

// Keys is a slice of all available environment variable keys.
var Keys = []string{
	"GOOS",
	"GOARCH",
	"GOROOT",
	"GOPATH",
	"GOCACHE",
	"CGO_ENABLED",
	"TINYGOROOT",
}

func init() {
	if Get("GOARCH") == "arm" {
		Keys = append(Keys, "GOARM")
	}
}

// TINYGOROOT is the path to the final location for checking tinygo files. If
// unset (by a -X ldflag), then sourceDir() will fallback to the original build
// directory.
var TINYGOROOT string

// Get returns a single environment variable, possibly calculating it on-demand.
// The empty string is returned for unknown environment variables.
func Get(name string) string {
	switch name {
	case "GOOS":
		if dir := os.Getenv("GOOS"); dir != "" {
			return dir
		}
		return runtime.GOOS
	case "GOARCH":
		if dir := os.Getenv("GOARCH"); dir != "" {
			return dir
		}
		return runtime.GOARCH
	case "GOARM":
		if goarm := os.Getenv("GOARM"); goarm != "" {
			return goarm
		}
		if goos := Get("GOOS"); goos == "windows" || goos == "android" {
			// Assume Windows and Android are running on modern CPU cores.
			// This matches upstream Go.
			return "7"
		}
		// Default to ARMv6 on other devices.
		// The difference between ARMv5 and ARMv6 is big, much bigger than the
		// difference between ARMv6 and ARMv7. ARMv6 binaries are much smaller,
		// especially when floating point instructions are involved.
		return "6"
	case "GOROOT":
		return getGoroot()
	case "GOPATH":
		if dir := os.Getenv("GOPATH"); dir != "" {
			return dir
		}

		// fallback
		home := getHomeDir()
		return filepath.Join(home, "go")
	case "GOCACHE":
		// Get the cache directory, usually ~/.cache/tinygo
		dir, err := os.UserCacheDir()
		if err != nil {
			panic("could not find cache dir: " + err.Error())
		}
		return filepath.Join(dir, "tinygo")
	case "CGO_ENABLED":
		val := os.Getenv("CGO_ENABLED")
		if val == "1" || val == "0" {
			return val
		}
		// Default to enabling CGo.
		return "1"
	case "TINYGOROOT":
		return sourceDir()
	case "WASMOPT":
		if path := os.Getenv("WASMOPT"); path != "" {
			err := wasmOptCheckVersion(path)
			if err != nil {
				fmt.Fprintf(os.Stderr, "cannot use %q as wasm-opt (from WASMOPT environment variable): %s", path, err.Error())
				os.Exit(1)
			}

			return path
		}

		return findWasmOpt()
	default:
		return ""
	}
}

// Find wasm-opt, or exit with an error.
func findWasmOpt() string {
	tinygoroot := sourceDir()
	searchPaths := []string{
		tinygoroot + "/bin/wasm-opt",
		tinygoroot + "/build/wasm-opt",
	}

	var paths []string
	for _, path := range searchPaths {
		if runtime.GOOS == "windows" {
			path += ".exe"
		}

		_, err := os.Stat(path)
		if err != nil && os.IsNotExist(err) {
			continue
		}

		paths = append(paths, path)
	}

	if path, err := exec.LookPath("wasm-opt"); err == nil {
		paths = append(paths, path)
	}

	if len(paths) == 0 {
		fmt.Fprintln(os.Stderr, "error: could not find wasm-opt, set the WASMOPT environment variable to override")
		os.Exit(1)
	}

	errs := make([]error, len(paths))
	for i, path := range paths {
		err := wasmOptCheckVersion(path)
		if err == nil {
			return path
		}

		errs[i] = err
	}
	fmt.Fprintln(os.Stderr, "no usable wasm-opt found, update or run \"make binaryen\"")
	for i, path := range paths {
		fmt.Fprintf(os.Stderr, "\t%s: %s\n", path, errs[i].Error())
	}
	os.Exit(1)
	panic("unreachable")
}

// wasmOptCheckVersion checks if a copy of wasm-opt is usable.
func wasmOptCheckVersion(path string) error {
	cmd := exec.Command(path, "--version")
	var buf bytes.Buffer
	cmd.Stdout = &buf
	cmd.Stderr = os.Stderr
	err := cmd.Run()
	if err != nil {
		return err
	}

	str := buf.String()
	if strings.Contains(str, "(") {
		// The git tag may be placed in parentheses after the main version string.
		str = strings.Split(str, "(")[0]
	}

	str = strings.TrimSpace(str)
	var ver uint
	_, err = fmt.Sscanf(str, "wasm-opt version %d", &ver)
	if err != nil || ver < 102 {
		return errors.New("incompatible wasm-opt (need 102 or newer)")
	}

	return nil
}

// Return the TINYGOROOT, or exit with an error.
func sourceDir() string {
	// Use $TINYGOROOT as root, if available.
	root := os.Getenv("TINYGOROOT")
	if root != "" {
		if !isSourceDir(root) {
			fmt.Fprintln(os.Stderr, "error: $TINYGOROOT was not set to the correct root")
			os.Exit(1)
		}
		return root
	}

	if TINYGOROOT != "" {
		if !isSourceDir(TINYGOROOT) {
			fmt.Fprintln(os.Stderr, "error: TINYGOROOT was not set to the correct root")
			os.Exit(1)
		}
		return TINYGOROOT
	}

	// Find root from executable path.
	path, err := os.Executable()
	if err != nil {
		// Very unlikely. Bail out if it happens.
		panic("could not get executable path: " + err.Error())
	}
	root = filepath.Dir(filepath.Dir(path))
	if isSourceDir(root) {
		return root
	}

	// Fallback: use the original directory from where it was built
	// https://stackoverflow.com/a/32163888/559350
	_, path, _, _ = runtime.Caller(0)
	root = filepath.Dir(filepath.Dir(path))
	if isSourceDir(root) {
		return root
	}

	fmt.Fprintln(os.Stderr, "error: could not autodetect root directory, set the TINYGOROOT environment variable to override")
	os.Exit(1)
	panic("unreachable")
}

// isSourceDir returns true if the directory looks like a TinyGo source directory.
func isSourceDir(root string) bool {
	_, err := os.Stat(filepath.Join(root, "src/runtime/internal/sys/zversion.go"))
	if err != nil {
		return false
	}
	_, err = os.Stat(filepath.Join(root, "src/device/arm/arm.go"))
	return err == nil
}

func getHomeDir() string {
	u, err := user.Current()
	if err != nil {
		panic("cannot get current user: " + err.Error())
	}
	if u.HomeDir == "" {
		// This is very unlikely, so panic here.
		// Not the nicest solution, however.
		panic("could not find home directory")
	}
	return u.HomeDir
}

// getGoroot returns an appropriate GOROOT from various sources. If it can't be
// found, it returns an empty string.
func getGoroot() string {
	// An explicitly set GOROOT always has preference.
	goroot := os.Getenv("GOROOT")
	if goroot != "" {
		// Convert to the standard GOROOT being referenced, if it's a TinyGo cache.
		return getStandardGoroot(goroot)
	}

	// Check for the location of the 'go' binary and base GOROOT on that.
	binpath, err := exec.LookPath("go")
	if err == nil {
		binpath, err = filepath.EvalSymlinks(binpath)
		if err == nil {
			goroot := filepath.Dir(filepath.Dir(binpath))
			if isGoroot(goroot) {
				return goroot
			}
		}
	}

	// Check what GOROOT was at compile time.
	if isGoroot(runtime.GOROOT()) {
		return runtime.GOROOT()
	}

	// Check for some standard locations, as a last resort.
	var candidates []string
	switch runtime.GOOS {
	case "linux":
		candidates = []string{
			"/usr/local/go",     // manually installed
			"/usr/lib/go",       // from the distribution
			"/snap/go/current/", // installed using snap
		}
	case "darwin":
		candidates = []string{
			"/usr/local/go",             // manually installed
			"/usr/local/opt/go/libexec", // from Homebrew
		}
	}

	for _, candidate := range candidates {
		if isGoroot(candidate) {
			return candidate
		}
	}

	// Can't find GOROOT...
	return ""
}

// isGoroot checks whether the given path looks like a GOROOT.
func isGoroot(goroot string) bool {
	_, err := os.Stat(filepath.Join(goroot, "src", "runtime", "internal", "sys", "zversion.go"))
	return err == nil
}

// getStandardGoroot returns the physical path to a real, standard Go GOROOT
// implied by the given path.
// If the given path appears to be a TinyGo cached GOROOT, it returns the path
// referenced by symlinks contained in the cache. Otherwise, it returns the
// given path as-is.
func getStandardGoroot(path string) string {
	// Check if the "bin" subdirectory of our given GOROOT is a symlink, and then
	// return the _parent_ directory of its destination.
	if dest, err := os.Readlink(filepath.Join(path, "bin")); nil == err {
		// Clean the destination to remove any trailing slashes, so that
		// filepath.Dir will always return the parent.
		//   (because both "/foo" and "/foo/" are valid symlink destinations,
		//   but filepath.Dir would return "/" and "/foo", respectively)
		return filepath.Dir(filepath.Clean(dest))
	}
	return path
}
