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

import (
	"errors"
	"fmt"
	"io/fs"
	"maps"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"regexp"
	"runtime"
	"slices"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/cilium/hive/script/internal/diff"
	"github.com/spf13/pflag"
	"golang.org/x/term"
)

// DefaultCmds returns a set of broadly useful script commands.
//
// Run the 'help' command within a script engine to view a list of the available
// commands.
func DefaultCmds() map[string]Cmd {
	return map[string]Cmd{
		"cat":     Cat(),
		"cd":      Cd(),
		"chmod":   Chmod(),
		"cmp":     Cmp(),
		"empty":   Empty(),
		"cmpenv":  Cmpenv(),
		"cp":      Cp(),
		"echo":    Echo(),
		"env":     Env(),
		"exec":    Exec(func(cmd *exec.Cmd) error { return cmd.Process.Signal(os.Interrupt) }, 100*time.Millisecond), // arbitrary grace period
		"exists":  Exists(),
		"grep":    Grep(),
		"help":    Help(),
		"mkdir":   Mkdir(),
		"mv":      Mv(),
		"rm":      Rm(),
		"replace": Replace(),
		"sed":     Sed(),
		"sleep":   Sleep(),
		"stderr":  Stderr(),
		"stdout":  Stdout(),
		"stop":    Stop(),
		"symlink": Symlink(),
		"wait":    Wait(),
		"break":   Break(),
	}
}

// Command returns a new Cmd with a Usage method that returns a copy of the
// given CmdUsage and a Run method calls the given function.
func Command(usage CmdUsage, run func(*State, ...string) (WaitFunc, error)) Cmd {
	return &funcCmd{
		usage: usage,
		run:   run,
	}
}

// A funcCmd implements Cmd using a function value.
type funcCmd struct {
	usage CmdUsage
	run   func(*State, ...string) (WaitFunc, error)
}

func (c *funcCmd) Run(s *State, args ...string) (WaitFunc, error) {
	return c.run(s, args...)
}

func (c *funcCmd) Usage() *CmdUsage { return &c.usage }

// firstNonFlag returns a slice containing the index of the first argument in
// rawArgs that is not a flag, or nil if all arguments are flags.
func firstNonFlag(rawArgs ...string) []int {
	for i, arg := range rawArgs {
		if !strings.HasPrefix(arg, "-") {
			return []int{i}
		}
		if arg == "--" {
			return []int{i + 1}
		}
	}
	return nil
}

func autocompleteFile(state *State, inPath string, dirOnly bool) []string {
	entries, err := os.ReadDir(state.Path(inPath))
	dir := inPath
	file := ""
	if err != nil {
		dir, file = path.Split(state.Path(inPath))
		entries, err = os.ReadDir(dir)
		if err != nil {
			return nil
		}
	}

	var suggestions []string
	for _, entry := range entries {
		if dirOnly && !entry.IsDir() {
			continue
		}

		if file == "" || strings.HasPrefix(entry.Name(), file) {
			suggestions = append(suggestions, path.Join(dir, entry.Name()))
		}
	}

	return suggestions
}

// Cat writes the concatenated contents of the named file(s) to the script's
// stdout buffer.
func Cat() Cmd {
	return Command(
		CmdUsage{
			Summary: "concatenate files and print to the script's stdout buffer",
			Args:    "files...",
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				return autocompleteFile(state, cur, false)
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args) == 0 {
				return nil, ErrUsage
			}

			paths := make([]string, 0, len(args))
			for _, arg := range args {
				paths = append(paths, s.Path(arg))
			}

			var buf strings.Builder
			errc := make(chan error, 1)
			go func() {
				for _, p := range paths {
					b, err := os.ReadFile(p)
					buf.Write(b)
					if err != nil {
						errc <- err
						return
					}
				}
				errc <- nil
			}()

			wait := func(*State) (stdout, stderr string, err error) {
				err = <-errc
				return buf.String(), "", err
			}
			return wait, nil
		})
}

// Cd changes the current working directory.
func Cd() Cmd {
	return Command(
		CmdUsage{
			Summary: "change the working directory",
			Args:    "dir",
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				if len(before) != 0 {
					// Only autocomplete the first argument.
					return nil
				}

				return autocompleteFile(state, cur, true)
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args) != 1 {
				return nil, ErrUsage
			}
			return nil, s.Chdir(args[0])
		})
}

// Chmod changes the permissions of a file or a directory..
func Chmod() Cmd {
	return Command(
		CmdUsage{
			Summary: "change file mode bits",
			Args:    "perm paths...",
			Detail: []string{
				"Changes the permissions of the named files or directories to be equal to perm.",
				"Only numerical permissions are supported.",
			},
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				if len(before) < 2 {
					// Only autocomplete the paths, not the permission.
					return nil
				}

				return autocompleteFile(state, cur, false)
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args) < 2 {
				return nil, ErrUsage
			}

			perm, err := strconv.ParseUint(args[0], 0, 32)
			if err != nil || perm&uint64(fs.ModePerm) != perm {
				return nil, fmt.Errorf("invalid mode: %s", args[0])
			}

			for _, arg := range args[1:] {
				err := os.Chmod(s.Path(arg), fs.FileMode(perm))
				if err != nil {
					return nil, err
				}
			}
			return nil, nil
		})
}

