// Copyright 2015 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 main

import (
	"bufio"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"

	"golang.org/x/mobile/internal/sdkpath"
)

// General mobile build environment. Initialized by envInit.
var (
	gomobilepath string              // $GOPATH/pkg/gomobile
	androidEnv   map[string][]string // android arch -> []string
	appleEnv     map[string][]string
	appleNM      string
)

func isAndroidPlatform(platform string) bool {
	return platform == "android"
}

func isApplePlatform(platform string) bool {
	return contains(applePlatforms, platform)
}

var applePlatforms = []string{"ios", "iossimulator", "macos", "maccatalyst"}

func platformArchs(platform string) []string {
	switch platform {
	case "ios":
		return []string{"arm64"}
	case "iossimulator":
		return []string{"arm64", "amd64"}
	case "macos", "maccatalyst":
		return []string{"arm64", "amd64"}
	case "android":
		return []string{"arm", "arm64", "386", "amd64"}
	default:
		panic(fmt.Sprintf("unexpected platform: %s", platform))
	}
}

func isSupportedArch(platform, arch string) bool {
	return contains(platformArchs(platform), arch)
}

// platformOS returns the correct GOOS value for platform.
func platformOS(platform string) string {
	switch platform {
	case "android":
		return "android"
	case "ios", "iossimulator":
		return "ios"
	case "macos", "maccatalyst":
		// For "maccatalyst", Go packages should be built with GOOS=darwin,
		// not GOOS=ios, since the underlying OS (and kernel, runtime) is macOS.
		// But, using GOOS=darwin with build-tag ios leads to corrupt builds: https://go.dev/issue/52299
		// => So we use GOOS=ios for now.
		// We also apply a "macos" or "maccatalyst" build tag, respectively.
		// See below for additional context.
		return "ios"
	default:
		panic(fmt.Sprintf("unexpected platform: %s", platform))
	}
}

func platformTags(platform string) []string {
	switch platform {
	case "android":
		return []string{"android"}
	case "ios", "iossimulator":
		return []string{"ios"}
	case "macos":
		return []string{"macos"}
	case "maccatalyst":
		// Mac Catalyst is a subset of iOS APIs made available on macOS
		// designed to ease porting apps developed for iPad to macOS.
		// See
		//   https://developer.apple.com/mac-catalyst/.
		//   https://stackoverflow.com/questions/12132933/preprocessor-macro-for-os-x-targets/49560690#49560690
		//
		// Historically gomobile used GOOS=darwin with build tag ios when
		// targeting Mac Catalyst. However, this configuration is not officially
		// supported and leads to corrupt builds after go1.18: https://go.dev/issues/52299
		// Use GOOS=ios.
		// To help discriminate between darwin, ios, macos, and maccatalyst
		// targets, there is also a "maccatalyst" tag.
		return []string{"macos", "maccatalyst"}
	default:
		panic(fmt.Sprintf("unexpected platform: %s", platform))
	}
}

func contains(haystack []string, needle string) bool {
	for _, v := range haystack {
		if v == needle {
			return true
		}
	}
	return false
}

func buildEnvInit() (cleanup func(), err error) {
	// Find gomobilepath.
	gopath := goEnv("GOPATH")
	for _, p := range filepath.SplitList(gopath) {
		gomobilepath = filepath.Join(p, "pkg", "gomobile")
		if _, err := os.Stat(gomobilepath); buildN || err == nil {
			break
		}
	}

	if buildX {
		fmt.Fprintln(xout, "GOMOBILE="+gomobilepath)
	}

	// Check the toolchain is in a good state.
	// Pick a temporary directory for assembling an apk/app.
	if gomobilepath == "" {
		return nil, errors.New("toolchain not installed, run `gomobile init`")
	}

	cleanupFn := func() {
		if buildWork {
			fmt.Printf("WORK=%s\n", tmpdir)
			return
		}
		removeAll(tmpdir)
	}
	if buildN {
		tmpdir = "$WORK"
		cleanupFn = func() {}
	} else {
		tmpdir, err = os.MkdirTemp("", "gomobile-work-")
		if err != nil {
			return nil, err
		}
	}
	if buildX {
		fmt.Fprintln(xout, "WORK="+tmpdir)
	}

	if err := envInit(); err != nil {
		return nil, err
	}

	return cleanupFn, nil
}

