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

// Package mkversion gets version info from git and provides a bunch of
// differently formatted version strings that get used elsewhere in the build
// system to embed version numbers into binaries.
package mkversion

import (
	"bytes"
	"fmt"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/google/uuid"
	"golang.org/x/mod/modfile"
)

// VersionInfo is version information extracted from a git checkout.
type VersionInfo struct {
	// Major is the major version number portion of Short.
	Major int
	// Minor is the minor version number portion of Short.
	Minor int
	// Patch is the patch version number portion of Short.
	Patch int
	// Short is the short version string. See the documentation of version.Short
	// for possible values.
	Short string
	// Long is the long version string. See the documentation for version.Long
	// for possible values.
	Long string
	// GitHash is the git hash of the tailscale.com Go module.
	GitHash string
	// OtherHash is the git hash of a supplemental git repository, if any. For
	// example, the commit of the tailscale-android repository.
	OtherHash string
	// Xcode is the version string that gets embedded into Xcode builds for the
	// Tailscale iOS app and macOS standalone (aka "macsys") app.
	//
	// It is the same as Short, but with 100 added to the major version number.
	// This is because Apple requires monotonically increasing version numbers,
	// and very early builds of Tailscale used a single incrementing integer,
	// which the Apple interprets as the major version number. When we switched
	// to the current scheme, we started the major version number at 100 (v0,
	// plus 100) to make the transition.
	Xcode string
	// XcodeMacOS is the version string that gets embedded into Xcode builds for
	// the Tailscale macOS app store app.
	//
	// This used to be the same as Xcode, but at some point Xcode reverted to
	// auto-incrementing build numbers instead of using the version we embedded.
	// As a result, we had to alter the version scheme again, and switched to
	// GitHash's commit date, in the format "YYYY.DDD.HHMMSS"
	XcodeMacOS string
	// Winres is the version string that gets embedded into Windows exe
	// metadata. It is of the form "x,y,z,0".
	Winres string
	// Synology is a map of Synology DSM major version to the
	// Tailscale numeric version that gets embedded in Synology spk
	// files.
	Synology map[int]int64
	// GitDate is the unix timestamp of GitHash's commit date.
	GitDate string
	// OtherDate is the unix timestamp of OtherHash's commit date, if any.
	OtherDate string
	// Track is the release track of this build: "stable" if the minor version
	// number is even, "unstable" if it's odd.
	Track string
	// MSIProductCodes is a map of Windows CPU architecture names to UUIDv5
	// hashes that uniquely identify the version of the build. These are used in
	// the MSI installer logic to uniquely identify particular builds.
	MSIProductCodes map[string]string
}

// String returns v's information as shell variable assignments.
func (v VersionInfo) String() string {
	f := fmt.Fprintf
	var b bytes.Buffer
	f(&b, "VERSION_MAJOR=%d\n", v.Major)
	f(&b, "VERSION_MINOR=%d\n", v.Minor)
	f(&b, "VERSION_PATCH=%d\n", v.Patch)
	f(&b, "VERSION_SHORT=%q\n", v.Short)
	f(&b, "VERSION_LONG=%q\n", v.Long)
	f(&b, "VERSION_GIT_HASH=%q\n", v.GitHash)
	f(&b, "VERSION_TRACK=%q\n", v.Track)
	if v.OtherHash != "" {
		f(&b, "VERSION_EXTRA_HASH=%q\n", v.OtherHash)
		f(&b, "VERSION_XCODE=%q\n", v.Xcode)
		f(&b, "VERSION_XCODE_MACOS=%q\n", v.XcodeMacOS)
		f(&b, "VERSION_WINRES=%q\n", v.Winres)
		// Ensure a predictable order for these variables for testing purposes.
		for _, k := range []string{"amd64", "arm64", "x86"} {
			f(&b, "VERSION_MSIPRODUCT_%s=%q\n", strings.ToUpper(k), v.MSIProductCodes[k])
		}
	}

	return b.String()
}

// Info constructs a VersionInfo from the current working directory and returns
// it, or terminates the process via log.Fatal.
func Info() VersionInfo {
	v, err := InfoFrom("")
	if err != nil {
		log.Fatal(err)
	}
	return v
}

