// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

package version

import (
	"os"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"sync"

	"tailscale.com/tailcfg"
	"tailscale.com/types/lazy"
)

// AppIdentifierFn, if non-nil, is a callback function that returns the
// application identifier of the running process or an empty string if unknown.
//
// tailscale(d) implementations can set an explicit callback to return an identifier
// for the running process if such a concept exists.  The Apple bundle identifier, for example.
var AppIdentifierFn func() string // or nil

const (
	macsysBundleID      = "io.tailscale.ipn.macsys"                     // The macsys gui app and CLI
	appStoreBundleID    = "io.tailscale.ipn.macos"                      // The App Store gui app and CLI
	macsysExtBundleId   = "io.tailscale.ipn.macsys.network-extension"   // The macsys system extension
	appStoreExtBundleId = "io.tailscale.ipn.macos.network-extension"    // The App Store network extension
	tvOSExtBundleId     = "io.tailscale.ipn.ios.network-extension-tvos" // The tvOS network extension
	iOSExtBundleId      = "io.tailscale.ipn.ios.network-extension"      // The iOS network extension
)

// IsMobile reports whether this is a mobile client build.
func IsMobile() bool {
	return runtime.GOOS == "android" || runtime.GOOS == "ios"
}

// OS returns runtime.GOOS, except instead of returning "darwin" it returns
// "iOS" or "macOS".
func OS() string {
	// If you're wondering why we have this function that just returns
	// runtime.GOOS written differently: in the old days, Go reported
	// GOOS=darwin for both iOS and macOS, so we needed this function to
	// differentiate them. Then a later Go release added GOOS=ios as a separate
	// platform, but by then the "iOS" and "macOS" values we'd picked, with that
	// exact capitalization, were already baked into databases.
	if IsAppleTV() {
		return "tvOS"
	}
	if runtime.GOOS == "ios" {
		return "iOS"
	}
	if runtime.GOOS == "darwin" {
		return "macOS"
	}
	return runtime.GOOS
}

// IsMacGUIVariant reports whether runtime.GOOS=="darwin" and this one of the
// two GUI variants (that is, not tailscaled-on-macOS).
// This predicate should not be used to determine sandboxing properties. It's
// meant for callers to determine whether the NetworkExtension-like auto-netns
// is in effect.
func IsMacGUIVariant() bool {
	return IsMacAppStore() || IsMacSysExt()
}

// IsSandboxedMacOS reports whether this process is a sandboxed macOS
// process (either the app or the extension). It is true for the Mac App Store
// and macsys (only its System Extension) variants on macOS, and false for
// tailscaled and the macsys GUI process on macOS.
func IsSandboxedMacOS() bool {
	return IsMacAppStore() || IsMacSysExt()
}

// IsMacSys reports whether this process is part of the Standalone variant of
// Tailscale for macOS, either the main GUI process (non-sandboxed) or the
// system extension (sandboxed).
func IsMacSys() bool {
	return IsMacSysExt() || IsMacSysGUI()
}

var isMacSysApp lazy.SyncValue[bool]

// IsMacSysGUI reports whether this process is the main, non-sandboxed GUI process
// that ships with the Standalone variant of Tailscale for macOS.
func IsMacSysGUI() bool {
	if runtime.GOOS != "darwin" {
		return false
	}
	return isMacSysApp.Get(func() bool {
		if AppIdentifierFn != nil {
			return AppIdentifierFn() == macsysBundleID
		}

		// TODO (barnstar): This check should be redundant once all relevant callers
		// use AppIdentifierFn.
		return strings.Contains(os.Getenv("HOME"), "/Containers/io.tailscale.ipn.macsys/") ||
			strings.Contains(os.Getenv("XPC_SERVICE_NAME"), macsysBundleID)
	})
}

var isMacSysExt lazy.SyncValue[bool]