func compareFlags(fs *pflag.FlagSet) {
	fs.BoolP("quiet", "q", false, "Suppress printing of diff")
}

// Cmp compares the contents of two files, or the contents of either the
// "stdout" or "stderr" buffer and a file, returning a non-nil error if the
// contents differ.
func Cmp() Cmd {
	return Command(
		CmdUsage{
			Args:    "file1 file2",
			Summary: "compare files for differences",
			Flags:   compareFlags,
			Detail: []string{
				"By convention, file1 is the actual data and file2 is the expected data.",
				"The command succeeds if the file contents are identical.",
				"File1 can be 'stdout' or 'stderr' to compare the stdout or stderr buffer from the most recent command.",
			},
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				if len(before) > 2 {
					// Only autocomplete the first two arguments.
					return nil
				}

				return autocompleteFile(state, cur, false)
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			return nil, doCompare(s, false, args...)
		})
}

// Cmpenv is like Compare, but also performs environment substitutions
// on the contents of both arguments.
func Cmpenv() Cmd {
	return Command(
		CmdUsage{
			Args:    "file1 file2",
			Flags:   compareFlags,
			Summary: "compare files for differences, with environment expansion",
			Detail: []string{
				"By convention, file1 is the actual data and file2 is the expected data.",
				"The command succeeds if the file contents are identical after substituting variables from the script environment.",
				"File1 can be 'stdout' or 'stderr' to compare the script's stdout or stderr buffer.",
			},
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				if len(before) > 2 {
					// Only autocomplete the first two arguments.
					return nil
				}

				return autocompleteFile(state, cur, false)
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			return nil, doCompare(s, true, args...)
		})
}

func doCompare(s *State, env bool, args ...string) error {
	quiet, err := s.Flags.GetBool("quiet")
	if err != nil {
		return err
	}
	if len(args) != 2 {
		return ErrUsage
	}

	name1, name2 := args[0], args[1]
	var text1, text2 string

	text1, err = getActualFileData(s, name1)
	if err != nil {
		return err
	}

	data, err := os.ReadFile(s.Path(name2))
	if err != nil {
		return err
	}
	text2 = string(data)

	if env {
		text1 = s.ExpandEnv(text1, false)
		text2 = s.ExpandEnv(text2, false)
	}

	if text1 != text2 {
		if s.DoUpdate {
			switch {
			case len(text1) == 0 || len(text2) == 0:
				s.Logf("cmp: refusing to update empty files, use 'empty' command instead")
			case !s.retriesRequested:
				// Not retrying, just take the contents right away
				fallthrough
			case s.retriesRequested && s.RetryCount >= 2:
				// Updates requested and we've already retried at least twice
				// (and given time for things to settle down).
				// Store the file contents and ignore the mismatch.
				s.FileUpdates[name1] = text2
				s.FileUpdates[name2] = text1
				return nil
			}
		}

		if !quiet {
			diffText := diff.Diff(name1, []byte(text1), name2, []byte(text2))
			s.Logf("%s\n", diffText)
		}
		return fmt.Errorf("%s and %s differ", name1, name2)
	}
	return nil
}

// Cp copies one or more files to a new location.
func Cp() Cmd {
	return Command(
		CmdUsage{
			Summary: "copy files to a target file or directory",
			Args:    "src... dst",
			Detail: []string{
				"src can include 'stdout' or 'stderr' to copy from the script's stdout or stderr buffer.",
			},
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				fileSuggestions := autocompleteFile(state, cur, false)
				if strings.HasPrefix("stdout", cur) || cur == "" {
					fileSuggestions = append([]string{"stdout"}, fileSuggestions...)
				}
				if strings.HasPrefix("stderr", cur) || cur == "" {
					fileSuggestions = append([]string{"stderr"}, fileSuggestions...)
				}
				return fileSuggestions
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args) < 2 {
				return nil, ErrUsage
			}

			dst := s.Path(args[len(args)-1])
			info, err := os.Stat(dst)
			dstDir := err == nil && info.IsDir()
			if len(args) > 2 && !dstDir {
				return nil, &fs.PathError{Op: "cp", Path: dst, Err: errors.New("destination is not a directory")}
			}

			for _, arg := range args[:len(args)-1] {
				var (
					src  string
					data []byte
					mode fs.FileMode
				)
				switch arg {
				case "stdout":
					src = arg
					data = []byte(s.Stdout())
					mode = 0666
				case "stderr":
					src = arg
					data = []byte(s.Stderr())
					mode = 0666
				default:
					src = s.Path(arg)
					info, err := os.Stat(src)
					if err != nil {
						return nil, err
					}
					mode = info.Mode() & 0777
					data, err = os.ReadFile(src)
					if err != nil {
						return nil, err
					}
				}
				targ := dst
				if dstDir {
					targ = filepath.Join(dst, filepath.Base(src))
				}
				err := os.WriteFile(targ, data, mode)
				if err != nil {
					return nil, err
				}
			}

			return nil, nil
		})
}

