// Copyright 2015 The Gogs Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package git

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"strings"
	"time"
)

// Command contains the name, arguments and environment variables of a command.
type Command struct {
	name    string
	args    []string
	envs    []string
	timeout time.Duration
	ctx     context.Context
}

// CommandOptions contains options for running a command.
// If timeout is zero, DefaultTimeout will be used.
// If timeout is less than zero, no timeout will be set.
// If context is nil, context.Background() will be used.
type CommandOptions struct {
	Args    []string
	Envs    []string
	Timeout time.Duration
	Context context.Context
}

// String returns the string representation of the command.
func (c *Command) String() string {
	if len(c.args) == 0 {
		return c.name
	}
	return fmt.Sprintf("%s %s", c.name, strings.Join(c.args, " "))
}

// NewCommand creates and returns a new Command with given arguments for "git".
func NewCommand(args ...string) *Command {
	return NewCommandWithContext(context.Background(), args...)
}

// NewCommandWithContext creates and returns a new Command with given arguments
// and context for "git".
func NewCommandWithContext(ctx context.Context, args ...string) *Command {
	return &Command{
		name: "git",
		args: args,
		ctx:  ctx,
	}
}

// AddArgs appends given arguments to the command.
func (c *Command) AddArgs(args ...string) *Command {
	c.args = append(c.args, args...)
	return c
}

// AddEnvs appends given environment variables to the command.
func (c *Command) AddEnvs(envs ...string) *Command {
	c.envs = append(c.envs, envs...)
	return c
}

// WithContext returns a new Command with the given context.
func (c Command) WithContext(ctx context.Context) *Command {
	c.ctx = ctx
	return &c
}

// WithTimeout returns a new Command with given timeout.
func (c Command) WithTimeout(timeout time.Duration) *Command {
	c.timeout = timeout
	return &c
}

// SetTimeout sets the timeout for the command.
func (c *Command) SetTimeout(timeout time.Duration) {
	c.timeout = timeout
}

// AddOptions adds options to the command.
// Note: only the last option will take effect if there are duplicated options.
func (c *Command) AddOptions(opts ...CommandOptions) *Command {
	for _, opt := range opts {
		c.timeout = opt.Timeout
		c.ctx = opt.Context
		c.AddArgs(opt.Args...)
		c.AddEnvs(opt.Envs...)
	}
	return c
}

// AddCommitter appends given committer to the command.
func (c *Command) AddCommitter(committer *Signature) *Command {
	c.AddEnvs("GIT_COMMITTER_NAME="+committer.Name, "GIT_COMMITTER_EMAIL="+committer.Email)
	return c
}

// DefaultTimeout is the default timeout duration for all commands.
const DefaultTimeout = time.Minute

// A limitDualWriter writes to W but limits the amount of data written to just N
// bytes. On the other hand, it passes everything to w.
type limitDualWriter struct {
	W        io.Writer // underlying writer
	N        int64     // max bytes remaining
	prompted bool

	w io.Writer
}

func (w *limitDualWriter) Write(p []byte) (int, error) {
	if w.N > 0 {
		limit := int64(len(p))
		if limit > w.N {
			limit = w.N
		}
		n, _ := w.W.Write(p[:limit])
		w.N -= int64(n)
	}

	if !w.prompted && w.N <= 0 {
		w.prompted = true
		_, _ = w.W.Write([]byte("... (more omitted)"))
	}

	return w.w.Write(p)
}

// RunInDirOptions contains options for running a command in a directory.
type RunInDirOptions struct {
	// Stdin is the input to the command.
	Stdin io.Reader
	// Stdout is the outputs from the command.
	Stdout io.Writer
	// Stderr is the error output from the command.
	Stderr io.Writer
	// Timeout is the duration to wait before timing out.
	//
	// Deprecated: Use CommandOptions.Timeout or *Command.WithTimeout instead.
	Timeout time.Duration
}

