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

package modfetch

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"math/rand"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"

	"cmd/go/internal/base"
	"cmd/go/internal/cfg"
	"cmd/go/internal/gover"
	"cmd/go/internal/lockedfile"
	"cmd/go/internal/modfetch/codehost"
	"cmd/go/internal/par"
	"cmd/go/internal/robustio"

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

func cacheDir(ctx context.Context, path string) (string, error) {
	if err := checkCacheDir(ctx); err != nil {
		return "", err
	}
	enc, err := module.EscapePath(path)
	if err != nil {
		return "", err
	}
	return filepath.Join(cfg.GOMODCACHE, "cache/download", enc, "/@v"), nil
}

func CachePath(ctx context.Context, m module.Version, suffix string) (string, error) {
	if gover.IsToolchain(m.Path) {
		return "", ErrToolchain
	}
	dir, err := cacheDir(ctx, m.Path)
	if err != nil {
		return "", err
	}
	if !gover.ModIsValid(m.Path, m.Version) {
		return "", fmt.Errorf("non-semver module version %q", m.Version)
	}
	if module.CanonicalVersion(m.Version) != m.Version {
		return "", fmt.Errorf("non-canonical module version %q", m.Version)
	}
	encVer, err := module.EscapeVersion(m.Version)
	if err != nil {
		return "", err
	}
	return filepath.Join(dir, encVer+"."+suffix), nil
}

// DownloadDir returns the directory to which m should have been downloaded.
// An error will be returned if the module path or version cannot be escaped.
// An error satisfying errors.Is(err, fs.ErrNotExist) will be returned
// along with the directory if the directory does not exist or if the directory
// is not completely populated.
func DownloadDir(ctx context.Context, m module.Version) (string, error) {
	if gover.IsToolchain(m.Path) {
		return "", ErrToolchain
	}
	if err := checkCacheDir(ctx); err != nil {
		return "", err
	}
	enc, err := module.EscapePath(m.Path)
	if err != nil {
		return "", err
	}
	if !gover.ModIsValid(m.Path, m.Version) {
		return "", fmt.Errorf("non-semver module version %q", m.Version)
	}
	if module.CanonicalVersion(m.Version) != m.Version {
		return "", fmt.Errorf("non-canonical module version %q", m.Version)
	}
	encVer, err := module.EscapeVersion(m.Version)
	if err != nil {
		return "", err
	}

	// Check whether the directory itself exists.
	dir := filepath.Join(cfg.GOMODCACHE, enc+"@"+encVer)
	if fi, err := os.Stat(dir); os.IsNotExist(err) {
		return dir, err
	} else if err != nil {
		return dir, &DownloadDirPartialError{dir, err}
	} else if !fi.IsDir() {
		return dir, &DownloadDirPartialError{dir, errors.New("not a directory")}
	}

	// Check if a .partial file exists. This is created at the beginning of
	// a download and removed after the zip is extracted.
	partialPath, err := CachePath(ctx, m, "partial")
	if err != nil {
		return dir, err
	}
	if _, err := os.Stat(partialPath); err == nil {
		return dir, &DownloadDirPartialError{dir, errors.New("not completely extracted")}
	} else if !os.IsNotExist(err) {
		return dir, err
	}

	// Check if a .ziphash file exists. It should be created before the
	// zip is extracted, but if it was deleted (by another program?), we need
	// to re-calculate it. Note that checkMod will repopulate the ziphash
	// file if it doesn't exist, but if the module is excluded by checks
	// through GONOSUMDB or GOPRIVATE, that check and repopulation won't happen.
	ziphashPath, err := CachePath(ctx, m, "ziphash")
	if err != nil {
		return dir, err
	}
	if _, err := os.Stat(ziphashPath); os.IsNotExist(err) {
		return dir, &DownloadDirPartialError{dir, errors.New("ziphash file is missing")}
	} else if err != nil {
		return dir, err
	}
	return dir, nil
}

// DownloadDirPartialError is returned by DownloadDir if a module directory
// exists but was not completely populated.
//
// DownloadDirPartialError is equivalent to fs.ErrNotExist.
type DownloadDirPartialError struct {
	Dir string
	Err error
}

func (e *DownloadDirPartialError) Error() string     { return fmt.Sprintf("%s: %v", e.Dir, e.Err) }
func (e *DownloadDirPartialError) Is(err error) bool { return err == fs.ErrNotExist }