// Echo writes its arguments to stdout, followed by a newline.
func Echo() Cmd {
	return Command(
		CmdUsage{
			Summary: "display a line of text",
			Args:    "string...",
		},
		func(s *State, args ...string) (WaitFunc, error) {
			var buf strings.Builder
			for i, arg := range args {
				if i > 0 {
					buf.WriteString(" ")
				}
				buf.WriteString(arg)
			}
			buf.WriteString("\n")
			out := buf.String()

			// Stuff the result into a callback to satisfy the OutputCommandFunc
			// interface, even though it isn't really asynchronous even if run in the
			// background.
			//
			// Nobody should be running 'echo' as a background command, but it's not worth
			// defining yet another interface, and also doesn't seem worth shoehorning
			// into a SimpleCommand the way we did with Wait.
			return func(*State) (stdout, stderr string, err error) {
				return out, "", nil
			}, nil
		})
}

// Env sets or logs the values of environment variables.
//
// With no arguments, Env reports all variables in the environment.
// "key=value" arguments set variables, and arguments without "="
// cause the corresponding value to be printed to the stdout buffer.
//
// Passing --from-stdout will set the "key" to the stdout of the previous
// command as the "value".
func Env() Cmd {
	return Command(
		CmdUsage{
			Summary: "set or log the values of environment variables",
			Flags: func(fs *pflag.FlagSet) {
				fs.Bool("from-stdout", false, "Whether to set the value of the variable from stdout")
			},
			Args: "[key[=value]...]",
			Detail: []string{
				"With no arguments, print the script environment to the log.",
				"Otherwise, add the listed key=value pairs to the environment or print the listed keys.",
			},
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				if cur == "" {
					return slices.Sorted(maps.Keys(state.envMap))
				}

				suggestions := make([]string, 0, len(state.envMap))
				for envKey := range state.envMap {
					if strings.HasPrefix(envKey, cur) {
						suggestions = append(suggestions, envKey)
					}
				}
				slices.Sort(suggestions)
				return suggestions
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			fromStdout, err := s.Flags.GetBool("from-stdout")
			if err != nil {
				return nil, err
			}

			out := new(strings.Builder)
			if len(args) == 0 {
				for _, kv := range s.env {
					fmt.Fprintf(out, "%s\n", kv)
				}
			} else {
				for _, env := range args {
					if fromStdout {
						if err := s.Setenv(env, strings.Trim(s.Stdout(), "\n ")); err != nil {
							return nil, err
						}
					} else {
						i := strings.Index(env, "=")
						if i < 0 {
							// Display value instead of setting it.
							fmt.Fprintf(out, "%s=%s\n", env, s.envMap[env])
							continue
						}
						if err := s.Setenv(env[:i], env[i+1:]); err != nil {
							return nil, err
						}
					}
				}
			}
			var wait WaitFunc
			if out.Len() > 0 || len(args) == 0 {
				wait = func(*State) (stdout, stderr string, err error) {
					return out.String(), "", nil
				}
			}
			return wait, nil
		})
}

// Exec runs an arbitrary executable as a subprocess.
//
// When the Script's context is canceled, Exec sends the interrupt signal, then
// waits for up to the given delay for the subprocess to flush output before
// terminating it with os.Kill.
func Exec(cancel func(*exec.Cmd) error, waitDelay time.Duration) Cmd {
	return Command(
		CmdUsage{
			Summary: "run an executable program with arguments",
			Args:    "program [args...]",
			Detail: []string{
				"Note that 'exec' does not terminate the script (unlike Unix shells).",
			},
			Async: true,
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				// TODO(dylandreimerink): Make multi-platform, currently UNIX only.

				// Only autocomplete the first argument.
				if len(before) != 0 {
					return nil
				}

				// If the current argument is a path, autocomplete it.
				if strings.Contains(cur, string(filepath.Separator)) {
					entries, err := os.ReadDir(state.Path(cur))
					dir := cur
					file := ""
					if err != nil {
						dir, file = path.Split(state.Path(cur))
						entries, err = os.ReadDir(dir)
						if err != nil {
							return nil
						}
					}

					var suggestions []string
					for _, entry := range entries {
						// If the entry is a directory, add it if it matches the prefix.
						if entry.IsDir() {
							if file == "" || strings.HasPrefix(entry.Name(), file) {
								suggestions = append(suggestions, path.Join(dir, entry.Name()))
							}
							continue
						}

						// If the entry is a file, check if it is executable.
						if !dirEntryIsExec(entry) {
							continue
						}

						// If the entry is executable, add it if it matches the prefix.
						if file == "" || strings.HasPrefix(entry.Name(), file) {
							suggestions = append(suggestions, path.Join(dir, entry.Name()))
						}
					}

					return suggestions
				}

				// Otherwise, look for executables in the script's PATH.
				var suggestions []string
				for _, dir := range strings.Split(state.envMap["PATH"], ":") {
					entries, err := os.ReadDir(dir)
					if err != nil {
						continue
					}

					for _, entry := range entries {
						// If the entry is a directory, skip it.
						if entry.IsDir() {
							continue
						}

						// If the entry is a file, check if it is executable.
						if !dirEntryIsExec(entry) {
							continue
						}

						// If the entry is executable, add it if it matches the prefix.
						if strings.HasPrefix(entry.Name(), cur) {
							suggestions = append(suggestions, entry.Name())
						}
					}
				}

				return suggestions
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args) < 1 {
				return nil, ErrUsage
			}

			// Use the script's PATH to look up the command (if it does not contain a separator)
			// instead of the test process's PATH (see lookPath).
			// Don't use filepath.Clean, since that changes "./foo" to "foo".
			name := filepath.FromSlash(args[0])
			path := name
			if !strings.Contains(name, string(filepath.Separator)) {
				var err error
				path, err = lookPath(s, name)
				if err != nil {
					return nil, err
				}
			}

			return startCommand(s, name, path, args[1:], cancel, waitDelay)
		})
}

