package oidc

import (
	"context"
	"net/url"
	"testing"
	"time"

	"github.com/ory/fosite"
	"github.com/stretchr/testify/require"

	"github.com/pocket-id/pocket-id/backend/internal/common"
	"github.com/pocket-id/pocket-id/backend/internal/model"
	datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
	testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
	"gorm.io/gorm"
)

type fakeAuditLogger struct {
	events []model.AuditLogEvent
	data   []model.AuditLogData
}

func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, _, _, _ string, data model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
	f.events = append(f.events, event)
	f.data = append(f.data, data)
	return model.AuditLog{}, true
}

func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	auditLogger := &fakeAuditLogger{}
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, nil)

	const (
		userID   = "test-user"
		clientID = "test-client"
	)

	require.NoError(t, db.Create(&model.User{
		Base: model.Base{ID: userID},
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
		UserID:   userID,
		ClientID: clientID,
		Scope:    datatype.StringList{"openid"},
	}).Error)

	authorization, err := service.authorize(t.Context(), authorizeInput{
		userID:             userID,
		authenticationTime: time.Now().UTC(),
		requester:          newTestAuthorizeRequester("audit-request", clientID, ""),
		meta:               requestMeta{IPAddress: "203.0.113.1", UserAgent: "test-agent"},
	})
	require.NoError(t, err)
	require.False(t, authorization.RequiresInteraction)

	require.Equal(t, []model.AuditLogEvent{model.AuditLogEventClientAuthorization}, auditLogger.events)
	require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0])
}

func TestAuthorizationServiceRejectsCustomScopeWithoutResource(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID   = "test-user"
		clientID = "test-client"
	)
	require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error)
	require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)

	// A custom permission requested with no resource must be rejected at the
	// authorize endpoint, not displayed on the consent screen.
	requester := newTestAuthorizeRequesterWithForm("bad-scope-request", clientID, url.Values{})
	requester.(*fosite.AuthorizeRequest).RequestedScope = fosite.Arguments{"openid", "users:read"}

	_, err := service.authorize(t.Context(), authorizeInput{
		userID:             userID,
		authenticationTime: time.Now().UTC(),
		requester:          requester,
		meta:               requestMeta{},
	})
	require.Error(t, err)

	var rfcErr *fosite.RFC6749Error
	require.ErrorAs(t, err, &rfcErr)
	require.Equal(t, "invalid_scope", rfcErr.ErrorField)
}

// TestAuthorizationServiceCollapsesResourceErrorsBeforeAuthentication guards the pre-authentication resource validation against API enumeration.
// An unknown API and an API the client is simply not granted are different backend states, but the authorize endpoint runs before the user logs in, so both must surface the exact same generic error; otherwise anyone holding a public client_id could diff the responses to map which API audiences exist and which ones the client may request.
func TestAuthorizationServiceCollapsesResourceErrorsBeforeAuthentication(t *testing.T) {
	const (
		clientID   = "test-client"
		knownAPI   = "https://api.orders.example.com"
		unknownAPI = "https://api.unknown.example.com"
	)

	authorizeWithResource := func(t *testing.T, apiAccess APIAccessProvider, resource string) error {
		t.Helper()
		db := testutils.NewDatabaseForTest(t)
		require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)
		service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, apiAccess)

		requester := newTestAuthorizeRequesterWithForm("resource-probe", clientID, url.Values{"resource": {resource}})
		_, err := service.authorize(t.Context(), authorizeInput{
			userID:             "test-user",
			authenticationTime: time.Now().UTC(),
			requester:          requester,
		})
		return err
	}

	// The targeted API does not exist at all
	unknownErr := authorizeWithResource(t, userAccess(map[string][]string{}), unknownAPI)
	// The targeted API exists but this client has been granted none of its permissions
	ungrantedErr := authorizeWithResource(t, userAccess(map[string][]string{knownAPI: {}}), knownAPI)

	require.Error(t, unknownErr)
	require.Error(t, ungrantedErr)

	var unknownRFC, ungrantedRFC *fosite.RFC6749Error
	require.ErrorAs(t, unknownErr, &unknownRFC)
	require.ErrorAs(t, ungrantedErr, &ungrantedRFC)

	// Both states collapse to the identical generic error, so an anonymous caller cannot tell an unknown API from an ungranted one
	require.Equal(t, "invalid_request", unknownRFC.ErrorField)
	require.Equal(t, unknownRFC.ErrorField, ungrantedRFC.ErrorField)
	require.Equal(t, unknownRFC.GetDescription(), ungrantedRFC.GetDescription())
	// The ungranted case must not leak fosite's access_denied, which would reveal that the API exists
	require.NotEqual(t, "access_denied", ungrantedRFC.ErrorField)
}