// lockVersion locks a file within the module cache that guards the downloading
// and extraction of the zipfile for the given module version.
func lockVersion(ctx context.Context, mod module.Version) (unlock func(), err error) {
	path, err := CachePath(ctx, mod, "lock")
	if err != nil {
		return nil, err
	}
	if err := os.MkdirAll(filepath.Dir(path), 0777); err != nil {
		return nil, err
	}
	return lockedfile.MutexAt(path).Lock()
}

// SideLock locks a file within the module cache that previously guarded
// edits to files outside the cache, such as go.sum and go.mod files in the
// user's working directory.
// If err is nil, the caller MUST eventually call the unlock function.
func SideLock(ctx context.Context) (unlock func(), err error) {
	if err := checkCacheDir(ctx); err != nil {
		return nil, err
	}

	path := filepath.Join(cfg.GOMODCACHE, "cache", "lock")
	if err := os.MkdirAll(filepath.Dir(path), 0777); err != nil {
		return nil, fmt.Errorf("failed to create cache directory: %w", err)
	}

	return lockedfile.MutexAt(path).Lock()
}

// A cachingRepo is a cache around an underlying Repo,
// avoiding redundant calls to ModulePath, Versions, Stat, Latest, and GoMod (but not CheckReuse or Zip).
// It is also safe for simultaneous use by multiple goroutines
// (so that it can be returned from Lookup multiple times).
// It serializes calls to the underlying Repo.
type cachingRepo struct {
	path          string
	versionsCache par.ErrCache[string, *Versions]
	statCache     par.ErrCache[string, *RevInfo]
	latestCache   par.ErrCache[struct{}, *RevInfo]
	gomodCache    par.ErrCache[string, []byte]

	once     sync.Once
	initRepo func(context.Context) (Repo, error)
	r        Repo
}

func newCachingRepo(ctx context.Context, path string, initRepo func(context.Context) (Repo, error)) *cachingRepo {
	return &cachingRepo{
		path:     path,
		initRepo: initRepo,
	}
}

func (r *cachingRepo) repo(ctx context.Context) Repo {
	r.once.Do(func() {
		var err error
		r.r, err = r.initRepo(ctx)
		if err != nil {
			r.r = errRepo{r.path, err}
		}
	})
	return r.r
}

func (r *cachingRepo) CheckReuse(ctx context.Context, old *codehost.Origin) error {
	return r.repo(ctx).CheckReuse(ctx, old)
}

func (r *cachingRepo) ModulePath() string {
	return r.path
}

func (r *cachingRepo) Versions(ctx context.Context, prefix string) (*Versions, error) {
	v, err := r.versionsCache.Do(prefix, func() (*Versions, error) {
		return r.repo(ctx).Versions(ctx, prefix)
	})

	if err != nil {
		return nil, err
	}
	return &Versions{
		Origin: v.Origin,
		List:   append([]string(nil), v.List...),
	}, nil
}

type cachedInfo struct {
	info *RevInfo
	err  error
}

func (r *cachingRepo) Stat(ctx context.Context, rev string) (*RevInfo, error) {
	if gover.IsToolchain(r.path) {
		// Skip disk cache; the underlying golang.org/toolchain repo is cached instead.
		return r.repo(ctx).Stat(ctx, rev)
	}
	info, err := r.statCache.Do(rev, func() (*RevInfo, error) {
		file, info, err := readDiskStat(ctx, r.path, rev)
		if err == nil {
			return info, err
		}

		info, err = r.repo(ctx).Stat(ctx, rev)
		if err == nil {
			// If we resolved, say, 1234abcde to v0.0.0-20180604122334-1234abcdef78,
			// then save the information under the proper version, for future use.
			if info.Version != rev {
				file, _ = CachePath(ctx, module.Version{Path: r.path, Version: info.Version}, "info")
				r.statCache.Do(info.Version, func() (*RevInfo, error) {
					return info, nil
				})
			}

			if err := writeDiskStat(ctx, file, info); err != nil {
				fmt.Fprintf(os.Stderr, "go: writing stat cache: %v\n", err)
			}
		}
		return info, err
	})
	if info != nil {
		copy := *info
		info = &copy
	}
	return info, err
}

