// Copyright 2020 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 selfupdate provides an interface to force update with Message Gateway Service and S3

package selfupdate

import (
	"fmt"
	"hash/fnv"
	"math/rand"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"runtime/debug"
	"strconv"
	"strings"
	"time"

	"github.com/aws/amazon-ssm-agent/agent/appconfig"
	"github.com/aws/amazon-ssm-agent/agent/log"
	"github.com/aws/amazon-ssm-agent/agent/log/logger"
	"github.com/aws/amazon-ssm-agent/agent/platform"
	"github.com/aws/amazon-ssm-agent/agent/updateutil"
	"github.com/aws/amazon-ssm-agent/agent/updateutil/updateconstants"
	"github.com/aws/amazon-ssm-agent/agent/version"
	"github.com/aws/amazon-ssm-agent/core/app/context"
	"github.com/aws/amazon-ssm-agent/core/app/selfupdate/fileutil"
	"github.com/aws/amazon-ssm-agent/core/app/selfupdate/fileutil/artifact"
	"github.com/carlescere/scheduler"
	"github.com/nightlyone/lockfile"
)

// UpdatePluginResult represents Agent update plugin result
type UpdateResult struct {
	StandOut      string    `json:"StandOut"`
	StartDateTime time.Time `json:"StartDateTime"`
}

type SelfUpdate struct {
	updateJob            *scheduler.Job
	updateFrequencyHrs   int
	context              context.ICoreAgentContext
	fileManager          artifact.IArtifact
	filsys               fileutil.IFileutil
	updateSchedulerTimer chan bool
}

var platformNameGetter = platform.PlatformName
var nanoChecker = platform.IsPlatformNanoServer
var execCommand = exec.Command
var cmdStart = (*exec.Cmd).Start
var lockFileName = appconfig.UpdaterPidLockfile

// The main purpose of these delegates is to easily test the self update
var (
	updateInitialize        func(string) error
	updateDownloadResource  func(string) error
	updateExecuteSelfUpdate func(log log.T, region string) (pid int, err error)
	updateDelayFactor       = "43200" // 12 hours
	updateDelayBase         = "1800"  // 1800 seconds
)

// IUpdateProvider is the interface to start/stop selfupdate component
type ISelfUpdate interface {
	Start()
	Stop()
}

func NewSelfUpdater(context context.ICoreAgentContext) *SelfUpdate {
	selfupdateContext := context.With("[" + name + "]")
	selfupdateContext.Log().Debug("Initializing self update ...")
	fileManager := artifact.NewSelfUpdateArtifact(selfupdateContext.Log(), *context.AppConfig())
	fileutl := fileutil.NewFileUtil(context.Log())

	selfUpdateProvider := SelfUpdate{
		context:              selfupdateContext,
		fileManager:          fileManager,
		filsys:               fileutl,
		updateSchedulerTimer: make(chan bool, 1),
	}
	updateInitialize = selfUpdateProvider.init
	updateDownloadResource = selfUpdateProvider.downloadResource
	updateExecuteSelfUpdate = selfUpdateProvider.executeSelfUpdate
	return &selfUpdateProvider
}

// Start selfupdate component
func (u *SelfUpdate) Start() {
	log := u.context.Log()

	appConfig := u.context.AppConfig()

	// start the self update process when customer enable it
	if appConfig.Agent.SelfUpdate == true {
		log.Info("Self update is enabled. Starting scheduling ...")
		// initialize the schedule days
		u.loadScheduledFrequency(*appConfig)

		// SSM Agent should first waiting for update event from MGS, if it could not connect with MGS, it should
		// pull the manifest from s3 and check the version status
		u.selfupdateWithS3(log)
	} else {
		log.Debugf("self update is disabled, skipping")
	}

	return
}

func (u *SelfUpdate) loadScheduledFrequency(config appconfig.SsmagentConfig) {
	log := u.context.Log()
	u.updateFrequencyHrs = config.Agent.SelfUpdateScheduleDay * 24 // converting to hours
	log.Debugf("%v frequency is: %d day(s).", name, config.Agent.SelfUpdateScheduleDay)
}

