package auth

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"os"
	"strings"
	"sync"
	"time"

	"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/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/rs/zerolog/log"
)

const activeUserCacheTTL = 60 * time.Second

const renewedBearerOverlapWindow = 30 * time.Second

// permCacheInvalidator is the subset of PermissionService needed for cache invalidation.
type permCacheInvalidator interface {
	InvalidateUserCache(userID string)
}

// activeUserEntry is a single cached IsActive result with an expiry timestamp.
type activeUserEntry struct {
	isActive bool
	cachedAt time.Time
}

// activeUserCache is a short-lived in-memory cache of user IsActive status.
// It reduces redundant DB queries on the ValidateSession hot path.
type activeUserCache struct {
	mu      sync.RWMutex
	entries map[string]activeUserEntry
}

func newActiveUserCache() *activeUserCache {
	return &activeUserCache{entries: make(map[string]activeUserEntry)}
}

// get returns (isActive, ok). ok is false when the entry is absent or expired.
func (c *activeUserCache) get(userID string) (bool, bool) {
	now := time.Now()
	c.mu.RLock()
	e, ok := c.entries[userID]
	c.mu.RUnlock()
	if !ok {
		return false, false
	}
	if now.Sub(e.cachedAt) <= activeUserCacheTTL {
		return e.isActive, true
	}
	c.mu.Lock()
	if current, exists := c.entries[userID]; exists && current.cachedAt.Equal(e.cachedAt) {
		delete(c.entries, userID)
	}
	c.mu.Unlock()
	return false, false
}

// set stores an IsActive value for userID.
func (c *activeUserCache) set(userID string, isActive bool) {
	c.mu.Lock()
	c.entries[userID] = activeUserEntry{isActive: isActive, cachedAt: time.Now()}
	c.mu.Unlock()
}

// invalidate removes a userID from the cache (called on deactivation).
func (c *activeUserCache) invalidate(userID string) {
	c.mu.Lock()
	delete(c.entries, userID)
	c.mu.Unlock()
}

// sessionControlRepository is an auth-owned optional capability. Keeping these
// proof-specific writes out of ports.SessionRepository preserves compatibility
// for control-plane and other consumers of the shared session contract.
type sessionControlRepository interface {
	RevokeSessionWithControlProof(ctx context.Context, sessionID, expectedControlProofHash string) (bool, error)
	RenewSessionWithControlProof(ctx context.Context, sessionID, previousToken, expectedControlProofHash, token string, expiresAt, now time.Time) (bool, error)
}

// AuthService handles authentication, session management, and password operations
type AuthService struct {
	userRepo            ports.UserRepository
	permissionRepo      ports.PermissionRepository
	sessionRepo         ports.SessionRepository
	authorizingSessions ports.AuthorizingSessionRepository
	sessionControlRepo  sessionControlRepository

	// credentialScopeObserver receives one observation per successful admission, for the
	// empty-scope inventory design section 11 requires. Nil by default: reporting is opt-in
	// and must never affect admission.
	credentialScopeObserver CredentialScopeObserver
	permSvc                 permCacheInvalidator
	loginHistoryRepo        loginHistoryRecorder
	setupAuditor            *SetupAuditor
	activeCache             *activeUserCache
	connectedAppCache       *activeUserCache
	sessionTouch            *sessionTouchCoalescer
	sessionTouchSlots       chan struct{}
	// credentialRevocationHooks run after a user's sessions are revoked for a
	// credential change (password change/reset). They let other credential stores
	// — notably OAuth refresh tokens — be revoked in the same breath, so a stolen
	// long-lived credential cannot survive the session revocation. Best-effort:
	// a hook error is logged, not surfaced, so it never blocks the password change.
	credentialRevocationHooks []func(ctx context.Context, userID string) error
}

// AddCredentialRevocationHook registers a callback invoked (best-effort) whenever
// a user's sessions are revoked for a credential change. Used to fold OAuth
// refresh-token revocation into password change/reset.
func (s *AuthService) AddCredentialRevocationHook(hook func(ctx context.Context, userID string) error) {
	if hook == nil {
		return
	}
	s.credentialRevocationHooks = append(s.credentialRevocationHooks, hook)
}

// runCredentialRevocationHooks invokes every registered hook and FAILS CLOSED:
// if any hook errors (e.g. refresh-token revocation could not complete), it
// returns that error so the caller can surface the failure rather than leave a
// long-lived credential active after a password change/reset. Each hook is
// retried briefly to ride out transient blips before giving up.
func (s *AuthService) runCredentialRevocationHooks(ctx context.Context, userID string) error {
	for _, hook := range s.credentialRevocationHooks {
		if hook == nil {
			continue
		}
		var lastErr error
		for attempt := 0; attempt < 3; attempt++ {
			if lastErr = hook(ctx, userID); lastErr == nil {
				break
			}
			log.Warn().Err(lastErr).Str("userID", userID).Int("attempt", attempt+1).Msg("credential revocation hook failed; retrying")
		}
		if lastErr != nil {
			log.Error().Err(lastErr).Str("userID", userID).Msg("credential revocation hook failed after retries; failing closed")
			return errors.NewInternalError("failed to revoke dependent credentials", lastErr)
		}
	}
	return nil
}

const (
	defaultSessionTouchMinInterval = 60 * time.Second
	defaultSessionTouchConcurrency = 4
	defaultSessionTouchMaxTracked  = 10000
	defaultSessionRenewalLeadTime  = auth.DefaultSessionLifetime / 2
)

type sessionTouchState struct {
	lastQueued time.Time
	inFlight   bool
}

// sessionTouchCoalescer keeps LastActivity out of the per-request hot path.
// LastActivity is an approximate liveness timestamp, not an auth decision
// source, so multiple requests for the same session can be folded into one
// bounded async DB write.
type sessionTouchCoalescer struct {
	mu          sync.Mutex
	minInterval time.Duration
	maxTracked  int
	entries     map[string]sessionTouchState
}

func newSessionTouchCoalescer(minInterval time.Duration) *sessionTouchCoalescer {
	if minInterval <= 0 {
		minInterval = defaultSessionTouchMinInterval
	}
	return &sessionTouchCoalescer{
		minInterval: minInterval,
		maxTracked:  defaultSessionTouchMaxTracked,
		entries:     make(map[string]sessionTouchState),
	}
}

