// Copyright 2019 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 secretcache

import (
	"context"
	"fmt"
	"math"
	"math/rand"
	"time"

	"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
)

// secretCacheItem maintains a cache of secret versions.
type secretCacheItem struct {
	versions *lruCache

	// The next scheduled refresh time for this item.  Once the item is accessed
	// after this time, the item will be synchronously refreshed.
	nextRefreshTime int64
	*cacheObject
}

// newSecretCacheItem initialises a secretCacheItem using default cache size and sets next refresh time to now
func newSecretCacheItem(config CacheConfig, client SecretsManagerAPIClient, secretId string) secretCacheItem {
	return secretCacheItem{
		versions:        newLRUCache(10),
		cacheObject:     &cacheObject{config: config, client: client, secretId: secretId, refreshNeeded: true},
		nextRefreshTime: time.Now().UnixNano(),
	}
}

// isRefreshNeeded determines if the cached item should be refreshed.
func (ci *secretCacheItem) isRefreshNeeded() bool {
	if ci.cacheObject.isRefreshNeeded() {
		return true
	}

	return ci.nextRefreshTime <= time.Now().UnixNano()
}

// getVersionId gets the version id for the given version stage.
// Returns the version id and a boolean to indicate success.
func (ci *secretCacheItem) getVersionId(versionStage string) (string, bool) {
	result := ci.getWithHook()
	if result == nil {
		return "", false
	}

	if result.VersionIdsToStages == nil {
		return "", false
	}

	for versionId, stages := range result.VersionIdsToStages {
		for _, stage := range stages {
			if versionStage == stage {
				return versionId, true
			}
		}
	}

	return "", false
}

// executeRefresh performs the actual refresh of the cached secret information.
// Returns the DescribeSecret API result and an error if call failed.
func (ci *secretCacheItem) executeRefresh(ctx context.Context) (*secretsmanager.DescribeSecretOutput, error) {
	input := &secretsmanager.DescribeSecretInput{
		SecretId: &ci.secretId,
	}

	result, err := ci.client.DescribeSecret(ctx, input)

	var maxTTL int64
	if ci.config.CacheItemTTL == 0 {
		maxTTL = DefaultCacheItemTTL
	} else {
		maxTTL = ci.config.CacheItemTTL
	}

	var ttl int64
	if maxTTL < 0 {
		return nil, &InvalidConfigError{
			baseError{
				Message: "cannot set negative ttl on cache",
			},
		}
	} else if maxTTL < 2 {
		ttl = maxTTL
	} else {
		ttl = rand.Int63n(maxTTL/2) + maxTTL/2
	}

	ci.nextRefreshTime = time.Now().Add(time.Nanosecond * time.Duration(ttl)).UnixNano()
	return result, err
}

// getVersion gets the secret cache version associated with the given stage.
// Returns a boolean to indicate operation success.
func (ci *secretCacheItem) getVersion(versionStage string) (*cacheVersion, bool) {
	versionId, versionIdFound := ci.getVersionId(versionStage)
	if !versionIdFound {
		return nil, false
	}

	cachedValue, cachedValueFound := ci.versions.get(versionId)

	if !cachedValueFound {
		cacheVersion := newCacheVersion(ci.config, ci.client, ci.secretId, versionId)
		ci.versions.putIfAbsent(versionId, &cacheVersion)
		cachedValue, _ = ci.versions.get(versionId)
	}

	secretCacheVersion, _ := cachedValue.(*cacheVersion)
	return secretCacheVersion, true
}

// refresh the cached object on demand
func (ci *secretCacheItem) refreshNow(ctx context.Context) {
	ci.refreshNeeded = true
	// Generate a random number to have a sleep jitter to not get stuck in a retry loop
	sleep := rand.Int63n((forceRefreshJitterSleep+1)-(forceRefreshJitterSleep/2)+1) + (forceRefreshJitterSleep / 2)

	if ci.err != nil {
		exceptionSleep := ci.nextRefreshTime - time.Now().UnixNano()
		if exceptionSleep > sleep {
			sleep = exceptionSleep
		}
	}

	time.Sleep(time.Millisecond * time.Duration(sleep))
	ci.refresh(ctx)
}

// refresh the cached object when needed.
func (ci *secretCacheItem) refresh(ctx context.Context) {
	if !ci.isRefreshNeeded() {
		return
	}

	ci.refreshNeeded = false

	result, err := ci.executeRefresh(ctx)

	if err != nil {
		ci.errorCount++
		ci.err = err
		delay := exceptionRetryDelayBase * math.Pow(exceptionRetryGrowthFactor, float64(ci.errorCount))
		delay = math.Min(delay, exceptionRetryDelayMax)
		delayDuration := time.Millisecond * time.Duration(delay)
		ci.nextRetryTime = time.Now().Add(delayDuration).UnixNano()
		return
	}

	ci.setWithHook(result)
	ci.err = nil
	ci.errorCount = 0
}

// getSecretValue gets the cached secret value for the given version stage.
// Returns the GetSecretValue API result and an error if operation fails.
func (ci *secretCacheItem) getSecretValue(ctx context.Context, versionStage string) (*secretsmanager.GetSecretValueOutput, error) {
	if versionStage == "" && ci.config.VersionStage == "" {
		versionStage = DefaultVersionStage
	} else if versionStage == "" && ci.config.VersionStage != "" {
		versionStage = ci.config.VersionStage
	}

	ci.mux.Lock()
	defer ci.mux.Unlock()

	ci.refresh(ctx)
	version, ok := ci.getVersion(versionStage)

	if !ok {
		if ci.err != nil {
			return nil, ci.err
		} else {
			return nil, &VersionNotFoundError{
				baseError{
					Message: fmt.Sprintf("could not find secret version for versionStage %s", versionStage),
				},
			}
		}

	}
	return version.getSecretValue(ctx)
}

// setWithHook sets the cache item's data using the CacheHook, if one is configured.
func (ci *secretCacheItem) setWithHook(result *secretsmanager.DescribeSecretOutput) {
	if ci.config.Hook != nil {
		ci.data = ci.config.Hook.Put(result)
	} else {
		ci.data = result
	}
}

// getWithHook gets the cache item's data using the CacheHook, if one is configured.
func (ci *secretCacheItem) getWithHook() *secretsmanager.DescribeSecretOutput {
	var result interface{}
	if ci.config.Hook != nil {
		result = ci.config.Hook.Get(ci.data)
	} else {
		result = ci.data
	}

	if result == nil {
		return nil
	} else {
		return result.(*secretsmanager.DescribeSecretOutput)
	}
}