func TestAuthorizationServiceConsentStepLogsNewClientAuthorization(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	auditLogger := &fakeAuditLogger{}
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, nil)

	const (
		userID        = "test-user"
		clientID      = "test-client"
		interactionID = "test-interaction"
	)

	require.NoError(t, db.Create(&model.User{
		Base: model.Base{ID: userID},
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&InteractionSession{
		Base:            model.Base{ID: interactionID},
		Scopes:          datatype.StringList{"openid"},
		ClientID:        clientID,
		ConsentRequired: true,
		RequestedAt:     datatype.DateTime(time.Now().UTC()),
		Parameters:      map[string]string{},
	}).Error)

	response, err := service.completeInteractionStep(t.Context(), interactionID, userID, interactionStepConsent, "", time.Now().UTC(), requestMeta{})
	require.NoError(t, err)
	require.NotEmpty(t, response.RedirectURL)

	require.Equal(t, []model.AuditLogEvent{model.AuditLogEventNewClientAuthorization}, auditLogger.events)
	require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0])
}

func TestAuthorizationServiceConsentMergesAudienceQualifiedScopeKeys(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	auditLogger := &fakeAuditLogger{}

	const (
		userID   = "test-user"
		clientID = "test-client"
		apiA     = "https://api-a.example.com"
		apiB     = "https://api-b.example.com"
	)

	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, userAccess(map[string][]string{
		apiA: {"read"},
		apiB: {"read"},
	}))

	require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)

	apiAConsent := consentScopeKeys(apiA, []string{"openid", "read"})
	apiBConsent := consentScopeKeys(apiB, []string{"openid", "read"})

	hasAlreadyAuthorized, err := service.consent(t.Context(), userID, clientID, apiAConsent)
	require.NoError(t, err)
	require.False(t, hasAlreadyAuthorized)

	hasAlreadyAuthorized, err = service.consent(t.Context(), userID, clientID, apiBConsent)
	require.NoError(t, err)
	require.False(t, hasAlreadyAuthorized)

	var authorizedClient model.UserAuthorizedOidcClient
	require.NoError(t, db.First(&authorizedClient, "user_id = ? AND client_id = ?", userID, clientID).Error)
	require.ElementsMatch(t, []string{"openid", consentScopeKey(apiA, "read"), consentScopeKey(apiB, "read")}, authorizedClient.Scope)

	hasAuthorizedAPIA, err := service.hasAuthorizedClient(t.Context(), clientID, userID, apiAConsent)
	require.NoError(t, err)
	require.True(t, hasAuthorizedAPIA)
	hasAuthorizedAPIB, err := service.hasAuthorizedClient(t.Context(), clientID, userID, apiBConsent)
	require.NoError(t, err)
	require.True(t, hasAuthorizedAPIB)

	form := url.Values{
		"prompt":   {"none"},
		"resource": {apiA},
	}
	requester := newTestAuthorizeRequesterWithForm("silent-api-a-request", clientID, form)
	requester.(*fosite.AuthorizeRequest).RequestedScope = fosite.Arguments{"openid", "read"}

	authorization, err := service.authorize(t.Context(), authorizeInput{
		userID:             userID,
		authenticationTime: time.Now().UTC(),
		requester:          requester,
		meta:               requestMeta{IPAddress: "203.0.113.1", UserAgent: "test-agent"},
	})
	require.NoError(t, err)
	require.False(t, authorization.RequiresInteraction)
	require.Equal(t, []model.AuditLogEvent{model.AuditLogEventClientAuthorization}, auditLogger.events)
}

func TestAuthorizationServiceAuthorizeConsumesInteractionSession(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID        = "test-user"
		clientID      = "test-client"
		interactionID = "test-interaction"
	)

	require.NoError(t, db.Create(&model.User{
		Base: model.Base{ID: userID},
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
		UserID:   userID,
		ClientID: clientID,
		Scope:    datatype.StringList{"openid"},
	}).Error)
	require.NoError(t, db.Create(&InteractionSession{
		Base:        model.Base{ID: interactionID},
		Scopes:      datatype.StringList{"openid"},
		ClientID:    clientID,
		RequestedAt: datatype.DateTime(time.Now().UTC()),
		Parameters:  map[string]string{},
	}).Error)

	authorize := func() (authorizationResult, error) {
		return service.authorize(t.Context(), authorizeInput{
			userID:             userID,
			authenticationTime: time.Now().UTC(),
			requester:          newTestAuthorizeRequester("consume-request", clientID, ""),
			interactionID:      interactionID,
		})
	}

	authorization, err := authorize()
	require.NoError(t, err)
	require.False(t, authorization.RequiresInteraction)

	// The interaction session is consumed together with the grant and must be single-use
	var count int64
	require.NoError(t, db.Model(&InteractionSession{}).Where("id = ?", interactionID).Count(&count).Error)
	require.Zero(t, count)

	_, err = authorize()
	require.ErrorIs(t, err, fosite.ErrInvalidRequest)
}