func (c *sessionTouchCoalescer) shouldQueue(sessionID string, now time.Time) bool {
	if c == nil || sessionID == "" {
		return false
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	c.pruneLocked(now)
	state := c.entries[sessionID]
	if state.inFlight || (!state.lastQueued.IsZero() && now.Sub(state.lastQueued) < c.minInterval) {
		return false
	}
	state.inFlight = true
	state.lastQueued = now
	c.entries[sessionID] = state
	return true
}

func (c *sessionTouchCoalescer) pruneLocked(now time.Time) {
	if c.maxTracked <= 0 || len(c.entries) <= c.maxTracked {
		return
	}
	for sessionID, state := range c.entries {
		if state.inFlight {
			continue
		}
		if state.lastQueued.IsZero() || now.Sub(state.lastQueued) >= c.minInterval {
			delete(c.entries, sessionID)
		}
	}
}

func (c *sessionTouchCoalescer) finish(sessionID string) {
	if c == nil || sessionID == "" {
		return
	}
	c.mu.Lock()
	state := c.entries[sessionID]
	state.inFlight = false
	c.entries[sessionID] = state
	c.mu.Unlock()
}

// loginHistoryRecorder is the subset of LoginHistoryRepository needed by AuthService.
type loginHistoryRecorder interface {
	RecordLogin(ctx context.Context, userID, loginType, sourceIP, browser, status string) error
}

// SetPermissionService wires the PermissionService for cache invalidation on user changes.
func (s *AuthService) SetPermissionService(ps permCacheInvalidator) {
	s.permSvc = ps
}

// OnUserDeactivated invalidates the active-user cache for defense-in-depth.
func (s *AuthService) OnUserDeactivated(userID string) {
	s.activeCache.invalidate(userID)
}

// OnConnectedAppChanged invalidates Connected App active-state cache entries
// after app security metadata changes.
func (s *AuthService) OnConnectedAppChanged(appID string) {
	s.connectedAppCache.invalidate(appID)
}

// NewAuthService creates a new AuthService
// init wires the kernel's JWT resource-identifier resolver to this module's single
// source of truth for the deployment's canonical origin (resolveDeploymentResource).
// Minted session/OAuth-access tokens are then stamped with that origin as their aud
// claim and verified against it on every request, so a token issued for one
// environment cannot be replayed against another.
//
// Fails closed: in production/staging the resource identifier is REQUIRED. If it
// cannot be resolved the process refuses to boot (mirroring the JWT_SECRET
// requirement) — otherwise binding would be silently disabled and cross-environment
// replay would stay open. Dev/test may run without one, which no-ops the binding.
func init() {
	auth.SetResourceIdentifierResolver(resolveDeploymentResource)
	// Fail closed: production/staging MUST bind to a globally-unique https,
	// non-loopback resource identifier — an empty, loopback, or non-https value
	// would leave binding ineffective (shared audience) and keep cross-environment
	// replay open. Refuse to boot otherwise (mirrors the JWT_SECRET requirement).
	if isProductionLikeEnv() && !resourceIsSecurePublicOrigin(resolveDeploymentResource()) {
		log.Fatal().Msg("SECURITY: production/staging requires API_BASE_URL to be a valid https non-loopback issuer so session and OAuth tokens are bound to a unique deployment resource (prevents cross-environment token replay).")
	}
}

// isProductionLikeEnv reports whether this process runs in a production-like
// deployment, using the SAME signals as the server runtime's own production guard
// (ENVIRONMENT / NEXUS_ENV in production|prod|staging), so the resource-binding
// fail-closed check covers every mode the runtime treats as production.
func isProductionLikeEnv() bool {
	for _, v := range []string{os.Getenv("ENVIRONMENT"), os.Getenv("NEXUS_ENV")} {
		switch strings.ToLower(strings.TrimSpace(v)) {
		case "production", "prod", "staging":
			return true
		}
	}
	return false
}

func NewAuthService(
	userRepo ports.UserRepository,
	sessionRepo ports.SessionRepository,
	permissionRepo ports.PermissionRepository,
) *AuthService {
	sessionControlRepo, _ := sessionRepo.(sessionControlRepository)
	authorizingSessions, _ := sessionRepo.(ports.AuthorizingSessionRepository)
	return &AuthService{
		userRepo:            userRepo,
		permissionRepo:      permissionRepo,
		sessionRepo:         sessionRepo,
		authorizingSessions: authorizingSessions,
		sessionControlRepo:  sessionControlRepo,
		activeCache:         newActiveUserCache(),
		connectedAppCache:   newActiveUserCache(),
		sessionTouch:        newSessionTouchCoalescer(defaultSessionTouchMinInterval),
		sessionTouchSlots:   make(chan struct{}, defaultSessionTouchConcurrency),
	}
}

// SetLoginHistoryRepo wires the LoginHistoryRepository for recording login events.
func (s *AuthService) SetLoginHistoryRepo(repo loginHistoryRecorder) {
	s.loginHistoryRepo = repo
}

// SetSetupAuditor wires the SetupAuditor for recording admin setup changes.
func (s *AuthService) SetSetupAuditor(a *SetupAuditor) {
	s.setupAuditor = a
}

// SessionOptions holds optional parameters for session creation (e.g. OAuth flows).
type SessionOptions struct {
	ConnectedAppId string
	Scope          string
	GrantType      string
	SessionClass   string
	// ResourceAudience binds this session's bearer to one exact protected
	// resource instead of the generic deployment-origin audience.
	ResourceAudience string
	// IssueControlProof is an explicit transport-owned opt-in. P4a callers leave
	// it false so no unreachable proof hash is stored before a handler can
	// deliver the raw proof through the protected browser cookie boundary.
	IssueControlProof bool
}

// LoginResult contains the result of a successful login
type LoginResult struct {
	Token        string
	User         auth.UserSession
	ExpiresAt    time.Time
	ControlProof string `json:"-"`
}

// SessionTokenResult keeps the browser control proof out of the OAuth token
// response model. A future first-party handler can opt in and deliver the proof
// through its HttpOnly cookie boundary; existing OAuth callers continue using
// CreateSessionForUser and receive only the standard token response.
type SessionTokenResult struct {
	SessionToken *models.SessionToken
	ExpiresAt    time.Time
	ControlProof string `json:"-"`
}

type sessionCreationResult struct {
	token        string
	expiresAt    time.Time
	controlProof string
}

func rejectInactiveUser(user *models.User) error {
	if user == nil || (user.IsActive && !user.IsDeleted) {
		return nil
	}

	log.Warn().
		Str("userID", user.ID).
		Str("email", user.Email).
		Msg("Authentication rejected for inactive or deleted user")

	return errors.NewUnauthorizedError("User account is inactive")
}

// recordLoginHistory fires a goroutine to record a login event without blocking the caller.
func (s *AuthService) recordLoginHistory(ctx context.Context, userID, loginType, sourceIP, userAgent, status string) {
	if s.loginHistoryRepo == nil {
		return
	}
	traceparent, requestID := authAsyncWriteObservability(ctx)
	go func() {
		ctx, cancel := authAsyncWriteContext(traceparent, requestID)
		defer cancel()
		if err := s.loginHistoryRepo.RecordLogin(ctx, userID, loginType, sourceIP, userAgent, status); err != nil {
			log.Warn().Err(err).Msg("failed to record login history")
		}
	}()
}

func authAsyncWriteObservability(ctx context.Context) (string, string) {
	if ctx == nil {
		return "", ""
	}
	traceparent, _ := ctx.Value(constants.ContextKeyTraceparent).(string)
	requestID, _ := ctx.Value(constants.ContextKeyRequestID).(string)
	return traceparent, requestID
}

func authAsyncWriteContext(traceparent, requestID string) (context.Context, context.CancelFunc) {
	ctx := context.Background()
	if traceparent != "" {
		ctx = context.WithValue(ctx, constants.ContextKeyTraceparent, traceparent)
	}
	if requestID != "" {
		ctx = context.WithValue(ctx, constants.ContextKeyRequestID, requestID)
	}
	return context.WithTimeout(ctx, 5*time.Second)
}

// Login authenticates a user and creates a session.
// opts is optional; pass nil for non-OAuth logins.
func (s *AuthService) Login(ctx context.Context, email, password, ip, userAgent string, opts *SessionOptions) (*LoginResult, error) {
	// 1. Find user by email
	user, err := s.userRepo.FindUserByEmailWithPassword(ctx, email)
	if err != nil {
		return nil, errors.NewInternalError("database error", err)
	}
	if user == nil {
		log.Warn().Str("email", email).Msg("Login failed: user not found")
		return nil, errors.NewUnauthorizedError("Invalid email or password")
	}

	// 2. Check account lockout
	if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) {
		log.Warn().Str("email", email).Time("lockedUntil", *user.LockedUntil).Msg("Login rejected: account is locked")
		s.recordLoginHistory(ctx, user.ID, "Application", ip, userAgent, "Failed")
		return nil, errors.NewUnauthorizedError("Account is temporarily locked due to too many failed login attempts")
	}

	// 3. Verify password
	if user.PasswordHash == "" {
		log.Warn().Str("email", email).Msg("Login failed: password hash is NULL")
		s.recordLoginHistory(ctx, user.ID, "Application", ip, userAgent, "Failed")
		return nil, errors.NewUnauthorizedError("Password authentication not configured for this user")
	}

	if !auth.VerifyPassword(password, user.PasswordHash) {
		log.Warn().Str("email", email).Msg("Login failed: invalid password")
		s.recordLoginHistory(ctx, user.ID, "Application", ip, userAgent, "Failed")
		// Atomically increment counter and lock if threshold reached
		locked, err := s.userRepo.IncrementFailedAndLockIfNeeded(ctx, user.ID, constants.MaxFailedLoginAttempts, constants.AccountLockoutDuration)
		if err != nil {
			log.Error().Err(err).Str("userID", user.ID).Msg("failed to increment login attempts")
		}
		if locked {
			// Revoke all sessions for the locked account
			if revokeErr := s.sessionRepo.RevokeAllSessionsForUser(ctx, user.ID); revokeErr != nil {
				log.Warn().Err(revokeErr).Str("userID", user.ID).Msg("Failed to revoke sessions on lockout")
			}
			log.Warn().Str("email", email).Msg("Account locked after too many failed login attempts")
		}
		return nil, errors.NewUnauthorizedError("Invalid email or password")
	}

	if err := rejectInactiveUser(user.User); err != nil {
		return nil, err
	}

	// Reset failed login attempts on successful authentication
	if err := s.userRepo.ResetFailedLoginAttempts(ctx, user.ID); err != nil {
		log.Error().Err(err).Str("userID", user.ID).Msg("failed to reset login attempts")
	}

	// 3. Create user session object
	// RoleID is populated by FindUserByEmailWithPassword (Repository Layer)
	displayName := auth.ConstructDisplayName(user.Username, user.FirstName, user.LastName)
	userSession := auth.UserSession{
		ID:          user.ID,
		Name:        displayName,
		Email:       user.Email,
		ProfileId:   user.ProfileID,
		ProfileName: s.resolveProfileName(ctx, user.ProfileID),
		RoleId:      user.UserRoleID,
	}

	// Resolve role name if role is assigned
	if user.UserRoleID != nil {
		if roleName, err := s.resolveRoleName(ctx, *user.UserRoleID); err == nil && roleName != "" {
			userSession.RoleName = &roleName
		}
	}

	created, err := s.createAndPersistSession(ctx, userSession, ip, userAgent, opts)
	if err != nil {
		return nil, err
	}

	s.recordLoginHistory(ctx, user.ID, "Application", ip, userAgent, "Success")

	return &LoginResult{
		Token:        created.token,
		User:         userSession,
		ExpiresAt:    created.expiresAt,
		ControlProof: created.controlProof,
	}, nil
}