// Generate the schedule job for self update via s3  periodically
func (u *SelfUpdate) scheduleSelfUpdateS3Job(log log.T) {
	var err error
	if u.updateJob, err = scheduler.Every(u.updateFrequencyHrs).Hours().NotImmediately().Run(u.updateFromS3WithDelay); err != nil {
		log.Errorf("unable to schedule self update job. %v", err)
	}
	return
}

// selfupdate based on S3 manifest file pulling
func (u *SelfUpdate) selfupdateWithS3(log log.T) {
	log.Debugf("Start scheduling job for s3 based self update")

	instanceId, _ := u.context.Identity().InstanceID()
	hash := fnv.New32a()
	hash.Write([]byte(instanceId))
	rand.Seed(time.Now().UTC().UnixNano() + int64(hash.Sum32()))

	go u.updateFromS3WithDelay()
	u.scheduleSelfUpdateS3Job(log)
}

// Stop selfupdate component
func (u *SelfUpdate) Stop() {
	log := u.context.Log()

	if u.updateJob != nil {
		u.updateJob.Quit <- true
		u.updateSchedulerTimer <- true
		log.Info("Self update stopped successfully...")
	}
}

// Periodically Pulling manifest file and updater from regional S3 bucket.
// Unzip updater and execute the updater
func (u *SelfUpdate) updateFromS3WithDelay() {
	log := u.context.Log()
	updateDelayFactorInt, err := strconv.Atoi(updateDelayFactor)
	if err != nil {
		log.Errorf("updateDelayFactor has non-digits: %d", updateDelayFactor)
		return
	}
	updateDelayBaseInt, err := strconv.Atoi(updateDelayBase)
	if err != nil {
		log.Errorf("updateDelayBase has non-digits: %d", updateDelayBase)
		return
	}
	nextTrigger := time.Duration(rand.Intn(updateDelayFactorInt)+updateDelayBaseInt) * time.Second
	select {
	case <-time.After(nextTrigger):
		_ = u.updateFromS3()
	case <-u.updateSchedulerTimer:
		return
	}
}

// Periodically Pulling manifest file and updater from regional S3 bucket.
// Unzip updater and execute the updater
func (u *SelfUpdate) updateFromS3() (err error) {
	log := u.context.Log()
	log.Debugf("Start self updater")

	var pid int
	var instanceId, region string

	lockFileHandle, _ := lockfile.New(lockFileName)
	err = lockFileHandle.TryLockExpire(updateconstants.UpdateLockFileMinutes) // 60 minutes

	if err != nil {
		if err == lockfile.ErrBusy {
			log.Errorf("Failed to lock update lockfile, another update is in progress: %s", err)
			log.WriteEvent(logger.AgentUpdateResultMessage, "", u.generateEventCode(updateconstants.ErrorUpdaterLockBusy))
			return
		} else {
			log.Warnf("Proceeding update process with new lock. Failed to lock update lockfile: %s", err)
			log.WriteEvent(logger.AgentUpdateResultMessage, "", u.generateWarnEventCode(updateconstants.WarnUpdaterLockFail))
		}
	}

	defer func() {
		if err != nil {
			log.Debug("Unlocked file due to self update error")
			lockFileHandle.Unlock()
		}
		if msg := recover(); msg != nil {
			log.Errorf("update from S3 run panic: %v", msg)
			log.Errorf("%s: %s", msg, debug.Stack())
		}
	}()

	if region, err = u.context.Identity().Region(); err != nil {
		log.Errorf("Self update failed to get region from platform package, %s", err)
		log.WriteEvent(logger.AgentUpdateResultMessage, "", u.generateEventCode(updateconstants.ErrorInitializationFailed))
		return
	}

	if instanceId, err = u.context.Identity().InstanceID(); err != nil {
		log.Errorf("Self update failed to get the instance id, %v", err)
		log.WriteEvent(logger.AgentUpdateResultMessage, "", u.generateEventCode(updateconstants.ErrorInitializationFailed))
		return
	}

	if err = updateInitialize(instanceId); err != nil {
		log.Errorf("Self Update failed to init, %v", err)
		log.WriteEvent(logger.AgentUpdateResultMessage, "", u.generateEventCode(updateconstants.ErrorCreateUpdateFolder))
		return
	}

	if err = updateDownloadResource(region); err != nil {
		log.Errorf("Self Update failed to download resource, %v", err)
		log.WriteEvent(logger.AgentUpdateResultMessage, "", u.generateEventCode(updateconstants.ErrorDownloadUpdater))
		return
	}

	if pid, err = updateExecuteSelfUpdate(log, region); err != nil {
		log.Errorf("Self update failed to execute the self update, %v", err)
		log.WriteEvent(logger.AgentUpdateResultMessage, "", u.generateEventCode(updateconstants.ErrorExecuteUpdater))
		return
	}

	// change owner to updater process. This error should not unlock
	err = lockFileHandle.ChangeOwner(pid)
	if err != nil {
		log.Warnf("Failed to transfer ownership of update lockfile to updater, unlocking: %s", err)
		return
	}
	return
}