func TestInteractionSessionServiceGetRejectsExpiredSession(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newInteractionSessionService(db)

	const (
		clientID      = "test-client"
		interactionID = "test-interaction"
	)

	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&InteractionSession{
		Base:        model.Base{ID: interactionID},
		Scopes:      datatype.StringList{"openid"},
		ClientID:    clientID,
		RequestedAt: datatype.DateTime(time.Now().UTC()),
		Parameters:  map[string]string{},
	}).Error)

	_, err := service.get(t.Context(), interactionID)
	require.NoError(t, err)

	// Backdate the session past its lifetime; BeforeCreate stamps CreatedAt, so update directly
	expiredCreatedAt := datatype.DateTime(time.Now().Add(-interactionSessionLifetime - time.Minute))
	require.NoError(t, db.Model(&InteractionSession{}).Where("id = ?", interactionID).Update("created_at", expiredCreatedAt).Error)

	_, err = service.get(t.Context(), interactionID)
	require.ErrorIs(t, err, gorm.ErrRecordNotFound)
}

func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID   = "test-user"
		clientID = "test-client"
	)

	require.NoError(t, db.Create(&model.User{
		Base: model.Base{ID: userID},
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)

	newInteractionSession := func(id string) {
		require.NoError(t, db.Create(&InteractionSession{
			Base:        model.Base{ID: id},
			Scopes:      datatype.StringList{"openid"},
			ClientID:    clientID,
			RequestedAt: datatype.DateTime(time.Now().UTC()),
			Parameters:  map[string]string{},
		}).Error)
	}

	authorize := func(interactionID string, scopes ...string) (authorizationResult, error) {
		requester := newTestAuthorizeRequester("scope-binding-request", clientID, "")
		requester.(*fosite.AuthorizeRequest).RequestedScope = fosite.Arguments(scopes)
		return service.authorize(t.Context(), authorizeInput{
			userID:             userID,
			authenticationTime: time.Now().UTC(),
			requester:          requester,
			interactionID:      interactionID,
		})
	}

	// Scopes matching the consented interaction session are granted
	newInteractionSession("matching-interaction")
	authorization, err := authorize("matching-interaction", "openid")
	require.NoError(t, err)
	require.False(t, authorization.RequiresInteraction)

	// Scopes added to the URL after consent must not be granted silently
	newInteractionSession("escalated-interaction")
	_, err = authorize("escalated-interaction", "openid", "profile", "email", "groups")
	require.ErrorIs(t, err, fosite.ErrInvalidRequest)

	// The user must not have been recorded as having authorized the escalated scopes
	var authorizedClient model.UserAuthorizedOidcClient
	require.NoError(t, db.First(&authorizedClient, "user_id = ? AND client_id = ?", userID, clientID).Error)
	require.Equal(t, datatype.StringList{"openid"}, authorizedClient.Scope)
}

func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID        = "test-user"
		clientID      = "test-client"
		otherClientID = "other-client"
		interactionID = "test-interaction"
	)

	require.NoError(t, db.Create(&model.User{
		Base: model.Base{ID: userID},
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: otherClientID},
		Name: "Other Client",
	}).Error)
	require.NoError(t, db.Create(&InteractionSession{
		Base:        model.Base{ID: interactionID},
		Scopes:      datatype.StringList{"openid"},
		ClientID:    clientID,
		RequestedAt: datatype.DateTime(time.Now().UTC()),
		Parameters:  map[string]string{},
	}).Error)

	_, err := service.authorize(t.Context(), authorizeInput{
		userID:             userID,
		authenticationTime: time.Now().UTC(),
		requester:          newTestAuthorizeRequester("other-client-request", otherClientID, ""),
		interactionID:      interactionID,
	})
	require.ErrorIs(t, err, fosite.ErrInvalidRequest)
}

