package config

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"runtime"
	"slices"
	"strings"
	"testing"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/aws/middleware"
	"github.com/aws/aws-sdk-go-v2/credentials"
	"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
	"github.com/aws/aws-sdk-go-v2/internal/awstesting"
	"github.com/aws/aws-sdk-go-v2/service/sts"
	"github.com/aws/aws-sdk-go-v2/service/sts/types"
	smithytime "github.com/aws/smithy-go/time"
)

// HTTPClient implementation that captures the User-Agent header and
// the features associated with it.
// why STS? because it's an already existing dependency to this package, but in reality
// any client that uses Credentials would work
type stsCaptureUserAgentFeatures struct {
	ua       string
	features []string
	// lost of credential providers use an intermediate STS client to fetch credentials.
	// capture which features were emitted by that client
	intermediateFeatures []string
}

func (u *stsCaptureUserAgentFeatures) Do(r *http.Request) (*http.Response, error) {
	if err := r.ParseForm(); err != nil {
		return nil, err
	}
	form := r.Form
	switch form.Get("Action") {
	// We use "DecodeAuthorization" as the sentinel value to say "this is the target operation, not an intermediate one"
	case "DecodeAuthorizationMessage":
		u.features = u.captureFeatures(r)
		return &http.Response{StatusCode: 503, Body: http.NoBody}, nil
	case "AssumeRoleWithWebIdentity":
		u.features = u.captureFeatures(r)
		return &http.Response{StatusCode: 200, Body: replaceDateInTemplate(assumeRoleWithWebIdentityResponse)}, nil
	}
	// if none of the above matched, assume his was an intermediate call and return the standard assume role response
	u.intermediateFeatures = u.captureFeatures(r)
	return &http.Response{StatusCode: 200, Body: replaceDateInTemplate(assumeRoleRespMsg)}, nil
}

func (u *stsCaptureUserAgentFeatures) captureFeatures(r *http.Request) []string {
	u.ua = r.Header.Get("User-Agent")
	parts := strings.Split(u.ua, " ")

	var features string
	for _, part := range parts {
		if after, ok := strings.CutPrefix(part, "m/"); ok {
			features = after
			break
		}
	}
	allFeatures := strings.Split(features, ",")
	result := make([]string, 0)
	// calling Split("x", ",") results in `["x"]`, so treat this as a special case
	if len(allFeatures) == 1 {
		result = append(result, allFeatures[0])
		return result
	}

	for _, f := range allFeatures {
		asRune := f[0]
		// only capture features related to credentials providers
		if (asRune >= 'e' && asRune <= 'z') || (asRune == '0') {
			result = append(result, string(asRune))
		}
	}

	return result
}

// bunch of our templates have "%s" as a placeholder for expiration. Using this to quickly create an io readcloser from those
func replaceDateInTemplate(template string) io.ReadCloser {
	t := fmt.Sprintf(template, smithytime.FormatDateTime(time.Now().Add(15*time.Minute)))
	return io.NopCloser(strings.NewReader(t))
}

type mockAssumeRole struct {
	TestInput func(*sts.AssumeRoleInput)
}

func (s mockAssumeRole) AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) {
	if s.TestInput != nil {
		s.TestInput(params)
	}
	expiry := time.Now().Add(60 * time.Minute)

	return &sts.AssumeRoleOutput{
		AssumedRoleUser: &types.AssumedRoleUser{
			Arn: aws.String("arn:aws:sts::111111111111:assumed-role/assume-role-integration-test-role/Name"),
		},
		Credentials: &types.Credentials{
			AccessKeyId:     params.RoleArn,
			SecretAccessKey: aws.String("assumedSecretAccessKey"),
			SessionToken:    aws.String("assumedSessionToken"),
			Expiration:      &expiry,
		},
	}, nil
}

// intercepts calls to IMDS and handles them appropriately
// forwards any that don't match known paths to the inner HTTPClient
type imdsForwarder struct {
	innerClient HTTPClient
}

func (f imdsForwarder) Do(r *http.Request) (*http.Response, error) {
	if r.URL.Path == "/latest/api/token" {
		header := http.Header{}
		// bounce the TTL header
		const ttlHeader = "X-Aws-Ec2-Metadata-Token-Ttl-Seconds"
		header.Set(ttlHeader, r.Header.Get(ttlHeader))
		return &http.Response{StatusCode: 200, Header: header, Body: io.NopCloser(strings.NewReader("validToken"))}, nil
	}
	if r.URL.Path == "/latest/meta-data/iam/security-credentials/" {
		return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("RoleName"))}, nil
	}
	if r.URL.Path == "/latest/meta-data/iam/security-credentials/RoleName" {
		return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(ecsResponse))}, nil
	}
	return f.innerClient.Do(r)
}

type ssoForwarder struct {
	innerClient HTTPClient
}

func (f ssoForwarder) Do(r *http.Request) (*http.Response, error) {
	if strings.Contains(r.URL.Host, "sso") {
		body := fmt.Sprintf(
			getRoleCredentialsResponse,
			time.Now().
				Add(15*time.Minute).
				UnixNano()/int64(time.Millisecond))
		return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil
	}
	return f.innerClient.Do(r)
}

