package service

import (
	"context"
	"encoding/json"
	"errors"
	"sort"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"

	"wucher/internal/domain/auth"
	"wucher/internal/shared/pkg/uuidv7"
)

type authSessionRecord struct {
	SessionID    string    `json:"session_id"`
	UserID       string    `json:"user_id"`
	CurrentJTI   string    `json:"current_jti"`
	RefreshExpAt time.Time `json:"refresh_exp_at"`
	AuthProvider string    `json:"auth_provider,omitempty"`
	MicrosoftSID string    `json:"microsoft_sid,omitempty"`
	DeviceType   string    `json:"device_type,omitempty"`
	UserAgent    string    `json:"user_agent,omitempty"`
	DeviceName   string    `json:"device_name"`
	IPAddress    string    `json:"ip_address,omitempty"`
	LongLived    bool      `json:"long_lived"`
	CreatedAt    time.Time `json:"created_at"`
	LastActiveAt time.Time `json:"last_active_at"`
}

type UserSession struct {
	ID           string    `json:"id"`
	DeviceName   string    `json:"device_name"`
	UserAgent    string    `json:"user_agent,omitempty"`
	IPAddress    string    `json:"ip_address,omitempty"`
	CreatedAt    time.Time `json:"created_at"`
	LastActiveAt time.Time `json:"last_active_at"`
	ActiveNow    bool      `json:"active_now"`
}

const (
	refreshReplayTTL          = 5 * time.Second
	refreshLockTTL            = 15 * time.Second
	refreshReplayWaitTimeout  = 5 * time.Second
	refreshReplayPollInterval = 50 * time.Millisecond
)

type refreshReplayPayload struct {
	AccessToken      string `json:"access_token"`
	RefreshToken     string `json:"refresh_token"`
	RefreshTTLSecond int64  `json:"refresh_ttl_second"`
	SessionID        string `json:"session_id"`
	UserID           string `json:"user_id"`
}

func (p refreshReplayPayload) toTokenPair() TokenPair {
	return TokenPair{
		AccessToken:  p.AccessToken,
		RefreshToken: p.RefreshToken,
		RefreshTTL:   time.Duration(p.RefreshTTLSecond) * time.Second,
		SessionID:    p.SessionID,
	}
}

func newRefreshReplayPayload(pair TokenPair, userID, sessionID string) refreshReplayPayload {
	refreshTTL := pair.RefreshTTL
	if refreshTTL <= 0 {
		refreshTTL = time.Second
	}
	return refreshReplayPayload{
		AccessToken:      pair.AccessToken,
		RefreshToken:     pair.RefreshToken,
		RefreshTTLSecond: int64(refreshTTL.Seconds()),
		SessionID:        strings.TrimSpace(sessionID),
		UserID:           strings.TrimSpace(userID),
	}
}

func (s *AuthService) IssueTokensWithClient(ctx context.Context, user *auth.User, userAgent, ipAddress string) (TokenPair, error) {
	return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTLocalRefreshTTL, false, userAgent, ipAddress, "", "", "")
}

func (s *AuthService) IssueTokensAfterTOTPWithClient(ctx context.Context, user *auth.User, userAgent, ipAddress string) (TokenPair, error) {
	return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTRefreshTOTPTTL, true, userAgent, ipAddress, "", "", "")
}

func (s *AuthService) IssueTokensWithClientAndDeviceType(
	ctx context.Context,
	user *auth.User,
	userAgent, ipAddress, deviceTypeHint string,
) (TokenPair, error) {
	return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTLocalRefreshTTL, false, userAgent, ipAddress, "", deviceTypeHint, "")
}

func (s *AuthService) IssueTokensAfterTOTPWithClientAndDeviceType(
	ctx context.Context,
	user *auth.User,
	userAgent, ipAddress, deviceTypeHint string,
) (TokenPair, error) {
	return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTRefreshTOTPTTL, true, userAgent, ipAddress, "", deviceTypeHint, "")
}

func (s *AuthService) IssueTokensWithClientForProvider(
	ctx context.Context,
	user *auth.User,
	userAgent, ipAddress, authProvider string,
) (TokenPair, error) {
	refreshTTL := s.providerRefreshTTL(ctx, user.ID, authProvider)
	return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, false, userAgent, ipAddress, authProvider, "", "")
}

