package processcreds

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"os/exec"
	"runtime"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/internal/sdkio"
)

const (
	// ProviderName is the name this credentials provider will label any
	// returned credentials Value with.
	ProviderName = `ProcessProvider`

	// DefaultTimeout default limit on time a process can run.
	DefaultTimeout = time.Duration(1) * time.Minute
)

// ProviderError is an error indicating failure initializing or executing the
// process credentials provider
type ProviderError struct {
	Err error
}

// Error returns the error message.
func (e *ProviderError) Error() string {
	return fmt.Sprintf("process provider error: %v", e.Err)
}

// Unwrap returns the underlying error the provider error wraps.
func (e *ProviderError) Unwrap() error {
	return e.Err
}

// Provider satisfies the credentials.Provider interface, and is a
// client to retrieve credentials from a process.
type Provider struct {
	// Provides a constructor for exec.Cmd that are invoked by the provider for
	// retrieving credentials. Use this to provide custom creation of exec.Cmd
	// with things like environment variables, or other configuration.
	//
	// The provider defaults to the DefaultNewCommand function.
	commandBuilder NewCommandBuilder

	options Options
}

// Options is the configuration options for configuring the Provider.
type Options struct {
	// Timeout limits the time a process can run.
	Timeout time.Duration
}

// NewCommandBuilder provides the interface for specifying how command will be
// created that the Provider will use to retrieve credentials with.
type NewCommandBuilder interface {
	NewCommand(context.Context) (*exec.Cmd, error)
}

// NewCommandBuilderFunc provides a wrapper type around a function pointer to
// satisfy the NewCommandBuilder interface.
type NewCommandBuilderFunc func(context.Context) (*exec.Cmd, error)

// NewCommand calls the underlying function pointer the builder was initialized with.
func (fn NewCommandBuilderFunc) NewCommand(ctx context.Context) (*exec.Cmd, error) {
	return fn(ctx)
}

// DefaultNewCommandBuilder provides the default NewCommandBuilder
// implementation used by the provider. It takes a command and arguments to
// invoke. The command will also be initialized with the current process
// environment variables, stderr, and stdin pipes.
type DefaultNewCommandBuilder struct {
	Args []string
}

// NewCommand returns an initialized exec.Cmd with the builder's initialized
// Args. The command is also initialized current process environment variables,
// stderr, and stdin pipes.
func (b DefaultNewCommandBuilder) NewCommand(ctx context.Context) (*exec.Cmd, error) {
	var cmdArgs []string
	if runtime.GOOS == "windows" {
		cmdArgs = []string{"cmd.exe", "/C"}
	} else {
		cmdArgs = []string{"sh", "-c"}
	}

	if len(b.Args) == 0 {
		return nil, &ProviderError{
			Err: fmt.Errorf("failed to prepare command: command must not be empty"),
		}
	}

	cmdArgs = append(cmdArgs, b.Args...)
	cmd := exec.CommandContext(ctx, cmdArgs[0], cmdArgs[1:]...)
	cmd.Env = os.Environ()

	cmd.Stderr = os.Stderr // display stderr on console for MFA
	cmd.Stdin = os.Stdin   // enable stdin for MFA

	return cmd, nil
}

// NewProvider returns a pointer to a new Credentials object wrapping the
// Provider.
//
// The provider defaults to the DefaultNewCommandBuilder for creating command
// the Provider will use to retrieve credentials with.
func NewProvider(command string, options ...func(*Options)) *Provider {
	var args []string

	// Ensure that the command arguments are not set if the provided command is
	// empty. This will error out when the command is executed since no
	// arguments are specified.
	if len(command) > 0 {
		args = []string{command}
	}

	commanBuilder := DefaultNewCommandBuilder{
		Args: args,
	}
	return NewProviderCommand(commanBuilder, options...)
}