// CreateSessionForUser generates a session for a given user ID without password check (for OAuth)
func (s *AuthService) CreateSessionForUser(ctx context.Context, userID, ip, userAgent string, opts *SessionOptions) (*models.SessionToken, error) {
	if opts != nil && opts.IssueControlProof {
		return nil, errors.NewValidationError("IssueControlProof", "use CreateControlledSessionForUser when the transport can deliver the raw proof")
	}
	result, err := s.createSessionForUserWithResult(ctx, userID, ip, userAgent, opts)
	if err != nil {
		return nil, err
	}
	return result.SessionToken, nil
}

// CreateControlledSessionForUser is the additive result surface for a future
// first-party browser handler. It opts in atomically so a verifier cannot be
// persisted unless the caller receives the corresponding raw proof.
func (s *AuthService) CreateControlledSessionForUser(ctx context.Context, userID, ip, userAgent string, opts *SessionOptions) (*SessionTokenResult, error) {
	controlled := SessionOptions{}
	if opts != nil {
		controlled = *opts
	}
	controlled.IssueControlProof = true
	return s.createSessionForUserWithResult(ctx, userID, ip, userAgent, &controlled)
}

// buildUserSessionForUser loads a user and assembles the JWT user-session
// context (identity, profile, role) shared by interactive login, OAuth session
// creation, and personal-access-token validation. It rejects inactive or
// locked accounts so every issuance/validation path fails closed identically.
func (s *AuthService) buildUserSessionForUser(ctx context.Context, userID string) (auth.UserSession, error) {
	user, err := s.userRepo.GetUserByID(ctx, userID)
	if err != nil {
		return auth.UserSession{}, err
	}
	if user == nil {
		return auth.UserSession{}, errors.NewNotFoundError("User", userID)
	}
	if err := rejectInactiveUser(user); err != nil {
		return auth.UserSession{}, err
	}
	if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) {
		return auth.UserSession{}, errors.NewUnauthorizedError("Account is temporarily locked")
	}

	roleID, _ := s.userRepo.GetUserRoleID(ctx, userID)
	displayName := auth.ConstructDisplayName(user.Username, user.FirstName, user.LastName)

	userSession := auth.UserSession{
		ID:          user.ID,
		Name:        displayName,
		Email:       user.Email,
		ProfileId:   user.ProfileID,
		ProfileName: s.resolveProfileName(ctx, user.ProfileID),
		RoleId:      roleID,
	}
	if roleID != nil {
		if roleName, err := s.resolveRoleName(ctx, *roleID); err == nil && roleName != "" {
			userSession.RoleName = &roleName
		}
	}
	return userSession, nil
}

func (s *AuthService) createSessionForUserWithResult(ctx context.Context, userID, ip, userAgent string, opts *SessionOptions) (*SessionTokenResult, error) {
	userSession, err := s.buildUserSessionForUser(ctx, userID)
	if err != nil {
		return nil, err
	}

	created, err := s.createAndPersistSession(ctx, userSession, ip, userAgent, opts)
	if err != nil {
		return nil, err
	}

	s.recordLoginHistory(ctx, userID, "OAuth2", ip, userAgent, "Success")

	// Map to models.SessionToken (which we need to define or use string)
	// We'll return a struct compatible with OAuth response
	return &SessionTokenResult{
		SessionToken: &models.SessionToken{
			AccessToken: created.token,
			TokenType:   "Bearer",
			ExpiresIn:   int(time.Until(created.expiresAt).Seconds()),
		},
		ExpiresAt:    created.expiresAt,
		ControlProof: created.controlProof,
	}, nil
}

// BuildOAuthSession creates the durable session payload and its bearer token but
// does not persist either. OAuth credential issuance uses this split form when
// the session must commit with a one-time credential transition. Callers must
// persist through PersistOAuthSession only after every issuance gate has passed.
func (s *AuthService) BuildOAuthSession(ctx context.Context, userID, ip, userAgent string, opts *SessionOptions) (*models.SystemSession, *models.SessionToken, error) {
	if opts != nil && opts.IssueControlProof {
		return nil, nil, errors.NewValidationError("IssueControlProof", "OAuth credential issuance cannot create an undeliverable session control proof")
	}
	userSession, err := s.buildUserSessionForUser(ctx, userID)
	if err != nil {
		return nil, nil, err
	}
	session, created, err := s.buildSession(userSession, ip, userAgent, opts)
	if err != nil {
		return nil, nil, err
	}
	return session, &models.SessionToken{
		AccessToken: created.token,
		TokenType:   "Bearer",
		ExpiresIn:   int(time.Until(created.expiresAt).Seconds()),
	}, nil
}