func (s *AuthService) IssueTokensWithClientForProviderAndDeviceType(
	ctx context.Context,
	user *auth.User,
	userAgent, ipAddress, authProvider, deviceTypeHint string,
) (TokenPair, error) {
	refreshTTL := s.providerRefreshTTL(ctx, user.ID, authProvider)
	return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, false, userAgent, ipAddress, authProvider, deviceTypeHint, "")
}

func (s *AuthService) IssueTokensWithClientForProviderAndMicrosoftSID(
	ctx context.Context,
	user *auth.User,
	userAgent, ipAddress, authProvider, microsoftSID string,
) (TokenPair, error) {
	refreshTTL := s.providerRefreshTTL(ctx, user.ID, authProvider)
	return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, false, userAgent, ipAddress, authProvider, "", microsoftSID)
}

// providerRefreshTTL resolves the refresh-token lifetime for a login session.
// For Microsoft/SSO sessions it follows the lifetime of the stored Microsoft
// refresh token so the app session stays valid for exactly as long as Microsoft
// will keep refreshing it (validated on every /auth/refresh). When Microsoft
// does not report an expiry it falls back to the configured SSO default. Other
// providers keep the standard refresh TTL.
func (s *AuthService) providerRefreshTTL(ctx context.Context, userID []byte, authProvider string) time.Duration {
	if !strings.EqualFold(strings.TrimSpace(authProvider), ssoProviderMicrosoft) {
		return s.cfg.JWTRefreshTTL
	}
	fallback := s.cfg.JWTSSORefreshTTL
	if fallback <= 0 {
		fallback = s.cfg.JWTRefreshTTL
	}
	repo, ok := s.repo.(microsoftOAuthTokenStore)
	if !ok {
		return fallback
	}
	record, err := repo.GetMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft)
	if err != nil || record == nil || record.RefreshTokenExpiresAt == nil {
		return fallback
	}
	remaining := time.Until(*record.RefreshTokenExpiresAt)
	if remaining <= time.Minute {
		return fallback
	}
	return remaining
}

func (s *AuthService) issueLoginTokensWithRefreshTTLAndClient(
	ctx context.Context,
	user *auth.User,
	refreshTTL time.Duration,
	longLived bool,
	userAgent, ipAddress, authProvider, deviceTypeHint, microsoftSID string,
) (TokenPair, error) {
	if user == nil || len(user.ID) != 16 {
		return TokenPair{}, ErrInvalidToken
	}
	if !user.IsActive {
		return TokenPair{}, ErrInvalidToken
	}
	return s.issueTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, longLived, "", userAgent, ipAddress, authProvider, deviceTypeHint, microsoftSID)
}

