package auth

import (
	"context"
	"crypto/rand"
	"crypto/subtle"
	"encoding/base64"
	stdErrors "errors"
	"fmt"
	"net/http"
	"os"
	"strings"
	"time"

	pkgauth "github.com/nexuscrm/kernel/pkg/auth"
	"github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/kernel/pkg/utils"
	"github.com/nexuscrm/shared/pkg/models"
)

// ComputeEffectiveScope returns the intersection of app-declared and client-requested scopes.
// Three states:
//   - App has no scopes configured (nil/empty): returns "" (legacy, full access)
//   - Client didn't request specific scopes: returns all app scopes
//   - Intersection computed: returns intersection; errors if empty on a configured app
func ComputeEffectiveScope(appScopes *string, requestedScopes string) (string, error) {
	if appScopes == nil || strings.TrimSpace(*appScopes) == "" {
		return "", nil // Legacy app, full access
	}
	if strings.TrimSpace(requestedScopes) == "" {
		return *appScopes, nil // Grant all app scopes
	}
	appSet := make(map[string]bool)
	for _, s := range strings.Fields(*appScopes) {
		appSet[s] = true
	}
	var result []string
	for _, s := range strings.Fields(requestedScopes) {
		if appSet[s] {
			result = append(result, s)
		}
	}
	if len(result) == 0 {
		return "", fmt.Errorf("requested scope is not permitted by this Connected App")
	}
	return strings.Join(result, " "), nil
}

// getInstanceURL returns the API base URL from environment or default.
func getInstanceURL() string {
	if url := os.Getenv("API_BASE_URL"); url != "" {
		return url
	}
	return "http://127.0.0.1:3001"
}

// OAuthService handles OAuth 2.0 flows
type OAuthService struct {
	userRepo                  ports.UserRepository
	systemRepo                ports.SystemRepository
	sessionRepo               ports.SessionRepository
	authService               *AuthService
	oauthRepo                 ports.OAuthRepository
	authorizationGrants       ports.AuthorizationGrantRepository
	publicScopePolicyProvider PublicScopePolicyProvider
	tokenResponseProfiles     []TokenResponseProfileRegistration
	disconnectAudit           OAuthDisconnectAuditRecorder
}

const authorizeRequestHandleTTL = 10 * time.Minute

func NewOAuthService(
	userRepo ports.UserRepository,
	systemRepo ports.SystemRepository,
	sessionRepo ports.SessionRepository,
	authService *AuthService,
	oauthRepo ports.OAuthRepository,
) *OAuthService {
	var authorizationGrants ports.AuthorizationGrantRepository
	if grants, ok := oauthRepo.(ports.AuthorizationGrantRepository); ok {
		authorizationGrants = grants
	}
	return &OAuthService{
		userRepo:            userRepo,
		systemRepo:          systemRepo,
		sessionRepo:         sessionRepo,
		authService:         authService,
		oauthRepo:           oauthRepo,
		authorizationGrants: authorizationGrants,
	}
}

func (s *OAuthService) SetPublicScopePolicyProvider(provider PublicScopePolicyProvider) {
	s.publicScopePolicyProvider = provider
}

// SetDisconnectAuditRecorder wires the transactional setup-audit sink for the
// self-service credential-cutover action. The caller owns the concrete
// repository; DisconnectUserConnection fails closed when it is absent.
func (s *OAuthService) SetDisconnectAuditRecorder(recorder OAuthDisconnectAuditRecorder) {
	s.disconnectAudit = recorder
}

func (s *OAuthService) SetTokenResponseProfiles(profiles []TokenResponseProfileRegistration) {
	s.tokenResponseProfiles = NormalizeTokenResponseProfiles(profiles)
}

func (s *OAuthService) ResolveOAuthPolicy(ctx context.Context, req OAuthPolicyRequest) (*OAuthPolicyDecision, error) {
	app, err := s.GetConnectedApp(ctx, req.ClientID)
	if err != nil {
		return nil, err
	}
	return s.resolveOAuthPolicyForApp(app, req)
}

func (s *OAuthService) resolveOAuthPolicyForApp(app *models.SystemConnectedApp, req OAuthPolicyRequest) (*OAuthPolicyDecision, error) {
	grant := strings.TrimSpace(req.GrantType)
	if grant == "" {
		switch req.UseCase {
		case OAuthPolicyPasswordGrant:
			grant = OAuthGrantPassword
		case OAuthPolicyClientCredentials:
			grant = OAuthGrantClientCredentials
		case OAuthPolicyRefreshToken:
			grant = OAuthGrantRefreshToken
		default:
			grant = OAuthGrantAuthorizationCode
		}
	}

	clientType := connectedAppClientType(app)
	decision := &OAuthPolicyDecision{
		App:                          app,
		ClientType:                   clientType,
		TokenAuthMethod:              connectedAppTokenAuthMethod(app),
		GrantType:                    grant,
		RedirectURI:                  strings.TrimSpace(req.RedirectURI),
		RequiresPKCE:                 connectedAppRequiresPKCE(app),
		ConsentPolicy:                connectedAppConsentPolicy(app),
		CanIssueWithoutConsentRecord: connectedAppAllowsPublicIssueWithoutConsentRecord(app),
		SessionIssuerClass:           connectedAppSessionIssuerClass(app),
	}

	switch req.UseCase {
	case OAuthPolicyAuthorizeCodeIssue:
		if responseType := strings.TrimSpace(req.ResponseType); responseType != "code" {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "unsupported_response_type", "response_type must be code", "response_type_not_code")
		}
		if grant != OAuthGrantAuthorizationCode {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "unsupported_response_type", "response_type must be code", "grant_not_authorization_code")
		}
		if err := validateRegisteredRedirectURI(app, req.RedirectURI); err != nil {
			return nil, err
		}
		if !connectedAppAllowsGrant(app, OAuthGrantAuthorizationCode) {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "unsupported_response_type", "response_type must be code", "grant_not_allowed")
		}
		if err := s.resolveAuthorizeScopeAndPKCE(decision, req); err != nil {
			return nil, err
		}
		return decision, nil

	case OAuthPolicyAuthorizeCodeToken:
		if grant != OAuthGrantAuthorizationCode {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "grant_not_authorization_code")
		}
		if err := s.resolveTokenClientAuth(decision, req.PresentedSecret); err != nil {
			return nil, err
		}
		if !connectedAppAllowsGrant(app, OAuthGrantAuthorizationCode) {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "grant_not_allowed")
		}
		if err := validateRegisteredRedirectURI(app, req.RedirectURI); err != nil {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "redirect_policy_changed")
		}
		if clientType == OAuthClientTypePublic {
			if !PublicOAuthPKCEEnabled() {
				return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "public_oauth_disabled")
			}
			issuer, err := PublicOAuthCanonicalIssuer()
			if err != nil {
				return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "public_oauth_issuer_missing")
			}
			decision.PublicIssuer = issuer
			if normalizeOAuthIssuer(req.Issuer) != issuer {
				return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "issuer_mismatch")
			}
			if strings.TrimSpace(req.RequestedScope) != "" {
				effectiveScope, err := computePublicEffectiveScope(app, req.RequestedScope, s.publicScopePolicyProvider)
				if err != nil {
					return nil, err
				}
				if strings.TrimSpace(effectiveScope) == "" {
					return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "public_empty_effective_scope")
				}
				decision.EffectiveScope = effectiveScope
			}
		} else if strings.TrimSpace(req.RequestedScope) != "" || app != nil && app.Scopes != nil {
			effectiveScope, err := computeConfidentialAuthCodeTokenScope(app, req.RequestedScope)
			if err != nil {
				return nil, err
			}
			decision.EffectiveScope = effectiveScope
		}
		return decision, nil

	case OAuthPolicyPasswordGrant:
		if clientType == OAuthClientTypePublic {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_client", "invalid client", "public_client_password_grant")
		}
		if err := s.resolveTokenClientAuth(decision, req.PresentedSecret); err != nil {
			return nil, err
		}
		if !connectedAppAllowsGrant(app, OAuthGrantPassword) {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "grant type is not allowed for this Connected App", "grant_not_allowed")
		}
		effectiveScope, err := ComputeEffectiveScope(connectedAppScopesForPolicy(app), req.RequestedScope)
		if err != nil {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_scope", err.Error(), "scope_not_permitted")
		}
		decision.EffectiveScope = effectiveScope
		return decision, nil

	case OAuthPolicyClientCredentials:
		if clientType == OAuthClientTypePublic {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_client", "invalid client", "public_client_client_credentials")
		}
		if err := s.resolveTokenClientAuth(decision, req.PresentedSecret); err != nil {
			return nil, err
		}
		if !connectedAppAllowsGrant(app, OAuthGrantClientCredentials) {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "grant type is not allowed for this Connected App", "grant_not_allowed")
		}
		effectiveScope, err := ComputeEffectiveScope(connectedAppScopesForPolicy(app), req.RequestedScope)
		if err != nil {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_scope", err.Error(), "scope_not_permitted")
		}
		decision.EffectiveScope = effectiveScope
		return decision, nil

	case OAuthPolicyRefreshToken:
		// Both confidential (secret) and public (PKCE) clients may hold refresh
		// tokens, so — unlike password/client_credentials — public clients are
		// not rejected here; resolveTokenClientAuth enforces the per-type rule.
		if err := s.resolveTokenClientAuth(decision, req.PresentedSecret); err != nil {
			return nil, err
		}
		// Honor the public-OAuth kill switch: when public PKCE is disabled, a
		// public client's previously issued refresh tokens must stop rotating too,
		// exactly as the authorization-code path fails closed.
		if clientType == OAuthClientTypePublic && !PublicOAuthPKCEEnabled() {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid refresh token", "public_oauth_disabled")
		}
		if !connectedAppAllowsGrant(app, OAuthGrantRefreshToken) {
			return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "grant type is not allowed for this Connected App", "grant_not_allowed")
		}
		// Enforce the canonical-origin contract on the public refresh path exactly
		// as the authorization-code token path does (fail closed): require a
		// configured canonical issuer and reject a request whose origin does not
		// match it, so a refresh cannot mint a session through an alternate host or
		// with a missing/malformed configuration.
		if clientType == OAuthClientTypePublic {
			issuer, err := PublicOAuthCanonicalIssuer()
			if err != nil {
				return nil, err
			}
			decision.PublicIssuer = issuer
			if normalizeOAuthIssuer(req.Issuer) != issuer {
				return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid refresh token", "issuer_mismatch")
			}
		}
		return decision, nil
	default:
		return nil, newOAuthProtocolError(http.StatusBadRequest, "unsupported_grant_type", "unsupported grant type", "unsupported_policy_use_case")
	}
}