// RunInDirWithOptions executes the command in given directory and options. It
// pipes stdin from supplied io.Reader, and pipes stdout and stderr to supplied
// io.Writer. DefaultTimeout will be used if the timeout duration is less than
// time.Nanosecond (i.e. less than or equal to 0). It returns an ErrExecTimeout
// if the execution was timed out.
func (c *Command) RunInDirWithOptions(dir string, opts ...RunInDirOptions) (err error) {
	var opt RunInDirOptions
	if len(opts) > 0 {
		opt = opts[0]
	}

	timeout := c.timeout
	// TODO: remove this in newer version
	if opt.Timeout > 0 {
		timeout = opt.Timeout
	}

	if timeout == 0 {
		timeout = DefaultTimeout
	}

	buf := new(bytes.Buffer)
	w := opt.Stdout
	if logOutput != nil {
		buf.Grow(512)
		w = &limitDualWriter{
			W: buf,
			N: int64(buf.Cap()),
			w: opt.Stdout,
		}
	}

	defer func() {
		if len(dir) == 0 {
			log("[timeout: %v] %s\n%s", timeout, c, buf.Bytes())
		} else {
			log("[timeout: %v] %s: %s\n%s", timeout, dir, c, buf.Bytes())
		}
	}()

	ctx := context.Background()
	if c.ctx != nil {
		ctx = c.ctx
	}

	if timeout > 0 {
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, timeout)
		defer func() {
			cancel()
			if err == context.DeadlineExceeded {
				err = ErrExecTimeout
			}
		}()
	}

	cmd := exec.CommandContext(ctx, c.name, c.args...)
	if len(c.envs) > 0 {
		cmd.Env = append(os.Environ(), c.envs...)
	}
	cmd.Dir = dir
	cmd.Stdin = opt.Stdin
	cmd.Stdout = w
	cmd.Stderr = opt.Stderr
	if err = cmd.Start(); err != nil {
		return err
	}

	result := make(chan error)
	go func() {
		result <- cmd.Wait()
	}()

	select {
	case <-ctx.Done():
		<-result
		if cmd.Process != nil && cmd.ProcessState != nil && !cmd.ProcessState.Exited() {
			if err := cmd.Process.Kill(); err != nil {
				return fmt.Errorf("kill process: %v", err)
			}
		}

		return ErrExecTimeout
	case err = <-result:
		return err
	}

}

// RunInDirPipeline executes the command in given directory and default timeout
// duration. It pipes stdout and stderr to supplied io.Writer.
func (c *Command) RunInDirPipeline(stdout, stderr io.Writer, dir string) error {
	return c.RunInDirWithOptions(dir, RunInDirOptions{
		Stdin:  nil,
		Stdout: stdout,
		Stderr: stderr,
	})
}

// RunInDirPipelineWithTimeout executes the command in given directory and
// timeout duration. It pipes stdout and stderr to supplied io.Writer.
// DefaultTimeout will be used if the timeout duration is less than
// time.Nanosecond (i.e. less than or equal to 0). It returns an ErrExecTimeout
// if the execution was timed out.
//
// Deprecated: Use RunInDirPipeline and CommandOptions instead.
// TODO: remove this in the next major version
func (c *Command) RunInDirPipelineWithTimeout(timeout time.Duration, stdout, stderr io.Writer, dir string) (err error) {
	if timeout != 0 {
		c = c.WithTimeout(timeout)
	}
	return c.RunInDirPipeline(stdout, stderr, dir)
}

// RunInDirWithTimeout executes the command in given directory and timeout
// duration. It returns stdout in []byte and error (combined with stderr).
//
// Deprecated: Use RunInDir and CommandOptions instead.
// TODO: remove this in the next major version
func (c *Command) RunInDirWithTimeout(timeout time.Duration, dir string) ([]byte, error) {
	if timeout != 0 {
		c = c.WithTimeout(timeout)
	}
	return c.RunInDir(dir)
}

// RunInDir executes the command in given directory and default timeout
// duration. It returns stdout and error (combined with stderr).
func (c *Command) RunInDir(dir string) ([]byte, error) {
	stdout := new(bytes.Buffer)
	stderr := new(bytes.Buffer)
	if err := c.RunInDirPipeline(stdout, stderr, dir); err != nil {
		return nil, concatenateError(err, stderr.String())
	}
	return stdout.Bytes(), nil
}

// RunWithTimeout executes the command in working directory and given timeout
// duration. It returns stdout in string and error (combined with stderr).
//
// Deprecated: Use RunInDir and CommandOptions instead.
// TODO: remove this in the next major version
func (c *Command) RunWithTimeout(timeout time.Duration) ([]byte, error) {
	if timeout != 0 {
		c = c.WithTimeout(timeout)
	}
	return c.Run()
}

// Run executes the command in working directory and default timeout duration.
// It returns stdout in string and error (combined with stderr).
func (c *Command) Run() ([]byte, error) {
	stdout, err := c.RunInDir("")
	if err != nil {
		return nil, err
	}
	return stdout, nil
}