func (s *AuthService) issueTokensWithRefreshTTLAndClient(
	ctx context.Context,
	user *auth.User,
	refreshTTL time.Duration,
	longLived bool,
	sessionID, userAgent, ipAddress, authProvider, deviceTypeHint, microsoftSID string,
) (TokenPair, error) {
	if s.cfg.JWTAccessSecret == "" || s.cfg.JWTRefreshSecret == "" {
		return TokenPair{}, errors.New("jwt secrets not configured")
	}
	if refreshTTL <= 0 {
		refreshTTL = s.cfg.JWTRefreshTTL
	}

	now := time.Now().UTC()
	newLogin := strings.TrimSpace(sessionID) == ""
	if newLogin {
		sessionID, _ = uuidv7.BytesToString(uuidv7.MustBytes())
	}

	existingSessionIDs := []string{}
	if s.tokens != nil && newLogin {
		if ids, err := s.loadUserSessionIDs(ctx, user.ID); err == nil {
			existingSessionIDs = ids
		}
	}

	sessionVersion, _ := s.sessionVersion(ctx, user.ID)
	accessClaims := jwt.MapClaims{
		"sub":  bytesToString(user.ID),
		"iat":  now.Unix(),
		"exp":  now.Add(s.cfg.JWTAccessTTL).Unix(),
		"type": "access",
		"sv":   sessionVersion,
		"sid":  sessionID,
	}
	accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(s.cfg.JWTAccessSecret))
	if err != nil {
		return TokenPair{}, err
	}

	jti, err := randomToken(32)
	if err != nil {
		return TokenPair{}, err
	}
	refreshClaims := jwt.MapClaims{
		"sub":  bytesToString(user.ID),
		"iat":  now.Unix(),
		"exp":  now.Add(refreshTTL).Unix(),
		"jti":  jti,
		"type": "refresh",
		"sv":   sessionVersion,
		"sid":  sessionID,
	}
	if longLived {
		refreshClaims["ll"] = true
	}
	refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(s.cfg.JWTRefreshSecret))
	if err != nil {
		return TokenPair{}, err
	}

	if s.tokens != nil {
		_ = s.tokens.Set(ctx, refreshKey(jti), []byte(sessionID), refreshTTL)
		if strings.EqualFold(strings.TrimSpace(authProvider), ssoProviderMicrosoft) && strings.TrimSpace(microsoftSID) != "" {
			_ = s.tokens.Set(ctx, microsoftSessionKey(microsoftSID), []byte(sessionID), refreshTTL)
		}

		createdAt := now
		record, _ := s.loadSessionRecord(ctx, sessionID)
		if record != nil && record.UserID == bytesToString(user.ID) {
			createdAt = record.CreatedAt
			if strings.TrimSpace(authProvider) == "" {
				authProvider = strings.TrimSpace(record.AuthProvider)
			}
			if strings.TrimSpace(userAgent) == "" {
				userAgent = record.UserAgent
			}
			if strings.TrimSpace(ipAddress) == "" {
				ipAddress = record.IPAddress
			}
		}

		session := &authSessionRecord{
			SessionID:    sessionID,
			UserID:       bytesToString(user.ID),
			CurrentJTI:   jti,
			RefreshExpAt: now.Add(refreshTTL),
			AuthProvider: strings.TrimSpace(authProvider),
			MicrosoftSID: strings.TrimSpace(microsoftSID),
			DeviceType:   classifyDeviceType(strings.TrimSpace(userAgent), deviceTypeHint),
			UserAgent:    strings.TrimSpace(userAgent),
			DeviceName:   deriveDeviceName(strings.TrimSpace(userAgent)),
			IPAddress:    sanitizeIP(strings.TrimSpace(ipAddress), s.ipProtector),
			LongLived:    longLived,
			CreatedAt:    createdAt,
			LastActiveAt: now,
		}
		if err := s.saveSessionRecord(ctx, session, refreshTTL); err == nil {
			if newLogin {
				_ = s.enforceUserDeviceSlots(ctx, user.ID, existingSessionIDs, session)
			} else {
				_ = s.addUserSessionID(ctx, user.ID, sessionID)
			}
		}
	}

	return TokenPair{
		AccessToken:  accessToken,
		RefreshToken: refreshToken,
		RefreshTTL:   refreshTTL,
		SessionID:    sessionID,
	}, nil
}

func (s *AuthService) RefreshTokensWithClient(ctx context.Context, refreshToken, userAgent, ipAddress string) (TokenPair, error) {
	return s.RefreshTokensWithClientAndDeviceType(ctx, refreshToken, userAgent, ipAddress, "")
}