// InfoFrom constructs a VersionInfo from dir and returns it, or an error.
func InfoFrom(dir string) (VersionInfo, error) {
	runner := dirRunner(dir)

	gitRoot, err := runner.output("git", "rev-parse", "--show-toplevel")
	if err != nil {
		return VersionInfo{}, fmt.Errorf("finding git root: %w", err)
	}
	runner = dirRunner(gitRoot)

	modBs, err := os.ReadFile(filepath.Join(gitRoot, "go.mod"))
	if err != nil {
		return VersionInfo{}, fmt.Errorf("reading go.mod: %w", err)
	}
	modPath := modfile.ModulePath(modBs)

	if modPath == "" {
		return VersionInfo{}, fmt.Errorf("no module path in go.mod")
	}
	if modPath == "tailscale.com" {
		// Invoked in the tailscale.com repo directly, just no further info to
		// collect.
		v, err := infoFromDir(gitRoot)
		if err != nil {
			return VersionInfo{}, err
		}
		return mkOutput(v)
	}

	// We seem to be in a repo that imports tailscale.com. Find the
	// tailscale.com repo and collect additional info from it.
	otherHash, err := runner.output("git", "rev-parse", "HEAD")
	if err != nil {
		return VersionInfo{}, fmt.Errorf("getting git hash: %w", err)
	}
	otherDate, err := runner.output("git", "log", "-n1", "--format=%ct", "HEAD")
	if err != nil {
		return VersionInfo{}, fmt.Errorf("getting git date: %w", err)
	}

	// Note, this mechanism doesn't correctly support go.mod replacements,
	// or go workdirs. We only parse out the commit ref from go.mod's
	// "require" line, nothing else.
	tailscaleRef, err := tailscaleModuleRef(modBs)
	if err != nil {
		return VersionInfo{}, err
	}

	v, err := infoFromCache(tailscaleRef, runner)
	if err != nil {
		return VersionInfo{}, err
	}
	v.otherHash = otherHash
	v.otherDate = otherDate

	if !runner.ok("git", "diff-index", "--quiet", "HEAD") {
		v.otherHash = v.otherHash + "-dirty"
	}

	return mkOutput(v)
}

// tailscaleModuleRef returns the git ref of the 'require tailscale.com' line
// in the given go.mod bytes. The ref is either a short commit hash, or a git
// tag.
func tailscaleModuleRef(modBs []byte) (string, error) {
	mod, err := modfile.Parse("go.mod", modBs, nil)
	if err != nil {
		return "", err
	}
	for _, req := range mod.Require {
		if req.Mod.Path != "tailscale.com" {
			continue
		}
		// Get the last - separated part of req.Mod.Version
		// (which is the git hash).
		if i := strings.LastIndexByte(req.Mod.Version, '-'); i != -1 {
			return req.Mod.Version[i+1:], nil
		}
		// If there are no dashes, the version is a tag.
		return req.Mod.Version, nil
	}
	return "", fmt.Errorf("no require tailscale.com line in go.mod")
}

