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

//go:build (freebsd || linux || netbsd || openbsd) && integration
// +build freebsd linux netbsd openbsd
// +build integration

// Package executers contains general purpose (shell) command executing objects.
package executers

import (
	"bytes"
	"fmt"
	"io"
	"io/ioutil"
	"os"
	"os/exec"
	"path/filepath"
	"testing"
	"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"
	contextmocks "github.com/aws/amazon-ssm-agent/agent/mocks/context"
	"github.com/aws/amazon-ssm-agent/agent/mocks/log"
	"github.com/aws/amazon-ssm-agent/agent/task"
	identityMocks "github.com/aws/amazon-ssm-agent/common/identity/mocks"
	"github.com/google/uuid"
	"github.com/stretchr/testify/assert"
)

const (
	stdoutMsg                       = "hello stdout"
	stderrMsg                       = "hello stderr"
	stdoutMsg2                      = "bye stdout"
	stderrMsg2                      = "bye stderr"
	cancelWaitTimeoutSeconds        = 3.0
	successExitCode                 = 0
	processTerminatedByUserExitCode = 137
	defaultExecutionTimeout         = 5000
	stdOutFileName                  = "stdout"
	stdErrFileName                  = "stderr"
)

type CommandInvoker func(commands []string) (stdout io.Reader, stderr io.Reader, exitCode int, errs []error)

type TestCase struct {
	Commands         []string
	ExpectedStdout   string
	ExpectedStderr   string
	ExpectedExitCode int
}

var RunCommandTestCases = []TestCase{
	// test stdout is captured
	{
		Commands:         []string{"echo", stdoutMsg},
		ExpectedStdout:   stdoutMsg + "\n",
		ExpectedStderr:   "",
		ExpectedExitCode: successExitCode,
	},
	// test stderr is captured
	{
		Commands:         []string{"awk", awkPrintToStderr(stderrMsg)},
		ExpectedStdout:   "",
		ExpectedStderr:   stderrMsg + "\n",
		ExpectedExitCode: successExitCode,
	},
	// test both stdout and stderr are captured
	{
		Commands:         []string{"sh", "-c", echoToStdout(stdoutMsg) + ";" + echoToStderr(stderrMsg)},
		ExpectedStdout:   stdoutMsg + "\n",
		ExpectedStderr:   stderrMsg + "\n",
		ExpectedExitCode: successExitCode,
	},
	// instance id environment variable is set
	{
		Commands:         []string{"sh", "-c", fmt.Sprintf("echo $%v", envVarInstanceID)},
		ExpectedStdout:   testInstanceID + "\n",
		ExpectedStderr:   "",
		ExpectedExitCode: successExitCode,
	},
	// region name environment variable is set
	{
		Commands:         []string{"sh", "-c", fmt.Sprintf("echo $%v", envVarRegionName)},
		ExpectedStdout:   testRegionName + "\n",
		ExpectedStderr:   "",
		ExpectedExitCode: successExitCode,
	},
}

var RunCommandCancelTestCases = []TestCase{
	// test stdout and stderr are captured
	{
		Commands:         []string{"sleep", "10"},
		ExpectedStdout:   "",
		ExpectedStderr:   "",
		ExpectedExitCode: processTerminatedByUserExitCode,
	},
}

var RunCommandAsyncTestCases = []TestCase{
	// test both stdout and stderr are captured
	{
		Commands:         []string{"sh", "-c", echoToStdout(stdoutMsg) + ";" + echoToStderr(stderrMsg)},
		ExpectedStdout:   stdoutMsg + "\n",
		ExpectedStderr:   stderrMsg + "\n",
		ExpectedExitCode: successExitCode,
	},
	// test both stdout and stderr are captured if there is a delay between multiple outputs
	{
		Commands: []string{
			"sh",
			"-c",
			echoToStdout(stdoutMsg) + ";" + echoToStderr(stderrMsg) + ";" + "sleep 1" + ";" + echoToStdout(stdoutMsg2) + ";" + echoToStderr(stderrMsg2),
		},
		ExpectedStdout:   stdoutMsg + "\n" + stdoutMsg2 + "\n",
		ExpectedStderr:   stderrMsg + "\n" + stderrMsg2 + "\n",
		ExpectedExitCode: successExitCode,
	},
}