func (s *AuthService) RefreshTokensWithClientAndDeviceType(
	ctx context.Context,
	refreshToken, userAgent, ipAddress, deviceTypeHint string,
) (TokenPair, error) {
	if refreshToken == "" {
		return TokenPair{}, ErrInvalidToken
	}
	parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, ErrInvalidToken
		}
		return []byte(s.cfg.JWTRefreshSecret), nil
	})
	if err != nil || !parsed.Valid {
		return TokenPair{}, ErrInvalidToken
	}

	claims, ok := parsed.Claims.(jwt.MapClaims)
	if !ok || claims["type"] != "refresh" {
		return TokenPair{}, ErrInvalidToken
	}
	jti, _ := claims["jti"].(string)
	sub, _ := claims["sub"].(string)
	sid, _ := claims["sid"].(string)
	if jti == "" || sub == "" || sid == "" {
		return TokenPair{}, ErrInvalidToken
	}
	userID, err := uuidv7.ParseString(sub)
	if err != nil {
		return TokenPair{}, ErrInvalidToken
	}
	if s.tokens == nil {
		return TokenPair{}, ErrInvalidToken
	}
	if !s.validSessionVersion(ctx, userID, claims["sv"]) {
		return TokenPair{}, ErrInvalidToken
	}
	if !s.isSessionActive(ctx, userID, sid) {
		return TokenPair{}, ErrInvalidToken
	}
	if replay, ok, err := s.loadRefreshReplay(ctx, jti, userID, sid); err != nil {
		return TokenPair{}, ErrInvalidToken
	} else if ok {
		return replay, nil
	}

	acquired := false
	acquired, err = s.tokens.SetIfNotExists(ctx, refreshLockKey(sid), []byte(jti), refreshLockTTL)
	if err != nil {
		return TokenPair{}, ErrInvalidToken
	}
	if !acquired {
		replay, ok, waitErr := s.waitForRefreshReplay(ctx, jti, userID, sid)
		if waitErr != nil {
			return TokenPair{}, ErrInvalidToken
		}
		if ok {
			return replay, nil
		}
		return TokenPair{}, ErrInvalidToken
	}
	defer func() {
		_ = s.tokens.Delete(ctx, refreshLockKey(sid))
	}()

	var record *authSessionRecord
	var raw []byte
	raw, err = s.tokens.Get(ctx, refreshKey(jti))
	if err != nil || raw == nil || !subtleConstantTimeCompare(raw, []byte(sid)) {
		return TokenPair{}, ErrInvalidToken
	}
	record, err = s.loadSessionRecord(ctx, sid)
	if err != nil || record == nil {
		return TokenPair{}, ErrInvalidToken
	}
	if record.UserID != bytesToString(userID) || record.CurrentJTI != jti {
		return TokenPair{}, ErrInvalidToken
	}
	if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) {
		if _, err := s.RefreshMicrosoftAccessToken(ctx, userID); err != nil {
			// Only revoke when Microsoft says the grant is permanently invalid
			// (invalid_grant/interaction_required/consent_required, or the
			// stored refresh token is missing). Transient failures — network
			// errors, 5xx, throttling, request timeouts, or an open circuit
			// breaker — must NOT log the user out: keep the app session alive
			// and retry Microsoft on the next refresh cycle.
			if errors.Is(err, ErrMicrosoftReauthRequired) {
				_ = s.RevokeUserSession(ctx, userID, sid)
				return TokenPair{}, ErrInvalidToken
			}
		}
	}
	if s.isSessionIdleExpired(record) {
		// SSO sessions may continue when Microsoft token refresh is still valid.
		if !s.canRecoverSSOSessionOnIdle(ctx, userID, record) {
			_ = s.RevokeUserSession(ctx, userID, sid)
			return TokenPair{}, ErrInvalidToken
		}
	}

	user, err := s.repo.GetUserByID(ctx, userID)
	if err != nil || user == nil || !user.IsActive {
		return TokenPair{}, ErrInvalidToken
	}
	longLived := boolClaim(claims["ll"])
	refreshTTL := s.cfg.JWTRefreshTTL
	if longLived {
		refreshTTL = s.cfg.JWTRefreshTOTPTTL
	} else if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) {
		// Keep the session sliding with the Microsoft refresh token lifetime.
		refreshTTL = s.providerRefreshTTL(ctx, userID, record.AuthProvider)
	}
	pair, err := s.issueTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, longLived, sid, userAgent, ipAddress, "", deviceTypeHint, "")
	if err != nil {
		return TokenPair{}, err
	}
	if s.tokens != nil {
		replay := newRefreshReplayPayload(pair, bytesToString(userID), sid)
		if payload, marshalErr := json.Marshal(replay); marshalErr == nil {
			_ = s.tokens.Set(ctx, refreshReplayKey(jti), payload, refreshReplayTTL)
		}
	}
	return pair, nil
}

func (s *AuthService) loadRefreshReplay(ctx context.Context, jti string, userID []byte, sessionID string) (TokenPair, bool, error) {
	if s.tokens == nil {
		return TokenPair{}, false, nil
	}
	raw, err := s.tokens.Get(ctx, refreshReplayKey(jti))
	if err != nil || raw == nil {
		return TokenPair{}, false, err
	}
	var payload refreshReplayPayload
	if err := json.Unmarshal(raw, &payload); err != nil {
		return TokenPair{}, false, err
	}
	if strings.TrimSpace(payload.SessionID) != strings.TrimSpace(sessionID) {
		return TokenPair{}, false, nil
	}
	if strings.TrimSpace(payload.UserID) != bytesToString(userID) {
		return TokenPair{}, false, nil
	}
	if !s.isSessionActive(ctx, userID, sessionID) {
		return TokenPair{}, false, nil
	}
	return payload.toTokenPair(), true, nil
}

