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

// Script-driven tests.
// See testdata/script/README for an overview.

//go:generate go test cmd/go -v -run=TestScript/README --fixreadme

package main_test

import (
	"bufio"
	"bytes"
	"context"
	_ "embed"
	"flag"
	"internal/testenv"
	"internal/txtar"
	"net/url"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
	"time"

	"cmd/go/internal/cfg"
	"cmd/go/internal/gover"
	"cmd/go/internal/vcweb/vcstest"
	"cmd/internal/script"
	"cmd/internal/script/scripttest"

	"golang.org/x/telemetry/counter/countertest"
)

var testSum = flag.String("testsum", "", `may be tidy, listm, or listall. If set, TestScript generates a go.sum file at the beginning of each test and updates test files if they pass.`)

// TestScript runs the tests in testdata/script/*.txt.
func TestScript(t *testing.T) {
	testenv.MustHaveGoBuild(t)
	testenv.SkipIfShortAndSlow(t)

	if testing.Short() && runtime.GOOS == "plan9" {
		t.Skipf("skipping test in -short mode on %s", runtime.GOOS)
	}

	srv, err := vcstest.NewServer()
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() {
		if err := srv.Close(); err != nil {
			t.Fatal(err)
		}
	})
	certFile, err := srv.WriteCertificateFile()
	if err != nil {
		t.Fatal(err)
	}

	StartProxy()

	var (
		ctx         = context.Background()
		gracePeriod = 100 * time.Millisecond
	)
	if deadline, ok := t.Deadline(); ok {
		timeout := time.Until(deadline)

		// If time allows, increase the termination grace period to 5% of the
		// remaining time.
		if gp := timeout / 20; gp > gracePeriod {
			gracePeriod = gp
		}

		// When we run commands that execute subprocesses, we want to reserve two
		// grace periods to clean up. We will send the first termination signal when
		// the context expires, then wait one grace period for the process to
		// produce whatever useful output it can (such as a stack trace). After the
		// first grace period expires, we'll escalate to os.Kill, leaving the second
		// grace period for the test function to record its output before the test
		// process itself terminates.
		timeout -= 2 * gracePeriod

		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, timeout)
		t.Cleanup(cancel)
	}

	env, err := scriptEnv(srv, certFile)
	if err != nil {
		t.Fatal(err)
	}
	engine := &script.Engine{
		Conds: scriptConditions(t),
		Cmds:  scriptCommands(quitSignal(), gracePeriod),
		Quiet: !testing.Verbose(),
	}

	t.Run("README", func(t *testing.T) {
		checkScriptReadme(t, engine, env)
	})

	files, err := filepath.Glob("testdata/script/*.txt")
	if err != nil {
		t.Fatal(err)
	}
	for _, file := range files {
		file := file
		name := strings.TrimSuffix(filepath.Base(file), ".txt")
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			StartProxy()

			workdir, err := os.MkdirTemp(testTmpDir, name)
			if err != nil {
				t.Fatal(err)
			}
			if !*testWork {
				defer removeAll(workdir)
			}

			s, err := script.NewState(tbContext(ctx, t), workdir, env)
			if err != nil {
				t.Fatal(err)
			}

			// Unpack archive.
			a, err := txtar.ParseFile(file)
			if err != nil {
				t.Fatal(err)
			}
			telemetryDir := initScriptDirs(t, s)
			if err := s.ExtractFiles(a); err != nil {
				t.Fatal(err)
			}

			t.Log(time.Now().UTC().Format(time.RFC3339))
			work, _ := s.LookupEnv("WORK")
			t.Logf("$WORK=%s", work)

			// With -testsum, if a go.mod file is present in the test's initial
			// working directory, run 'go mod tidy'.
			if *testSum != "" {
				if updateSum(t, engine, s, a) {
					defer func() {
						if t.Failed() {
							return
						}
						data := txtar.Format(a)
						if err := os.WriteFile(file, data, 0666); err != nil {
							t.Errorf("rewriting test file: %v", err)
						}
					}()
				}
			}

			// Note: Do not use filepath.Base(file) here:
			// editors that can jump to file:line references in the output
			// will work better seeing the full path relative to cmd/go
			// (where the "go test" command is usually run).
			scripttest.Run(t, engine, s, file, bytes.NewReader(a.Comment))
			checkCounters(t, telemetryDir)
		})
	}
}

// testingTBKey is the Context key for a testing.TB.
type testingTBKey struct{}

// tbContext returns a Context derived from ctx and associated with t.
func tbContext(ctx context.Context, t testing.TB) context.Context {
	return context.WithValue(ctx, testingTBKey{}, t)
}

// tbFromContext returns the testing.TB associated with ctx, if any.
func tbFromContext(ctx context.Context) (testing.TB, bool) {
	t := ctx.Value(testingTBKey{})
	if t == nil {
		return nil, false
	}
	return t.(testing.TB), true
}