func envInit() (err error) {
	// Setup the cross-compiler environments.
	if ndkRoot, err := ndkRoot(); err == nil {
		androidEnv = make(map[string][]string)
		if buildAndroidAPI < minAndroidAPI {
			return fmt.Errorf("gomobile requires Android API level >= %d", minAndroidAPI)
		}
		for arch, toolchain := range ndk {
			clang := toolchain.Path(ndkRoot, "clang")
			clangpp := toolchain.Path(ndkRoot, "clang++")
			if !buildN {
				tools := []string{clang, clangpp}
				if runtime.GOOS == "windows" {
					// Because of https://github.com/android-ndk/ndk/issues/920,
					// we require r19c, not just r19b. Fortunately, the clang++.cmd
					// script only exists in r19c.
					tools = append(tools, clangpp+".cmd")
				}
				for _, tool := range tools {
					_, err = os.Stat(tool)
					if err != nil {
						return fmt.Errorf("No compiler for %s was found in the NDK (tried %s). Make sure your NDK version is >= r19c. Use `sdkmanager --update` to update it.", arch, tool)
					}
				}
			}
			androidEnv[arch] = []string{
				"GOOS=android",
				"GOARCH=" + arch,
				"CC=" + clang,
				"CXX=" + clangpp,
				"CGO_ENABLED=1",
			}
			if arch == "arm" {
				androidEnv[arch] = append(androidEnv[arch], "GOARM=7")
			}
		}
	}

	if !xcodeAvailable() {
		return nil
	}

	appleNM = "nm"
	appleEnv = make(map[string][]string)
	for _, platform := range applePlatforms {
		for _, arch := range platformArchs(platform) {
			var env []string
			var goos, sdk, clang, cflags string
			var err error
			switch platform {
			case "ios":
				goos = "ios"
				sdk = "iphoneos"
				clang, cflags, err = envClang(sdk)
				cflags += " -miphoneos-version-min=" + buildIOSVersion
				cflags += " -fembed-bitcode"
			case "iossimulator":
				goos = "ios"
				sdk = "iphonesimulator"
				clang, cflags, err = envClang(sdk)
				cflags += " -mios-simulator-version-min=" + buildIOSVersion
				cflags += " -fembed-bitcode"
			case "maccatalyst":
				// See the comment about maccatalyst's GOOS, build tags configuration
				// in platformOS and platformTags.
				// Using GOOS=darwin with build-tag ios leads to corrupt builds: https://go.dev/issue/52299
				// => So we use GOOS=ios for now.
				goos = "ios"
				sdk = "macosx"
				clang, cflags, err = envClang(sdk)
				// TODO(ydnar): the following 3 lines MAY be needed to compile
				// packages or apps for maccatalyst. Commenting them out now in case
				// it turns out they are necessary. Currently none of the example
				// apps will build for macos or maccatalyst because they have a
				// GLKit dependency, which is deprecated on all Apple platforms, and
				// broken on maccatalyst (GLKView isn’t available).
				// sysroot := strings.SplitN(cflags, " ", 2)[1]
				// cflags += " -isystem " + sysroot + "/System/iOSSupport/usr/include"
				// cflags += " -iframework " + sysroot + "/System/iOSSupport/System/Library/Frameworks"
				switch arch {
				case "amd64":
					cflags += " -target x86_64-apple-ios" + buildIOSVersion + "-macabi"
				case "arm64":
					cflags += " -target arm64-apple-ios" + buildIOSVersion + "-macabi"
					cflags += " -fembed-bitcode"
				}
			case "macos":
				goos = "darwin"
				sdk = "macosx" // Note: the SDK is called "macosx", not "macos"
				clang, cflags, err = envClang(sdk)
				if arch == "arm64" {
					cflags += " -fembed-bitcode"
				}
			default:
				panic(fmt.Errorf("unknown Apple target: %s/%s", platform, arch))
			}

			if err != nil {
				return err
			}

			env = append(env,
				"GOOS="+goos,
				"GOARCH="+arch,
				"GOFLAGS="+"-tags="+strings.Join(platformTags(platform), ","),
				"CC="+clang,
				"CXX="+clang+"++",
				"CGO_CFLAGS="+cflags+" -arch "+archClang(arch),
				"CGO_CXXFLAGS="+cflags+" -arch "+archClang(arch),
				"CGO_LDFLAGS="+cflags+" -arch "+archClang(arch),
				"CGO_ENABLED=1",
				"DARWIN_SDK="+sdk,
			)
			appleEnv[platform+"/"+arch] = env
		}
	}

	return nil
}

