package session

import (
	"fmt"
	"os"
	"time"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/awserr"
	"github.com/aws/aws-sdk-go/aws/credentials"
	"github.com/aws/aws-sdk-go/aws/credentials/processcreds"
	"github.com/aws/aws-sdk-go/aws/credentials/ssocreds"
	"github.com/aws/aws-sdk-go/aws/credentials/stscreds"
	"github.com/aws/aws-sdk-go/aws/defaults"
	"github.com/aws/aws-sdk-go/aws/request"
	"github.com/aws/aws-sdk-go/internal/shareddefaults"
	"github.com/aws/aws-sdk-go/service/ssooidc"
	"github.com/aws/aws-sdk-go/service/sts"
)

// CredentialsProviderOptions specifies additional options for configuring
// credentials providers.
type CredentialsProviderOptions struct {
	// WebIdentityRoleProviderOptions configures a WebIdentityRoleProvider,
	// such as setting its ExpiryWindow.
	WebIdentityRoleProviderOptions func(*stscreds.WebIdentityRoleProvider)

	// ProcessProviderOptions configures a ProcessProvider,
	// such as setting its Timeout.
	ProcessProviderOptions func(*processcreds.ProcessProvider)
}

func resolveCredentials(cfg *aws.Config,
	envCfg envConfig, sharedCfg sharedConfig,
	handlers request.Handlers,
	sessOpts Options,
) (*credentials.Credentials, error) {

	switch {
	case len(sessOpts.Profile) != 0:
		// User explicitly provided a Profile in the session's configuration
		// so load that profile from shared config first.
		// Github(aws/aws-sdk-go#2727)
		return resolveCredsFromProfile(cfg, envCfg, sharedCfg, handlers, sessOpts)

	case envCfg.Creds.HasKeys():
		// Environment credentials
		return credentials.NewStaticCredentialsFromCreds(envCfg.Creds), nil

	case len(envCfg.WebIdentityTokenFilePath) != 0:
		// Web identity token from environment, RoleARN required to also be
		// set.
		return assumeWebIdentity(cfg, handlers,
			envCfg.WebIdentityTokenFilePath,
			envCfg.RoleARN,
			envCfg.RoleSessionName,
			sessOpts.CredentialsProviderOptions,
		)

	default:
		// Fallback to the "default" credential resolution chain.
		return resolveCredsFromProfile(cfg, envCfg, sharedCfg, handlers, sessOpts)
	}
}

// WebIdentityEmptyRoleARNErr will occur if 'AWS_WEB_IDENTITY_TOKEN_FILE' was set but
// 'AWS_ROLE_ARN' was not set.
var WebIdentityEmptyRoleARNErr = awserr.New(stscreds.ErrCodeWebIdentity, "role ARN is not set", nil)

// WebIdentityEmptyTokenFilePathErr will occur if 'AWS_ROLE_ARN' was set but
// 'AWS_WEB_IDENTITY_TOKEN_FILE' was not set.
var WebIdentityEmptyTokenFilePathErr = awserr.New(stscreds.ErrCodeWebIdentity, "token file path is not set", nil)

func assumeWebIdentity(cfg *aws.Config, handlers request.Handlers,
	filepath string,
	roleARN, sessionName string,
	credOptions *CredentialsProviderOptions,
) (*credentials.Credentials, error) {

	if len(filepath) == 0 {
		return nil, WebIdentityEmptyTokenFilePathErr
	}

	if len(roleARN) == 0 {
		return nil, WebIdentityEmptyRoleARNErr
	}

	svc := sts.New(&Session{
		Config:   cfg,
		Handlers: handlers.Copy(),
	})

	var optFns []func(*stscreds.WebIdentityRoleProvider)
	if credOptions != nil && credOptions.WebIdentityRoleProviderOptions != nil {
		optFns = append(optFns, credOptions.WebIdentityRoleProviderOptions)
	}

	p := stscreds.NewWebIdentityRoleProviderWithOptions(svc, roleARN, sessionName, stscreds.FetchTokenPath(filepath), optFns...)
	return credentials.NewCredentials(p), nil
}