func (u *SelfUpdate) init(instanceId string) (err error) {
	log := u.context.Log()

	var orchestrationDir string

	orchestrationDir = filepath.Join(
		appconfig.DefaultDataStorePath,
		instanceId,
		appconfig.DefaultDocumentRootDirName,
		"orchestration", DefaultSelfUpdateFolder, DefaultOutputFolder)
	log.Debugf("orchestration dir is %v", orchestrationDir)

	if err = u.prepareUpdaterDir(log,
		appconfig.UpdaterArtifactsRoot,
		orchestrationDir, instanceId); err != nil {
		return err
	}

	log.Debugf("Self update init successfully...")
	return nil
}

func (u *SelfUpdate) generateEventCode(errorCode updateconstants.ErrorCode) string {
	return updateconstants.UpdateFailed + updateconstants.SelfUpdatePrefix + "_" + string(errorCode)
}

func (u *SelfUpdate) generateWarnEventCode(errorCode string) string {
	return updateconstants.UpdateSucceeded + updateconstants.SelfUpdatePrefix + "_" + errorCode
}

func (u *SelfUpdate) downloadResource(region string) (err error) {
	log := u.context.Log()
	var sourceURL, fileName string
	var updaterDownloadOutput artifact.DownloadOutput

	if fileName, err = u.getUpdaterFileName(log, runtime.GOARCH, updateconstants.CompressFormat); err != nil {
		return fmt.Errorf("selfupdate failed to get updater file name, %v", err)
	}

	sourceURL = u.generateDownloadUpdaterURL(log, region, fileName)
	log.Debugf("Download updater URL is , %s", sourceURL)

	downloadDirectory := filepath.Join(appconfig.DownloadRoot, "update")
	downloadInput := artifact.DownloadInput{
		SourceURL:            sourceURL,
		DestinationDirectory: downloadDirectory,
	}

	if updaterDownloadOutput, err = u.downloadResourceFromS3(downloadInput); err != nil {
		return fmt.Errorf("error during downloading updater, %v", err)
	}

	if err := u.unCompress(log, updaterDownloadOutput); err != nil {
		return fmt.Errorf("error during uncompress updater, %v", err)
	}

	return
}

func (u *SelfUpdate) downloadResourceFromS3(
	downloadInput artifact.DownloadInput) (downloadOutput artifact.DownloadOutput, err error) {
	log := u.context.Log()

	downloadOutput, err = u.fileManager.Download(downloadInput)
	if err != nil {
		return downloadOutput, fmt.Errorf("failed to download Download context %v, %v", downloadInput, err)
	}

	log.Debugf("Succeed to download the contents")
	log.Debugf("Local file path : %v", downloadOutput.LocalFilePath)
	log.Debugf("Is updated: %v", downloadOutput.IsUpdated)
	log.Debugf("Is hash matched %v", downloadOutput.IsHashMatched)
	return
}