func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID        = "test-user"
		otherUserID   = "other-user"
		clientID      = "test-client"
		interactionID = "test-interaction"
	)

	require.NoError(t, db.Create(&model.User{
		Base:     model.Base{ID: userID},
		Username: "test-user",
	}).Error)
	require.NoError(t, db.Create(&model.User{
		Base:     model.Base{ID: otherUserID},
		Username: "other-user",
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
		UserID:   userID,
		ClientID: clientID,
		Scope:    datatype.StringList{"openid"},
	}).Error)
	require.NoError(t, db.Create(&InteractionSession{
		Base:                     model.Base{ID: interactionID},
		Scopes:                   datatype.StringList{"openid"},
		ClientID:                 clientID,
		UserID:                   stringPointer(userID),
		ReauthenticatedAt:        new(datatype.DateTime(time.Now().Add(-time.Minute).UTC())),
		ReauthenticationRequired: false,
		RequestedAt:              datatype.DateTime(time.Now().UTC()),
		Parameters: map[string]string{
			"prompt": "login",
		},
	}).Error)

	authorization, err := service.authorize(t.Context(), authorizeInput{
		userID:             otherUserID,
		authenticationTime: time.Now().UTC(),
		requester:          newTestAuthorizeRequester("other-user-request", clientID, ""),
		interactionID:      interactionID,
	})
	require.NoError(t, err)
	require.True(t, authorization.RequiresInteraction)
	require.Equal(t, interactionID, authorization.InteractionID)

	var interactionSession InteractionSession
	require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error)
	require.NotNil(t, interactionSession.UserID)
	require.Equal(t, otherUserID, *interactionSession.UserID)
	require.True(t, interactionSession.ConsentRequired)
	require.True(t, interactionSession.ReauthenticationRequired)
	require.Nil(t, interactionSession.ReauthenticatedAt)
}

func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID        = "test-user"
		clientID      = "test-client"
		interactionID = "test-interaction"
	)

	require.NoError(t, db.Create(&model.User{
		Base: model.Base{ID: userID},
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&InteractionSession{
		Base:        model.Base{ID: interactionID},
		Scopes:      datatype.StringList{"openid"},
		ClientID:    clientID,
		UserID:      stringPointer(userID),
		RequestedAt: datatype.DateTime(time.Now().UTC()),
		Parameters:  map[string]string{},
	}).Error)

	_, err := service.authorize(t.Context(), authorizeInput{
		requester:     newTestAuthorizeRequester("unauthenticated-bound-request", clientID, ""),
		interactionID: interactionID,
	})
	require.ErrorIs(t, err, fosite.ErrLoginRequired)
}

func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID        = "test-user"
		clientID      = "test-client"
		interactionID = "test-interaction"
	)

	require.NoError(t, db.Create(&model.User{
		Base: model.Base{ID: userID},
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&InteractionSession{
		Base:            model.Base{ID: interactionID},
		Scopes:          datatype.StringList{"openid"},
		ClientID:        clientID,
		ConsentRequired: true,
		RequestedAt:     datatype.DateTime(time.Now().UTC()),
		Parameters:      map[string]string{},
	}).Error)

	response, err := service.completeInteractionStep(t.Context(), interactionID, userID, interactionStepConsent, "", time.Now().UTC(), requestMeta{})
	require.NoError(t, err)
	require.NotEmpty(t, response.RedirectURL)

	var interactionSession InteractionSession
	require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error)
	require.NotNil(t, interactionSession.UserID)
	require.Equal(t, userID, *interactionSession.UserID)
}

func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirements(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID        = "test-user"
		otherUserID   = "other-user"
		clientID      = "test-client"
		interactionID = "test-interaction"
	)

	require.NoError(t, db.Create(&model.User{
		Base:     model.Base{ID: userID},
		Username: "test-user",
	}).Error)
	require.NoError(t, db.Create(&model.User{
		Base:     model.Base{ID: otherUserID},
		Username: "other-user",
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&InteractionSession{
		Base:                     model.Base{ID: interactionID},
		Scopes:                   datatype.StringList{"openid"},
		ClientID:                 clientID,
		UserID:                   stringPointer(userID),
		AccountSelectionRequired: true,
		RequestedAt:              datatype.DateTime(time.Now().UTC()),
		Parameters: map[string]string{
			"prompt": "select_account",
		},
	}).Error)

	response, err := service.completeInteractionStep(t.Context(), interactionID, otherUserID, interactionStepSelectAccount, "", time.Now().UTC(), requestMeta{})
	require.NoError(t, err)
	require.Empty(t, response.RedirectURL)
	require.NotNil(t, response.Interaction)
	require.Equal(t, interactionStepConsent, response.Interaction.CurrentStep)

	var interactionSession InteractionSession
	require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error)
	require.NotNil(t, interactionSession.UserID)
	require.Equal(t, otherUserID, *interactionSession.UserID)
	require.False(t, interactionSession.AccountSelectionRequired)
	require.True(t, interactionSession.ConsentRequired)
}

// TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser ensures consent is
// evaluated for the FINAL selected user. When an already-consented user starts
// prompt=select_account and a different, not-yet-consented user is selected, consent must
// still be required for that user rather than being inherited from the initiator.
func TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		initiatorID = "initiator-user"
		switchedID  = "switched-user"
		clientID    = "test-client"
	)

	require.NoError(t, db.Create(&model.User{Base: model.Base{ID: initiatorID}, Username: "initiator"}).Error)
	require.NoError(t, db.Create(&model.User{Base: model.Base{ID: switchedID}, Username: "switched"}).Error)
	require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)

	// The initiator has previously consented to the client; the switched-to user has not.
	require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
		UserID:   initiatorID,
		ClientID: clientID,
		Scope:    datatype.StringList{"openid"},
	}).Error)

	authorization, err := service.authorize(t.Context(), authorizeInput{
		userID:             initiatorID,
		authenticationTime: time.Now().UTC(),
		requester:          newTestAuthorizeRequester("select-account-request", clientID, "select_account"),
		requestParams:      map[string]string{"prompt": "select_account"},
	})
	require.NoError(t, err)
	require.True(t, authorization.RequiresInteraction)

	response, err := service.completeInteractionStep(t.Context(), authorization.InteractionID, switchedID, interactionStepSelectAccount, "", time.Now().UTC(), requestMeta{})
	require.NoError(t, err)

	// The flow must not be granted yet: consent is still pending for the switched-to user.
	require.Empty(t, response.RedirectURL)
	require.NotNil(t, response.Interaction)
	require.Equal(t, interactionStepConsent, response.Interaction.CurrentStep)

	// No authorization record may have been silently created for the switched-to user.
	var count int64
	require.NoError(t, db.Model(&model.UserAuthorizedOidcClient{}).
		Where("user_id = ? AND client_id = ?", switchedID, clientID).
		Count(&count).Error)
	require.Zero(t, count)
}

func TestValidateClientPKCERequirement(t *testing.T) {
	pkceClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "c"}, PkceEnabled: true}}
	plainClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "c"}}}

	withChallenge := newTestAuthorizeRequesterWithForm("with-challenge", "c", url.Values{"code_challenge": {"abc123"}})
	withoutChallenge := newTestAuthorizeRequesterWithForm("without-challenge", "c", url.Values{})

	require.NoError(t, validateClientPKCERequirement(plainClient, withoutChallenge))
	require.NoError(t, validateClientPKCERequirement(pkceClient, withChallenge))

	err := validateClientPKCERequirement(pkceClient, withoutChallenge)
	require.ErrorIs(t, err, fosite.ErrInvalidRequest)
}

// TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE proves the per-client PkceEnabled
// flag is honored for confidential clients (fosite only enforces PKCE for public clients).
func TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID   = "test-user"
		clientID = "pkce-client"
	)
	require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "test-user"}).Error)
	require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "PKCE Client", PkceEnabled: true}).Error)
	require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: userID, ClientID: clientID, Scope: datatype.StringList{"openid"}}).Error)

	pkceClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, Name: "PKCE Client", PkceEnabled: true}}

	// Without a code_challenge the request is rejected before any interaction.
	missing := newTestAuthorizeRequesterWithForm("authz-no-pkce", clientID, url.Values{})
	missing.(*fosite.AuthorizeRequest).Client = pkceClient
	_, err := service.authorize(t.Context(), authorizeInput{
		userID:             userID,
		authenticationTime: time.Now().UTC(),
		requester:          missing,
	})
	require.ErrorIs(t, err, fosite.ErrInvalidRequest)

	// With a code_challenge the PKCE gate passes and the (already-consented) request is granted.
	withChallenge := newTestAuthorizeRequesterWithForm("authz-pkce", clientID, url.Values{"code_challenge": {"abc123"}})
	withChallenge.(*fosite.AuthorizeRequest).Client = pkceClient
	result, err := service.authorize(t.Context(), authorizeInput{
		userID:             userID,
		authenticationTime: time.Now().UTC(),
		requester:          withChallenge,
	})
	require.NoError(t, err)
	require.False(t, result.RequiresInteraction)
}

