// 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 fingerprint contains functions that helps identify an instance
// this is done to protect customers from launching two instances with the same instance identifier
// and thus running commands intended for one on the other
package fingerprint

import (
	"crypto/md5"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"os"
	"os/exec"
	"sync"
	"time"
	"unicode/utf8"

	"github.com/aws/amazon-ssm-agent/agent/log"
	"github.com/twinj/uuid"
)

type hwInfo struct {
	Fingerprint         string            `json:"fingerprint"`
	HardwareHash        map[string]string `json:"hardwareHash"`
	SimilarityThreshold int               `json:"similarityThreshold"`
}

const (
	defaultMatchPercent = 40
	vaultKey            = "InstanceFingerprint"
	ipAddressID         = "ipaddress-info"
)

var (
	fingerprint string = ""
	logger      log.T
	logLock     sync.RWMutex
)

func InstanceFingerprint(log log.T) (string, error) {
	if isLoaded() {
		return fingerprint, nil
	}

	lock.Lock()
	defer lock.Unlock()

	var err error
	fingerprint, err = generateFingerprint(log)
	if err != nil {
		return "", err
	}

	loaded = true
	return fingerprint, nil
}

func SetSimilarityThreshold(log log.T, value int) (err error) {
	if value != -1 && (value < 1 || 100 < value) { // zero not allowed
		return fmt.Errorf("Invalid Similarity Threshold value of %v. Value must be between 1 and 100 or -1 (check disabled)", value)
	}

	savedHwInfo := hwInfo{}
	if savedHwInfo, err = fetch(log); err == nil {
		savedHwInfo.SimilarityThreshold = value
		err = save(savedHwInfo)
	}

	if err != nil {
		return fmt.Errorf("Unable to set similarity threshold due to, %v", err)
	}
	return nil
}

// generateFingerprint generates new fingerprint and saves it in the vault
func generateFingerprint(log log.T) (string, error) {
	var hardwareHash map[string]string
	var savedHwInfo hwInfo
	var err error
	var hwHashErr error

	// retry getting the new hash and compare with the saved hash for 3 times
	for attempt := 1; attempt <= 3; attempt++ {
		// fetch current hardware hash values
		hardwareHash, hwHashErr = currentHwHash()

		if hwHashErr != nil || !isValidHardwareHash(hardwareHash) {
			// sleep 5 seconds until the next retry
			time.Sleep(5 * time.Second)
			continue
		}

		// try get previously saved fingerprint data from vault
		savedHwInfo, err = fetch(log)
		if err != nil {
			continue
		}

		// first time generation, breakout retry
		if !hasFingerprint(savedHwInfo) {
			log.Debugf("No initial fingerprint detected, skipping retry...")
			// Set the default similarity threshold during first time generation
			savedHwInfo.SimilarityThreshold = defaultMatchPercent
			break
		}

		// stop retry if the hardware hashes are the same
		if isSimilarHardwareHash(log, savedHwInfo.HardwareHash, hardwareHash, savedHwInfo.SimilarityThreshold) {
			log.Debugf("Calculated hardware hash is same as saved one, returning fingerprint")
			return savedHwInfo.Fingerprint, nil
		}

		log.Debugf("Calculated hardware hash is different with saved one, retry to ensure the difference is not cause by the dependency has not been ready")
		// sleep 5 seconds until the next retry
		time.Sleep(5 * time.Second)
	}

	if hwHashErr != nil {
		log.Errorf("Error while fetching hardware hashes from instance: %s", hwHashErr)
		return "", hwHashErr
	} else if !isValidHardwareHash(hardwareHash) {
		return "", fmt.Errorf("Hardware hash generated contains invalid characters. %s", hardwareHash)
	}

	if err != nil {
		log.Warnf("Error while fetching fingerprint data from vault: %s", err)
	}

	uuid.SwitchFormat(uuid.CleanHyphen)
	// check if this is the first time we are generating the fingerprint
	// or if there is no match
	new_fingerprint := ""
	if !hasFingerprint(savedHwInfo) {
		// generate new fingerprint
		log.Info("No initial fingerprint detected, generating fingerprint file...")
		new_fingerprint = uuid.NewV4().String()
	} else if !isSimilarHardwareHash(log, savedHwInfo.HardwareHash, hardwareHash, savedHwInfo.SimilarityThreshold) {
		log.Info("Calculated hardware difference, regenerating fingerprint...")
		new_fingerprint = uuid.NewV4().String()
	} else {
		return savedHwInfo.Fingerprint, nil
	}

	// generate updated info to save to vault
	updatedHwInfo := hwInfo{
		Fingerprint:         new_fingerprint,
		HardwareHash:        hardwareHash,
		SimilarityThreshold: savedHwInfo.SimilarityThreshold,
	}

	// save content in vault
	if err = save(updatedHwInfo); err != nil {
		log.Errorf("Error while saving fingerprint data from vault: %s", err)
	}
	return new_fingerprint, err
}

func fetch(log log.T) (hwInfo, error) {
	savedHwInfo := hwInfo{}

	// try get previously saved fingerprint data from vault
	d, err := vault.Retrieve("", vaultKey)
	if err != nil {
		_ = log.Warnf("Could not read InstanceFingerprint file: %v", err)
		return hwInfo{}, nil
	} else if d == nil {
		return hwInfo{}, nil
	}

	// unmarshal the retrieved data
	if err := json.Unmarshal([]byte(d), &savedHwInfo); err != nil {
		return hwInfo{}, err
	}
	return savedHwInfo, nil
}