// IsMacSysExt reports whether this binary is the system extension shipped as part of
// the standalone "System Extension" (a.k.a. "macsys") version of Tailscale
// for macOS.
func IsMacSysExt() bool {
	if runtime.GOOS != "darwin" {
		return false
	}
	return isMacSysExt.Get(func() bool {
		if AppIdentifierFn != nil {
			return AppIdentifierFn() == macsysExtBundleId
		}

		// TODO (barnstar): This check should be redundant once all relevant callers
		// use AppIdentifierFn.
		exe, err := os.Executable()
		if err != nil {
			return false
		}
		return filepath.Base(exe) == macsysExtBundleId
	})
}

var isMacAppStore lazy.SyncValue[bool]

// IsMacAppStore returns whether this binary is from the App Store version of Tailscale
// for macOS.  Returns true for both the network extension and the GUI app.
func IsMacAppStore() bool {
	if runtime.GOOS != "darwin" {
		return false
	}
	return isMacAppStore.Get(func() bool {
		if AppIdentifierFn != nil {
			id := AppIdentifierFn()
			return id == appStoreBundleID || id == appStoreExtBundleId
		}
		// TODO (barnstar): This check should be redundant once all relevant callers
		// use AppIdentifierFn.
		// Both macsys and app store versions can run CLI executable with
		// suffix /Contents/MacOS/Tailscale. Check $HOME to filter out running
		// as macsys.
		return strings.Contains(os.Getenv("HOME"), "/Containers/io.tailscale.ipn.macos/") ||
			strings.Contains(os.Getenv("XPC_SERVICE_NAME"), appStoreBundleID)
	})
}

var isMacAppStoreGUI lazy.SyncValue[bool]

// IsMacAppStoreGUI reports whether this binary is the GUI app from the App Store
// version of Tailscale for macOS.
func IsMacAppStoreGUI() bool {
	if runtime.GOOS != "darwin" {
		return false
	}
	return isMacAppStoreGUI.Get(func() bool {
		if AppIdentifierFn != nil {
			return AppIdentifierFn() == appStoreBundleID
		}
		// TODO (barnstar): This check should be redundant once all relevant callers
		// use AppIdentifierFn.
		exe, err := os.Executable()
		if err != nil {
			return false
		}
		// Check that this is the GUI binary, and it is not sandboxed. The GUI binary
		// shipped in the App Store will always have the App Sandbox enabled.
		return strings.Contains(exe, "/Tailscale") && !IsMacSysGUI()
	})
}

var isAppleTV lazy.SyncValue[bool]

// IsAppleTV reports whether this binary is part of the Tailscale network extension for tvOS.
// Needed because runtime.GOOS returns "ios" otherwise.
func IsAppleTV() bool {
	if runtime.GOOS != "ios" {
		return false
	}
	return isAppleTV.Get(func() bool {
		if AppIdentifierFn != nil {
			return AppIdentifierFn() == tvOSExtBundleId
		}

		// TODO (barnstar): This check should be redundant once all relevant callers
		// use AppIdentifierFn.
		return strings.EqualFold(os.Getenv("XPC_SERVICE_NAME"), tvOSExtBundleId)
	})
}

var isWindowsGUI lazy.SyncValue[bool]

// IsWindowsGUI reports whether the current process is the Windows GUI.
func IsWindowsGUI() bool {
	if runtime.GOOS != "windows" {
		return false
	}
	return isWindowsGUI.Get(func() bool {
		exe, err := os.Executable()
		if err != nil {
			return false
		}
		// It is okay to use GOARCH here because we're checking whether our
		// _own_ process is the GUI.
		return isGUIExeName(exe, runtime.GOARCH)
	})
}

var isUnstableBuild lazy.SyncValue[bool]

// IsUnstableBuild reports whether this is an unstable build.
// That is, whether its minor version number is odd.
func IsUnstableBuild() bool {
	return isUnstableBuild.Get(func() bool {
		_, rest, ok := strings.Cut(Short(), ".")
		if !ok {
			return false
		}
		minorStr, _, ok := strings.Cut(rest, ".")
		if !ok {
			return false
		}
		minor, err := strconv.Atoi(minorStr)
		if err != nil {
			return false
		}
		return minor%2 == 1
	})
}