// PersistOAuthSession is deliberately separate from BuildOAuthSession so an
// OAuth issuer can put the session write in its caller-owned transaction.
func (s *AuthService) PersistOAuthSession(ctx context.Context, session *models.SystemSession) error {
	if session == nil {
		return errors.NewValidationError("session", "session is required")
	}
	if err := s.sessionRepo.InsertSession(ctx, session); err != nil {
		return errors.NewInternalError("failed to persist session", err)
	}
	return nil
}

// NoteOAuthLoginSuccess emits the audit event only after the caller's
// credential-issuance transaction commits. Emitting it before commit would
// produce a false success record for a rolled-back grant/code/session write.
func (s *AuthService) NoteOAuthLoginSuccess(ctx context.Context, userID, ip, userAgent string) {
	s.recordLoginHistory(ctx, userID, "OAuth2", ip, userAgent, "Success")
}

// createAndPersistSession handles token generation and session persistence
func (s *AuthService) createAndPersistSession(ctx context.Context, userSession auth.UserSession, ip, userAgent string, opts *SessionOptions) (*sessionCreationResult, error) {
	sessionStruct, created, err := s.buildSession(userSession, ip, userAgent, opts)
	if err != nil {
		return nil, err
	}
	if err := s.sessionRepo.InsertSession(ctx, sessionStruct); err != nil {
		return nil, errors.NewInternalError("failed to persist session", err)
	}
	return created, nil
}

// buildSession creates the bearer and its backing row without making it
// durable. Keeping construction separate lets OAuth bind the row to the same
// transaction as its grant and one-time credential transition.
func (s *AuthService) buildSession(userSession auth.UserSession, ip, userAgent string, opts *SessionOptions) (*models.SystemSession, *sessionCreationResult, error) {
	// Apply opts to userSession before token generation
	if opts != nil {
		userSession.ConnectedAppId = opts.ConnectedAppId
		userSession.Scope = opts.Scope
		userSession.GrantType = opts.GrantType
		userSession.SessionClass = opts.SessionClass
	}

	// 1. Generate JWT token and capture claims. Resource-bound OAuth grants use
	// an exact audience; every other session retains the deployment audience.
	var token string
	var claims *auth.Claims
	var err error
	if opts != nil && strings.TrimSpace(opts.ResourceAudience) != "" {
		token, claims, err = auth.GenerateTokenForSessionWithAudience(
			userSession,
			utils.GenerateID(),
			time.Now(),
			auth.DefaultSessionLifetime,
			opts.ResourceAudience,
		)
	} else {
		token, claims, err = auth.GenerateToken(userSession)
	}
	if err != nil {
		return nil, nil, errors.NewInternalError("failed to generate token", err)
	}

	// 2. Build session metadata from the claims we already have
	expiresAt := time.Unix(claims.ExpiresAt.Unix(), 0)

	// 3. Store session in database using SessionRepository
	var scope *string
	if userSession.Scope != "" {
		scope = utils.StringPtr(userSession.Scope)
	}
	var connectedAppID *string
	if userSession.ConnectedAppId != "" {
		connectedAppID = utils.StringPtr(userSession.ConnectedAppId)
	}
	var grantType *string
	if opts != nil && opts.GrantType != "" {
		grantType = utils.StringPtr(opts.GrantType)
	}
	var controlProof string
	var controlProofHash *string
	if opts != nil && opts.IssueControlProof {
		if !IsInteractiveBrowserSession(userSession.SessionClass, userSession.Scope, userSession.ConnectedAppId, userSession.GrantType) {
			return nil, nil, errors.NewValidationError("IssueControlProof", "session control is only valid for an interactive browser session")
		}
		proof, proofHash, proofErr := newSessionControlProof()
		if proofErr != nil {
			return nil, nil, errors.NewInternalError("failed to generate session control proof", proofErr)
		}
		controlProof = proof
		controlProofHash = utils.StringPtr(proofHash)
	}
	sessionStruct := &models.SystemSession{
		ID:               claims.RegisteredClaims.ID,
		UserID:           userSession.ID,
		Token:            token,
		ExpiresAt:        expiresAt,
		IPAddress:        ip,
		UserAgent:        userAgent,
		IsRevoked:        false,
		AuthEpoch:        1,
		ControlProofHash: controlProofHash,
		LastActivity:     time.Now(),
		Scope:            scope,
		ConnectedAppID:   connectedAppID,
		GrantType:        grantType,
	}

	return sessionStruct, &sessionCreationResult{token: token, expiresAt: expiresAt, controlProof: controlProof}, nil
}

// ValidateSession checks that the presented token is the current durable bearer.
// No-renewal transports use this path so a superseded token cannot establish a
// connection that outlives the bounded HTTP overlap window.
func (s *AuthService) ValidateSession(ctx context.Context, tokenString string) (*auth.Claims, error) {
	claims, _, err := s.validateSessionClaims(ctx, tokenString, false)
	return claims, err
}

// ValidateSessionWithRenewal validates the bearer, including the bounded
// in-flight overlap, and returns the durable replacement when appropriate.
// OAuth/Connected App sessions are intentionally unchanged.
func (s *AuthService) ValidateSessionWithRenewal(ctx context.Context, tokenString string) (*auth.Claims, string, error) {
	claims, session, err := s.validateSessionClaims(ctx, tokenString, true)
	if err != nil {
		return nil, "", err
	}
	if session.Token != tokenString {
		return claims, session.Token, nil
	}
	refreshedToken, err := s.renewInteractiveSession(ctx, claims, session, tokenString)
	if err != nil {
		return nil, "", err
	}
	return claims, refreshedToken, nil
}

// ValidateSessionWithControl is the dormant proof-aware validation surface for
// the future first-party browser adapter. A current bearer remains valid when
// the proof is missing or invalid, but it is never renewed. Recovering a
// durable replacement from a superseded bearer requires the matching proof.
// proofExpiresAt is nonzero only with a replacement so the adapter can safely
// align a reissued proof cookie with the durable session lifetime.
func (s *AuthService) ValidateSessionWithControl(ctx context.Context, tokenString, controlProof string) (*auth.Claims, string, time.Time, error) {
	if s == nil {
		return nil, "", time.Time{}, errors.NewUnauthorizedError("Invalid session")
	}
	if s.sessionControlRepo == nil {
		claims, _, err := s.validateSessionClaims(ctx, tokenString, false)
		return claims, "", time.Time{}, err
	}
	claims, session, controlProofHash, err := s.validateSessionClaimsForPolicy(ctx, tokenString, sessionValidationPolicy{controlProof: controlProof})
	if err != nil {
		return nil, "", time.Time{}, err
	}
	if controlProofHash == "" {
		return claims, "", time.Time{}, nil
	}
	if session.Token != tokenString {
		return claims, session.Token, session.ExpiresAt, nil
	}
	refreshedToken, err := s.renewInteractiveSessionWithControl(ctx, claims, session, tokenString, controlProof, controlProofHash)
	if err != nil {
		return nil, "", time.Time{}, err
	}
	if refreshedToken == "" {
		return claims, "", time.Time{}, nil
	}
	return claims, refreshedToken, session.ExpiresAt, nil
}