func (r *cachingRepo) Latest(ctx context.Context) (*RevInfo, error) {
	if gover.IsToolchain(r.path) {
		// Skip disk cache; the underlying golang.org/toolchain repo is cached instead.
		return r.repo(ctx).Latest(ctx)
	}
	info, err := r.latestCache.Do(struct{}{}, func() (*RevInfo, error) {
		info, err := r.repo(ctx).Latest(ctx)

		// Save info for likely future Stat call.
		if err == nil {
			r.statCache.Do(info.Version, func() (*RevInfo, error) {
				return info, nil
			})
			if file, _, err := readDiskStat(ctx, r.path, info.Version); err != nil {
				writeDiskStat(ctx, file, info)
			}
		}

		return info, err
	})
	if info != nil {
		copy := *info
		info = &copy
	}
	return info, err
}

func (r *cachingRepo) GoMod(ctx context.Context, version string) ([]byte, error) {
	if gover.IsToolchain(r.path) {
		// Skip disk cache; the underlying golang.org/toolchain repo is cached instead.
		return r.repo(ctx).GoMod(ctx, version)
	}
	text, err := r.gomodCache.Do(version, func() ([]byte, error) {
		file, text, err := readDiskGoMod(ctx, r.path, version)
		if err == nil {
			// Note: readDiskGoMod already called checkGoMod.
			return text, nil
		}

		text, err = r.repo(ctx).GoMod(ctx, version)
		if err == nil {
			if err := checkGoMod(r.path, version, text); err != nil {
				return text, err
			}
			if err := writeDiskGoMod(ctx, file, text); err != nil {
				fmt.Fprintf(os.Stderr, "go: writing go.mod cache: %v\n", err)
			}
		}
		return text, err
	})
	if err != nil {
		return nil, err
	}
	return append([]byte(nil), text...), nil
}

func (r *cachingRepo) Zip(ctx context.Context, dst io.Writer, version string) error {
	if gover.IsToolchain(r.path) {
		return ErrToolchain
	}
	return r.repo(ctx).Zip(ctx, dst, version)
}

// InfoFile is like Lookup(ctx, path).Stat(version) but also returns the name of the file
// containing the cached information.
func InfoFile(ctx context.Context, path, version string) (*RevInfo, string, error) {
	if !gover.ModIsValid(path, version) {
		return nil, "", fmt.Errorf("invalid version %q", version)
	}

	if file, info, err := readDiskStat(ctx, path, version); err == nil {
		return info, file, nil
	}

	var info *RevInfo
	var err2info map[error]*RevInfo
	err := TryProxies(func(proxy string) error {
		i, err := Lookup(ctx, proxy, path).Stat(ctx, version)
		if err == nil {
			info = i
		} else {
			if err2info == nil {
				err2info = make(map[error]*RevInfo)
			}
			err2info[err] = info
		}
		return err
	})
	if err != nil {
		return err2info[err], "", err
	}

	// Stat should have populated the disk cache for us.
	file, err := CachePath(ctx, module.Version{Path: path, Version: version}, "info")
	if err != nil {
		return nil, "", err
	}
	return info, file, nil
}

// GoMod is like Lookup(ctx, path).GoMod(rev) but avoids the
// repository path resolution in Lookup if the result is
// already cached on local disk.
func GoMod(ctx context.Context, path, rev string) ([]byte, error) {
	// Convert commit hash to pseudo-version
	// to increase cache hit rate.
	if !gover.ModIsValid(path, rev) {
		if _, info, err := readDiskStat(ctx, path, rev); err == nil {
			rev = info.Version
		} else {
			if errors.Is(err, statCacheErr) {
				return nil, err
			}
			err := TryProxies(func(proxy string) error {
				info, err := Lookup(ctx, proxy, path).Stat(ctx, rev)
				if err == nil {
					rev = info.Version
				}
				return err
			})
			if err != nil {
				return nil, err
			}
		}
	}

	_, data, err := readDiskGoMod(ctx, path, rev)
	if err == nil {
		return data, nil
	}

	err = TryProxies(func(proxy string) (err error) {
		data, err = Lookup(ctx, proxy, path).GoMod(ctx, rev)
		return err
	})
	return data, err
}

// GoModFile is like GoMod but returns the name of the file containing
// the cached information.
func GoModFile(ctx context.Context, path, version string) (string, error) {
	if !gover.ModIsValid(path, version) {
		return "", fmt.Errorf("invalid version %q", version)
	}
	if _, err := GoMod(ctx, path, version); err != nil {
		return "", err
	}
	// GoMod should have populated the disk cache for us.
	file, err := CachePath(ctx, module.Version{Path: path, Version: version}, "mod")
	if err != nil {
		return "", err
	}
	return file, nil
}