func save(info hwInfo) error {
	// check fingerprint
	if info.Fingerprint == "" {
		return errors.New("save called with empty fingerprint key")
	}

	// marshal the updated info
	data, err := json.Marshal(info)
	if err != nil {
		return err
	}

	// save content in vault with no manifestFileNamePrefix for onprem
	if err = vault.Store("", vaultKey, data); err != nil {
		return err
	}

	return nil
}

func hasFingerprint(info hwInfo) bool {
	return info.Fingerprint != ""
}

// isSimilarHardwareHash returns true if the VM or container running this instance is the same one that was registered
// with Systems Manager; otherwise, false.
//
// If the current machine ID (SID) and IP Address are the same as when the agent was registered, then this is definitely
// the same machine.  If the SID is different, then this is definitely *not* the same instance.  If the IP Address has
// changed, then this *might* be the same machine.
//
// IP Address can change if the VM moves to a new host or just randomly if a DHCP server assigns a new address.
// If the IP address is changed we look at other machine configuration values to decide whether the instance is
// *probably* the same.  How many configuration values can be different and still be the "same machine" is controlled
// by the threshold value.
//
// logger is the application log writer
// savedHwHash is a map of machine property names to their values when the agent was registered
// currentHwHash is a map of machine property names to their current values
// threshold is the percentage of machine properties (other than SID and IP Address) that have to match for the instance
// to be considered the same
func isSimilarHardwareHash(log log.T, savedHwHash map[string]string, currentHwHash map[string]string, threshold int) bool {

	var totalCount, successCount int
	isSimilar := true

	// similarity check is disabled when threshold is set to -1
	if threshold == -1 {
		log.Debugf("Similarity check is disabled, skipping hardware comparison")
		return true
	}

	// check input
	if len(savedHwHash) == 0 || len(currentHwHash) == 0 {

		_ = log.Errorf(
			"Cannot connect to AWS Systems Manager.  " +
				"The saved machine configuration could not be loaded or the current machine configuration could not be determined.")

		return false
	}

	// check whether hardwareId (uuid/machineid) has changed
	// this usually happens during provisioning
	if currentHwHash[hardwareID] != savedHwHash[hardwareID] {

		_ = log.Errorf(
			"Cannot connect to AWS Systems Manager.  The hardware ID (%v) has changed from the registered value (%v).",
			currentHwHash[hardwareID],
			savedHwHash[hardwareID])

		isSimilar = false

	} else {

		mismatchedKeyMessages := make([]string, 0, len(currentHwHash))
		const unmatchedValueFormat = "The '%s' value (%s) has changed from the registered machine configuration value (%s)."
		const matchedValueFormat = "The '%s' value matches the registered machine configuration value."

		// check whether ipaddress is the same - if the machine key and the IP address have not changed, it's the same instance.
		if currentHwHash[ipAddressID] == savedHwHash[ipAddressID] {

			log.Debugf(matchedValueFormat, "IP Address")

		} else {

			message := fmt.Sprintf(unmatchedValueFormat, "IP Address", currentHwHash[ipAddressID], savedHwHash[ipAddressID])
			mismatchedKeyMessages = append(mismatchedKeyMessages, message)
			log.Debug(message)

			// identify number of successful matches
			for key, currValue := range currentHwHash {

				if prevValue, ok := savedHwHash[key]; ok && currValue == prevValue {

					log.Debugf(matchedValueFormat, key)
					successCount++

				} else {

					message := fmt.Sprintf(unmatchedValueFormat, key, currValue, prevValue)
					mismatchedKeyMessages = append(mismatchedKeyMessages, message)
					log.Debug(message)
				}
			}

			// check if the changed match exceeds the minimum match percent
			totalCount = len(currentHwHash)
			if float32(successCount)/float32(totalCount)*100 < float32(threshold) {

				_ = log.Error("Cannot connect to AWS Systems Manager.  Machine configuration has changed more than the allowed threshold.")
				for _, message := range mismatchedKeyMessages {

					_ = log.Warn(message)
				}

				isSimilar = false
			}
		}
	}

	return isSimilar
}

func hostnameInfo() (value string, err error) {
	return os.Hostname()
}

func primaryIpInfo() (value string, err error) {
	addrs, err := net.InterfaceAddrs()
	if err != nil {
		return "", err
	}
	for _, address := range addrs {
		// check the address type and if it is not a loopback then return it
		if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
			if ipnet.IP.To4() != nil {
				return ipnet.IP.String(), nil
			}
		}
	}
	return "", err
}

func macAddrInfo() (value string, err error) {
	ifaces, err := net.Interfaces()
	if err != nil {
		return "", err
	}

	for _, i := range ifaces {
		if i.HardwareAddr.String() != "" {
			return i.HardwareAddr.String(), nil
		}
	}

	return "", nil
}

func commandOutputHash(command string, params ...string) (encodedValue string, value string, err error) {
	var contentBytes []byte
	if contentBytes, err = exec.Command(command, params...).Output(); err == nil {
		value = string(contentBytes) // without encoding
		sum := md5.Sum(contentBytes)
		encodedValue = base64.StdEncoding.EncodeToString(sum[:])
	}
	return
}

func isValidHardwareHash(hardwareHash map[string]string) bool {
	for _, value := range hardwareHash {
		if !utf8.ValidString(value) {
			return false
		}
	}

	return true
}

func ClearStoredHardwareInfo(log log.T) {
	// create empty hardware info
	data, err := json.Marshal(hwInfo{})
	if err != nil {
		log.Errorf("Failed to create empty hardware info: %v", err)
		return
	}

	// save content in vault with no manifestFileNamePrefix for onprem
	if err = vault.Store("", vaultKey, data); err != nil {
		log.Errorf("Failed to store empty hardware info: %v", err)
	}
}