// Package logincreds implements AWS credential provision for sessions created
// via an `aws login` command.
package logincreds

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/internal/sdk"
	"github.com/aws/aws-sdk-go-v2/service/signin"
	"github.com/aws/aws-sdk-go-v2/service/signin/types"
)

// ProviderName identifies the login provider.
const ProviderName = "LoginProvider"

// TokenAPIClient provides the interface for the login session's token
// retrieval operation.
type TokenAPIClient interface {
	CreateOAuth2Token(context.Context, *signin.CreateOAuth2TokenInput, ...func(*signin.Options)) (*signin.CreateOAuth2TokenOutput, error)
}

// Provider supplies credentials for an `aws login` session.
type Provider struct {
	options Options
}

var _ aws.CredentialsProvider = (*Provider)(nil)

// Options configures the Provider.
type Options struct {
	Client TokenAPIClient

	// APIOptions to pass to the underlying CreateOAuth2Token operation.
	ClientOptions []func(*signin.Options)

	// The path to the cached login token.
	CachedTokenFilepath string

	// Whether to restrict file permissions on newly-written cache files.
	// When true, files are created with 0600 on Unix.
	RestrictPermissions bool

	// The chain of providers that was used to create this provider.
	//
	// These values are for reporting purposes and are not meant to be set up
	// directly.
	CredentialSources []aws.CredentialSource
}

// New returns a new login session credentials provider.
func New(client TokenAPIClient, path string, opts ...func(*Options)) *Provider {
	options := Options{
		Client:              client,
		CachedTokenFilepath: path,
	}

	for _, opt := range opts {
		opt(&options)
	}

	return &Provider{options}
}

// Retrieve generates a new set of temporary credentials using an `aws login`
// session.
func (p *Provider) Retrieve(ctx context.Context) (aws.Credentials, error) {
	token, err := p.loadToken()
	if err != nil {
		return aws.Credentials{}, fmt.Errorf("load login token: %w", err)
	}
	if err := token.Validate(); err != nil {
		return aws.Credentials{}, fmt.Errorf("validate login token: %w", err)
	}

	// the token may have been refreshed elsewhere or the login session might
	// have just been created
	if sdk.NowTime().Before(token.AccessToken.ExpiresAt) {
		return token.Credentials(), nil
	}

	opts := make([]func(*signin.Options), len(p.options.ClientOptions)+1)
	opts[0] = addSignDPOP(token)
	copy(opts[1:], p.options.ClientOptions)

	out, err := p.options.Client.CreateOAuth2Token(ctx, &signin.CreateOAuth2TokenInput{
		TokenInput: &types.CreateOAuth2TokenRequestBody{
			ClientId:     aws.String(token.ClientID),
			GrantType:    aws.String("refresh_token"),
			RefreshToken: aws.String(token.RefreshToken),
		},
	}, opts...)
	if err != nil {
		var terr *types.AccessDeniedException
		if errors.As(err, &terr) {
			err = toAccessDeniedError(terr)
		}
		return aws.Credentials{}, fmt.Errorf("create oauth2 token: %w", err)
	}

	token.Update(out)
	if err := p.saveToken(token); err != nil {
		return aws.Credentials{}, fmt.Errorf("save token: %w", err)
	}

	return token.Credentials(), nil
}

// ProviderSources returns the credential chain that was used to construct this
// provider.
func (p *Provider) ProviderSources() []aws.CredentialSource {
	if p.options.CredentialSources == nil {
		return []aws.CredentialSource{aws.CredentialSourceLogin}
	}
	return p.options.CredentialSources
}

func (p *Provider) loadToken() (*loginToken, error) {
	f, err := openFile(p.options.CachedTokenFilepath)
	if err != nil && os.IsNotExist(err) {
		return nil, fmt.Errorf("token file not found, please reauthenticate")
	}
	if err != nil {
		return nil, err
	}
	defer f.Close()

	j, err := io.ReadAll(f)
	if err != nil {
		return nil, err
	}

	var t *loginToken
	if err := json.Unmarshal(j, &t); err != nil {
		return nil, err
	}

	return t, nil
}

func (p *Provider) saveToken(token *loginToken) error {
	j, err := json.Marshal(token)
	if err != nil {
		return err
	}

	mode := os.FileMode(0666) // matches that used by os.Create
	if p.options.RestrictPermissions {
		mode = 0600
	}

	// createFile DOES NOT re-create the file with new permissions if it
	// already exists, so in that scenario any existing permissions are
	// preserved
	f, err := createFile(p.options.CachedTokenFilepath, mode)
	if err != nil {
		return err
	}
	defer f.Close()

	if _, err := f.Write(j); err != nil {
		return err
	}

	return nil
}

func toAccessDeniedError(err *types.AccessDeniedException) error {
	switch err.Error_ {
	case types.OAuth2ErrorCodeTokenExpired:
		return fmt.Errorf("login session has expired, please reauthenticate")
	case types.OAuth2ErrorCodeUserCredentialsChanged:
		return fmt.Errorf("login session password has changed, please reauthenticate")
	case types.OAuth2ErrorCodeInsufficientPermissions:
		return fmt.Errorf("insufficient permissions, you may be missing permissions for the CreateOAuth2Token action")
	default:
		return err
	}
}