func (s *OAuthService) resolveAuthorizeScopeAndPKCE(decision *OAuthPolicyDecision, req OAuthPolicyRequest) error {
	if decision.ClientType == OAuthClientTypePublic {
		if !PublicOAuthPKCEEnabled() {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "public OAuth clients are not enabled", "public_oauth_disabled")
		}
		issuer, err := PublicOAuthCanonicalIssuer()
		if err != nil {
			return err
		}
		decision.PublicIssuer = issuer
		if normalizeOAuthIssuer(req.Issuer) != issuer {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "public OAuth issuer is invalid", "public_oauth_issuer_mismatch")
		}
		effectiveScope, err := computePublicEffectiveScope(decision.App, req.RequestedScope, s.publicScopePolicyProvider)
		if err != nil {
			return err
		}
		decision.EffectiveScope = effectiveScope
		if !decision.CanIssueWithoutConsentRecord {
			return newOAuthProtocolError(http.StatusForbidden, "access_denied", "consent is required for this app", "consent_prompt_not_implemented")
		}
	} else {
		effectiveScope, err := ComputeEffectiveScope(connectedAppScopesForPolicy(decision.App), req.RequestedScope)
		if err != nil {
			return errors.NewValidationError("scope", err.Error())
		}
		decision.EffectiveScope = effectiveScope
	}
	if decision.RequiresPKCE {
		if err := validatePKCEChallenge(req.CodeChallengeMethod, req.CodeChallenge); err != nil {
			return err
		}
	}
	return nil
}

func (s *OAuthService) resolveTokenClientAuth(decision *OAuthPolicyDecision, presentedSecret string) error {
	switch decision.TokenAuthMethod {
	case OAuthTokenAuthNone:
		if decision.ClientType != OAuthClientTypePublic {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_client", "invalid client", "confidential_client_without_auth")
		}
		if strings.TrimSpace(presentedSecret) != "" {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "client_secret is not allowed for this client", "public_client_sent_secret")
		}
	case OAuthTokenAuthClientSecretPost:
		if strings.TrimSpace(presentedSecret) == "" {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_client", "invalid client", "missing_client_secret")
		}
		if !VerifyConnectedAppSecret(decision.App, presentedSecret) {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_client", "invalid client", "invalid_client_secret")
		}
		decision.PresentedClientSecretValidated = true
	default:
		return newOAuthProtocolError(http.StatusBadRequest, "invalid_client", "invalid client", "unsupported_token_auth_method")
	}
	return nil
}

func resolvedIssueIssuer(requestIssuer string, decision *OAuthPolicyDecision) string {
	if decision != nil && decision.ClientType == OAuthClientTypePublic {
		return decision.PublicIssuer
	}
	return requestIssuer
}

func normalizeOAuthIssuer(raw string) string {
	return strings.TrimRight(strings.TrimSpace(raw), "/")
}

func connectedAppScopesForPolicy(app *models.SystemConnectedApp) *string {
	if app == nil {
		return nil
	}
	return app.Scopes
}

func computeConfidentialAuthCodeTokenScope(app *models.SystemConnectedApp, codeScope string) (string, error) {
	codeScope = strings.TrimSpace(codeScope)
	appScopes := connectedAppScopesForPolicy(app)
	if codeScope != "" && (appScopes == nil || strings.TrimSpace(*appScopes) == "") {
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "scope_policy_changed")
	}
	effectiveScope, err := ComputeEffectiveScope(appScopes, codeScope)
	if err != nil {
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "scope_policy_changed")
	}
	if codeScope != "" && strings.TrimSpace(effectiveScope) == "" {
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "scope_policy_changed")
	}
	return effectiveScope, nil
}

// GenerateAuthCode creates a temporary authorization code
func (s *OAuthService) GenerateAuthCode(ctx context.Context, clientID, redirectURI, scope, userID string) (string, error) {
	return s.GenerateAuthCodeForRequest(ctx, AuthorizationCodeRequest{
		ClientID:    clientID,
		RedirectURI: redirectURI,
		Scope:       scope,
		UserID:      userID,
	})
}