// GoModSum returns the go.sum entry for the module version's go.mod file.
// (That is, it returns the entry listed in go.sum as "path version/go.mod".)
func GoModSum(ctx context.Context, path, version string) (string, error) {
	if !gover.ModIsValid(path, version) {
		return "", fmt.Errorf("invalid version %q", version)
	}
	data, err := GoMod(ctx, path, version)
	if err != nil {
		return "", err
	}
	sum, err := goModSum(data)
	if err != nil {
		return "", err
	}
	return sum, nil
}

var errNotCached = fmt.Errorf("not in cache")

// readDiskStat reads a cached stat result from disk,
// returning the name of the cache file and the result.
// If the read fails, the caller can use
// writeDiskStat(file, info) to write a new cache entry.
func readDiskStat(ctx context.Context, path, rev string) (file string, info *RevInfo, err error) {
	if gover.IsToolchain(path) {
		return "", nil, errNotCached
	}
	file, data, err := readDiskCache(ctx, path, rev, "info")
	if err != nil {
		// If the cache already contains a pseudo-version with the given hash, we
		// would previously return that pseudo-version without checking upstream.
		// However, that produced an unfortunate side-effect: if the author added a
		// tag to the repository, 'go get' would not pick up the effect of that new
		// tag on the existing commits, and 'go' commands that referred to those
		// commits would use the previous name instead of the new one.
		//
		// That's especially problematic if the original pseudo-version starts with
		// v0.0.0-, as was the case for all pseudo-versions during vgo development,
		// since a v0.0.0- pseudo-version has lower precedence than pretty much any
		// tagged version.
		//
		// In practice, we're only looking up by hash during initial conversion of a
		// legacy config and during an explicit 'go get', and a little extra latency
		// for those operations seems worth the benefit of picking up more accurate
		// versions.
		//
		// Fall back to this resolution scheme only if the GOPROXY setting prohibits
		// us from resolving upstream tags.
		if cfg.GOPROXY == "off" {
			if file, info, err := readDiskStatByHash(ctx, path, rev); err == nil {
				return file, info, nil
			}
		}
		return file, nil, err
	}
	info = new(RevInfo)
	if err := json.Unmarshal(data, info); err != nil {
		return file, nil, errNotCached
	}
	// The disk might have stale .info files that have Name and Short fields set.
	// We want to canonicalize to .info files with those fields omitted.
	// Remarshal and update the cache file if needed.
	data2, err := json.Marshal(info)
	if err == nil && !bytes.Equal(data2, data) {
		writeDiskCache(ctx, file, data)
	}
	return file, info, nil
}

// readDiskStatByHash is a fallback for readDiskStat for the case
// where rev is a commit hash instead of a proper semantic version.
// In that case, we look for a cached pseudo-version that matches
// the commit hash. If we find one, we use it.
// This matters most for converting legacy package management
// configs, when we are often looking up commits by full hash.
// Without this check we'd be doing network I/O to the remote repo
// just to find out about a commit we already know about
// (and have cached under its pseudo-version).
func readDiskStatByHash(ctx context.Context, path, rev string) (file string, info *RevInfo, err error) {
	if gover.IsToolchain(path) {
		return "", nil, errNotCached
	}
	if cfg.GOMODCACHE == "" {
		// Do not download to current directory.
		return "", nil, errNotCached
	}

	if !codehost.AllHex(rev) || len(rev) < 12 {
		return "", nil, errNotCached
	}
	rev = rev[:12]
	cdir, err := cacheDir(ctx, path)
	if err != nil {
		return "", nil, errNotCached
	}
	dir, err := os.Open(cdir)
	if err != nil {
		return "", nil, errNotCached
	}
	names, err := dir.Readdirnames(-1)
	dir.Close()
	if err != nil {
		return "", nil, errNotCached
	}

	// A given commit hash may map to more than one pseudo-version,
	// depending on which tags are present on the repository.
	// Take the highest such version.
	var maxVersion string
	suffix := "-" + rev + ".info"
	err = errNotCached
	for _, name := range names {
		if strings.HasSuffix(name, suffix) {
			v := strings.TrimSuffix(name, ".info")
			if module.IsPseudoVersion(v) && semver.Compare(v, maxVersion) > 0 {
				maxVersion = v
				file, info, err = readDiskStat(ctx, path, strings.TrimSuffix(name, ".info"))
			}
		}
	}
	return file, info, err
}

