// Copyright 2016 Amazon.com, Inc. or its affiliates. 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. A copy of the
// License is located at
//
// http://aws.amazon.com/apache2.0/
//
// or in the "license" file accompanying this file. This file 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 executers contains general purpose (shell) command executing objects.
package executers

import (
	"bytes"
	"fmt"
	"io"
	"io/ioutil"
	"os"
	"os/exec"
	"runtime"
	"runtime/debug"
	"strings"
	"syscall"
	"time"

	"github.com/aws/amazon-ssm-agent/agent/appconfig"
	"github.com/aws/amazon-ssm-agent/agent/context"
	"github.com/aws/amazon-ssm-agent/agent/fileutil"
	"github.com/aws/amazon-ssm-agent/agent/log"
	"github.com/aws/amazon-ssm-agent/agent/platform"
	"github.com/aws/amazon-ssm-agent/agent/task"
)

const (
	// envVar* constants are names of environment variables set for processes executed by ssm agent and should start with AWS_SSM_
	envVarInstanceID           = "AWS_SSM_INSTANCE_ID"
	envVarRegionName           = "AWS_SSM_REGION_NAME"
	envVarPlatformName         = "AWS_SSM_PLATFORM_NAME"
	envVarPlatformVersion      = "AWS_SSM_PLATFORM_VERSION"
	envVarUseDualStackEndpoint = "AWS_SSM_USE_DUALSTACK_ENDPOINT"
)

// T is the interface type for ShellCommandExecuter.
type T interface {
	//TODO: Remove Execute and rename NewExecute to Execute.
	Execute(context.T, string, string, string, task.CancelFlag, int, string, []string, map[string]string) (io.Reader, io.Reader, int, []error)
	NewExecute(context.T, string, io.Writer, io.Writer, task.CancelFlag, int, string, []string, map[string]string) (int, error)
	StartExe(context.T, string, io.Writer, io.Writer, task.CancelFlag, string, []string) (*os.Process, int, error)
}

// ShellCommandExecuter is specially added for testing purposes
type ShellCommandExecuter struct {
}

type timeoutSignal struct {
	// process kill doesn't send proper signal to the process status
	// Setting the execInterruptedOnWindows to indicate execution was interrupted
	execInterruptedOnWindows bool
}

// Execute executes a list of shell commands in the given working directory.
// If no file path is provided for either stdout or stderr, output will be written to a byte buffer.
// Returns readers for the standard output and standard error streams, process exit code, and a set of errors.
// The errors need not be fatal - the output streams may still have data
// even though some errors are reported. For example, if the command got killed while executing,
// the streams will have whatever data was printed up to the kill point, and the errors will
// indicate that the process got terminated.
//
// For files, the reader returned will not contain more than appconfig.MaxStdoutLength and appconfig.MaxStderrLength respectively
// so if the caller needs to process more output than that, it should open its own reader on the output files.
//
// For byte buffer output, the reader will be a reader over the buffer, which will accumulate the entire output.  Be careful
// not to use the byte buffer approach for extremely large output (or unknown output) because it could take up a large amount
// of memory.
func (ShellCommandExecuter) Execute(
	context context.T,
	workingDir string,
	stdoutFilePath string,
	stderrFilePath string,
	cancelFlag task.CancelFlag,
	executionTimeout int,
	commandName string,
	commandArguments []string,
	envVars map[string]string,
) (stdout io.Reader, stderr io.Reader, exitCode int, errs []error) {

	var stdoutWriter io.Writer
	var stdoutBuf *bytes.Buffer
	if stdoutFilePath != "" {
		// create stdout file
		// fix the permissions appropriately
		// Allow append so that if arrays of run command write to the same file, we keep appending to the file.
		stdoutFileWriter, err := os.OpenFile(stdoutFilePath, appconfig.FileFlagsCreateOrAppend, appconfig.ReadWriteAccess)
		if err != nil {
			return
		}
		stdoutWriter = stdoutFileWriter
		defer stdoutFileWriter.Close()
	} else {
		stdoutBuf = bytes.NewBuffer(nil)
		stdoutWriter = stdoutBuf
	}

	var stderrWriter io.Writer
	var stderrBuf *bytes.Buffer
	if stderrFilePath != "" {
		// create stderr file
		// fix the permissions appropriately
		// Allow append so that if arrays of run command write to the same file, we keep appending to the file.
		stderrFileWriter, err := os.OpenFile(stderrFilePath, appconfig.FileFlagsCreateOrAppend, appconfig.ReadWriteAccess)
		if err != nil {
			return
		}
		stderrWriter = stderrFileWriter
		defer stderrFileWriter.Close() // ExecuteCommand creates a copy of the handle
	} else {
		stderrBuf = bytes.NewBuffer(nil)
		stderrWriter = stderrBuf
	}

	// NOTE: Regarding the defer close of the file writers.
	// Technically, closing the files should happen after ExecuteCommand and before opening the files for reading.
	// In this case, there is no need for that because the child process inherits copies of the file handles and does
	// the actual writing to the files. So, when using files, it does not matter when we close our copies of the file
	// writers as long as it is after the process starts.

	var err error
	exitCode, err = ExecuteCommand(context, cancelFlag, workingDir, stdoutWriter, stderrWriter, executionTimeout, commandName, commandArguments, envVars)
	if err != nil {
		errs = append(errs, err)
	}

	// create reader from stdout, if it exist, otherwise use the buffer
	if fileutil.Exists(stdoutFilePath) {
		stdoutReader, err := os.Open(stdoutFilePath)
		if err != nil {
			// some unexpected error (file should exist)
			errs = append(errs, err)
		}
		defer stdoutReader.Close()
		stdoutString, _ := ioutil.ReadAll(io.LimitReader(stdoutReader, appconfig.MaxStdoutLength))
		stdout = bytes.NewReader([]byte(stdoutString))
	} else {
		stdout = bytes.NewReader(stdoutBuf.Bytes())
	}

	// create reader from stderr, if it exist, otherwise use the buffer
	if fileutil.Exists(stderrFilePath) {
		stderrReader, err := os.Open(stderrFilePath)
		if err != nil {
			// some unexpected error (file should exist)
			errs = append(errs, err)
		}
		defer stderrReader.Close()
		stderrString, _ := ioutil.ReadAll(io.LimitReader(stderrReader, appconfig.MaxStderrLength))
		stderr = bytes.NewReader([]byte(stderrString))
	} else {
		stderr = bytes.NewReader(stderrBuf.Bytes())
	}

	return
}

