// Copyright 2022 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 vcweb

import (
	"bufio"
	"bytes"
	"cmd/internal/script"
	"context"
	"errors"
	"fmt"
	"internal/txtar"
	"io"
	"log"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime"
	"strconv"
	"strings"
	"time"

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

// newScriptEngine returns a script engine augmented with commands for
// reproducing version-control repositories by replaying commits.
func newScriptEngine() *script.Engine {
	conds := script.DefaultConds()

	add := func(name string, cond script.Cond) {
		if _, ok := conds[name]; ok {
			panic(fmt.Sprintf("condition %q is already registered", name))
		}
		conds[name] = cond
	}
	lazyBool := func(summary string, f func() bool) script.Cond {
		return script.OnceCondition(summary, func() (bool, error) { return f(), nil })
	}
	add("bzr", lazyBool("the 'bzr' executable exists and provides the standard CLI", hasWorkingBzr))
	add("git-sha256", script.OnceCondition("the local 'git' version is recent enough to support sha256 object/commit hashes", gitSupportsSHA256))

	interrupt := func(cmd *exec.Cmd) error { return cmd.Process.Signal(os.Interrupt) }
	gracePeriod := 30 * time.Second // arbitrary

	cmds := script.DefaultCmds()
	cmds["at"] = scriptAt()
	cmds["bzr"] = script.Program("bzr", interrupt, gracePeriod)
	cmds["fossil"] = script.Program("fossil", interrupt, gracePeriod)
	cmds["git"] = script.Program("git", interrupt, gracePeriod)
	cmds["hg"] = script.Program("hg", interrupt, gracePeriod)
	cmds["handle"] = scriptHandle()
	cmds["modzip"] = scriptModzip()
	cmds["skip"] = scriptSkip()
	cmds["svnadmin"] = script.Program("svnadmin", interrupt, gracePeriod)
	cmds["svn"] = script.Program("svn", interrupt, gracePeriod)
	cmds["unquote"] = scriptUnquote()

	return &script.Engine{
		Cmds:  cmds,
		Conds: conds,
	}
}

// loadScript interprets the given script content using the vcweb script engine.
// loadScript always returns either a non-nil handler or a non-nil error.
//
// The script content must be a txtar archive with a comment containing a script
// with exactly one "handle" command and zero or more VCS commands to prepare
// the repository to be served.
func (s *Server) loadScript(ctx context.Context, logger *log.Logger, scriptPath string, scriptContent []byte, workDir string) (http.Handler, error) {
	ar := txtar.Parse(scriptContent)

	if err := os.MkdirAll(workDir, 0755); err != nil {
		return nil, err
	}

	st, err := s.newState(ctx, workDir)
	if err != nil {
		return nil, err
	}
	if err := st.ExtractFiles(ar); err != nil {
		return nil, err
	}

	scriptName := filepath.Base(scriptPath)
	scriptLog := new(strings.Builder)
	err = s.engine.Execute(st, scriptName, bufio.NewReader(bytes.NewReader(ar.Comment)), scriptLog)
	closeErr := st.CloseAndWait(scriptLog)
	logger.Printf("%s:", scriptName)
	io.WriteString(logger.Writer(), scriptLog.String())
	io.WriteString(logger.Writer(), "\n")
	if err != nil {
		return nil, err
	}
	if closeErr != nil {
		return nil, err
	}

	sc, err := getScriptCtx(st)
	if err != nil {
		return nil, err
	}
	if sc.handler == nil {
		return nil, errors.New("script completed without setting handler")
	}
	return sc.handler, nil
}

// newState returns a new script.State for executing scripts in workDir.
func (s *Server) newState(ctx context.Context, workDir string) (*script.State, error) {
	ctx = &scriptCtx{
		Context: ctx,
		server:  s,
	}

	st, err := script.NewState(ctx, workDir, s.env)
	if err != nil {
		return nil, err
	}
	return st, nil
}

// scriptEnviron returns a new environment that attempts to provide predictable
// behavior for the supported version-control tools.
func scriptEnviron(homeDir string) []string {
	env := []string{
		"USER=gopher",
		homeEnvName() + "=" + homeDir,
		"GIT_CONFIG_NOSYSTEM=1",
		"HGRCPATH=" + filepath.Join(homeDir, ".hgrc"),
		"HGENCODING=utf-8",
	}
	// Preserve additional environment variables that may be needed by VCS tools.
	for _, k := range []string{
		pathEnvName(),
		tempEnvName(),
		"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
		"ComSpec",           // must be preserved on Windows to be able to run Batch files; golang.org/issue/56555
		"DYLD_LIBRARY_PATH", // must be preserved on macOS systems to find shared libraries
		"LD_LIBRARY_PATH",   // must be preserved on Unix systems to find shared libraries
		"LIBRARY_PATH",      // allow override of non-standard static library paths
		"PYTHONPATH",        // may be needed by hg to find imported modules
	} {
		if v, ok := os.LookupEnv(k); ok {
			env = append(env, k+"="+v)
		}
	}

	if os.Getenv("GO_BUILDER_NAME") != "" || 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")
	}

	return env
}

