package s3

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"reflect"
	"strings"
	"testing"
	"time"

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

func TestPresignPutObject(t *testing.T) {
	fixedTime := time.Date(2022, time.February, 1, 0, 0, 0, 0, time.UTC)
	defer mockTime(fixedTime)()

	cases := map[string]struct {
		input            PutObjectInput
		options          []func(*PresignPostOptions)
		expectedExpires  time.Time
		expectedURL      string
		region           string
		pathStyleEnabled bool
		BaseEndpoint     string
	}{
		"sample": {
			input: PutObjectInput{
				Bucket: aws.String("bucket"),
				Key:    aws.String("key"),
			},
		},
		"bucket and key have the same value": {
			input: PutObjectInput{
				Bucket: aws.String("bucket"),
				Key:    aws.String("bucket"),
			},
		},
		"expires override": {
			input: PutObjectInput{
				Bucket: aws.String("bucket"),
				Key:    aws.String("key"),
			},
			expectedExpires: fixedTime.Add(5 * time.Minute),
			options: []func(o *PresignPostOptions){
				func(o *PresignPostOptions) {
					o.Expires = 5 * time.Minute
				},
			},
		},
		"body is ignored": {
			input: PutObjectInput{
				Bucket: aws.String("bucket"),
				Key:    aws.String("key"),
				// This will be ignored
				Body: strings.NewReader("hello-world"),
			},
		},
		"different region": {
			input: PutObjectInput{
				Bucket: aws.String("bucket"),
				Key:    aws.String("key"),
			},
			region:      "eu-central-1",
			expectedURL: "https://bucket.s3.eu-central-1.amazonaws.com",
		},
		"mrap endpoint is changed": {
			input: PutObjectInput{
				Bucket: aws.String("arn:aws:s3::123456789012:accesspoint:mfzwi23gnjvgw.mrap"),
				Key:    aws.String("mockkey"),
			},
			expectedURL: "https://mfzwi23gnjvgw.mrap.accesspoint.s3-global.amazonaws.com",
		},
		"use path style bucket hosting pattern": {
			input: PutObjectInput{
				Bucket: aws.String("bucket"),
				Key:    aws.String("key"),
			},
			expectedURL:      "https://s3.us-west-2.amazonaws.com/bucket",
			pathStyleEnabled: true,
		},
		"use path style bucket and key have the same value ": {
			input: PutObjectInput{
				Bucket: aws.String("value"),
				Key:    aws.String("value"),
			},
			expectedURL:      "https://s3.us-west-2.amazonaws.com/value",
			pathStyleEnabled: true,
		},
		"use path style bucket with custom baseEndpoint": {
			input: PutObjectInput{
				Bucket: aws.String("bucket"),
				Key:    aws.String("key"),
			},
			expectedURL:      "https://s3.custom-domain.com/bucket",
			pathStyleEnabled: true,
			BaseEndpoint:     "https://s3.custom-domain.com",
		},
		"use path style bucket with custom baseEndpoint with path": {
			input: PutObjectInput{
				Bucket: aws.String("bucket"),
				Key:    aws.String("key"),
			},
			BaseEndpoint:     "https://my-custom-domain.com/path_my_path",
			pathStyleEnabled: true,
			expectedURL:      "https://my-custom-domain.com/path_my_path/bucket",
		},
	}
	for name, tc := range cases {
		t.Run(name, func(t *testing.T) {
			ctx := context.Background()
			region := "us-west-2"
			if tc.region != "" {
				region = tc.region
			}
			cfg := aws.Config{
				Region:      region,
				Credentials: unit.StubCredentialsProvider{},
				Retryer: func() aws.Retryer {
					return aws.NopRetryer{}
				},
			}
			presignClient := NewPresignClient(NewFromConfig(cfg, func(options *Options) {
				options.UsePathStyle = tc.pathStyleEnabled
				if tc.BaseEndpoint != "" {
					options.BaseEndpoint = aws.String(tc.BaseEndpoint)
				}
			}))
			postObject, err := presignClient.PresignPostObject(ctx, &tc.input, tc.options...)
			if err != nil {
				t.Error(err)
			}
			if postObject == nil {
				t.Error("expected non-nil postObject")
			}
			if tc.expectedURL != "" {
				if tc.expectedURL != postObject.URL {
					t.Errorf("expected URL %q; got %q", tc.expectedURL, postObject.URL)
				}
			} else {
				if "https://bucket.s3.us-west-2.amazonaws.com" != postObject.URL {
					t.Error("expected URL to contain 'https://amazon.com', was: ", postObject.URL)
				}
			}

			if len(postObject.Values) < 1 {
				t.Error("expected non-empty values")
			}
			policy, ok := postObject.Values["policy"]
			if !ok {
				t.Error("expected non-empty policy on postObject")
			}
			decoded, err := base64.StdEncoding.DecodeString(policy)
			if err != nil {
				t.Error("expected base64 encoded policy, got error", err, "policy", policy)
			}
			var policyJSON map[string]any
			err = json.Unmarshal(decoded, &policyJSON)
			if err != nil {
				t.Error("expected valid JSON for policy, got error", err, "with policy", policy)
			}
			actualExpires, ok := policyJSON["expiration"]
			if !ok {
				t.Error("expected non-empty expiration on policy JSON policy", policyJSON)
			}

			if !time.Time.IsZero(tc.expectedExpires) {
				isEqual, err := isTimeEqual(actualExpires.(string), tc.expectedExpires)
				if err != nil {
					t.Error("Error parsing expires", actualExpires, err)
				}
				if !isEqual {
					t.Error("expected expiration to be", tc.expectedExpires, "got", actualExpires)
				}
			} else {
				// Check the default is set. Go serializes JSON values as RFC3339
				expectedExpires := fixedTime.Add(15 * time.Minute).Format(time.RFC3339)
				if actualExpires != expectedExpires {
					t.Error("expected expiration to be", expectedExpires, "got", actualExpires)
				}
			}
		})
	}
}