func mkOutput(v verInfo) (VersionInfo, error) {
	if override := os.Getenv("TS_VERSION_OVERRIDE"); override != "" {
		var err error
		v.major, v.minor, v.patch, err = parseVersion(override)
		if err != nil {
			return VersionInfo{}, fmt.Errorf("failed to parse TS_VERSION_OVERRIDE: %w", err)
		}
	}
	var changeSuffix string
	if v.minor%2 == 1 {
		// Odd minor numbers are unstable builds.
		if v.patch != 0 {
			return VersionInfo{}, fmt.Errorf("unstable release %d.%d.%d has a non-zero patch number, which is not allowed", v.major, v.minor, v.patch)
		}
		v.patch = v.changeCount
	} else if v.changeCount != 0 {
		// Even minor numbers are stable builds, but stable builds are
		// supposed to have a zero change count. Therefore, we're currently
		// describing a commit that's on a release branch, but hasn't been
		// tagged as a patch release yet.
		//
		// We used to change the version number to 0.0.0 in that case, but that
		// caused some features to get disabled due to the low version number.
		// Instead, add yet another suffix to the version number, with a change
		// count.
		changeSuffix = "-" + strconv.Itoa(v.changeCount)
	}

	var hashes string
	if v.otherHash != "" {
		hashes = "-g" + shortHash(v.otherHash)
	}
	if v.hash != "" {
		hashes = "-t" + shortHash(v.hash) + hashes
	}

	var track string
	if v.minor%2 == 1 {
		track = "unstable"
	} else {
		track = "stable"
	}

	ret := VersionInfo{
		Major:   v.major,
		Minor:   v.minor,
		Patch:   v.patch,
		Short:   fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch),
		Long:    fmt.Sprintf("%d.%d.%d%s%s", v.major, v.minor, v.patch, changeSuffix, hashes),
		GitHash: fmt.Sprintf("%s", v.hash),
		GitDate: fmt.Sprintf("%s", v.date),
		Track:   track,
		Synology: map[int]int64{
			// Synology requires that version numbers be in a specific format.
			// Builds with version numbers that don't start with "60" or "70" will fail,
			// and the full version number must be within int32 range.
			// So, we do the following mapping from our Tailscale version to Synology version,
			// giving major version three decimal places, minor version three, and patch two.
			6: 6*100_000_000 + int64(v.major-1)*1_000_000 + int64(v.minor)*1_000 + int64(v.patch),
			7: 7*100_000_000 + int64(v.major-1)*1_000_000 + int64(v.minor)*1_000 + int64(v.patch),
		},
	}

	if v.otherHash != "" {
		ret.OtherHash = fmt.Sprintf("%s", v.otherHash)

		// Technically we could populate these fields without the otherHash, but
		// these version numbers only make sense when building from Tailscale's
		// proprietary repo, so don't clutter open-source-only outputs with
		// them.
		ret.Xcode = fmt.Sprintf("%d.%d.%d", v.major+100, v.minor, v.patch)
		ret.Winres = fmt.Sprintf("%d,%d,%d,0", v.major, v.minor, v.patch)
		ret.MSIProductCodes = makeMSIProductCodes(v, track)
	}
	if v.otherDate != "" {
		ret.OtherDate = fmt.Sprintf("%s", v.otherDate)

		// Generate a monotonically increasing version number for the macOS app, as
		// expected by Apple. We use the date so that it's always increasing (if we
		// based it on the actual version number we'd run into issues when doing
		// cherrypick stable builds from a release branch after unstable builds from
		// HEAD).
		otherSec, err := strconv.ParseInt(v.otherDate, 10, 64)
		if err != nil {
			return VersionInfo{}, fmt.Errorf("Could not parse otherDate %q: %w", v.otherDate, err)
		}
		otherTime := time.Unix(otherSec, 0).UTC()
		// We started to need to do this in 2023, and the last Apple-generated
		// incrementing build number was 273. To avoid using up the space, we
		// use <year - 1750> as the major version (thus 273.*, 274.* in 2024, etc.),
		// so that we we're still in the same range. This way if Apple goes back to
		// auto-incrementing the number for us, we can go back to it with
		// reasonable-looking numbers.
		ret.XcodeMacOS = fmt.Sprintf("%d.%d.%d", otherTime.Year()-1750, otherTime.YearDay(), otherTime.Hour()*60*60+otherTime.Minute()*60+otherTime.Second())
	}

	return ret, nil
}

// makeMSIProductCodes produces per-architecture v5 UUIDs derived from the pkgs
// url that would be used for the current version, thus ensuring that product IDs
// are mapped 1:1 to a unique version number.
func makeMSIProductCodes(v verInfo, track string) map[string]string {
	urlBase := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%d.%d.%d-", track, v.major, v.minor, v.patch)

	result := map[string]string{}

	for _, arch := range []string{"amd64", "arm64", "x86"} {
		url := fmt.Sprintf("%s%s.msi", urlBase, arch)
		curUUID := uuid.NewSHA1(uuid.NameSpaceURL, []byte(url))
		// MSI prefers hex digits in UUIDs to be uppercase.
		result[arch] = strings.ToUpper(curUUID.String())
	}

	return result
}

type verInfo struct {
	major, minor, patch int
	changeCount         int
	hash                string
	date                string

	otherHash string
	otherDate string
}

// unknownPatchVersion is the patch version used when the tailscale.com package
// doesn't contain enough version information to derive the correct version.
// Such builds only get used when generating bug reports in an ephemeral working
// environment, so will never be distributed. As such, we use a highly visible
// sentinel patch number.
const unknownPatchVersion = 9999999

func infoFromCache(ref string, runner dirRunner) (verInfo, error) {
	cacheDir, err := os.UserCacheDir()
	if err != nil {
		return verInfo{}, fmt.Errorf("Getting user cache dir: %w", err)
	}
	tailscaleCache := filepath.Join(cacheDir, "tailscale-oss")
	r := dirRunner(tailscaleCache)

	if _, err := os.Stat(tailscaleCache); err != nil {
		if !runner.ok("git", "clone", "https://github.com/tailscale/tailscale", tailscaleCache) {
			return verInfo{}, fmt.Errorf("cloning tailscale.com repo failed")
		}
	}

	if !r.ok("git", "cat-file", "-e", ref) {
		if !r.ok("git", "fetch", "origin") {
			return verInfo{}, fmt.Errorf("updating OSS repo failed")
		}
	}
	hash, err := r.output("git", "rev-parse", ref)
	if err != nil {
		return verInfo{}, err
	}
	date, err := r.output("git", "log", "-n1", "--format=%ct", ref)
	if err != nil {
		return verInfo{}, err
	}
	baseHash, err := r.output("git", "rev-list", "--max-count=1", hash, "--", "VERSION.txt")
	if err != nil {
		return verInfo{}, err
	}
	s, err := r.output("git", "show", baseHash+":VERSION.txt")
	if err != nil {
		return verInfo{}, err
	}
	major, minor, patch, err := parseVersion(s)
	if err != nil {
		return verInfo{}, err
	}
	s, err = r.output("git", "rev-list", "--count", hash, "^"+baseHash)
	if err != nil {
		return verInfo{}, err
	}
	changeCount, err := strconv.Atoi(s)
	if err != nil {
		return verInfo{}, fmt.Errorf("infoFromCache: parsing changeCount %q: %w", changeCount, err)
	}

	return verInfo{
		major:       major,
		minor:       minor,
		patch:       patch,
		changeCount: changeCount,
		hash:        hash,
		date:        date,
	}, nil
}

