/* Copyright 2022 The Bazel Authors. All rights reserved.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

   http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package golang

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"go/build"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"sort"
	"strings"

	"github.com/bazelbuild/bazel-gazelle/label"
	"github.com/bazelbuild/bazel-gazelle/rule"
)

// goListModules invokes "go list" in a directory containing a go.mod file.
var goListModules = func(dir string) ([]byte, error) {
	return runGoCommandForOutput(dir, "list", "-mod=readonly", "-e", "-m", "-json", "all")
}

// goModDownload invokes "go mod download" in a directory containing a
// go.mod file.
var goModDownload = func(dir string, args []string) ([]byte, error) {
	dlArgs := []string{"mod", "download", "-json"}
	dlArgs = append(dlArgs, args...)
	return runGoCommandForOutput(dir, dlArgs...)
}

// modulesFromList is an abstraction to preserve the output of `go list`.
// The output schema is documented at https://go.dev/ref/mod#go-list-m
type moduleFromList struct {
	Path, Version, Sum string
	Main               bool
	Replace            *struct {
		Path, Version string
	}
	Error *moduleError
}

type moduleError struct {
	Err string
}

// moduleFromDownload is an abstraction to preserve the output of `go mod download`.
// The output schema is documented at https://go.dev/ref/mod#go-mod-download
type moduleFromDownload struct {
	Path, Version, Sum string
	Main               bool
	Replace            *struct {
		Path, Version string
	}
	Error string
}

// extractModules lists all modules except for the main module,
// including implicit indirect dependencies.
func extractModules(data []byte) (map[string]*moduleFromList, error) {
	// path@version can be used as a unique identifier for looking up sums
	pathToModule := map[string]*moduleFromList{}
	dec := json.NewDecoder(bytes.NewReader(data))
	for dec.More() {
		mod := new(moduleFromList)
		if err := dec.Decode(mod); err != nil {
			return nil, err
		}
		if mod.Error != nil {
			return nil, fmt.Errorf("error listing %s: %s", mod.Path, mod.Error.Err)
		}
		if mod.Main {
			continue
		}
		if mod.Replace != nil {
			if filepath.IsAbs(mod.Replace.Path) || build.IsLocalImport(mod.Replace.Path) {
				log.Printf("go_repository does not support file path replacements for %s -> %s", mod.Path,
					mod.Replace.Path)
				continue
			}
			pathToModule[mod.Replace.Path+"@"+mod.Replace.Version] = mod
		} else {
			pathToModule[mod.Path+"@"+mod.Version] = mod
		}
	}
	return pathToModule, nil
}

// fillMissingSums runs `go mod download` to get missing sums.
// This must be done in a temporary directory because 'go mod download'
// may modify go.mod and go.sum. It does not support -mod=readonly.
func fillMissingSums(pathToModule map[string]*moduleFromList) (map[string]*moduleFromList, error) {
	var missingSumArgs []string
	for pathVer, mod := range pathToModule {
		if mod.Sum == "" {
			missingSumArgs = append(missingSumArgs, pathVer)
		}
	}

	if len(missingSumArgs) > 0 {
		tmpDir, err := os.MkdirTemp("", "")
		if err != nil {
			return nil, err
		}
		defer os.RemoveAll(tmpDir)
		data, err := goModDownload(tmpDir, missingSumArgs)
		dec := json.NewDecoder(bytes.NewReader(data))
		if err != nil {
			// Best-effort try to adorn specific error details from the JSON output.
			for dec.More() {
				var dl moduleFromDownload
				if decodeErr := dec.Decode(&dl); decodeErr != nil {
					// If we couldn't parse a possible error description, just return the raw error.
					err = fmt.Errorf("%w\nError parsing module for more error information: %v", err, decodeErr)
					break
				}
				if dl.Error != "" {
					err = fmt.Errorf("%w\nError downloading %v: %v", err, dl.Path, dl.Error)
				}
			}
			err = fmt.Errorf("error from go mod download: %w", err)

			return nil, err
		}
		for dec.More() {
			var dl moduleFromDownload
			if err := dec.Decode(&dl); err != nil {
				return nil, err
			}
			if mod, ok := pathToModule[dl.Path+"@"+dl.Version]; ok {
				mod.Sum = dl.Sum
			}
		}
	}

	return pathToModule, nil
}

// toRepositoryRules transforms the input map into repository rules.
func toRepositoryRules(pathToModule map[string]*moduleFromList) []*rule.Rule {
	gen := make([]*rule.Rule, 0, len(pathToModule))
	for pathVer, mod := range pathToModule {
		if mod.Sum == "" {
			log.Printf("could not determine sum for module %s", pathVer)
			continue
		}
		r := rule.NewRule("go_repository", label.ImportPathToBazelRepoName(mod.Path))
		r.SetAttr("importpath", mod.Path)
		r.SetAttr("sum", mod.Sum)
		if mod.Replace == nil {
			r.SetAttr("version", mod.Version)
		} else {
			r.SetAttr("replace", mod.Replace.Path)
			r.SetAttr("version", mod.Replace.Version)
		}
		gen = append(gen, r)
	}
	sort.Slice(gen, func(i, j int) bool {
		return gen[i].Name() < gen[j].Name()
	})

	return gen
}

// processGoListError attempts a best-effort try to adorn specific error details from the JSON output of `go list`.
func processGoListError(err error, data []byte) error {
	dec := json.NewDecoder(bytes.NewReader(data))
	for dec.More() {
		var dl moduleFromList
		if decodeErr := dec.Decode(&dl); decodeErr != nil {
			// If we couldn't parse a possible error description, just return the raw error.
			err = fmt.Errorf("%w\nError parsing module for more error information: %v", err, decodeErr)
			break
		}
		if dl.Error != nil {
			err = fmt.Errorf("%w\nError listing %v: %v", err, dl.Path, dl.Error.Err)
		}
	}
	err = fmt.Errorf("error from go list: %w", err)

	return err
}

// findGoTool attempts to locate the go executable. If GOROOT is set, we'll
// prefer the one in there; otherwise, we'll rely on PATH. If the wrapper
// script generated by the gazelle rule is invoked by Bazel, it will set
// GOROOT to the configured SDK. We don't want to rely on the host SDK in
// that situation.
func findGoTool() string {
	path := "go" // rely on PATH by default
	if goroot, ok := os.LookupEnv("GOROOT"); ok {
		path = filepath.Join(goroot, "bin", "go")
	}
	if runtime.GOOS == "windows" {
		path += ".exe"
	}
	return path
}

func runGoCommandForOutput(dir string, args ...string) ([]byte, error) {
	goTool := findGoTool()
	env := os.Environ()
	env = append(env, "GO111MODULE=on")
	if os.Getenv("GOCACHE") == "" && os.Getenv("HOME") == "" {
		gocache, err := os.MkdirTemp("", "")
		if err != nil {
			return nil, err
		}
		env = append(env, "GOCACHE="+gocache)
		defer os.RemoveAll(gocache)
	}
	if os.Getenv("GOPATH") == "" && os.Getenv("HOME") == "" {
		gopath, err := os.MkdirTemp("", "")
		if err != nil {
			return nil, err
		}
		env = append(env, "GOPATH="+gopath)
		defer os.RemoveAll(gopath)
	}
	cmd := exec.Command(goTool, args...)
	stderr := &bytes.Buffer{}
	cmd.Stderr = stderr
	cmd.Dir = dir
	cmd.Env = env
	out, err := cmd.Output()
	if err != nil {
		var errStr string
		var xerr *exec.ExitError
		if errors.As(err, &xerr) {
			errStr = strings.TrimSpace(stderr.String())
		} else {
			errStr = err.Error()
		}
		return out, fmt.Errorf("running '%s %s': %s", cmd.Path, strings.Join(cmd.Args, " "), errStr)
	}
	return out, nil
}