// NewExecute executes a list of shell commands in the given working directory and provides the stdout and stderr writers.
func (ShellCommandExecuter) NewExecute(
	context context.T,
	workingDir string,
	stdoutWriter io.Writer,
	stderrWriter io.Writer,
	cancelFlag task.CancelFlag,
	executionTimeout int,
	commandName string,
	commandArguments []string,
	envVars map[string]string,
) (exitCode int, err error) {
	exitCode, err = ExecuteCommand(context, cancelFlag, workingDir, stdoutWriter, stderrWriter, executionTimeout, commandName, commandArguments, envVars)
	return
}

// StartExe starts a list of shell commands in the given working directory.
// Returns process started, an exit code (0 if successfully launch, 1 if error launching process), and a set of errors.
// The errors need not be fatal - the output streams may still have data
// even though some errors are reported. For example, if the command got killed while executing,
// the streams will have whatever data was printed up to the kill point, and the errors will
// indicate that the process got terminated.
func (ShellCommandExecuter) StartExe(
	context context.T,
	workingDir string,
	stdoutWriter io.Writer,
	stderrWriter io.Writer,
	cancelFlag task.CancelFlag,
	commandName string,
	commandArguments []string,
) (process *os.Process, exitCode int, err error) {
	process, exitCode, err = StartCommand(context, cancelFlag, workingDir, stdoutWriter, stderrWriter, commandName, commandArguments)
	return
}

// CreateScriptFile creates a script containing the given commands.
func CreateScriptFile(scriptPath string, commands []string) (err error) {
	// create script
	file, err := os.Create(scriptPath)
	if err != nil {
		return
	}
	defer file.Close()

	// write commands
	_, err = file.WriteString(strings.Join(commands, "\n") + "\n")
	if err != nil {
		return
	}
	return
}

// Wrapper around a writer (such as a file) that provides a way to stop writing to the file
// This allows us to disconnect the writer on cancel or timeout even if the process is running and writing a large volume of output
type cancellableWriter struct {
	baseWriter    io.Writer
	cancelChannel chan bool
	cancelled     bool
}

func newWriter(writer io.Writer) (*cancellableWriter, chan bool) {
	newCancelChannel := make(chan bool, 1)
	return &cancellableWriter{
		baseWriter:    writer,
		cancelChannel: newCancelChannel,
	}, newCancelChannel
}

func (r *cancellableWriter) Write(p []byte) (n int, err error) {
	if r.cancelled {
		runtime.Gosched()
		return 0, io.ErrClosedPipe
	}
	select {
	case <-r.cancelChannel:
		r.cancelled = true
		return 0, io.ErrClosedPipe
	default:
		n, err = r.baseWriter.Write(p)
		// Necessary to prevent a busy loop from a process writing massive amounts of output from starving a timeout timer
		// Yield after the write to prevent another process from interrupting the write to the underlying writer
		runtime.Gosched()
		return
	}
}