// CreateAuthorizeRequestHandle validates and stores a public authorize request
// snapshot for browser login/resume. Only public clients use this bridge.
func (s *OAuthService) CreateAuthorizeRequestHandle(ctx context.Context, req AuthorizeRequestCreateRequest) (*AuthorizeRequestCreateResult, error) {
	resource, err := ValidateMCPResourceRequest(req.Resource, req.ResourcePresent)
	if err != nil {
		return nil, err
	}
	req.Resource = resource
	decision, err := s.ResolveOAuthPolicy(ctx, OAuthPolicyRequest{
		UseCase:             OAuthPolicyAuthorizeCodeIssue,
		ClientID:            req.ClientID,
		GrantType:           OAuthGrantAuthorizationCode,
		ResponseType:        req.ResponseType,
		RedirectURI:         req.RedirectURI,
		RequestedScope:      req.Scope,
		CodeChallenge:       req.CodeChallenge,
		CodeChallengeMethod: req.CodeChallengeMethod,
		Issuer:              req.Issuer,
	})
	if err != nil {
		return nil, err
	}
	if decision.ClientType != OAuthClientTypePublic {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "authorize resume is only supported for public clients", "authorize_resume_non_public_client")
	}
	if err := validateMCPResourceConnectedApp(decision.App, req.Resource); err != nil {
		return nil, err
	}
	if err := validateMCPResourcePKCE(req.Resource, req.CodeChallenge, req.CodeChallengeMethod); err != nil {
		return nil, err
	}

	handle, err := generateRandomString(32)
	if err != nil {
		return nil, errors.NewInternalError("failed to generate authorize request handle", err)
	}
	expiresAt := time.Now().Add(authorizeRequestHandleTTL)
	if err := s.oauthRepo.StoreAuthorizeRequest(ctx, ports.OAuthAuthorizeRequestCreateRequest{
		Handle:              handle,
		ClientID:            req.ClientID,
		RedirectURI:         req.RedirectURI,
		Scope:               req.Scope,
		State:               req.State,
		CodeChallenge:       req.CodeChallenge,
		CodeChallengeMethod: req.CodeChallengeMethod,
		Issuer:              resolvedIssueIssuer(req.Issuer, decision),
		Resource:            req.Resource,
		ExpiresAt:           expiresAt,
	}); err != nil {
		return nil, err
	}
	return &AuthorizeRequestCreateResult{
		Handle:    handle,
		ExpiresAt: expiresAt,
	}, nil
}

// ResumeAuthorizeRequest consumes a stored pre-login authorize request and
// issues a normal authorization code for the supplied platform browser user.
func (s *OAuthService) ResumeAuthorizeRequest(ctx context.Context, req AuthorizeRequestResumeRequest) (*AuthorizeRequestResumeResult, error) {
	if strings.TrimSpace(req.Handle) == "" {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "authorize request handle is required", "authorize_request_handle_missing")
	}
	if strings.TrimSpace(req.UserID) == "" {
		return nil, newOAuthProtocolError(http.StatusForbidden, "access_denied", "interactive login is required", "authorize_resume_user_missing")
	}

	// An SSO login state is not an authorize handle. Both live in the same one-time store, so
	// without this the flow with the weaker preconditions is a way into the other one — and the
	// check is on the handle's namespace, before any read, so a foreign value cannot be fetched
	// here at all.
	if strings.HasPrefix(strings.TrimSpace(req.Handle), ssoLoginStatePrefix) {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "invalid or expired authorize request", "authorize_request_wrong_flow")
	}
	stored, err := s.oauthRepo.GetAuthorizeRequest(ctx, req.Handle)
	if err != nil {
		return nil, err
	}
	if stored == nil {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "invalid or expired authorize request", "authorize_request_missing_expired_or_used")
	}
	result := &AuthorizeRequestResumeResult{
		RedirectURI: stored.RedirectURI,
		State:       stored.State,
	}

	var consumed *ports.OAuthAuthorizeRequestData
	codeRequest, err := s.issueAuthCodeUnderIssuanceLock(ctx, AuthorizationCodeRequest{
		ClientID:            stored.ClientID,
		RedirectURI:         stored.RedirectURI,
		Scope:               stored.Scope,
		UserID:              req.UserID,
		CodeChallenge:       stored.CodeChallenge,
		CodeChallengeMethod: stored.CodeChallengeMethod,
		Issuer:              stored.Issuer,
		Resource:            stored.Resource,
	}, func(txCtx context.Context, codeReq ports.OAuthCodeCreateRequest) error {
		// The pre-login handle, code, and grant must commit or roll back together.
		var consumeErr error
		consumed, consumeErr = s.oauthRepo.ConsumeAuthorizeRequestAndStoreAuthCode(txCtx, req.Handle, codeReq)
		if consumeErr != nil {
			return consumeErr
		}
		if consumed == nil {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "invalid or expired authorize request", "authorize_request_missing_expired_or_used")
		}
		// Re-read under the atomic consume lock before committing the code. The
		// pre-login snapshot is the source of the resource binding; a stale read
		// must never turn into a code for a different resource.
		if consumed.Resource != codeReq.Resource {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_target", "resource does not match authorize request", "mcp_resource_authorize_resume_mismatch")
		}
		return nil
	})
	if err != nil {
		if isConnectedAppLookupNotFound(err) {
			return result, newOAuthProtocolError(http.StatusBadRequest, "invalid_client", "invalid client", "authorize_resume_connected_app_hidden")
		}
		// Only a terminal OAuth policy decision consumes the pre-login handle.
		// Storage, transaction, and grant-issuance failures roll back the atomic
		// consume-and-store operation, so the user must be able to retry the same
		// interactive authorization request instead of losing it to a transient
		// backend failure.
		if authorizeResumeFailureConsumesHandle(err) {
			if _, consumeErr := s.oauthRepo.ConsumeAuthorizeRequest(ctx, req.Handle); consumeErr != nil {
				return nil, consumeErr
			}
		}
		return result, err
	}

	result.Code = codeRequest.Code
	result.RedirectURI = consumed.RedirectURI
	result.State = consumed.State
	return result, nil
}

// GenerateAuthCodeForRequest creates a temporary authorization code and binds
// it to the validated OAuth request context.
func (s *OAuthService) GenerateAuthCodeForRequest(ctx context.Context, req AuthorizationCodeRequest) (string, error) {
	codeRequest, err := s.issueAuthCodeUnderIssuanceLock(ctx, req, func(txCtx context.Context, codeReq ports.OAuthCodeCreateRequest) error {
		// The code and its authorization era are written by the same caller-owned
		// transaction, so a storage failure cannot leave a live era without its
		// one-time credential.
		return s.oauthRepo.StoreAuthCodeRequest(txCtx, codeReq)
	})
	if err != nil {
		return "", err
	}
	return codeRequest.Code, nil
}

// issueAuthCodeUnderIssuanceLock establishes the generic authorization grant
// and writes its OAuth code in one Connected App issuance transaction. The
// app record is locked by SystemRepository before the grant adapter takes the
// subject lock, preserving the global client -> subject -> grant order.
func (s *OAuthService) issueAuthCodeUnderIssuanceLock(
	ctx context.Context,
	req AuthorizationCodeRequest,
	store func(context.Context, ports.OAuthCodeCreateRequest) error,
) (*ports.OAuthCodeCreateRequest, error) {
	resource, err := ValidateMCPResourceRequest(req.Resource, strings.TrimSpace(req.Resource) != "")
	if err != nil {
		return nil, err
	}
	req.Resource = resource
	policyRequest := OAuthPolicyRequest{
		UseCase:             OAuthPolicyAuthorizeCodeIssue,
		ClientID:            req.ClientID,
		GrantType:           OAuthGrantAuthorizationCode,
		ResponseType:        "code",
		RedirectURI:         req.RedirectURI,
		RequestedScope:      req.Scope,
		CodeChallenge:       req.CodeChallenge,
		CodeChallengeMethod: req.CodeChallengeMethod,
		Issuer:              req.Issuer,
	}
	// This preliminary policy read only identifies the client to lock. Every
	// security-sensitive value is re-resolved while the client row is locked.
	decision, err := s.ResolveOAuthPolicy(ctx, policyRequest)
	if err != nil {
		return nil, err
	}
	if decision.App == nil || strings.TrimSpace(decision.App.ID) == "" {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_client", "invalid client", "connected_app_unresolved")
	}

	code, err := generateRandomString(32)
	if err != nil {
		return nil, errors.NewInternalError("failed to generate authorization code", err)
	}

	grants, err := s.requireAuthorizationGrantRepository()
	if err != nil {
		return nil, err
	}
	var codeRequest *ports.OAuthCodeCreateRequest
	err = s.systemRepo.WithConnectedAppCredentialIssuance(ctx, decision.App.ID, func(txCtx context.Context) error {
		freshApp, freshErr := s.GetConnectedApp(txCtx, req.ClientID)
		if freshErr != nil {
			return freshErr
		}
		if freshApp == nil || freshApp.ID != decision.App.ID {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_client", "invalid client", "client_changed_during_authorize")
		}
		lockedDecision, policyErr := s.resolveOAuthPolicyForApp(freshApp, policyRequest)
		if policyErr != nil {
			return policyErr
		}
		if resourceErr := validateMCPResourceConnectedApp(freshApp, req.Resource); resourceErr != nil {
			return resourceErr
		}
		if pkceErr := validateMCPResourcePKCE(req.Resource, req.CodeChallenge, req.CodeChallengeMethod); pkceErr != nil {
			return pkceErr
		}
		permitted, permissionErr := s.IsUserPermittedForApp(txCtx, freshApp, req.UserID)
		if permissionErr != nil {
			return errors.NewInternalError("failed to check app access policy", permissionErr)
		}
		if !permitted {
			return newOAuthProtocolError(http.StatusForbidden, "access_denied", "user is not authorized for this app", "app_access_denied")
		}

		grant, grantErr := grants.EnsureAuthorizationGrant(
			ports.WithAuthorizationClientLock(txCtx, freshApp.ID),
			ports.AuthorizationGrantRequest{
				SubjectID:     req.UserID,
				ClientID:      freshApp.ID,
				Scope:         lockedDecision.EffectiveScope,
				PolicyVersion: freshApp.ConsentVersion,
			},
		)
		if grantErr != nil {
			return grantErr
		}

		now := time.Now()
		built := ports.OAuthCodeCreateRequest{
			Code:                code,
			ClientID:            req.ClientID,
			RedirectURI:         req.RedirectURI,
			Scope:               lockedDecision.EffectiveScope,
			UserID:              req.UserID,
			ExpiresAt:           now.Add(10 * time.Minute),
			CodeChallenge:       req.CodeChallenge,
			CodeChallengeMethod: req.CodeChallengeMethod,
			AuthTime:            now,
			Issuer:              resolvedIssueIssuer(req.Issuer, lockedDecision),
			ClientTypeAtIssue:   lockedDecision.ClientType,
			ConsentID:           grant.ID,
			Resource:            req.Resource,
		}
		if store != nil {
			if storeErr := store(txCtx, built); storeErr != nil {
				return storeErr
			}
		}
		codeRequest = &built
		return nil
	})
	if err != nil {
		return nil, err
	}
	return codeRequest, nil
}

