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

package toolchain

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"cmd/go/internal/base"
	"cmd/go/internal/cfg"
	"cmd/go/internal/gover"
	"cmd/go/internal/modfetch"
	"cmd/go/internal/modload"
	"cmd/internal/telemetry/counter"
)

// A Switcher collects errors to be reported and then decides
// between reporting the errors or switching to a new toolchain
// to resolve them.
//
// The client calls [Switcher.Error] repeatedly with errors encountered
// and then calls [Switcher.Switch]. If the errors included any
// *gover.TooNewErrors (potentially wrapped) and switching is
// permitted by GOTOOLCHAIN, Switch switches to a new toolchain.
// Otherwise Switch prints all the errors using base.Error.
//
// See https://go.dev/doc/toolchain#switch.
type Switcher struct {
	TooNew      *gover.TooNewError // max go requirement observed
	Errors      []error            // errors collected so far
	loaderstate *modload.State     // temporarily here while we eliminate global module loader state
}

func NewSwitcher(s *modload.State) *Switcher {
	sw := new(Switcher)
	sw.loaderstate = s
	return sw
}

// Error reports the error to the Switcher,
// which saves it for processing during Switch.
func (s *Switcher) Error(err error) {
	s.Errors = append(s.Errors, err)
	s.addTooNew(err)
}

// addTooNew adds any TooNew errors that can be found in err.
func (s *Switcher) addTooNew(err error) {
	switch err := err.(type) {
	case interface{ Unwrap() []error }:
		for _, e := range err.Unwrap() {
			s.addTooNew(e)
		}

	case interface{ Unwrap() error }:
		s.addTooNew(err.Unwrap())

	case *gover.TooNewError:
		if s.TooNew == nil ||
			gover.Compare(err.GoVersion, s.TooNew.GoVersion) > 0 ||
			gover.Compare(err.GoVersion, s.TooNew.GoVersion) == 0 && err.What < s.TooNew.What {
			s.TooNew = err
		}
	}
}

// NeedSwitch reports whether Switch would attempt to switch toolchains.
func (s *Switcher) NeedSwitch() bool {
	return s.TooNew != nil && (HasAuto() || HasPath())
}

// Switch decides whether to switch to a newer toolchain
// to resolve any of the saved errors.
// It switches if toolchain switches are permitted and there is at least one TooNewError.
//
// If Switch decides not to switch toolchains, it prints the errors using base.Error and returns.
//
// If Switch decides to switch toolchains but cannot identify a toolchain to use.
// it prints the errors along with one more about not being able to find the toolchain
// and returns.
//
// Otherwise, Switch prints an informational message giving a reason for the
// switch and the toolchain being invoked and then switches toolchains.
// This operation never returns.
func (s *Switcher) Switch(ctx context.Context) {
	if !s.NeedSwitch() {
		for _, err := range s.Errors {
			base.Error(err)
		}
		return
	}

	// Switch to newer Go toolchain if necessary and possible.
	tv, err := NewerToolchain(ctx, s.loaderstate.Fetcher(), s.TooNew.GoVersion)
	if err != nil {
		for _, err := range s.Errors {
			base.Error(err)
		}
		base.Error(fmt.Errorf("switching to go >= %v: %w", s.TooNew.GoVersion, err))
		return
	}

	fmt.Fprintf(os.Stderr, "go: %v requires go >= %v; switching to %v\n", s.TooNew.What, s.TooNew.GoVersion, tv)
	counterSwitchExec.Inc()
	Exec(s.loaderstate, tv)
	panic("unreachable")
}

var counterSwitchExec = counter.New("go/toolchain/switch-exec")

// SwitchOrFatal attempts a toolchain switch based on the information in err
// and otherwise falls back to base.Fatal(err).
func SwitchOrFatal(loaderstate *modload.State, ctx context.Context, err error) {
	s := NewSwitcher(loaderstate)
	s.Error(err)
	s.Switch(ctx)
	base.Exit()
}