// initScriptDirs creates the initial directory structure in s for unpacking a
// cmd/go script.
func initScriptDirs(t testing.TB, s *script.State) (telemetryDir string) {
	must := func(err error) {
		if err != nil {
			t.Helper()
			t.Fatal(err)
		}
	}

	work := s.Getwd()
	must(s.Setenv("WORK", work))

	telemetryDir = filepath.Join(work, "telemetry")
	must(os.MkdirAll(telemetryDir, 0777))
	must(s.Setenv("TEST_TELEMETRY_DIR", filepath.Join(work, "telemetry")))

	must(os.MkdirAll(filepath.Join(work, "tmp"), 0777))
	must(s.Setenv(tempEnvName(), filepath.Join(work, "tmp")))

	gopath := filepath.Join(work, "gopath")
	must(s.Setenv("GOPATH", gopath))
	gopathSrc := filepath.Join(gopath, "src")
	must(os.MkdirAll(gopathSrc, 0777))
	must(s.Chdir(gopathSrc))
	return telemetryDir
}

func scriptEnv(srv *vcstest.Server, srvCertFile string) ([]string, error) {
	httpURL, err := url.Parse(srv.HTTP.URL)
	if err != nil {
		return nil, err
	}
	httpsURL, err := url.Parse(srv.HTTPS.URL)
	if err != nil {
		return nil, err
	}
	env := []string{
		pathEnvName() + "=" + testBin + string(filepath.ListSeparator) + os.Getenv(pathEnvName()),
		homeEnvName() + "=/no-home",
		"CCACHE_DISABLE=1", // ccache breaks with non-existent HOME
		"GOARCH=" + runtime.GOARCH,
		"TESTGO_GOHOSTARCH=" + goHostArch,
		"GOCACHE=" + testGOCACHE,
		"GOCOVERDIR=" + os.Getenv("GOCOVERDIR"),
		"GODEBUG=" + os.Getenv("GODEBUG"),
		"GOEXE=" + cfg.ExeSuffix,
		"GOEXPERIMENT=" + os.Getenv("GOEXPERIMENT"),
		"GOOS=" + runtime.GOOS,
		"TESTGO_GOHOSTOS=" + goHostOS,
		"GOPROXY=" + proxyURL,
		"GOPRIVATE=",
		"GOROOT=" + testGOROOT,
		"GOTRACEBACK=system",
		"TESTGONETWORK=panic", // allow only local connections by default; the [net] condition resets this
		"TESTGO_GOROOT=" + testGOROOT,
		"TESTGO_EXE=" + testGo,
		"TESTGO_VCSTEST_HOST=" + httpURL.Host,
		"TESTGO_VCSTEST_TLS_HOST=" + httpsURL.Host,
		"TESTGO_VCSTEST_CERT=" + srvCertFile,
		"TESTGONETWORK=panic", // cleared by the [net] condition
		"GOSUMDB=" + testSumDBVerifierKey,
		"GONOPROXY=",
		"GONOSUMDB=",
		"GOVCS=*:all",
		"devnull=" + os.DevNull,
		"goversion=" + gover.Local(),
		"CMDGO_TEST_RUN_MAIN=true",
		"HGRCPATH=",
		"GOTOOLCHAIN=auto",
		"newline=\n",
	}

	if testenv.Builder() != "" || os.Getenv("GIT_TRACE_CURL") == "1" {
		// To help diagnose https://go.dev/issue/52545,
		// enable tracing for Git HTTPS requests.
		env = append(env,
			"GIT_TRACE_CURL=1",
			"GIT_TRACE_CURL_NO_DATA=1",
			"GIT_REDACT_COOKIES=o,SSO,GSSO_Uberproxy")
	}
	if testing.Short() {
		// VCS commands are always somewhat slow: they either require access to external hosts,
		// or they require our intercepted vcs-test.golang.org to regenerate the repository.
		// Require all tests that use VCS commands which require remote lookups to be skipped in
		// short mode.
		env = append(env, "TESTGOVCSREMOTE=panic")
	}
	if os.Getenv("CGO_ENABLED") != "" || runtime.GOOS != goHostOS || runtime.GOARCH != goHostArch {
		// If the actual CGO_ENABLED might not match the cmd/go default, set it
		// explicitly in the environment. Otherwise, leave it unset so that we also
		// cover the default behaviors.
		env = append(env, "CGO_ENABLED="+cgoEnabled)
	}

	for _, key := range extraEnvKeys {
		if val, ok := os.LookupEnv(key); ok {
			env = append(env, key+"="+val)
		}
	}

	return env, nil
}

var extraEnvKeys = []string{
	"SYSTEMROOT",         // must be preserved on Windows to find DLLs; golang.org/issue/25210
	"WINDIR",             // must be preserved on Windows to be able to run PowerShell command; golang.org/issue/30711
	"LD_LIBRARY_PATH",    // must be preserved on Unix systems to find shared libraries
	"LIBRARY_PATH",       // allow override of non-standard static library paths
	"C_INCLUDE_PATH",     // allow override non-standard include paths
	"CC",                 // don't lose user settings when invoking cgo
	"GO_TESTING_GOTOOLS", // for gccgo testing
	"GCCGO",              // for gccgo testing
	"GCCGOTOOLDIR",       // for gccgo testing
}