func (s *OAuthService) requireAuthorizationGrantRepository() (ports.AuthorizationGrantRepository, error) {
	if s.authorizationGrants == nil {
		return nil, errors.NewInternalError("OAuth authorization grant repository is not configured", nil)
	}
	return s.authorizationGrants, nil
}

// validateMCPResourceConnectedApp keeps the resource-bound grant deliberately
// short-lived. A Connected App that can issue or redeem refresh credentials is
// not eligible for the MCP resource flow; the check is performed at authorize
// and again while the app row is locked during code/token issuance.
func validateMCPResourceConnectedApp(app *models.SystemConnectedApp, resource string) error {
	if strings.TrimSpace(resource) == "" {
		return nil
	}
	if app == nil || connectedAppClientType(app) != OAuthClientTypePublic ||
		app.IssueRefreshTokens || connectedAppAllowsGrant(app, OAuthGrantRefreshToken) {
		return newOAuthProtocolError(http.StatusBadRequest, "invalid_target", "MCP resource requires a public, non-refresh client", "mcp_resource_client_ineligible")
	}
	return nil
}

// validateMCPResourcePKCE keeps the resource flow on public S256 PKCE even if
// a legacy Connected App row is misconfigured after authorization starts.
func validateMCPResourcePKCE(resource, challenge, method string) error {
	if strings.TrimSpace(resource) == "" {
		return nil
	}
	if strings.TrimSpace(challenge) == "" || method != OAuthCodeChallengeMethodS256 {
		return newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "MCP resource requires S256 PKCE", "mcp_resource_pkce_required")
	}
	return nil
}

// validateMCPCodeResource binds the token request to the exact resource stored
// with the one-time code. A legacy/unclassified code cannot be converted into
// an MCP token by adding resource only at the token endpoint.
func validateMCPCodeResource(storedResource string, tokenReq TokenExchangeRequest) error {
	if strings.TrimSpace(storedResource) == "" {
		if tokenReq.ResourcePresent {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_target", "authorization code was not issued for this resource", "mcp_resource_code_unbound")
		}
		return nil
	}
	if !tokenReq.ResourcePresent || tokenReq.Resource != storedResource {
		return newOAuthProtocolError(http.StatusBadRequest, "invalid_target", "resource does not match authorization code", "mcp_resource_code_mismatch")
	}
	return nil
}

// ExchangeToken swaps an auth code for access/refresh tokens
func (s *OAuthService) ExchangeToken(ctx context.Context, clientID, clientSecret, code, redirectURI string) (*models.SessionToken, error) {
	return s.ExchangeTokenForRequest(ctx, TokenExchangeRequest{
		ClientID:     clientID,
		ClientSecret: clientSecret,
		Code:         code,
		RedirectURI:  redirectURI,
	})
}

// ExchangeClientCredentials is the single durable machine-grant issuance
// path. It revalidates client policy, run-as identity, and app access while the
// Connected App credential lock is held, then persists the app-bound session
// in that same transaction scope.
func (s *OAuthService) ExchangeClientCredentials(ctx context.Context, request ClientCredentialsExchangeRequest) (*ClientCredentialsExchangeResult, error) {
	if s == nil || s.systemRepo == nil {
		return nil, errors.NewInternalError("OAuth client-credentials authority is not configured", nil)
	}
	if request.ResourcePresent || strings.TrimSpace(request.Resource) != "" {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_target", "resource is not supported for this grant", "client_credentials_resource_unsupported")
	}
	policyRequest := OAuthPolicyRequest{
		UseCase: OAuthPolicyClientCredentials, ClientID: request.ClientID,
		GrantType: OAuthGrantClientCredentials, RequestedScope: request.RequestedScope,
		PresentedSecret: request.ClientSecret, Issuer: request.Issuer,
	}
	decision, err := s.ResolveOAuthPolicy(ctx, policyRequest)
	if err != nil {
		return nil, err
	}
	if decision == nil || decision.App == nil || strings.TrimSpace(decision.App.ID) == "" {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_client", "invalid client", "connected_app_unresolved")
	}

	var result *ClientCredentialsExchangeResult
	err = s.systemRepo.WithConnectedAppCredentialIssuance(ctx, decision.App.ID, func(txCtx context.Context) error {
		lockedDecision, policyErr := s.ResolveOAuthPolicy(txCtx, policyRequest)
		if policyErr != nil {
			return policyErr
		}
		if lockedDecision == nil || lockedDecision.App == nil || lockedDecision.App.ID != decision.App.ID {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_client", "invalid client", "client_changed_during_issuance")
		}
		if !lockedDecision.App.IsActive {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_client", "invalid client", "connected_app_inactive")
		}
		runAsUserID := ""
		if lockedDecision.App.RunAsUserID != nil {
			runAsUserID = *lockedDecision.App.RunAsUserID
		}
		if strings.TrimSpace(runAsUserID) == "" || strings.TrimSpace(runAsUserID) != runAsUserID {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_client", "invalid client", "run_as_user_missing")
		}
		permitted, permissionErr := s.IsUserPermittedForApp(txCtx, lockedDecision.App, runAsUserID)
		if permissionErr != nil {
			return errors.NewInternalError("failed to check app access policy", permissionErr)
		}
		if !permitted {
			return newOAuthProtocolError(http.StatusBadRequest, "unauthorized_client", "client is not authorized", "app_access_denied")
		}
		if s.authService == nil {
			return errors.NewInternalError("OAuth client-credentials session authority is not configured", nil)
		}
		session, token, issueErr := s.authService.BuildOAuthSession(
			txCtx, runAsUserID, request.SourceAddress, request.UserAgent,
			&SessionOptions{ConnectedAppId: lockedDecision.App.ID, Scope: lockedDecision.EffectiveScope,
				GrantType: OAuthGrantClientCredentials, SessionClass: lockedDecision.SessionIssuerClass},
		)
		if issueErr != nil {
			return issueErr
		}
		if session == nil || token == nil || strings.TrimSpace(token.AccessToken) == "" || token.RefreshToken != "" {
			return errors.NewInternalError("OAuth client-credentials session result is invalid", nil)
		}
		token.Scope = lockedDecision.EffectiveScope
		if issueErr := s.authService.PersistOAuthSession(txCtx, session); issueErr != nil {
			return issueErr
		}
		result = &ClientCredentialsExchangeResult{Token: token, UserID: runAsUserID,
			ConnectedAppID: lockedDecision.App.ID, SessionIssuerClass: lockedDecision.SessionIssuerClass}
		return nil
	})
	if err != nil {
		return nil, err
	}
	if result == nil {
		return nil, errors.NewInternalError("OAuth client-credentials issuance returned no result", nil)
	}
	// Emit success only after the Connected App policy/session transaction has
	// committed. A rollback must never leave a durable false-success audit.
	s.authService.NoteOAuthLoginSuccess(ctx, result.UserID, request.SourceAddress, request.UserAgent)
	return result, nil
}