func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID        = "test-user"
		clientID      = "test-client"
		interactionID = "test-interaction"
	)

	require.NoError(t, db.Create(&model.User{
		Base: model.Base{ID: userID},
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base:                                model.Base{ID: clientID},
		Name:                                "Test Client",
		RequiresPushedAuthorizationRequests: true,
	}).Error)
	require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
		UserID:   userID,
		ClientID: clientID,
		Scope:    datatype.StringList{"openid"},
	}).Error)

	authorize := func(interactionID string, hasPushedAuthorizationRequest bool) (authorizationResult, error) {
		requester := newTestAuthorizeRequester("par-request", clientID, "")
		requester.(*fosite.AuthorizeRequest).Client = Client{
			OidcClient: model.OidcClient{
				Base:                                model.Base{ID: clientID},
				Name:                                "Test Client",
				RequiresPushedAuthorizationRequests: true,
			},
		}
		return service.authorize(t.Context(), authorizeInput{
			userID:                        userID,
			authenticationTime:            time.Now().UTC(),
			requester:                     requester,
			hasPushedAuthorizationRequest: hasPushedAuthorizationRequest,
			interactionID:                 interactionID,
		})
	}

	// Without a pushed authorization request the client must be rejected
	_, err := authorize("", false)
	var parRequiredError *common.OidcPARRequiredError
	require.ErrorAs(t, err, &parRequiredError)

	// Re-entry after a completed interaction carries no request_uri, but the bound
	// interaction session proves the original request was PAR-validated
	require.NoError(t, db.Create(&InteractionSession{
		Base:        model.Base{ID: interactionID},
		Scopes:      datatype.StringList{"openid"},
		ClientID:    clientID,
		RequestedAt: datatype.DateTime(time.Now().UTC()),
		Parameters:  map[string]string{},
	}).Error)

	authorization, err := authorize(interactionID, false)
	require.NoError(t, err)
	require.False(t, authorization.RequiresInteraction)

	// An unknown interaction ID must not satisfy the PAR requirement
	_, err = authorize("nonexistent", false)
	require.ErrorIs(t, err, fosite.ErrInvalidRequest)
}

func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		clientID      = "test-client"
		interactionID = "test-interaction"
	)

	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&InteractionSession{
		Base:        model.Base{ID: interactionID},
		Scopes:      datatype.StringList{"openid"},
		ClientID:    clientID,
		RequestedAt: datatype.DateTime(time.Now().UTC()),
		Parameters: map[string]string{
			"client_id":     clientID,
			"response_type": "code",
			"scope":         "openid",
			"redirect_uri":  "https://client.example/callback",
			"state":         "test-state",
		},
	}).Error)

	query, err := service.interactionRequestQuery(t.Context(), interactionID)
	require.NoError(t, err)
	require.Equal(t, clientID, query.Get("client_id"))
	require.Equal(t, "code", query.Get("response_type"))
	require.Equal(t, "openid", query.Get("scope"))
	require.Equal(t, "https://client.example/callback", query.Get("redirect_uri"))
	require.Equal(t, "test-state", query.Get("state"))
	require.Equal(t, interactionID, query.Get("interaction"))

	_, err = service.interactionRequestQuery(t.Context(), "nonexistent")
	require.ErrorIs(t, err, fosite.ErrInvalidRequest)
}

func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID   = "test-user"
		clientID = "test-client"
	)
	loginTime := time.Now().Add(-10 * time.Minute).UTC().Truncate(time.Second)

	require.NoError(t, db.Create(&model.User{
		Base: model.Base{ID: userID},
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
		UserID:   userID,
		ClientID: clientID,
		Scope:    datatype.StringList{"openid"},
	}).Error)

	firstAuthorization, err := service.authorize(t.Context(), authorizeInput{
		userID:             userID,
		authenticationTime: loginTime,
		requester:          newTestAuthorizeRequester("first-request", clientID, ""),
	})
	require.NoError(t, err)
	require.False(t, firstAuthorization.RequiresInteraction)
	require.Equal(t, loginTime, firstAuthorization.Session.IDTokenClaims().AuthTime)

	secondAuthorization, err := service.authorize(t.Context(), authorizeInput{
		userID:             userID,
		authenticationTime: loginTime,
		requester:          newTestAuthorizeRequester("second-request", clientID, "none"),
	})
	require.NoError(t, err)
	require.False(t, secondAuthorization.RequiresInteraction)
	require.Equal(t, loginTime, secondAuthorization.Session.IDTokenClaims().AuthTime)
}

func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID   = "test-user"
		clientID = "test-client"
	)
	loginTime := time.Now().Add(-2 * time.Minute).UTC()

	require.NoError(t, db.Create(&model.User{
		Base: model.Base{ID: userID},
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
		UserID:   userID,
		ClientID: clientID,
		Scope:    datatype.StringList{"openid"},
	}).Error)

	authorization, err := service.authorize(t.Context(), authorizeInput{
		userID:             userID,
		authenticationTime: loginTime,
		requester:          newTestAuthorizeRequesterWithForm("max-age-request", clientID, url.Values{"max_age": {"1"}}),
		requestParams:      map[string]string{"max_age": "1"},
	})
	require.NoError(t, err)
	require.True(t, authorization.RequiresInteraction)

	interaction, err := service.getInteractionSession(t.Context(), authorization.InteractionID)
	require.NoError(t, err)
	require.Equal(t, interactionStepReauthenticate, interaction.CurrentStep)
}