func infoFromDir(dir string) (verInfo, error) {
	r := dirRunner(dir)
	gitDir := filepath.Join(dir, ".git")
	if _, err := os.Stat(gitDir); err != nil {
		// Raw directory fetch, get as much info as we can and make up the rest.
		bs, err := os.ReadFile(filepath.Join(dir, "VERSION.txt"))
		if err != nil {
			return verInfo{}, err
		}
		major, minor, patch, err := parseVersion(strings.TrimSpace(string(bs)))
		return verInfo{
			major:       major,
			minor:       minor,
			patch:       patch,
			changeCount: unknownPatchVersion,
		}, err
	}

	hash, err := r.output("git", "rev-parse", "HEAD")
	if err != nil {
		return verInfo{}, err
	}
	date, err := r.output("git", "log", "-n1", "--format=%%ct", "HEAD")
	if err != nil {
		return verInfo{}, err
	}
	baseHash, err := r.output("git", "rev-list", "--max-count=1", hash, "--", "VERSION.txt")
	if err != nil {
		return verInfo{}, err
	}
	s, err := r.output("git", "show", baseHash+":VERSION.txt")
	if err != nil {
		return verInfo{}, err
	}
	major, minor, patch, err := parseVersion(s)
	if err != nil {
		return verInfo{}, err
	}
	s, err = r.output("git", "rev-list", "--count", hash, "^"+baseHash)
	if err != nil {
		return verInfo{}, err
	}
	changeCount, err := strconv.Atoi(s)
	if err != nil {
		return verInfo{}, err
	}

	return verInfo{
		major:       major,
		minor:       minor,
		patch:       patch,
		changeCount: changeCount,
		hash:        hash,
		date:        date,
	}, nil
}

func parseVersion(s string) (major, minor, patch int, err error) {
	fs := strings.Split(strings.TrimSpace(s), ".")
	if len(fs) != 3 {
		err = fmt.Errorf("parseVersion: parsing %q: wrong number of parts: %d", s, len(fs))
		return
	}
	ints := make([]int, 0, 3)
	for _, s := range fs {
		var i int
		i, err = strconv.Atoi(s)
		if err != nil {
			err = fmt.Errorf("parseVersion: parsing %q: %w", s, err)
			return
		}
		ints = append(ints, i)
	}
	return ints[0], ints[1], ints[2], nil
}

func shortHash(hash string) string {
	if len(hash) < 9 {
		return hash
	}
	return hash[:9]
}

// dirRunner executes commands in the specified dir.
type dirRunner string

func (r dirRunner) output(prog string, args ...string) (string, error) {
	cmd := exec.Command(prog, args...)
	// Sometimes, our binaries end up running in a world where
	// GO111MODULE=off, because x/tools/go/packages disables Go
	// modules on occasion and then runs other Go code. This breaks
	// executing "go mod edit", which requires that Go modules be
	// enabled.
	//
	// Since nothing we do here ever wants Go modules to be turned
	// off, force it on here so that we can read module data
	// regardless of the environment.
	cmd.Env = append(os.Environ(), "GO111MODULE=on")
	cmd.Dir = string(r)
	out, err := cmd.Output()
	if err != nil {
		if ee, ok := err.(*exec.ExitError); ok {
			return "", fmt.Errorf("running %v: %w, out=%s, err=%s", cmd.Args, err, out, ee.Stderr)
		}
		return "", fmt.Errorf("running %v: %w, %s", cmd.Args, err, out)
	}
	return strings.TrimSpace(string(out)), nil
}

func (r dirRunner) ok(prog string, args ...string) bool {
	cmd := exec.Command(prog, args...)
	cmd.Dir = string(r)
	return cmd.Run() == nil
}