func TestUserAgentCredentials(t *testing.T) {
	restoreEnv := awstesting.StashEnv()
	defer awstesting.PopEnv(restoreEnv)

	cases := map[string]struct {
		// given
		ExtraLoadFunctions []func(*LoadOptions) error
		Init               func(*testing.T)
		HTTPClientOverride func(existing HTTPClient) HTTPClient

		// assert
		Expect                []middleware.UserAgentFeature
		ExpectIntermediateSts []middleware.UserAgentFeature
	}{
		"hardcoded credentials": {
			Expect:             []middleware.UserAgentFeature{middleware.UserAgentFeatureCredentialsCode},
			ExtraLoadFunctions: []func(*LoadOptions) error{WithCredentialsProvider(credentials.NewStaticCredentialsProvider("key", "id", "session"))},
		},
		"environment credentials": {
			Init: func(t *testing.T) {
				t.Setenv("AWS_ACCESS_KEY_ID", "key")
				t.Setenv("AWS_SECRET_ACCESS_KEY", "secret")
			},
			Expect: []middleware.UserAgentFeature{middleware.UserAgentFeatureCredentialsEnvVars},
		},
		"web identity token from profile": {
			Init: func(t *testing.T) {
				t.Setenv("AWS_WEB_IDENTITY_TOKEN_FILE", "testdata/wit.txt")
				t.Setenv("AWS_REGION", "us-east-1")
			},
			ExtraLoadFunctions: []func(*LoadOptions) error{
				WithWebIdentityRoleCredentialOptions(func(options *stscreds.WebIdentityRoleOptions) {
					options.RoleARN = "test-arn"
				})},
			Expect: []middleware.UserAgentFeature{middleware.UserAgentFeatureCredentialsEnvVarsStsWebIDToken},
		},
		"sts assume role": {
			ExtraLoadFunctions: []func(*LoadOptions) error{
				WithCredentialsProvider(stscreds.NewAssumeRoleProvider(mockAssumeRole{}, "role-arn", func(o *stscreds.AssumeRoleOptions) {}))},
			Expect: []middleware.UserAgentFeature{middleware.UserAgentFeatureCredentialsStsAssumeRole},
		},
		"credentials profile": {
			ExtraLoadFunctions: []func(*LoadOptions) error{
				WithSharedCredentialsFiles([]string{"testdata/config_ua_credential_provider"}),
			},
			Expect: []middleware.UserAgentFeature{middleware.UserAgentFeatureCredentialsProfile},
		},
		"assume role with source profile": {
			ExtraLoadFunctions: []func(*LoadOptions) error{
				WithSharedConfigFiles([]string{"testdata/config_ua_credential_provider"}),
				WithSharedConfigProfile("A"),
			},
			Expect: []middleware.UserAgentFeature{
				middleware.UserAgentFeatureCredentialsProfile,
				middleware.UserAgentFeatureCredentialsProfileSourceProfile,
				middleware.UserAgentFeatureCredentialsStsAssumeRole,
			},
			ExpectIntermediateSts: []middleware.UserAgentFeature{
				middleware.UserAgentFeatureCredentialsProfile,
				middleware.UserAgentFeatureCredentialsProfileSourceProfile,
			},
		},
		"named credentials profile http": {
			ExtraLoadFunctions: []func(*LoadOptions) error{
				WithSharedConfigFiles([]string{"testdata/config_ua_credential_provider"}),
				WithSharedConfigProfile("ecscontainer"),
			},
			Init: func(t *testing.T) {
				ecsMetadataServer := httptest.NewServer(http.HandlerFunc(
					func(w http.ResponseWriter, r *http.Request) {
						w.Write([]byte(ecsResponse))
					}))
				t.Setenv("AWS_CONTAINER_CREDENTIALS_FULL_URI", ecsMetadataServer.URL)
				t.Cleanup(ecsMetadataServer.Close)
			},
			Expect: []middleware.UserAgentFeature{
				middleware.UserAgentFeatureCredentialsStsAssumeRole,
				middleware.UserAgentFeatureCredentialsHTTP,
				middleware.UserAgentFeatureCredentialsProfileNamedProvider,
			},
			ExpectIntermediateSts: []middleware.UserAgentFeature{
				middleware.UserAgentFeatureCredentialsHTTP,
				middleware.UserAgentFeatureCredentialsProfileNamedProvider,
			},
		},
		"sso credentials": {
			Init: func(t *testing.T) {
				// filename is the sha1 of the sso_sesion value
				ssoSetup(t, "d033e22ae348aeb5660fc2140aec35850c4da997.json")
			},
			HTTPClientOverride: func(existing HTTPClient) HTTPClient {
				return ssoForwarder{existing}
			},
			Expect: []middleware.UserAgentFeature{
				middleware.UserAgentFeatureCredentialsProfileSso,
				middleware.UserAgentFeatureCredentialsSso,
			},
			ExtraLoadFunctions: []func(*LoadOptions) error{
				WithSharedConfigFiles([]string{"testdata/config_ua_credential_provider"}),
				WithSharedConfigProfile("sso-token"),
			},
		},
		"sso credentials legacy": {
			Init: func(t *testing.T) {
				// filename is sha1 of the host name
				ssoSetup(t, "239e5a291d940f9e746e1c722f1b936d0dc3814d.json")
			},
			HTTPClientOverride: func(existing HTTPClient) HTTPClient {
				return ssoForwarder{existing}
			},
			Expect: []middleware.UserAgentFeature{
				middleware.UserAgentFeatureCredentialsProfileSsoLegacy,
				middleware.UserAgentFeatureCredentialsSsoLegacy,
			},
			ExtraLoadFunctions: []func(*LoadOptions) error{
				WithSharedConfigFiles([]string{"testdata/config_ua_credential_provider"}),
				WithSharedConfigProfile("sso_creds"),
			},
		},
		"profile process": {
			Expect: []middleware.UserAgentFeature{
				middleware.UserAgentFeatureCredentialsProfileProcess,
				middleware.UserAgentFeatureCredentialsProcess,
			},
			ExtraLoadFunctions: []func(*LoadOptions) error{
				func(options *LoadOptions) error {
					if runtime.GOOS == "windows" {
						options.SharedConfigFiles = []string{"testdata/config_ua_credential_provider_windows"}
					} else {
						options.SharedConfigFiles = []string{"testdata/config_ua_credential_provider"}
					}
					return nil
				},
				WithSharedConfigProfile("process"),
			},
		},
		"imds": {
			Init: func(t *testing.T) {
				// Actual value doesn't matter, as long as the env variable is set
				t.Setenv("AWS_EC2_METADATA_SERVICE_ENDPOINT", "http://non-routable-just-setting-to-signal-ec2.invalid")
			},
			HTTPClientOverride: func(existing HTTPClient) HTTPClient {
				return imdsForwarder{existing}
			},
			Expect: []middleware.UserAgentFeature{
				middleware.UserAgentFeatureCredentialsIMDS,
			},
		},
	}

	for name, c := range cases {
		t.Run(name, func(t *testing.T) {
			var ua stsCaptureUserAgentFeatures
			optFns := []func(*LoadOptions) error{
				WithHTTPClient(&ua),
				// If any credentials or config files are set up locally, ignore them
				// to prevent accidentally picking them up and messing up the tests
				WithSharedConfigFiles([]string{""}),
				WithSharedCredentialsFiles([]string{""}),
				WithRetryer(func() aws.Retryer {
					return aws.NopRetryer{}
				}),
				// some endpoint rules seem to require a region set up
				WithRegion("us-east-1"),
			}
			if c.ExtraLoadFunctions != nil {
				optFns = append(optFns, c.ExtraLoadFunctions...)
			}
			if c.Init != nil {
				c.Init(t)
			}
			if c.HTTPClientOverride != nil {
				optFns = append(optFns, WithHTTPClient(c.HTTPClientOverride(&ua)))
			}
			cfg, err := LoadDefaultConfig(context.TODO(), optFns...)
			if err != nil {
				t.Fatalf("expect no error, got %v", err)
			}

			client := sts.NewFromConfig(cfg)
			// doesn't matter, we're just making a call to record user agent
			in := "test"
			client.DecodeAuthorizationMessage(context.TODO(), &sts.DecodeAuthorizationMessageInput{EncodedMessage: &in})
			compareFeatures(t, c.Expect, ua.features)
			if c.ExpectIntermediateSts != nil {
				compareFeatures(t, c.ExpectIntermediateSts, ua.intermediateFeatures)
			}
		})
	}
}

