// Copyright 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 ssmsecret

import (
	"encoding/json"
	"fmt"
	"sync"
	"time"

	"github.com/cihub/seelog"
	"github.com/pkg/errors"

	apicontainer "github.com/aws/amazon-ecs-agent/agent/api/container"
	"github.com/aws/amazon-ecs-agent/agent/config"
	"github.com/aws/amazon-ecs-agent/agent/ssm"
	"github.com/aws/amazon-ecs-agent/agent/ssm/factory"
	"github.com/aws/amazon-ecs-agent/agent/taskresource"
	resourcestatus "github.com/aws/amazon-ecs-agent/agent/taskresource/status"
	apicontainerstatus "github.com/aws/amazon-ecs-agent/ecs-agent/api/container/status"
	"github.com/aws/amazon-ecs-agent/ecs-agent/api/task/status"
	"github.com/aws/amazon-ecs-agent/ecs-agent/credentials"
	"github.com/aws/amazon-ecs-agent/ecs-agent/ipcompatibility"
)

const (
	// ResourceName is the name of the ssmsecret resource
	ResourceName = "ssmsecret"

	//MaxBatchNum is the maximum batch number that ssm GetParameters API can accept at one time
	MaxBatchNum = 10
)

// SSMSecretResource represents secrets as a task resource.
// The secrets are stored in SSM Parameter Store.
type SSMSecretResource struct {
	taskARN             string
	createdAt           time.Time
	desiredStatusUnsafe resourcestatus.ResourceStatus
	knownStatusUnsafe   resourcestatus.ResourceStatus
	// appliedStatus is the status that has been "applied" (e.g., we've called some
	// operation such as 'Create' on the resource) but we don't yet know that the
	// application was successful, which may then change the known status. This is
	// used while progressing resource states in progressTask() of task manager
	appliedStatus                      resourcestatus.ResourceStatus
	resourceStatusToTransitionFunction map[resourcestatus.ResourceStatus]func() error
	credentialsManager                 credentials.Manager
	executionCredentialsID             string

	// required for store ssm secrets value, key is region of secret
	requiredSecrets map[string][]apicontainer.Secret
	// map to store secret values, key is a combination of valueFrom and region
	secretData map[string]string

	// ssmClientCreator is a factory interface that creates new SSM clients. This is
	// needed mostly for testing.
	ssmClientCreator factory.SSMClientCreator
	ipCompatibility  ipcompatibility.IPCompatibility

	// terminalReason should be set for resource creation failures. This ensures
	// the resource object carries some context for why provisioning failed.
	terminalReason     string
	terminalReasonOnce sync.Once

	// lock is used for fields that are accessed and updated concurrently
	lock sync.RWMutex
}

// NewSSMSecretResource creates a new SSMSecretResource object
func NewSSMSecretResource(taskARN string,
	ssmSecrets map[string][]apicontainer.Secret,
	executionCredentialsID string,
	credentialsManager credentials.Manager,
	ssmClientCreator factory.SSMClientCreator,
	ipCompatibility ipcompatibility.IPCompatibility) *SSMSecretResource {

	s := &SSMSecretResource{
		taskARN:                taskARN,
		requiredSecrets:        ssmSecrets,
		credentialsManager:     credentialsManager,
		executionCredentialsID: executionCredentialsID,
		ssmClientCreator:       ssmClientCreator,
		ipCompatibility:        ipCompatibility,
	}

	s.initStatusToTransition()
	return s
}

func (secret *SSMSecretResource) initStatusToTransition() {
	resourceStatusToTransitionFunction := map[resourcestatus.ResourceStatus]func() error{
		resourcestatus.ResourceStatus(SSMSecretCreated): secret.Create,
	}
	secret.resourceStatusToTransitionFunction = resourceStatusToTransitionFunction
}

func (secret *SSMSecretResource) setTerminalReason(reason string) {
	secret.terminalReasonOnce.Do(func() {
		seelog.Infof("ssm secret resource: setting terminal reason for ssm secret resource in task: [%s]", secret.taskARN)
		secret.terminalReason = reason
	})
}

// GetTerminalReason returns an error string to propagate up through to task
// state change messages
func (secret *SSMSecretResource) GetTerminalReason() string {
	return secret.terminalReason
}

// SetDesiredStatus safely sets the desired status of the resource
func (secret *SSMSecretResource) SetDesiredStatus(status resourcestatus.ResourceStatus) {
	secret.lock.Lock()
	defer secret.lock.Unlock()

	secret.desiredStatusUnsafe = status
}

// GetDesiredStatus safely returns the desired status of the task
func (secret *SSMSecretResource) GetDesiredStatus() resourcestatus.ResourceStatus {
	secret.lock.RLock()
	defer secret.lock.RUnlock()

	return secret.desiredStatusUnsafe
}