func (s *AuthService) waitForRefreshReplay(ctx context.Context, jti string, userID []byte, sessionID string) (TokenPair, bool, error) {
	deadline := time.Now().UTC().Add(refreshReplayWaitTimeout)
	for {
		replay, ok, err := s.loadRefreshReplay(ctx, jti, userID, sessionID)
		if err != nil {
			return TokenPair{}, false, err
		}
		if ok {
			return replay, true, nil
		}
		if time.Now().UTC().After(deadline) {
			return TokenPair{}, false, nil
		}
		select {
		case <-ctx.Done():
			return TokenPair{}, false, ctx.Err()
		case <-time.After(refreshReplayPollInterval):
		}
	}
}

func (s *AuthService) revokeRefreshTokenWithSession(ctx context.Context, refreshToken string) error {
	if refreshToken == "" {
		return nil
	}
	parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, ErrInvalidToken
		}
		return []byte(s.cfg.JWTRefreshSecret), nil
	})
	if err != nil || !parsed.Valid {
		return nil
	}
	claims, ok := parsed.Claims.(jwt.MapClaims)
	if !ok {
		return nil
	}
	jti, _ := claims["jti"].(string)
	sid, _ := claims["sid"].(string)
	sub, _ := claims["sub"].(string)
	if sid != "" && sub != "" {
		if userID, err := uuidv7.ParseString(sub); err == nil {
			_ = s.RevokeUserSession(ctx, userID, sid)
			return nil
		}
	}
	if jti != "" && s.tokens != nil {
		_ = s.tokens.Delete(ctx, refreshKey(jti))
	}
	return nil
}

func (s *AuthService) ParseAccessTokenSessionID(ctx context.Context, accessToken string) (string, error) {
	_, sid, err := s.parseAccessTokenWithSession(ctx, accessToken)
	if err != nil {
		return "", err
	}
	return sid, nil
}

func (s *AuthService) GetSessionAuthContextFromAccessToken(ctx context.Context, accessToken string) (string, string, error) {
	_, sid, err := s.parseAccessTokenWithSession(ctx, accessToken)
	if err != nil {
		return "", "", err
	}
	if s.tokens == nil {
		return "email", "email", nil
	}
	record, err := s.loadSessionRecord(ctx, sid)
	if err != nil || record == nil {
		return "", "", ErrInvalidToken
	}
	provider := strings.ToLower(strings.TrimSpace(record.AuthProvider))
	if provider == ssoProviderMicrosoft {
		return "sso", ssoProviderMicrosoft, nil
	}
	return "email", "email", nil
}

func (s *AuthService) ListUserSessions(ctx context.Context, userID []byte, currentSessionID string) ([]UserSession, error) {
	if len(userID) != 16 {
		return nil, ErrInvalidToken
	}
	if s.tokens == nil {
		return []UserSession{}, nil
	}
	ids, err := s.loadUserSessionIDs(ctx, userID)
	if err != nil {
		return nil, err
	}
	out := make([]UserSession, 0, len(ids))
	for i := range ids {
		record, err := s.loadSessionRecord(ctx, ids[i])
		if err != nil || record == nil {
			continue
		}
		if record.UserID != bytesToString(userID) {
			continue
		}
		out = append(out, UserSession{
			ID:           record.SessionID,
			DeviceName:   record.DeviceName,
			UserAgent:    record.UserAgent,
			IPAddress:    record.IPAddress,
			CreatedAt:    record.CreatedAt,
			LastActiveAt: record.LastActiveAt,
			ActiveNow:    record.SessionID == currentSessionID,
		})
	}
	sort.Slice(out, func(i, j int) bool {
		return out[i].LastActiveAt.After(out[j].LastActiveAt)
	})
	return out, nil
}

func (s *AuthService) RevokeUserSession(ctx context.Context, userID []byte, sessionID string) error {
	if len(userID) != 16 || strings.TrimSpace(sessionID) == "" {
		return ErrInvalidToken
	}
	if s.tokens == nil {
		return nil
	}
	record, err := s.loadSessionRecord(ctx, sessionID)
	if err != nil {
		return err
	}
	if record == nil {
		return ErrSessionNotFound
	}
	if record.UserID != bytesToString(userID) {
		return ErrInvalidToken
	}
	if strings.TrimSpace(record.CurrentJTI) != "" {
		_ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI))
	}
	if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) && strings.TrimSpace(record.MicrosoftSID) != "" {
		_ = s.tokens.Delete(ctx, microsoftSessionKey(record.MicrosoftSID))
	}
	_ = s.tokens.Delete(ctx, sessionKey(sessionID))
	_ = s.removeUserSessionID(ctx, userID, sessionID)
	return nil
}