// oldVgoPrefix is the prefix in the old auto-generated cached go.mod files.
// We stopped trying to auto-generate the go.mod files. Now we use a trivial
// go.mod with only a module line, and we've dropped the version prefix
// entirely. If we see a version prefix, that means we're looking at an old copy
// and should ignore it.
var oldVgoPrefix = []byte("//vgo 0.0.")

// readDiskGoMod reads a cached go.mod file from disk,
// returning the name of the cache file and the result.
// If the read fails, the caller can use
// writeDiskGoMod(file, data) to write a new cache entry.
func readDiskGoMod(ctx context.Context, path, rev string) (file string, data []byte, err error) {
	if gover.IsToolchain(path) {
		return "", nil, errNotCached
	}
	file, data, err = readDiskCache(ctx, path, rev, "mod")

	// If the file has an old auto-conversion prefix, pretend it's not there.
	if bytes.HasPrefix(data, oldVgoPrefix) {
		err = errNotCached
		data = nil
	}

	if err == nil {
		if err := checkGoMod(path, rev, data); err != nil {
			return "", nil, err
		}
	}

	return file, data, err
}

// readDiskCache is the generic "read from a cache file" implementation.
// It takes the revision and an identifying suffix for the kind of data being cached.
// It returns the name of the cache file and the content of the file.
// If the read fails, the caller can use
// writeDiskCache(file, data) to write a new cache entry.
func readDiskCache(ctx context.Context, path, rev, suffix string) (file string, data []byte, err error) {
	if gover.IsToolchain(path) {
		return "", nil, errNotCached
	}
	file, err = CachePath(ctx, module.Version{Path: path, Version: rev}, suffix)
	if err != nil {
		return "", nil, errNotCached
	}
	data, err = robustio.ReadFile(file)
	if err != nil {
		return file, nil, errNotCached
	}
	return file, data, nil
}

// writeDiskStat writes a stat result cache entry.
// The file name must have been returned by a previous call to readDiskStat.
func writeDiskStat(ctx context.Context, file string, info *RevInfo) error {
	if file == "" {
		return nil
	}

	if info.Origin != nil {
		// Clean the origin information, which might have too many
		// validation criteria, for example if we are saving the result of
		// m@master as m@pseudo-version.
		clean := *info
		info = &clean
		o := *info.Origin
		info.Origin = &o

		// Tags never matter if you are starting with a semver version,
		// as we would be when finding this cache entry.
		o.TagSum = ""
		o.TagPrefix = ""
		// Ref doesn't matter if you have a pseudoversion.
		if module.IsPseudoVersion(info.Version) {
			o.Ref = ""
		}
	}

	js, err := json.Marshal(info)
	if err != nil {
		return err
	}
	return writeDiskCache(ctx, file, js)
}

// writeDiskGoMod writes a go.mod cache entry.
// The file name must have been returned by a previous call to readDiskGoMod.
func writeDiskGoMod(ctx context.Context, file string, text []byte) error {
	return writeDiskCache(ctx, file, text)
}

// writeDiskCache is the generic "write to a cache file" implementation.
// The file must have been returned by a previous call to readDiskCache.
func writeDiskCache(ctx context.Context, file string, data []byte) error {
	if file == "" {
		return nil
	}
	// Make sure directory for file exists.
	if err := os.MkdirAll(filepath.Dir(file), 0777); err != nil {
		return err
	}

	// Write the file to a temporary location, and then rename it to its final
	// path to reduce the likelihood of a corrupt file existing at that final path.
	f, err := tempFile(ctx, filepath.Dir(file), filepath.Base(file), 0666)
	if err != nil {
		return err
	}
	defer func() {
		// Only call os.Remove on f.Name() if we failed to rename it: otherwise,
		// some other process may have created a new file with the same name after
		// the rename completed.
		if err != nil {
			f.Close()
			os.Remove(f.Name())
		}
	}()

	if _, err := f.Write(data); err != nil {
		return err
	}
	if err := f.Close(); err != nil {
		return err
	}
	if err := robustio.Rename(f.Name(), file); err != nil {
		return err
	}

	if strings.HasSuffix(file, ".mod") {
		rewriteVersionList(ctx, filepath.Dir(file))
	}
	return nil
}

// tempFile creates a new temporary file with given permission bits.
func tempFile(ctx context.Context, dir, prefix string, perm fs.FileMode) (f *os.File, err error) {
	for i := 0; i < 10000; i++ {
		name := filepath.Join(dir, prefix+strconv.Itoa(rand.Intn(1000000000))+".tmp")
		f, err = os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, perm)
		if os.IsExist(err) {
			if ctx.Err() != nil {
				return nil, ctx.Err()
			}
			continue
		}
		break
	}
	return
}