// osVariant returns the OS variant string for systems where we support
// multiple ways of running tailscale(d), if any.
//
// For example: "appstore", "macsys", "darwin".
func osVariant() string {
	if IsMacAppStore() {
		return "appstore"
	}
	if IsMacSys() {
		return "macsys"
	}
	if runtime.GOOS == "darwin" {
		return "darwin"
	}
	return ""
}

var isDev = sync.OnceValue(func() bool {
	return strings.Contains(Short(), "-dev")
})

// Meta is a JSON-serializable type that contains all the version
// information.
type Meta struct {
	// MajorMinorPatch is the "major.minor.patch" version string, without
	// any hyphenated suffix.
	MajorMinorPatch string `json:"majorMinorPatch"`

	// IsDev is whether Short contains a -dev suffix. This is whether the build
	// is a development build (as opposed to an official stable or unstable
	// build stamped in the usual ways). If you just run "go install" or "go
	// build" on a dev branch, this will be true.
	IsDev bool `json:"isDev,omitempty"`

	// Short is MajorMinorPatch but optionally adding "-dev" or "-devYYYYMMDD"
	// for dev builds, depending on how it was build.
	Short string `json:"short"`

	// Long is the full version string, including git commit hash(es) as the
	// suffix.
	Long string `json:"long"`

	// UnstableBranch is whether the build is from an unstable (development)
	// branch. That is, it reports whether the minor version is odd.
	UnstableBranch bool `json:"unstableBranch,omitempty"`

	// GitCommit, if non-empty, is the git commit of the
	// github.com/tailscale/tailscale repository at which Tailscale was
	// built. Its format is the one returned by `git describe --always
	// --exclude "*" --dirty --abbrev=200`.
	GitCommit string `json:"gitCommit,omitempty"`

	// GitDirty is whether Go stamped the binary as having dirty version
	// control changes in the working directory (debug.ReadBuildInfo
	// setting "vcs.modified" was true).
	GitDirty bool `json:"gitDirty,omitempty"`

	// OSVariant is specific variant of the binary, if applicable. For example,
	// macsys/appstore/darwin for macOS builds.  Nil/empty where not supported
	// or on oses without variants.
	OSVariant string `json:"osVariant,omitempty"`

	// ExtraGitCommit, if non-empty, is the git commit of a "supplemental"
	// repository at which Tailscale was built. Its format is the same as
	// gitCommit.
	//
	// ExtraGitCommit is used to track the source revision when the main
	// Tailscale repository is integrated into and built from another
	// repository (for example, Tailscale's proprietary code, or the
	// Android OSS repository). Together, GitCommit and ExtraGitCommit
	// exactly describe what repositories and commits were used in a
	// build.
	ExtraGitCommit string `json:"extraGitCommit,omitempty"`

	// DaemonLong is the version number from the tailscaled
	// daemon, if requested.
	DaemonLong string `json:"daemonLong,omitempty"`

	// GitCommitTime is the commit time of the git commit in GitCommit.
	GitCommitTime string `json:"gitCommitTime,omitempty"`

	// TailscaleGoGitHash is the git commit hash from
	// https://github.com/tailscale/go used to build this binary, if built
	// with the Tailscale Go toolchain. Otherwise it is empty.
	TailscaleGoGitHash string `json:"tailscaleGoGitHash,omitempty"`

	// Cap is the current Tailscale capability version. It's a monotonically
	// incrementing integer that's incremented whenever a new capability is
	// added.
	Cap int `json:"cap"`
}

var getMeta lazy.SyncValue[Meta]

// GetMeta returns version metadata about the current build.
func GetMeta() Meta {
	return getMeta.Get(func() Meta {
		return Meta{
			MajorMinorPatch:    majorMinorPatch(),
			Short:              Short(),
			Long:               Long(),
			GitCommitTime:      getEmbeddedInfo().commitTime,
			GitCommit:          gitCommit(),
			GitDirty:           gitDirty(),
			OSVariant:          osVariant(),
			ExtraGitCommit:     extraGitCommitStamp,
			IsDev:              isDev(),
			UnstableBranch:     IsUnstableBuild(),
			TailscaleGoGitHash: tailscaleToolchainRev(),
			Cap:                int(tailcfg.CurrentCapabilityVersion),
		}
	})
}