func resolveCredsFromProfile(cfg *aws.Config,
	envCfg envConfig, sharedCfg sharedConfig,
	handlers request.Handlers,
	sessOpts Options,
) (creds *credentials.Credentials, err error) {

	switch {
	case sharedCfg.SourceProfile != nil:
		// Assume IAM role with credentials source from a different profile.
		creds, err = resolveCredsFromProfile(cfg, envCfg,
			*sharedCfg.SourceProfile, handlers, sessOpts,
		)

	case sharedCfg.Creds.HasKeys():
		// Static Credentials from Shared Config/Credentials file.
		creds = credentials.NewStaticCredentialsFromCreds(
			sharedCfg.Creds,
		)

	case len(sharedCfg.CredentialSource) != 0:
		creds, err = resolveCredsFromSource(cfg, envCfg,
			sharedCfg, handlers, sessOpts,
		)

	case len(sharedCfg.WebIdentityTokenFile) != 0:
		// Credentials from Assume Web Identity token require an IAM Role, and
		// that roll will be assumed. May be wrapped with another assume role
		// via SourceProfile.
		return assumeWebIdentity(cfg, handlers,
			sharedCfg.WebIdentityTokenFile,
			sharedCfg.RoleARN,
			sharedCfg.RoleSessionName,
			sessOpts.CredentialsProviderOptions,
		)

	case sharedCfg.hasSSOConfiguration():
		creds, err = resolveSSOCredentials(cfg, sharedCfg, handlers)

	case len(sharedCfg.CredentialProcess) != 0:
		// Get credentials from CredentialProcess
		var optFns []func(*processcreds.ProcessProvider)
		if sessOpts.CredentialsProviderOptions != nil && sessOpts.CredentialsProviderOptions.ProcessProviderOptions != nil {
			optFns = append(optFns, sessOpts.CredentialsProviderOptions.ProcessProviderOptions)
		}
		creds = processcreds.NewCredentials(sharedCfg.CredentialProcess, optFns...)

	default:
		// Fallback to default credentials provider, include mock errors for
		// the credential chain so user can identify why credentials failed to
		// be retrieved.
		creds = credentials.NewCredentials(&credentials.ChainProvider{
			VerboseErrors: aws.BoolValue(cfg.CredentialsChainVerboseErrors),
			Providers: []credentials.Provider{
				&credProviderError{
					Err: awserr.New("EnvAccessKeyNotFound",
						"failed to find credentials in the environment.", nil),
				},
				&credProviderError{
					Err: awserr.New("SharedCredsLoad",
						fmt.Sprintf("failed to load profile, %s.", envCfg.Profile), nil),
				},
				defaults.RemoteCredProvider(*cfg, handlers),
			},
		})
	}
	if err != nil {
		return nil, err
	}

	if len(sharedCfg.RoleARN) > 0 {
		cfgCp := *cfg
		cfgCp.Credentials = creds
		return credsFromAssumeRole(cfgCp, handlers, sharedCfg, sessOpts)
	}

	return creds, nil
}

func resolveSSOCredentials(cfg *aws.Config, sharedCfg sharedConfig, handlers request.Handlers) (*credentials.Credentials, error) {
	if err := sharedCfg.validateSSOConfiguration(); err != nil {
		return nil, err
	}

	var optFns []func(provider *ssocreds.Provider)
	cfgCopy := cfg.Copy()

	if sharedCfg.SSOSession != nil {
		cfgCopy.Region = &sharedCfg.SSOSession.SSORegion
		cachedPath, err := ssocreds.StandardCachedTokenFilepath(sharedCfg.SSOSession.Name)
		if err != nil {
			return nil, err
		}
		// create oidcClient with AnonymousCredentials to avoid recursively resolving credentials
		mySession := Must(NewSession(&aws.Config{
			Credentials: credentials.AnonymousCredentials,
		}))
		oidcClient := ssooidc.New(mySession, cfgCopy)
		tokenProvider := ssocreds.NewSSOTokenProvider(oidcClient, cachedPath)
		optFns = append(optFns, func(p *ssocreds.Provider) {
			p.TokenProvider = tokenProvider
			p.CachedTokenFilepath = cachedPath
		})
	} else {
		cfgCopy.Region = &sharedCfg.SSORegion
	}

	return ssocreds.NewCredentials(
		&Session{
			Config:   cfgCopy,
			Handlers: handlers.Copy(),
		},
		sharedCfg.SSOAccountID,
		sharedCfg.SSORoleName,
		sharedCfg.SSOStartURL,
		optFns...,
	), nil
}