// ExchangeTokenForRequest swaps an auth code for an access token while enforcing
// client type, PKCE, redirect, scope, and app access policy.
func (s *OAuthService) ExchangeTokenForRequest(ctx context.Context, tokenReq TokenExchangeRequest) (*models.SessionToken, error) {
	resource, err := ValidateMCPResourceRequest(tokenReq.Resource, tokenReq.ResourcePresent)
	if err != nil {
		return nil, err
	}
	tokenReq.Resource = resource
	decision, err := s.ResolveOAuthPolicy(ctx, OAuthPolicyRequest{
		UseCase:         OAuthPolicyAuthorizeCodeToken,
		ClientID:        tokenReq.ClientID,
		GrantType:       OAuthGrantAuthorizationCode,
		RedirectURI:     tokenReq.RedirectURI,
		PresentedSecret: tokenReq.ClientSecret,
		Issuer:          tokenReq.Issuer,
	})
	if err != nil {
		return nil, err
	}
	var token *models.SessionToken
	var loginUserID string
	err = s.systemRepo.WithConnectedAppCredentialIssuance(
		ctx,
		decision.App.ID,
		func(txCtx context.Context) error {
			issued, userID, err := s.exchangeTokenForRequestLocked(txCtx, tokenReq, decision.App.ID)
			token = issued
			loginUserID = userID
			return err
		},
	)
	if err != nil {
		return nil, err
	}
	// The audit sink is intentionally outside the transaction: a successful
	// event must only be emitted after the grant/code/session bundle commits.
	if strings.TrimSpace(loginUserID) != "" {
		s.authService.NoteOAuthLoginSuccess(ctx, loginUserID, "OAuth2", "API")
	}
	return token, nil
}

func (s *OAuthService) exchangeTokenForRequestLocked(
	ctx context.Context,
	tokenReq TokenExchangeRequest,
	lockedAppID string,
) (*models.SessionToken, string, error) {
	decision, err := s.ResolveOAuthPolicy(ctx, OAuthPolicyRequest{
		UseCase:         OAuthPolicyAuthorizeCodeToken,
		ClientID:        tokenReq.ClientID,
		GrantType:       OAuthGrantAuthorizationCode,
		RedirectURI:     tokenReq.RedirectURI,
		PresentedSecret: tokenReq.ClientSecret,
		Issuer:          tokenReq.Issuer,
	})
	if err != nil {
		return nil, "", err
	}
	if decision.App == nil || decision.App.ID != lockedAppID {
		return nil, "", errors.NewInternalError("Connected App changed during credential issuance", nil)
	}
	app := decision.App
	clientType := decision.ClientType

	// Preview the code first so runtime token-response profile conflicts fail
	// before consuming the one-time authorization code.
	req, err := s.oauthRepo.GetAuthCode(ctx, tokenReq.Code)
	if err != nil {
		return nil, "", err
	}

	if req == nil {
		return nil, "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "code_missing_expired_or_used")
	}
	if err := validateMCPCodeResource(req.Resource, tokenReq); err != nil {
		return nil, "", err
	}
	if err := validateMCPResourceConnectedApp(app, req.Resource); err != nil {
		return nil, "", err
	}
	if err := validateMCPResourcePKCE(req.Resource, req.CodeChallenge, req.CodeChallengeMethod); err != nil {
		return nil, "", err
	}

	// Validate client and redirect URI match the stored values
	if req.ClientID != tokenReq.ClientID {
		return nil, "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "client_mismatch")
	}
	if req.RedirectURI != tokenReq.RedirectURI {
		return nil, "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "redirect_mismatch")
	}
	if req.ClientTypeAtIssue != "" && req.ClientTypeAtIssue != clientType {
		return nil, "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "client_type_changed")
	}
	// Selected cutover policy B grandfathers a pre-grant public code. New
	// issuance always stores ConsentID, so this path is limited to the short
	// lifetime of a code created before grant binding existed; disconnect sweeps
	// all such codes for the subject in the same credential-revocation
	// transaction.
	if clientType == OAuthClientTypePublic && normalizeOAuthIssuer(req.Issuer) != decision.PublicIssuer {
		return nil, "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "issuer_mismatch")
	}

	decision, err = s.resolveOAuthPolicyForApp(app, OAuthPolicyRequest{
		UseCase:         OAuthPolicyAuthorizeCodeToken,
		ClientID:        tokenReq.ClientID,
		GrantType:       OAuthGrantAuthorizationCode,
		RedirectURI:     tokenReq.RedirectURI,
		RequestedScope:  req.Scope,
		PresentedSecret: tokenReq.ClientSecret,
		Issuer:          tokenReq.Issuer,
	})
	if err != nil {
		return nil, "", err
	}
	if req.CodeChallenge != "" {
		if req.CodeChallengeMethod != OAuthCodeChallengeMethodS256 {
			return nil, "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "stored_pkce_method_invalid")
		}
		if err := verifyPKCEVerifier(tokenReq.CodeVerifier, req.CodeChallenge); err != nil {
			return nil, "", err
		}
	} else if connectedAppRequiresPKCE(app) {
		return nil, "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "stored_pkce_missing")
	}

	// Check AdminApproved access policy before issuing token
	permitted, err := s.IsUserPermittedForApp(ctx, decision.App, req.UserID)
	if err != nil {
		return nil, "", errors.NewInternalError("failed to check app access policy", err)
	}
	if !permitted {
		return nil, "", errors.NewForbiddenError("User is not authorized to use this Connected App")
	}

	effectiveScope := decision.EffectiveScope
	instanceURL := getInstanceURL()
	if clientType == OAuthClientTypePublic {
		instanceURL = decision.PublicIssuer
	}
	issuedAt := fmt.Sprintf("%d", time.Now().UnixMilli())
	profileCtx := TokenResponseProfileContext{
		InstanceURL:    instanceURL,
		UserID:         req.UserID,
		GrantType:      OAuthGrantAuthorizationCode,
		IssuedAt:       issuedAt,
		ClientSecret:   tokenReq.ClientSecret,
		ConnectedAppID: decision.App.ID,
		SessionClass:   decision.SessionIssuerClass,
	}
	profileClaims, err := selectTokenResponseProfileClaims(profileCtx, s.tokenResponseProfiles)
	if err != nil {
		return nil, "", err
	}

	// A new code carries the exact authorization-grant era established at
	// issuance. Check it in the same app-lock transaction immediately before
	// the one-time consume and durable session write. P6 owns the explicit
	// cutover policy for historic codes that have no grant id.
	if strings.TrimSpace(req.ConsentID) != "" {
		grants, grantErr := s.requireAuthorizationGrantRepository()
		if grantErr != nil {
			return nil, "", grantErr
		}
		active, grantErr := grants.AuthorizationGrantActive(
			ports.WithAuthorizationClientLock(ctx, app.ID),
			req.ConsentID,
			req.UserID,
			app.ID,
			app.ConsentVersion,
		)
		if grantErr != nil {
			return nil, "", grantErr
		}
		if !active {
			return nil, "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "connection_revoked")
		}
	}

	// Consume code from database after every pre-issuance check has passed.
	// This remains the atomic one-time-use gate; a concurrent redeem wins here
	// and this request returns invalid_grant without issuing auth state.
	req, err = s.oauthRepo.ConsumeAuthCode(ctx, tokenReq.Code)
	if err != nil {
		return nil, "", err
	}
	if req == nil {
		return nil, "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid authorization code", "code_missing_expired_or_used")
	}

	// The session persist uses this ambient transaction, so code consumption,
	// grant gate and session creation commit or roll back together.
	session, token, err := s.authService.BuildOAuthSession(ctx, req.UserID, "OAuth2", "API", &SessionOptions{
		ConnectedAppId:   decision.App.ID,
		Scope:            effectiveScope,
		GrantType:        OAuthGrantAuthorizationCode,
		SessionClass:     decision.SessionIssuerClass,
		ResourceAudience: req.Resource,
	})
	if err != nil {
		return nil, "", err
	}
	if err := s.authService.PersistOAuthSession(ctx, session); err != nil {
		return nil, "", err
	}

	// Stamp scope on response so callers can inspect granted scopes
	token.Scope = effectiveScope

	// Issue a rotating refresh token when the Connected App opts in, so a client
	// can refresh silently instead of re-running the browser flow. Require BOTH
	// IssueRefreshTokens and the refresh_token grant, so we never hand out a
	// refresh token the refresh endpoint would then reject as grant-not-allowed.
	if req.Resource == "" && app != nil && app.IssueRefreshTokens && connectedAppAllowsGrant(app, OAuthGrantRefreshToken) {
		refreshToken, refreshErr := s.issueRefreshToken(ctx, app, req.UserID, effectiveScope, "", req.ConsentID)
		if refreshErr != nil {
			return nil, "", refreshErr
		}
		token.RefreshToken = refreshToken
	}

	token.InstanceURL = instanceURL
	token.IssuedAt = issuedAt
	applyTokenResponseProfileClaims(token, profileClaims)

	return token, req.UserID, nil
}