var ShellCommandExecuterTestCases = []TestCase{
	// test stdout and stderr are captured
	{
		Commands: []string{
			"sh",
			"-c",
			echoToStdout(stdoutMsg) + ";" + echoToStderr(stderrMsg),
		},
		ExpectedStdout:   stdoutMsg + "\n",
		ExpectedStderr:   stderrMsg + "\n",
		ExpectedExitCode: successExitCode,
	},
	// test both stdout and stderr are captured if there is a delay between multiple outputs
	{
		Commands: []string{
			"sh",
			"-c",
			echoToStdout(stdoutMsg) + ";" + echoToStderr(stderrMsg) + ";" + "sleep 1" + ";" + echoToStdout(stdoutMsg2) + ";" + echoToStderr(stderrMsg2),
		},
		ExpectedStdout:   stdoutMsg + "\n" + stdoutMsg2 + "\n",
		ExpectedStderr:   stderrMsg + "\n" + stderrMsg2 + "\n",
		ExpectedExitCode: successExitCode,
	},
}

var ShellCommandExecuterCancelTestCases = []TestCase{
	{
		Commands: []string{
			"sh",
			"-c",
			echoToStdout(stdoutMsg) + ";" + echoToStderr(stderrMsg) + ";" + "sleep 10" + ";" + echoToStdout("bye stdout") + ";" + echoToStderr("bye stderr"),
		},
		ExpectedStdout:   stdoutMsg + "\n",
		ExpectedStderr:   stderrMsg + "\n",
		ExpectedExitCode: processTerminatedByUserExitCode,
	},
}

var envVars = map[string]string{
	"key1": "val1",
	"key2": "val2",
}

var logger = log.NewMockLog()
var config = appconfig.DefaultConfig()

func getTestContext() context.T {
	identityMock := &identityMocks.IAgentIdentity{}
	identityMock.On("Region").Return(testRegionName, nil)
	identityMock.On("InstanceID").Return(testInstanceID, nil)

	contextMock := &contextmocks.Mock{}
	contextMock.On("AppConfig").Return(config)
	contextMock.On("Identity").Return(identityMock)
	contextMock.On("Log").Return(logger)

	return contextMock
}

// TestRunCommand tests that RunCommand (in memory call, no local script or output files) works correctly.
func TestRunCommand(t *testing.T) {
	for _, testCase := range RunCommandTestCases {
		runCommandInvoker, _ := prepareTestRunCommand(t, make(map[string]string))
		testCommandInvoker(t, runCommandInvoker, testCase)
	}

	for _, testCase := range RunCommandTestCases {
		runCommandInvoker, _ := prepareTestRunCommand(t, envVars)
		testCommandInvoker(t, runCommandInvoker, testCase)
	}
}

// TestRunCommand_cancel tests that RunCommand (in memory call, no local script or output files) is canceled correctly.
func TestRunCommand_cancel(t *testing.T) {
	for _, testCase := range RunCommandCancelTestCases {
		runCommandInvoker, cancelFlag := prepareTestRunCommand(t, make(map[string]string))
		testCommandInvokerCancel(t, runCommandInvoker, cancelFlag, testCase)
	}

	for _, testCase := range RunCommandCancelTestCases {
		runCommandInvoker, cancelFlag := prepareTestRunCommand(t, envVars)
		testCommandInvokerCancel(t, runCommandInvoker, cancelFlag, testCase)
	}
}

// TestRunCommand_cancel tests that RunCommand (in memory call, no local script or output files) is canceled correctly.
func TestRunCommand_async(t *testing.T) {
	for _, testCase := range RunCommandAsyncTestCases {
		startCommandInvoker, cancelFlag := prepareTestStartCommand(t)
		testCommandInvoker(t, startCommandInvoker, testCase)
		testCommandInvokerShutdown(t, startCommandInvoker, cancelFlag, testCase)
	}
}

// TestShellCommandExecuter tests that ShellCommandExecuter (creates local script, redirects outputs to files) works
func TestShellCommandExecuter(t *testing.T) {
	runTest := func(testCase TestCase) {
		orchestrationDir, shCommandExecuterInvoker, _ := prepareTestShellCommandExecuter(t, make(map[string]string))
		defer fileutil.DeleteDirectory(orchestrationDir)
		testCommandInvoker(t, shCommandExecuterInvoker, testCase)
	}
	runTestWithEnvironment := func(testCase TestCase) {
		orchestrationDir, shCommandExecuterInvoker, _ := prepareTestShellCommandExecuter(t, envVars)
		defer fileutil.DeleteDirectory(orchestrationDir)
		testCommandInvoker(t, shCommandExecuterInvoker, testCase)
	}
	runTestShutdown := func(testCase TestCase) {
		orchestrationDir, shCommandExecuterInvoker, cancelFlag := prepareTestShellCommandExecuter(t, make(map[string]string))
		defer fileutil.DeleteDirectory(orchestrationDir)
		testCommandInvokerShutdown(t, shCommandExecuterInvoker, cancelFlag, testCase)
	}

	for _, testCase := range ShellCommandExecuterTestCases {
		runTest(testCase)
		runTestWithEnvironment(testCase)
		runTestShutdown(testCase)
	}
}