func (s *AuthService) RevokeSessionByID(ctx context.Context, sessionID string) error {
	sessionID = strings.TrimSpace(sessionID)
	if sessionID == "" {
		return ErrInvalidToken
	}
	if s.tokens == nil {
		return nil
	}
	record, err := s.loadSessionRecord(ctx, sessionID)
	if err != nil {
		return err
	}
	if record == nil {
		return ErrSessionNotFound
	}
	userID, err := uuidv7.ParseString(strings.TrimSpace(record.UserID))
	if err != nil {
		return ErrInvalidToken
	}
	return s.RevokeUserSession(ctx, userID, sessionID)
}

func (s *AuthService) RevokeMicrosoftSessionBySID(ctx context.Context, microsoftSID string) error {
	microsoftSID = strings.TrimSpace(microsoftSID)
	if microsoftSID == "" {
		return ErrInvalidToken
	}
	if s.tokens == nil {
		return nil
	}
	raw, err := s.tokens.Get(ctx, microsoftSessionKey(microsoftSID))
	if err != nil || raw == nil {
		return err
	}
	sessionID := strings.TrimSpace(string(raw))
	if sessionID == "" {
		_ = s.tokens.Delete(ctx, microsoftSessionKey(microsoftSID))
		return ErrSessionNotFound
	}
	record, err := s.loadSessionRecord(ctx, sessionID)
	if err != nil {
		return err
	}
	if record == nil {
		_ = s.tokens.Delete(ctx, microsoftSessionKey(microsoftSID))
		return ErrSessionNotFound
	}
	if !strings.EqualFold(strings.TrimSpace(record.MicrosoftSID), microsoftSID) {
		_ = s.tokens.Delete(ctx, microsoftSessionKey(microsoftSID))
		return ErrSessionNotFound
	}
	userID, err := uuidv7.ParseString(strings.TrimSpace(record.UserID))
	if err != nil {
		return ErrInvalidToken
	}
	return s.RevokeUserSession(ctx, userID, sessionID)
}

func (s *AuthService) RevokeAllUserSessions(ctx context.Context, userID []byte) error {
	if len(userID) != 16 {
		return ErrInvalidToken
	}
	if s.tokens == nil {
		return nil
	}
	sessionIDs, err := s.loadUserSessionIDs(ctx, userID)
	if err != nil {
		return err
	}
	for i := range sessionIDs {
		sessionID := strings.TrimSpace(sessionIDs[i])
		if sessionID == "" {
			continue
		}
		record, loadErr := s.loadSessionRecord(ctx, sessionID)
		if loadErr != nil {
			return loadErr
		}
		if record != nil && record.UserID == bytesToString(userID) && strings.TrimSpace(record.CurrentJTI) != "" {
			_ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI))
			if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) && strings.TrimSpace(record.MicrosoftSID) != "" {
				_ = s.tokens.Delete(ctx, microsoftSessionKey(record.MicrosoftSID))
			}
		}
		_ = s.tokens.Delete(ctx, sessionKey(sessionID))
	}
	_ = s.replaceUserSessionIDs(ctx, userID, []string{})
	return nil
}

func (s *AuthService) isSessionActive(ctx context.Context, userID []byte, sessionID string) bool {
	if s.tokens == nil || len(userID) != 16 || strings.TrimSpace(sessionID) == "" {
		return false
	}
	ids, err := s.loadUserSessionIDs(ctx, userID)
	if err != nil {
		return false
	}
	active := false
	for i := range ids {
		if ids[i] == sessionID {
			active = true
			break
		}
	}
	if !active {
		return false
	}
	record, err := s.loadSessionRecord(ctx, sessionID)
	if err != nil {
		return false
	}
	if record == nil {
		return false
	}
	if record.UserID != bytesToString(userID) {
		return false
	}
	return true
}

func (s *AuthService) isSessionIdleExpired(record *authSessionRecord) bool {
	if record == nil || s.cfg.SessionIdleTTL <= 0 || record.LastActiveAt.IsZero() {
		return false
	}
	if !strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) {
		return false
	}
	return record.LastActiveAt.Add(s.cfg.SessionIdleTTL).Before(time.Now().UTC())
}

func (s *AuthService) canRecoverSSOSessionOnIdle(ctx context.Context, userID []byte, record *authSessionRecord) bool {
	if record == nil || !strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) {
		return false
	}
	if _, err := s.RefreshMicrosoftAccessToken(ctx, userID); err != nil {
		// Transient Microsoft failures should not force a re-login on an idle
		// session; only a permanent reauth-required error means it cannot
		// recover.
		return !errors.Is(err, ErrMicrosoftReauthRequired)
	}
	return true
}