func (u *SelfUpdate) executeSelfUpdate(log log.T, region string) (pid int, err error) {
	var workDic, sourceURL, cmd string

	sourceURL = u.generateDownloadManifestURL(log, region)

	cmd = u.generateUpdateCmd(log, sourceURL)
	log.Infof("Self Update command %v", cmd)

	workDic = filepath.Join(appconfig.UpdaterArtifactsRoot, PackageName, PackageVersion)
	if pid, err = u.exeCommand(log, cmd, workDic); err != nil {
		return -1, fmt.Errorf("failed to execute command for self update, %v", err)
	}

	return pid, nil
}

// Generate the command to check current version is deprecated or not.
func (u *SelfUpdate) generateUpdateCmd(log log.T, sourceURL string) (cmd string) {
	log.Debugf("Starting generate command for self update")

	cmd = filepath.Join(appconfig.UpdaterArtifactsRoot, PackageName, PackageVersion, Updater) + " -update" + " -selfupdate"

	cmd = u.buildUpdateCommand(cmd, updateconstants.ManifestFileUrlCmd, sourceURL)
	cmd = u.buildUpdateCommand(cmd, updateconstants.SourceVersionCmd, version.Version)

	return cmd
}

// BuildUpdateCommand builds command string with argument and value
func (u *SelfUpdate) buildUpdateCommand(cmd string, arg string, value string) string {
	if value == "" || arg == "" {
		return cmd
	}
	return fmt.Sprintf("%v -%v %v", cmd, arg, value)
}

func (u *SelfUpdate) exeCommand(
	log log.T,
	cmd string,
	workingDir string) (pid int, err error) {

	parts := strings.Fields(cmd)

	command := execCommand(parts[0], parts[1:]...)
	command.Dir = workingDir
	prepareProcess(command)
	// Start command asynchronously
	err = cmdStart(command)
	pid = updateutil.GetCommandPid(command)
	return
}

func (u *SelfUpdate) unCompress(log log.T,
	downloadOutput artifact.DownloadOutput) (err error) {

	log.Debugf("Starting to uncompress updater")

	dest := filepath.Join(appconfig.UpdaterArtifactsRoot, PackageName, PackageVersion)
	log.Debugf("Uncompress destination file path is %v", dest)
	log.Debugf("Source file path is %v", downloadOutput.LocalFilePath)
	if uncompressErr := u.fileManager.Uncompress(downloadOutput.LocalFilePath, dest); uncompressErr != nil {
		return fmt.Errorf("Failed to uncompress updater package for self update, %v, %v\n",
			downloadOutput.LocalFilePath,
			uncompressErr.Error())
	}
	log.Debugf("Succeed to uncompress the updater")

	return
}

func (u *SelfUpdate) generateDownloadUpdaterURL(log log.T, region string, fileName string) (url string) {
	var urlFormat string

	if dynamicS3Endpoint := u.context.Identity().GetServiceEndpoint("s3"); dynamicS3Endpoint != "" {
		urlFormat = "https://" + dynamicS3Endpoint + UrlPath
	} else {
		// could not retrieve the default s3 endpoint, generate endpoint from region information
		urlFormat = CommonUrlPath
	}

	urlFormat = strings.Replace(urlFormat, RegionHolder, region, -1)
	urlFormat = strings.Replace(urlFormat, FileNameHolder, fileName, -1)

	log.Debugf("updater download url is %s ", urlFormat)

	return urlFormat
}

func (u *SelfUpdate) generateDownloadManifestURL(log log.T, region string) (manifestUrl string) {

	if dynamicS3Endpoint := u.context.Identity().GetServiceEndpoint("s3"); dynamicS3Endpoint != "" {
		manifestUrl = "https://" + dynamicS3Endpoint + ManifestPath
	} else {
		// could not retrieve the default s3 endpoint, generate endpoint from region information
		manifestUrl = CommonManifestURL
	}

	manifestUrl = strings.Replace(manifestUrl, RegionHolder, region, -1)

	log.Debugf("manifest download url is %s", manifestUrl)

	return
}