// Test that comes straight from the docs https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html
// Unfortunately it can't be verified with the exact same values
// since the sample in the docs lowercases all headers `x-amzn-header`
// while the SDK does not `X-Amzn-Header`, so the signature and policy are different.
// However, the values have been manually inspected to match the desired output
func TestSampleFromPublicDocs(t *testing.T) {
	accessKeyID := "AKIAIOSFODNN7EXAMPLE"
	secretAccessKey := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
	bucket := "sigv4examplebucket"
	key := "user/user1"
	testTime := time.Date(2015, time.December, 29, 0, 0, 0, 0, time.UTC)
	defer mockTime(testTime)()
	expiresIn := 36 * time.Hour
	staticCredentials := staticCredentialsProvider{Key: accessKeyID, Secret: secretAccessKey}
	ctx := context.Background()

	cfg := aws.Config{
		Region:      "us-east-1",
		Credentials: staticCredentials,
		Retryer: func() aws.Retryer {
			return aws.NopRetryer{}
		},
	}

	presignClient := NewPresignClient(NewFromConfig(cfg))
	input := PutObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)}
	conditions := []any{
		[]any{"starts-with", "$key", "user/user1/"},
		map[string]string{"acl": "public-read"},
		map[string]string{"success_action_redirect": "http://sigv4examplebucket.s3.amazonaws.com/successful_upload.html"},
		[]any{"starts-with", "$Content-Type", "image/"},
		map[string]string{"x-amz-meta-uuid": "14365123651274"},
		[]any{"starts-with", "$x-amz-meta-tag", ""},
	}
	opts := func(o *PresignPostOptions) {
		o.Expires = expiresIn
		o.Conditions = conditions
	}
	postObject, err := presignClient.PresignPostObject(ctx, &input, opts)
	if err != nil {
		t.Error(err)
	}
	if postObject == nil {
		t.Error("expected non-nil postObject")
	}
	values := postObject.Values
	signature, ok := values["X-Amz-Signature"]
	if !ok {
		t.Error("expected non-empty signature on postObject", values)
	}
	// Signature and policy are VERY sensitive to any change in output or order. If these tests fail,
	// it can be due to a change in order for the policy or a change in capitalization
	if signature != "41eb7f468113e77dca133475d38815dbe1f92b073964f4a0575f036e9c02d28a" {
		t.Error("expected signature to equal to be precomputed", signature, "got", values)
	}
	policy, ok := values["policy"]
	if !ok {
		t.Error("expected non-empty policy on values", values)
	}
	expectedPolicy := "eyJjb25kaXRpb25zIjpbeyJYLUFtei1BbGdvcml0aG0iOiJBV1M0LUhNQUMtU0hBMjU2In0seyJidWN" +
		"rZXQiOiJzaWd2NGV4YW1wbGVidWNrZXQifSx7IlgtQW16LUNyZWRlbnRpYWwiOiJBS0lBSU9TRk9ETk" +
		"43RVhBTVBMRS8yMDE1MTIyOS91cy1lYXN0LTEvczMvYXdzNF9yZXF1ZXN0In0seyJYLUFtei1EYXRlI" +
		"joiMjAxNTEyMjlUMDAwMDAwWiJ9LFsic3RhcnRzLXdpdGgiLCIka2V5IiwidXNlci91c2VyMS8iXSx7" +
		"ImFjbCI6InB1YmxpYy1yZWFkIn0seyJzdWNjZXNzX2FjdGlvbl9yZWRpcmVjdCI6Imh0dHA6Ly9zaWd" +
		"2NGV4YW1wbGVidWNrZXQuczMuYW1hem9uYXdzLmNvbS9zdWNjZXNzZnVsX3VwbG9hZC5odG1sIn0sWy" +
		"JzdGFydHMtd2l0aCIsIiRDb250ZW50LVR5cGUiLCJpbWFnZS8iXSx7IngtYW16LW1ldGEtdXVpZCI6I" +
		"jE0MzY1MTIzNjUxMjc0In0sWyJzdGFydHMtd2l0aCIsIiR4LWFtei1tZXRhLXRhZyIsIiJdXSwiZXhw" +
		"aXJhdGlvbiI6IjIwMTUtMTItMzBUMTI6MDA6MDBaIn0="
	if policy != expectedPolicy {
		t.Error("expected policy to equal", expectedPolicy, "got", policy)
	}
}