// ValidateInteractiveSessionControl requires the independent browser proof for
// an interactive session. Personal, first-party-only surfaces use this stricter
// check instead of treating the platform_browser class as sufficient authority.
func (s *AuthService) ValidateInteractiveSessionControl(ctx context.Context, tokenString, controlProof string) error {
	if s == nil || s.sessionControlRepo == nil {
		return errors.NewUnauthorizedError("Invalid session")
	}
	claims, _, controlProofHash, err := s.validateSessionClaimsForPolicy(ctx, tokenString, sessionValidationPolicy{controlProof: controlProof})
	if err != nil {
		return err
	}
	if controlProofHash == "" || !IsInteractiveBrowserSession(
		claims.User.SessionClass,
		claims.User.Scope,
		claims.User.ConnectedAppId,
		claims.User.GrantType,
	) {
		return errors.NewUnauthorizedError("Invalid session")
	}
	return nil
}

type sessionValidationPolicy struct {
	allowRenewedBearerOverlap bool
	controlProof              string
	expectedAudience          string
}

func (s *AuthService) validateSessionClaims(ctx context.Context, tokenString string, allowRenewedBearerOverlap bool) (*auth.Claims, *models.SystemSession, error) {
	claims, session, _, err := s.validateSessionClaimsForPolicy(ctx, tokenString, sessionValidationPolicy{
		allowRenewedBearerOverlap: allowRenewedBearerOverlap,
	})
	return claims, session, err
}

func (s *AuthService) validateSessionClaimsForPolicy(ctx context.Context, tokenString string, policy sessionValidationPolicy) (*auth.Claims, *models.SystemSession, string, error) {
	// 1. Verify JWT signature and claims
	claims, err := auth.ValidateToken(tokenString)
	if err != nil {
		// Token parsing, signature, and expiry failures are credential failures,
		// not identity-authority outages. Keep the parser detail private so every
		// public-session transport observes the existing stable unauthorized
		// classification without learning why the credential was invalid.
		return nil, nil, "", errors.NewUnauthorizedError("Invalid session")
	}

	// 2. Reuse the one AuthService-owned durable validation path.
	session, _, err := s.validateDurableSession(ctx, claims.RegisteredClaims.ID, true)
	if err != nil {
		return nil, nil, "", err
	}
	if strings.TrimSpace(session.Token) == "" {
		return nil, nil, "", errors.NewUnauthorizedError("Invalid session")
	}
	presentedIsCurrent := session.Token == tokenString
	// Once a session carries a browser-held verifier, omitting the transport
	// intent/proof must not downgrade it into the legacy bearer-only renewal
	// lane. Controlled rows accept only their current bearer without proof;
	// overlap recovery and renewal require the matching independent proof.
	allowOverlap := policy.allowRenewedBearerOverlap && session.ControlProofHash == nil && allowsRenewedBearerOverlap(claims, session, time.Now())
	controlProofHash, controlProofMatches := matchingSessionControlProofHash(policy.controlProof, session.ControlProofHash)
	controlCandidate := controlProofMatches && claims.RegisteredClaims.ID == session.ID && claims.User.ID == session.UserID
	if !presentedIsCurrent && !allowOverlap && !controlCandidate {
		return nil, nil, "", errors.NewUnauthorizedError("Invalid session")
	}

	// The durable session row, not the signed token's historical projection, is
	// authoritative for the session-to-principal binding and every currently
	// persisted scope/app/grant fact. Reconciliation rejects constrained tokens
	// whose durable grant disappeared, because empty is a legacy broad-access
	// sentinel downstream and cannot safely represent revocation.
	claims.User.ID = session.UserID
	claims.Subject = session.UserID
	claims.User.Scope, err = reconcileDurableSessionGrant(claims.User.Scope, session.Scope)
	if err != nil {
		return nil, nil, "", err
	}
	claims.User.ConnectedAppId, err = reconcileDurableSessionGrant(claims.User.ConnectedAppId, session.ConnectedAppID)
	if err != nil {
		return nil, nil, "", err
	}
	claims.User.GrantType, err = reconcileDurableSessionGrant(claims.User.GrantType, session.GrantType)
	if err != nil {
		return nil, nil, "", err
	}
	if controlCandidate && !IsInteractiveBrowserSession(
		claims.User.SessionClass, claims.User.Scope, claims.User.ConnectedAppId, claims.User.GrantType,
	) {
		controlCandidate = false
	}
	if !presentedIsCurrent && !allowOverlap && !controlCandidate {
		return nil, nil, "", errors.NewUnauthorizedError("Invalid session")
	}
	if !controlCandidate {
		controlProofHash = ""
	}

	return claims, session, controlProofHash, nil
}

func (s *AuthService) renewInteractiveSessionWithControl(ctx context.Context, claims *auth.Claims, session *models.SystemSession, previousToken, controlProof, expectedControlProofHash string) (string, error) {
	_, refreshedToken, err := s.renewInteractiveSessionWithControlMode(
		ctx,
		claims,
		session,
		previousToken,
		controlProof,
		expectedControlProofHash,
		false,
	)
	return refreshedToken, err
}

func (s *AuthService) renewAuthorizingInteractiveSessionWithControl(ctx context.Context, claims *auth.Claims, session *models.SystemSession, previousToken, controlProof, expectedControlProofHash string) (*AuthorizingSessionValidation, string, error) {
	return s.renewInteractiveSessionWithControlMode(
		ctx,
		claims,
		session,
		previousToken,
		controlProof,
		expectedControlProofHash,
		true,
	)
}

func (s *AuthService) renewInteractiveSessionWithControlMode(ctx context.Context, claims *auth.Claims, session *models.SystemSession, previousToken, controlProof, expectedControlProofHash string, strongRecovery bool) (*AuthorizingSessionValidation, string, error) {
	if s == nil || s.sessionControlRepo == nil || claims == nil || session == nil || session.Token != previousToken {
		return nil, "", nil
	}
	proofHash, proofMatches := matchingSessionControlProofHash(controlProof, session.ControlProofHash)
	if !proofMatches || proofHash != expectedControlProofHash {
		return nil, "", nil
	}
	if !IsInteractiveBrowserSession(claims.User.SessionClass, claims.User.Scope, claims.User.ConnectedAppId, claims.User.GrantType) {
		return nil, "", nil
	}
	now := time.Now()
	if session.ExpiresAt.Sub(now) > defaultSessionRenewalLeadTime {
		return nil, "", nil
	}
	refreshedToken, refreshedClaims, err := auth.GenerateTokenForSession(claims.User, session.ID, now, auth.DefaultSessionLifetime)
	if err != nil {
		return nil, "", errors.NewInternalError("failed to renew session token", err)
	}
	expiresAt := time.Unix(refreshedClaims.ExpiresAt.Unix(), 0)
	renewed, err := s.sessionControlRepo.RenewSessionWithControlProof(
		ctx, session.ID, previousToken, expectedControlProofHash, refreshedToken, expiresAt, now,
	)
	if err != nil {
		log.Warn().Err(err).Str("session_id", session.ID).Msg("failed to renew controlled browser session; continuing with existing bearer")
		return nil, "", nil
	}
	if !renewed {
		recoveredValidation, currentSession, currentProofHash, validationErr :=
			s.recoverControlledSessionRenewal(
				ctx, previousToken, sessionValidationPolicy{controlProof: controlProof},
				strongRecovery,
			)
		if validationErr == nil && currentSession != nil && currentProofHash == expectedControlProofHash && currentSession.Token != previousToken {
			session.Token = currentSession.Token
			session.ExpiresAt = currentSession.ExpiresAt
			session.LastActivity = currentSession.LastActivity
			return recoveredValidation, currentSession.Token, nil
		}
		if strongRecovery {
			if validationErr != nil {
				return nil, "", validationErr
			}
			return nil, "", errors.NewUnauthorizedError("Invalid session")
		}
		log.Warn().Str("session_id", session.ID).Msg("controlled browser session was not renewed; bearer may have been superseded or revoked")
		return nil, "", nil
	}
	session.ExpiresAt = expiresAt
	session.Token = refreshedToken
	session.LastActivity = now
	claims.ExpiresAt = refreshedClaims.ExpiresAt
	claims.IssuedAt = refreshedClaims.IssuedAt
	return nil, refreshedToken, nil
}