func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID        = "test-user"
		clientID      = "test-client"
		interactionID = "test-interaction"
	)
	loginTime := time.Now().Add(-2 * time.Minute).UTC()
	reauthenticatedAt := time.Now().Add(-1 * time.Second).UTC().Truncate(time.Second)

	require.NoError(t, db.Create(&model.User{
		Base: model.Base{ID: userID},
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
		UserID:   userID,
		ClientID: clientID,
		Scope:    datatype.StringList{"openid"},
	}).Error)
	require.NoError(t, db.Create(&InteractionSession{
		Base:                     model.Base{ID: interactionID},
		Scopes:                   datatype.StringList{"openid"},
		ClientID:                 clientID,
		ReauthenticationRequired: false,
		ReauthenticatedAt:        new(datatype.DateTime(reauthenticatedAt)),
		Parameters: map[string]string{
			"max_age": "1",
		},
	}).Error)

	authorization, err := service.authorize(t.Context(), authorizeInput{
		userID:             userID,
		authenticationTime: loginTime,
		requester:          newTestAuthorizeRequesterWithForm("final-request", clientID, url.Values{"max_age": {"1"}}),
		interactionID:      interactionID,
		requestParams:      map[string]string{"max_age": "1"},
	})
	require.NoError(t, err)
	require.False(t, authorization.RequiresInteraction)
	require.Equal(t, reauthenticatedAt, authorization.Session.IDTokenClaims().AuthTime)
}

func TestAuthorizationServiceAuthorizeUsesOriginalInteractionRequestTime(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID        = "test-user"
		clientID      = "test-client"
		interactionID = "test-interaction"
	)
	originalRequestedAt := time.Now().Add(-10 * time.Second).UTC().Truncate(time.Second)
	reauthenticatedAt := originalRequestedAt.Add(5 * time.Second)
	continuationRequestedAt := reauthenticatedAt.Add(5 * time.Second)

	require.NoError(t, db.Create(&model.User{
		Base: model.Base{ID: userID},
	}).Error)
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)
	require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
		UserID:   userID,
		ClientID: clientID,
		Scope:    datatype.StringList{"openid"},
	}).Error)
	require.NoError(t, db.Create(&InteractionSession{
		Base:                     model.Base{ID: interactionID},
		Scopes:                   datatype.StringList{"openid"},
		ClientID:                 clientID,
		ReauthenticationRequired: false,
		RequestedAt:              datatype.DateTime(originalRequestedAt),
		ReauthenticatedAt:        new(datatype.DateTime(reauthenticatedAt)),
		Parameters: map[string]string{
			"prompt": "login",
		},
	}).Error)

	requester := newTestAuthorizeRequesterWithForm(
		"final-request",
		clientID,
		url.Values{"prompt": {"login"}},
	)
	requester.(*fosite.AuthorizeRequest).RequestedAt = continuationRequestedAt

	authorization, err := service.authorize(t.Context(), authorizeInput{
		userID:             userID,
		authenticationTime: originalRequestedAt.Add(-time.Minute),
		requester:          requester,
		interactionID:      interactionID,
		requestParams:      map[string]string{"prompt": "login"},
	})
	require.NoError(t, err)
	require.False(t, authorization.RequiresInteraction)
	require.Equal(t, originalRequestedAt, authorization.Session.IDTokenClaims().RequestedAt)
	require.Equal(t, reauthenticatedAt, authorization.Session.IDTokenClaims().AuthTime)
	require.False(t, authorization.Session.IDTokenClaims().AuthTime.Before(authorization.Session.IDTokenClaims().RequestedAt))
}

func TestInteractionSessionServiceSavePersistsParameters(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newInteractionSessionService(db)

	const (
		clientID      = "test-client"
		interactionID = "test-interaction"
	)

	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Test Client",
	}).Error)

	interactionSession, err := service.create(t.Context(), InteractionSession{
		Base:                     model.Base{ID: interactionID},
		Scopes:                   datatype.StringList{"openid"},
		ClientID:                 clientID,
		ReauthenticationRequired: true,
		RequestedAt:              datatype.DateTime(time.Now().UTC()),
		Parameters: map[string]string{
			"max_age": "1",
		},
	})
	require.NoError(t, err)

	reauthenticatedAt := datatype.DateTime(time.Now().UTC().Truncate(time.Second))
	interactionSession.ReauthenticationRequired = false
	interactionSession.ReauthenticatedAt = &reauthenticatedAt
	require.NoError(t, service.update(t.Context(), interactionSession))

	storedInteractionSession, err := service.get(t.Context(), interactionID)
	require.NoError(t, err)
	require.False(t, storedInteractionSession.ReauthenticationRequired)
	require.Equal(t, "1", storedInteractionSession.Parameters["max_age"])
	require.NotNil(t, storedInteractionSession.ReauthenticatedAt)
	require.Equal(t, reauthenticatedAt.UTC(), storedInteractionSession.ReauthenticatedAt.UTC())
}