// NewerToolchain returns the name of the toolchain to use when we need
// to switch to a newer toolchain that must support at least the given Go version.
// See https://go.dev/doc/toolchain#switch.
//
// If the latest major release is 1.N.0, we use the latest patch release of 1.(N-1) if that's >= version.
// Otherwise we use the latest 1.N if that's allowed.
// Otherwise we use the latest release.
func NewerToolchain(ctx context.Context, f *modfetch.Fetcher, version string) (string, error) {
	fetch := func(ctx context.Context) ([]string, error) {
		return autoToolchains(ctx, f)
	}

	if !HasAuto() {
		fetch = pathToolchains
	}
	list, err := fetch(ctx)
	if err != nil {
		return "", err
	}
	return newerToolchain(version, list)
}

// autoToolchains returns the list of toolchain versions available to GOTOOLCHAIN=auto or =min+auto mode.
func autoToolchains(ctx context.Context, f *modfetch.Fetcher) ([]string, error) {
	var versions *modfetch.Versions
	err := modfetch.TryProxies(func(proxy string) error {
		v, err := f.Lookup(ctx, proxy, "go").Versions(ctx, "")
		if err != nil {
			return err
		}
		versions = v
		return nil
	})
	if err != nil {
		return nil, err
	}
	return versions.List, nil
}

// pathToolchains returns the list of toolchain versions available to GOTOOLCHAIN=path or =min+path mode.
func pathToolchains(ctx context.Context) ([]string, error) {
	have := make(map[string]bool)
	var list []string
	for _, dir := range pathDirs() {
		if dir == "" || !filepath.IsAbs(dir) {
			// Refuse to use local directories in $PATH (hard-coding exec.ErrDot).
			continue
		}
		entries, err := os.ReadDir(dir)
		if err != nil {
			continue
		}
		for _, de := range entries {
			if de.IsDir() || !strings.HasPrefix(de.Name(), "go1.") {
				continue
			}
			info, err := de.Info()
			if err != nil {
				continue
			}
			v, ok := pathVersion(dir, de, info)
			if !ok || !strings.HasPrefix(v, "1.") || have[v] {
				continue
			}
			have[v] = true
			list = append(list, v)
		}
	}
	sort.Slice(list, func(i, j int) bool {
		return gover.Compare(list[i], list[j]) < 0
	})
	return list, nil
}

// newerToolchain implements NewerToolchain where the list of choices is known.
// It is separated out for easier testing of this logic.
func newerToolchain(need string, list []string) (string, error) {
	// Consider each release in the list, from newest to oldest,
	// considering only entries >= need and then only entries
	// that are the latest in their language family
	// (the latest 1.40, the latest 1.39, and so on).
	// We prefer the latest patch release before the most recent release family,
	// so if the latest release is 1.40.1 we'll take the latest 1.39.X.
	// Failing that, we prefer the latest patch release before the most recent
	// prerelease family, so if the latest release is 1.40rc1 is out but 1.39 is okay,
	// we'll still take 1.39.X.
	// Failing that we'll take the latest release.
	latest := ""
	for i := len(list) - 1; i >= 0; i-- {
		v := list[i]
		if gover.Compare(v, need) < 0 {
			break
		}
		if gover.Lang(latest) == gover.Lang(v) {
			continue
		}
		newer := latest
		latest = v
		if newer != "" && !gover.IsPrerelease(newer) {
			// latest is the last patch release of Go 1.X, and we saw a non-prerelease of Go 1.(X+1),
			// so latest is the one we want.
			break
		}
	}
	if latest == "" {
		return "", fmt.Errorf("no releases found for go >= %v", need)
	}
	return "go" + latest, nil
}

// HasAuto reports whether the GOTOOLCHAIN setting allows "auto" upgrades.
func HasAuto() bool {
	env := cfg.Getenv("GOTOOLCHAIN")
	return env == "auto" || strings.HasSuffix(env, "+auto")
}

// HasPath reports whether the GOTOOLCHAIN setting allows "path" upgrades.
func HasPath() bool {
	env := cfg.Getenv("GOTOOLCHAIN")
	return env == "path" || strings.HasSuffix(env, "+path")
}