// rewriteVersionList rewrites the version list in dir
// after a new *.mod file has been written.
func rewriteVersionList(ctx context.Context, dir string) (err error) {
	if filepath.Base(dir) != "@v" {
		base.Fatalf("go: internal error: misuse of rewriteVersionList")
	}

	listFile := filepath.Join(dir, "list")

	// Lock listfile when writing to it to try to avoid corruption to the file.
	// Under rare circumstances, for instance, if the system loses power in the
	// middle of a write it is possible for corrupt data to be written. This is
	// not a problem for the go command itself, but may be an issue if the
	// cache is being served by a GOPROXY HTTP server. This will be corrected
	// the next time a new version of the module is fetched and the file is rewritten.
	// TODO(matloob): golang.org/issue/43313 covers adding a go mod verify
	// command that removes module versions that fail checksums. It should also
	// remove list files that are detected to be corrupt.
	f, err := lockedfile.Edit(listFile)
	if err != nil {
		return err
	}
	defer func() {
		if cerr := f.Close(); cerr != nil && err == nil {
			err = cerr
		}
	}()
	infos, err := os.ReadDir(dir)
	if err != nil {
		return err
	}
	var list []string
	for _, info := range infos {
		// We look for *.mod files on the theory that if we can't supply
		// the .mod file then there's no point in listing that version,
		// since it's unusable. (We can have *.info without *.mod.)
		// We don't require *.zip files on the theory that for code only
		// involved in module graph construction, many *.zip files
		// will never be requested.
		name := info.Name()
		if v, found := strings.CutSuffix(name, ".mod"); found {
			if v != "" && module.CanonicalVersion(v) == v {
				list = append(list, v)
			}
		}
	}
	semver.Sort(list)

	var buf bytes.Buffer
	for _, v := range list {
		buf.WriteString(v)
		buf.WriteString("\n")
	}
	if fi, err := f.Stat(); err == nil && int(fi.Size()) == buf.Len() {
		old := make([]byte, buf.Len()+1)
		if n, err := f.ReadAt(old, 0); err == io.EOF && n == buf.Len() && bytes.Equal(buf.Bytes(), old) {
			return nil // No edit needed.
		}
	}
	// Remove existing contents, so that when we truncate to the actual size it will zero-fill,
	// and we will be able to detect (some) incomplete writes as files containing trailing NUL bytes.
	if err := f.Truncate(0); err != nil {
		return err
	}
	// Reserve the final size and zero-fill.
	if err := f.Truncate(int64(buf.Len())); err != nil {
		return err
	}
	// Write the actual contents. If this fails partway through,
	// the remainder of the file should remain as zeroes.
	if _, err := f.Write(buf.Bytes()); err != nil {
		f.Truncate(0)
		return err
	}

	return nil
}

var (
	statCacheOnce sync.Once
	statCacheErr  error
)

// checkCacheDir checks if the directory specified by GOMODCACHE exists. An
// error is returned if it does not.
func checkCacheDir(ctx context.Context) error {
	if cfg.GOMODCACHE == "" {
		// modload.Init exits if GOPATH[0] is empty, and cfg.GOMODCACHE
		// is set to GOPATH[0]/pkg/mod if GOMODCACHE is empty, so this should never happen.
		return fmt.Errorf("module cache not found: neither GOMODCACHE nor GOPATH is set")
	}
	if !filepath.IsAbs(cfg.GOMODCACHE) {
		return fmt.Errorf("GOMODCACHE entry is relative; must be absolute path: %q.\n", cfg.GOMODCACHE)
	}

	// os.Stat is slow on Windows, so we only call it once to prevent unnecessary
	// I/O every time this function is called.
	statCacheOnce.Do(func() {
		fi, err := os.Stat(cfg.GOMODCACHE)
		if err != nil {
			if !os.IsNotExist(err) {
				statCacheErr = fmt.Errorf("could not create module cache: %w", err)
				return
			}
			if err := os.MkdirAll(cfg.GOMODCACHE, 0777); err != nil {
				statCacheErr = fmt.Errorf("could not create module cache: %w", err)
				return
			}
			return
		}
		if !fi.IsDir() {
			statCacheErr = fmt.Errorf("could not create module cache: %q is not a directory", cfg.GOMODCACHE)
			return
		}
	})
	return statCacheErr
}