// ExecuteCommand executes the given commands using the given working directory.
// Standard output and standard error are sent to the given writers.
func ExecuteCommand(
	context context.T,
	cancelFlag task.CancelFlag,
	workingDir string,
	stdoutWriter io.Writer,
	stderrWriter io.Writer,
	executionTimeout int,
	commandName string,
	commandArguments []string,
	envVars map[string]string,
) (exitCode int, err error) {
	log := context.Log()

	stdoutInterruptable, stopStdout := newWriter(stdoutWriter)
	stderrInterruptable, stopStderr := newWriter(stderrWriter)

	command := exec.Command(commandName, commandArguments...)
	command.Dir = workingDir
	exitCode = 0

	// If we assign the writers directly, the command may never exit even though a command.Process.Wait() does due to https://github.com/golang/go/issues/13155
	// However, if we run goroutines to copy from the StdoutPipe and StderrPipe we may lose the last write.
	command.Stdout = stdoutInterruptable
	command.Stderr = stderrInterruptable
	/*
		stdoutPipe, err := command.StdoutPipe()
		if err != nil {
			return 1, err
		}
		stderrPipe, err := command.StderrPipe()
		if err != nil {
			return 1, err
		}
		go io.Copy(stdoutInterruptable, stdoutPipe)
		go io.Copy(stderrInterruptable, stderrPipe)
	*/

	// configure OS-specific process settings
	prepareProcess(command)

	// configure environment variables
	prepareEnvironment(context, command, envVars)

	log.Debugf("Running in directory %v, command: %v %v", workingDir, commandName, commandArguments)

	quiesce()
	if err = command.Start(); err != nil {
		log.Error("error occurred starting the command", err)
		exitCode = 1
		return
	}

	signal := timeoutSignal{}

	cancelled := make(chan bool, 1)
	go func() {
		cancelState := cancelFlag.Wait()
		if cancelFlag.Canceled() {
			cancelled <- true
			log.Debug("Cancel flag set to cancelled")
		}
		log.Debugf("Cancel flag set to %v", cancelState)
	}()

	done := make(chan error, 1)
	go func() {
		done <- command.Wait()
	}()

	select {
	case <-time.After(time.Duration(executionTimeout) * time.Second):
		stopStdout <- true
		stopStderr <- true
		if err = killProcess(command.Process, &signal); err != nil {
			exitCode = 1
			log.Error(err)
		} else {
			// set appropriate exit code based on timeout
			exitCode = appconfig.CommandStoppedPreemptivelyExitCode
			err = &exec.ExitError{Stderr: []byte("Process timed out")}
			log.Infof("The execution of command was timedout.")
		}
	case <-cancelled:
		// task has been asked to cancel, kill process
		log.Debug("Process cancelled. Attempting to stop process.")
		stopStdout <- true
		stopStderr <- true
		if err = killProcess(command.Process, &signal); err != nil {
			exitCode = 1
			log.Error(err)
		} else {
			// set appropriate exit code based on cancel
			exitCode = appconfig.CommandStoppedPreemptivelyExitCode
			err = &exec.ExitError{Stderr: []byte("Cancelled process")}
			log.Infof("The execution of command was cancelled.")
		}
	case err = <-done:
		log.Debug("Process completed.")
		if err != nil {
			exitCode = 1
			log.Debugf("command returned error %v", err)
			if exiterr, ok := err.(*exec.ExitError); ok {
				// The program has exited with an exit code != 0
				if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
					exitCode = status.ExitStatus()

					if signal.execInterruptedOnWindows {
						log.Debug("command interrupted by cancel or timeout")
						exitCode = -1
					}

					// First try to handle Cancel and Timeout scenarios
					// SIGKILL will result in an exitcode of -1
					if exitCode == -1 {
						if cancelFlag.Canceled() {
							// set appropriate exit code based on cancel or timeout
							exitCode = appconfig.CommandStoppedPreemptivelyExitCode
							log.Infof("The execution of command was cancelled.")
						}
					} else {
						log.Infof("The execution of command returned Exit Status: %d", exitCode)
					}
				}
			}
		} else {
			// check if cancellation or timeout failed to kill the process
			// This will not occur as we do a SIGKILL, which is not recoverable.
			if cancelFlag.Canceled() {
				// This is when the cancellation failed and the command completed successfully
				log.Errorf("the cancellation failed to stop the process.")
				// do not return as the command could have been cancelled and also timedout
			}
		}
	}
	return
}

