package manager

import (
	"context"
	"errors"
	"fmt"
	"strconv"
	"strings"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)

// PatchInput in the input to Patch. nil pointers mean no change.
type PatchInput struct {
	// primary key - should be set in the usually
	Name string `dynamodbav:"-"`

	// patches
	Plaintext      []byte       `json:"plaintext,omitempty" dynamodbav:"-"`
	DoNotBroadcast *bool        `json:"do_not_broadcast,omitempty" dynamodbav:"do_not_broadcast"`
	Class          *SecretClass `json:"class,omitempty" dynamodbav:"class"`
	CrossEnv       *bool        `json:"cross_env,omitempty" dynamodbav:"cross_env"`

	// Autogenerate fills Plaintext with a randomly server-side generated value
	Autogenerate *FillPlaintextRequest `json:"autogenerate"`

	// these should be set if Plaintext was updated
	UpdatedAt *int64  `dynamodbav:"updated_at"`
	Key       *string `dynamodbav:"key"`
	Value     *string `dynamodbav:"value"`
	KeyARN    *string `dynamodbav:"key_arn"`

	// true if validated at least once
	validated bool

	//Store AD user name performing patch on this secret
	ActionUser *string
}

// Validate parameters
func (p *PatchInput) Validate() (err error) {
	if p.validated {
		return
	}

	if !validateSecretName(p.Name) {
		return errInvalidSecretName
	}

	if p.Autogenerate != nil && p.Plaintext != nil {
		return ErrAutogenerateAndPlaintextBothSent
	}

	if p.Autogenerate != nil {
		err = p.Autogenerate.Validate()
		if err != nil {
			return
		}
	}

	if (p.UpdatedAt != nil) || (p.Key != nil) || (p.Value != nil) || (p.KeyARN != nil) {
		return errors.New("attributes UpdatedAt, Key, Value, KeyARN should not be sent to be patched")
	}

	if p.Class != nil && (*p.Class < 1 || 4 < *p.Class) {
		return ErrInvalidClass
	}

	p.validated = true
	return
}

func (p *PatchInput) hasPlaintextUpdates() bool {
	return p.Autogenerate != nil || len(p.Plaintext) > 0
}

// Patch will take a secrest object and
// -Insert it into DynamoDB if secrets have changed. OR
// - Update the class and DoNotBroadcast of the existing item
// This+POST will eventually replace PUT method.
func (m *Manager) Patch(patchInput *PatchInput) error {
	if err := m.patch(patchInput, true); err != nil {
		return err
	}

	if patchInput.hasPlaintextUpdates() {
		return m.cleanUpSecretColumns(context.Background(), patchInput.Name)
	}

	return nil
}

func (m *Manager) patch(patchInput *PatchInput, updateAuditTable bool) (err error) {

	err = m.statter.WithMeasuredResult("secret.patch", nil, func() (err error) {
		err = patchInput.Validate()
		if err != nil {
			return
		}

		var oldSecret *Secret
		oldSecret, err = m.GetEncrypted(patchInput.Name)

		if err != nil {
			return err
		}
		if oldSecret == nil {
			//It has verified that the secert exist, If we get here its most likely a dev error
			err = ErrSecretDoesNotExist
			return
		}

		var updatedAt int64
		// if Plaintext is patched, we will want to Seal and use the new UpdatedAt for the audit table
		// else, use the old UpdatedAt for the audit table
		if patchInput.hasPlaintextUpdates() {
			err = m.sealPatchInputPlaintext(patchInput)
			if err != nil {
				return
			}
			updatedAt = *patchInput.UpdatedAt
		} else {
			updatedAt = oldSecret.UpdatedAt
		}

		updateInput, err := m.inputForSecretUpdate(patchInput, m.Config.stack().SecretsTableName())
		if err != nil {
			return
		}
		if updateInput == nil {
			// no changes
			return
		}

		err = m.updateItemWithMetrics(updateInput)
		if err != nil {
			return err
		}

		if updateAuditTable {
			updateInput, err = m.inputForAuditUpdate(patchInput, m.AuditTableName(), updatedAt)
			if err != nil {
				return
			}
			if updateInput == nil {
				// no changes
				return
			}
			err = m.updateItemWithMetrics(updateInput)
			if err != nil {
				return
			}
		}
		return
	})
	return
}

func (m *Manager) sealPatchInputPlaintext(patchInput *PatchInput) (err error) {
	secret := &Secret{
		Name:      patchInput.Name,
		Plaintext: patchInput.Plaintext,
	}

	if patchInput.Autogenerate != nil {
		err = secret.FillPlaintext(patchInput.Autogenerate)
		if err != nil {
			return
		}
	}

	err = m.Seal(secret)
	if err != nil {
		return
	}

	ddbSecret := secret.asDynamoSecret()
	patchInput.UpdatedAt = &ddbSecret.UpdatedAt
	patchInput.Key = &ddbSecret.Key
	patchInput.Value = &ddbSecret.Value
	patchInput.KeyARN = &ddbSecret.KeyARN

	return
}