func startCommand(s *State, name, path string, args []string, cancel func(*exec.Cmd) error, waitDelay time.Duration) (WaitFunc, error) {
	var (
		cmd                  *exec.Cmd
		stdoutBuf, stderrBuf strings.Builder
	)
	for {
		cmd = exec.CommandContext(s.Context(), path, args...)
		if cancel == nil {
			cmd.Cancel = nil
		} else {
			cmd.Cancel = func() error { return cancel(cmd) }
		}
		cmd.WaitDelay = waitDelay
		cmd.Args[0] = name
		cmd.Dir = s.Getwd()
		cmd.Env = s.env
		cmd.Stdout = &stdoutBuf
		cmd.Stderr = &stderrBuf
		err := cmd.Start()
		if err == nil {
			break
		}
		if isETXTBSY(err) {
			// If the script (or its host process) just wrote the executable we're
			// trying to run, a fork+exec in another thread may be holding open the FD
			// that we used to write the executable (see https://go.dev/issue/22315).
			// Since the descriptor should have CLOEXEC set, the problem should
			// resolve as soon as the forked child reaches its exec call.
			// Keep retrying until that happens.
		} else {
			return nil, err
		}
	}

	wait := func(s *State) (stdout, stderr string, err error) {
		err = cmd.Wait()
		return stdoutBuf.String(), stderrBuf.String(), err
	}
	return wait, nil
}

// lookPath is (roughly) like exec.LookPath, but it uses the script's current
// PATH to find the executable.
func lookPath(s *State, command string) (string, error) {
	var strEqual func(string, string) bool
	if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
		// Using GOOS as a proxy for case-insensitive file system.
		// TODO(bcmills): Remove this assumption.
		strEqual = strings.EqualFold
	} else {
		strEqual = func(a, b string) bool { return a == b }
	}

	var pathExt []string
	var searchExt bool
	var isExecutable func(os.FileInfo) bool
	if runtime.GOOS == "windows" {
		// Use the test process's PathExt instead of the script's.
		// If PathExt is set in the command's environment, cmd.Start fails with
		// "parameter is invalid". Not sure why.
		// If the command already has an extension in PathExt (like "cmd.exe")
		// don't search for other extensions (not "cmd.bat.exe").
		pathExt = strings.Split(os.Getenv("PathExt"), string(filepath.ListSeparator))
		searchExt = true
		cmdExt := filepath.Ext(command)
		for _, ext := range pathExt {
			if strEqual(cmdExt, ext) {
				searchExt = false
				break
			}
		}
		isExecutable = func(fi os.FileInfo) bool {
			return fi.Mode().IsRegular()
		}
	} else {
		isExecutable = func(fi os.FileInfo) bool {
			return fi.Mode().IsRegular() && fi.Mode().Perm()&0111 != 0
		}
	}

	pathEnv, _ := s.LookupEnv(pathEnvName())
	for _, dir := range strings.Split(pathEnv, string(filepath.ListSeparator)) {
		if dir == "" {
			continue
		}

		// Determine whether dir needs a trailing path separator.
		// Note: we avoid filepath.Join in this function because it cleans the
		// result: we want to preserve the exact dir prefix from the environment.
		sep := string(filepath.Separator)
		if os.IsPathSeparator(dir[len(dir)-1]) {
			sep = ""
		}

		if searchExt {
			ents, err := os.ReadDir(dir)
			if err != nil {
				continue
			}
			for _, ent := range ents {
				for _, ext := range pathExt {
					if !ent.IsDir() && strEqual(ent.Name(), command+ext) {
						return dir + sep + ent.Name(), nil
					}
				}
			}
		} else {
			path := dir + sep + command
			if fi, err := os.Stat(path); err == nil && isExecutable(fi) {
				return path, nil
			}
		}
	}
	return "", &exec.Error{Name: command, Err: exec.ErrNotFound}
}

// pathEnvName returns the platform-specific variable used by os/exec.LookPath
// to look up executable names (either "PATH" or "path").
//
// TODO(bcmills): Investigate whether we can instead use PATH uniformly and
// rewrite it to $path when executing subprocesses.
func pathEnvName() string {
	switch runtime.GOOS {
	case "plan9":
		return "path"
	default:
		return "PATH"
	}
}