// homeEnvName returns the environment variable used by os.UserHomeDir
// to locate the user's home directory.
func homeEnvName() string {
	switch runtime.GOOS {
	case "windows":
		return "USERPROFILE"
	case "plan9":
		return "home"
	default:
		return "HOME"
	}
}

// tempEnvName returns the environment variable used by os.TempDir
// to locate the default directory for temporary files.
func tempEnvName() string {
	switch runtime.GOOS {
	case "windows":
		return "TMP"
	case "plan9":
		return "TMPDIR" // actually plan 9 doesn't have one at all but this is fine
	default:
		return "TMPDIR"
	}
}

// pathEnvName returns the environment variable used by exec.LookPath to
// identify directories to search for executables.
func pathEnvName() string {
	switch runtime.GOOS {
	case "plan9":
		return "path"
	default:
		return "PATH"
	}
}

// A scriptCtx is a context.Context that stores additional state for script
// commands.
type scriptCtx struct {
	context.Context
	server      *Server
	commitTime  time.Time
	handlerName string
	handler     http.Handler
}

// scriptCtxKey is the key associating the *scriptCtx in a script's Context..
type scriptCtxKey struct{}

func (sc *scriptCtx) Value(key any) any {
	if key == (scriptCtxKey{}) {
		return sc
	}
	return sc.Context.Value(key)
}

func getScriptCtx(st *script.State) (*scriptCtx, error) {
	sc, ok := st.Context().Value(scriptCtxKey{}).(*scriptCtx)
	if !ok {
		return nil, errors.New("scriptCtx not found in State.Context")
	}
	return sc, nil
}

func scriptAt() script.Cmd {
	return script.Command(
		script.CmdUsage{
			Summary: "set the current commit time for all version control systems",
			Args:    "time",
			Detail: []string{
				"The argument must be an absolute timestamp in RFC3339 format.",
			},
		},
		func(st *script.State, args ...string) (script.WaitFunc, error) {
			if len(args) != 1 {
				return nil, script.ErrUsage
			}

			sc, err := getScriptCtx(st)
			if err != nil {
				return nil, err
			}

			sc.commitTime, err = time.ParseInLocation(time.RFC3339, args[0], time.UTC)
			if err == nil {
				st.Setenv("GIT_COMMITTER_DATE", args[0])
				st.Setenv("GIT_AUTHOR_DATE", args[0])
			}
			return nil, err
		})
}