func (s *AuthService) renewInteractiveSession(ctx context.Context, claims *auth.Claims, session *models.SystemSession, previousToken string) (string, error) {
	_, refreshedToken, err := s.renewInteractiveSessionMode(
		ctx,
		claims,
		session,
		previousToken,
		false,
	)
	return refreshedToken, err
}

func (s *AuthService) renewAuthorizingInteractiveSession(ctx context.Context, claims *auth.Claims, session *models.SystemSession, previousToken string) (*AuthorizingSessionValidation, string, error) {
	return s.renewInteractiveSessionMode(
		ctx,
		claims,
		session,
		previousToken,
		true,
	)
}

func (s *AuthService) renewInteractiveSessionMode(ctx context.Context, claims *auth.Claims, session *models.SystemSession, previousToken string, strongRecovery bool) (*AuthorizingSessionValidation, string, error) {
	if s == nil || s.sessionRepo == nil || claims == nil || session == nil {
		return nil, "", nil
	}
	if session.ControlProofHash != nil {
		return nil, "", nil
	}
	if session.Token != previousToken {
		return nil, "", nil
	}
	if !IsInteractiveBrowserSession(claims.User.SessionClass, claims.User.Scope, claims.User.ConnectedAppId, claims.User.GrantType) {
		return nil, "", nil
	}
	now := time.Now()
	if session.ExpiresAt.Sub(now) > defaultSessionRenewalLeadTime {
		return nil, "", nil
	}
	refreshedToken, refreshedClaims, err := auth.GenerateTokenForSession(claims.User, session.ID, now, auth.DefaultSessionLifetime)
	if err != nil {
		return nil, "", errors.NewInternalError("failed to renew session token", err)
	}
	expiresAt := time.Unix(refreshedClaims.ExpiresAt.Unix(), 0)
	renewed, err := s.sessionRepo.RenewSession(ctx, session.ID, previousToken, refreshedToken, expiresAt, now)
	if err != nil {
		log.Warn().Err(err).Str("session_id", session.ID).Msg("failed to renew interactive session; continuing with existing bearer")
		return nil, "", nil
	}
	if !renewed {
		// A concurrent request may have won the compare-and-swap. Re-read the
		// durable row through the matching legacy or strong overlap path so this
		// response can still return that winner's bearer if the winning response
		// is lost.
		recoveredValidation, currentSession, validationErr := s.recoverSessionRenewal(
			ctx,
			previousToken,
			strongRecovery,
		)
		if validationErr == nil && currentSession != nil && currentSession.Token != previousToken {
			return recoveredValidation, currentSession.Token, nil
		}
		if strongRecovery {
			if validationErr != nil {
				return nil, "", validationErr
			}
			return nil, "", errors.NewUnauthorizedError("Invalid session")
		}
		log.Warn().Str("session_id", session.ID).Msg("interactive session was not renewed; bearer may have been superseded")
		return nil, "", nil
	}
	session.ExpiresAt = expiresAt
	session.Token = refreshedToken
	session.LastActivity = now
	claims.ExpiresAt = refreshedClaims.ExpiresAt
	claims.IssuedAt = refreshedClaims.IssuedAt
	return nil, refreshedToken, nil
}

func (s *AuthService) recoverSessionRenewal(
	ctx context.Context,
	previousToken string,
	strong bool,
) (*AuthorizingSessionValidation, *models.SystemSession, error) {
	if strong {
		validation, session, _, err := s.validateAuthorizingSessionForPolicy(
			ctx,
			previousToken,
			sessionValidationPolicy{allowRenewedBearerOverlap: true},
		)
		return validation, session, err
	}
	_, session, err := s.validateSessionClaims(ctx, previousToken, true)
	return nil, session, err
}

func (s *AuthService) recoverControlledSessionRenewal(
	ctx context.Context,
	previousToken string,
	policy sessionValidationPolicy,
	strong bool,
) (*AuthorizingSessionValidation, *models.SystemSession, string, error) {
	if strong {
		validation, session, controlProofHash, err :=
			s.validateAuthorizingSessionForPolicy(
				ctx,
				previousToken,
				policy,
			)
		return validation, session, controlProofHash, err
	}
	_, session, controlProofHash, err := s.validateSessionClaimsForPolicy(
		ctx,
		previousToken,
		policy,
	)
	return nil, session, controlProofHash, err
}

func allowsRenewedBearerOverlap(claims *auth.Claims, session *models.SystemSession, now time.Time) bool {
	if claims == nil || session == nil || claims.ExpiresAt == nil {
		return false
	}
	if !session.ExpiresAt.After(claims.ExpiresAt.Time) {
		return false
	}
	renewedAt := session.ExpiresAt.Add(-auth.DefaultSessionLifetime)
	if renewedAt.After(now) || now.Sub(renewedAt) > renewedBearerOverlapWindow {
		return false
	}
	return claims.RegisteredClaims.ID == session.ID && claims.User.ID == session.UserID
}

// RuntimeSessionValidation is retained for compatibility with non-authorizing
// in-process callers. Production delegated admission uses
// RuntimeAuthorizingSessionValidation so current Subject and Permission State
// floor cannot be dropped.
type RuntimeSessionValidation struct {
	User      models.UserSession
	AuthEpoch uint64
}

// ValidateRuntimeSession is retained for existing in-process callers. New
// delegated admission uses ValidateRuntimeSessionState so it cannot drop the
// authoritative AuthEpoch comparison.
func (s *AuthService) ValidateRuntimeSession(ctx context.Context, sessionID, principalID string) (*models.UserSession, error) {
	state, err := s.ValidateRuntimeSessionState(ctx, sessionID, principalID)
	if err != nil {
		return nil, err
	}
	user := state.User
	return &user, nil
}