// Exists checks that the named file(s) exist.
func Exists() Cmd {
	return Command(
		CmdUsage{
			Summary: "check that files exist",
			Args:    "file...",
			Flags: func(fs *pflag.FlagSet) {
				fs.Bool("readonly", false, "File must not be writable")
				fs.Bool("exec", false, "File must not be executable")
			},
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				if len(before) == 0 {
					// Only autocomplete the first argument.
					return nil
				}

				return autocompleteFile(state, cur, false)
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			readonly, err := s.Flags.GetBool("readonly")
			if err != nil {
				return nil, err
			}
			exec, err := s.Flags.GetBool("exec")
			if err != nil {
				return nil, err
			}

			if len(args) == 0 {
				return nil, ErrUsage
			}

			for _, file := range args {
				file = s.Path(file)
				info, err := os.Stat(file)
				if err != nil {
					return nil, err
				}
				if readonly && info.Mode()&0222 != 0 {
					return nil, fmt.Errorf("%s exists but is writable", file)
				}
				if exec && runtime.GOOS != "windows" && info.Mode()&0111 == 0 {
					return nil, fmt.Errorf("%s exists but is not executable", file)
				}
			}

			return nil, nil
		})
}

// Grep checks that file content matches a regexp.
// Like stdout/stderr and unlike Unix grep, it accepts Go regexp syntax.
//
// Grep does not modify the State's stdout or stderr buffers.
// (Its output goes to the script log, not stdout.)
func Grep() Cmd {
	return Command(
		CmdUsage{
			Summary: "find lines in a file that match a pattern",
			Args:    "'pattern' file",
			Flags:   matchFlags,
			Detail: []string{
				"The command succeeds if at least one match (or the exact count, if given) is found.",
				"The -q flag suppresses printing of matches.",
			},
			RegexpArgs: firstNonFlag,
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				if len(before) != 1 {
					// Only autocomplete the second argument.
					return nil
				}

				// Autocomplete the file argument.
				return autocompleteFile(state, cur, false)
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			return nil, match(s, args, "", "grep")
		})
}

func matchFlags(fs *pflag.FlagSet) {
	fs.Int("count", 0, "Exact count of matches")
	fs.BoolP("quiet", "q", false, "Suppress printing of matches")
}

// match implements the Grep, Stdout, and Stderr commands.
func match(s *State, args []string, text, name string) error {
	n, err := s.Flags.GetInt("count")
	if err != nil {
		return err
	}
	quiet, err := s.Flags.GetBool("quiet")
	if err != nil {
		return err
	}

	isGrep := name == "grep"

	wantArgs := 1
	if isGrep {
		wantArgs = 2
	}
	if len(args) != wantArgs {
		return ErrUsage
	}

	pattern := `(?m)` + args[0]
	re, err := regexp.Compile(pattern)
	if err != nil {
		return err
	}

	if isGrep {
		name = args[1] // for error messages
		data, err := os.ReadFile(s.Path(args[1]))
		if err != nil {
			return err
		}
		text = string(data)
	}

	if n > 0 {
		count := len(re.FindAllString(text, -1))
		if count != n {
			return fmt.Errorf("found %d matches for %#q in %s", count, pattern, name)
		}
		return nil
	}

	if !re.MatchString(text) {
		return fmt.Errorf("no match for %#q in %s", pattern, name)
	}

	if !quiet {
		// Print the lines containing the match.
		loc := re.FindStringIndex(text)
		for loc[0] > 0 && text[loc[0]-1] != '\n' {
			loc[0]--
		}
		for loc[1] < len(text) && text[loc[1]] != '\n' {
			loc[1]++
		}
		lines := strings.TrimSuffix(text[loc[0]:loc[1]], "\n")
		s.Logf("matched: %s\n", lines)
	}
	return nil
}