// abi maps GOARCH values to Android ABI strings.
// See https://developer.android.com/ndk/guides/abis
func abi(goarch string) string {
	switch goarch {
	case "arm":
		return "armeabi-v7a"
	case "arm64":
		return "arm64-v8a"
	case "386":
		return "x86"
	case "amd64":
		return "x86_64"
	default:
		return ""
	}
}

// checkNDKRoot returns nil if the NDK in `ndkRoot` supports the current configured
// API version and all the specified Android targets.
func checkNDKRoot(ndkRoot string, targets []targetInfo) error {
	platformsJson, err := os.Open(filepath.Join(ndkRoot, "meta", "platforms.json"))
	if err != nil {
		return err
	}
	defer platformsJson.Close()
	decoder := json.NewDecoder(platformsJson)
	supportedVersions := struct {
		Min int
		Max int
	}{}
	if err := decoder.Decode(&supportedVersions); err != nil {
		return err
	}
	if supportedVersions.Min > buildAndroidAPI ||
		supportedVersions.Max < buildAndroidAPI {
		return fmt.Errorf("unsupported API version %d (not in %d..%d)", buildAndroidAPI, supportedVersions.Min, supportedVersions.Max)
	}
	abisJson, err := os.Open(filepath.Join(ndkRoot, "meta", "abis.json"))
	if err != nil {
		return err
	}
	defer abisJson.Close()
	decoder = json.NewDecoder(abisJson)
	abis := make(map[string]struct{})
	if err := decoder.Decode(&abis); err != nil {
		return err
	}
	for _, target := range targets {
		if !isAndroidPlatform(target.platform) {
			continue
		}
		if _, found := abis[abi(target.arch)]; !found {
			return fmt.Errorf("ndk does not support %s", target.platform)
		}
	}
	return nil
}

// compatibleNDKRoots searches the side-by-side NDK dirs for compatible SDKs.
func compatibleNDKRoots(ndkForest string, targets []targetInfo) ([]string, error) {
	ndkDirs, err := os.ReadDir(ndkForest)
	if err != nil {
		return nil, err
	}
	compatibleNDKRoots := []string{}
	var lastErr error
	for _, dirent := range ndkDirs {
		ndkRoot := filepath.Join(ndkForest, dirent.Name())
		lastErr = checkNDKRoot(ndkRoot, targets)
		if lastErr == nil {
			compatibleNDKRoots = append(compatibleNDKRoots, ndkRoot)
		}
	}
	if len(compatibleNDKRoots) > 0 {
		return compatibleNDKRoots, nil
	}
	return nil, lastErr
}

// ndkVersion returns the full version number of an installed copy of the NDK,
// or "" if it cannot be determined.
func ndkVersion(ndkRoot string) string {
	properties, err := os.Open(filepath.Join(ndkRoot, "source.properties"))
	if err != nil {
		return ""
	}
	defer properties.Close()
	// Parse the version number out of the .properties file.
	// See https://en.wikipedia.org/wiki/.properties
	scanner := bufio.NewScanner(properties)
	for scanner.Scan() {
		line := scanner.Text()
		tokens := strings.SplitN(line, "=", 2)
		if len(tokens) != 2 {
			continue
		}
		if strings.TrimSpace(tokens[0]) == "Pkg.Revision" {
			return strings.TrimSpace(tokens[1])
		}
	}
	return ""
}