// Post will take a plaintext secret, encrypt and then insert a new record in DynamoDB
func (m *Manager) Post(secret *Secret) error {
	err := m.statter.WithMeasuredResult("secret.post", nil, func() error {
		oldSecret, err := m.GetEncrypted(secret.Name)
		if err != nil {
			return err
		}
		if oldSecret != nil {
			//It has verified that the secert exist, If we get here its most likely a dev error
			return fmt.Errorf("Secret %s already found. Please use 'PATCH' to update a secret", secret.Name)
		}
		return m.put(secret, true)
	})

	return err
}

//Put @Deprecated
// Put will take a plaintext secret, encrypt and then insert a new record in DynamoDB
func (m *Manager) Put(secret *Secret) error {
	if err := m.statter.WithMeasuredResult("secret.put", nil, func() error {
		return m.put(secret, true)
	}); err != nil {
		return err
	}

	return m.cleanUpSecretColumns(context.Background(), secret.Name)
}

func (m *Manager) put(secret *Secret, updateAuditTable bool) error {
	if !validateSecretName(secret.Name) {
		return errInvalidSecretName
	}
	// Create the newest version of the secret and persist it
	if err := m.Seal(secret); err != nil {
		return err
	}
	err := m.putItem(secret, updateAuditTable)
	return err
}

func (m *Manager) putItem(secret *Secret, updateAuditTable bool) (err error) {

	putInput, err := m.inputForPut(secret)
	if err != nil {
		return
	}

	err = m.putItemWithMetrics(putInput)
	if err != nil {
		return
	}

	//@TODO: @bach/@jon I think we should use lambda to update the audit/namespace table
	// to control what goes go in those table and when
	if updateAuditTable {
		putInput, err = m.inputForAuditPut(secret)
		if err != nil {
			return
		}
		err = m.putItemWithMetrics(putInput)
		if err != nil {
			return
		}
	}

	//@TODO @bach/@jon should we get rid of namespace table ?
	nsInput := m.inputForNamespacePut(SecretNameToNamespace(secret.Name))
	err = m.putItemWithMetrics(nsInput)
	if err != nil {
		return
	}

	return
}

func (m *Manager) putItemWithMetrics(input *dynamodb.PutItemInput) error {
	_, err := m.dynamoDB().PutItem(input)
	if err != nil {
		return fmt.Errorf("Error writing to %s: %s", *input.TableName, err)
	}
	return nil
}

func (m *Manager) updateItemWithMetrics(input *dynamodb.UpdateItemInput) error {
	_, err := m.dynamoDB().UpdateItem(input)
	if err != nil {
		return fmt.Errorf("Error writing to %s: %s", *input.TableName, err)
	}
	return nil
}

// SecretNameToNamespace strips the namespace from a secret's Name
func SecretNameToNamespace(secretName string) string {
	splitSecret := strings.SplitN(secretName, "/", 2)
	return splitSecret[0]
}

func (m *Manager) inputForPut(secret *Secret) (input *dynamodb.PutItemInput, err error) {
	item, err := dynamodbattribute.MarshalMap(secret.asDynamoSecret())
	if err != nil {
		return
	}

	input = &dynamodb.PutItemInput{
		Item:                   item,
		ReturnConsumedCapacity: aws.String("INDEXES"),
		TableName:              aws.String(m.Config.stack().SecretsTableName()),
	}
	return
}

func (m *Manager) inputForAuditPut(secret *Secret) (input *dynamodb.PutItemInput, err error) {
	item, err := dynamodbattribute.MarshalMap(m.secretToDynamodbSecretAudit(secret))
	if err != nil {
		return
	}
	input = &dynamodb.PutItemInput{
		Item:                   item,
		ReturnConsumedCapacity: aws.String("INDEXES"),
		TableName:              aws.String(m.AuditTableName()),
	}
	return
}

func (m *Manager) inputForSecretUpdate(patchInput *PatchInput, tableName string) (*dynamodb.UpdateItemInput, error) {
	key := map[string]*dynamodb.AttributeValue{
		"name": {S: aws.String(patchInput.Name)},
	}
	return m.inputForUpdate(patchInput, key, tableName)
}