// GetName safely returns the name of the resource
func (secret *SSMSecretResource) GetName() string {
	secret.lock.RLock()
	defer secret.lock.RUnlock()

	return ResourceName
}

// DesiredTerminal returns true if the secret's desired status is REMOVED
func (secret *SSMSecretResource) DesiredTerminal() bool {
	secret.lock.RLock()
	defer secret.lock.RUnlock()

	return secret.desiredStatusUnsafe == resourcestatus.ResourceStatus(SSMSecretRemoved)
}

// KnownCreated returns true if the secret's known status is CREATED
func (secret *SSMSecretResource) KnownCreated() bool {
	secret.lock.RLock()
	defer secret.lock.RUnlock()

	return secret.knownStatusUnsafe == resourcestatus.ResourceStatus(SSMSecretCreated)
}

// TerminalStatus returns the last transition state of cgroup
func (secret *SSMSecretResource) TerminalStatus() resourcestatus.ResourceStatus {
	return resourcestatus.ResourceStatus(SSMSecretRemoved)
}

// NextKnownState returns the state that the resource should
// progress to based on its `KnownState`.
func (secret *SSMSecretResource) NextKnownState() resourcestatus.ResourceStatus {
	return secret.GetKnownStatus() + 1
}

// ApplyTransition calls the function required to move to the specified status
func (secret *SSMSecretResource) ApplyTransition(nextState resourcestatus.ResourceStatus) error {
	transitionFunc, ok := secret.resourceStatusToTransitionFunction[nextState]
	if !ok {
		return errors.Errorf("resource [%s]: transition to %s impossible", secret.GetName(),
			secret.StatusString(nextState))
	}
	return transitionFunc()
}

// SteadyState returns the transition state of the resource defined as "ready"
func (secret *SSMSecretResource) SteadyState() resourcestatus.ResourceStatus {
	return resourcestatus.ResourceStatus(SSMSecretCreated)
}

// SetKnownStatus safely sets the currently known status of the resource
func (secret *SSMSecretResource) SetKnownStatus(status resourcestatus.ResourceStatus) {
	secret.lock.Lock()
	defer secret.lock.Unlock()

	secret.knownStatusUnsafe = status
	secret.updateAppliedStatusUnsafe(status)
}

// updateAppliedStatusUnsafe updates the resource transitioning status
func (secret *SSMSecretResource) updateAppliedStatusUnsafe(knownStatus resourcestatus.ResourceStatus) {
	if secret.appliedStatus == resourcestatus.ResourceStatus(SSMSecretStatusNone) {
		return
	}

	// Check if the resource transition has already finished
	if secret.appliedStatus <= knownStatus {
		secret.appliedStatus = resourcestatus.ResourceStatus(SSMSecretStatusNone)
	}
}

// SetAppliedStatus sets the applied status of resource and returns whether
// the resource is already in a transition
func (secret *SSMSecretResource) SetAppliedStatus(status resourcestatus.ResourceStatus) bool {
	secret.lock.Lock()
	defer secret.lock.Unlock()

	if secret.appliedStatus != resourcestatus.ResourceStatus(SSMSecretStatusNone) {
		// return false to indicate the set operation failed
		return false
	}

	secret.appliedStatus = status
	return true
}

// GetKnownStatus safely returns the currently known status of the task
func (secret *SSMSecretResource) GetKnownStatus() resourcestatus.ResourceStatus {
	secret.lock.RLock()
	defer secret.lock.RUnlock()

	return secret.knownStatusUnsafe
}

// StatusString returns the string of the cgroup resource status
func (secret *SSMSecretResource) StatusString(status resourcestatus.ResourceStatus) string {
	return SSMSecretStatus(status).String()
}

// SetCreatedAt sets the timestamp for resource's creation time
func (secret *SSMSecretResource) SetCreatedAt(createdAt time.Time) {
	if createdAt.IsZero() {
		return
	}
	secret.lock.Lock()
	defer secret.lock.Unlock()

	secret.createdAt = createdAt
}

// GetCreatedAt sets the timestamp for resource's creation time
func (secret *SSMSecretResource) GetCreatedAt() time.Time {
	secret.lock.RLock()
	defer secret.lock.RUnlock()

	return secret.createdAt
}