func TestBuildPresignPostRequest(t *testing.T) {
	cases := map[string]struct {
		credentials       aws.Credentials
		extraConditions   []any
		isKeyConditionSet bool
	}{
		"credentials without access token": {
			credentials:     credentialsNoToken,
			extraConditions: []any{},
		},
		"credentials with access token": {
			credentials:     credentialsWithToken,
			extraConditions: []any{},
		},
		"no extra conditions": {
			credentials:     credentialsWithToken,
			extraConditions: []any{},
		},
		"extra conditions": {
			credentials: credentialsWithToken,
			extraConditions: []any{
				map[string]string{"acl": "public-read"},
				[]string{"starts-with", "$Content-Type", "image/"},
			},
		},
		"extra conditions collision": {
			credentials: credentialsWithToken,
			extraConditions: []any{
				map[string]string{"bucket": "otherBucket"},
			},
		},
		"a key condition is set, no extra one is generated": {
			credentials: credentialsNoToken,
			extraConditions: []any{
				[]any{"starts-with", "$key", "user/user1/"},
			},
			isKeyConditionSet: true,
		},
	}
	requiredFields := []string{
		"X-Amz-Algorithm",
		"X-Amz-Credential",
		"X-Amz-Date",
		"X-Amz-Signature",
		"key",
		"policy",
	}

	requiredConditions := []string{"X-Amz-Algorithm", "bucket", "X-Amz-Credential", "X-Amz-Date"}

	for name, tc := range cases {
		t.Run(name, func(t *testing.T) {
			target := postSignAdapter{}
			aBucketKey := "someKey"
			bucket := "someBucket"
			signingTime := sdk.NowTime()
			expiration := signingTime.Add(time.Hour)
			fields, err := target.PresignPost(tc.credentials, bucket, aBucketKey, "region", "service", signingTime, tc.extraConditions, expiration)
			if err != nil {
				t.Errorf("PresignPostHTTP returned unexepected error: %s", err.Error())
			}
			if len(fields) == 0 {
				t.Errorf("PresignPostHTTP returned no fields")
			}

			for _, field := range requiredFields {
				_, ok := fields[field]
				if !ok {
					t.Errorf("Fields response did not contain required key %s. Res %v", field, fields)
				}
			}

			if tc.credentials.SessionToken != "" {
				_, ok := fields["X-Amz-Security-Token"]
				if !ok {
					t.Errorf("Credentials are using a session token, but is not set on the fields response")
				}
			}

			actualKey := fields["key"]
			if actualKey != aBucketKey {
				t.Errorf("PresignPostHTTP did not contain expected \"key\" %s. Has %s", aBucketKey, actualKey)
			}
			policy := fields["policy"]
			decoded, err := base64.StdEncoding.DecodeString(policy)
			if err != nil {
				t.Errorf("Decoding policy document %s failed with error %v", policy, err)
			}
			var doc map[string]any
			err = json.Unmarshal(decoded, &doc)
			if err != nil {
				t.Errorf("Policy document %s failed to parse to JSON with error %v", policy, err)
			}
			_, ok := doc["conditions"]
			if !ok {
				t.Errorf("Conditions field not present in policy document %s", policy)
			}
			exp, ok := doc["expiration"]
			if !ok {
				t.Errorf("Expiration field not present in policy document %s", policy)
			}
			docExpiration, ok := exp.(string)
			if !ok {
				t.Errorf("Expiration field is not a time as expected, is %v", doc["expiration"])
			}
			isEqual, err := isTimeEqual(docExpiration, expiration)
			if err != nil {
				t.Errorf("PresignPost did not parse expiration time %s. Error %v", docExpiration, err)
			}
			if !isEqual {
				t.Errorf("Expected policy expiration to be %v. Got %v", expiration, docExpiration)
			}
			conditions := doc["conditions"].([]any)
			if len(conditions) == 0 {
				t.Errorf("Policy document didn't contain any conditions")
			}
			for _, required := range requiredConditions {
				val := findInSlice(conditions, required)
				if val == nil {
					t.Errorf("Policy document didn't contain required conditions %s. Has %v", required, conditions)
				}
			}
			actualBucket := findInSlice(conditions, "bucket")
			if !reflect.DeepEqual(bucket, actualBucket) {
				t.Errorf("Expected bucket to be %v, was %v", bucket, actualBucket)
			}
			actualDate := findInSlice(conditions, "X-Amz-Date")
			signingTimeStr := signingTime.UTC().Format("20060102T150405Z")
			if signingTimeStr != actualDate {
				t.Errorf("Expected date to be %v, was %v", signingTimeStr, actualDate)
			}
			if len(tc.extraConditions) > 0 {
				for _, ec := range tc.extraConditions {
					if !isPresent(ec, conditions) {
						t.Errorf("Expected item %v not found on conditions %v", ec, conditions)
					}
				}
			}
			if !tc.isKeyConditionSet {
				// check the default is set
				conditionKey := findInSlice(conditions, "key")
				if conditionKey == nil {
					t.Errorf("Expected Condition 'key' to be set on policy conditions, none found. Conditions %v", conditions)
				}
				actualVal, ok := conditionKey.(string)
				if !ok {
					t.Errorf("Expected condition key to be a string, was %v", conditionKey)
				}
				if actualVal != aBucketKey {
					t.Errorf("Expected bucket key to be %v, was %v", aBucketKey, actualVal)
				}
			} else {
				// check the key condition is not set
				conditionKey := findInSlice(conditions, "key")
				if conditionKey != nil {
					t.Errorf("Expected condition key to be nil since %v was set, was %v", tc.isKeyConditionSet, conditionKey)
				}

			}
		})
	}
}

