// Copyright 2023 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.

//go:generate go run . -w

//go:build go1.21

// Package configgen generates the upload config file stored in the config.json
// file of golang.org/x/telemetry/config based on the graphconfig stored in
// config.txt.
package main

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"slices"
	"sort"
	"strings"

	_ "embed"

	"golang.org/x/mod/semver"
	"golang.org/x/telemetry/internal/graphconfig"
	"golang.org/x/telemetry/internal/telemetry"
)

var (
	write = flag.Bool("w", false, "if set, write the config file; otherwise, print to stdout")
	force = flag.Bool("f", false, "if set, force the write of the config file even if the current content is still valid")

	// SamplingRate is the fraction of otherwise uploadable reports that will be uploaded
	SamplingRate = 1.0
)

//go:embed config.txt
var graphConfig []byte

func main() {
	flag.Parse()

	// The padding heuristics below are based on the example of gopls.
	//
	// The goal is to pad enough versions for a quarter.
	cfg, err := generate(graphConfig, padding{
		// 6 releases into the future translates to approximately three months for gopls.
		releases: 6,
		// We may release gopls 1.0, but won't release 2.0 in a three month timespan!
		maj: 1,
		// We don't usually do more than one minor release a month.
		majmin: 3,
		// Since golang/go#55267, which committed to adhering to semver, gopls
		// hasn't had more than 5 patches per minor version.
		patch: 6,
		// Gopls has never had more than 4 prereleases.
		pre: 4,
	})

	if err != nil {
		log.Fatal(err)
	}
	cfgJSON, err := json.MarshalIndent(cfg, "", "\t")
	if err != nil {
		log.Fatal(err)
	}

	if !*write {
		fmt.Println(string(cfgJSON))
		os.Exit(0)
	}

	configFile, err := configFile()
	if err != nil {
		log.Fatalf("finding config file: %v", err)
	}

	if !*force {
		currentCfg, err := readConfig(configFile)
		if err != nil {
			log.Fatal(err)
		}
		// Guarantee that we have enough padding to do two patches releases tomorrow.
		minCfg, err := generate(graphConfig, padding{
			releases: 2,
			maj:      1,
			majmin:   1, // we're not ever going to do more than one major/minor release in a day
			patch:    2,
			pre:      2, // in a single day, we wouldn't prep more than two prereleases per version
		})
		if err != nil {
			log.Fatal(err)
		}
		if contains(currentCfg, minCfg) {
			fmt.Fprintln(os.Stderr, "not writing the config file as it is still valid; use -f to force")
			os.Exit(0)
		}
	}
	if err := os.WriteFile(configFile, cfgJSON, 0666); err != nil {
		log.Fatal(err)
	}
}

// configFile returns the path to the x/telemetry/config config.json file in
// this repo.
//
// The file must already exist: this won't be a valid location if running from
// the module cache; this functionality only works when executed from the
// telemetry repo.
func configFile() (string, error) {
	out, err := exec.Command("go", "list", "-f", "{{.Dir}}", "golang.org/x/telemetry/internal/configgen").Output()
	if err != nil {
		return "", err
	}
	dir := strings.TrimSpace(string(out))
	configFile := filepath.Join(dir, "..", "..", "config", "config.json")
	if _, err := os.Stat(configFile); err != nil {
		return "", err
	}
	return configFile, nil
}

func readConfig(file string) (*telemetry.UploadConfig, error) {
	data, err := os.ReadFile(file)
	if err != nil {
		return nil, fmt.Errorf("reading config file: %v", err)
	}
	cfg := new(telemetry.UploadConfig)
	if err := json.Unmarshal(data, cfg); err != nil {
		return nil, fmt.Errorf("unmarshalling config file: %v", err)
	}
	return cfg, nil
}

