//go:build integration

package oidc_test

import (
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/zitadel/oidc/v3/pkg/client/rp"
	"github.com/zitadel/oidc/v3/pkg/oidc"
	"golang.org/x/oauth2"

	oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
	"github.com/zitadel/zitadel/internal/domain"
	"github.com/zitadel/zitadel/internal/integration"
	feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
	"github.com/zitadel/zitadel/pkg/grpc/management"
	oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
)

// TestServer_UserInfo is a top-level test which re-executes the actual
// userinfo integration test against a matrix of different feature flags.
// This ensure that the response of the different implementations remains the same.
func TestServer_UserInfo(t *testing.T) {
	iamOwnerCTX := Tester.WithAuthorization(CTX, integration.IAMOwner)
	t.Cleanup(func() {
		_, err := Tester.Client.FeatureV2.ResetInstanceFeatures(iamOwnerCTX, &feature.ResetInstanceFeaturesRequest{})
		require.NoError(t, err)
	})
	tests := []struct {
		name    string
		legacy  bool
		trigger bool
	}{
		{
			name:   "legacy enabled",
			legacy: true,
		},
		{
			name:    "legacy disabled, trigger disabled",
			legacy:  false,
			trigger: false,
		},
		{
			name:    "legacy disabled, trigger enabled",
			legacy:  false,
			trigger: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			_, err := Tester.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{
				OidcLegacyIntrospection:             &tt.legacy,
				OidcTriggerIntrospectionProjections: &tt.trigger,
			})
			require.NoError(t, err)
			testServer_UserInfo(t)
		})
	}
}

// testServer_UserInfo is the actual userinfo integration test,
// which calls the userinfo endpoint with different client configurations, roles and token scopes.
func testServer_UserInfo(t *testing.T) {
	const (
		roleFoo = "foo"
		roleBar = "bar"
	)

	clientID, projectID := createClient(t)
	addProjectRolesGrants(t, User.GetUserId(), projectID, roleFoo, roleBar)

	tests := []struct {
		name       string
		prepare    func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims]
		scope      []string
		assertions []func(*testing.T, *oidc.UserInfo)
		wantErr    bool
	}{
		{
			name: "invalid token",
			prepare: func(*testing.T, string, []string) *oidc.Tokens[*oidc.IDTokenClaims] {
				return &oidc.Tokens[*oidc.IDTokenClaims]{
					Token: &oauth2.Token{
						AccessToken: "DEAFBEEFDEADBEEF",
						TokenType:   oidc.BearerToken,
					},
					IDTokenClaims: &oidc.IDTokenClaims{
						TokenClaims: oidc.TokenClaims{
							Subject: User.GetUserId(),
						},
					},
				}
			},
			scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
			assertions: []func(*testing.T, *oidc.UserInfo){
				func(t *testing.T, ui *oidc.UserInfo) {
					assert.Nil(t, ui)
				},
			},
			wantErr: true,
		},
		{
			name:    "standard scopes",
			prepare: getTokens,
			scope:   []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
			assertions: []func(*testing.T, *oidc.UserInfo){
				assertUserinfo,
				func(t *testing.T, ui *oidc.UserInfo) {
					assertNoReservedScopes(t, ui.Claims)
				},
			},
		},
		{
			name: "project role assertion",
			prepare: func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] {
				_, err := Tester.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{
					Id:                   projectID,
					Name:                 fmt.Sprintf("project-%d", time.Now().UnixNano()),
					ProjectRoleAssertion: true,
				})
				require.NoError(t, err)
				t.Cleanup(func() {
					_, err := Tester.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{
						Id:                   projectID,
						Name:                 fmt.Sprintf("project-%d", time.Now().UnixNano()),
						ProjectRoleAssertion: false,
					})
					require.NoError(t, err)
				})
				resp, err := Tester.Client.Mgmt.GetProjectByID(CTX, &management.GetProjectByIDRequest{Id: projectID})
				require.NoError(t, err)
				require.True(t, resp.GetProject().GetProjectRoleAssertion(), "project role assertion")

				return getTokens(t, clientID, scope)
			},
			scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
			assertions: []func(*testing.T, *oidc.UserInfo){
				assertUserinfo,
				func(t *testing.T, ui *oidc.UserInfo) {
					assertProjectRoleClaims(t, projectID, ui.Claims, true, roleFoo, roleBar)
				},
			},
		},
		{
			name:    "project role scope",
			prepare: getTokens,
			scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess,
				oidc_api.ScopeProjectRolePrefix + roleFoo,
			},
			assertions: []func(*testing.T, *oidc.UserInfo){
				assertUserinfo,
				func(t *testing.T, ui *oidc.UserInfo) {
					assertProjectRoleClaims(t, projectID, ui.Claims, true, roleFoo)
				},
			},
		},
		{
			name:    "project role and audience scope",
			prepare: getTokens,
			scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess,
				oidc_api.ScopeProjectRolePrefix + roleFoo,
				domain.ProjectIDScope + projectID + domain.AudSuffix,
			},
			assertions: []func(*testing.T, *oidc.UserInfo){
				assertUserinfo,
				func(t *testing.T, ui *oidc.UserInfo) {
					assertProjectRoleClaims(t, projectID, ui.Claims, true, roleFoo)
				},
			},
		},
		{
			name: "PAT",
			prepare: func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] {
				user := Tester.Users.Get(integration.FirstInstanceUsersKey, integration.OrgOwner)
				return &oidc.Tokens[*oidc.IDTokenClaims]{
					Token: &oauth2.Token{
						AccessToken: user.Token,
						TokenType:   oidc.BearerToken,
					},
					IDTokenClaims: &oidc.IDTokenClaims{
						TokenClaims: oidc.TokenClaims{
							Subject: user.ID,
						},
					},
				}
			},
			assertions: []func(*testing.T, *oidc.UserInfo){
				func(t *testing.T, ui *oidc.UserInfo) {
					user := Tester.Users.Get(integration.FirstInstanceUsersKey, integration.OrgOwner)
					assert.Equal(t, user.ID, ui.Subject)
					assert.Equal(t, user.PreferredLoginName, ui.PreferredUsername)
					assert.Equal(t, user.Machine.Name, ui.Name)
					assert.Equal(t, user.ResourceOwner, ui.Claims[oidc_api.ClaimResourceOwnerID])
					assert.NotEmpty(t, ui.Claims[oidc_api.ClaimResourceOwnerName])
					assert.NotEmpty(t, ui.Claims[oidc_api.ClaimResourceOwnerPrimaryDomain])
				},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			tokens := tt.prepare(t, clientID, tt.scope)
			provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI)
			require.NoError(t, err)
			userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
			if tt.wantErr {
				assert.Error(t, err)
				return
			}
			require.NoError(t, err)
			for _, assertion := range tt.assertions {
				assertion(t, userinfo)
			}
		})
	}
}