func newTestAuthorizeRequester(requestID, clientID, prompt string) fosite.AuthorizeRequester {
	form := url.Values{}
	if prompt != "" {
		form.Set("prompt", prompt)
	}
	return newTestAuthorizeRequesterWithForm(requestID, clientID, form)
}

func newTestAuthorizeRequesterWithForm(requestID, clientID string, form url.Values) fosite.AuthorizeRequester {
	requester := fosite.NewAuthorizeRequest()
	requester.ID = requestID
	requester.RequestedAt = time.Now().UTC()
	requester.Client = Client{
		OidcClient: model.OidcClient{
			Base: model.Base{ID: clientID},
			Name: "Test Client",
		},
	}
	requester.RequestedScope = fosite.Arguments{"openid"}
	requester.ResponseTypes = fosite.Arguments{"code"}
	requester.RedirectURI = &url.URL{Scheme: "https", Host: "client.example", Path: "/callback"}
	requester.Form = form
	return requester
}

func stringPointer(value string) *string {
	return &value
}

func TestConsentRequired(t *testing.T) {
	tests := []struct {
		name                 string
		hasAlreadyAuthorized bool
		skipConsent          bool
		prompt               string
		want                 bool
	}{
		{"new client without skip requires consent", false, false, "", true},
		{"returning client without skip skips consent", true, false, "", false},
		{"new client with skip skips consent", false, true, "", false},
		{"returning client with skip skips consent", true, true, "", false},
		{"prompt=consent forces consent despite skip", false, true, "consent", true},
		{"prompt=consent forces consent for returning client", true, false, "consent", true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := consentRequired(tt.hasAlreadyAuthorized, tt.skipConsent, newPromptValues(tt.prompt))
			require.Equal(t, tt.want, got)
		})
	}
}

// A client with SkipConsent must be granted without a consent interaction even on the first authorization, while consent is still recorded so the user can later see and revoke the client
func TestAuthorizationServiceSkipConsentGrantsWithoutInteraction(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	auditLogger := &fakeAuditLogger{}
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, nil)

	const (
		userID   = "test-user"
		clientID = "test-client"
	)
	require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error)
	require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}).Error)

	// No prior UserAuthorizedOidcClient record exists, so a client without skip-consent would require the consent screen here
	requester := newTestAuthorizeRequester("skip-consent-request", clientID, "")
	requester.(*fosite.AuthorizeRequest).Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}}

	authorization, err := service.authorize(t.Context(), authorizeInput{
		userID:             userID,
		authenticationTime: time.Now().UTC(),
		requester:          requester,
		meta:               requestMeta{IPAddress: "203.0.113.1", UserAgent: "test-agent"},
	})
	require.NoError(t, err)
	require.False(t, authorization.RequiresInteraction)
	require.NotNil(t, authorization.Session)

	var count int64
	require.NoError(t, db.Model(&model.UserAuthorizedOidcClient{}).Where("user_id = ? AND client_id = ?", userID, clientID).Count(&count).Error)
	require.Equal(t, int64(1), count)

	require.Equal(t, []model.AuditLogEvent{model.AuditLogEventNewClientAuthorization}, auditLogger.events)
}

// A client with SkipConsent must still show the consent screen when the request explicitly asks for it with prompt=consent
func TestAuthorizationServiceSkipConsentHonorsPromptConsent(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)

	const (
		userID   = "test-user"
		clientID = "test-client"
	)
	require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error)
	require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}).Error)

	requester := newTestAuthorizeRequester("skip-consent-prompt-request", clientID, "consent")
	requester.(*fosite.AuthorizeRequest).Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}}

	authorization, err := service.authorize(t.Context(), authorizeInput{
		userID:             userID,
		authenticationTime: time.Now().UTC(),
		requester:          requester,
		meta:               requestMeta{},
	})
	require.NoError(t, err)
	require.True(t, authorization.RequiresInteraction)

	interactionSession, err := newInteractionSessionService(db).get(t.Context(), authorization.InteractionID)
	require.NoError(t, err)
	require.True(t, interactionSession.ConsentRequired)
}
