// 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 windows
// +build windows

// Package updateec2config implements the UpdateEC2Config plugin.
package updateec2config

import (
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/aws/amazon-ssm-agent/agent/appconfig"
	"github.com/aws/amazon-ssm-agent/agent/context"
	"github.com/aws/amazon-ssm-agent/agent/contracts"
	"github.com/aws/amazon-ssm-agent/agent/fileutil"
	"github.com/aws/amazon-ssm-agent/agent/fileutil/artifact"
	"github.com/aws/amazon-ssm-agent/agent/framework/processor/executer/iohandler"
	"github.com/aws/amazon-ssm-agent/agent/jsonutil"
	"github.com/aws/amazon-ssm-agent/agent/log"
	"github.com/aws/amazon-ssm-agent/agent/plugins/pluginutil"
	"github.com/aws/amazon-ssm-agent/agent/s3util"
	"github.com/aws/amazon-ssm-agent/agent/task"
	"github.com/aws/amazon-ssm-agent/agent/updateutil"
	"github.com/aws/amazon-ssm-agent/agent/updateutil/updateconstants"
	"github.com/aws/amazon-ssm-agent/agent/updateutil/updateinfo"
	"github.com/aws/amazon-ssm-agent/agent/versionutil"
)

var (
	ec2ConfigVersionPattern = regexp.MustCompile("[.0-9]+")
	sourcePattern           = regexp.MustCompile(`^https?:[\/][\/][\w.-]+:?[\/\w.-]+$`)
)

// Plugin is the type for the RunCommand plugin.
type Plugin struct {
	context context.T
	// Manifest location
	ManifestLocation string
}

// UpdatePluginInput represents one set of commands executed by the UpdateAgent plugin.
type UpdatePluginInput struct {
	contracts.PluginInput
	AgentName      string `json:"agentName"`
	AllowDowngrade string `json:"allowDowngrade"`
	TargetVersion  string `json:"targetVersion"`
	Source         string `json:"source"`
	UpdaterName    string `json:"-"`
}

// UpdatePluginConfig is used for initializing update agent plugin with default values
type UpdatePluginConfig struct {
	ManifestLocation string
}

type updateManager struct {
	context context.T
}

// TODO move the interface and structs into a separate file to reduce the size of this main file
// pluginHelper is a interface that has helper functions for update manager
type pluginHelper interface {
	generateSetupUpdateCmd(log log.T,
		manifest *Manifest,
		pluginInput *UpdatePluginInput,
		region string,
		updaterPath string,
		messageID string,
		agentVersion string) (cmd string, err error)

	generateUpdateCmd(log log.T,
		updaterPath string) (cmd string, err error)

	downloadManifest(context context.T,
		util updateutil.T,
		pluginInput *UpdatePluginInput,
		info updateinfo.T,
		out iohandler.IOHandler) (manifest *Manifest, err error)

	downloadUpdater(context context.T,
		region string,
		updaterPackageName string,
		manifest *Manifest,
		out iohandler.IOHandler,
		info updateinfo.T) (version string, err error)

	validateUpdate(log log.T,
		pluginInput *UpdatePluginInput,
		info updateinfo.T,
		manifest *Manifest,
		out iohandler.IOHandler, version string) (noNeedToUpdate bool, err error)

	loadUpdateContext(log log.T,
		path string) (updateContext *UpdateContextFile, err error)
}

// Assign method to global variables to allow unittest to override
var fileDownload = artifact.Download
var fileUncompress = fileutil.Uncompress
var updateAgent = runUpdateAgent
var mkDirAll = os.MkdirAll

// NewPlugin returns a new instance of the plugin.
func NewPlugin(context context.T, updatePluginConfig UpdatePluginConfig) (*Plugin, error) {
	return &Plugin{
		context,
		updatePluginConfig.ManifestLocation,
	}, nil
}

// getEC2ConfigCurrentVersion gets the current version of EC2 config installed on the platform
func getEC2ConfigCurrentVersion(log log.T) (res string, err error) {

	ec2ConfigCLICmd := filepath.Join(os.Getenv("ProgramFiles"), "Amazon", "Ec2ConfigService", "ec2config-cli.exe")
	var cmdOut []byte
	cmdArgs := []string{"--ec2config-version"}

	if cmdOut, err = exec.Command(ec2ConfigCLICmd, cmdArgs...).CombinedOutput(); err != nil {
		log.Errorf("There was an error running %v %v. Error = %s", ec2ConfigCLICmd, cmdArgs, err)
		return res, err
	}

	data := string(cmdOut)
	matches := ec2ConfigVersionPattern.FindStringSubmatch(data)
	if matches != nil {
		version := matches[0]
		log.Debugf("GetEC2ConfigCurrentVersion: version after trimming space is %s", version)
		return version, err
	}

	return res, fmt.Errorf("Ec2Config version cannot be determined")
}