// ndkRoot returns the root path of an installed NDK that supports all the
// specified Android targets. For details of NDK locations, see
// https://github.com/android/ndk-samples/wiki/Configure-NDK-Path
func ndkRoot(targets ...targetInfo) (string, error) {
	if buildN {
		return "$NDK_PATH", nil
	}

	// Try the ANDROID_NDK_HOME variable. This approach is deprecated, but it
	// has the highest priority because it represents an explicit user choice.
	if ndkRoot := os.Getenv("ANDROID_NDK_HOME"); ndkRoot != "" {
		if err := checkNDKRoot(ndkRoot, targets); err != nil {
			return "", fmt.Errorf("ANDROID_NDK_HOME specifies %s, which is unusable: %w", ndkRoot, err)
		}
		return ndkRoot, nil
	}

	androidHome, err := sdkpath.AndroidHome()
	if err != nil {
		return "", fmt.Errorf("could not locate Android SDK: %w", err)
	}

	// Use the newest compatible NDK under the side-by-side path arrangement.
	ndkForest := filepath.Join(androidHome, "ndk")
	ndkRoots, sideBySideErr := compatibleNDKRoots(ndkForest, targets)
	if len(ndkRoots) != 0 {
		// Choose the latest version that supports the build configuration.
		// NDKs whose version cannot be determined will be least preferred.
		// In the event of a tie, the later ndkRoot will win.
		maxVersion := ""
		var selected string
		for _, ndkRoot := range ndkRoots {
			version := ndkVersion(ndkRoot)
			if version >= maxVersion {
				maxVersion = version
				selected = ndkRoot
			}
		}
		return selected, nil
	}
	// Try the deprecated NDK location.
	ndkRoot := filepath.Join(androidHome, "ndk-bundle")
	if legacyErr := checkNDKRoot(ndkRoot, targets); legacyErr != nil {
		return "", fmt.Errorf("no usable NDK in %s: %w, %v", androidHome, sideBySideErr, legacyErr)
	}
	return ndkRoot, nil
}

func envClang(sdkName string) (clang, cflags string, err error) {
	if buildN {
		return sdkName + "-clang", "-isysroot " + sdkName, nil
	}
	cmd := exec.Command("xcrun", "--sdk", sdkName, "--find", "clang")
	out, err := cmd.Output()
	if err != nil {
		if ee := (*exec.ExitError)(nil); errors.As(err, &ee) {
			out = append(out, ee.Stderr...)
		}
		return "", "", fmt.Errorf("xcrun --find: %v\n%s", err, out)
	}
	clang = strings.TrimSpace(string(out))

	cmd = exec.Command("xcrun", "--sdk", sdkName, "--show-sdk-path")
	out, err = cmd.Output()
	if err != nil {
		if ee := (*exec.ExitError)(nil); errors.As(err, &ee) {
			out = append(out, ee.Stderr...)
		}
		return "", "", fmt.Errorf("xcrun --show-sdk-path: %v\n%s", err, out)
	}
	sdk := strings.TrimSpace(string(out))
	return clang, "-isysroot " + sdk, nil
}

func archClang(goarch string) string {
	switch goarch {
	case "arm":
		return "armv7"
	case "arm64":
		return "arm64"
	case "386":
		return "i386"
	case "amd64":
		return "x86_64"
	default:
		panic(fmt.Sprintf("unknown GOARCH: %q", goarch))
	}
}

// environ merges os.Environ and the given "key=value" pairs.
// If a key is in both os.Environ and kv, kv takes precedence.
func environ(kv []string) []string {
	cur := os.Environ()
	new := make([]string, 0, len(cur)+len(kv))

	envs := make(map[string]string, len(cur))
	for _, ev := range cur {
		elem := strings.SplitN(ev, "=", 2)
		if len(elem) != 2 || elem[0] == "" {
			// pass the env var of unusual form untouched.
			// e.g. Windows may have env var names starting with "=".
			new = append(new, ev)
			continue
		}
		if goos == "windows" {
			elem[0] = strings.ToUpper(elem[0])
		}
		envs[elem[0]] = elem[1]
	}
	for _, ev := range kv {
		elem := strings.SplitN(ev, "=", 2)
		if len(elem) != 2 || elem[0] == "" {
			panic(fmt.Sprintf("malformed env var %q from input", ev))
		}
		if goos == "windows" {
			elem[0] = strings.ToUpper(elem[0])
		}
		envs[elem[0]] = elem[1]
	}
	for k, v := range envs {
		new = append(new, k+"="+v)
	}
	return new
}