// Create fetches secret value from SSM in batches. It spins up multiple goroutines in order to
// retrieve values in parallel.
func (secret *SSMSecretResource) Create() error {

	// To fail fast, check execution role first
	executionCredentials, ok := secret.credentialsManager.GetTaskCredentials(secret.getExecutionCredentialsID())
	if !ok {
		// No need to log here. managedTask.applyResourceState already does that
		err := errors.New("ssm secret resource: unable to find execution role credentials")
		secret.setTerminalReason(err.Error())
		return err
	}
	iamCredentials := executionCredentials.GetIAMRoleCredentials()

	var wg sync.WaitGroup

	// Get the maximum number of errors can be returned, which will be one error per goroutine
	chanLen := secret.getGoRoutineMaxNum()
	errorEvents := make(chan error, chanLen)

	seelog.Infof("ssm secret resource: retrieving secrets for containers in task: [%s]", secret.taskARN)
	secret.secretData = make(map[string]string)

	for region, secrets := range secret.getRequiredSecrets() {
		wg.Add(1)
		// Spin up goroutine each region to speed up processing time
		go secret.retrieveSSMSecretValuesByRegion(region, secrets, iamCredentials, &wg, errorEvents)
	}

	wg.Wait()

	// Get the first error returned and set as terminal reason
	select {
	case err := <-errorEvents:
		secret.setTerminalReason(err.Error())
		return err
	default:
		return nil
	}
}

// getGoRoutineMaxNum calculates the maximum number of goroutines that we need to spin up
// to retrieve secret values from SSM parameter store. Assume each goroutine initiates one
// SSM GetParameters call and each call will have 10 parameters
func (secret *SSMSecretResource) getGoRoutineMaxNum() int {
	total := 0
	for _, secrets := range secret.requiredSecrets {
		total += len(secrets)/MaxBatchNum + 1
	}
	return total
}

// retrieveSSMSecretValuesByRegion reads secret values from cache first, if not exists, batches secrets based on field
// valueFrom and call retrieveSSMSecretValues to retrieve values from SSM
func (secret *SSMSecretResource) retrieveSSMSecretValuesByRegion(region string, secrets []apicontainer.Secret, iamCredentials credentials.IAMRoleCredentials, wg *sync.WaitGroup, errorEvents chan error) {
	seelog.Infof("ssm secret resource: retrieving secrets for region %s in task: [%s]", region, secret.taskARN)
	defer wg.Done()

	var wgPerRegion sync.WaitGroup
	var secretNames []string

	for _, s := range secrets {
		secretKey := s.GetSecretResourceCacheKey()
		if _, ok := secret.GetCachedSecretValue(secretKey); ok {
			continue
		}
		secretNames = append(secretNames, s.ValueFrom)
		if len(secretNames) == MaxBatchNum {
			secretNamesTmp := make([]string, MaxBatchNum)
			copy(secretNamesTmp, secretNames)
			wgPerRegion.Add(1)
			go secret.retrieveSSMSecretValues(region, secretNamesTmp, iamCredentials, &wgPerRegion, errorEvents)
			secretNames = []string{}
		}
	}

	if len(secretNames) > 0 {
		wgPerRegion.Add(1)
		go secret.retrieveSSMSecretValues(region, secretNames, iamCredentials, &wgPerRegion, errorEvents)
	}
	wgPerRegion.Wait()
}

// retrieveSSMSecretValues retrieves secret values from SSM parameter store and caches them into memory
func (secret *SSMSecretResource) retrieveSSMSecretValues(region string, names []string, iamCredentials credentials.IAMRoleCredentials, wg *sync.WaitGroup, errorEvents chan error) {
	defer wg.Done()

	ssmClient, err := secret.ssmClientCreator.NewSSMClient(region, iamCredentials, secret.ipCompatibility)
	if err != nil {
		errorEvents <- fmt.Errorf("unable to create SSM client in %s: %v", region, err)
		return
	}
	seelog.Debugf("ssm secret resource: retrieving resource for secrets %v in region [%s] in task: [%s]", names, region, secret.taskARN)
	secValueMap, err := ssm.GetSecretsFromSSM(names, ssmClient)
	if err != nil {
		errorEvents <- fmt.Errorf("fetching secret data from SSM Parameter Store in %s: %v", region, err)
		return
	}

	secret.lock.Lock()
	defer secret.lock.Unlock()

	// put secret value in secretData
	for secretName, secretValue := range secValueMap {
		secretKey := secretName + "_" + region
		secret.secretData[secretKey] = secretValue
	}
}

// getRequiredSecrets returns the requiredSecrets field of ssmsecret task resource
func (secret *SSMSecretResource) getRequiredSecrets() map[string][]apicontainer.Secret {
	secret.lock.RLock()
	defer secret.lock.RUnlock()

	return secret.requiredSecrets
}

// getExecutionCredentialsID returns the execution role's credential ID
func (secret *SSMSecretResource) getExecutionCredentialsID() string {
	secret.lock.RLock()
	defer secret.lock.RUnlock()

	return secret.executionCredentialsID
}

// Cleanup removes the secret value created for the task
func (secret *SSMSecretResource) Cleanup() error {
	secret.clearSSMSecretValue()
	return nil
}