// TestShellCommandExecuter_cancel tests that ShellCommandExecuter (creates local script, redirects outputs to files) is canceled correctly
func TestShellCommandExecuter_cancel(t *testing.T) {
	runTest := func(testCase TestCase) {
		orchestrationDir, shCommandExecuterInvoker, cancelFlag := prepareTestShellCommandExecuter(t, make(map[string]string))
		defer fileutil.DeleteDirectory(orchestrationDir)
		testCommandInvokerCancel(t, shCommandExecuterInvoker, cancelFlag, testCase)
	}

	for _, testCase := range ShellCommandExecuterCancelTestCases {
		runTest(testCase)
	}
}

func testCommandInvoker(t *testing.T, invoke CommandInvoker, testCase TestCase) {
	logger.Infof("testCommandInvoker")
	stdout, stderr, exitCode, errs := invoke(testCase.Commands)
	logger.Infof("errors %v", errs)

	assert.Equal(t, 0, len(errs))
	assertReaderEquals(t, testCase.ExpectedStdout, stdout)
	assertReaderEquals(t, testCase.ExpectedStderr, stderr)
	assert.Equal(t, exitCode, testCase.ExpectedExitCode)
}

// using long-running testcases for this test
func testCommandInvokerShutdown(t *testing.T, invoke CommandInvoker, cancelFlag task.CancelFlag, testCase TestCase) {
	go func() {
		time.Sleep(100 * time.Millisecond)
		cancelFlag.Set(task.ShutDown)
	}()
	logger.Infof("testCommandInvoker with shutdown")
	stdout, stderr, exitCode, errs := invoke(testCase.Commands)
	logger.Infof("errors %v", errs)
	// command should be uninterferred
	assert.Equal(t, 0, len(errs))
	assertReaderEquals(t, testCase.ExpectedStdout, stdout)
	assertReaderEquals(t, testCase.ExpectedStderr, stderr)
	assert.Equal(t, exitCode, testCase.ExpectedExitCode)
}

func testCommandInvokerCancel(t *testing.T, invoke CommandInvoker, cancelFlag task.CancelFlag, testCase TestCase) {
	go func() {
		time.Sleep(100 * time.Millisecond)
		cancelFlag.Set(task.Canceled)
	}()

	start := time.Now().UTC()
	stdout, stderr, exitCode, errs := invoke(testCase.Commands)
	duration := time.Since(start)

	// test that the job returned before the normal time
	assert.True(t, duration.Seconds() <= cancelWaitTimeoutSeconds, "The command took too long to kill (%v)!", duration)

	// test that we receive kill exception
	assert.Equal(t, len(errs), 1)
	assert.IsType(t, &exec.ExitError{}, errs[0])

	assertReaderEquals(t, testCase.ExpectedStdout, stdout)
	assertReaderEquals(t, testCase.ExpectedStderr, stderr)

	assert.Equal(t, exitCode, testCase.ExpectedExitCode)
}

// echoToStdout returns a shell command that outputs a message to the standard output stream.
func echoToStdout(msg string) string {
	return fmt.Sprintf(`echo "%v"`, msg)
}

// echoToStderr returns a shell command that outputs a message to the standard error stream.
func echoToStderr(msg string) string {
	return fmt.Sprintf("awk '%v'", awkPrintToStderr(msg))
}

// awkPrintToStderr returns an awk script that outputs a message to the standard error stream.
func awkPrintToStderr(stderrMsg string) string {
	return fmt.Sprintf(`BEGIN{print "%v" > "/dev/stderr"}`, stderrMsg)
}

// prepareTestShellCommandExecuter contains boiler plate code for testing shell executer, to avoid duplication.
func prepareTestShellCommandExecuter(t *testing.T, envVars map[string]string) (orchestrationDir string, commandInvoker CommandInvoker, cancelFlag task.CancelFlag) {
	// create shell executer, cancel flag, working dir
	sh := ShellCommandExecuter{}
	cancelFlag = task.NewChanneledCancelFlag()
	orchestrationDir, err := ioutil.TempDir("", "TestShellExecute")
	if err != nil {
		t.Fatal(err)
	}
	workDir := "."

	// commandInvoker calls the shell then sets the state of the flag to completed
	commandInvoker = func(commands []string) (stdout io.Reader, stderr io.Reader, exitCode int, errs []error) {
		defer cancelFlag.Set(task.Completed)
		scriptPath := filepath.Join(orchestrationDir, appconfig.RunCommandScriptName)
		stdoutFilePath := filepath.Join(orchestrationDir, stdOutFileName)
		stderrFilePath := filepath.Join(orchestrationDir, stdErrFileName)

		// Used to mimic the process
		CreateScriptFile(scriptPath, commands)
		return sh.Execute(getTestContext(), workDir, stdoutFilePath, stderrFilePath, cancelFlag, defaultExecutionTimeout, commands[0], commands[1:], envVars)
	}

	return
}