func (m *Manager) inputForAuditUpdate(patchInput *PatchInput, tableName string, updatedAt int64) (*dynamodb.UpdateItemInput, error) {
	// no need to update updatedAt for audit table since it's the range key
	patchInputCopy := *patchInput
	patchInputCopy.UpdatedAt = nil

	if patchInputCopy.ActionUser == nil {
		patchInputCopy.ActionUser = &m.Config.ActionUser
	}

	key := map[string]*dynamodb.AttributeValue{
		"name":       {S: aws.String(patchInput.Name)},
		"updated_at": {N: aws.String(strconv.FormatInt(updatedAt, 10))},
	}
	return m.inputForUpdate(&patchInputCopy, key, tableName)
}

// build a secret update request
func (m *Manager) inputForUpdate(
	patchInput *PatchInput,
	key map[string]*dynamodb.AttributeValue,
	tableName string,
) (
	// input to dynamodb.UpdateItem. If no changes are provided, this will be nil.
	updateInput *dynamodb.UpdateItemInput,
	err error,
) {
	updateExpressionClauses := []string{}
	expressionAttributeNames := map[string]*string{}
	expressionAttributeValues := map[string]*dynamodb.AttributeValue{}

	updates, err := dynamodbattribute.MarshalMap(patchInput)
	if err != nil {
		return
	}

	for key, attributeValue := range updates {
		if attributeValue.NULL != nil && *attributeValue.NULL {
			continue
		}

		attrExpName := "#" + key
		attrExpValue := ":" + key
		updateExpressionClauses = append(updateExpressionClauses, attrExpName+" = "+attrExpValue)
		expressionAttributeNames[attrExpName] = aws.String(key)
		expressionAttributeValues[attrExpValue] = attributeValue
	}

	if tableName == m.AuditTableName() {
		serviceName := m.Config.ServiceName
		if serviceName == "" {
			serviceName = unknownServiceName
		}
		auditTableKeyMap := map[string]interface{}{
			dynamoDBKeyActionUser:  *patchInput.ActionUser,
			dynamoDBKeyServiceName: serviceName,
		}

		for key, value := range auditTableKeyMap {
			attrExpName := "#" + key
			attrExpValue := ":" + key
			updateExpressionClauses = append(updateExpressionClauses, attrExpName+" = "+attrExpValue)
			expressionAttributeNames[attrExpName] = aws.String(key)
			expressionAttributeValues[attrExpValue] = &dynamodb.AttributeValue{
				S: aws.String(value.(string)),
			}
		}
	}

	if len(updateExpressionClauses) == 0 {
		return
	}

	updateExpression := "SET " + strings.Join(updateExpressionClauses, ", ")

	updateInput = &dynamodb.UpdateItemInput{
		Key:                       key,
		TableName:                 aws.String(tableName),
		ReturnConsumedCapacity:    aws.String("INDEXES"),
		UpdateExpression:          aws.String(updateExpression),
		ExpressionAttributeNames:  expressionAttributeNames,
		ExpressionAttributeValues: expressionAttributeValues,
	}
	return
}

func (m *Manager) inputForNamespacePut(namespace string) *dynamodb.PutItemInput {
	return &dynamodb.PutItemInput{
		Item: map[string]*dynamodb.AttributeValue{
			"namespace": {S: aws.String(namespace)},
		},
		TableName:              aws.String(m.NamespaceTableName()),
		ReturnConsumedCapacity: aws.String("INDEXES"),
	}
}

func (m *Manager) secretToDynamodbSecretAudit(s *Secret) (auditSecret *dynamoSecretAudit) {
	ddbSecret := s.asDynamoSecret()
	serviceName := m.Config.ServiceName
	if serviceName == "" {
		serviceName = unknownServiceName
	}

	if s.ActionUser == "" {
		s.ActionUser = m.Config.ActionUser
	}

	auditSecret = &dynamoSecretAudit{
		dynamoSecret: *ddbSecret,
		ActionUser:   s.ActionUser,
		ServiceName:  m.Config.ServiceName,
	}
	return
}

func (m *Manager) setPreviousVersion(secret *Secret, prevSecretVersion int64) (err error) {

	input := &dynamodb.UpdateItemInput{
		Key: map[string]*dynamodb.AttributeValue{
			"name":       {S: aws.String(secret.Name)},
			"updated_at": {N: aws.String(strconv.FormatInt(secret.UpdatedAt, 10))},
		},
		ExpressionAttributeNames: map[string]*string{
			"#pv": aws.String(dynamoDBKeyPreviousVersion),
		},
		ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
			":pv": {
				N: aws.String(strconv.FormatInt(prevSecretVersion, 10)),
			},
		},
		UpdateExpression:       aws.String(fmt.Sprintf("SET #pv = :pv")),
		ReturnConsumedCapacity: aws.String("INDEXES"),
		TableName:              aws.String(m.AuditTableName()),
	}
	_, err = m.dynamoDB().UpdateItem(input)
	return
}