const oauthRefreshTokenLifetime = 30 * 24 * time.Hour

// RefreshTokenExchangeRequest captures token endpoint parameters for the
// refresh_token grant.
type RefreshTokenExchangeRequest struct {
	ClientID       string
	ClientSecret   string
	RefreshToken   string
	RequestedScope string
	Issuer         string
}

// newRefreshTokenRecord mints a fresh opaque refresh token and builds its
// (hash-only) persistence record. An empty familyID starts a new token family
// (initial issuance); a non-empty one carries the family forward across a
// rotation so replay of any member can revoke the whole chain. It does not
// persist — callers store it (StoreRefreshToken) or rotate it in
// (RotateRefreshToken).
func (s *OAuthService) newRefreshTokenRecord(app *models.SystemConnectedApp, userID, scope, familyID, consentID string) (string, *models.SystemOAuthRefreshToken, error) {
	random, err := generateRandomString(30)
	if err != nil {
		return "", nil, err
	}
	plaintext := OAuthRefreshTokenPrefix + random
	if strings.TrimSpace(familyID) == "" {
		familyID = utils.GenerateID()
	}
	family := familyID
	record := &models.SystemOAuthRefreshToken{
		ID:        utils.GenerateID(),
		TokenHash: hashRefreshToken(plaintext),
		UserID:    userID,
		FamilyID:  &family,
		ExpiresAt: time.Now().Add(oauthRefreshTokenLifetime),
	}
	if app != nil {
		appID := app.ID
		record.ConnectedAppID = &appID
	}
	if strings.TrimSpace(scope) != "" {
		sc := scope
		record.Scope = &sc
	}
	if strings.TrimSpace(consentID) != "" {
		id := consentID
		record.ConsentID = &id
	}
	return plaintext, record, nil
}

// issueRefreshToken mints, stores (hashed), and returns a new opaque refresh
// token. Used for initial issuance (authorization-code exchange); rotation goes
// through RotateRefreshToken so the revoke-and-reissue is atomic.
func (s *OAuthService) issueRefreshToken(ctx context.Context, app *models.SystemConnectedApp, userID, scope, familyID, consentID string) (string, error) {
	plaintext, record, err := s.newRefreshTokenRecord(app, userID, scope, familyID, consentID)
	if err != nil {
		return "", err
	}
	if err := s.oauthRepo.StoreRefreshToken(ctx, record); err != nil {
		return "", err
	}
	return plaintext, nil
}

// intersectScopeCeiling caps the originally-granted scope to what the Connected
// App still permits today. Either side being empty is the legacy "full access"
// grant and is treated as universal (a superset of any concrete set), so the
// non-empty side wins; when both are concrete the result is their set
// intersection. This is what makes scope removed from the app AFTER a refresh
// token was issued stop being honored on refresh.
func intersectScopeCeiling(storedScope, appScope string) string {
	stored := normalizeScopeSet(storedScope)
	app := normalizeScopeSet(appScope)
	if len(stored) == 0 {
		return strings.Join(app, " ")
	}
	if len(app) == 0 {
		return strings.Join(stored, " ")
	}
	var out []string
	for _, sc := range stored {
		if scopeSetContains(app, sc) {
			out = append(out, sc)
		}
	}
	return strings.Join(out, " ")
}

// narrowRefreshScope enforces RFC 6749 §6: a refresh request may only narrow,
// never widen, the scope of the original grant.
func narrowRefreshScope(storedScope, requestedScope string) (string, error) {
	stored := normalizeScopeSet(storedScope)
	requested := normalizeScopeSet(requestedScope)
	// An empty stored scope is the legacy "full access" grant, which is a
	// superset of any concrete scope — so any requested set is a valid
	// narrowing rather than a widening.
	if len(stored) == 0 {
		return strings.Join(requested, " "), nil
	}
	for _, sc := range requested {
		if !scopeSetContains(stored, sc) {
			return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_scope", "requested scope exceeds the original grant", "refresh_scope_exceeds_original")
		}
	}
	return strings.Join(requested, " "), nil
}

// effectiveRefreshScope derives the refresh response scope from the originally
// granted scope and the current Connected App policy. It is evaluated before
// entering the issuance transaction for a fast rejection, then evaluated again
// under the Connected App lock immediately before credentials are created. The
// locked result is the only result used for persisted/session credentials.
func (s *OAuthService) effectiveRefreshScope(app *models.SystemConnectedApp, decision *OAuthPolicyDecision, storedScope, requestedScope string) (string, error) {
	if app == nil || decision == nil {
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid refresh token", "refresh_policy_missing")
	}

	appScopesRaw := ""
	if app.Scopes != nil {
		appScopesRaw = *app.Scopes
	}
	// Cap the originally-granted scope to what the app still permits, so scope
	// narrowed on the Connected App after issuance is honored on refresh rather
	// than the client silently retaining the broader grant it was first issued.
	storedConcrete := strings.TrimSpace(storedScope) != ""
	appConcrete := strings.TrimSpace(appScopesRaw) != ""
	effectiveScope := intersectScopeCeiling(storedScope, appScopesRaw)
	// A concrete stored scope can only coexist with empty app scopes if the app's
	// scopes were CLEARED after this token was issued (a legacy full-access app
	// issues empty-scoped tokens, not concrete ones). Treat that as the
	// authorization having been withdrawn and fail closed, rather than falling back
	// to the legacy empty=="full access" reading and continuing to mint sessions.
	// Likewise reject when a still-scoped app no longer intersects the grant at all.
	if storedConcrete && (!appConcrete || strings.TrimSpace(effectiveScope) == "") {
		return "", newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid refresh token", "refresh_scope_revoked")
	}
	if strings.TrimSpace(requestedScope) != "" {
		narrowed, scopeErr := narrowRefreshScope(effectiveScope, requestedScope)
		if scopeErr != nil {
			return "", scopeErr
		}
		effectiveScope = narrowed
	}
	// Public clients must always carry a concrete scope that the live public
	// scope policy still allows; re-validate the capped set against it. This only
	// applies once the app has scopes configured — a public app with no allowed
	// scopes could never have issued a scoped refresh token in the first place
	// (computePublicEffectiveScope rejects it at authorize time).
	if decision.ClientType == OAuthClientTypePublic && strings.TrimSpace(appScopesRaw) != "" {
		publicScope, scopeErr := computePublicEffectiveScope(app, effectiveScope, s.publicScopePolicyProvider)
		if scopeErr != nil {
			return "", scopeErr
		}
		effectiveScope = publicScope
	}
	return effectiveScope, nil
}