// TODO add to update manager to merge codes between update agent and update ssm agent to avoid duplication
// updateAgent downloads the installation packages and update the agent
func runUpdateAgent(
	p *Plugin,
	config contracts.Configuration,
	context context.T,
	manager pluginHelper,
	util updateutil.T,
	rawPluginInput interface{},
	output iohandler.IOHandler,
	startTime time.Time) {
	var pluginInput UpdatePluginInput
	var updatecontext *UpdateContextFile = new(UpdateContextFile)
	var log log.T = context.Log()
	var err error
	var info updateinfo.T
	var region string

	pluginConfig := iohandler.DefaultOutputConfig()

	if err = jsonutil.Remarshal(rawPluginInput, &pluginInput); err != nil {
		output.MarkAsFailed(fmt.Errorf("invalid format in plugin properties %v;\nerror %v", rawPluginInput, err))
		return
	}

	log.Debugf("[runUpdateAgent]: Will now create the instance info")
	if info, err = updateinfo.New(context); err != nil {
		output.MarkAsFailed(err)
		return
	}

	log.Debugf("[runUpdateAgent]: Will now get region information")
	if region, err = context.Identity().Region(); err != nil {
		output.MarkAsFailed(err)
		return
	}

	//Use default manifest location is the override is not present
	if len(pluginInput.Source) == 0 {
		pluginInput.Source = p.ManifestLocation
	}
	//Calculate manifest location base on current instance's region
	pluginInput.Source = strings.Replace(pluginInput.Source, updateconstants.RegionHolder, region, -1)

	//Check the format of the source input and revert to default if not
	if err = validateSource(pluginInput); err != nil {
		log.Warnf("Invalid url provided at %v (url must be https/http link) falling back to default manifest location", pluginInput.Source)
		pluginInput.Source = p.ManifestLocation
		pluginInput.Source = strings.Replace(pluginInput.Source, updateconstants.RegionHolder, region, -1)
	}

	//Calculate updater package name base on agent name
	pluginInput.UpdaterName = pluginInput.AgentName + updateconstants.UpdaterPackageNamePrefix
	//Generate update output
	targetVersion := pluginInput.TargetVersion
	if len(targetVersion) == 0 {
		targetVersion = "latest"
	}

	var agentVersion string
	log.Debugf("[runUpdateAgent]: getting the current version of ec2config ")
	if agentVersion, err = getEC2ConfigCurrentVersion(log); err != nil {
		output.MarkAsFailed(err)
		return
	}

	// If disk space is not sufficient, fail the update to prevent installation and notify user in output
	// If loading disk space fails, continue to update (agent update is backed by rollback handler)
	log.Infof("Checking available disk space ...")
	if isDiskSpaceSufficient, err := util.IsDiskSpaceSufficientForUpdate(log); err == nil && !isDiskSpaceSufficient {
		output.MarkAsFailed(errors.New("Insufficient available disk space"))
		return
	}

	output.AppendInfof("Updating %v from %v to %v",
		pluginInput.AgentName,
		agentVersion,
		targetVersion)

	if updatecontext, err = manager.loadUpdateContext(log, appconfig.UpdateContextFilePath); err != nil {
		log.Error("Update context load error: ", err)
	}

	//Update only when no other update process is running
	if updatecontext.UpdateState != notStarted && updatecontext.UpdateState != completed { //update process is running
		output.MarkAsFailed(fmt.Errorf("Another update in progress, try again later"))
	} else { //if update process is not running

		//Download manifest file
		manifest, downloadErr := manager.downloadManifest(context, util, &pluginInput, info, output)
		if downloadErr != nil {
			output.MarkAsFailed(downloadErr)
			return
		}

		//Validate update details
		noNeedToUpdate := false
		if noNeedToUpdate, err = manager.validateUpdate(log, &pluginInput, info, manifest, output, agentVersion); noNeedToUpdate {
			if err != nil {
				output.MarkAsFailed(err)
			}
			return
		}

		//Download updater and retrieve the version number
		updaterVersion := ""
		if updaterVersion, err = manager.downloadUpdater(
			context, region, pluginInput.UpdaterName, manifest, output, info); err != nil {
			output.MarkAsFailed(err)
			return
		}

		//Generate update command base on the update detail
		cmd := ""
		if cmd, err = manager.generateSetupUpdateCmd(log,
			manifest,
			&pluginInput,
			region,
			UpdaterFilePath(appconfig.EC2UpdateArtifactsRoot, pluginInput.UpdaterName, updaterVersion),
			config.MessageId,
			agentVersion); err != nil {
			output.MarkAsFailed(err)
			return
		}
		log.Debugf("Setup update command %v", cmd)

		//Save update plugin result to local file, updater will read it during agent update
		updatePluginResult := &updateutil.UpdatePluginResult{
			StandOut:      output.GetStdout(),
			StartDateTime: startTime,
		}
		if err = util.SaveUpdatePluginResult(log, appconfig.EC2UpdateArtifactsRoot, updatePluginResult); err != nil {
			output.MarkAsFailed(err)
			return
		}

		workDir := updateutil.UpdateArtifactFolder(
			appconfig.EC2UpdateArtifactsRoot, pluginInput.UpdaterName, updaterVersion)

		//Command to setup the installation
		commandInput := &updateutil.CommandExecutionSettings{
			Log:         log,
			Cmd:         strings.Fields(cmd),
			WorkingDir:  workDir,
			UpdaterRoot: appconfig.EC2UpdateArtifactsRoot,
			StdOut:      pluginConfig.StdoutFileName,
			StdErr:      pluginConfig.StderrFileName,
			IsAsync:     false,
		}
		if _, _, err = util.ExeCommand(commandInput); err != nil {
			output.MarkAsFailed(err)
			return
		}
		cmd = ""

		log.Infof("Start Installation")
		log.Infof("Hand over update process to %v", pluginInput.UpdaterName)
		//Execute updater, hand over the update process
		if cmd, err = manager.generateUpdateCmd(log,
			UpdaterFilePath(appconfig.EC2UpdateArtifactsRoot, pluginInput.UpdaterName, updaterVersion)); err != nil {
			output.MarkAsFailed(err)
			return
		}
		log.Debugf("Setup update command %v", cmd)
		asyncCommandInput := &updateutil.CommandExecutionSettings{
			Log:         log,
			Cmd:         strings.Fields(cmd),
			WorkingDir:  workDir,
			UpdaterRoot: appconfig.EC2UpdateArtifactsRoot,
			StdOut:      pluginConfig.StdoutFileName,
			StdErr:      pluginConfig.StderrFileName,
			IsAsync:     true,
		}
		if _, _, err = util.ExeCommand(asyncCommandInput); err != nil {
			output.MarkAsFailed(err)
			return
		}
		output.MarkAsInProgress()
	}
	return
}