func (s *AuthService) loadSessionRecord(ctx context.Context, sessionID string) (*authSessionRecord, error) {
	raw, err := s.tokens.Get(ctx, sessionKey(sessionID))
	if err != nil || raw == nil {
		return nil, err
	}
	var record authSessionRecord
	if err := json.Unmarshal(raw, &record); err != nil {
		return nil, err
	}
	return &record, nil
}

func (s *AuthService) saveSessionRecord(ctx context.Context, session *authSessionRecord, ttl time.Duration) error {
	if session == nil || strings.TrimSpace(session.SessionID) == "" {
		return ErrInvalidToken
	}
	payload, err := json.Marshal(session)
	if err != nil {
		return err
	}
	if !session.RefreshExpAt.IsZero() {
		remaining := time.Until(session.RefreshExpAt)
		if remaining <= 0 {
			return s.tokens.Delete(ctx, sessionKey(session.SessionID))
		}
		ttl = remaining
	}
	return s.tokens.Set(ctx, sessionKey(session.SessionID), payload, ttl)
}

func (s *AuthService) touchSessionActivity(ctx context.Context, userID []byte, sessionID string) {
	if s.tokens == nil || len(userID) != 16 || strings.TrimSpace(sessionID) == "" {
		return
	}
	record, err := s.loadSessionRecord(ctx, sessionID)
	if err != nil || record == nil || record.UserID != bytesToString(userID) {
		return
	}
	record.LastActiveAt = time.Now().UTC()
	if record.RefreshExpAt.IsZero() {
		_ = s.saveSessionRecord(ctx, record, s.cfg.JWTRefreshTTL)
		return
	}
	_ = s.saveSessionRecord(ctx, record, time.Until(record.RefreshExpAt))
}

func (s *AuthService) replaceUserSessionIDs(ctx context.Context, userID []byte, sessionIDs []string) error {
	unique := make([]string, 0, len(sessionIDs))
	seen := make(map[string]struct{}, len(sessionIDs))
	for i := range sessionIDs {
		sessionID := strings.TrimSpace(sessionIDs[i])
		if sessionID == "" {
			continue
		}
		if _, ok := seen[sessionID]; ok {
			continue
		}
		seen[sessionID] = struct{}{}
		unique = append(unique, sessionID)
	}
	payload, err := json.Marshal(unique)
	if err != nil {
		return err
	}
	return s.tokens.Set(ctx, userSessionsKey(userID), payload, 0)
}

func (s *AuthService) loadUserSessionIDs(ctx context.Context, userID []byte) ([]string, error) {
	raw, err := s.tokens.Get(ctx, userSessionsKey(userID))
	if err != nil || raw == nil {
		return []string{}, err
	}
	var ids []string
	if err := json.Unmarshal(raw, &ids); err != nil {
		return []string{}, nil
	}
	return ids, nil
}

func (s *AuthService) addUserSessionID(ctx context.Context, userID []byte, sessionID string) error {
	if strings.TrimSpace(sessionID) == "" {
		return nil
	}
	ids, err := s.loadUserSessionIDs(ctx, userID)
	if err != nil {
		return err
	}
	for i := range ids {
		if ids[i] == sessionID {
			return nil
		}
	}
	ids = append(ids, sessionID)
	payload, err := json.Marshal(ids)
	if err != nil {
		return err
	}
	return s.tokens.Set(ctx, userSessionsKey(userID), payload, 0)
}

func (s *AuthService) revokeStaleUserSessions(ctx context.Context, userID []byte, sessionIDs []string, keepSessionID string) error {
	expectedUserID := bytesToString(userID)
	seen := make(map[string]struct{}, len(sessionIDs))
	for i := range sessionIDs {
		sessionID := strings.TrimSpace(sessionIDs[i])
		if sessionID == "" || sessionID == keepSessionID {
			continue
		}
		if _, ok := seen[sessionID]; ok {
			continue
		}
		seen[sessionID] = struct{}{}

		record, err := s.loadSessionRecord(ctx, sessionID)
		if err != nil {
			return err
		}
		if record != nil && record.UserID == expectedUserID && strings.TrimSpace(record.CurrentJTI) != "" {
			_ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI))
		}
		_ = s.tokens.Delete(ctx, sessionKey(sessionID))
	}
	return nil
}

