// Copyright 2019 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 zip_sum_test tests that the module zip files produced by modfetch
// have consistent content sums. Ideally the zip files themselves are also
// stable over time, though this is not strictly necessary.
//
// This test loads a table from testdata/zip_sums.csv. The table has columns
// for module path, version, content sum, and zip file hash. The table
// includes a large number of real modules. The test downloads these modules
// in direct mode and verifies the zip files.
//
// This test is very slow, and it depends on outside modules that change
// frequently, so this is a manual test. To enable it, pass the -zipsum flag.
package zip_sum_test

import (
	"context"
	"crypto/sha256"
	"encoding/csv"
	"encoding/hex"
	"flag"
	"fmt"
	"internal/testenv"
	"io"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"cmd/go/internal/cfg"
	"cmd/go/internal/modfetch"
	"cmd/go/internal/modload"

	"golang.org/x/mod/module"
)

var (
	updateTestData = flag.Bool("u", false, "when set, tests may update files in testdata instead of failing")
	enableZipSum   = flag.Bool("zipsum", false, "enable TestZipSums")
	debugZipSum    = flag.Bool("testwork", false, "when set, TestZipSums will preserve its test directory")
	modCacheDir    = flag.String("zipsumcache", "", "module cache to use instead of temp directory")
	shardCount     = flag.Int("zipsumshardcount", 1, "number of shards to divide TestZipSums into")
	shardIndex     = flag.Int("zipsumshard", 0, "index of TestZipSums shard to test (0 <= zipsumshard < zipsumshardcount)")
)

const zipSumsPath = "testdata/zip_sums.csv"

type zipSumTest struct {
	m                     module.Version
	wantSum, wantFileHash string
}

func TestZipSums(t *testing.T) {
	if !*enableZipSum {
		// This test is very slow and heavily dependent on external repositories.
		// Only run it explicitly.
		t.Skip("TestZipSum not enabled with -zipsum")
	}
	if *shardCount < 1 {
		t.Fatal("-zipsumshardcount must be a positive integer")
	}
	if *shardIndex < 0 || *shardCount <= *shardIndex {
		t.Fatal("-zipsumshard must be between 0 and -zipsumshardcount")
	}

	testenv.MustHaveGoBuild(t)
	testenv.MustHaveExternalNetwork(t)
	testenv.MustHaveExecPath(t, "bzr")
	testenv.MustHaveExecPath(t, "git")
	// TODO(jayconrod): add hg, svn, and fossil modules to testdata.
	// Could not find any for now.

	tests, err := readZipSumTests()
	if err != nil {
		t.Fatal(err)
	}

	if *modCacheDir != "" {
		cfg.BuildContext.GOPATH = *modCacheDir
	} else {
		tmpDir, err := os.MkdirTemp("", "TestZipSums")
		if err != nil {
			t.Fatal(err)
		}
		if *debugZipSum {
			fmt.Fprintf(os.Stderr, "TestZipSums: modCacheDir: %s\n", tmpDir)
		} else {
			defer os.RemoveAll(tmpDir)
		}
		cfg.BuildContext.GOPATH = tmpDir
	}

	cfg.GOPROXY = "direct"
	cfg.GOSUMDB = "off"
	modload.Init()

	// Shard tests by downloading only every nth module when shard flags are set.
	// This makes it easier to test small groups of modules quickly. We avoid
	// testing similarly named modules together (the list is sorted by module
	// path and version).
	if *shardCount > 1 {
		r := *shardIndex
		w := 0
		for r < len(tests) {
			tests[w] = tests[r]
			w++
			r += *shardCount
		}
		tests = tests[:w]
	}

	// Download modules with a rate limit. We may run out of file descriptors
	// or cause timeouts without a limit.
	needUpdate := false
	for i := range tests {
		test := &tests[i]
		name := fmt.Sprintf("%s@%s", strings.ReplaceAll(test.m.Path, "/", "_"), test.m.Version)
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			zipPath, err := modfetch.DownloadZip(context.Background(), test.m)
			if err != nil {
				if *updateTestData {
					t.Logf("%s: could not download module: %s (will remove from testdata)", test.m, err)
					test.m.Path = "" // mark for deletion
					needUpdate = true
				} else {
					t.Errorf("%s: could not download module: %s", test.m, err)
				}
				return
			}

			sum := modfetch.Sum(test.m)
			if sum != test.wantSum {
				if *updateTestData {
					t.Logf("%s: updating content sum to %s", test.m, sum)
					test.wantSum = sum
					needUpdate = true
				} else {
					t.Errorf("%s: got content sum %s; want sum %s", test.m, sum, test.wantSum)
					return
				}
			}

			h := sha256.New()
			f, err := os.Open(zipPath)
			if err != nil {
				t.Errorf("%s: %v", test.m, err)
			}
			defer f.Close()
			if _, err := io.Copy(h, f); err != nil {
				t.Errorf("%s: %v", test.m, err)
			}
			zipHash := hex.EncodeToString(h.Sum(nil))
			if zipHash != test.wantFileHash {
				if *updateTestData {
					t.Logf("%s: updating zip file hash to %s", test.m, zipHash)
					test.wantFileHash = zipHash
					needUpdate = true
				} else {
					t.Errorf("%s: got zip file hash %s; want hash %s (but content sum matches)", test.m, zipHash, test.wantFileHash)
				}
			}
		})
	}

	if needUpdate {
		// Remove tests marked for deletion
		r, w := 0, 0
		for r < len(tests) {
			if tests[r].m.Path != "" {
				tests[w] = tests[r]
				w++
			}
			r++
		}
		tests = tests[:w]

		if err := writeZipSumTests(tests); err != nil {
			t.Error(err)
		}
	}
}

func readZipSumTests() ([]zipSumTest, error) {
	f, err := os.Open(filepath.FromSlash(zipSumsPath))
	if err != nil {
		return nil, err
	}
	defer f.Close()
	r := csv.NewReader(f)

	var tests []zipSumTest
	for {
		line, err := r.Read()
		if err == io.EOF {
			break
		} else if err != nil {
			return nil, err
		} else if len(line) != 4 {
			return nil, fmt.Errorf("%s:%d: malformed line", f.Name(), len(tests)+1)
		}
		test := zipSumTest{m: module.Version{Path: line[0], Version: line[1]}, wantSum: line[2], wantFileHash: line[3]}
		tests = append(tests, test)
	}
	return tests, nil
}

func writeZipSumTests(tests []zipSumTest) (err error) {
	f, err := os.Create(filepath.FromSlash(zipSumsPath))
	if err != nil {
		return err
	}
	defer func() {
		if cerr := f.Close(); err == nil && cerr != nil {
			err = cerr
		}
	}()
	w := csv.NewWriter(f)
	line := make([]string, 0, 4)
	for _, test := range tests {
		line = append(line[:0], test.m.Path, test.m.Version, test.wantSum, test.wantFileHash)
		if err := w.Write(line); err != nil {
			return err
		}
	}
	w.Flush()
	return nil
}