// TODO Create a command package for command execution
// generateSetupUpdateCmd generates cmd to setup the installation process
func (m *updateManager) generateSetupUpdateCmd(log log.T,
	manifest *Manifest,
	pluginInput *UpdatePluginInput,
	region string,
	updaterPath string,
	messageID string,
	agentVersion string) (cmd string, err error) {
	cmd = updaterPath + SetupInstallCmd //Command sent to updater to setup the installation
	source := ""
	hash := ""

	//Get download url and hash value from for the current version of ssm agent
	if source, hash, err = manifest.DownloadURLAndHash(
		EC2ConfigAgentName, agentVersion, EC2SetupFileName, S3Format, HTTPFormat, region); err != nil {
		return
	}
	cmd = updateutil.BuildUpdateCommand(cmd, SourceVersionCmd, agentVersion)
	cmd = updateutil.BuildUpdateCommand(cmd, SourceLocationCmd, source)
	cmd = updateutil.BuildUpdateCommand(cmd, SourceHashCmd, hash)

	//Get download url and hash value from for the target version of ssm agent
	if source, hash, err = manifest.DownloadURLAndHash(
		EC2ConfigAgentName, pluginInput.TargetVersion, EC2SetupFileName, S3Format, HTTPFormat, region); err != nil {
		return
	}
	cmd = updateutil.BuildUpdateCommand(cmd, TargetVersionCmd, pluginInput.TargetVersion)
	cmd = updateutil.BuildUpdateCommand(cmd, TargetLocationCmd, source)
	cmd = updateutil.BuildUpdateCommand(cmd, TargetHashCmd, hash)

	cmd = updateutil.BuildUpdateCommand(cmd, "-"+updateconstants.PackageNameCmd, EC2ConfigAgentName)

	//messageID obtained from ssm is in the format = aws.ssm.{message-id}.{instance-id}. Parsing for use here
	messageinfo := strings.Split(messageID, ".")
	cmd = updateutil.BuildUpdateCommand(cmd, MessageIDCmd, messageID)

	cmd = updateutil.BuildUpdateCommand(cmd, DocumentIDCmd, messageID)

	cmd = updateutil.BuildUpdateCommand(cmd, HistoryCmd, numHistories)

	appConfig := m.context.AppConfig()

	cmd = updateutil.BuildUpdateCommand(cmd, MdsEndpointCmd, appConfig.Mds.Endpoint)

	cmd = updateutil.BuildUpdateCommand(cmd, InstanceID, messageinfo[3])

	cmd = updateutil.BuildUpdateCommand(cmd, RegionIDCmd, region)

	user_agent := "EC2Config" + "/" + agentVersion
	cmd = updateutil.BuildUpdateCommand(cmd, UserAgentCmd, user_agent)

	cmd = cmd + UpdateHealthCmd //sends command to update health information after setting up installation

	log.Debug("Setup installation command is ", cmd)
	return
}