func scriptHandle() script.Cmd {
	return script.Command(
		script.CmdUsage{
			Summary: "set the HTTP handler that will serve the script's output",
			Args:    "handler [dir]",
			Detail: []string{
				"The handler will be passed the script's current working directory and environment as arguments.",
				"Valid handlers include 'dir' (for general http.Dir serving), 'bzr', 'fossil', 'git', and 'hg'",
			},
		},
		func(st *script.State, args ...string) (script.WaitFunc, error) {
			if len(args) == 0 || len(args) > 2 {
				return nil, script.ErrUsage
			}

			sc, err := getScriptCtx(st)
			if err != nil {
				return nil, err
			}

			if sc.handler != nil {
				return nil, fmt.Errorf("server handler already set to %s", sc.handlerName)
			}

			name := args[0]
			h, ok := sc.server.vcsHandlers[name]
			if !ok {
				return nil, fmt.Errorf("unrecognized VCS %q", name)
			}
			sc.handlerName = name
			if !h.Available() {
				return nil, ServerNotInstalledError{name}
			}

			dir := st.Getwd()
			if len(args) >= 2 {
				dir = st.Path(args[1])
			}
			sc.handler, err = h.Handler(dir, st.Environ(), sc.server.logger)
			return nil, err
		})
}

func scriptModzip() script.Cmd {
	return script.Command(
		script.CmdUsage{
			Summary: "create a Go module zip file from a directory",
			Args:    "zipfile path@version dir",
		},
		func(st *script.State, args ...string) (wait script.WaitFunc, err error) {
			if len(args) != 3 {
				return nil, script.ErrUsage
			}
			zipPath := st.Path(args[0])
			mPath, version, ok := strings.Cut(args[1], "@")
			if !ok {
				return nil, script.ErrUsage
			}
			dir := st.Path(args[2])

			if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil {
				return nil, err
			}
			f, err := os.Create(zipPath)
			if err != nil {
				return nil, err
			}
			defer func() {
				if closeErr := f.Close(); err == nil {
					err = closeErr
				}
			}()

			return nil, zip.CreateFromDir(f, module.Version{Path: mPath, Version: version}, dir)
		})
}

func scriptSkip() script.Cmd {
	return script.Command(
		script.CmdUsage{
			Summary: "skip the current test",
			Args:    "[msg]",
		},
		func(_ *script.State, args ...string) (script.WaitFunc, error) {
			if len(args) > 1 {
				return nil, script.ErrUsage
			}
			if len(args) == 0 {
				return nil, SkipError{""}
			}
			return nil, SkipError{args[0]}
		})
}

type SkipError struct {
	Msg string
}

func (s SkipError) Error() string {
	if s.Msg == "" {
		return "skip"
	}
	return s.Msg
}

func scriptUnquote() script.Cmd {
	return script.Command(
		script.CmdUsage{
			Summary: "unquote the argument as a Go string",
			Args:    "string",
		},
		func(st *script.State, args ...string) (script.WaitFunc, error) {
			if len(args) != 1 {
				return nil, script.ErrUsage
			}

			s, err := strconv.Unquote(`"` + args[0] + `"`)
			if err != nil {
				return nil, err
			}

			wait := func(*script.State) (stdout, stderr string, err error) {
				return s, "", nil
			}
			return wait, nil
		})
}

func hasWorkingBzr() bool {
	bzr, err := exec.LookPath("bzr")
	if err != nil {
		return false
	}
	// Check that 'bzr help' exits with code 0.
	// See go.dev/issue/71504 for an example where 'bzr' exists in PATH but doesn't work.
	err = exec.Command(bzr, "help").Run()
	return err == nil
}

// Capture the major, minor and (optionally) patch version, but ignore anything later
var gitVersLineExtract = regexp.MustCompile(`git version\s+(\d+\.\d+(?:\.\d+)?)`)

func gitVersion() (string, error) {
	gitOut, runErr := exec.Command("git", "version").CombinedOutput()
	if runErr != nil {
		return "v0", fmt.Errorf("failed to execute git version: %w", runErr)
	}
	matches := gitVersLineExtract.FindSubmatch(gitOut)
	if len(matches) < 2 {
		return "v0", fmt.Errorf("git version extraction regexp did not match version line: %q", gitOut)
	}
	return "v" + string(matches[1]), nil
}

func hasAtLeastGitVersion(minVers string) (bool, error) {
	gitVers, gitVersErr := gitVersion()
	if gitVersErr != nil {
		return false, gitVersErr
	}
	return semver.Compare(minVers, gitVers) <= 0, nil
}

func gitSupportsSHA256() (bool, error) {
	return hasAtLeastGitVersion("v2.29")
}