// prepareTestRunCommand contains boiler plate code for testing run command, to avoid duplication.
func prepareTestRunCommand(t *testing.T, envVars map[string]string) (commandInvoker CommandInvoker, cancelFlag task.CancelFlag) {
	cancelFlag = task.NewChanneledCancelFlag()
	commandInvoker = func(commands []string) (stdout io.Reader, stderr io.Reader, exitCode int, errs []error) {
		defer cancelFlag.Set(task.Completed)

		// run command and output to in-memory buffers
		var stdoutBuf bytes.Buffer
		var stderrBuf bytes.Buffer
		workDir := "."
		tempExitCode, err := ExecuteCommand(getTestContext(), cancelFlag, workDir, &stdoutBuf, &stderrBuf, defaultExecutionTimeout, commands[0], commands[1:], envVars)
		exitCode = tempExitCode

		// record error if any
		if err != nil {
			errs = append(errs, err)
		} else {
			errs = []error{}
		}

		// return readers that read from in-memory buffers
		stdout = bytes.NewReader(stdoutBuf.Bytes())
		stderr = bytes.NewReader(stderrBuf.Bytes())
		return
	}
	return
}

// prepareTestStartCommand contains boiler plate code for testing start command, to avoid duplication.
func prepareTestStartCommand(t *testing.T) (commandInvoker CommandInvoker, cancelFlag task.CancelFlag) {
	cancelFlag = task.NewChanneledCancelFlag()
	commandInvoker = func(commands []string) (stdout io.Reader, stderr io.Reader, exitCode int, errs []error) {
		// run command and output to temp files
		orchestrationDir, err := ioutil.TempDir("", "TestAsyncExecute")
		if err != nil {
			t.Fatal(err)
		}
		defer fileutil.DeleteDirectory(orchestrationDir)

		stdoutFilePath := filepath.Join(orchestrationDir, uuid.New().String())
		stdoutWriter, err := os.OpenFile(stdoutFilePath, appconfig.FileFlagsCreateOrAppend, appconfig.ReadWriteAccess)
		if err != nil {
			t.Fatal(err)
		}
		defer os.Remove(stdoutFilePath)

		stderrFilePath := filepath.Join(orchestrationDir, uuid.New().String())
		stderrWriter, err := os.OpenFile(stderrFilePath, appconfig.FileFlagsCreateOrAppend, appconfig.ReadWriteAccess)
		if err != nil {
			t.Fatal(err)
		}
		defer os.Remove(stdoutFilePath)

		workDir := "."
		process, tempExitCode, err := StartCommand(getTestContext(), cancelFlag, workDir, stdoutWriter, stderrWriter, commands[0], commands[1:])
		stdoutWriter.Close()
		stderrWriter.Close()
		exitCode = tempExitCode

		// record error if any
		if err != nil {
			errs = append(errs, err)
		} else {
			errs = []error{}
		}

		// wait for async process to finish
		process.Wait()

		// read temp files before they are deleted and return buffers
		stdoutBuf := bytes.NewBuffer(nil)
		stdoutReader, _ := os.Open(stdoutFilePath)
		defer stdoutReader.Close()
		_, err = io.Copy(stdoutBuf, stdoutReader)
		if err != nil {
			t.Fatal(err)
		}

		stderrBuf := bytes.NewBuffer(nil)
		stderrReader, _ := os.Open(stderrFilePath)
		defer stderrReader.Close()
		_, err = io.Copy(stderrBuf, stderrReader)
		if err != nil {
			t.Fatal(err)
		}

		stdout = bytes.NewReader(stdoutBuf.Bytes())
		stderr = bytes.NewReader(stderrBuf.Bytes())
		return
	}
	return
}

// assertReaderEquals is a convenience method that reads everything from a Reader then compares to a string.
func assertReaderEquals(t *testing.T, expected string, reader io.Reader) {
	actual, err := ioutil.ReadAll(reader)
	assert.Nil(t, err)
	assert.Equal(t, expected, string(actual))
}

// TestCreateScriptFile tests that CreateScriptFile correctly returns error (to avoid shadowing bug).
func TestCreateScriptFile(t *testing.T) {
	err := CreateScriptFile("/someDir,ThatDoes:Not#Exist/scriptName.sh", []string{"echo hello"})
	assert.NotNil(t, err)
}
