/*
Copyright 2019 The Kubernetes Authors.

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 command

import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"regexp"
	"strings"
	"sync"
	"syscall"

	"github.com/sirupsen/logrus"
)

// A generic command abstraction.
type Command struct {
	cmds                         []*command
	stdErrWriters, stdOutWriters []io.Writer
	env                          []string
	verbose                      bool
	filter                       *filter
}

// The internal command representation.
type command struct {
	*exec.Cmd
	pipeWriter *io.PipeWriter
}

// filter is the internally used struct for filtering command output.
type filter struct {
	regex      *regexp.Regexp
	replaceAll string
}

// A generic command exit status.
type Status struct { //nolint: errname
	waitStatus syscall.WaitStatus
	*Stream
}

// Stream combines standard output and error.
type Stream struct { //nolint: errname
	stdOut string
	stdErr string
}

// Commands is an abstraction over multiple Command structures.
type Commands []*Command

// New creates a new command from the provided arguments.
func New(cmd string, args ...string) *Command {
	return NewWithWorkDir("", cmd, args...)
}

// NewWithWorkDir creates a new command from the provided workDir and the command
// arguments.
func NewWithWorkDir(workDir, cmd string, args ...string) *Command {
	return &Command{
		cmds: []*command{{
			Cmd:        cmdWithDir(workDir, cmd, args...),
			pipeWriter: nil,
		}},
		stdErrWriters: []io.Writer{},
		stdOutWriters: []io.Writer{},
		verbose:       false,
	}
}

func cmdWithDir(dir, cmd string, args ...string) *exec.Cmd {
	c := exec.Command(cmd, args...)
	c.Dir = dir

	return c
}

// Pipe creates a new command where the previous should be piped to.
func (c *Command) Pipe(cmd string, args ...string) *Command {
	pipeCmd := cmdWithDir(c.cmds[0].Dir, cmd, args...)

	reader, writer := io.Pipe()
	c.cmds[len(c.cmds)-1].Stdout = writer
	pipeCmd.Stdin = reader

	c.cmds = append(c.cmds, &command{
		Cmd:        pipeCmd,
		pipeWriter: writer,
	})

	return c
}

// Env specifies the environment added to the command. Each entry is of the
// form "key=value". The environment of the current process is being preserved,
// while it is possible to overwrite already existing environment variables.
func (c *Command) Env(env ...string) *Command {
	c.env = append(c.env, env...)

	return c
}

// Verbose enables verbose output aka printing the command before executing it.
func (c *Command) Verbose() *Command {
	c.verbose = true

	return c
}

// isVerbose returns true if the command is in verbose mode, either set locally
// or global.
func (c *Command) isVerbose() bool {
	return GetGlobalVerbose() || c.verbose
}

// Add a command with the same working directory as well as verbosity mode.
// Returns a new Commands instance.
func (c *Command) Add(cmd string, args ...string) Commands {
	addCmd := NewWithWorkDir(c.cmds[0].Dir, cmd, args...)
	addCmd.verbose = c.verbose
	addCmd.filter = c.filter

	return Commands{c, addCmd}
}

// AddWriter can be used to add an additional output (stdout) and error
// (stderr) writer to the command, for example when having the need to log to
// files.
func (c *Command) AddWriter(writer io.Writer) *Command {
	c.AddOutputWriter(writer)
	c.AddErrorWriter(writer)

	return c
}

// AddErrorWriter can be used to add an additional error (stderr) writer to the
// command, for example when having the need to log to files.
func (c *Command) AddErrorWriter(writer io.Writer) *Command {
	c.stdErrWriters = append(c.stdErrWriters, writer)

	return c
}

// AddOutputWriter can be used to add an additional output (stdout) writer to
// the command, for example when having the need to log to files.
func (c *Command) AddOutputWriter(writer io.Writer) *Command {
	c.stdOutWriters = append(c.stdOutWriters, writer)

	return c
}

// Filter adds an output filter regular expression to the command. Every output
// will then be replaced with the string provided by replaceAll.
func (c *Command) Filter(regex, replaceAll string) (*Command, error) {
	filterRegex, err := regexp.Compile(regex)
	if err != nil {
		return nil, fmt.Errorf("compile regular expression: %w", err)
	}

	c.filter = &filter{
		regex:      filterRegex,
		replaceAll: replaceAll,
	}

	return c, nil
}

// Run starts the command and waits for it to finish. It returns an error if
// the command execution was not possible at all, otherwise the Status.
// This method prints the commands output during execution.
func (c *Command) Run() (res *Status, err error) {
	return c.run(true)
}

// RunSuccessOutput starts the command and waits for it to finish. It returns
// an error if the command execution was not successful, otherwise its output.
func (c *Command) RunSuccessOutput() (output *Stream, err error) {
	res, err := c.run(true)
	if err != nil {
		return nil, err
	}

	if !res.Success() {
		return nil, fmt.Errorf("command %v did not succeed: %v", c.String(), res.Error())
	}

	return res.Stream, nil
}

// RunSuccess starts the command and waits for it to finish. It returns an
// error if the command execution was not successful.
func (c *Command) RunSuccess() error {
	_, err := c.RunSuccessOutput()

	return err
}

// String returns a string representation of the full command.
func (c *Command) String() string {
	str := []string{}

	for _, x := range c.cmds {
		// Note: the following logic can be replaced with x.String(), which was
		// implemented in go1.13
		b := new(strings.Builder)
		b.WriteString(x.Path)

		for _, a := range x.Args[1:] {
			b.WriteByte(' ')
			b.WriteString(a)
		}

		str = append(str, b.String())
	}

	return strings.Join(str, " | ")
}

// Run starts the command and waits for it to finish. It returns an error if
// the command execution was not possible at all, otherwise the Status.
// This method does not print the output of the command during its execution.
func (c *Command) RunSilent() (res *Status, err error) {
	return c.run(false)
}

// RunSilentSuccessOutput starts the command and waits for it to finish. It
// returns an error if the command execution was not successful, otherwise its
// output. This method does not print the output of the command during its
// execution.
func (c *Command) RunSilentSuccessOutput() (output *Stream, err error) {
	res, err := c.run(false)
	if err != nil {
		return nil, err
	}

	if !res.Success() {
		return nil, fmt.Errorf("command %v did not succeed: %w", c.String(), res)
	}

	return res.Stream, nil
}

// RunSilentSuccess starts the command and waits for it to finish. It returns
// an error if the command execution was not successful. This method does not
// print the output of the command during its execution.
func (c *Command) RunSilentSuccess() error {
	_, err := c.RunSilentSuccessOutput()

	return err
}

// run is the internal run method.
func (c *Command) run(printOutput bool) (res *Status, err error) {
	var runErr error

	stdOutBuffer := &bytes.Buffer{}
	stdErrBuffer := &bytes.Buffer{}
	status := &Status{Stream: &Stream{}}

	type done struct {
		stdout error
		stderr error
	}

	doneChan := make(chan done, 1)

	var stdOutWriter io.Writer

	for i, cmd := range c.cmds {
		// Last command handling
		if i+1 == len(c.cmds) {
			stdout, err := cmd.StdoutPipe()
			if err != nil {
				return nil, err
			}

			stderr, err := cmd.StderrPipe()
			if err != nil {
				return nil, err
			}

			var stdErrWriter io.Writer

			if printOutput {
				stdOutWriter = io.MultiWriter(append(
					[]io.Writer{os.Stdout, stdOutBuffer}, c.stdOutWriters...,
				)...)
				stdErrWriter = io.MultiWriter(append(
					[]io.Writer{os.Stderr, stdErrBuffer}, c.stdErrWriters...,
				)...)
			} else {
				stdOutWriter = stdOutBuffer
				stdErrWriter = stdErrBuffer
			}

			go func() {
				var stdoutErr, stderrErr error

				wg := sync.WaitGroup{}

				wg.Add(2)

				filterCopy := func(read io.ReadCloser, write io.Writer) (err error) {
					if c.filter != nil {
						builder := &strings.Builder{}

						_, err = io.Copy(builder, read)
						if err != nil {
							return err
						}

						str := c.filter.regex.ReplaceAllString(
							builder.String(), c.filter.replaceAll,
						)
						_, err = io.Copy(write, strings.NewReader(str))
					} else {
						_, err = io.Copy(write, read)
					}

					return err
				}

				go func() {
					stdoutErr = filterCopy(stdout, stdOutWriter)

					wg.Done()
				}()

				go func() {
					stderrErr = filterCopy(stderr, stdErrWriter)

					wg.Done()
				}()

				wg.Wait()

				doneChan <- done{stdoutErr, stderrErr}
			}()
		}

		if c.isVerbose() {
			logrus.Infof("+ %s", c.String())
		}

		cmd.Env = append(os.Environ(), c.env...)

		if err := cmd.Start(); err != nil {
			return nil, err
		}

		if i > 0 {
			if err := c.cmds[i-1].Wait(); err != nil {
				return nil, err
			}
		}

		if cmd.pipeWriter != nil {
			if err := cmd.pipeWriter.Close(); err != nil {
				return nil, err
			}
		}

		// Wait for last command in the pipe to finish
		if i+1 == len(c.cmds) {
			err := <-doneChan
			if err.stdout != nil && strings.Contains(err.stdout.Error(), os.ErrClosed.Error()) {
				return nil, fmt.Errorf("unable to copy stdout: %w", err.stdout)
			}

			if err.stderr != nil && strings.Contains(err.stderr.Error(), os.ErrClosed.Error()) {
				return nil, fmt.Errorf("unable to copy stderr: %w", err.stderr)
			}

			runErr = cmd.Wait()
		}
	}

	status.stdOut = stdOutBuffer.String()
	status.stdErr = stdErrBuffer.String()

	exitErr := &exec.ExitError{}
	if errors.As(runErr, &exitErr) {
		if waitStatus, ok := exitErr.Sys().(syscall.WaitStatus); ok {
			status.waitStatus = waitStatus

			return status, nil
		}
	}

	return status, runErr
}

// Success returns if a Status was successful.
func (s *Status) Success() bool {
	return s.waitStatus.ExitStatus() == 0
}

// ExitCode returns the exit status of the command status.
func (s *Status) ExitCode() int {
	return s.waitStatus.ExitStatus()
}

// Output returns stdout of the command status.
func (s *Stream) Output() string {
	return s.stdOut
}

// OutputTrimNL returns stdout of the command status with newlines trimmed
// Use only when output is expected to be a single "word", like a version string.
func (s *Stream) OutputTrimNL() string {
	return strings.TrimSpace(s.stdOut)
}

// Error returns the stderr of the command status.
func (s *Stream) Error() string {
	return s.stdErr
}

// Execute is a convenience function which creates a new Command, executes it
// and evaluates its status.
func Execute(cmd string, args ...string) error {
	status, err := New(cmd, args...).Run()
	if err != nil {
		return fmt.Errorf("command %q is not executable: %w", cmd, err)
	}

	if !status.Success() {
		return fmt.Errorf(
			"command %q did not exit successful (%d)",
			cmd, status.ExitCode(),
		)
	}

	return nil
}

// Available verifies that the specified `commands` are available within the
// current `$PATH` environment and returns true if so. The function does not
// check for duplicates nor if the provided slice is empty.
func Available(commands ...string) (ok bool) {
	ok = true

	for _, command := range commands {
		if _, err := exec.LookPath(command); err != nil {
			logrus.Warnf("Unable to %v", err)

			ok = false
		}
	}

	return ok
}

// Add adds another command with the same working directory as well as
// verbosity mode to the Commands.
func (c Commands) Add(cmd string, args ...string) Commands {
	addCmd := NewWithWorkDir(c[0].cmds[0].Dir, cmd, args...)
	addCmd.verbose = c[0].verbose
	addCmd.filter = c[0].filter

	return append(c, addCmd)
}

// Run executes all commands sequentially and abort if any of those fails.
func (c Commands) Run() (*Status, error) {
	res := &Status{Stream: &Stream{}}

	for _, cmd := range c {
		output, err := cmd.RunSuccessOutput()
		if err != nil {
			return nil, fmt.Errorf("running command %q: %w", cmd.String(), err)
		}

		res.stdOut += "\n" + output.stdOut
		res.stdErr += "\n" + output.stdErr
	}

	return res, nil
}