// generate computes the upload config from graph configs and module
// information, returning the resulting formatted JSON.
func generate(graphConfig []byte, padding padding) (*telemetry.UploadConfig, error) {
	ucfg := &telemetry.UploadConfig{
		GOOS:   goos(),
		GOARCH: goarch(),
		// the probability of uploading a report
		SampleRate: SamplingRate,
	}
	var err error
	ucfg.GoVersion, err = goVersions()
	if err != nil {
		return nil, fmt.Errorf("querying go info: %v", err)
	}

	gcfgs, err := graphconfig.Parse(graphConfig)
	if err != nil {
		return nil, fmt.Errorf("parsing graph config records: %v", err)
	}

	for i, r := range gcfgs {
		if err := graphconfig.Validate(r); err != nil {
			// TODO(rfindley): this is a poor way to identify the faulty record. We
			// should probably store position information in the GraphConfig.
			return nil, fmt.Errorf("graph config #%d (%q): %v", i, r.Title, err)
		}
	}

	var (
		programs    = make(map[string]*telemetry.ProgramConfig) // package path -> config
		minVersions = make(map[string]string)                   // package path -> min version required, or "" for all
	)
	for _, gcfg := range gcfgs {
		pcfg := programs[gcfg.Program]
		if pcfg == nil {
			pcfg = &telemetry.ProgramConfig{
				Name: gcfg.Program,
			}
			programs[gcfg.Program] = pcfg
			minVersions[gcfg.Program] = gcfg.Version
		}
		minVersions[gcfg.Program] = minVersion(minVersions[gcfg.Program], gcfg.Version)
		ccfg := telemetry.CounterConfig{
			Name:  gcfg.Counter,
			Rate:  1.0, // TODO(rfindley): how should rate be configured?
			Depth: gcfg.Depth,
		}
		if gcfg.Depth > 0 {
			pcfg.Stacks = append(pcfg.Stacks, ccfg)
		} else {
			pcfg.Counters = append(pcfg.Counters, ccfg)
		}
	}

	for _, p := range programs {
		minVersion := minVersions[p.Name]
		versions, err := listProxyVersions(p.Name)
		if err != nil {
			return nil, fmt.Errorf("listing versions for %q: %v", p.Name, err)
		}
		// Filter proxy versions in place.
		i := 0
		for _, v := range versions {
			if !semver.IsValid(v) {
				// In order to perform semver comparison below, we must have valid
				// versions. This should always be the case for the proxy.
				// Trust, but verify.
				return nil, fmt.Errorf("invalid semver %q returned from proxy for %q", v, p.Name)
			}
			if minVersion == "" || semver.Compare(minVersion, v) <= 0 {
				versions[i] = v
				i++
			}
		}
		p.Versions = padVersions(versions[:i], prereleasesForProgram(p.Name), padding)
		// TODO(hakim): allow to collect counters from gopls@devel. go.dev/issues/62271
		if p.Name == "golang.org/x/tools/gopls" {
			p.Versions = append(p.Versions, "devel") // added at the end.
		}
		ucfg.Programs = append(ucfg.Programs, p)
	}
	sort.Slice(ucfg.Programs, func(i, j int) bool {
		return ucfg.Programs[i].Name < ucfg.Programs[j].Name
	})

	return ucfg, nil
}

// contains reports whether outer contains all program versions of inner, and
// is otherwise equivalent to inner.
func contains(outer, inner *telemetry.UploadConfig) bool {
	if !slices.Equal(outer.GOARCH, inner.GOARCH) {
		return false
	}
	if !slices.Equal(outer.GOOS, inner.GOOS) {
		return false
	}
	if !slices.Equal(outer.GoVersion, inner.GoVersion) {
		return false
	}

	for _, pi := range inner.Programs {
		i := slices.IndexFunc(outer.Programs, func(po *telemetry.ProgramConfig) bool {
			return po.Name == pi.Name
		})
		if i < 0 {
			return false
		}
		po := outer.Programs[i]
		if !sliceContains(po.Versions, pi.Versions) {
			return false
		}
		if !slices.Equal(po.Counters, pi.Counters) {
			return false
		}
		if !slices.Equal(po.Stacks, pi.Stacks) {
			return false
		}
	}
	for _, po := range outer.Programs {
		if !slices.ContainsFunc(inner.Programs, func(pi *telemetry.ProgramConfig) bool {
			return pi.Name == po.Name
		}) {
			return false
		}
	}
	return true
}

func sliceContains[T comparable](outer, inner []T) bool {
	m := toMap(outer)
	for _, v := range inner {
		if !m[v] {
			return false
		}
	}
	return true
}

