package s3api

import (
	"context"
	"crypto/rand"
	"crypto/rsa"
	"encoding/base64"
	"encoding/json"
	"encoding/xml"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/seaweedfs/seaweedfs/weed/iam/integration"
	"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
	"github.com/seaweedfs/seaweedfs/weed/iam/policy"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// AssumeRoleWithWebIdentity is the public, unauthenticated STS entry point, but
// every existing test reaches the OIDC path either at the IAMManager service
// layer or through the Authorization: Bearer shortcut. Nothing drove a real
// signed OIDC token through HandleSTSRequest, which is what an AWS SDK actually
// does - and that is the path carrying parameter parsing, the IAMManager
// dispatch, and the XML response shape.
func TestAssumeRoleWithWebIdentityOverHTTP(t *testing.T) {
	ctx := context.Background()

	key, err := rsa.GenerateKey(rand.Reader, 2048)
	require.NoError(t, err)

	jwks := map[string]interface{}{
		"keys": []map[string]interface{}{{
			"kty": "RSA",
			"kid": "web-identity-test-key",
			"use": "sig",
			"alg": "RS256",
			"n":   base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
			"e":   "AQAB",
		}},
	}
	idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/jwks" {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(jwks)
	}))
	defer idp.Close()

	provider := oidc.NewOIDCProvider("test-oidc")
	require.NoError(t, provider.Initialize(&oidc.OIDCConfig{
		Issuer:   idp.URL,
		ClientID: "test-client",
		JWKSUri:  idp.URL + "/jwks",
	}))

	manager := newTestSTSIntegrationManager(t)
	require.NoError(t, manager.RegisterIdentityProvider(provider))

	require.NoError(t, manager.CreatePolicy(ctx, "", "WebIdentityPolicy", &policy.PolicyDocument{
		Version: "2012-10-17",
		Statement: []policy.Statement{{
			Effect:   "Allow",
			Action:   []string{"s3:GetObject"},
			Resource: []string{"arn:aws:s3:::*/*"},
		}},
	}))

	trustPolicy := func(federatedProvider string) *policy.PolicyDocument {
		return &policy.PolicyDocument{
			Version: "2012-10-17",
			Statement: []policy.Statement{{
				Effect:    "Allow",
				Principal: map[string]interface{}{"Federated": federatedProvider},
				Action:    []string{"sts:AssumeRoleWithWebIdentity"},
			}},
		}
	}
	require.NoError(t, manager.CreateRole(ctx, "", "WebIdentityRole", &integration.RoleDefinition{
		RoleName:         "WebIdentityRole",
		TrustPolicy:      trustPolicy("test-oidc"),
		AttachedPolicies: []string{"WebIdentityPolicy"},
	}))
	require.NoError(t, manager.CreateRole(ctx, "", "WebIdentityDeniedRole", &integration.RoleDefinition{
		RoleName:         "WebIdentityDeniedRole",
		TrustPolicy:      trustPolicy("some-other-provider"),
		AttachedPolicies: []string{"WebIdentityPolicy"},
	}))

	iam := &IdentityAccessManagement{iamIntegration: NewS3IAMIntegration(manager, "")}
	handlers := NewSTSHandlers(manager.GetSTSService(), iam)

	signOIDCToken := func(t *testing.T, subject string) string {
		t.Helper()
		token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
			"iss": idp.URL,
			"sub": subject,
			"aud": "test-client",
			"exp": time.Now().Add(time.Hour).Unix(),
			"iat": time.Now().Unix(),
		})
		token.Header["kid"] = "web-identity-test-key"
		signed, err := token.SignedString(key)
		require.NoError(t, err)
		return signed
	}

	const trustedRoleArn = "arn:aws:iam::" + defaultAccountID + ":role/WebIdentityRole"

	// The SDK may put the parameters in the query string or the form body; both
	// reach HandleSTSRequest and must behave identically.
	for _, encoding := range []string{"query", "body"} {
		t.Run("succeeds with parameters in the "+encoding, func(t *testing.T) {
			form := url.Values{
				"Action":           {"AssumeRoleWithWebIdentity"},
				"Version":          {"2011-06-15"},
				"RoleArn":          {trustedRoleArn},
				"RoleSessionName":  {"web-session"},
				"WebIdentityToken": {signOIDCToken(t, "oidc-user-1")},
			}

			var req *http.Request
			if encoding == "query" {
				req = httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/?"+form.Encode(), nil)
			} else {
				req = httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(form.Encode()))
				req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
			}

			rec := httptest.NewRecorder()
			handlers.HandleSTSRequest(rec, req)
			require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())

			var resp AssumeRoleWithWebIdentityResponse
			require.NoError(t, xml.Unmarshal(rec.Body.Bytes(), &resp))
			assert.Equal(t, "oidc-user-1", resp.Result.SubjectFromWebIdentityToken)
			assert.True(t, strings.HasPrefix(resp.Result.Credentials.AccessKeyId, "ASIA"),
				"temporary credentials must use the ASIA prefix")
			require.NotEmpty(t, resp.Result.Credentials.SessionToken)
			assert.Nil(t, resp.Result.PackedPolicySize, "no inline session policy was sent")

			// The minted session must resolve to the assumed-role principal.
			// Unlike AssumeRole, this path does not embed the role's attached
			// policies in the token - they are resolved from the role at request
			// time - so assert the effective permission rather than the claim.
			info, err := manager.GetSTSService().ValidateSessionToken(ctx, resp.Result.Credentials.SessionToken)
			require.NoError(t, err)
			assert.Contains(t, info.Principal, "assumed-role/WebIdentityRole/web-session")

			allowed, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
				Principal:    info.Principal,
				Action:       "s3:GetObject",
				Resource:     "arn:aws:s3:::bucket/key",
				SessionToken: resp.Result.Credentials.SessionToken,
			})
			require.NoError(t, err)
			assert.True(t, allowed, "the role grants s3:GetObject")

			denied, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
				Principal:    info.Principal,
				Action:       "s3:PutObject",
				Resource:     "arn:aws:s3:::bucket/key",
				SessionToken: resp.Result.Credentials.SessionToken,
			})
			require.NoError(t, err)
			assert.False(t, denied, "the role does not grant s3:PutObject")
		})
	}

	t.Run("inline session policy is reported in PackedPolicySize", func(t *testing.T) {
		sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"]}]}`
		form := url.Values{
			"Action":           {"AssumeRoleWithWebIdentity"},
			"Version":          {"2011-06-15"},
			"RoleArn":          {trustedRoleArn},
			"RoleSessionName":  {"web-session-scoped"},
			"WebIdentityToken": {signOIDCToken(t, "oidc-user-2")},
			"Policy":           {sessionPolicy},
		}
		req := httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(form.Encode()))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

		rec := httptest.NewRecorder()
		handlers.HandleSTSRequest(rec, req)
		require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())

		var resp AssumeRoleWithWebIdentityResponse
		require.NoError(t, xml.Unmarshal(rec.Body.Bytes(), &resp))
		require.NotNil(t, resp.Result.PackedPolicySize)
		assert.Greater(t, *resp.Result.PackedPolicySize, int64(0))

		info, err := manager.GetSTSService().ValidateSessionToken(ctx, resp.Result.Credentials.SessionToken)
		require.NoError(t, err)
		require.NotEmpty(t, info.SessionPolicy, "the inline policy must travel in the session token")

		// Carrying the policy is not the point - restricting the session is. The
		// role allows s3:GetObject on any bucket; the session policy narrows that
		// to one bucket, so the same action on another bucket must be refused.
		allowed, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
			Principal:    info.Principal,
			Action:       "s3:GetObject",
			Resource:     "arn:aws:s3:::bucket/key",
			SessionToken: resp.Result.Credentials.SessionToken,
		})
		require.NoError(t, err)
		assert.True(t, allowed, "the session policy allows this bucket")

		denied, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
			Principal:    info.Principal,
			Action:       "s3:GetObject",
			Resource:     "arn:aws:s3:::other-bucket/key",
			SessionToken: resp.Result.Credentials.SessionToken,
		})
		require.NoError(t, err)
		assert.False(t, denied, "the session policy must scope the session down to one bucket")
	})

	t.Run("role whose trust policy rejects the provider is denied", func(t *testing.T) {
		form := url.Values{
			"Action":           {"AssumeRoleWithWebIdentity"},
			"Version":          {"2011-06-15"},
			"RoleArn":          {"arn:aws:iam::" + defaultAccountID + ":role/WebIdentityDeniedRole"},
			"RoleSessionName":  {"web-session"},
			"WebIdentityToken": {signOIDCToken(t, "oidc-user-1")},
		}
		req := httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(form.Encode()))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

		rec := httptest.NewRecorder()
		handlers.HandleSTSRequest(rec, req)
		assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String())
	})

	// Both rejection paths matter and they are not the same code: one fails
	// signature verification against a key we do publish, the other never finds
	// a key to verify against. The claim set is otherwise identical to a token
	// signOIDCToken would mint, so neither can pass or fail for want of a claim.
	forgedTokenCases := []struct {
		name string
		kid  string
	}{
		{name: "token signed by a key we do not publish is rejected", kid: "web-identity-test-key"},
		{name: "token naming an unknown key id is rejected", kid: "not-in-the-jwks"},
	}
	for _, tc := range forgedTokenCases {
		t.Run(tc.name, func(t *testing.T) {
			otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
			require.NoError(t, err)
			token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
				"iss": idp.URL,
				"sub": "forged-user",
				"aud": "test-client",
				"exp": time.Now().Add(time.Hour).Unix(),
				"iat": time.Now().Unix(),
			})
			token.Header["kid"] = tc.kid
			forged, err := token.SignedString(otherKey)
			require.NoError(t, err)

			form := url.Values{
				"Action":           {"AssumeRoleWithWebIdentity"},
				"Version":          {"2011-06-15"},
				"RoleArn":          {trustedRoleArn},
				"RoleSessionName":  {"web-session"},
				"WebIdentityToken": {forged},
			}
			req := httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(form.Encode()))
			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

			rec := httptest.NewRecorder()
			handlers.HandleSTSRequest(rec, req)
			assert.NotEqual(t, http.StatusOK, rec.Code, "a forged token must not mint a session")
		})
	}
}