// NewProviderCommand returns a pointer to a new Credentials object with the
// specified command, and default timeout duration. Use this to provide custom
// creation of exec.Cmd for options like environment variables, or other
// configuration.
func NewProviderCommand(builder NewCommandBuilder, options ...func(*Options)) *Provider {
	p := &Provider{
		commandBuilder: builder,
		options: Options{
			Timeout: DefaultTimeout,
		},
	}

	for _, option := range options {
		option(&p.options)
	}

	return p
}

type credentialProcessResponse struct {
	Version         int
	AccessKeyID     string `json:"AccessKeyId"`
	SecretAccessKey string
	SessionToken    string
	Expiration      *time.Time
}

// Retrieve executes the credential process command and returns the
// credentials, or error if the command fails.
func (p *Provider) Retrieve(ctx context.Context) (aws.Credentials, error) {
	out, err := p.executeCredentialProcess(ctx)
	if err != nil {
		return aws.Credentials{Source: ProviderName}, err
	}

	// Serialize and validate response
	resp := &credentialProcessResponse{}
	if err = json.Unmarshal(out, resp); err != nil {
		return aws.Credentials{Source: ProviderName}, &ProviderError{
			Err: fmt.Errorf("parse failed of process output: %s, error: %w", out, err),
		}
	}

	if resp.Version != 1 {
		return aws.Credentials{Source: ProviderName}, &ProviderError{
			Err: fmt.Errorf("wrong version in process output (not 1)"),
		}
	}

	if len(resp.AccessKeyID) == 0 {
		return aws.Credentials{Source: ProviderName}, &ProviderError{
			Err: fmt.Errorf("missing AccessKeyId in process output"),
		}
	}

	if len(resp.SecretAccessKey) == 0 {
		return aws.Credentials{Source: ProviderName}, &ProviderError{
			Err: fmt.Errorf("missing SecretAccessKey in process output"),
		}
	}

	creds := aws.Credentials{
		Source:          ProviderName,
		AccessKeyID:     resp.AccessKeyID,
		SecretAccessKey: resp.SecretAccessKey,
		SessionToken:    resp.SessionToken,
	}

	// Handle expiration
	if resp.Expiration != nil {
		creds.CanExpire = true
		creds.Expires = *resp.Expiration
	}

	return creds, nil
}

// executeCredentialProcess starts the credential process on the OS and
// returns the results or an error.
func (p *Provider) executeCredentialProcess(ctx context.Context) ([]byte, error) {
	if p.options.Timeout >= 0 {
		var cancelFunc func()
		ctx, cancelFunc = context.WithTimeout(ctx, p.options.Timeout)
		defer cancelFunc()
	}

	cmd, err := p.commandBuilder.NewCommand(ctx)
	if err != nil {
		return nil, err
	}

	// get creds json on process's stdout
	output := bytes.NewBuffer(make([]byte, 0, int(8*sdkio.KibiByte)))
	if cmd.Stdout != nil {
		cmd.Stdout = io.MultiWriter(cmd.Stdout, output)
	} else {
		cmd.Stdout = output
	}

	execCh := make(chan error, 1)
	go executeCommand(cmd, execCh)

	select {
	case execError := <-execCh:
		if execError == nil {
			break
		}
		select {
		case <-ctx.Done():
			return output.Bytes(), &ProviderError{
				Err: fmt.Errorf("credential process timed out: %w", execError),
			}
		default:
			return output.Bytes(), &ProviderError{
				Err: fmt.Errorf("error in credential_process: %w", execError),
			}
		}
	}

	out := output.Bytes()
	if runtime.GOOS == "windows" {
		// windows adds slashes to quotes
		out = bytes.ReplaceAll(out, []byte(`\"`), []byte(`"`))
	}

	return out, nil
}

func executeCommand(cmd *exec.Cmd, exec chan error) {
	// Start the command
	err := cmd.Start()
	if err == nil {
		err = cmd.Wait()
	}

	exec <- err
}