// generateUpdateCmd generates the command to perform update
func (m *updateManager) generateUpdateCmd(log log.T,
	updaterPath string) (cmd string, err error) {
	cmd = updaterPath + UpdateCmd //argument provided to the updater to perform update

	cmd = updateutil.BuildUpdateCommand(cmd, HistoryCmd, numHistories)

	appConfig := m.context.AppConfig()

	cmd = updateutil.BuildUpdateCommand(cmd, MdsEndpointCmd, appConfig.Mds.Endpoint)

	log.Debug("Update command is ", cmd)
	return
}

// createUpdateDownloadFolder creates folder for storing update downloads
func createUpdateDownloadFolder() (folder string, err error) {
	root := filepath.Join(appconfig.EC2UpdaterDownloadRoot, "update")
	if err = mkDirAll(root, os.ModePerm|os.ModeDir); err != nil {
		return "", err
	}

	return root, nil
}

// downloadManifest downloads manifest file from s3 bucket
func (m *updateManager) downloadManifest(context context.T,
	util updateutil.T,
	pluginInput *UpdatePluginInput,
	info updateinfo.T,
	out iohandler.IOHandler) (manifest *Manifest, err error) {
	//Download source
	var updateDownload = ""

	if updateDownload, err = createUpdateDownloadFolder(); err != nil {
		return nil, err
	}

	downloadInput := artifact.DownloadInput{
		SourceURL:            pluginInput.Source,
		DestinationDirectory: updateDownload,
	}

	downloadOutput, downloadErr := fileDownload(context, downloadInput)
	if downloadErr != nil ||
		downloadOutput.IsHashMatched == false ||
		downloadOutput.LocalFilePath == "" {
		return nil, downloadErr
	}
	out.AppendInfof("Successfully downloaded %v", downloadInput.SourceURL)
	return ParseManifest(context.Log(), downloadOutput.LocalFilePath)
}

// downloadUpdater downloads updater from the s3 bucket
func (m *updateManager) downloadUpdater(
	context context.T,
	region string,
	updaterPackageName string,
	manifest *Manifest,
	out iohandler.IOHandler,
	info updateinfo.T) (version string, err error) {
	var hash = ""
	var source = ""
	var log = context.Log()

	if version, err = manifest.LatestVersion(log, info); err != nil {
		return
	}
	if source, hash, err = manifest.DownloadURLAndHash(EC2UpdaterPackageName, version, EC2UpdaterFileName, HTTPFormat, S3Format, region); err != nil {
		return
	}
	var updateDownloadFolder = ""
	if updateDownloadFolder, err = createUpdateDownloadFolder(); err != nil {
		return
	}

	downloadInput := artifact.DownloadInput{
		SourceURL: source,
		SourceChecksums: map[string]string{
			updateconstants.HashType: hash,
		},
		DestinationDirectory: updateDownloadFolder,
	}
	downloadOutput, downloadErr := fileDownload(context, downloadInput)
	if downloadErr != nil ||
		downloadOutput.IsHashMatched == false ||
		downloadOutput.LocalFilePath == "" {

		errMessage := fmt.Sprintf("failed to download file reliably, %v", downloadInput.SourceURL)
		if downloadErr != nil {
			errMessage = fmt.Sprintf("%v, %v", errMessage, downloadErr.Error())
		}
		// delete downloaded file, if it exists
		pluginutil.CleanupFile(log, downloadOutput.LocalFilePath)
		return version, errors.New(errMessage)
	}

	out.AppendInfof("Successfully downloaded %v", downloadInput.SourceURL)
	if uncompressErr := fileUncompress(
		log,
		downloadOutput.LocalFilePath,
		updateutil.UpdateArtifactFolder(appconfig.EC2UpdateArtifactsRoot, updaterPackageName, version)); uncompressErr != nil {
		// delete downloaded file, if it exists
		pluginutil.CleanupFile(log, downloadOutput.LocalFilePath)
		return version, fmt.Errorf("failed to uncompress updater package, %v, %v",
			downloadOutput.LocalFilePath,
			uncompressErr.Error())
	}
	// delete downloaded file, if it exists
	pluginutil.CleanupFile(log, downloadOutput.LocalFilePath)

	return version, nil
}