// FileName generates downloadable file name base on agreed convension
func (u *SelfUpdate) getUpdaterFileName(log log.T, Arch string, CompressFormat string) (fileName string, err error) {

	var PlatformName string

	if PlatformName, err = u.getPlatformName(log); err != nil {
		return "", fmt.Errorf("failed to get platform name, %s", err)
	}

	log.Debugf("Platform Name is %s", PlatformName)
	log.Debugf("Running Arch Name is %s", Arch)
	log.Debugf("Compress format is %s", CompressFormat)

	fileName = "amazon-ssm-agent-updater-{Platform}-{Arch}.{Compressed}"
	fileName = strings.Replace(fileName, PlatformHolder, PlatformName, -1)
	fileName = strings.Replace(fileName, ArchHolder, Arch, -1)
	fileName = strings.Replace(fileName, CompressedHolder, CompressFormat, -1)

	return
}

func (u *SelfUpdate) getPlatformName(log log.T) (platformName string, err error) {
	var isNano bool

	if platformName, err = platformNameGetter(log); err != nil {
		log.Errorf("Failed to get platform name, %s", err)
		return
	}

	platformName = strings.ToLower(platformName)
	if strings.Contains(platformName, PlatformAmazonLinux) ||
		strings.Contains(platformName, PlatformRedHat) ||
		strings.Contains(platformName, PlatformCentOS) ||
		strings.Contains(platformName, PlatformSuseOS) ||
		strings.Contains(platformName, PlatformOracleLinux) {
		platformName = PlatformLinux
	} else if strings.Contains(platformName, PlatformRaspbian) {
		platformName = PlatformUbuntu
	} else if strings.Contains(platformName, PlatformDebian) {
		platformName = PlatformUbuntu
	} else if strings.Contains(platformName, PlatformMacOsX) {
		platformName = PlatformDarwin
	} else if strings.Contains(platformName, PlatformUbuntu) {
		if isSnap, err := u.isAgentInstalledUsingSnap(log); err == nil && isSnap {
			platformName = PlatformUbuntuSnap
		} else {
			platformName = PlatformUbuntu
		}
	} else if isNano, err = nanoChecker(log); isNano {
		return platformName, fmt.Errorf("self update doesn't support this platform")
	} else if strings.Contains(platformName, PlatformWindows) {
		platformName = PlatformWindows
	} else {
		return platformName, fmt.Errorf("self update doesn't support this platform")
	}

	return platformName, err
}

func (u *SelfUpdate) isAgentInstalledUsingSnap(log log.T) (result bool, err error) {
	if _, commandErr := execCommand("snap", "services", "amazon-ssm-agent").Output(); commandErr != nil {
		log.Debugf("Error checking 'snap services amazon-ssm-agent' - %v", commandErr)
		return false, commandErr
	}
	log.Debug("Agent is installed using snap")
	return true, nil
}

func (u *SelfUpdate) prepareUpdaterDir(log log.T, rootDir, orchestDir string, instanceId string) (err error) {
	var fileName [2]string

	fileName[0] = DefaultStandOut
	fileName[1] = DefaultStandErr

	for _, filePath := range fileName {
		if err = u.createIfNotExist(log, rootDir, filePath); err != nil {
			return fmt.Errorf("failed to create file for self update, filepath: %v %v error : %v",
				rootDir,
				filePath,
				err)
		}
		log.Debugf("Clean up std file %v/%v", rootDir, filePath)
		if err = u.createIfNotExist(log, orchestDir, filePath); err != nil {
			return fmt.Errorf("failed to create file for self update orchestration, filepath: %v %v error : %v",
				orchestDir,
				filePath,
				err)
		}
		log.Debugf("Clean up std file %v/%v", orchestDir, filePath)
	}

	return
}

func (u *SelfUpdate) createIfNotExist(log log.T, root, filePath string) (err error) {
	var FileWriter *os.File
	var fullFilePath string
	fullFilePath = filepath.Join(root, filePath)

	if u.filsys.Exists(fullFilePath) == false {
		err = u.filsys.MakeDirs(root)
	}

	FileWriter, _ = os.OpenFile(fullFilePath, appconfig.FileFlagsCreateOrAppend, appconfig.ReadWriteAccess)
	defer FileWriter.Close()
	return
}