// ValidateRuntimeSessionState is retained for compatible in-process callers.
// It accepts no bearer token and bypasses ordinary HTTP caches, but it does not
// provide current Permission State authority and cannot satisfy production
// delegated admission.
func (s *AuthService) ValidateRuntimeSessionState(ctx context.Context, sessionID, principalID string) (*RuntimeSessionValidation, error) {
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	if strings.TrimSpace(sessionID) == "" || strings.TrimSpace(principalID) == "" {
		return nil, errors.NewUnauthorizedError("Invalid session")
	}
	session, user, err := s.validateDurableSession(ctx, sessionID, false)
	if err != nil {
		return nil, err
	}
	if session.UserID != principalID || user.ID != principalID {
		return nil, errors.NewUnauthorizedError("Invalid session")
	}

	projected, err := s.projectSelectedRootUserSession(ctx, user)
	if err != nil {
		return nil, err
	}
	projected.ConnectedAppId = durableSessionString(session.ConnectedAppID)
	projected.Scope = durableSessionString(session.Scope)
	projected.GrantType = durableSessionString(session.GrantType)
	// SessionClass is JWT-owned in the current schema and selected-root does
	// not consume it as authority. Never copy that bearer-only fact into the
	// Runtime TrustedCall projection.
	projected.SessionClass = ""
	if session.AuthEpoch == 0 {
		return nil, errors.NewUnauthorizedError("Invalid session")
	}
	return &RuntimeSessionValidation{User: *projected, AuthEpoch: session.AuthEpoch}, nil
}

func (s *AuthService) validateDurableSession(ctx context.Context, sessionID string, allowCache bool) (*models.SystemSession, *models.User, error) {
	if err := ctx.Err(); err != nil {
		return nil, nil, err
	}
	session, err := s.sessionRepo.GetSession(ctx, sessionID)
	if err != nil {
		return nil, nil, errors.NewInternalError("database error", err)
	}
	if session == nil {
		return nil, nil, errors.NewUnauthorizedError("Session not found")
	}
	if session.IsRevoked {
		return nil, nil, errors.NewUnauthorizedError("Session has been revoked")
	}
	if session.ExpiresAt.IsZero() || !time.Now().Before(session.ExpiresAt) {
		return nil, nil, errors.NewUnauthorizedError("Session has expired")
	}
	if err := ctx.Err(); err != nil {
		return nil, nil, err
	}

	var user *models.User
	if allowCache {
		if isActive, cached := s.activeCache.get(session.UserID); cached {
			if !isActive {
				return nil, nil, errors.NewUnauthorizedError("User account is inactive")
			}
		} else {
			user, err = s.userRepo.GetUserByID(ctx, session.UserID)
			if err != nil {
				return nil, nil, errors.NewInternalError("failed to verify user status", err)
			}
			isActive := user != nil && user.IsActive
			s.activeCache.set(session.UserID, isActive)
			if !isActive {
				return nil, nil, errors.NewUnauthorizedError("User account is inactive")
			}
		}
	} else {
		user, err = s.userRepo.GetUserByID(ctx, session.UserID)
		if err != nil {
			return nil, nil, errors.NewInternalError("failed to verify user status", err)
		}
		if user == nil || !user.IsActive {
			return nil, nil, errors.NewUnauthorizedError("User account is inactive")
		}
	}
	if err := ctx.Err(); err != nil {
		return nil, nil, err
	}

	appID := durableSessionString(session.ConnectedAppID)
	if appID != "" {
		appActive := false
		cached := false
		if allowCache {
			appActive, cached = s.connectedAppCache.get(appID)
		}
		if !cached {
			appActive, err = s.sessionRepo.IsConnectedAppActive(ctx, appID)
			if err != nil {
				return nil, nil, errors.NewInternalError("failed to verify connected app status", err)
			}
			if allowCache {
				s.connectedAppCache.set(appID, appActive)
			}
		}
		if !appActive {
			return nil, nil, errors.NewUnauthorizedError("Connected App has been deactivated")
		}
	}
	return session, user, nil
}

func (s *AuthService) projectSelectedRootUserSession(ctx context.Context, user *models.User) (*models.UserSession, error) {
	if user == nil {
		return nil, errors.NewUnauthorizedError("User account is inactive")
	}
	roleID, err := s.userRepo.GetUserRoleID(ctx, user.ID)
	if err != nil {
		return nil, errors.NewInternalError("failed to resolve user role", err)
	}
	projected := &models.UserSession{
		ID: user.ID, ProfileID: user.ProfileID, RoleID: roleID,
	}
	return projected, nil
}

func durableSessionString(value *string) string {
	if value == nil {
		return ""
	}
	return *value
}

// reconcileDurableSessionGrant prevents storage absence from being confused
// with the legacy empty-value sentinel, which means broad/unscoped access to
// downstream middleware. If a token was issued with a constrained fact and
// that durable authority disappears, invalidate the whole session rather than
// projecting an empty string that could widen access.
func reconcileDurableSessionGrant(issued string, durable *string) (string, error) {
	current := durableSessionString(durable)
	if strings.TrimSpace(issued) != "" && strings.TrimSpace(current) == "" {
		return "", errors.NewUnauthorizedError("Session authority has changed")
	}
	return current, nil
}

// TouchSession updates the last activity timestamp for a session
func (s *AuthService) TouchSession(ctx context.Context, sessionID string) {
	if s == nil || s.sessionRepo == nil || strings.TrimSpace(sessionID) == "" {
		return
	}
	sessionID = strings.TrimSpace(sessionID)
	sessionTouch := s.sessionTouch
	sessionTouchSlots := s.sessionTouchSlots
	if sessionTouch == nil || sessionTouchSlots == nil {
		return
	}
	if !sessionTouch.shouldQueue(sessionID, time.Now()) {
		return
	}
	select {
	case sessionTouchSlots <- struct{}{}:
	default:
		sessionTouch.finish(sessionID)
		return
	}
	traceparent, requestID := authAsyncWriteObservability(ctx)
	// Fire and forget - errors are acceptable for non-critical activity timestamps
	go func() {
		defer func() {
			<-sessionTouchSlots
			sessionTouch.finish(sessionID)
		}()
		// Use a detached context with timeout to prevent goroutine leaks
		ctx, cancel := authAsyncWriteContext(traceparent, requestID)
		defer cancel()
		_ = s.sessionRepo.UpdateLastActivity(ctx, sessionID)
	}()
}

// Logout revokes a session. Per RFC 7009 section 2.2, invalid tokens are
// silently accepted (HTTP 200, no disclosure) to prevent session enumeration.
func (s *AuthService) Logout(ctx context.Context, tokenString string) error {
	claims, err := auth.ValidateToken(tokenString)
	if err != nil {
		// RFC 7009: always return success to the caller. Invalid or tampered
		// tokens are silently ignored so that session state is not mutated.
		return nil
	}

	revoked, err := s.sessionRepo.RevokeSession(ctx, claims.RegisteredClaims.ID, tokenString)
	if err == nil && revoked {
		log.Info().Str("user", claims.RegisteredClaims.Subject).Str("session", claims.RegisteredClaims.ID).Msg("User logged out")
	}
	return err
}

// BrowserLogout is the dormant first-party revocation surface. It uses the
// signed bearer only to identify the browser session and the independent proof
// to authorize revocation, so a lost-response browser holding a superseded
// bearer can still revoke the current durable generation. Invalid credentials
// remain idempotent and non-disclosing like RFC 7009 logout.
func (s *AuthService) BrowserLogout(ctx context.Context, tokenString, controlProof string) error {
	if s == nil || s.sessionControlRepo == nil {
		return nil
	}
	claims, err := auth.ValidateToken(tokenString)
	if err != nil || !IsInteractiveBrowserSession(
		claims.User.SessionClass, claims.User.Scope, claims.User.ConnectedAppId, claims.User.GrantType,
	) {
		return nil
	}
	controlProofHash, ok := presentedSessionControlProofHash(controlProof)
	if !ok {
		return nil
	}
	revoked, err := s.sessionControlRepo.RevokeSessionWithControlProof(ctx, claims.RegisteredClaims.ID, controlProofHash)
	if err == nil && revoked {
		log.Info().Str("user", claims.RegisteredClaims.Subject).Str("session", claims.RegisteredClaims.ID).Msg("Browser session logged out")
	}
	return err
}