func (s *AuthService) enforceUserDeviceSlots(ctx context.Context, userID []byte, existingSessionIDs []string, current *authSessionRecord) error {
	if current == nil {
		return nil
	}
	currentType := strings.TrimSpace(current.DeviceType)
	if currentType == "" {
		currentType = "unknown"
	}
	kept := make([]string, 0, len(existingSessionIDs)+1)
	seen := map[string]struct{}{}
	for i := range existingSessionIDs {
		sessionID := strings.TrimSpace(existingSessionIDs[i])
		if sessionID == "" || sessionID == current.SessionID {
			continue
		}
		if _, ok := seen[sessionID]; ok {
			continue
		}
		seen[sessionID] = struct{}{}
		record, err := s.loadSessionRecord(ctx, sessionID)
		if err != nil {
			return err
		}
		if record == nil || record.UserID != bytesToString(userID) {
			continue
		}
		recordType := strings.TrimSpace(record.DeviceType)
		if recordType == "" {
			recordType = classifyDeviceType(record.UserAgent, "")
		}
		if currentType != "unknown" && recordType == currentType {
			if strings.TrimSpace(record.CurrentJTI) != "" {
				_ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI))
			}
			_ = s.tokens.Delete(ctx, sessionKey(sessionID))
			continue
		}
		kept = append(kept, sessionID)
	}
	kept = append(kept, current.SessionID)
	return s.replaceUserSessionIDs(ctx, userID, kept)
}

func (s *AuthService) removeUserSessionID(ctx context.Context, userID []byte, sessionID string) error {
	ids, err := s.loadUserSessionIDs(ctx, userID)
	if err != nil {
		return err
	}
	out := make([]string, 0, len(ids))
	for i := range ids {
		if ids[i] == sessionID {
			continue
		}
		out = append(out, ids[i])
	}
	payload, err := json.Marshal(out)
	if err != nil {
		return err
	}
	return s.tokens.Set(ctx, userSessionsKey(userID), payload, 0)
}

func sessionKey(sessionID string) string {
	return "auth:session:" + sessionID
}

func microsoftSessionKey(sessionID string) string {
	return "auth:session:microsoft:sid:" + strings.TrimSpace(sessionID)
}

func userSessionsKey(userID []byte) string {
	return "auth:sessions:user:" + bytesToString(userID)
}

func deriveDeviceName(userAgent string) string {
	ua := strings.ToLower(strings.TrimSpace(userAgent))
	if ua == "" {
		return "Unknown device"
	}
	os := "Unknown OS"
	switch {
	case strings.Contains(ua, "iphone"):
		os = "iPhone"
	case strings.Contains(ua, "ipad"):
		os = "iPad"
	case strings.Contains(ua, "android"):
		os = "Android"
	case strings.Contains(ua, "mac os x"), strings.Contains(ua, "macintosh"):
		os = "macOS"
	case strings.Contains(ua, "windows"):
		os = "Windows"
	case strings.Contains(ua, "linux"):
		os = "Linux"
	}
	browser := "Unknown browser"
	switch {
	case strings.Contains(ua, "edg/"):
		browser = "Edge"
	case strings.Contains(ua, "chrome/"):
		browser = "Chrome"
	case strings.Contains(ua, "firefox/"):
		browser = "Firefox"
	case strings.Contains(ua, "safari/"):
		browser = "Safari"
	}
	return browser + " on " + os
}

func classifyDeviceType(userAgent, hint string) string {
	if h := normalizeDeviceTypeHint(hint); h != "" {
		return h
	}
	ua := strings.ToLower(strings.TrimSpace(userAgent))
	if ua == "" {
		return "unknown"
	}
	switch {
	case strings.Contains(ua, "iphone"), strings.Contains(ua, "ipad"), strings.Contains(ua, "android"), strings.Contains(ua, "mobile"):
		return "mobile"
	case strings.Contains(ua, "windows"), strings.Contains(ua, "mac os x"), strings.Contains(ua, "macintosh"), strings.Contains(ua, "linux"), strings.Contains(ua, "x11"):
		return "desktop"
	default:
		return "unknown"
	}
}

func normalizeDeviceTypeHint(v string) string {
	switch strings.ToLower(strings.TrimSpace(v)) {
	case "mobile":
		return "mobile"
	case "desktop":
		return "desktop"
	default:
		return ""
	}
}