// ExchangeRefreshToken validates a refresh token, rotates it (single-use), and
// issues a new access-token session. Supports confidential (secret) and public
// (PKCE) clients.
func (s *OAuthService) ExchangeRefreshToken(ctx context.Context, tokenReq RefreshTokenExchangeRequest) (*models.SessionToken, error) {
	decision, err := s.ResolveOAuthPolicy(ctx, OAuthPolicyRequest{
		UseCase:         OAuthPolicyRefreshToken,
		ClientID:        tokenReq.ClientID,
		GrantType:       OAuthGrantRefreshToken,
		PresentedSecret: tokenReq.ClientSecret,
		Issuer:          tokenReq.Issuer,
	})
	if err != nil {
		return nil, err
	}
	app := decision.App

	// Turning IssueRefreshTokens off must immediately stop existing tokens from
	// rotating, not merely stop new issuance — otherwise an app that disables the
	// feature keeps honoring outstanding refresh tokens because the grant is still
	// in the allowlist. The grant allowlist itself is enforced by ResolveOAuthPolicy.
	if app == nil || !app.IssueRefreshTokens {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "grant type is not allowed for this Connected App", "refresh_tokens_disabled")
	}

	if strings.TrimSpace(tokenReq.RefreshToken) == "" {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_request", "refresh_token is required", "refresh_token_missing")
	}
	stored, err := s.oauthRepo.GetRefreshTokenByHash(ctx, hashRefreshToken(tokenReq.RefreshToken))
	if err != nil {
		return nil, err
	}
	if stored == nil {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid refresh token", "refresh_token_unknown_or_revoked")
	}
	// A known-but-revoked token (replay of an already-rotated token) is NOT handled
	// here with a best-effort family sweep — that swallowed repository errors and
	// could leave the live successor usable. Replay detection and family
	// revocation are done exclusively by the atomic RotateRefreshToken below, which
	// fails CLOSED: if the family revoke errors, the transaction rolls back and the
	// exchange returns an error rather than a false "reuse handled".
	//
	// Only short-circuit on expiry for a NON-revoked token. A revoked token is a
	// replay signal that must still reach RotateRefreshToken (which revokes the
	// family) even when it has since expired — otherwise replaying an expired-but-
	// already-rotated token A would return "expired" here and leave its live
	// successor B usable, defeating family replay defense.
	if !stored.IsRevoked && time.Now().After(stored.ExpiresAt) {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid refresh token", "refresh_token_expired")
	}
	if stored.ConnectedAppID == nil || app == nil || *stored.ConnectedAppID != app.ID {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid refresh token", "refresh_token_client_mismatch")
	}

	storedScope := ""
	if stored.Scope != nil {
		storedScope = *stored.Scope
	}
	if _, err := s.effectiveRefreshScope(app, decision, storedScope, tokenReq.RequestedScope); err != nil {
		return nil, err
	}

	permitted, err := s.IsUserPermittedForApp(ctx, app, stored.UserID)
	if err != nil {
		return nil, errors.NewInternalError("failed to check app access policy", err)
	}
	if !permitted {
		return nil, errors.NewForbiddenError("User is not authorized to use this Connected App")
	}

	// Rotate atomically: consuming the presented token (single-use) and inserting
	// its successor happen in one per-family-serialized transaction, so a
	// concurrent replay cannot revoke the family in the window between the two and
	// leave the successor active. The repository — not the earlier non-transactional
	// read — is the authoritative single-use + family-replay gate.
	familyID := ""
	if stored.FamilyID != nil {
		familyID = *stored.FamilyID
	}
	grantID := ""
	if stored.ConsentID != nil {
		grantID = *stored.ConsentID
	}
	var rotated, reuseDetected, connectionRevoked bool
	var token *models.SessionToken
	var newRefresh, issuedAt, instanceURL, issuedScope string
	var profileClaims TokenResponseProfileClaims
	issueErr := s.systemRepo.WithConnectedAppCredentialIssuance(ctx, app.ID, func(txCtx context.Context) error {
		lockedDecision, lockedErr := s.ResolveOAuthPolicy(txCtx, OAuthPolicyRequest{
			UseCase:         OAuthPolicyRefreshToken,
			ClientID:        tokenReq.ClientID,
			GrantType:       OAuthGrantRefreshToken,
			PresentedSecret: tokenReq.ClientSecret,
			Issuer:          tokenReq.Issuer,
		})
		if lockedErr != nil {
			return lockedErr
		}
		lockedApp := lockedDecision.App
		if lockedApp == nil || lockedApp.ID != app.ID || stored.ConnectedAppID == nil || *stored.ConnectedAppID != lockedApp.ID {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid refresh token", "refresh_token_client_mismatch")
		}
		// ResolveOAuthPolicy validates the allowed grant list, but refresh-token
		// issuance itself is controlled by this separate Connected App switch.
		// Re-check it from the locked App row so a disable that acquired the App
		// lock before this callback cannot be bypassed by the fast pre-lock read.
		if !lockedApp.IssueRefreshTokens {
			return newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "grant type is not allowed for this Connected App", "refresh_tokens_disabled")
		}
		lockedScope, scopeErr := s.effectiveRefreshScope(lockedApp, lockedDecision, storedScope, tokenReq.RequestedScope)
		if scopeErr != nil {
			return scopeErr
		}
		lockedPermitted, permissionErr := s.IsUserPermittedForApp(txCtx, lockedApp, stored.UserID)
		if permissionErr != nil {
			return errors.NewInternalError("failed to check app access policy", permissionErr)
		}
		if !lockedPermitted {
			return errors.NewForbiddenError("User is not authorized to use this Connected App")
		}
		// P6 owns the explicit behavior for legacy pre-SSoT refresh rows which
		// do not carry a grant id. All newly issued rows bind to one exact era.
		if strings.TrimSpace(grantID) != "" {
			grants, grantErr := s.requireAuthorizationGrantRepository()
			if grantErr != nil {
				return grantErr
			}
			active, grantErr := grants.AuthorizationGrantActive(
				ports.WithAuthorizationClientLock(txCtx, lockedApp.ID),
				grantID,
				stored.UserID,
				lockedApp.ID,
				lockedApp.ConsentVersion,
			)
			if grantErr != nil {
				return grantErr
			}
			if !active {
				connectionRevoked = true
				return nil
			}
		}

		// Construct every newly issued credential from the decision read under the
		// Connected App lock. A policy or access mutation that won the lock first
		// therefore cannot be bypassed by stale pre-lock response/session state.
		lockedInstanceURL := getInstanceURL()
		if lockedDecision.ClientType == OAuthClientTypePublic {
			lockedInstanceURL = lockedDecision.PublicIssuer
		}
		lockedIssuedAt := fmt.Sprintf("%d", time.Now().UnixMilli())
		lockedClaims, profileErr := selectTokenResponseProfileClaims(TokenResponseProfileContext{
			InstanceURL:    lockedInstanceURL,
			UserID:         stored.UserID,
			GrantType:      OAuthGrantRefreshToken,
			IssuedAt:       lockedIssuedAt,
			ClientSecret:   tokenReq.ClientSecret,
			ConnectedAppID: lockedApp.ID,
			SessionClass:   lockedDecision.SessionIssuerClass,
		}, s.tokenResponseProfiles)
		if profileErr != nil {
			return profileErr
		}
		session, lockedToken, sessionErr := s.authService.BuildOAuthSession(txCtx, stored.UserID, "OAuth2", "API", &SessionOptions{
			ConnectedAppId: lockedApp.ID,
			Scope:          lockedScope,
			GrantType:      OAuthGrantRefreshToken,
			SessionClass:   lockedDecision.SessionIssuerClass,
		})
		if sessionErr != nil {
			return sessionErr
		}
		lockedRefresh, successor, refreshErr := s.newRefreshTokenRecord(lockedApp, stored.UserID, lockedScope, familyID, grantID)
		if refreshErr != nil {
			return refreshErr
		}

		var rotateErr error
		rotated, reuseDetected, rotateErr = s.oauthRepo.RotateRefreshToken(txCtx, hashRefreshToken(tokenReq.RefreshToken), successor)
		if rotateErr != nil {
			return rotateErr
		}
		if !rotated {
			// A reuse result must still commit the family revocation. Returning a
			// protocol error here would roll it back with the outer transaction.
			return nil
		}
		if err := s.authService.PersistOAuthSession(txCtx, session); err != nil {
			return err
		}
		token = lockedToken
		newRefresh = lockedRefresh
		issuedAt = lockedIssuedAt
		instanceURL = lockedInstanceURL
		issuedScope = lockedScope
		profileClaims = lockedClaims
		return nil
	})
	if issueErr != nil {
		return nil, issueErr
	}
	if connectionRevoked {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid refresh token", "connection_revoked")
	}
	if reuseDetected {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid refresh token", "refresh_token_reuse_detected")
	}
	if !rotated {
		return nil, newOAuthProtocolError(http.StatusBadRequest, "invalid_grant", "invalid refresh token", "refresh_token_unknown_or_revoked")
	}
	if token == nil {
		return nil, errors.NewInternalError("refresh token issuance completed without a response", nil)
	}

	s.authService.NoteOAuthLoginSuccess(ctx, stored.UserID, "OAuth2", "API")
	token.Scope = issuedScope
	token.InstanceURL = instanceURL
	token.IssuedAt = issuedAt
	token.RefreshToken = newRefresh
	applyTokenResponseProfileClaims(token, profileClaims)
	return token, nil
}