// valid credential source values
const (
	credSourceEc2Metadata  = "Ec2InstanceMetadata"
	credSourceEnvironment  = "Environment"
	credSourceECSContainer = "EcsContainer"
)

func resolveCredsFromSource(cfg *aws.Config,
	envCfg envConfig, sharedCfg sharedConfig,
	handlers request.Handlers,
	sessOpts Options,
) (creds *credentials.Credentials, err error) {

	switch sharedCfg.CredentialSource {
	case credSourceEc2Metadata:
		p := defaults.RemoteCredProvider(*cfg, handlers)
		creds = credentials.NewCredentials(p)

	case credSourceEnvironment:
		creds = credentials.NewStaticCredentialsFromCreds(envCfg.Creds)

	case credSourceECSContainer:
		if len(os.Getenv(shareddefaults.ECSCredsProviderEnvVar)) == 0 {
			return nil, ErrSharedConfigECSContainerEnvVarEmpty
		}

		p := defaults.RemoteCredProvider(*cfg, handlers)
		creds = credentials.NewCredentials(p)

	default:
		return nil, ErrSharedConfigInvalidCredSource
	}

	return creds, nil
}

func credsFromAssumeRole(cfg aws.Config,
	handlers request.Handlers,
	sharedCfg sharedConfig,
	sessOpts Options,
) (*credentials.Credentials, error) {

	if len(sharedCfg.MFASerial) != 0 && sessOpts.AssumeRoleTokenProvider == nil {
		// AssumeRole Token provider is required if doing Assume Role
		// with MFA.
		return nil, AssumeRoleTokenProviderNotSetError{}
	}

	return stscreds.NewCredentials(
		&Session{
			Config:   &cfg,
			Handlers: handlers.Copy(),
		},
		sharedCfg.RoleARN,
		func(opt *stscreds.AssumeRoleProvider) {
			opt.RoleSessionName = sharedCfg.RoleSessionName

			if sessOpts.AssumeRoleDuration == 0 &&
				sharedCfg.AssumeRoleDuration != nil &&
				*sharedCfg.AssumeRoleDuration/time.Minute > 15 {
				opt.Duration = *sharedCfg.AssumeRoleDuration
			} else if sessOpts.AssumeRoleDuration != 0 {
				opt.Duration = sessOpts.AssumeRoleDuration
			}

			// Assume role with external ID
			if len(sharedCfg.ExternalID) > 0 {
				opt.ExternalID = aws.String(sharedCfg.ExternalID)
			}

			// Assume role with MFA
			if len(sharedCfg.MFASerial) > 0 {
				opt.SerialNumber = aws.String(sharedCfg.MFASerial)
				opt.TokenProvider = sessOpts.AssumeRoleTokenProvider
			}
		},
	), nil
}

// AssumeRoleTokenProviderNotSetError is an error returned when creating a
// session when the MFAToken option is not set when shared config is configured
// load assume a role with an MFA token.
type AssumeRoleTokenProviderNotSetError struct{}

// Code is the short id of the error.
func (e AssumeRoleTokenProviderNotSetError) Code() string {
	return "AssumeRoleTokenProviderNotSetError"
}

// Message is the description of the error
func (e AssumeRoleTokenProviderNotSetError) Message() string {
	return fmt.Sprintf("assume role with MFA enabled, but AssumeRoleTokenProvider session option not set.")
}

// OrigErr is the underlying error that caused the failure.
func (e AssumeRoleTokenProviderNotSetError) OrigErr() error {
	return nil
}

// Error satisfies the error interface.
func (e AssumeRoleTokenProviderNotSetError) Error() string {
	return awserr.SprintError(e.Code(), e.Message(), "", nil)
}

type credProviderError struct {
	Err error
}

func (c credProviderError) Retrieve() (credentials.Value, error) {
	return credentials.Value{}, c.Err
}
func (c credProviderError) IsExpired() bool {
	return true
}