// StartCommand starts the given commands using the given working directory.
// Standard output and standard error are sent to the given writers.
func StartCommand(context context.T,
	cancelFlag task.CancelFlag,
	workingDir string,
	stdoutWriter io.Writer,
	stderrWriter io.Writer,
	commandName string,
	commandArguments []string,
) (process *os.Process, exitCode int, err error) {
	log := context.Log()
	command := exec.Command(commandName, commandArguments...)
	command.Dir = workingDir
	command.Stdout = stdoutWriter
	command.Stderr = stderrWriter
	exitCode = 0

	// configure OS-specific process settings
	prepareProcess(command)

	// configure environment variables
	prepareEnvironment(context, command, make(map[string]string))

	log.Debugf("Running in directory %v, command: %v %v", workingDir, commandName, commandArguments)

	quiesce()
	if err = command.Start(); err != nil {
		log.Error("error occurred starting the command: ", err)
		exitCode = 1
		return
	}

	process = command.Process
	signal := timeoutSignal{}
	// Async commands don't use cancellable writers because we rely on the process having an independent copy of
	// the writer when it is a file handle and when the cancellable writer is assigned, it doesn't (by design) give
	// a reference to the file handle to the process
	cancelChannel := make(chan bool, 2)
	go killProcessOnCancel(log, command, cancelChannel, cancelChannel, cancelFlag, &signal)

	return
}

// killProcessOnCancel waits for a cancel request.
// If a cancel request is received, this method kills the underlying
// process of the command. This will unblock the command.Wait() call.
// If the task completed successfully this method returns with no action.
func killProcessOnCancel(log log.T, command *exec.Cmd, cancelStdout chan bool, cancelStderr chan bool, cancelFlag task.CancelFlag, signal *timeoutSignal) {
	defer func() {
		if r := recover(); r != nil {
			log.Errorf("Kill process on cancel panic: %v", r)
			log.Errorf("Stacktrace:\n%s", debug.Stack())
		}
	}()
	cancelFlag.Wait()
	if cancelFlag.Canceled() {
		log.Debug("Process cancelled. Attempting to stop process.")

		cancelStdout <- true
		cancelStderr <- true
		runtime.Gosched()

		// task has been asked to cancel, kill process
		if err := killProcess(command.Process, signal); err != nil {
			log.Error(err)
		} else {
			log.Debug("Process stopped successfully.")
		}
		return
	}
}

// prepareEnvironment adds ssm agent standard environment variables or environment variables defined by customer/other plugins to the command
func prepareEnvironment(context context.T, command *exec.Cmd, envVars map[string]string) {
	log := context.Log()
	env := os.Environ()

	for key, val := range envVars {
		env = append(env, fmtEnvVariable(key, val))
	}
	if instance, err := context.Identity().InstanceID(); err == nil {
		env = append(env, fmtEnvVariable(envVarInstanceID, instance))
	}
	if region, err := context.Identity().Region(); err == nil {
		env = append(env, fmtEnvVariable(envVarRegionName, region))
	}
	if platformName, err := platform.PlatformName(log); err == nil {
		env = append(env, fmtEnvVariable(envVarPlatformName, platformName))
	} else {
		log.Warnf("There was an error retrieving the platformName while setting the environment variables: %v", err)
	}
	if platformVersion, err := platform.PlatformVersion(log); err == nil {
		env = append(env, fmtEnvVariable(envVarPlatformVersion, platformVersion))
	} else {
		log.Warnf("There was an error retrieving the platformVersion while setting the environment variables: %v", err)
	}
	if context.AppConfig().Agent.UseDualStackEndpoint {
		env = append(env, fmtEnvVariable(envVarUseDualStackEndpoint, "true"))
	} else {
		env = append(env, fmtEnvVariable(envVarUseDualStackEndpoint, "false"))
	}

	command.Env = env

	// Running powershell on linux erquired the HOME env variable to be set and to remove the TERM env variable
	validateEnvironmentVariables(command)
}

// fmtEnvVariable creates the string to append to the current set of environment variables.
func fmtEnvVariable(name string, val string) string {
	return fmt.Sprintf("%s=%s", name, val)
}

// QuoteShString replaces the quote
func QuoteShString(str string) (result string) {
	// Simple quote replacement for now
	return fmt.Sprintf("'%v'", strings.Replace(str, "'", "'\\''", -1))
}

// QuotePsString replaces the quote
func QuotePsString(str string) (result string) {
	// Simple quote replacement for now
	str = strings.Replace(str, "`", "``", -1)
	return fmt.Sprintf("\"%v\"", strings.Replace(str, "\"", "`\"", -1))
}