func compareFeatures(t *testing.T, expected []middleware.UserAgentFeature, actual []string) {
	expectedStr := make([]string, 0, len(expected))
	for _, e := range expected {
		expectedStr = append(expectedStr, string(e))
	}

	slices.Sort(expectedStr)
	slices.Sort(actual)
	ok := slices.Equal(actual, expectedStr)
	if !ok {
		t.Errorf("expect %v, got %v", expectedStr, actual)
	}
}

func ssoSetup(t *testing.T, filename string) {
	dir := t.TempDir()
	cacheDir := filepath.Join(dir, ".aws", "sso", "cache")
	err := os.MkdirAll(cacheDir, 0750)
	if err != nil {
		t.Fatal(err)
	}

	tokenFile, err := os.Create(filepath.Join(cacheDir, filename))
	if err != nil {
		t.Fatal(err)
	}

	defer tokenFile.Close()

	tokenContent := fmt.Sprintf(ssoTokenCacheFile, smithytime.FormatDateTime(time.Now().Add(15*time.Minute)))
	_, err = tokenFile.WriteString(tokenContent)
	if err != nil {
		t.Fatal(err)
	}

	if runtime.GOOS == "windows" {
		t.Setenv("USERPROFILE", dir)
	} else {
		t.Setenv("HOME", dir)
	}
}