// https://github.com/zitadel/zitadel/issues/6662
func TestServer_UserInfo_Issue6662(t *testing.T) {
	const (
		roleFoo = "foo"
		roleBar = "bar"
	)

	project, err := Tester.CreateProject(CTX)
	projectID := project.GetId()
	require.NoError(t, err)
	userID, clientID, clientSecret, err := Tester.CreateOIDCCredentialsClient(CTX)
	require.NoError(t, err)
	addProjectRolesGrants(t, userID, projectID, roleFoo, roleBar)

	scope := []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess,
		oidc_api.ScopeProjectRolePrefix + roleFoo,
		domain.ProjectIDScope + projectID + domain.AudSuffix,
	}

	provider, err := rp.NewRelyingPartyOIDC(CTX, Tester.OIDCIssuer(), clientID, clientSecret, redirectURI, scope)
	require.NoError(t, err)
	tokens, err := rp.ClientCredentials(CTX, provider, nil)
	require.NoError(t, err)

	userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, userID, provider)
	require.NoError(t, err)
	assertProjectRoleClaims(t, projectID, userinfo.Claims, false, roleFoo)
}

func addProjectRolesGrants(t *testing.T, userID, projectID string, roles ...string) {
	t.Helper()
	bulkRoles := make([]*management.BulkAddProjectRolesRequest_Role, len(roles))
	for i, role := range roles {
		bulkRoles[i] = &management.BulkAddProjectRolesRequest_Role{
			Key:         role,
			DisplayName: role,
		}
	}
	_, err := Tester.Client.Mgmt.BulkAddProjectRoles(CTX, &management.BulkAddProjectRolesRequest{
		ProjectId: projectID,
		Roles:     bulkRoles,
	})
	require.NoError(t, err)
	_, err = Tester.Client.Mgmt.AddUserGrant(CTX, &management.AddUserGrantRequest{
		UserId:    userID,
		ProjectId: projectID,
		RoleKeys:  roles,
	})
	require.NoError(t, err)
}

func getTokens(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] {
	authRequestID := createAuthRequest(t, clientID, redirectURI, scope...)
	sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
	linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
		AuthRequestId: authRequestID,
		CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
			Session: &oidc_pb.Session{
				SessionId:    sessionID,
				SessionToken: sessionToken,
			},
		},
	})
	require.NoError(t, err)

	// code exchange
	code := assertCodeResponse(t, linkResp.GetCallbackUrl())
	tokens, err := exchangeTokens(t, clientID, code, redirectURI)
	require.NoError(t, err)
	assertTokens(t, tokens, true)
	assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime)

	return tokens
}

func assertUserinfo(t *testing.T, userinfo *oidc.UserInfo) {
	t.Helper()
	assert.Equal(t, User.GetUserId(), userinfo.Subject)
	assert.Equal(t, "Mickey", userinfo.GivenName)
	assert.Equal(t, "Mouse", userinfo.FamilyName)
	assert.Equal(t, "Mickey Mouse", userinfo.Name)
	assert.NotEmpty(t, userinfo.PreferredUsername)
	assert.Equal(t, userinfo.PreferredUsername, userinfo.Email)
	assert.False(t, bool(userinfo.EmailVerified))
	assertOIDCTime(t, userinfo.UpdatedAt, User.GetDetails().GetChangeDate().AsTime())
}

func assertNoReservedScopes(t *testing.T, claims map[string]any) {
	t.Helper()
	t.Log(claims)
	for claim := range claims {
		assert.Falsef(t, strings.HasPrefix(claim, oidc_api.ClaimPrefix), "claim %s has prefix %s", claim, oidc_api.ClaimPrefix)
	}
}

func assertProjectRoleClaims(t *testing.T, projectID string, claims map[string]any, claimProjectRole bool, roles ...string) {
	t.Helper()
	projectRoleClaims := []string{fmt.Sprintf(oidc_api.ClaimProjectRolesFormat, projectID)}
	if claimProjectRole {
		projectRoleClaims = append(projectRoleClaims, oidc_api.ClaimProjectRoles)
	}
	for _, claim := range projectRoleClaims {
		roleMap, ok := claims[claim].(map[string]any)
		require.Truef(t, ok, "claim %s not found or wrong type %T", claim, claims[claim])
		for _, roleKey := range roles {
			role, ok := roleMap[roleKey].(map[string]any)
			require.Truef(t, ok, "role %s not found or wrong type %T", roleKey, roleMap[roleKey])
			assert.Equal(t, role[Tester.Organisation.ID], Tester.Organisation.Domain, "org domain in role")
		}
	}
}