func toMap[T comparable](s []T) map[T]bool {
	m := make(map[T]bool)
	for _, v := range s {
		m[v] = true
	}
	return m
}

// prereleasesForProgram returns the set of prereleases to use for the provided
// program. We may need to customize this for the conventions of different
// programs.
func prereleasesForProgram(program string) []string {
	// Surely eight prereleases is enough for any program... :)
	return []string{"pre.1", "pre.2", "pre.3", "pre.4", "pre.5", "pre.6", "pre.7", "pre.8"}
}

// minVersion returns the lesser semantic version of v1 and v2.
//
// As a special case, the empty string is treated as an absolute minimum
// (empty => all versions are greater).
func minVersion(v1, v2 string) string {
	if v1 == "" || v2 == "" {
		return ""
	}
	if semver.Compare(v1, v2) > 0 {
		return v2
	}
	return v1
}

// goos returns a sorted slice of known GOOS values.
func goos() []string {
	var gooses []string
	for goos := range knownOS {
		gooses = append(gooses, goos)
	}
	sort.Strings(gooses)
	return gooses
}

// goarch returns a sorted slice of known GOARCH values.
func goarch() []string {
	var arches []string
	for arch := range knownArch {
		arches = append(arches, arch)
	}
	sort.Strings(arches)
	return arches
}

// goInfo queries the proxy for information about go distributions, including
// versions, GOOS, and GOARCH values.
func goVersions() ([]string, error) {
	// Trick: read Go distribution information from the module versions of
	// golang.org/toolchain. These define the set of valid toolchains, and
	// therefore are a reasonable source for version information.
	//
	// A more authoritative source for this information may be
	// https://go.dev/dl?mode=json&include=all.
	proxyVersions, err := listProxyVersions("golang.org/toolchain")
	if err != nil {
		return nil, fmt.Errorf("listing toolchain versions: %v", err)
	}
	var goVersionRx = regexp.MustCompile(`^-(go.+)\.[^.]+-[^.]+$`)
	verSet := make(map[string]struct{})
	for _, v := range proxyVersions {
		pre := semver.Prerelease(v)
		match := goVersionRx.FindStringSubmatch(pre)
		if match == nil {
			return nil, fmt.Errorf("proxy version %q does not match prerelease regexp %q", v, goVersionRx)
		}
		verSet[match[1]] = struct{}{}
	}
	var vers []string
	for v := range verSet {
		vers = append(vers, v)
	}
	sort.Sort(byGoVersion(vers))
	// TODO(golang/go#62271): temporarily monitor 'devel'.
	vers = append(vers, "devel")
	return vers, nil
}

type byGoVersion []string

func (vs byGoVersion) Len() int      { return len(vs) }
func (vs byGoVersion) Swap(i, j int) { vs[i], vs[j] = vs[j], vs[i] }
func (vs byGoVersion) Less(i, j int) bool {
	cmp := Compare(vs[i], vs[j])
	if cmp != 0 {
		return cmp < 0
	}
	// To ensure that we have a stable sort, order equivalent Go versions lexically.
	return vs[i] < vs[j]
}

// versionsForTesting contains versions to use for testing, rather than
// querying the proxy.
var versionsForTesting map[string][]string

// listProxyVersions queries the Go module mirror for published versions of the
// given modulePath.
//
// modulePath must be lower-case (or already escaped): this function doesn't do
// any escaping of upper-cased letters, as is required by the proxy prototol
// (https://go.dev/ref/mod#goproxy-protocol).
func listProxyVersions(modulePath string) ([]string, error) {
	if vers, ok := versionsForTesting[modulePath]; ok {
		return vers, nil
	}
	cmd := exec.Command("go", "list", "-m", "--versions", modulePath)
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	out, err := cmd.Output()
	if err != nil {
		return nil, fmt.Errorf("listing versions: %v (stderr: %v)", err, stderr.String())
	}
	fields := strings.Fields(strings.TrimSpace(string(out)))
	if len(fields) == 0 {
		return nil, fmt.Errorf("invalid version list output: %q", string(out))
	}
	return fields[1:], nil
}