// Help writes command documentation to the script log.
func Help() Cmd {
	return Command(
		CmdUsage{
			Summary: "log help text for commands and conditions",
			Args:    "[-v] (regexp)",
			Detail: []string{
				"To display help for a specific condition, enclose it in brackets: 'help [amd64]'.",
				"To display complete documentation when listing all commands, pass the -v flag.",
				"Commands can be filtered with a regexp: 'help ^db'",
			},
			RegexpArgs: firstNonFlag,
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if s.engine == nil {
				return nil, errors.New("no engine configured")
			}

			verbose := false
			remoteAutocomplete := false
			if len(args) > 0 {
				verbose = true
				if args[0] == "-v" {
					args = args[1:]
				}
				if args[0] == "-a" {
					remoteAutocomplete = true
					args = args[1:]
				}
			}

			if remoteAutocomplete {
				out := new(strings.Builder)
				for cmd := range maps.Keys(s.engine.Cmds) {
					if len(args) > 0 && !strings.HasPrefix(cmd, args[0]) {
						continue
					}
					fmt.Fprintln(out, cmd)
				}
				wait := func(*State) (stdout, stderr string, err error) {
					return out.String(), "", nil
				}
				return wait, nil
			}

			var cmds, conds []string
			for _, arg := range args {
				if strings.HasPrefix(arg, "[") && strings.HasSuffix(arg, "]") {
					conds = append(conds, arg[1:len(arg)-1])
				} else {
					cmds = append(cmds, arg)
				}
			}

			out := new(strings.Builder)

			if len(conds) > 0 || (len(args) == 0 && len(s.engine.Conds) > 0) {
				if conds == nil {
					out.WriteString("conditions:\n\n")
				}
				s.engine.ListConds(out, s, conds...)
			}

			if len(cmds) > 0 || len(args) == 0 {
				if len(args) == 0 {
					out.WriteString("\ncommands:\n\n")
				}
				s.engine.ListCmds(out, verbose, strings.Join(cmds, " "))
			}

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

// Mkdir creates a directory and any needed parent directories.
func Mkdir() Cmd {
	return Command(
		CmdUsage{
			Summary: "create directories, if they do not already exist",
			Args:    "path...",
			Detail: []string{
				"Unlike Unix mkdir, parent directories are always created if needed.",
			},
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				// Autocomplete the directory argument.
				return autocompleteFile(state, cur, true)
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args) < 1 {
				return nil, ErrUsage
			}
			for _, arg := range args {
				if err := os.MkdirAll(s.Path(arg), 0777); err != nil {
					return nil, err
				}
			}
			return nil, nil
		})
}

// Mv renames an existing file or directory to a new path.
func Mv() Cmd {
	return Command(
		CmdUsage{
			Summary: "rename a file or directory to a new path",
			Args:    "old new",
			Detail: []string{
				"OS-specific restrictions may apply when old and new are in different directories.",
			},
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				if len(before) >= 2 {
					// Only autocomplete the first two arguments.
					return nil
				}

				return autocompleteFile(state, cur, false)
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args) != 2 {
				return nil, ErrUsage
			}
			return nil, os.Rename(s.Path(args[0]), s.Path(args[1]))
		})
}

// Program returns a new command that runs the named program, found from the
// host process's PATH (not looked up in the script's PATH).
func Program(name string, cancel func(*exec.Cmd) error, waitDelay time.Duration) Cmd {
	var (
		shortName    string
		summary      string
		lookPathOnce sync.Once
		path         string
		pathErr      error
	)
	if filepath.IsAbs(name) {
		lookPathOnce.Do(func() { path = filepath.Clean(name) })
		shortName = strings.TrimSuffix(filepath.Base(path), ".exe")
		summary = "run the '" + shortName + "' program provided by the script host"
	} else {
		shortName = name
		summary = "run the '" + shortName + "' program from the script host's PATH"
	}

	return Command(
		CmdUsage{
			Summary: summary,
			Args:    "[args...]",
			Async:   true,
		},
		func(s *State, args ...string) (WaitFunc, error) {
			lookPathOnce.Do(func() {
				path, pathErr = exec.LookPath(name)
			})
			if pathErr != nil {
				return nil, pathErr
			}
			return startCommand(s, shortName, path, args, cancel, waitDelay)
		})
}

// Replace replaces all occurrences of a string in a file with another string.
func Replace() Cmd {
	return Command(
		CmdUsage{
			Summary: "replace strings in a file",
			Args:    "[old new]... file",
			Detail: []string{
				"The 'old' and 'new' arguments are unquoted as if in quoted Go strings.",
			},
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				// Only autocomplete possible file arguments.
				if len(before) < 2 || len(before)%2 != 0 {
					return nil
				}

				// Autocomplete the file argument.
				return autocompleteFile(state, cur, false)
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args)%2 != 1 {
				return nil, ErrUsage
			}

			oldNew := make([]string, 0, len(args)-1)
			for _, arg := range args[:len(args)-1] {
				s, err := strconv.Unquote(`"` + arg + `"`)
				if err != nil {
					return nil, err
				}
				oldNew = append(oldNew, s)
			}

			r := strings.NewReplacer(oldNew...)
			file := s.Path(args[len(args)-1])

			data, err := os.ReadFile(file)
			if err != nil {
				return nil, err
			}
			replaced := r.Replace(string(data))

			return nil, os.WriteFile(file, []byte(replaced), 0666)
		})
}

// Sed implements a simple regexp replacement of text in a file.
func Sed() Cmd {
	return Command(
		CmdUsage{
			Summary: "substitute strings in a file with a regexp",
			Args:    "regexp replacement file",
			Detail: []string{
				"A simple sed-like command for replacing text matching regular expressions",
				"in a file.",
				"",
				"Implemented using regexp.ReplaceAll, see its docs for what is supported:",
				"https://pkg.go.dev/regexp#Regexp.ReplaceAll",
			},
			RegexpArgs: firstNonFlag,
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				// Only autocomplete the file argument.
				if len(before) != 2 {
					return nil
				}

				return autocompleteFile(state, cur, false)
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args) != 3 {
				return nil, ErrUsage
			}

			re, err := regexp.Compile(args[0])
			if err != nil {
				return nil, err
			}
			replacement := args[1]
			file := s.Path(args[2])

			data, err := os.ReadFile(file)
			if err != nil {
				return nil, err
			}
			lines := strings.Split(string(data), "\n")
			for i, line := range lines {
				lines[i] = re.ReplaceAllString(line, replacement)
			}
			return nil, os.WriteFile(file, []byte(strings.Join(lines, "\n")), 0666)
		})
}

// Rm removes a file or directory.
//
// If a directory, Rm also recursively removes that directory's
// contents.
func Rm() Cmd {
	return Command(
		CmdUsage{
			Summary: "remove a file or directory",
			Args:    "path...",
			Detail: []string{
				"If the path is a directory, its contents are removed recursively.",
			},
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				// Autocomplete the file argument.
				return autocompleteFile(state, cur, false)
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args) < 1 {
				return nil, ErrUsage
			}
			for _, arg := range args {
				if err := removeAll(s.Path(arg)); err != nil {
					return nil, err
				}
			}
			return nil, nil
		})
}