// clearSSMSecretValue cycles through the collection of secret value data and
// removes them from the task
func (secret *SSMSecretResource) clearSSMSecretValue() {
	secret.lock.Lock()
	defer secret.lock.Unlock()

	for key := range secret.secretData {
		delete(secret.secretData, key)
	}
}

// GetCachedSecretValue retrieves the secret value from secretData field
func (secret *SSMSecretResource) GetCachedSecretValue(secretKey string) (string, bool) {
	secret.lock.RLock()
	defer secret.lock.RUnlock()

	s, ok := secret.secretData[secretKey]
	return s, ok
}

// SetCachedSecretValue set the secret value in the secretData field given the key and value
func (secret *SSMSecretResource) SetCachedSecretValue(secretKey string, secretValue string) {
	secret.lock.Lock()
	defer secret.lock.Unlock()

	if secret.secretData == nil {
		secret.secretData = make(map[string]string)
	}

	secret.secretData[secretKey] = secretValue
}

func (secret *SSMSecretResource) Initialize(
	config *config.Config,
	resourceFields *taskresource.ResourceFields,
	taskKnownStatus status.TaskStatus,
	taskDesiredStatus status.TaskStatus) {
	secret.initStatusToTransition()
	secret.credentialsManager = resourceFields.CredentialsManager
	secret.ssmClientCreator = resourceFields.SSMClientCreator
	secret.ipCompatibility = config.InstanceIPCompatibility

	// if task hasn't turn to 'created' status, and it's desire status is 'running'
	// the resource status needs to be reset to 'NONE' status so the secret value
	// will be retrieved again
	if taskKnownStatus < status.TaskCreated &&
		taskDesiredStatus <= status.TaskRunning {
		secret.SetKnownStatus(resourcestatus.ResourceStatusNone)
	}
}

type SSMSecretResourceJSON struct {
	TaskARN                string                           `json:"taskARN"`
	CreatedAt              *time.Time                       `json:"createdAt,omitempty"`
	DesiredStatus          *SSMSecretStatus                 `json:"desiredStatus"`
	KnownStatus            *SSMSecretStatus                 `json:"knownStatus"`
	RequiredSecrets        map[string][]apicontainer.Secret `json:"secretResources"`
	ExecutionCredentialsID string                           `json:"executionCredentialsID"`
}

// MarshalJSON serialises the SSMSecretResource struct to JSON
func (secret *SSMSecretResource) MarshalJSON() ([]byte, error) {
	if secret == nil {
		return nil, errors.New("ssmsecret resource is nil")
	}
	createdAt := secret.GetCreatedAt()
	return json.Marshal(SSMSecretResourceJSON{
		TaskARN:   secret.taskARN,
		CreatedAt: &createdAt,
		DesiredStatus: func() *SSMSecretStatus {
			desiredState := secret.GetDesiredStatus()
			s := SSMSecretStatus(desiredState)
			return &s
		}(),
		KnownStatus: func() *SSMSecretStatus {
			knownState := secret.GetKnownStatus()
			s := SSMSecretStatus(knownState)
			return &s
		}(),
		RequiredSecrets:        secret.getRequiredSecrets(),
		ExecutionCredentialsID: secret.getExecutionCredentialsID(),
	})
}

// UnmarshalJSON deserialises the raw JSON to a SSMSecretResource struct
func (secret *SSMSecretResource) UnmarshalJSON(b []byte) error {
	temp := SSMSecretResourceJSON{}

	if err := json.Unmarshal(b, &temp); err != nil {
		return err
	}

	if temp.DesiredStatus != nil {
		secret.SetDesiredStatus(resourcestatus.ResourceStatus(*temp.DesiredStatus))
	}
	if temp.KnownStatus != nil {
		secret.SetKnownStatus(resourcestatus.ResourceStatus(*temp.KnownStatus))
	}
	if temp.CreatedAt != nil && !temp.CreatedAt.IsZero() {
		secret.SetCreatedAt(*temp.CreatedAt)
	}
	if temp.RequiredSecrets != nil {
		secret.requiredSecrets = temp.RequiredSecrets
	}
	secret.taskARN = temp.TaskARN
	secret.executionCredentialsID = temp.ExecutionCredentialsID

	return nil
}

// GetAppliedStatus safely returns the currently applied status of the resource
func (secret *SSMSecretResource) GetAppliedStatus() resourcestatus.ResourceStatus {
	secret.lock.RLock()
	defer secret.lock.RUnlock()

	return secret.appliedStatus
}

func (secret *SSMSecretResource) DependOnTaskNetwork() bool {
	return false
}

func (secret *SSMSecretResource) BuildContainerDependency(containerName string, satisfied apicontainerstatus.ContainerStatus,
	dependent resourcestatus.ResourceStatus) {
}

func (secret *SSMSecretResource) GetContainerDependencies(dependent resourcestatus.ResourceStatus) []apicontainer.ContainerDependency {
	return nil
}