func mockTime(t time.Time) func() {
	sdk.NowTime = func() time.Time { return t }
	return func() { sdk.NowTime = time.Now }
}

type staticCredentialsProvider struct {
	Key    string
	Secret string
}

func (p staticCredentialsProvider) Retrieve(ctx context.Context) (aws.Credentials, error) {
	return aws.Credentials{AccessKeyID: p.Key, SecretAccessKey: p.Secret}, nil
}

var credentialsNoToken = aws.Credentials{AccessKeyID: "AKID", SecretAccessKey: "SECRET"}
var credentialsWithToken = aws.Credentials{AccessKeyID: "AKID", SecretAccessKey: "SECRET", SessionToken: "SESSION"}

func isPresent(needle any, haystack []any) bool {
	needleValue := reflect.ValueOf(needle)
	for _, item := range haystack {
		itemValue := reflect.ValueOf(item)

		// special checks for slices and maps, since interface{} are not typecasted
		// by reflect.DeepEquals
		isSlice := itemValue.Kind() == reflect.Slice && needleValue.Kind() == reflect.Slice
		if isSlice && areSlicesEqual(needleValue, itemValue) {
			return true
		}
		isMap := itemValue.Kind() == reflect.Map && needleValue.Kind() == reflect.Map
		if isMap && areMapsEqual(needleValue, itemValue) {
			return true
		}

		// else do a regular deep equal check
		if reflect.DeepEqual(item, needle) {
			return true
		}
	}
	return false
}