// removeAll removes dir and all files and directories it contains.
//
// Unlike os.RemoveAll, removeAll attempts to make the directories writable if
// needed in order to remove their contents.
func removeAll(dir string) error {
	// module cache has 0444 directories;
	// make them writable in order to remove content.
	filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error {
		// chmod not only directories, but also things that we couldn't even stat
		// due to permission errors: they may also be unreadable directories.
		if err != nil || info.IsDir() {
			os.Chmod(path, 0777)
		}
		return nil
	})
	return os.RemoveAll(dir)
}

// Sleep sleeps for the given Go duration or until the script's context is
// cancelled, whichever happens first.
func Sleep() Cmd {
	return Command(
		CmdUsage{
			Summary: "sleep for a specified duration",
			Args:    "duration",
			Detail: []string{
				"The duration must be given as a Go time.Duration string.",
			},
			Async: true,
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args) != 1 {
				return nil, ErrUsage
			}

			d, err := time.ParseDuration(args[0])
			if err != nil {
				return nil, err
			}

			timer := time.NewTimer(d)
			wait := func(s *State) (stdout, stderr string, err error) {
				ctx := s.Context()
				select {
				case <-ctx.Done():
					timer.Stop()
					return "", "", ctx.Err()
				case <-timer.C:
					return "", "", nil
				}
			}
			return wait, nil
		})
}

// Stderr searches for a regular expression in the stderr buffer.
func Stderr() Cmd {
	return Command(
		CmdUsage{
			Summary: "find lines in the stderr buffer that match a pattern",
			Args:    "'pattern'",
			Flags:   matchFlags,
			Detail: []string{
				"The command succeeds if at least one match (or the exact count, if given) is found.",
				"The -q flag suppresses printing of matches.",
			},
			RegexpArgs: firstNonFlag,
		},
		func(s *State, args ...string) (WaitFunc, error) {
			return nil, match(s, args, s.Stderr(), "stderr")
		})
}

// Stdout searches for a regular expression in the stdout buffer.
func Stdout() Cmd {
	return Command(
		CmdUsage{
			Summary: "find lines in the stdout buffer that match a pattern",
			Args:    "'pattern'",
			Flags:   matchFlags,
			Detail: []string{
				"The command succeeds if at least one match (or the exact count, if given) is found.",
				"The -q flag suppresses printing of matches.",
			},
			RegexpArgs: firstNonFlag,
		},
		func(s *State, args ...string) (WaitFunc, error) {
			return nil, match(s, args, s.Stdout(), "stdout")
		})
}

// Stop returns a sentinel error that causes script execution to halt
// and s.Execute to return with a nil error.
func Stop() Cmd {
	return Command(
		CmdUsage{
			Summary: "stop execution of the script",
			Args:    "[msg]",
			Detail: []string{
				"The message is written to the script log, but no error is reported from the script engine.",
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args) > 1 {
				return nil, ErrUsage
			}
			// TODO(bcmills): The argument passed to stop seems redundant with comments.
			// Either use it systematically or remove it.
			if len(args) == 1 {
				return nil, stopError{msg: args[0]}
			}
			return nil, stopError{}
		})
}

// stopError is the sentinel error type returned by the Stop command.
type stopError struct {
	msg string
}

func (s stopError) Error() string {
	if s.msg == "" {
		return "stop"
	}
	return "stop: " + s.msg
}

// Symlink creates a symbolic link.
func Symlink() Cmd {
	return Command(
		CmdUsage{
			Summary: "create a symlink",
			Args:    "path -> target",
			Detail: []string{
				"Creates path as a symlink to target.",
				"The '->' token (like in 'ls -l' output on Unix) is required.",
			},
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				if len(before) == 1 {
					return []string{"->"}
				}
				if len(before) > 2 {
					// Only autocomplete the first three arguments.
					return nil
				}

				return autocompleteFile(state, cur, false)
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args) != 3 || args[1] != "->" {
				return nil, ErrUsage
			}

			// Note that the link target args[2] is not interpreted with s.Path:
			// it will be interpreted relative to the directory file is in.
			return nil, os.Symlink(filepath.FromSlash(args[2]), s.Path(args[0]))
		})
}