// RevokeRefreshTokensForUser revokes all of a user's opaque refresh tokens. It
// is wired into AuthService credential revocation (password change/reset) so a
// stolen refresh token cannot outlive a session revocation and keep minting
// access tokens.
func (s *OAuthService) RevokeRefreshTokensForUser(ctx context.Context, userID string) error {
	_, err := s.oauthRepo.RevokeRefreshTokensForUser(ctx, userID)
	return err
}

// LooksLikeRefreshToken reports whether a token value is an opaque OAuth refresh
// token (so the revocation endpoint can route it to refresh-token revocation
// instead of session logout).
func LooksLikeRefreshToken(token string) bool {
	return strings.HasPrefix(strings.TrimSpace(token), OAuthRefreshTokenPrefix)
}

// RevokeRefreshTokenByValue revokes a presented opaque refresh token per RFC
// 7009. Revoking a refresh token also revokes its whole family (all rotations
// of the same grant), so a client "log out" is complete. It is non-disclosing:
// an unknown token is a successful no-op, matching the endpoint contract.
func (s *OAuthService) RevokeRefreshTokenByValue(ctx context.Context, presented string) error {
	presented = strings.TrimSpace(presented)
	if presented == "" {
		return nil
	}
	stored, err := s.oauthRepo.GetRefreshTokenByHash(ctx, hashRefreshToken(presented))
	if err != nil {
		return err
	}
	if stored == nil {
		return nil
	}
	if stored.FamilyID != nil && strings.TrimSpace(*stored.FamilyID) != "" {
		_, err = s.oauthRepo.RevokeRefreshTokenFamily(ctx, *stored.FamilyID)
		return err
	}
	_, err = s.oauthRepo.RevokeRefreshToken(ctx, stored.ID)
	return err
}

func applyTokenResponseProfiles(token *models.SessionToken, ctx TokenResponseProfileContext, profiles []TokenResponseProfileRegistration) error {
	if token == nil {
		return nil
	}
	match, err := selectTokenResponseProfileClaims(ctx, profiles)
	if err != nil {
		return err
	}
	applyTokenResponseProfileClaims(token, match)
	return nil
}

func selectTokenResponseProfileClaims(ctx TokenResponseProfileContext, profiles []TokenResponseProfileRegistration) (TokenResponseProfileClaims, error) {
	match, err := BuildTokenResponseProfileClaims(ctx, profiles)
	if err != nil {
		if stdErrors.Is(err, ErrTokenResponseProfileConflict) {
			return TokenResponseProfileClaims{}, newOAuthProtocolError(http.StatusInternalServerError, "server_error", "token response profile conflict", "token_response_profile_conflict")
		}
		return TokenResponseProfileClaims{}, err
	}
	return match, nil
}

func applyTokenResponseProfileClaims(token *models.SessionToken, match TokenResponseProfileClaims) {
	if token == nil {
		return
	}
	for key, value := range match.Claims {
		switch strings.ToLower(strings.TrimSpace(key)) {
		case "id":
			token.ID = stringTokenProfileValue(value)
		case "signature":
			token.Signature = stringTokenProfileValue(value)
		}
	}
}

func stringTokenProfileValue(value any) string {
	switch v := value.(type) {
	case string:
		return v
	case fmt.Stringer:
		return v.String()
	default:
		return fmt.Sprint(value)
	}
}

func OAuthProtocolErrorFrom(err error) (*OAuthProtocolError, bool) {
	var oauthErr *OAuthProtocolError
	if stdErrors.As(err, &oauthErr) {
		return oauthErr, true
	}
	return nil, false
}

func authorizeResumeFailureConsumesHandle(err error) bool {
	oauthErr, ok := OAuthProtocolErrorFrom(err)
	return ok && oauthErr.Code != "server_error"
}

// GetConnectedApp retrieves app by Client ID.
func (s *OAuthService) GetConnectedApp(ctx context.Context, clientID string) (*models.SystemConnectedApp, error) {
	return s.systemRepo.GetConnectedAppByClientID(ctx, clientID)
}

func isConnectedAppLookupNotFound(err error) bool {
	var notFound *errors.NotFoundError
	if !stdErrors.As(err, &notFound) {
		return false
	}
	resource := strings.ToLower(strings.ReplaceAll(notFound.Resource, "_", " "))
	return strings.Contains(resource, "connected") && strings.Contains(resource, "app")
}

// IsUserPermittedForApp checks if the user is authorized to use the app
// when PermittedUsers = 'AdminApproved'. Returns true if:
// - App.PermittedUsers != "AdminApproved" (all users allowed)
// - User's profile is assigned to the app
// - User has a permissionset that is assigned to the app
func (s *OAuthService) IsUserPermittedForApp(ctx context.Context, app *models.SystemConnectedApp, userID string) (bool, error) {
	if app.PermittedUsers == nil || *app.PermittedUsers != "AdminApproved" {
		return true, nil
	}
	return s.systemRepo.IsUserPermittedForApp(ctx, app.ID, userID)
}

func VerifyConnectedAppSecret(app *models.SystemConnectedApp, presented string) bool {
	if app == nil || strings.TrimSpace(presented) == "" {
		return false
	}
	if app.ConsumerSecretHash != nil && strings.TrimSpace(*app.ConsumerSecretHash) != "" {
		if pkgauth.VerifyPassword(presented, *app.ConsumerSecretHash) {
			return true
		}
	}
	if app.ConsumerSecret == "" {
		return false
	}
	return subtle.ConstantTimeCompare([]byte(app.ConsumerSecret), []byte(presented)) == 1
}

func generateRandomString(n int) (string, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return "", errors.NewInternalError("failed to generate random bytes", err)
	}
	return base64.URLEncoding.EncodeToString(b), nil
}