func areSlicesEqual(a reflect.Value, b reflect.Value) bool {
	if a.Len() != b.Len() {
		return false
	}

	for i := 0; i < a.Len(); i++ {
		aValue := a.Index(i).Interface()
		bValue := b.Index(i).Interface()

		if !reflect.DeepEqual(aValue, bValue) {
			return false
		}
	}

	return true
}

func areMapsEqual(aVal reflect.Value, bVal reflect.Value) bool {
	// Check if 'a' is a map
	if aVal.Kind() != reflect.Map {
		return false
	}

	// Check if both maps have the same number of keys
	if aVal.Len() != bVal.Len() {
		return false
	}

	// Iterate over the keys and values in the first map
	for _, key := range aVal.MapKeys() {
		aValue := aVal.MapIndex(key)
		if !aValue.IsValid() {
			return false
		}
		bValue := bVal.MapIndex(key)
		if !bValue.IsValid() {
			return false
		}

		// Compare values using reflect.DeepEqual
		if !reflect.DeepEqual(aValue.Interface(), bValue.Interface()) {
			return false
		}
	}
	return true
}

// filters items in slice that have a map[string]interface{} and returns
// the first items map that has the key from "key"
func findInSlice(slice []any, key string) any {
	for _, item := range slice {
		// filter only the values with keys. Ignore stuff like arrays
		if v, ok := item.(map[string]any); ok {
			// once in the maps, check if they have the desired key
			if _, ok := v[key]; ok {
				return v[key]
			}
		}
	}
	return nil
}

func isTimeEqual(t1s string, t2 time.Time) (bool, error) {
	t1, err := time.Parse(time.RFC3339, t1s)
	if err != nil {
		return false, err
	}
	areEqual := t1.Format(time.RFC3339) == t2.Format(time.RFC3339)
	return areEqual, nil
}