// validateUpdate validates manifest against update request
func (m *updateManager) validateUpdate(log log.T,
	pluginInput *UpdatePluginInput,
	info updateinfo.T,
	manifest *Manifest,
	out iohandler.IOHandler, currentVersion string) (noNeedToUpdate bool, err error) {
	var allowDowngrade = false

	if len(pluginInput.TargetVersion) == 0 {
		if pluginInput.TargetVersion, err = manifest.LatestVersion(log, info); err != nil {
			return true, err
		}
	}

	if allowDowngrade, err = strconv.ParseBool(pluginInput.AllowDowngrade); err != nil {
		return true, err
	}

	var versionCmpVal int
	if versionCmpVal, err = versionutil.VersionCompare(pluginInput.TargetVersion, currentVersion); err != nil {
		return true, err
	}

	if versionCmpVal == 0 {
		out.AppendInfof("%v %v has already been installed, update skipped",
			pluginInput.AgentName,
			currentVersion)
		out.MarkAsSucceeded()
		return true, nil
	}
	if versionCmpVal < 0 && !allowDowngrade {
		return true,
			fmt.Errorf(
				"updating %v to an older version, please enable allow downgrade to proceed",
				pluginInput.AgentName)

	}
	if !manifest.HasVersion(info, pluginInput.TargetVersion) {
		return true,
			fmt.Errorf(
				"%v version %v is unsupported",
				pluginInput.AgentName,
				pluginInput.TargetVersion)
	}
	if !manifest.HasVersion(info, currentVersion) {
		return true,
			fmt.Errorf(
				"%v current version %v is unsupported on current platform",
				pluginInput.AgentName,
				currentVersion)
	}

	return false, nil
}

// TODO Make common methods go into utility/helper/common package. Check if Execute can be added to that package
// Execute runs multiple sets of commands and returns their outputs.
func (p *Plugin) Execute(config contracts.Configuration, cancelFlag task.CancelFlag, output iohandler.IOHandler) {
	log := p.context.Log()
	log.Info("RunCommand started with update configuration for EC2 config update ", config)
	util := new(updateutil.Utility)
	util.Context = p.context
	manager := &updateManager{
		p.context,
	}

	if cancelFlag.ShutDown() {
		output.MarkAsShutdown()
	} else if cancelFlag.Canceled() {
		output.MarkAsCancelled()
	} else {
		updateAgent(p,
			config,
			p.context,
			manager,
			util,
			config.Properties,
			output,
			time.Now())
	}
	return
}

// Name returns the plugin name
func Name() string {
	return appconfig.PluginEC2ConfigUpdate
}

// GetUpdatePluginConfig returns the default values for the update plugin
func GetUpdatePluginConfig(context context.T) UpdatePluginConfig {
	var manifestURL string
	dynamicS3Endpoint := context.Identity().GetServiceEndpoint("s3")
	if dynamicS3Endpoint != "" {
		manifestURL = "https://" + dynamicS3Endpoint + ManifestPath
	} else {
		region, _ := context.Identity().Region()
		if strings.HasPrefix(region, s3util.ChinaRegionPrefix) {
			manifestURL = ChinaManifestURL
		} else {
			manifestURL = CommonManifestURL
		}
	}

	return UpdatePluginConfig{
		ManifestLocation: manifestURL,
	}
}

func validateSource(pluginInput UpdatePluginInput) (err error) {
	//This pattern allows for http/https links to local hosts and s3 buckets
	validSourceValue := sourcePattern
	if !validSourceValue.MatchString(pluginInput.Source) {
		return errors.New("Invalid source")
	}
	return err
}