// ChangePassword updates a user's password
func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
	// 1. Validate Strength
	if err := auth.ValidatePasswordStrength(newPassword); err != nil {
		return err
	}

	// 2. Load current password
	user, err := s.userRepo.FindUserByIDWithPassword(ctx, userID)
	if err != nil {
		return errors.NewInternalError("failed to retrieve user", err)
	}
	if user == nil {
		// Should verify if user exists even? Assuming middleware handled it.
		return errors.NewNotFoundError("User", userID)
	}

	if user.PasswordHash == "" {
		return errors.NewValidationError("password", "Password authentication not configured for this user")
	}

	// 2b. Check lockout
	if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) {
		return errors.NewUnauthorizedError("Account is temporarily locked")
	}

	// 3. Verify
	if !auth.VerifyPassword(currentPassword, user.PasswordHash) {
		return errors.NewUnauthorizedError("Current password is incorrect")
	}

	// 4. Hash New
	newHash, err := auth.HashPassword(newPassword)
	if err != nil {
		return errors.NewInternalError("failed to hash password", err)
	}

	// 5. Revoke dependent long-lived credentials (OAuth refresh tokens) BEFORE
	// committing the new password, and FAIL CLOSED: if revocation cannot complete
	// (after retries), abort without changing the password, so we never leave the
	// password changed while a stolen refresh token stays live. Revocation is
	// idempotent and safe to run ahead of the commit.
	if err := s.runCredentialRevocationHooks(ctx, userID); err != nil {
		return err
	}

	// 6. Commit the new password.
	err = s.userRepo.UpdatePassword(ctx, userID, newHash)
	if err != nil {
		return err
	}
	log.Info().Str("userID", userID).Msg("Password changed")

	// 7. Revoke all existing sessions so old credentials can't be reused.
	if err := s.sessionRepo.RevokeAllSessionsForUser(ctx, userID); err != nil {
		log.Warn().Err(err).Str("userID", userID).Msg("Failed to revoke sessions after password change")
	}
	return nil
}

// passwordResetToken represents a pending password reset request.
type passwordResetToken struct {
	UserID    string
	Token     string
	ExpiresAt time.Time
	Used      bool
}

// resetTokenStore is a simple in-memory store for password reset tokens.
// Sufficient for admin-only, low-volume usage. Tokens expire after 1 hour.
var resetTokenStore = struct {
	mu     sync.Mutex
	tokens map[string]*passwordResetToken
}{tokens: make(map[string]*passwordResetToken)}

// CreatePasswordResetToken generates a single-use reset token for the given user.
func (s *AuthService) CreatePasswordResetToken(ctx context.Context, userID string) (string, time.Time, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", time.Time{}, errors.NewInternalError("failed to generate reset token", err)
	}
	token := hex.EncodeToString(b)
	expiry := time.Now().Add(1 * time.Hour)

	resetTokenStore.mu.Lock()
	// Clean up expired tokens while we're here
	for k, v := range resetTokenStore.tokens {
		if time.Now().After(v.ExpiresAt) || v.Used {
			delete(resetTokenStore.tokens, k)
		}
	}
	resetTokenStore.tokens[token] = &passwordResetToken{
		UserID:    userID,
		Token:     token,
		ExpiresAt: expiry,
		Used:      false,
	}
	resetTokenStore.mu.Unlock()

	return token, expiry, nil
}

// ResetPasswordWithToken validates a reset token and sets a new password.
func (s *AuthService) ResetPasswordWithToken(ctx context.Context, token, newPassword string) error {
	// 1. Validate password strength
	if err := auth.ValidatePasswordStrength(newPassword); err != nil {
		return err
	}

	// 2. Look up and atomically claim token
	resetTokenStore.mu.Lock()
	entry, ok := resetTokenStore.tokens[token]
	if !ok || entry.Used || time.Now().After(entry.ExpiresAt) {
		resetTokenStore.mu.Unlock()
		return errors.NewUnauthorizedError("Invalid or expired reset token")
	}
	entry.Used = true // Claim immediately to prevent concurrent use
	resetTokenStore.mu.Unlock()

	// 3. Hash new password
	newHash, err := auth.HashPassword(newPassword)
	if err != nil {
		// Rollback: un-claim token so user can retry
		resetTokenStore.mu.Lock()
		entry.Used = false
		resetTokenStore.mu.Unlock()
		return errors.NewInternalError("failed to hash password", err)
	}

	// 4. Revoke dependent long-lived credentials (OAuth refresh tokens) BEFORE
	// committing the new password, failing closed: if revocation cannot complete,
	// un-claim the token and abort without changing the password, so a stolen
	// refresh token can never outlive the reset.
	if err := s.runCredentialRevocationHooks(ctx, entry.UserID); err != nil {
		resetTokenStore.mu.Lock()
		entry.Used = false
		resetTokenStore.mu.Unlock()
		return err
	}

	// 5. Update password
	if err := s.userRepo.UpdatePassword(ctx, entry.UserID, newHash); err != nil {
		// Rollback: un-claim token so user can retry
		resetTokenStore.mu.Lock()
		entry.Used = false
		resetTokenStore.mu.Unlock()
		return err
	}
	log.Info().Str("userID", entry.UserID).Msg("Password reset via token")

	// 6. Revoke all sessions.
	if err := s.sessionRepo.RevokeAllSessionsForUser(ctx, entry.UserID); err != nil {
		log.Warn().Err(err).Str("userID", entry.UserID).Msg("Failed to revoke sessions after password reset")
	}

	return nil
}

// GetUserByID retrieves a user session object by ID
func (s *AuthService) GetUserByID(ctx context.Context, userID string) (*models.UserSession, error) {
	// Reuse Repo logic
	user, err := s.userRepo.GetUserByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return nil, errors.NewNotFoundError("User", userID)
	}

	// Fetch Role ID separately since GetUserByID might not return it if not cached/joined
	roleID, err := s.userRepo.GetUserRoleID(ctx, userID)
	if err != nil {
		log.Warn().Err(err).Str("userID", userID).Msg("Failed to fetch role for user")
	}

	displayName := auth.ConstructDisplayName(user.Username, user.FirstName, user.LastName)

	session := &models.UserSession{
		ID:          user.ID,
		Name:        displayName,
		Email:       &user.Email,
		ProfileID:   user.ProfileID,
		ProfileName: s.resolveProfileName(ctx, user.ProfileID),
		RoleID:      roleID,
	}
	if roleID != nil {
		if roleName, err := s.resolveRoleName(ctx, *roleID); err == nil && roleName != "" {
			session.RoleName = &roleName
		}
	}
	return session, nil
}

// resolveRoleName looks up the display name of a UserRole by ID
func (s *AuthService) resolveRoleName(ctx context.Context, roleID string) (string, error) {
	return s.userRepo.GetRoleNameByID(ctx, roleID)
}

func (s *AuthService) resolveProfileName(ctx context.Context, profileID string) string {
	if profileName, err := s.userRepo.GetProfileNameByID(ctx, profileID); err == nil && profileName != "" {
		return profileName
	}
	return constants.ProfileDisplayName(profileID)
}