// padding defines constraints on additional versions to pad.
//
// These constraints help restrict version padding to "reasonable" versions,
// based on heuristics such as "we never do more than 3 prereleases for a given
// version" or "we never have more than 5 patch versions" or "we can't do more
// than 10 total releases over that time period". See the field documentation
// for details.
type padding struct {
	releases int // bounds on the total number of releases
	maj      int // bounds the number of new major versions
	majmin   int // bounds the number of new major+minor versions
	patch    int // bounds the number of new patch versions
	pre      int // the number of prereleases to pad per release
}

// padVersions pads the existing version list with potential next versions, so
// that we don't have to wait an additional day to start getting reports for a
// newly tagged version.
//
// The prereleases argument may be supplied to provide a set of potential
// prerelease candidates. For example, if the program releases prereleases of
// the form "-pre.N", prereleases should be {"pre.1", "pre.2", ...}. For each
// potential next release version, the next two prerelease versions will be
// selected out of the provided set of prereleases.
func padVersions(versions []string, prereleasePatterns []string, padding padding) []string {
	versions = slices.Clone(versions)
	semver.Sort(versions)

	latestRelease := "v0.0.0"
	all := make(map[string]bool) // for de-duplicating padded versions
	for _, v := range versions {
		cv := semver.Canonical(v)
		all[cv] = true
		if semver.Prerelease(cv) == "" && semver.Compare(latestRelease, cv) < 0 {
			latestRelease = cv
		}
	}

	parsedLatest, ok := parseSemver(latestRelease)
	if !ok {
		// "can't happen", since the latest release version should always be canonical.
		panic(fmt.Sprintf("unable to parse latest release version %q", latestRelease))
	}

	// Pad the latest version only.
	//
	// This assumes that the program in question doesn't patch older releases
	// (as is the case with gopls). If that assumption ever changes, we may need
	// to apply padding to older versions as well.
	versionsToPad := []version{parsedLatest}

	var maj, min, patch int
	for _, toPad := range versionsToPad {
		for majPadding := 0; majPadding <= padding.maj; majPadding++ {
			maj = toPad.major + majPadding
			for minPadding := 0; minPadding+majPadding <= padding.majmin; minPadding++ {
				if majPadding == 0 {
					min = toPad.minor + minPadding
				} else {
					min = minPadding
				}
				for patchPadding := 0; patchPadding <= padding.patch; patchPadding++ {
					releases := majPadding + minPadding + patchPadding
					if releases == 0 || releases > padding.releases {
						continue
					}
					if majPadding == 0 && minPadding == 0 {
						patch = toPad.patch + patchPadding
					} else {
						patch = patchPadding
					}

					v := fmt.Sprintf("v%d.%d.%d", maj, min, patch)
					if all[v] {
						// This guard is future proofing: we may have seen this version
						// before if we are ever padding something other than the latest
						// version.
						continue
					}
					versions = append(versions, v)

					// We may already have prereleases at this version. Don't pad
					// additional prereleases, under the assumption that we don't
					// typically have more than padding.pre prereleases.
					nextPrerelease := 0
					for i, patt := range prereleasePatterns {
						pre := fmt.Sprintf("%s-%s", v, patt)
						if all[pre] {
							nextPrerelease = i + 1
						}
					}
					for i := nextPrerelease; i < len(prereleasePatterns) && i < padding.pre; i++ {
						pre := fmt.Sprintf("%s-%s", v, prereleasePatterns[i])
						versions = append(versions, pre)
					}
				}
			}
		}
	}

	semver.Sort(versions)
	return versions
}

// version is a parsed semantic version.
type version struct {
	major, minor, patch int
	pre                 string
}

// parseSemver attempts to parse semver components out of the provided semver
// v. If v is not valid semver in canonical form, parseSemver returns _, _, _,
// _, false.
func parseSemver(v string) (_ version, ok bool) {
	var parsed version
	v, parsed.pre, _ = strings.Cut(v, "-")
	if _, err := fmt.Sscanf(v, "v%d.%d.%d", &parsed.major, &parsed.minor, &parsed.patch); err == nil {
		ok = true
	}
	return parsed, ok
}