func getenv(env []string, key string) string {
	prefix := key + "="
	for _, kv := range env {
		if strings.HasPrefix(kv, prefix) {
			return kv[len(prefix):]
		}
	}
	return ""
}

func archNDK() string {
	if runtime.GOOS == "windows" && runtime.GOARCH == "386" {
		return "windows"
	} else {
		var arch string
		switch runtime.GOARCH {
		case "386":
			arch = "x86"
		case "amd64":
			arch = "x86_64"
		case "arm64":
			// Android NDK does not contain arm64 toolchains (until and
			// including NDK 23), use use x86_64 instead. See:
			// https://github.com/android/ndk/issues/1299
			if runtime.GOOS == "darwin" {
				arch = "x86_64"
				break
			}
			fallthrough
		default:
			panic("unsupported GOARCH: " + runtime.GOARCH)
		}
		return runtime.GOOS + "-" + arch
	}
}

type ndkToolchain struct {
	arch        string
	abi         string
	minAPI      int
	toolPrefix  string
	clangPrefix string
}

func (tc *ndkToolchain) ClangPrefix() string {
	if buildAndroidAPI < tc.minAPI {
		return fmt.Sprintf("%s%d", tc.clangPrefix, tc.minAPI)
	}
	return fmt.Sprintf("%s%d", tc.clangPrefix, buildAndroidAPI)
}

func (tc *ndkToolchain) Path(ndkRoot, toolName string) string {
	cmdFromPref := func(pref string) string {
		return filepath.Join(ndkRoot, "toolchains", "llvm", "prebuilt", archNDK(), "bin", pref+"-"+toolName)
	}

	var cmd string
	switch toolName {
	case "clang", "clang++":
		cmd = cmdFromPref(tc.ClangPrefix())
	default:
		cmd = cmdFromPref(tc.toolPrefix)
		// Starting from NDK 23, GNU binutils are fully migrated to LLVM binutils.
		// See https://android.googlesource.com/platform/ndk/+/master/docs/Roadmap.md#ndk-r23
		if _, err := os.Stat(cmd); errors.Is(err, fs.ErrNotExist) {
			cmd = cmdFromPref("llvm")
		}
	}
	return cmd
}

type ndkConfig map[string]ndkToolchain // map: GOOS->androidConfig.

func (nc ndkConfig) Toolchain(arch string) ndkToolchain {
	tc, ok := nc[arch]
	if !ok {
		panic(`unsupported architecture: ` + arch)
	}
	return tc
}

var ndk = ndkConfig{
	"arm": {
		arch:        "arm",
		abi:         "armeabi-v7a",
		minAPI:      16,
		toolPrefix:  "arm-linux-androideabi",
		clangPrefix: "armv7a-linux-androideabi",
	},
	"arm64": {
		arch:        "arm64",
		abi:         "arm64-v8a",
		minAPI:      21,
		toolPrefix:  "aarch64-linux-android",
		clangPrefix: "aarch64-linux-android",
	},

	"386": {
		arch:        "x86",
		abi:         "x86",
		minAPI:      16,
		toolPrefix:  "i686-linux-android",
		clangPrefix: "i686-linux-android",
	},
	"amd64": {
		arch:        "x86_64",
		abi:         "x86_64",
		minAPI:      21,
		toolPrefix:  "x86_64-linux-android",
		clangPrefix: "x86_64-linux-android",
	},
}

func xcodeAvailable() bool {
	err := exec.Command("xcrun", "xcodebuild", "-version").Run()
	return err == nil
}