// updateSum runs 'go mod tidy', 'go list -mod=mod -m all', or
// 'go list -mod=mod all' in the test's current directory if a file named
// "go.mod" is present after the archive has been extracted. updateSum modifies
// archive and returns true if go.mod or go.sum were changed.
func updateSum(t testing.TB, e *script.Engine, s *script.State, archive *txtar.Archive) (rewrite bool) {
	gomodIdx, gosumIdx := -1, -1
	for i := range archive.Files {
		switch archive.Files[i].Name {
		case "go.mod":
			gomodIdx = i
		case "go.sum":
			gosumIdx = i
		}
	}
	if gomodIdx < 0 {
		return false
	}

	var cmd string
	switch *testSum {
	case "tidy":
		cmd = "go mod tidy"
	case "listm":
		cmd = "go list -m -mod=mod all"
	case "listall":
		cmd = "go list -mod=mod all"
	default:
		t.Fatalf(`unknown value for -testsum %q; may be "tidy", "listm", or "listall"`, *testSum)
	}

	log := new(strings.Builder)
	err := e.Execute(s, "updateSum", bufio.NewReader(strings.NewReader(cmd)), log)
	if log.Len() > 0 {
		t.Logf("%s", log)
	}
	if err != nil {
		t.Fatal(err)
	}

	newGomodData, err := os.ReadFile(s.Path("go.mod"))
	if err != nil {
		t.Fatalf("reading go.mod after -testsum: %v", err)
	}
	if !bytes.Equal(newGomodData, archive.Files[gomodIdx].Data) {
		archive.Files[gomodIdx].Data = newGomodData
		rewrite = true
	}

	newGosumData, err := os.ReadFile(s.Path("go.sum"))
	if err != nil && !os.IsNotExist(err) {
		t.Fatalf("reading go.sum after -testsum: %v", err)
	}
	switch {
	case os.IsNotExist(err) && gosumIdx >= 0:
		// go.sum was deleted.
		rewrite = true
		archive.Files = append(archive.Files[:gosumIdx], archive.Files[gosumIdx+1:]...)
	case err == nil && gosumIdx < 0:
		// go.sum was created.
		rewrite = true
		gosumIdx = gomodIdx + 1
		archive.Files = append(archive.Files, txtar.File{})
		copy(archive.Files[gosumIdx+1:], archive.Files[gosumIdx:])
		archive.Files[gosumIdx] = txtar.File{Name: "go.sum", Data: newGosumData}
	case err == nil && gosumIdx >= 0 && !bytes.Equal(newGosumData, archive.Files[gosumIdx].Data):
		// go.sum was changed.
		rewrite = true
		archive.Files[gosumIdx].Data = newGosumData
	}
	return rewrite
}

func readCounters(t *testing.T, telemetryDir string) map[string]uint64 {
	localDir := filepath.Join(telemetryDir, "local")
	dirents, err := os.ReadDir(localDir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil // The Go command didn't ever run so the local dir wasn't created
		}
		t.Fatalf("reading telemetry local dir: %v", err)
	}
	totals := map[string]uint64{}
	for _, dirent := range dirents {
		if dirent.IsDir() || !strings.HasSuffix(dirent.Name(), ".count") {
			// not a counter file
			continue
		}
		counters, _, err := countertest.ReadFile(filepath.Join(localDir, dirent.Name()))
		if err != nil {
			t.Fatalf("reading counter file: %v", err)
		}
		for k, v := range counters {
			totals[k] += v
		}
	}

	return totals
}

func checkCounters(t *testing.T, telemetryDir string) {
	counters := readCounters(t, telemetryDir)
	if _, ok := scriptGoInvoked.Load(testing.TB(t)); ok {
		if !disabledOnPlatform && len(counters) == 0 {
			t.Fatal("go was invoked but no counters were incremented")
		}
	}
}

// Copied from https://go.googlesource.com/telemetry/+/5f08a0cbff3f/internal/telemetry/mode.go#122
// TODO(go.dev/issues/66205): replace this with the public API once it becomes available.
//
// disabledOnPlatform indicates whether telemetry is disabled
// due to bugs in the current platform.
const disabledOnPlatform = false ||
	// The following platforms could potentially be supported in the future:
	runtime.GOOS == "openbsd" || // #60614
	runtime.GOOS == "solaris" || // #60968 #60970
	runtime.GOOS == "android" || // #60967
	runtime.GOOS == "illumos" || // #65544
	// These platforms fundamentally can't be supported:
	runtime.GOOS == "js" || // #60971
	runtime.GOOS == "wasip1" || // #60971
	runtime.GOOS == "plan9" // https://github.com/golang/go/issues/57540#issuecomment-1470766639