// Wait waits for the completion of background commands.
//
// When Wait returns, the stdout and stderr buffers contain the concatenation of
// the background commands' respective outputs in the order in which those
// commands were started.
func Wait() Cmd {
	return Command(
		CmdUsage{
			Summary: "wait for completion of background commands",
			Args:    "",
			Detail: []string{
				"Waits for all background commands to complete.",
				"The output (and any error) from each command is printed to the log in the order in which the commands were started.",
				"After the call to 'wait', the script's stdout and stderr buffers contain the concatenation of the background commands' outputs.",
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			if len(args) > 0 {
				return nil, ErrUsage
			}

			var stdouts, stderrs []string
			var errs []*CommandError
			for _, bg := range s.background {
				stdout, stderr, err := bg.wait(s)

				beforeArgs := ""
				if len(bg.args) > 0 {
					beforeArgs = " "
				}
				s.Logf("[background] %s%s%s\n", bg.name, beforeArgs, quoteArgs(bg.args))

				if stdout != "" {
					s.Logf("[stdout]\n%s", stdout)
					stdouts = append(stdouts, stdout)
				}
				if stderr != "" {
					s.Logf("[stderr]\n%s", stderr)
					stderrs = append(stderrs, stderr)
				}
				if err != nil {
					s.Logf("[%v]\n", err)
				}
				if cmdErr := checkStatus(bg.command, err); cmdErr != nil {
					errs = append(errs, cmdErr.(*CommandError))
				}
			}

			s.stdout = strings.Join(stdouts, "")
			s.stderr = strings.Join(stderrs, "")
			s.background = nil
			if len(errs) > 0 {
				return nil, waitError{errs: errs}
			}
			return nil, nil
		})
}

// A waitError wraps one or more errors returned by background commands.
type waitError struct {
	errs []*CommandError
}

func (w waitError) Error() string {
	b := new(strings.Builder)
	for i, err := range w.errs {
		if i != 0 {
			b.WriteString("\n")
		}
		b.WriteString(err.Error())
	}
	return b.String()
}

func (w waitError) Unwrap() error {
	if len(w.errs) == 1 {
		return w.errs[0]
	}
	return nil
}

func Break() Cmd {
	return Command(
		CmdUsage{
			Summary: "break into interactive prompt",
		},
		func(s *State, args ...string) (WaitFunc, error) {
			tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
			if err != nil {
				return nil, fmt.Errorf("open /dev/tty: %w", err)
			}
			defer tty.Close()

			// Flush any pending logs before switching to raw mode.
			s.FlushLog()

			// Hack: sleep a little bit to allow the log to be written out to stdout
			// before we switch to raw mode (which might mess up the output)
			time.Sleep(50 * time.Millisecond)

			prev, err := term.MakeRaw(int(tty.Fd()))
			if err != nil {
				return nil, fmt.Errorf("cannot set /dev/tty to raw mode")
			}
			defer term.Restore(int(tty.Fd()), prev)

			// Switch the log output to the terminal until we continue
			terminal := term.NewTerminal(tty, "debug> ")
			if width, height, err := term.GetSize(int(tty.Fd())); err == nil {
				terminal.SetSize(width, height)
			}
			origLogOut := s.logOut
			defer func() {
				s.logOut = origLogOut

			}()
			s.logOut = terminal

			fmt.Fprintf(terminal, "\nBreak! Control-d to continue.\n")

			engine := s.engine
			for {
				terminal.AutoCompleteCallback = engine.Autocomplete(terminal, s)

				line, err := terminal.ReadLine()
				if err != nil {
					return nil, nil
				}
				err = engine.ExecuteLine(s, line, terminal)
				if err != nil {
					fmt.Fprintln(terminal, err.Error())
				}
			}
		},
	)
}

func emptyFlags(fs *pflag.FlagSet) {
	fs.BoolP("quiet", "q", false, "Suppress printing of non-empty output diff")
	fs.BoolP("trim", "t", false, "Trim newline chars")
}

// Empty checks whether contents of a file, or contents of either "stdout" or "stderr"
// are empty.
// Returns non-nil error if this assertion fails.
func Empty() Cmd {
	return Command(
		CmdUsage{
			Args:    "file",
			Summary: "check if file is empty",
			Flags:   emptyFlags,
			Detail: []string{
				"The command succeeds if the file is empty.",
				"File can be 'stdout' or 'stderr' to compare the stdout or stderr buffer from the most recent command.",
			},
			AutocompleteArgs: func(state *State, before []string, cur string) []string {
				// Autocomplete the file argument.
				if len(before) != 0 {
					return nil
				}

				suggestions := autocompleteFile(state, cur, false)
				for _, stdfile := range []string{"stdout", "stderr"} {
					if cur == "" || strings.HasPrefix(stdfile, cur) {
						suggestions = append(suggestions, stdfile)
					}
				}

				return suggestions
			},
		},
		func(s *State, args ...string) (WaitFunc, error) {
			return nil, doEmpty(s, args...)
		})
}

func doEmpty(s *State, args ...string) error {
	quiet, err := s.Flags.GetBool("quiet")
	if err != nil {
		return err
	}
	trim, err := s.Flags.GetBool("trim")
	if err != nil {
		return err
	}
	if len(args) != 1 {
		return ErrUsage
	}

	name := args[0]
	text, err := getActualFileData(s, name)

	if trim {
		text = strings.Trim(text, "\n")
	}

	if text != "" {
		if !quiet {
			diffText := diff.Diff(name, []byte(text), "<empty>", []byte{})
			s.Logf("%s\n", diffText)
		}
		return fmt.Errorf("%s is not empty", name)
	}
	return nil
}

// getActualFileData is used to read "actual" test data (i.e. such as files
// created by test script or test stdout.
func getActualFileData(s *State, name string) (string, error) {
	var text string
	switch name {
	case "stdout":
		text = s.Stdout()
	case "stderr":
		text = s.Stderr()
	default:
		data, err := os.ReadFile(s.Path(name))
		if err != nil {
			return "", err
		}
		text = string(data)
	}
	return text, nil
}

func dirEntryIsExec(entry os.DirEntry) bool {
	// If the entry is a file, check if it is executable.
	const execAny = 0111
	info, err := entry.Info()
	if err != nil {
		return false
	}
	return info.Mode()&execAny != 0
}
